Skip to content

Commit

Permalink
packaging: deletion window machinery
Browse files Browse the repository at this point in the history
This adds `in_deletion_window` to the File, Release, and Project
models. The three models currently compose in deletion semantics
as follows:

1. A file is deletable iff it was uploaded within the last 7 days
2. A release is deletable iff all of its files are deletable
3. A project is deletable iff all of its releases are deletable

This results in no special casing: the deletability of
releases and projects is entirely "driven" by the deletability
of files, as files are the primary way in which users drive the
creation of releases and projects.

This PR does **not** connect the deletion semantics to actual
UI or view changes; I'll do that in a follow-up, to keep the
patches small.

Signed-off-by: William Woodruff <[email protected]>
  • Loading branch information
woodruffw committed Sep 30, 2024
1 parent 126ac91 commit af0165e
Show file tree
Hide file tree
Showing 2 changed files with 140 additions and 0 deletions.
108 changes: 108 additions & 0 deletions tests/unit/packaging/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
# limitations under the License.

from collections import OrderedDict
from datetime import datetime, timedelta

import freezegun
import pretend
import pytest

Expand Down Expand Up @@ -479,6 +481,47 @@ def test_deletion_macaroon_with_macaroon_warning(
== 0
)

def test_in_deletion_window(self, db_session):
project = DBProjectFactory.create()

# Empty project, trivially deletable.
assert project.in_deletion_window

fake_now = datetime(year=2000, month=1, day=1)

# Releases are all deletable, so the project is deletable.
release1 = DBReleaseFactory.create(project=project)
DBFileFactory.create(
release=release1, upload_time=fake_now, packagetype="bdist_wheel"
)
DBFileFactory.create(
release=release1,
upload_time=fake_now - timedelta(hours=1),
packagetype="bdist_wheel",
)

release2 = DBReleaseFactory.create(project=project)
DBFileFactory.create(
release=release2,
upload_time=fake_now - timedelta(days=2),
packagetype="bdist_wheel",
)
DBFileFactory.create(
release=release2,
upload_time=fake_now - timedelta(days=3),
packagetype="bdist_wheel",
)

with freezegun.freeze_time(fake_now):
assert project.in_deletion_window

# One release is not deletable, so the entire project is not deletable.
release3 = DBReleaseFactory.create(project=project)
DBFileFactory.create(release=release3, upload_time=fake_now - timedelta(days=8))
with freezegun.freeze_time(fake_now):
assert not release3.in_deletion_window
assert not project.in_deletion_window


class TestDependency:
def test_repr(self, db_session):
Expand Down Expand Up @@ -1087,6 +1130,43 @@ def test_description_relationship(self, db_request):
assert release in db_request.db.deleted
assert description in db_request.db.deleted

def test_in_deletion_window(self, db_session):
project = DBProjectFactory.create()
release = DBReleaseFactory.create(project=project)

# No files, trivially deletable.
assert release.in_deletion_window

fake_now = datetime(year=2000, month=1, day=1)

DBFileFactory.create(
release=release, upload_time=fake_now, packagetype="bdist_wheel"
)
DBFileFactory.create(
release=release,
upload_time=fake_now - timedelta(hours=1),
packagetype="bdist_wheel",
)
DBFileFactory.create(
release=release,
upload_time=fake_now - timedelta(days=1, hours=1),
packagetype="bdist_wheel",
)

with freezegun.freeze_time(fake_now):
# All files are deletable, so release is deletable.
assert release.in_deletion_window

DBFileFactory.create(
release=release,
upload_time=fake_now - timedelta(days=7, hours=1),
packagetype="bdist_wheel",
)

with freezegun.freeze_time(fake_now):
# One file is not deletable, so the entire release is not deletable.
assert not release.in_deletion_window


class TestFile:
def test_requires_python(self, db_session):
Expand Down Expand Up @@ -1215,3 +1295,31 @@ def test_pretty_wheel_tags(self, db_session):
)

assert rfile.pretty_wheel_tags == ["Source"]

@pytest.mark.parametrize(
("upload_time", "deletable"),
[
# Deletable at the instant of upload
(datetime(year=2000, month=1, day=1), True),
# Deletable within the normal period
(datetime(year=2000, month=1, day=1) - timedelta(hours=1), True),
(datetime(year=2000, month=1, day=1) - timedelta(days=1), True),
(datetime(year=2000, month=1, day=1) - timedelta(days=5), True),
# Deletable at the instant of the end of the deletion period
(datetime(year=2000, month=1, day=1) - timedelta(days=7), True),
# Deletable when inexplicably uploaded in the future
(datetime(year=2000, month=1, day=1) + timedelta(hours=1), True),
# Not deletable outside of the deletion period
(datetime(year=2000, month=1, day=1) - timedelta(days=7, seconds=1), False),
(datetime(year=2000, month=1, day=1) - timedelta(days=8), False),
],
)
def test_in_deletion_window(self, db_session, upload_time, deletable):
project = DBProjectFactory.create()
release = DBReleaseFactory.create(project=project)

fake_now = datetime(year=2000, month=1, day=1)

with freezegun.freeze_time(fake_now):
file = DBFileFactory.create(release=release, upload_time=upload_time)
assert file.in_deletion_window == deletable
32 changes: 32 additions & 0 deletions warehouse/packaging/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import typing

from collections import OrderedDict
from datetime import datetime, timedelta
from uuid import UUID

import packaging.utils
Expand Down Expand Up @@ -459,6 +460,17 @@ def latest_version(self):
.first()
)

@property
def in_deletion_window(self) -> bool:
"""
A project can be deleted by a non-admin owner if it's within its
"deletion window," i.e. each of its releases and constituent files
are within their respective deletion windows.
See `Release.in_deletion_window`.
"""
return all(release.in_deletion_window for release in self.releases)


class DependencyKind(enum.IntEnum):
requires = 1
Expand Down Expand Up @@ -812,6 +824,18 @@ def trusted_published(self) -> bool:
return False
return all(file.uploaded_via_trusted_publisher for file in files)

@property
def in_deletion_window(self) -> bool:
"""
A release can be deleted by a non-admin owner if its within its
"deletion window," i.e. each of its files is within its respective
deletion window.
See `File.in_deletion_window`.
"""
files = self.files.all() # type: ignore[attr-defined]
return all(file.in_deletion_window for file in files)


class PackageType(str, enum.Enum):
bdist_dmg = "bdist_dmg"
Expand Down Expand Up @@ -924,6 +948,14 @@ def validates_requires_python(self, *args, **kwargs):
def pretty_wheel_tags(self) -> list[str]:
return wheel.filename_to_pretty_tags(self.filename)

@property
def in_deletion_window(self) -> bool:
"""
A file can be deleted by a non-admin owner if it's within its
"deletion window," i.e. was uploaded no more than 7 days ago.
"""
return self.upload_time >= datetime.now() - timedelta(days=7)


class Filename(db.ModelBase):
__tablename__ = "file_registry"
Expand Down

0 comments on commit af0165e

Please sign in to comment.