Merge lp:~cjwatson/launchpad/archive-file-model into lp:launchpad

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
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):