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/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 3a7b029e8c7e..23973fa4bb73 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -118,6 +118,91 @@ 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_staged(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_only_staged(self, db_request): + project = ProjectFactory.create() + ReleaseFactory.create(project=project, version="1.0", published=False) + db_request.matchdict = {"name": project.normalized_name} + resp = json.latest_release_factory(db_request) + + assert isinstance(resp, HTTPNotFound) + _assert_has_cors_headers(resp.headers) + + @pytest.mark.parametrize( + ("release0_state", "release1_state", "release2_state", "latest_release"), + [ + ("published", "published", "published", 2), + ("published", "published", "staged", 1), + ("published", "published", "yanked", 1), + ("published", "staged", "published", 2), + ("published", "staged", "staged", 0), + ("published", "staged", "yanked", 0), + ("published", "yanked", "published", 2), + ("published", "yanked", "staged", 0), + ("published", "yanked", "yanked", 0), + ("staged", "published", "published", 2), + ("staged", "published", "staged", 1), + ("staged", "published", "yanked", 1), + ("staged", "staged", "published", 2), + ("staged", "staged", "staged", -1), + ("staged", "staged", "yanked", 2), # Same endpoint as none yanked + ("staged", "yanked", "published", 2), + ("staged", "yanked", "staged", 1), + ("staged", "yanked", "yanked", 2), + ("yanked", "published", "published", 2), + ("yanked", "published", "staged", 1), + ("yanked", "published", "yanked", 1), + ("yanked", "staged", "published", 2), + ("yanked", "staged", "staged", 0), + ("yanked", "staged", "yanked", 2), + ("yanked", "yanked", "published", 2), + ("yanked", "yanked", "staged", 1), + ("yanked", "yanked", "yanked", 2), + ], + ) + def test_with_mixed_states( + self, db_request, release0_state, release1_state, release2_state, latest_release + ): + project = ProjectFactory.create() + + releases = [] + for version, state in [ + ("1.0", release0_state), + ("1.1", release1_state), + ("2.0", release2_state), + ]: + if state == "published": + releases.append( + ReleaseFactory.create( + project=project, version=version, published=True + ) + ) + elif state == "staged": + releases.append( + ReleaseFactory.create( + project=project, version=version, published=False + ) + ) + else: + releases.append( + ReleaseFactory.create(project=project, version=version, yanked=True) + ) + + db_request.matchdict = {"name": project.normalized_name} + + resp = json.latest_release_factory(db_request) + if latest_release >= 0: + assert resp == releases[latest_release] + else: + assert isinstance(resp, HTTPNotFound) + _assert_has_cors_headers(resp.headers) + def test_project_quarantined(self, monkeypatch, db_request): project = ProjectFactory.create( lifecycle_status=LifecycleStatus.QuarantineEnter @@ -191,6 +276,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_models.py b/tests/unit/packaging/test_models.py index 8a80d58129e2..46f5138b9a99 100644 --- a/tests/unit/packaging/test_models.py +++ b/tests/unit/packaging/test_models.py @@ -28,6 +28,7 @@ Project, ProjectFactory, ProjectMacaroonWarningAssociation, + Release, ReleaseURL, ) @@ -1216,6 +1217,25 @@ def test_description_relationship(self, db_request): assert description in db_request.db.deleted +@pytest.mark.parametrize( + "published", + [ + True, + False, + ], +) +def test_filter_staged_releases(db_request, published): + DBReleaseFactory.create(published=published) + assert db_request.db.query(Release).count() == (1 if published else 0) + + +def test_filter_staged_releases_with_staged(db_request): + DBReleaseFactory.create(published=False) + assert ( + db_request.db.query(Release).execution_options(include_staged=True).count() == 1 + ) + + class TestFile: def test_requires_python(self, db_session): """ diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index f914c3e97b1b..8eba115cf851 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_staged(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 a staged 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/packaging/models.py b/warehouse/packaging/models.py index 8f6fa0d63bd3..0aea9d6241f2 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 @@ -1058,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(_, 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__ = (