diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py index 3b97b20cbd92..470f233a34c4 100644 --- a/tests/common/db/packaging.py +++ b/tests/common/db/packaging.py @@ -120,7 +120,8 @@ class Meta: lambda o: hashlib.blake2b(o.filename.encode("utf8"), digest_size=32).hexdigest() ) upload_time = factory.Faker( - "date_time_between_dates", datetime_start=datetime.datetime(2008, 1, 1) + "date_time_between_dates", + datetime_start=datetime.datetime(2008, 1, 1), ) path = factory.LazyAttribute( lambda o: "/".join( diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py index 05959699419b..3042251d7f4a 100644 --- a/tests/unit/forklift/test_legacy.py +++ b/tests/unit/forklift/test_legacy.py @@ -3613,6 +3613,7 @@ def test_upload_succeeds_creates_release( else None ), "uploaded_via_trusted_publisher": not test_with_user, + "published": True, } fileadd_event = { diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 3a7b029e8c7e..5edea5594161 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -118,6 +118,13 @@ def test_all_non_prereleases_yanked(self, monkeypatch, db_request): db_request.matchdict = {"name": project.normalized_name} assert json.latest_release_factory(db_request) == release + def test_with_unpublished(self, db_request): + project = ProjectFactory.create() + release = ReleaseFactory.create(project=project, version="1.0") + ReleaseFactory.create(project=project, version="2.0", published=False) + db_request.matchdict = {"name": project.normalized_name} + assert json.latest_release_factory(db_request) == release + def test_project_quarantined(self, monkeypatch, db_request): project = ProjectFactory.create( lifecycle_status=LifecycleStatus.QuarantineEnter @@ -191,6 +198,15 @@ def test_renders(self, pyramid_config, db_request, db_session): ) ] + ReleaseFactory.create( + project=project, + version="3.1", + description=DescriptionFactory.create( + content_type=description_content_type + ), + published=False, + ) + for urlspec in project_urls: label, _, purl = urlspec.partition(",") db_session.add( diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index f914c3e97b1b..f2139dc97a36 100644 --- a/tests/unit/packaging/test_views.py +++ b/tests/unit/packaging/test_views.py @@ -135,6 +135,19 @@ def test_only_yanked_release(self, monkeypatch, db_request): assert resp is response assert release_detail.calls == [pretend.call(release, db_request)] + def test_with_unpublished(self, monkeypatch, db_request): + project = ProjectFactory.create() + release = ReleaseFactory.create(project=project, version="1.0") + ReleaseFactory.create(project=project, version="1.1", published=False) + + response = pretend.stub() + release_detail = pretend.call_recorder(lambda ctx, request: response) + monkeypatch.setattr(views, "release_detail", release_detail) + + resp = views.project_detail(project, db_request) + assert resp is response + assert release_detail.calls == [pretend.call(release, db_request)] + class TestReleaseDetail: def test_normalizing_name_redirects(self, db_request): @@ -202,6 +215,19 @@ def test_detail_rendered(self, db_request): yanked_reason="plaintext yanked reason", ) ] + + # Add an unpublished version + staged_release = ReleaseFactory.create( + project=project, + version="5.1", + description=DescriptionFactory.create( + raw="unrendered description", + html="rendered description", + content_type="text/html", + ), + published=False, + ) + files = [ FileFactory.create( release=r, @@ -209,7 +235,7 @@ def test_detail_rendered(self, db_request): python_version="source", packagetype="sdist", ) - for r in releases + for r in releases + [staged_release] ] # Create a role for each user @@ -226,6 +252,7 @@ def test_detail_rendered(self, db_request): "bdists": [], "description": "rendered description", "latest_version": project.latest_version, + # Non published version are not listed here "all_versions": [ (r.version, r.created, r.is_prerelease, r.yanked, r.yanked_reason) for r in reversed(releases) @@ -324,6 +351,10 @@ def test_long_singleline_license(self, db_request): "characters, it's really so lo..." ) + def test_created_with_published(self, db_request): + release = ReleaseFactory.create() + assert release.published is True + class TestPEP740AttestationViewer: diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py index a3018ecdb2bd..863076c87383 100644 --- a/warehouse/forklift/legacy.py +++ b/warehouse/forklift/legacy.py @@ -932,6 +932,7 @@ def file_upload(request): else None ), "uploaded_via_trusted_publisher": bool(request.oidc_publisher), + "published": True, }, ) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 513699cbd665..8a95b4ccbb6d 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -62,7 +62,9 @@ def _json_data(request, project, release, *, all_releases): ) ) .outerjoin(File) - .filter(Release.project == project) + .filter( + Release.project == project, + ) ) # If we're not looking for all_releases, then we'll filter this further @@ -206,7 +208,7 @@ def latest_release_factory(request): .filter( Project.lifecycle_status.is_distinct_from( LifecycleStatus.QuarantineEnter - ) + ), ) .order_by( Release.yanked.asc(), diff --git a/warehouse/migrations/versions/6cac7b706953_add_published_field.py b/warehouse/migrations/versions/6cac7b706953_add_published_field.py new file mode 100644 index 000000000000..e00c3646cfb5 --- /dev/null +++ b/warehouse/migrations/versions/6cac7b706953_add_published_field.py @@ -0,0 +1,42 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +add published field + +Revision ID: 6cac7b706953 +Revises: 2a2c32c47a8f +Create Date: 2025-01-22 08:49:17.030343 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "6cac7b706953" +down_revision = "2a2c32c47a8f" + + +def upgrade(): + conn = op.get_bind() + conn.execute(sa.text("SET statement_timeout = 120000")) + conn.execute(sa.text("SET lock_timeout = 120000")) + + op.add_column( + "releases", + sa.Column( + "published", sa.Boolean(), server_default=sa.text("true"), nullable=False + ), + ) + + +def downgrade(): + op.drop_column("releases", "published") diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 476638de019b..751657373a62 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -52,10 +52,12 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import ( Mapped, + ORMExecuteState, attribute_keyed_dict, declared_attr, mapped_column, validates, + with_loader_criteria, ) from urllib3.exceptions import LocationParseError from urllib3.util import parse_url @@ -79,7 +81,7 @@ from warehouse.sitemap.models import SitemapMixin from warehouse.utils import dotted_navigator, wheel from warehouse.utils.attrs import make_repr -from warehouse.utils.db.types import bool_false, datetime_now +from warehouse.utils.db.types import bool_false, bool_true, datetime_now if typing.TYPE_CHECKING: from warehouse.oidc.models import OIDCPublisher @@ -633,6 +635,7 @@ def __table_args__(cls): # noqa _pypi_ordering: Mapped[int | None] requires_python: Mapped[str | None] = mapped_column(Text) created: Mapped[datetime_now] = mapped_column() + published: Mapped[bool_true] = mapped_column() description_id: Mapped[UUID] = mapped_column( ForeignKey("release_descriptions.id", onupdate="CASCADE", ondelete="CASCADE"), @@ -1057,6 +1060,21 @@ def ensure_monotonic_journals(config, session, flush_context, instances): return +@db.listens_for(db.Session, "do_orm_execute") +def filter_staged_release(config, state: ORMExecuteState): + if ( + state.is_select + and not state.is_column_load + and not state.is_relationship_load + and not state.statement.get_execution_options().get("include_staged", False) + ): + state.statement = state.statement.options( + with_loader_criteria( + Release, lambda cls: cls.published, include_aliases=True + ) + ) + + class ProhibitedProjectName(db.Model): __tablename__ = "prohibited_project_names" __table_args__ = ( diff --git a/warehouse/packaging/utils.py b/warehouse/packaging/utils.py index 7397cf45a740..b58e62610871 100644 --- a/warehouse/packaging/utils.py +++ b/warehouse/packaging/utils.py @@ -53,9 +53,10 @@ def _simple_detail(project, request): .join(Release) .filter(Release.project == project) # Exclude projects that are in the `quarantine-enter` lifecycle status. + # And exclude un-published releases from the index .join(Project) .filter( - Project.lifecycle_status.is_distinct_from(LifecycleStatus.QuarantineEnter) + Project.lifecycle_status.is_distinct_from(LifecycleStatus.QuarantineEnter), ) .all(), key=lambda f: (packaging_legacy.version.parse(f.release.version), f.filename), diff --git a/warehouse/templates/manage/project/history.html b/warehouse/templates/manage/project/history.html index 6ca65478a265..002d80500033 100644 --- a/warehouse/templates/manage/project/history.html +++ b/warehouse/templates/manage/project/history.html @@ -53,6 +53,14 @@

{% trans %}Security history{% endtrans %}

{{ event.additional.publisher_url }} {% endif %} + + {% trans %}Published:{% endtrans %} + {% if event.additional.published is defined and event.additional.published is false %} + {% trans %}No{% endtrans %} + {% else %} + {% trans %}Yes{% endtrans %} + {% endif %} + {% elif event.tag == EventTag.Project.ReleaseRemove %} {# No link to removed release #}