Merge ~pappacena/launchpad:mp-refs-privacy-change into launchpad:master
- Git
- lp:~pappacena/launchpad
- mp-refs-privacy-change
- Merge into master
Proposed by
Thiago F. Pappacena
Status: | Needs review |
---|---|
Proposed branch: | ~pappacena/launchpad:mp-refs-privacy-change |
Merge into: | launchpad:master |
Prerequisite: | ~pappacena/launchpad:create-mp-refs |
Diff against target: |
582 lines (+309/-16) 10 files modified
lib/lp/code/configure.zcml (+9/-0) lib/lp/code/interfaces/gitjob.py (+16/-1) lib/lp/code/model/branchmergeproposal.py (+9/-7) lib/lp/code/model/gitjob.py (+63/-1) lib/lp/code/model/gitrepository.py (+11/-1) lib/lp/code/model/tests/test_branchmergeproposal.py (+4/-4) lib/lp/code/model/tests/test_gitjob.py (+127/-1) lib/lp/code/model/tests/test_gitrepository.py (+62/-0) lib/lp/services/config/schema-lazr.conf (+4/-0) lib/lp/testing/fakemethod.py (+4/-1) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Needs Information | ||
Review via email: mp+390940@code.launchpad.net |
Commit message
Background job to add/remove merge proposal's virtual refs when a repository has its privacy changed
Description of the change
To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote : | # |
Added one comment about the complication on private-to-public, just to make sure I understood the scenario. Meanwhile, I'll work out the other details in the MP.
Revision history for this message
Colin Watson (cjwatson) : | # |
Unmerged commits
- dcdb5f4... by Thiago F. Pappacena
-
Merge branch 'create-mp-refs' into mp-refs-
privacy- change - f3bda00... by Thiago F. Pappacena
-
Fixing test
- 9a2f2f7... by Thiago F. Pappacena
-
Retry strategy for virtual refs sync job
- cca2a0f... by Thiago F. Pappacena
-
Merge branch 'create-mp-refs' into mp-refs-
privacy- change - 2ca86e6... by Thiago F. Pappacena
-
Fixing test
- ebd11bb... by Thiago F. Pappacena
-
Adding job to reconcile mp virtual refs
- 3c2ffb2... by Thiago F. Pappacena
-
Trigger MP reconciliation job when changing repository privacy setting
- 30c88c7... by Thiago F. Pappacena
-
Merge branch 'githosting-
copy-and- delete- refs' into create-mp-refs - 7e11cb0... by Thiago F. Pappacena
-
Refactoring virtual refs creation and preparing privacy code
- 1f7f371... by Thiago F. Pappacena
-
Adding feature flag and triggering copy on MP creation
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml | |||
2 | index 898e645..4adc8d0 100644 | |||
3 | --- a/lib/lp/code/configure.zcml | |||
4 | +++ b/lib/lp/code/configure.zcml | |||
5 | @@ -1111,6 +1111,11 @@ | |||
6 | 1111 | provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource"> | 1111 | provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource"> |
7 | 1112 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" /> | 1112 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" /> |
8 | 1113 | </securedutility> | 1113 | </securedutility> |
9 | 1114 | <securedutility | ||
10 | 1115 | component="lp.code.model.gitjob.GitRepositoryVirtualRefsSyncJob" | ||
11 | 1116 | provides="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJobSource"> | ||
12 | 1117 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJobSource" /> | ||
13 | 1118 | </securedutility> | ||
14 | 1114 | <class class="lp.code.model.gitjob.GitRefScanJob"> | 1119 | <class class="lp.code.model.gitjob.GitRefScanJob"> |
15 | 1115 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> | 1120 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> |
16 | 1116 | <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" /> | 1121 | <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" /> |
17 | @@ -1123,6 +1128,10 @@ | |||
18 | 1123 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> | 1128 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> |
19 | 1124 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" /> | 1129 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" /> |
20 | 1125 | </class> | 1130 | </class> |
21 | 1131 | <class class="lp.code.model.gitjob.GitRepositoryVirtualRefsSyncJob"> | ||
22 | 1132 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> | ||
23 | 1133 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJob" /> | ||
24 | 1134 | </class> | ||
25 | 1126 | 1135 | ||
26 | 1127 | <lp:help-folder folder="help" name="+help-code" /> | 1136 | <lp:help-folder folder="help" name="+help-code" /> |
27 | 1128 | 1137 | ||
28 | diff --git a/lib/lp/code/interfaces/gitjob.py b/lib/lp/code/interfaces/gitjob.py | |||
29 | index 4f31b19..7c3c038 100644 | |||
30 | --- a/lib/lp/code/interfaces/gitjob.py | |||
31 | +++ b/lib/lp/code/interfaces/gitjob.py | |||
32 | @@ -1,4 +1,4 @@ | |||
34 | 1 | # Copyright 2015 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
35 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
36 | 3 | 3 | ||
37 | 4 | """GitJob interfaces.""" | 4 | """GitJob interfaces.""" |
38 | @@ -11,6 +11,8 @@ __all__ = [ | |||
39 | 11 | 'IGitRefScanJobSource', | 11 | 'IGitRefScanJobSource', |
40 | 12 | 'IGitRepositoryModifiedMailJob', | 12 | 'IGitRepositoryModifiedMailJob', |
41 | 13 | 'IGitRepositoryModifiedMailJobSource', | 13 | 'IGitRepositoryModifiedMailJobSource', |
42 | 14 | 'IGitRepositoryVirtualRefsSyncJob', | ||
43 | 15 | 'IGitRepositoryVirtualRefsSyncJobSource', | ||
44 | 14 | 'IReclaimGitRepositorySpaceJob', | 16 | 'IReclaimGitRepositorySpaceJob', |
45 | 15 | 'IReclaimGitRepositorySpaceJobSource', | 17 | 'IReclaimGitRepositorySpaceJobSource', |
46 | 16 | ] | 18 | ] |
47 | @@ -93,3 +95,16 @@ class IGitRepositoryModifiedMailJobSource(IJobSource): | |||
48 | 93 | :param repository_delta: An `IGitRepositoryDelta` describing the | 95 | :param repository_delta: An `IGitRepositoryDelta` describing the |
49 | 94 | changes. | 96 | changes. |
50 | 95 | """ | 97 | """ |
51 | 98 | |||
52 | 99 | |||
53 | 100 | class IGitRepositoryVirtualRefsSyncJob(IRunnableJob): | ||
54 | 101 | """A job to synchronize all MPs virtual refs related to this repository.""" | ||
55 | 102 | |||
56 | 103 | |||
57 | 104 | class IGitRepositoryVirtualRefsSyncJobSource(IJobSource): | ||
58 | 105 | |||
59 | 106 | def create(repository): | ||
60 | 107 | """Send email about repository modifications. | ||
61 | 108 | |||
62 | 109 | :param repository: The `IGitRepository` that needs sync. | ||
63 | 110 | """ | ||
64 | diff --git a/lib/lp/code/model/branchmergeproposal.py b/lib/lp/code/model/branchmergeproposal.py | |||
65 | index 8a29a99..bd804a8 100644 | |||
66 | --- a/lib/lp/code/model/branchmergeproposal.py | |||
67 | +++ b/lib/lp/code/model/branchmergeproposal.py | |||
68 | @@ -1247,7 +1247,7 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): | |||
69 | 1247 | self.target_git_repository.getInternalPath(), | 1247 | self.target_git_repository.getInternalPath(), |
70 | 1248 | GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))] | 1248 | GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))] |
71 | 1249 | 1249 | ||
73 | 1250 | def copyGitHostingVirtualRefs(self): | 1250 | def copyGitHostingVirtualRefs(self, logger=logger): |
74 | 1251 | """Requests virtual refs copy operations on GitHosting in order to | 1251 | """Requests virtual refs copy operations on GitHosting in order to |
75 | 1252 | keep them up-to-date with current MP's state. | 1252 | keep them up-to-date with current MP's state. |
76 | 1253 | 1253 | ||
77 | @@ -1257,10 +1257,10 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): | |||
78 | 1257 | hosting_client = getUtility(IGitHostingClient) | 1257 | hosting_client = getUtility(IGitHostingClient) |
79 | 1258 | hosting_client.copyRefs( | 1258 | hosting_client.copyRefs( |
80 | 1259 | self.source_git_repository.getInternalPath(), | 1259 | self.source_git_repository.getInternalPath(), |
82 | 1260 | copy_operations) | 1260 | copy_operations, logger=logger) |
83 | 1261 | return copy_operations | 1261 | return copy_operations |
84 | 1262 | 1262 | ||
86 | 1263 | def deleteGitHostingVirtualRefs(self, except_refs=None): | 1263 | def deleteGitHostingVirtualRefs(self, except_refs=None, logger=None): |
87 | 1264 | """Deletes on git code hosting service all virtual refs, except | 1264 | """Deletes on git code hosting service all virtual refs, except |
88 | 1265 | those ones in the given list.""" | 1265 | those ones in the given list.""" |
89 | 1266 | if self.source_git_ref is None: | 1266 | if self.source_git_ref is None: |
90 | @@ -1278,14 +1278,16 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): | |||
91 | 1278 | if ref not in (except_refs or []): | 1278 | if ref not in (except_refs or []): |
92 | 1279 | hosting_client = getUtility(IGitHostingClient) | 1279 | hosting_client = getUtility(IGitHostingClient) |
93 | 1280 | hosting_client.deleteRef( | 1280 | hosting_client.deleteRef( |
95 | 1281 | self.target_git_repository.getInternalPath(), ref) | 1281 | self.target_git_repository.getInternalPath(), ref, |
96 | 1282 | logger=logger) | ||
97 | 1282 | 1283 | ||
99 | 1283 | def syncGitHostingVirtualRefs(self): | 1284 | def syncGitHostingVirtualRefs(self, logger=None): |
100 | 1284 | """Requests all copies and deletion of virtual refs to make git code | 1285 | """Requests all copies and deletion of virtual refs to make git code |
101 | 1285 | hosting in sync with this MP.""" | 1286 | hosting in sync with this MP.""" |
103 | 1286 | operations = self.copyGitHostingVirtualRefs() | 1287 | operations = self.copyGitHostingVirtualRefs(logger=logger) |
104 | 1287 | copied_refs = [i.target_ref for i in operations] | 1288 | copied_refs = [i.target_ref for i in operations] |
106 | 1288 | self.deleteGitHostingVirtualRefs(except_refs=copied_refs) | 1289 | self.deleteGitHostingVirtualRefs( |
107 | 1290 | except_refs=copied_refs, logger=logger) | ||
108 | 1289 | 1291 | ||
109 | 1290 | def scheduleDiffUpdates(self, return_jobs=True): | 1292 | def scheduleDiffUpdates(self, return_jobs=True): |
110 | 1291 | """See `IBranchMergeProposal`.""" | 1293 | """See `IBranchMergeProposal`.""" |
111 | diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py | |||
112 | index 3c041da..d845f59 100644 | |||
113 | --- a/lib/lp/code/model/gitjob.py | |||
114 | +++ b/lib/lp/code/model/gitjob.py | |||
115 | @@ -1,4 +1,4 @@ | |||
117 | 1 | # Copyright 2015-2018 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
118 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
119 | 3 | 3 | ||
120 | 4 | __metaclass__ = type | 4 | __metaclass__ = type |
121 | @@ -45,6 +45,8 @@ from lp.code.interfaces.gitjob import ( | |||
122 | 45 | IGitRefScanJobSource, | 45 | IGitRefScanJobSource, |
123 | 46 | IGitRepositoryModifiedMailJob, | 46 | IGitRepositoryModifiedMailJob, |
124 | 47 | IGitRepositoryModifiedMailJobSource, | 47 | IGitRepositoryModifiedMailJobSource, |
125 | 48 | IGitRepositoryVirtualRefsSyncJob, | ||
126 | 49 | IGitRepositoryVirtualRefsSyncJobSource, | ||
127 | 48 | IReclaimGitRepositorySpaceJob, | 50 | IReclaimGitRepositorySpaceJob, |
128 | 49 | IReclaimGitRepositorySpaceJobSource, | 51 | IReclaimGitRepositorySpaceJobSource, |
129 | 50 | ) | 52 | ) |
130 | @@ -100,6 +102,13 @@ class GitJobType(DBEnumeratedType): | |||
131 | 100 | modifications. | 102 | modifications. |
132 | 101 | """) | 103 | """) |
133 | 102 | 104 | ||
134 | 105 | SYNC_MP_VIRTUAL_REFS = DBItem(3, """ | ||
135 | 106 | Sync merge proposals virtual refs | ||
136 | 107 | |||
137 | 108 | This job runs against a repository to synchronize the virtual refs | ||
138 | 109 | from all merge proposals related to this repository. | ||
139 | 110 | """) | ||
140 | 111 | |||
141 | 103 | 112 | ||
142 | 104 | @implementer(IGitJob) | 113 | @implementer(IGitJob) |
143 | 105 | class GitJob(StormBase): | 114 | class GitJob(StormBase): |
144 | @@ -404,3 +413,56 @@ class GitRepositoryModifiedMailJob(GitJobDerived): | |||
145 | 404 | def run(self): | 413 | def run(self): |
146 | 405 | """See `IGitRepositoryModifiedMailJob`.""" | 414 | """See `IGitRepositoryModifiedMailJob`.""" |
147 | 406 | self.getMailer().sendAll() | 415 | self.getMailer().sendAll() |
148 | 416 | |||
149 | 417 | |||
150 | 418 | @implementer(IGitRepositoryVirtualRefsSyncJob) | ||
151 | 419 | @provider(IGitRepositoryVirtualRefsSyncJobSource) | ||
152 | 420 | class GitRepositoryVirtualRefsSyncJob(GitJobDerived): | ||
153 | 421 | """A Job that scans a Git repository for its current list of references.""" | ||
154 | 422 | class_job_type = GitJobType.SYNC_MP_VIRTUAL_REFS | ||
155 | 423 | |||
156 | 424 | class RetryException(Exception): | ||
157 | 425 | pass | ||
158 | 426 | |||
159 | 427 | max_retries = 5 | ||
160 | 428 | |||
161 | 429 | retry_error_types = (RetryException, ) | ||
162 | 430 | |||
163 | 431 | config = config.IGitRepositoryVirtualRefsSyncJobSource | ||
164 | 432 | |||
165 | 433 | @classmethod | ||
166 | 434 | def create(cls, repository): | ||
167 | 435 | metadata = {"synced_mp_ids": []} | ||
168 | 436 | git_job = GitJob(repository, cls.class_job_type, metadata) | ||
169 | 437 | job = cls(git_job) | ||
170 | 438 | job.celeryRunOnCommit() | ||
171 | 439 | return job | ||
172 | 440 | |||
173 | 441 | @property | ||
174 | 442 | def synced_mp_ids(self): | ||
175 | 443 | return self.metadata["synced_mp_ids"] | ||
176 | 444 | |||
177 | 445 | def add_synced_mp_id(self, mp_id): | ||
178 | 446 | self.metadata["synced_mp_ids"].append(mp_id) | ||
179 | 447 | |||
180 | 448 | def run(self): | ||
181 | 449 | log.info( | ||
182 | 450 | "Starting to re-sync virtual refs from repository %s", | ||
183 | 451 | self.repository) | ||
184 | 452 | failed = False | ||
185 | 453 | for mp in self.repository.landing_targets: | ||
186 | 454 | if mp.id in self.synced_mp_ids: | ||
187 | 455 | continue | ||
188 | 456 | try: | ||
189 | 457 | log.info("Re-syncing virtual refs from MP %s", mp) | ||
190 | 458 | mp.syncGitHostingVirtualRefs(logger=log) | ||
191 | 459 | self.synced_mp_ids.append(mp.id) | ||
192 | 460 | except Exception as e: | ||
193 | 461 | log.info( | ||
194 | 462 | "Re-syncing virtual refs from MP %s failed: %s", mp, e) | ||
195 | 463 | failed = True | ||
196 | 464 | log.info( | ||
197 | 465 | "Finished re-syncing virtual refs from repository %s%s", | ||
198 | 466 | self.repository, " with failures" if failed else "") | ||
199 | 467 | if failed: | ||
200 | 468 | raise GitRepositoryVirtualRefsSyncJob.RetryException() | ||
201 | diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py | |||
202 | index e2451fa..6379fce 100644 | |||
203 | --- a/lib/lp/code/model/gitrepository.py | |||
204 | +++ b/lib/lp/code/model/gitrepository.py | |||
205 | @@ -116,7 +116,10 @@ from lp.code.interfaces.gitcollection import ( | |||
206 | 116 | IGitCollection, | 116 | IGitCollection, |
207 | 117 | ) | 117 | ) |
208 | 118 | from lp.code.interfaces.githosting import IGitHostingClient | 118 | from lp.code.interfaces.githosting import IGitHostingClient |
210 | 119 | from lp.code.interfaces.gitjob import IGitRefScanJobSource | 119 | from lp.code.interfaces.gitjob import ( |
211 | 120 | IGitRefScanJobSource, | ||
212 | 121 | IGitRepositoryVirtualRefsSyncJobSource, | ||
213 | 122 | ) | ||
214 | 120 | from lp.code.interfaces.gitlookup import IGitLookup | 123 | from lp.code.interfaces.gitlookup import IGitLookup |
215 | 121 | from lp.code.interfaces.gitnamespace import ( | 124 | from lp.code.interfaces.gitnamespace import ( |
216 | 122 | get_git_namespace, | 125 | get_git_namespace, |
217 | @@ -866,6 +869,7 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): | |||
218 | 866 | raise CannotChangeInformationType("Forbidden by project policy.") | 869 | raise CannotChangeInformationType("Forbidden by project policy.") |
219 | 867 | # XXX cjwatson 2019-03-29: Check privacy rules on snaps that use | 870 | # XXX cjwatson 2019-03-29: Check privacy rules on snaps that use |
220 | 868 | # this repository. | 871 | # this repository. |
221 | 872 | was_private = self.private | ||
222 | 869 | self.information_type = information_type | 873 | self.information_type = information_type |
223 | 870 | self._reconcileAccess() | 874 | self._reconcileAccess() |
224 | 871 | if (information_type in PRIVATE_INFORMATION_TYPES and | 875 | if (information_type in PRIVATE_INFORMATION_TYPES and |
225 | @@ -883,6 +887,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): | |||
226 | 883 | # subscriptions. | 887 | # subscriptions. |
227 | 884 | getUtility(IRemoveArtifactSubscriptionsJobSource).create(user, [self]) | 888 | getUtility(IRemoveArtifactSubscriptionsJobSource).create(user, [self]) |
228 | 885 | 889 | ||
229 | 890 | # If privacy changed, we need to re-sync all virtual refs from | ||
230 | 891 | # all MPs to avoid disclosing private code, or to add the virtual | ||
231 | 892 | # refs to the now public code. | ||
232 | 893 | if was_private != self.private: | ||
233 | 894 | getUtility(IGitRepositoryVirtualRefsSyncJobSource).create(self) | ||
234 | 895 | |||
235 | 886 | def setName(self, new_name, user): | 896 | def setName(self, new_name, user): |
236 | 887 | """See `IGitRepository`.""" | 897 | """See `IGitRepository`.""" |
237 | 888 | self.namespace.moveRepository(self, user, new_name=new_name) | 898 | self.namespace.moveRepository(self, user, new_name=new_name) |
238 | diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py | |||
239 | index 54cf516..277650c 100644 | |||
240 | --- a/lib/lp/code/model/tests/test_branchmergeproposal.py | |||
241 | +++ b/lib/lp/code/model/tests/test_branchmergeproposal.py | |||
242 | @@ -285,7 +285,7 @@ class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory): | |||
243 | 285 | self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) | 285 | self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
244 | 286 | args, kwargs = self.hosting_fixture.copyRefs.calls[0] | 286 | args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
245 | 287 | arg_path, arg_copy_operations = args | 287 | arg_path, arg_copy_operations = args |
247 | 288 | self.assertEqual({}, kwargs) | 288 | self.assertEqual({'logger': None}, kwargs) |
248 | 289 | self.assertEqual(mp.source_git_repository.getInternalPath(), arg_path) | 289 | self.assertEqual(mp.source_git_repository.getInternalPath(), arg_path) |
249 | 290 | self.assertEqual(1, len(arg_copy_operations)) | 290 | self.assertEqual(1, len(arg_copy_operations)) |
250 | 291 | self.assertThat(arg_copy_operations[0], MatchesStructure( | 291 | self.assertThat(arg_copy_operations[0], MatchesStructure( |
251 | @@ -357,7 +357,7 @@ class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory): | |||
252 | 357 | # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created. | 357 | # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created. |
253 | 358 | self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) | 358 | self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
254 | 359 | args, kwargs = self.hosting_fixture.copyRefs.calls[0] | 359 | args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
256 | 360 | self.assertEquals({}, kwargs) | 360 | self.assertEquals({'logger': None}, kwargs) |
257 | 361 | self.assertEqual(args[0], source_repo.getInternalPath()) | 361 | self.assertEqual(args[0], source_repo.getInternalPath()) |
258 | 362 | self.assertEqual(1, len(args[1])) | 362 | self.assertEqual(1, len(args[1])) |
259 | 363 | self.assertThat(args[1][0], MatchesStructure( | 363 | self.assertThat(args[1][0], MatchesStructure( |
260 | @@ -384,7 +384,7 @@ class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory): | |||
261 | 384 | self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) | 384 | self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) |
262 | 385 | self.assertEqual(1, self.hosting_fixture.deleteRef.call_count) | 385 | self.assertEqual(1, self.hosting_fixture.deleteRef.call_count) |
263 | 386 | args, kwargs = self.hosting_fixture.deleteRef.calls[0] | 386 | args, kwargs = self.hosting_fixture.deleteRef.calls[0] |
265 | 387 | self.assertEqual({}, kwargs) | 387 | self.assertEqual({'logger': None}, kwargs) |
266 | 388 | self.assertEqual( | 388 | self.assertEqual( |
267 | 389 | (target_repo.getInternalPath(), "refs/merge/%s/head" % mp.id), | 389 | (target_repo.getInternalPath(), "refs/merge/%s/head" % mp.id), |
268 | 390 | args) | 390 | args) |
269 | @@ -1658,7 +1658,7 @@ class TestBranchMergeProposalDeletion(TestCaseWithFactory): | |||
270 | 1658 | args = hosting_fixture.deleteRef.calls[0] | 1658 | args = hosting_fixture.deleteRef.calls[0] |
271 | 1659 | self.assertEqual(( | 1659 | self.assertEqual(( |
272 | 1660 | (proposal.target_git_repository.getInternalPath(), | 1660 | (proposal.target_git_repository.getInternalPath(), |
274 | 1661 | 'refs/merge/%s/head' % proposal.id), {}), args) | 1661 | 'refs/merge/%s/head' % proposal.id), {'logger': None}), args) |
275 | 1662 | 1662 | ||
276 | 1663 | 1663 | ||
277 | 1664 | class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory): | 1664 | class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory): |
278 | diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py | |||
279 | index 5964944..c92be62 100644 | |||
280 | --- a/lib/lp/code/model/tests/test_gitjob.py | |||
281 | +++ b/lib/lp/code/model/tests/test_gitjob.py | |||
282 | @@ -1,4 +1,4 @@ | |||
284 | 1 | # Copyright 2015-2019 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
285 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
286 | 3 | 3 | ||
287 | 4 | """Tests for `GitJob`s.""" | 4 | """Tests for `GitJob`s.""" |
288 | @@ -20,13 +20,16 @@ from testtools.matchers import ( | |||
289 | 20 | ContainsDict, | 20 | ContainsDict, |
290 | 21 | Equals, | 21 | Equals, |
291 | 22 | MatchesDict, | 22 | MatchesDict, |
292 | 23 | MatchesListwise, | ||
293 | 23 | MatchesSetwise, | 24 | MatchesSetwise, |
294 | 24 | MatchesStructure, | 25 | MatchesStructure, |
295 | 25 | ) | 26 | ) |
296 | 26 | import transaction | 27 | import transaction |
297 | 28 | from zope.component import getUtility | ||
298 | 27 | from zope.interface import providedBy | 29 | from zope.interface import providedBy |
299 | 28 | from zope.security.proxy import removeSecurityProxy | 30 | from zope.security.proxy import removeSecurityProxy |
300 | 29 | 31 | ||
301 | 32 | from lp.app.enums import InformationType | ||
302 | 30 | from lp.code.adapters.gitrepository import GitRepositoryDelta | 33 | from lp.code.adapters.gitrepository import GitRepositoryDelta |
303 | 31 | from lp.code.enums import ( | 34 | from lp.code.enums import ( |
304 | 32 | GitGranteeType, | 35 | GitGranteeType, |
305 | @@ -38,8 +41,10 @@ from lp.code.interfaces.branchmergeproposal import ( | |||
306 | 38 | from lp.code.interfaces.gitjob import ( | 41 | from lp.code.interfaces.gitjob import ( |
307 | 39 | IGitJob, | 42 | IGitJob, |
308 | 40 | IGitRefScanJob, | 43 | IGitRefScanJob, |
309 | 44 | IGitRepositoryVirtualRefsSyncJobSource, | ||
310 | 41 | IReclaimGitRepositorySpaceJob, | 45 | IReclaimGitRepositorySpaceJob, |
311 | 42 | ) | 46 | ) |
312 | 47 | from lp.code.interfaces.gitrepository import GIT_CREATE_MP_VIRTUAL_REF | ||
313 | 43 | from lp.code.model.gitjob import ( | 48 | from lp.code.model.gitjob import ( |
314 | 44 | describe_repository_delta, | 49 | describe_repository_delta, |
315 | 45 | GitJob, | 50 | GitJob, |
316 | @@ -49,9 +54,11 @@ from lp.code.model.gitjob import ( | |||
317 | 49 | ReclaimGitRepositorySpaceJob, | 54 | ReclaimGitRepositorySpaceJob, |
318 | 50 | ) | 55 | ) |
319 | 51 | from lp.code.tests.helpers import GitHostingFixture | 56 | from lp.code.tests.helpers import GitHostingFixture |
320 | 57 | from lp.services.compat import mock | ||
321 | 52 | from lp.services.config import config | 58 | from lp.services.config import config |
322 | 53 | from lp.services.database.constants import UTC_NOW | 59 | from lp.services.database.constants import UTC_NOW |
323 | 54 | from lp.services.features.testing import FeatureFixture | 60 | from lp.services.features.testing import FeatureFixture |
324 | 61 | from lp.services.job.interfaces.job import JobStatus | ||
325 | 55 | from lp.services.job.runner import JobRunner | 62 | from lp.services.job.runner import JobRunner |
326 | 56 | from lp.services.utils import seconds_since_epoch | 63 | from lp.services.utils import seconds_since_epoch |
327 | 57 | from lp.services.webapp import canonical_url | 64 | from lp.services.webapp import canonical_url |
328 | @@ -484,5 +491,124 @@ class TestDescribeRepositoryDelta(TestCaseWithFactory): | |||
329 | 484 | snapshot, repository) | 491 | snapshot, repository) |
330 | 485 | 492 | ||
331 | 486 | 493 | ||
332 | 494 | class TestGitRepositoryVirtualRefsSyncJob(TestCaseWithFactory): | ||
333 | 495 | """Tests for `GitRepositoryVirtualRefsSyncJob`.""" | ||
334 | 496 | |||
335 | 497 | layer = ZopelessDatabaseLayer | ||
336 | 498 | |||
337 | 499 | def runJobs(self): | ||
338 | 500 | with dbuser("branchscanner"): | ||
339 | 501 | job_set = JobRunner.fromReady( | ||
340 | 502 | getUtility(IGitRepositoryVirtualRefsSyncJobSource)) | ||
341 | 503 | job_set.runAll() | ||
342 | 504 | return job_set | ||
343 | 505 | |||
344 | 506 | def test_changing_repo_to_private_deletes_refs(self): | ||
345 | 507 | self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) | ||
346 | 508 | hosting_fixture = self.useFixture(GitHostingFixture()) | ||
347 | 509 | mp = self.factory.makeBranchMergeProposalForGit() | ||
348 | 510 | source_repo = mp.source_git_repository | ||
349 | 511 | target_repo = mp.target_git_repository | ||
350 | 512 | source_repo.transitionToInformationType( | ||
351 | 513 | InformationType.PRIVATESECURITY, source_repo.owner, False) | ||
352 | 514 | |||
353 | 515 | hosting_fixture.copyRefs.resetCalls() | ||
354 | 516 | hosting_fixture.deleteRef.resetCalls() | ||
355 | 517 | jobs = self.runJobs() | ||
356 | 518 | self.assertEqual(1, len(jobs.completed_jobs)) | ||
357 | 519 | |||
358 | 520 | self.assertEqual(0, hosting_fixture.copyRefs.call_count) | ||
359 | 521 | self.assertEqual(1, hosting_fixture.deleteRef.call_count) | ||
360 | 522 | args, kwargs = hosting_fixture.deleteRef.calls[0] | ||
361 | 523 | self.assertEqual( | ||
362 | 524 | (target_repo.getInternalPath(), 'refs/merge/%s/head' % mp.id), | ||
363 | 525 | args) | ||
364 | 526 | |||
365 | 527 | def test_changing_repo_to_public_recreates_refs(self): | ||
366 | 528 | self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) | ||
367 | 529 | hosting_fixture = self.useFixture(GitHostingFixture()) | ||
368 | 530 | mp = self.factory.makeBranchMergeProposalForGit() | ||
369 | 531 | source_repo = mp.source_git_repository | ||
370 | 532 | source_repo.transitionToInformationType( | ||
371 | 533 | InformationType.PRIVATESECURITY, source_repo.owner, False) | ||
372 | 534 | self.runJobs() | ||
373 | 535 | |||
374 | 536 | # Move it back to public. | ||
375 | 537 | hosting_fixture.copyRefs.resetCalls() | ||
376 | 538 | hosting_fixture.deleteRef.resetCalls() | ||
377 | 539 | source_repo.transitionToInformationType( | ||
378 | 540 | InformationType.PUBLIC, source_repo.owner, False) | ||
379 | 541 | jobs = self.runJobs() | ||
380 | 542 | self.assertEqual(1, len(jobs.completed_jobs)) | ||
381 | 543 | |||
382 | 544 | self.assertEqual(1, hosting_fixture.copyRefs.call_count) | ||
383 | 545 | self.assertEqual(0, hosting_fixture.deleteRef.call_count) | ||
384 | 546 | args, kwargs = hosting_fixture.copyRefs.calls[0] | ||
385 | 547 | self.assertEqual({'logger': mock.ANY}, kwargs) | ||
386 | 548 | self.assertEqual(2, len(args)) | ||
387 | 549 | repo, operations = args | ||
388 | 550 | self.assertEqual(repo, mp.source_git_repository.getInternalPath()) | ||
389 | 551 | self.assertThat(operations, MatchesListwise([ | ||
390 | 552 | MatchesStructure( | ||
391 | 553 | source_ref=Equals(mp.source_git_commit_sha1), | ||
392 | 554 | target_repo=Equals(mp.target_git_repository.getInternalPath()), | ||
393 | 555 | target_ref=Equals("refs/merge/%s/head" % mp.id), | ||
394 | 556 | ) | ||
395 | 557 | ])) | ||
396 | 558 | |||
397 | 559 | @mock.patch("lp.code.model.branchmergeproposal.BranchMergeProposal." | ||
398 | 560 | "syncGitHostingVirtualRefs") | ||
399 | 561 | def test_changing_repo_retry_skips_successful_syncs( | ||
400 | 562 | self, syncGitHostingVirtualRefs): | ||
401 | 563 | # Makes sure that successful syncs are not retried on failures. | ||
402 | 564 | self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) | ||
403 | 565 | hosting_fixture = self.useFixture(GitHostingFixture()) | ||
404 | 566 | mp1 = self.factory.makeBranchMergeProposalForGit() | ||
405 | 567 | source_repo = mp1.source_git_repository | ||
406 | 568 | |||
407 | 569 | mp2 = self.factory.makeBranchMergeProposalForGit( | ||
408 | 570 | source_ref=mp1.source_git_ref) | ||
409 | 571 | mp3 = self.factory.makeBranchMergeProposalForGit( | ||
410 | 572 | source_ref=mp1.source_git_ref) | ||
411 | 573 | |||
412 | 574 | # The 1st call to syncGitHostingVirtualRefs (mp1) should succeed. | ||
413 | 575 | # The 2nd call (mp2, first try) should fail | ||
414 | 576 | # The 3rd call (mp3) should succeed. | ||
415 | 577 | # Then, 4th call (mp2 again) should succeed. | ||
416 | 578 | syncGitHostingVirtualRefs.reset_mock() | ||
417 | 579 | syncGitHostingVirtualRefs.side_effect = [None, Exception(), None, None] | ||
418 | 580 | |||
419 | 581 | # Make source repo private, to trigger the job. | ||
420 | 582 | hosting_fixture.copyRefs.resetCalls() | ||
421 | 583 | hosting_fixture.deleteRef.resetCalls() | ||
422 | 584 | source_repo.transitionToInformationType( | ||
423 | 585 | InformationType.PRIVATESECURITY, source_repo.owner, False) | ||
424 | 586 | jobs = self.runJobs() | ||
425 | 587 | # Should have no completed jobs, since the job should be retried. | ||
426 | 588 | self.assertEqual( | ||
427 | 589 | 0, len(jobs.completed_jobs), | ||
428 | 590 | "No job should have been finished.") | ||
429 | 591 | self.assertEqual( | ||
430 | 592 | 1, len(jobs.incomplete_jobs), "Job retry should be pending.") | ||
431 | 593 | self.assertEqual( | ||
432 | 594 | 3, syncGitHostingVirtualRefs.call_count, | ||
433 | 595 | "Even with a failure on mp2, syncGitHostingVirtualRefs should " | ||
434 | 596 | "have been called for every branch merge proposal.") | ||
435 | 597 | job = removeSecurityProxy(jobs.incomplete_jobs[0]) | ||
436 | 598 | self.assertEqual([mp1.id, mp3.id], job.synced_mp_ids) | ||
437 | 599 | |||
438 | 600 | # Run the job again. | ||
439 | 601 | syncGitHostingVirtualRefs.reset_mock() | ||
440 | 602 | JobRunner(jobs.incomplete_jobs).runAll() | ||
441 | 603 | self.assertEqual( | ||
442 | 604 | JobStatus.COMPLETED, jobs.incomplete_jobs[0].status, | ||
443 | 605 | "The job should have completed this time, since mp2 sync should " | ||
444 | 606 | "not have raised exception.") | ||
445 | 607 | self.assertEqual( | ||
446 | 608 | 1, syncGitHostingVirtualRefs.call_count, | ||
447 | 609 | "mp2.syncGitHostingVirtualRefs should be the only call to this " | ||
448 | 610 | "method, since the sync should have been skipped for mp1 and mp3.") | ||
449 | 611 | self.assertEqual([mp1.id, mp3.id, mp2.id], job.synced_mp_ids) | ||
450 | 612 | |||
451 | 487 | # XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too, | 613 | # XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too, |
452 | 488 | # but that isn't feasible until we have a proper turnip fixture. | 614 | # but that isn't feasible until we have a proper turnip fixture. |
453 | diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py | |||
454 | index b0116e1..c561b3c 100644 | |||
455 | --- a/lib/lp/code/model/tests/test_gitrepository.py | |||
456 | +++ b/lib/lp/code/model/tests/test_gitrepository.py | |||
457 | @@ -18,6 +18,7 @@ from datetime import ( | |||
458 | 18 | import email | 18 | import email |
459 | 19 | from functools import partial | 19 | from functools import partial |
460 | 20 | import hashlib | 20 | import hashlib |
461 | 21 | import itertools | ||
462 | 21 | import json | 22 | import json |
463 | 22 | 23 | ||
464 | 23 | from breezy import urlutils | 24 | from breezy import urlutils |
465 | @@ -89,6 +90,7 @@ from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository | |||
466 | 89 | from lp.code.interfaces.gitjob import ( | 90 | from lp.code.interfaces.gitjob import ( |
467 | 90 | IGitRefScanJobSource, | 91 | IGitRefScanJobSource, |
468 | 91 | IGitRepositoryModifiedMailJobSource, | 92 | IGitRepositoryModifiedMailJobSource, |
469 | 93 | IGitRepositoryVirtualRefsSyncJobSource, | ||
470 | 92 | ) | 94 | ) |
471 | 93 | from lp.code.interfaces.gitlookup import IGitLookup | 95 | from lp.code.interfaces.gitlookup import IGitLookup |
472 | 94 | from lp.code.interfaces.gitnamespace import ( | 96 | from lp.code.interfaces.gitnamespace import ( |
473 | @@ -147,6 +149,7 @@ from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory | |||
474 | 147 | from lp.registry.interfaces.personproduct import IPersonProductFactory | 149 | from lp.registry.interfaces.personproduct import IPersonProductFactory |
475 | 148 | from lp.registry.tests.test_accesspolicy import get_policies_for_artifact | 150 | from lp.registry.tests.test_accesspolicy import get_policies_for_artifact |
476 | 149 | from lp.services.authserver.xmlrpc import AuthServerAPIView | 151 | from lp.services.authserver.xmlrpc import AuthServerAPIView |
477 | 152 | from lp.services.compat import mock | ||
478 | 150 | from lp.services.config import config | 153 | from lp.services.config import config |
479 | 151 | from lp.services.database.constants import UTC_NOW | 154 | from lp.services.database.constants import UTC_NOW |
480 | 152 | from lp.services.database.interfaces import IStore | 155 | from lp.services.database.interfaces import IStore |
481 | @@ -181,6 +184,7 @@ from lp.testing import ( | |||
482 | 181 | verifyObject, | 184 | verifyObject, |
483 | 182 | ) | 185 | ) |
484 | 183 | from lp.testing.dbuser import dbuser | 186 | from lp.testing.dbuser import dbuser |
485 | 187 | from lp.testing.fixture import ZopeUtilityFixture | ||
486 | 184 | from lp.testing.layers import ( | 188 | from lp.testing.layers import ( |
487 | 185 | DatabaseFunctionalLayer, | 189 | DatabaseFunctionalLayer, |
488 | 186 | LaunchpadFunctionalLayer, | 190 | LaunchpadFunctionalLayer, |
489 | @@ -4662,3 +4666,61 @@ class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory): | |||
490 | 4662 | ["Caveat check for '%s' failed." % | 4666 | ["Caveat check for '%s' failed." % |
491 | 4663 | find_caveats_by_name(macaroon2, "lp.expires")[0].caveat_id], | 4667 | find_caveats_by_name(macaroon2, "lp.expires")[0].caveat_id], |
492 | 4664 | issuer, macaroon2, repository, user=repository.owner) | 4668 | issuer, macaroon2, repository, user=repository.owner) |
493 | 4669 | |||
494 | 4670 | |||
495 | 4671 | class TestGitRepositoryPrivacyChangeSyncVirtualRefs(TestCaseWithFactory): | ||
496 | 4672 | layer = DatabaseFunctionalLayer | ||
497 | 4673 | |||
498 | 4674 | def assertChangePrivacyTriggersSync( | ||
499 | 4675 | self, from_list, to_list, should_trigger_sync=True): | ||
500 | 4676 | """Runs repository.transitionToInformationType from every item in | ||
501 | 4677 | `from_list` to each item in `to_list`, and checks if the virtual | ||
502 | 4678 | refs sync was triggered or not, depending on `should_trigger_sync`.""" | ||
503 | 4679 | sync_job = mock.Mock() | ||
504 | 4680 | self.useFixture(ZopeUtilityFixture( | ||
505 | 4681 | sync_job, IGitRepositoryVirtualRefsSyncJobSource)) | ||
506 | 4682 | |||
507 | 4683 | admin = self.factory.makeAdministrator() | ||
508 | 4684 | login_person(admin) | ||
509 | 4685 | for from_type, to_type in itertools.product(from_list, to_list): | ||
510 | 4686 | if from_type == to_type: | ||
511 | 4687 | continue | ||
512 | 4688 | repository = self.factory.makeGitRepository() | ||
513 | 4689 | naked_repo = removeSecurityProxy(repository) | ||
514 | 4690 | naked_repo.information_type = from_type | ||
515 | 4691 | # Skip access policy reconciliation. | ||
516 | 4692 | naked_repo._reconcileAccess = mock.Mock() | ||
517 | 4693 | naked_repo.transitionToInformationType(to_type, admin, False) | ||
518 | 4694 | |||
519 | 4695 | if should_trigger_sync: | ||
520 | 4696 | sync_job.create.assert_called_with(repository) | ||
521 | 4697 | else: | ||
522 | 4698 | self.assertEqual( | ||
523 | 4699 | 0, sync_job.create.call_count, | ||
524 | 4700 | "Changing from %s to %s should't trigger vrefs sync" | ||
525 | 4701 | % (from_type, to_type)) | ||
526 | 4702 | sync_job.reset_mock() | ||
527 | 4703 | |||
528 | 4704 | def test_setting_repo_public_triggers_ref_sync_job(self): | ||
529 | 4705 | self.assertChangePrivacyTriggersSync( | ||
530 | 4706 | PRIVATE_INFORMATION_TYPES, | ||
531 | 4707 | PUBLIC_INFORMATION_TYPES, | ||
532 | 4708 | should_trigger_sync=True) | ||
533 | 4709 | |||
534 | 4710 | def test_setting_repo_private_triggers_ref_sync_job(self): | ||
535 | 4711 | self.assertChangePrivacyTriggersSync( | ||
536 | 4712 | PUBLIC_INFORMATION_TYPES, | ||
537 | 4713 | PRIVATE_INFORMATION_TYPES, | ||
538 | 4714 | should_trigger_sync=True) | ||
539 | 4715 | |||
540 | 4716 | def test_keeping_repo_private_dont_trigger_ref_sync_job(self): | ||
541 | 4717 | self.assertChangePrivacyTriggersSync( | ||
542 | 4718 | PRIVATE_INFORMATION_TYPES, | ||
543 | 4719 | PRIVATE_INFORMATION_TYPES, | ||
544 | 4720 | should_trigger_sync=False) | ||
545 | 4721 | |||
546 | 4722 | def test_keeping_repo_public_dont_trigger_ref_sync_job(self): | ||
547 | 4723 | self.assertChangePrivacyTriggersSync( | ||
548 | 4724 | PUBLIC_INFORMATION_TYPES, | ||
549 | 4725 | PUBLIC_INFORMATION_TYPES, | ||
550 | 4726 | should_trigger_sync=False) | ||
551 | diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf | |||
552 | index bb8afcd..3921da2 100644 | |||
553 | --- a/lib/lp/services/config/schema-lazr.conf | |||
554 | +++ b/lib/lp/services/config/schema-lazr.conf | |||
555 | @@ -1828,6 +1828,10 @@ module: lp.code.interfaces.gitjob | |||
556 | 1828 | dbuser: send-branch-mail | 1828 | dbuser: send-branch-mail |
557 | 1829 | crontab_group: MAIN | 1829 | crontab_group: MAIN |
558 | 1830 | 1830 | ||
559 | 1831 | [IGitRepositoryVirtualRefsSyncJobSource] | ||
560 | 1832 | module: lp.code.interfaces.gitjob | ||
561 | 1833 | dbuser: branchscanner | ||
562 | 1834 | |||
563 | 1831 | [IInitializeDistroSeriesJobSource] | 1835 | [IInitializeDistroSeriesJobSource] |
564 | 1832 | module: lp.soyuz.interfaces.distributionjob | 1836 | module: lp.soyuz.interfaces.distributionjob |
565 | 1833 | dbuser: initializedistroseries | 1837 | dbuser: initializedistroseries |
566 | diff --git a/lib/lp/testing/fakemethod.py b/lib/lp/testing/fakemethod.py | |||
567 | index 4bba8d3..b4895ce 100644 | |||
568 | --- a/lib/lp/testing/fakemethod.py | |||
569 | +++ b/lib/lp/testing/fakemethod.py | |||
570 | @@ -1,4 +1,4 @@ | |||
572 | 1 | # Copyright 2009-2011 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2009-2020 Canonical Ltd. This software is licensed under the |
573 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
574 | 3 | 3 | ||
575 | 4 | __metaclass__ = type | 4 | __metaclass__ = type |
576 | @@ -57,3 +57,6 @@ class FakeMethod: | |||
577 | 57 | def extract_kwargs(self): | 57 | def extract_kwargs(self): |
578 | 58 | """Return just the calls' keyword-arguments dicts.""" | 58 | """Return just the calls' keyword-arguments dicts.""" |
579 | 59 | return [kwargs for args, kwargs in self.calls] | 59 | return [kwargs for args, kwargs in self.calls] |
580 | 60 | |||
581 | 61 | def resetCalls(self): | ||
582 | 62 | self.calls = [] |
There's an unfortunate complication around private-to-public transitions that I fear is going to require quite a bit more work to get right (assuming I haven't got the wrong end of the stick somewhere). Details below.