Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

packaging: deletion window machinery #16813

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 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=1),
packagetype="bdist_wheel",
)
DBFileFactory.create(
release=release2,
upload_time=fake_now - timedelta(days=2),
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=4))
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 @@ -1122,6 +1165,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=3, 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 @@ -1250,3 +1330,40 @@ def test_pretty_wheel_tags(self, db_session):
)

assert rfile.pretty_wheel_tags == ["Source"]

@pytest.mark.parametrize(
("upload_time", "is_prerelease", "deletable"),
[
# Deletable at the instant of upload
(datetime(year=2000, month=1, day=1), False, True),
# Deletable within the normal period
(datetime(year=2000, month=1, day=1) - timedelta(hours=1), False, True),
(datetime(year=2000, month=1, day=1) - timedelta(days=1), False, True),
(datetime(year=2000, month=1, day=1) - timedelta(days=2), False, True),
# Not deletable if uploaded 72 hours after the upload time
# PEP-753 specifies deletion period is less than 72 hours
(datetime(year=2000, month=1, day=1) - timedelta(days=3), False, False),
# Deletable when inexplicably uploaded in the future
(datetime(year=2000, month=1, day=1) + timedelta(hours=1), False, True),
# Not deletable outside of the deletion period
(
datetime(year=2000, month=1, day=1) - timedelta(days=3, seconds=1),
False,
False,
),
(datetime(year=2000, month=1, day=1) - timedelta(days=4), False, False),
# Deletable when it's a prerelease and outside the deletion period
(datetime(year=2000, month=1, day=1) - timedelta(days=4), True, False),
],
)
def test_in_deletion_window(
self, db_session, upload_time, is_prerelease, deletable
):
project = DBProjectFactory.create()
release = DBReleaseFactory.create(project=project, is_prerelease=is_prerelease)

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
36 changes: 36 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 @@ -824,6 +836,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 @@ -939,6 +963,18 @@ 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 less than 72 hours ago OR
it has a pre-release version specifier.
"""
return (
self.release.is_prerelease
or self.upload_time > datetime.now() - timedelta(hours=72)
)


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