Merge lp:~cjwatson/launchpad/archive-file-model into lp:launchpad
- archive-file-model
- Merge into devel
Proposed by
Colin Watson
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 17959 | ||||
Proposed branch: | lp:~cjwatson/launchpad/archive-file-model | ||||
Merge into: | lp:launchpad | ||||
Diff against target: |
484 lines (+441/-0) 5 files modified
lib/lp/soyuz/configure.zcml (+17/-0) lib/lp/soyuz/interfaces/archivefile.py (+112/-0) lib/lp/soyuz/model/archivefile.py (+143/-0) lib/lp/soyuz/tests/test_archivefile.py (+154/-0) lib/lp/testing/factory.py (+15/-0) |
||||
To merge this branch: | bzr merge lp:~cjwatson/launchpad/archive-file-model | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+289376@code.launchpad.net |
Commit message
Add model for ArchiveFile.
Description of the change
Add model for ArchiveFile. This will be used to implement by-hash, and the current set of methods is tailored to that. It should be possible to use it for diskless archives as well later on.
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/soyuz/configure.zcml' |
2 | --- lib/lp/soyuz/configure.zcml 2016-03-04 14:18:23 +0000 |
3 | +++ lib/lp/soyuz/configure.zcml 2016-03-18 15:10:41 +0000 |
4 | @@ -422,6 +422,23 @@ |
5 | interface="lp.soyuz.interfaces.archive.IArchiveSet"/> |
6 | </securedutility> |
7 | |
8 | + <!-- ArchiveFile --> |
9 | + |
10 | + <class class="lp.soyuz.model.archivefile.ArchiveFile"> |
11 | + <allow interface="lp.soyuz.interfaces.archivefile.IArchiveFile"/> |
12 | + </class> |
13 | + |
14 | + <!-- ArchiveFileSet --> |
15 | + |
16 | + <class class="lp.soyuz.model.archivefile.ArchiveFileSet"> |
17 | + <allow interface="lp.soyuz.interfaces.archivefile.IArchiveFileSet"/> |
18 | + </class> |
19 | + <securedutility |
20 | + class="lp.soyuz.model.archivefile.ArchiveFileSet" |
21 | + provides="lp.soyuz.interfaces.archivefile.IArchiveFileSet"> |
22 | + <allow interface="lp.soyuz.interfaces.archivefile.IArchiveFileSet"/> |
23 | + </securedutility> |
24 | + |
25 | <!-- ArchiveJob --> |
26 | |
27 | <class class="lp.soyuz.model.archivejob.ArchiveJob"> |
28 | |
29 | === added file 'lib/lp/soyuz/interfaces/archivefile.py' |
30 | --- lib/lp/soyuz/interfaces/archivefile.py 1970-01-01 00:00:00 +0000 |
31 | +++ lib/lp/soyuz/interfaces/archivefile.py 2016-03-18 15:10:41 +0000 |
32 | @@ -0,0 +1,112 @@ |
33 | +# Copyright 2016 Canonical Ltd. This software is licensed under the |
34 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
35 | + |
36 | +"""Interface for a file in an archive.""" |
37 | + |
38 | +from __future__ import absolute_import, print_function, unicode_literals |
39 | + |
40 | +__metaclass__ = type |
41 | +__all__ = [ |
42 | + 'IArchiveFile', |
43 | + 'IArchiveFileSet', |
44 | + ] |
45 | + |
46 | +from lazr.restful.fields import Reference |
47 | +from zope.interface import Interface |
48 | +from zope.schema import ( |
49 | + Datetime, |
50 | + Int, |
51 | + TextLine, |
52 | + ) |
53 | + |
54 | +from lp import _ |
55 | +from lp.services.librarian.interfaces import ILibraryFileAlias |
56 | +from lp.soyuz.interfaces.archive import IArchive |
57 | + |
58 | + |
59 | +class IArchiveFile(Interface): |
60 | + """A file in an archive. |
61 | + |
62 | + This covers files that are not published in the archive's package pool, |
63 | + such as the Packages and Sources index files. |
64 | + """ |
65 | + |
66 | + id = Int(title=_("ID"), required=True, readonly=True) |
67 | + |
68 | + archive = Reference( |
69 | + title=_("The archive containing the index file."), |
70 | + schema=IArchive, required=True, readonly=True) |
71 | + |
72 | + container = TextLine( |
73 | + title=_("An identifier for the component that manages this file."), |
74 | + required=True, readonly=True) |
75 | + |
76 | + path = TextLine( |
77 | + title=_("The path to the index file within the published archive."), |
78 | + required=True, readonly=True) |
79 | + |
80 | + library_file = Reference( |
81 | + title=_("The index file in the librarian."), |
82 | + schema=ILibraryFileAlias, required=True, readonly=True) |
83 | + |
84 | + scheduled_deletion_date = Datetime( |
85 | + title=_("The date when this file should stop being published."), |
86 | + required=False, readonly=False) |
87 | + |
88 | + |
89 | +class IArchiveFileSet(Interface): |
90 | + """Bulk operations on files in an archive.""" |
91 | + |
92 | + def new(archive, container, path, library_file): |
93 | + """Create a new `IArchiveFile`. |
94 | + |
95 | + :param archive: The `IArchive` containing the new file. |
96 | + :param container: An identifier for the component that manages this |
97 | + file. |
98 | + :param path: The path to the new file within its archive. |
99 | + :param library_file: The `ILibraryFileAlias` embodying the new file. |
100 | + """ |
101 | + |
102 | + def newFromFile(archive, container, path, fileobj, size, content_type): |
103 | + """Create a new `IArchiveFile` from a file on the file system. |
104 | + |
105 | + :param archive: The `IArchive` containing the new file. |
106 | + :param container: An identifier for the component that manages this |
107 | + file. |
108 | + :param path: The path to the new file within its archive. |
109 | + :param fileobj: A file-like object to read the data from. |
110 | + :param size: The size of the file in bytes. |
111 | + :param content_type: The MIME type of the file. |
112 | + """ |
113 | + |
114 | + def getByArchive(archive, container=None, eager_load=False): |
115 | + """Get files in an archive. |
116 | + |
117 | + :param archive: Return files in this `IArchive`. |
118 | + :param container: Return only files with this container. |
119 | + :param eager_load: If True, preload related `LibraryFileAlias` and |
120 | + `LibraryFileContent` rows. |
121 | + """ |
122 | + |
123 | + def scheduleDeletion(archive_files, stay_of_execution): |
124 | + """Schedule these archive files for future deletion. |
125 | + |
126 | + :param archive_files: The `IArchiveFile`s to schedule for deletion. |
127 | + :param stay_of_execution: A `timedelta`; schedule files for deletion |
128 | + this amount of time in the future. |
129 | + """ |
130 | + |
131 | + def getContainersToReap(archive, container_prefix=None): |
132 | + """Return containers in this archive with files that should be reaped. |
133 | + |
134 | + :param archive: Return containers in this `IArchive`. |
135 | + :param container_prefix: Return only containers that start with this |
136 | + prefix. |
137 | + """ |
138 | + |
139 | + def reap(archive, container=None): |
140 | + """Delete archive files that are past their scheduled deletion date. |
141 | + |
142 | + :param archive: Delete files from this `IArchive`. |
143 | + :param container: Delete only files with this container. |
144 | + """ |
145 | |
146 | === added file 'lib/lp/soyuz/model/archivefile.py' |
147 | --- lib/lp/soyuz/model/archivefile.py 1970-01-01 00:00:00 +0000 |
148 | +++ lib/lp/soyuz/model/archivefile.py 2016-03-18 15:10:41 +0000 |
149 | @@ -0,0 +1,143 @@ |
150 | +# Copyright 2016 Canonical Ltd. This software is licensed under the |
151 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
152 | + |
153 | +"""A file in an archive.""" |
154 | + |
155 | +from __future__ import absolute_import, print_function, unicode_literals |
156 | + |
157 | +__metaclass__ = type |
158 | +__all__ = [ |
159 | + 'ArchiveFile', |
160 | + 'ArchiveFileSet', |
161 | + ] |
162 | + |
163 | +import os.path |
164 | + |
165 | +import pytz |
166 | +from storm.locals import ( |
167 | + DateTime, |
168 | + Int, |
169 | + Reference, |
170 | + Storm, |
171 | + Unicode, |
172 | + ) |
173 | +from zope.component import getUtility |
174 | +from zope.interface import implementer |
175 | + |
176 | +from lp.services.database.bulk import load_related |
177 | +from lp.services.database.constants import UTC_NOW |
178 | +from lp.services.database.decoratedresultset import DecoratedResultSet |
179 | +from lp.services.database.interfaces import ( |
180 | + IMasterStore, |
181 | + IStore, |
182 | + ) |
183 | +from lp.services.librarian.interfaces import ILibraryFileAliasSet |
184 | +from lp.services.librarian.model import ( |
185 | + LibraryFileAlias, |
186 | + LibraryFileContent, |
187 | + ) |
188 | +from lp.soyuz.interfaces.archivefile import ( |
189 | + IArchiveFile, |
190 | + IArchiveFileSet, |
191 | + ) |
192 | + |
193 | + |
194 | +@implementer(IArchiveFile) |
195 | +class ArchiveFile(Storm): |
196 | + """See `IArchiveFile`.""" |
197 | + |
198 | + __storm_table__ = 'ArchiveFile' |
199 | + |
200 | + id = Int(primary=True) |
201 | + |
202 | + archive_id = Int(name='archive', allow_none=False) |
203 | + archive = Reference(archive_id, 'Archive.id') |
204 | + |
205 | + container = Unicode(name='container', allow_none=False) |
206 | + |
207 | + path = Unicode(name='path', allow_none=False) |
208 | + |
209 | + library_file_id = Int(name='library_file', allow_none=False) |
210 | + library_file = Reference(library_file_id, 'LibraryFileAlias.id') |
211 | + |
212 | + scheduled_deletion_date = DateTime( |
213 | + name='scheduled_deletion_date', tzinfo=pytz.UTC, allow_none=True) |
214 | + |
215 | + def __init__(self, archive, container, path, library_file): |
216 | + """Construct an `ArchiveFile`.""" |
217 | + super(ArchiveFile, self).__init__() |
218 | + self.archive = archive |
219 | + self.container = container |
220 | + self.path = path |
221 | + self.library_file = library_file |
222 | + self.scheduled_deletion_date = None |
223 | + |
224 | + |
225 | +@implementer(IArchiveFileSet) |
226 | +class ArchiveFileSet: |
227 | + """See `IArchiveFileSet`.""" |
228 | + |
229 | + @staticmethod |
230 | + def new(archive, container, path, library_file): |
231 | + """See `IArchiveFileSet`.""" |
232 | + archive_file = ArchiveFile(archive, container, path, library_file) |
233 | + IMasterStore(ArchiveFile).add(archive_file) |
234 | + return archive_file |
235 | + |
236 | + @classmethod |
237 | + def newFromFile(cls, archive, container, path, fileobj, size, |
238 | + content_type): |
239 | + library_file = getUtility(ILibraryFileAliasSet).create( |
240 | + os.path.basename(path), size, fileobj, content_type, |
241 | + restricted=archive.private) |
242 | + return cls.new(archive, container, path, library_file) |
243 | + |
244 | + @staticmethod |
245 | + def getByArchive(archive, container=None, eager_load=False): |
246 | + """See `IArchiveFileSet`.""" |
247 | + clauses = [ArchiveFile.archive == archive] |
248 | + # XXX cjwatson 2016-03-15: We'll need some more sophisticated way to |
249 | + # match containers once we're using them for custom uploads. |
250 | + if container is not None: |
251 | + clauses.append(ArchiveFile.container == container) |
252 | + archive_files = IStore(ArchiveFile).find(ArchiveFile, *clauses) |
253 | + |
254 | + def eager_load(rows): |
255 | + lfas = load_related(LibraryFileAlias, rows, ["library_file_id"]) |
256 | + load_related(LibraryFileContent, lfas, ["contentID"]) |
257 | + |
258 | + if eager_load: |
259 | + return DecoratedResultSet(archive_files, pre_iter_hook=eager_load) |
260 | + else: |
261 | + return archive_files |
262 | + |
263 | + @staticmethod |
264 | + def scheduleDeletion(archive_files, stay_of_execution): |
265 | + """See `IArchiveFileSet`.""" |
266 | + archive_file_ids = set( |
267 | + archive_file.id for archive_file in archive_files) |
268 | + rows = IMasterStore(ArchiveFile).find( |
269 | + ArchiveFile, ArchiveFile.id.is_in(archive_file_ids)) |
270 | + rows.set(scheduled_deletion_date=UTC_NOW + stay_of_execution) |
271 | + |
272 | + @staticmethod |
273 | + def getContainersToReap(archive, container_prefix=None): |
274 | + clauses = [ |
275 | + ArchiveFile.archive == archive, |
276 | + ArchiveFile.scheduled_deletion_date < UTC_NOW, |
277 | + ] |
278 | + if container_prefix is not None: |
279 | + clauses.append(ArchiveFile.container.startswith(container_prefix)) |
280 | + return IStore(ArchiveFile).find( |
281 | + ArchiveFile.container, *clauses).group_by(ArchiveFile.container) |
282 | + |
283 | + @staticmethod |
284 | + def reap(archive, container=None): |
285 | + """See `IArchiveFileSet`.""" |
286 | + clauses = [ |
287 | + ArchiveFile.archive == archive, |
288 | + ArchiveFile.scheduled_deletion_date < UTC_NOW, |
289 | + ] |
290 | + if container is not None: |
291 | + clauses.append(ArchiveFile.container == container) |
292 | + IMasterStore(ArchiveFile).find(ArchiveFile, *clauses).remove() |
293 | |
294 | === added file 'lib/lp/soyuz/tests/test_archivefile.py' |
295 | --- lib/lp/soyuz/tests/test_archivefile.py 1970-01-01 00:00:00 +0000 |
296 | +++ lib/lp/soyuz/tests/test_archivefile.py 2016-03-18 15:10:41 +0000 |
297 | @@ -0,0 +1,154 @@ |
298 | +# Copyright 2016 Canonical Ltd. This software is licensed under the |
299 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
300 | + |
301 | +"""ArchiveFile tests.""" |
302 | + |
303 | +from __future__ import absolute_import, print_function, unicode_literals |
304 | + |
305 | +__metaclass__ = type |
306 | + |
307 | +from datetime import ( |
308 | + datetime, |
309 | + timedelta, |
310 | + ) |
311 | +import os |
312 | + |
313 | +import pytz |
314 | +from testtools.matchers import LessThan |
315 | +import transaction |
316 | +from zope.component import getUtility |
317 | +from zope.security.proxy import removeSecurityProxy |
318 | + |
319 | +from lp.services.osutils import open_for_writing |
320 | +from lp.soyuz.interfaces.archivefile import IArchiveFileSet |
321 | +from lp.testing import TestCaseWithFactory |
322 | +from lp.testing.layers import LaunchpadZopelessLayer |
323 | + |
324 | + |
325 | +class TestArchiveFile(TestCaseWithFactory): |
326 | + |
327 | + layer = LaunchpadZopelessLayer |
328 | + |
329 | + def test_new(self): |
330 | + archive = self.factory.makeArchive() |
331 | + library_file = self.factory.makeLibraryFileAlias() |
332 | + archive_file = getUtility(IArchiveFileSet).new( |
333 | + archive, "foo", "dists/foo", library_file) |
334 | + self.assertEqual(archive, archive_file.archive) |
335 | + self.assertEqual("foo", archive_file.container) |
336 | + self.assertEqual("dists/foo", archive_file.path) |
337 | + self.assertEqual(library_file, archive_file.library_file) |
338 | + self.assertIsNone(archive_file.scheduled_deletion_date) |
339 | + |
340 | + def test_newFromFile(self): |
341 | + root = self.makeTemporaryDirectory() |
342 | + with open_for_writing(os.path.join(root, "dists/foo"), "w") as f: |
343 | + f.write("abc\n") |
344 | + archive = self.factory.makeArchive() |
345 | + with open(os.path.join(root, "dists/foo"), "rb") as f: |
346 | + archive_file = getUtility(IArchiveFileSet).newFromFile( |
347 | + archive, "foo", "dists/foo", f, 4, "text/plain") |
348 | + transaction.commit() |
349 | + self.assertEqual(archive, archive_file.archive) |
350 | + self.assertEqual("foo", archive_file.container) |
351 | + self.assertEqual("dists/foo", archive_file.path) |
352 | + archive_file.library_file.open() |
353 | + try: |
354 | + self.assertEqual("abc\n", archive_file.library_file.read()) |
355 | + finally: |
356 | + archive_file.library_file.close() |
357 | + self.assertIsNone(archive_file.scheduled_deletion_date) |
358 | + |
359 | + def test_getByArchive(self): |
360 | + archives = [self.factory.makeArchive(), self.factory.makeArchive()] |
361 | + archive_files = [] |
362 | + for archive in archives: |
363 | + archive_files.append(self.factory.makeArchiveFile(archive=archive)) |
364 | + archive_files.append(self.factory.makeArchiveFile( |
365 | + archive=archive, container="foo")) |
366 | + archive_file_set = getUtility(IArchiveFileSet) |
367 | + self.assertContentEqual( |
368 | + archive_files[:2], archive_file_set.getByArchive(archives[0])) |
369 | + self.assertContentEqual( |
370 | + [archive_files[1]], |
371 | + archive_file_set.getByArchive(archives[0], container="foo")) |
372 | + self.assertContentEqual( |
373 | + [], archive_file_set.getByArchive(archives[0], container="bar")) |
374 | + self.assertContentEqual( |
375 | + archive_files[2:], archive_file_set.getByArchive(archives[1])) |
376 | + self.assertContentEqual( |
377 | + [archive_files[3]], |
378 | + archive_file_set.getByArchive(archives[1], container="foo")) |
379 | + self.assertContentEqual( |
380 | + [], archive_file_set.getByArchive(archives[1], container="bar")) |
381 | + |
382 | + def test_scheduleDeletion(self): |
383 | + archive_files = [self.factory.makeArchiveFile() for _ in range(3)] |
384 | + getUtility(IArchiveFileSet).scheduleDeletion( |
385 | + archive_files[:2], timedelta(days=1)) |
386 | + tomorrow = datetime.now(pytz.UTC) + timedelta(days=1) |
387 | + # Allow a bit of timing slack for slow tests. |
388 | + self.assertThat( |
389 | + tomorrow - archive_files[0].scheduled_deletion_date, |
390 | + LessThan(timedelta(minutes=5))) |
391 | + self.assertThat( |
392 | + tomorrow - archive_files[1].scheduled_deletion_date, |
393 | + LessThan(timedelta(minutes=5))) |
394 | + self.assertIsNone(archive_files[2].scheduled_deletion_date) |
395 | + |
396 | + def test_getContainersToReap(self): |
397 | + archive = self.factory.makeArchive() |
398 | + archive_files = [] |
399 | + for container in ("release:foo", "other:bar", "baz"): |
400 | + for _ in range(2): |
401 | + archive_files.append(self.factory.makeArchiveFile( |
402 | + archive=archive, container=container)) |
403 | + other_archive = self.factory.makeArchive() |
404 | + archive_files.append(self.factory.makeArchiveFile( |
405 | + archive=other_archive, container="baz")) |
406 | + now = datetime.now(pytz.UTC) |
407 | + removeSecurityProxy(archive_files[0]).scheduled_deletion_date = ( |
408 | + now - timedelta(days=1)) |
409 | + removeSecurityProxy(archive_files[1]).scheduled_deletion_date = ( |
410 | + now - timedelta(days=1)) |
411 | + removeSecurityProxy(archive_files[2]).scheduled_deletion_date = ( |
412 | + now + timedelta(days=1)) |
413 | + removeSecurityProxy(archive_files[6]).scheduled_deletion_date = ( |
414 | + now - timedelta(days=1)) |
415 | + archive_file_set = getUtility(IArchiveFileSet) |
416 | + self.assertContentEqual( |
417 | + ["release:foo"], archive_file_set.getContainersToReap(archive)) |
418 | + self.assertContentEqual( |
419 | + ["baz"], archive_file_set.getContainersToReap(other_archive)) |
420 | + removeSecurityProxy(archive_files[3]).scheduled_deletion_date = ( |
421 | + now - timedelta(days=1)) |
422 | + self.assertContentEqual( |
423 | + ["release:foo", "other:bar"], |
424 | + archive_file_set.getContainersToReap(archive)) |
425 | + self.assertContentEqual( |
426 | + ["release:foo"], |
427 | + archive_file_set.getContainersToReap( |
428 | + archive, container_prefix="release:")) |
429 | + |
430 | + def test_reap(self): |
431 | + archive = self.factory.makeArchive() |
432 | + archive_files = [ |
433 | + self.factory.makeArchiveFile(archive=archive, container="foo") |
434 | + for _ in range(3)] |
435 | + archive_files.append(self.factory.makeArchiveFile(archive=archive)) |
436 | + other_archive = self.factory.makeArchive() |
437 | + archive_files.append( |
438 | + self.factory.makeArchiveFile(archive=other_archive)) |
439 | + now = datetime.now(pytz.UTC) |
440 | + removeSecurityProxy(archive_files[0]).scheduled_deletion_date = ( |
441 | + now - timedelta(days=1)) |
442 | + removeSecurityProxy(archive_files[1]).scheduled_deletion_date = ( |
443 | + now + timedelta(days=1)) |
444 | + removeSecurityProxy(archive_files[3]).scheduled_deletion_date = ( |
445 | + now - timedelta(days=1)) |
446 | + removeSecurityProxy(archive_files[4]).scheduled_deletion_date = ( |
447 | + now - timedelta(days=1)) |
448 | + archive_file_set = getUtility(IArchiveFileSet) |
449 | + archive_file_set.reap(archive, container="foo") |
450 | + self.assertContentEqual( |
451 | + archive_files[1:4], archive_file_set.getByArchive(archive)) |
452 | |
453 | === modified file 'lib/lp/testing/factory.py' |
454 | --- lib/lp/testing/factory.py 2016-03-09 01:37:56 +0000 |
455 | +++ lib/lp/testing/factory.py 2016-03-18 15:10:41 +0000 |
456 | @@ -289,6 +289,7 @@ |
457 | default_name_by_purpose, |
458 | IArchiveSet, |
459 | ) |
460 | +from lp.soyuz.interfaces.archivefile import IArchiveFileSet |
461 | from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet |
462 | from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuildSet |
463 | from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet |
464 | @@ -2878,6 +2879,20 @@ |
465 | permission_set.newQueueAdmin(archive, person, 'main') |
466 | return person |
467 | |
468 | + def makeArchiveFile(self, archive=None, container=None, path=None, |
469 | + library_file=None): |
470 | + if archive is None: |
471 | + archive = self.makeArchive() |
472 | + if container is None: |
473 | + container = self.getUniqueUnicode() |
474 | + if path is None: |
475 | + path = self.getUniqueUnicode() |
476 | + if library_file is None: |
477 | + library_file = self.makeLibraryFileAlias() |
478 | + return getUtility(IArchiveFileSet).new( |
479 | + archive=archive, container=container, path=path, |
480 | + library_file=library_file) |
481 | + |
482 | def makeBuilder(self, processors=None, url=None, name=None, title=None, |
483 | owner=None, active=True, virtualized=True, vm_host=None, |
484 | vm_reset_protocol=None, manual=False): |