diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index fb3e8a085e8f..00fc0c901d9f 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -19,7 +19,12 @@ import pytest import pytz -from pyramid.httpexceptions import HTTPMovedPermanently, HTTPSeeOther +from pyramid.httpexceptions import ( + HTTPMovedPermanently, + HTTPNotFound, + HTTPSeeOther, + HTTPTooManyRequests, +) from sqlalchemy.orm.exc import NoResultFound from webauthn.authentication.verify_authentication_response import ( VerifiedAuthentication, @@ -42,6 +47,8 @@ from warehouse.accounts.views import two_factor_and_totp_validate from warehouse.admin.flags import AdminFlag, AdminFlagValue from warehouse.events.tags import EventTag +from warehouse.metrics.interfaces import IMetricsService +from warehouse.oidc.interfaces import TooManyOIDCRegistrations from warehouse.organizations.models import ( OrganizationInvitation, OrganizationRole, @@ -2890,3 +2897,905 @@ def test_reauth_no_user(self, monkeypatch, pyramid_request): assert isinstance(result, HTTPSeeOther) assert pyramid_request.route_path.calls == [pretend.call("accounts.login")] assert result.headers["Location"] == "/the-redirect" + + +class TestManageAccountPublishingViews: + def test_initializes(self): + metrics = pretend.stub() + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + ) + view = views.ManageAccountPublishingViews(request) + + assert view.request is request + assert view.oidc_enabled + assert view.metrics is metrics + + assert view.request.find_service.calls == [ + pretend.call(IMetricsService, context=None) + ] + + @pytest.mark.parametrize( + "ip_exceeded, user_exceeded", + [ + (False, False), + (False, True), + (True, False), + ], + ) + def test_ratelimiting(self, ip_exceeded, user_exceeded): + metrics = pretend.stub() + user_rate_limiter = pretend.stub( + hit=pretend.call_recorder(lambda *a, **kw: None), + test=pretend.call_recorder(lambda uid: not user_exceeded), + resets_in=pretend.call_recorder(lambda uid: pretend.stub()), + ) + ip_rate_limiter = pretend.stub( + hit=pretend.call_recorder(lambda *a, **kw: None), + test=pretend.call_recorder(lambda ip: not ip_exceeded), + resets_in=pretend.call_recorder(lambda uid: pretend.stub()), + ) + + def find_service(iface, name=None, context=None): + if iface is IMetricsService: + return metrics + + if name == "user_oidc.provider.register": + return user_rate_limiter + else: + return ip_rate_limiter + + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=pretend.call_recorder(find_service), + user=pretend.stub(id=pretend.stub()), + remote_addr=pretend.stub(), + ) + + view = views.ManageAccountPublishingViews(request) + + assert view._ratelimiters == { + "user.oidc": user_rate_limiter, + "ip.oidc": ip_rate_limiter, + } + assert request.find_service.calls == [ + pretend.call(IMetricsService, context=None), + pretend.call(IRateLimiter, name="user_oidc.provider.register"), + pretend.call(IRateLimiter, name="ip_oidc.provider.register"), + ] + + view._hit_ratelimits() + + assert user_rate_limiter.hit.calls == [ + pretend.call(request.user.id), + ] + assert ip_rate_limiter.hit.calls == [pretend.call(request.remote_addr)] + + if user_exceeded or ip_exceeded: + with pytest.raises(TooManyOIDCRegistrations): + view._check_ratelimits() + else: + view._check_ratelimits() + + def test_manage_publishing(self, monkeypatch): + metrics = pretend.stub() + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + POST=pretend.stub(), + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub() + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + + assert view.manage_publishing() == { + "oidc_enabled": True, + "pending_github_provider_form": pending_github_provider_form_obj, + } + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert project_factory_cls.calls == [pretend.call(request)] + assert pending_github_provider_form_cls.calls == [ + pretend.call( + request.POST, + api_token="fake-api-token", + project_factory=project_factory, + ) + ] + + def test_manage_publishing_oidc_disabled(self): + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": False}), + find_service=lambda *a, **kw: None, + ) + + view = views.ManageAccountPublishingViews(request) + + with pytest.raises(HTTPNotFound): + view.manage_publishing() + + def test_manage_publishing_admin_disabled(self, monkeypatch): + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: None), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: True)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub() + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + + assert view.manage_publishing() == { + "oidc_enabled": True, + "pending_github_provider_form": pending_github_provider_form_obj, + } + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert request.session.flash.calls == [ + pretend.call( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + assert pending_github_provider_form_cls.calls == [ + pretend.call( + request.POST, + api_token="fake-api-token", + project_factory=project_factory, + ) + ] + + def test_add_pending_github_oidc_provider_oidc_disabled(self): + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": False}), + find_service=lambda *a, **kw: None, + ) + + view = views.ManageAccountPublishingViews(request) + + with pytest.raises(HTTPNotFound): + view.add_pending_github_oidc_provider() + + def test_add_pending_github_oidc_provider_admin_disabled(self, monkeypatch): + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: None), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: True)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub() + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + + assert view.add_pending_github_oidc_provider() == { + "oidc_enabled": True, + "pending_github_provider_form": pending_github_provider_form_obj, + } + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert request.session.flash.calls == [ + pretend.call( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + assert pending_github_provider_form_cls.calls == [ + pretend.call( + request.POST, + api_token="fake-api-token", + project_factory=project_factory, + ) + ] + + def test_add_pending_github_oidc_provider_user_cannot_register(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + user=pretend.stub(has_primary_verified_email=False), + _=lambda s: s, + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub() + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + + assert view.add_pending_github_oidc_provider() == { + "oidc_enabled": True, + "pending_github_provider_form": pending_github_provider_form_obj, + } + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"] + ), + ] + assert request.session.flash.calls == [ + pretend.call( + ( + "You must have a verified email in order to register a " + "pending OpenID Connect provider. " + "See https://pypi.org/help#openid-connect for details." + ), + queue="error", + ) + ] + assert pending_github_provider_form_cls.calls == [ + pretend.call( + request.POST, + api_token="fake-api-token", + project_factory=project_factory, + ) + ] + + def test_add_pending_github_oidc_provider_too_many_already(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + user=pretend.stub( + has_primary_verified_email=True, + pending_oidc_providers=[pretend.stub(), pretend.stub(), pretend.stub()], + ), + _=lambda s: s, + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub() + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + + assert view.add_pending_github_oidc_provider() == { + "oidc_enabled": True, + "pending_github_provider_form": pending_github_provider_form_obj, + } + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"] + ), + ] + assert request.session.flash.calls == [ + pretend.call( + ( + "You can't register more than 3 pending OpenID Connect " + "providers at once." + ), + queue="error", + ) + ] + assert pending_github_provider_form_cls.calls == [ + pretend.call( + request.POST, + api_token="fake-api-token", + project_factory=project_factory, + ) + ] + + def test_add_pending_github_oidc_provider_ratelimited(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + user=pretend.stub( + has_primary_verified_email=True, + pending_oidc_providers=[], + ), + _=lambda s: s, + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub() + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + monkeypatch.setattr( + view, + "_check_ratelimits", + pretend.call_recorder( + pretend.raiser( + TooManyOIDCRegistrations( + resets_in=pretend.stub(total_seconds=lambda: 60) + ) + ) + ), + ) + + assert view.add_pending_github_oidc_provider().__class__ == HTTPTooManyRequests + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"] + ), + pretend.call( + "warehouse.oidc.add_pending_provider.ratelimited", + tags=["provider:GitHub"], + ), + ] + + def test_add_pending_github_oidc_provider_invalid_form(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + user=pretend.stub( + has_primary_verified_email=True, + pending_oidc_providers=[], + ), + _=lambda s: s, + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: False), + ) + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + default_response = { + "pending_github_provider_form": pending_github_provider_form_obj + } + monkeypatch.setattr( + views.ManageAccountPublishingViews, "default_response", default_response + ) + monkeypatch.setattr( + view, "_check_ratelimits", pretend.call_recorder(lambda: None) + ) + monkeypatch.setattr( + view, "_hit_ratelimits", pretend.call_recorder(lambda: None) + ) + + assert view.add_pending_github_oidc_provider() == default_response + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"] + ), + ] + assert view._hit_ratelimits.calls == [pretend.call()] + assert view._check_ratelimits.calls == [pretend.call()] + assert pending_github_provider_form_obj.validate.calls == [pretend.call()] + + def test_add_pending_github_oidc_provider_already_exists(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + pending_provider = pretend.stub() + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + user=pretend.stub( + has_primary_verified_email=True, + pending_oidc_providers=[], + ), + _=lambda s: s, + db=pretend.stub( + query=lambda q: pretend.stub( + filter_by=lambda **kw: pretend.stub(first=lambda: pending_provider) + ) + ), + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + repository=pretend.stub(data="some-repo"), + normalized_owner="some-owner", + workflow_filename=pretend.stub(data="some-workflow.yml"), + ) + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + default_response = { + "pending_github_provider_form": pending_github_provider_form_obj + } + monkeypatch.setattr( + views.ManageAccountPublishingViews, "default_response", default_response + ) + monkeypatch.setattr( + view, "_check_ratelimits", pretend.call_recorder(lambda: None) + ) + monkeypatch.setattr( + view, "_hit_ratelimits", pretend.call_recorder(lambda: None) + ) + + assert view.add_pending_github_oidc_provider() == default_response + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"] + ), + ] + assert view._hit_ratelimits.calls == [pretend.call()] + assert view._check_ratelimits.calls == [pretend.call()] + assert pending_github_provider_form_obj.validate.calls == [pretend.call()] + assert request.session.flash.calls == [ + pretend.call( + ( + "This OpenID Connect provider has already been registered. " + "Please contact PyPI's admins if this wasn't intentional." + ), + queue="error", + ) + ] + + def test_add_pending_github_oidc_provider(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + user=pretend.stub( + has_primary_verified_email=True, + pending_oidc_providers=[], + record_event=pretend.call_recorder(lambda **kw: None), + ), + _=lambda s: s, + db=pretend.stub( + query=lambda q: pretend.stub( + filter_by=lambda **kw: pretend.stub(first=lambda: None) + ), + add=pretend.call_recorder(lambda o: None), + ), + path="some-path", + remote_addr="0.0.0.0", + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_provider = pretend.stub( + project_name="some-project-name", + provider_name="some-provider", + id=uuid.uuid4(), + ) + # NOTE: Can't set __str__ using pretend.stub() + monkeypatch.setattr( + pending_provider.__class__, "__str__", lambda s: "fakespecifier" + ) + + pending_provider_cls = pretend.call_recorder(lambda **kw: pending_provider) + monkeypatch.setattr(views, "PendingGitHubProvider", pending_provider_cls) + + pending_github_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + project_name=pretend.stub(data="some-project-name"), + repository=pretend.stub(data="some-repo"), + normalized_owner="some-owner", + owner_id="some-owner-id", + workflow_filename=pretend.stub(data="some-workflow.yml"), + ) + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + default_response = { + "pending_github_provider_form": pending_github_provider_form_obj + } + monkeypatch.setattr( + views.ManageAccountPublishingViews, "default_response", default_response + ) + monkeypatch.setattr( + view, "_check_ratelimits", pretend.call_recorder(lambda: None) + ) + monkeypatch.setattr( + view, "_hit_ratelimits", pretend.call_recorder(lambda: None) + ) + + assert view.add_pending_github_oidc_provider().__class__ == HTTPSeeOther + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"] + ), + pretend.call( + "warehouse.oidc.add_pending_provider.ok", tags=["provider:GitHub"] + ), + ] + assert view._hit_ratelimits.calls == [pretend.call()] + assert view._check_ratelimits.calls == [pretend.call()] + assert pending_github_provider_form_obj.validate.calls == [pretend.call()] + + assert pending_provider_cls.calls == [ + pretend.call( + project_name="some-project-name", + added_by=request.user, + repository_name="some-repo", + repository_owner="some-owner", + repository_owner_id="some-owner-id", + workflow_filename="some-workflow.yml", + ) + ] + assert request.db.add.calls == [pretend.call(pending_provider)] + assert request.user.record_event.calls == [ + pretend.call( + tag=EventTag.Account.PendingOIDCProviderAdded, + ip_address="0.0.0.0", + additional={ + "project": "some-project-name", + "provider": "some-provider", + "id": str(pending_provider.id), + "specifier": "fakespecifier", + }, + ) + ] + + assert request.session.flash.calls == [ + pretend.call( + "Registered a new publishing provider to create " + f"the project '{pending_provider.project_name}'.", + queue="success", + ) + ] + + def test_delete_pending_oidc_provider_oidc_disabled(self): + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": False}), + find_service=lambda *a, **kw: None, + ) + + view = views.ManageAccountPublishingViews(request) + + with pytest.raises(HTTPNotFound): + view.delete_pending_oidc_provider() + + def test_delete_pending_oidc_provider_admin_disabled(self, monkeypatch): + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=pretend.call_recorder(lambda *a, **kw: None), + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: True)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + ) + + project_factory = pretend.stub() + project_factory_cls = pretend.call_recorder(lambda r: project_factory) + monkeypatch.setattr(views, "ProjectFactory", project_factory_cls) + + pending_github_provider_form_obj = pretend.stub() + pending_github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_github_provider_form_obj + ) + monkeypatch.setattr( + views, "PendingGitHubProviderForm", pending_github_provider_form_cls + ) + + view = views.ManageAccountPublishingViews(request) + + assert view.delete_pending_oidc_provider() == { + "oidc_enabled": True, + "pending_github_provider_form": pending_github_provider_form_obj, + } + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert request.session.flash.calls == [ + pretend.call( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + assert pending_github_provider_form_cls.calls == [ + pretend.call( + request.POST, + api_token="fake-api-token", + project_factory=project_factory, + ) + ] + + def test_delete_pending_oidc_provider_invalid_form(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + POST=pretend.stub(), + ) + + delete_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: False), + ) + delete_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: delete_provider_form_obj + ) + monkeypatch.setattr(views, "DeleteProviderForm", delete_provider_form_cls) + + view = views.ManageAccountPublishingViews(request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageAccountPublishingViews, "default_response", default_response + ) + + assert view.delete_pending_oidc_provider() == default_response + + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.delete_pending_provider.attempt", + ), + ] + + assert delete_provider_form_cls.calls == [pretend.call(request.POST)] + assert delete_provider_form_obj.validate.calls == [pretend.call()] + + def test_delete_pending_oidc_provider_not_found(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + POST=pretend.stub(), + db=pretend.stub(query=lambda m: pretend.stub(get=lambda id: None)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + ) + + delete_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + provider_id=pretend.stub(data="some-id"), + ) + delete_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: delete_provider_form_obj + ) + monkeypatch.setattr(views, "DeleteProviderForm", delete_provider_form_cls) + + view = views.ManageAccountPublishingViews(request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageAccountPublishingViews, "default_response", default_response + ) + + assert view.delete_pending_oidc_provider() == default_response + + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.delete_pending_provider.attempt", + ), + ] + assert delete_provider_form_cls.calls == [pretend.call(request.POST)] + assert delete_provider_form_obj.validate.calls == [pretend.call()] + assert request.session.flash.calls == [ + pretend.call( + "Invalid publisher for user", + queue="error", + ) + ] + + def test_delete_pending_oidc_provider(self, monkeypatch): + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + pending_provider = pretend.stub( + project_name="some-project-name", + provider_name="some-provider", + id=uuid.uuid4(), + ) + # NOTE: Can't set __str__ using pretend.stub() + monkeypatch.setattr( + pending_provider.__class__, "__str__", lambda s: "fakespecifier" + ) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + POST=pretend.stub(), + db=pretend.stub( + query=lambda m: pretend.stub(get=lambda id: pending_provider), + delete=pretend.call_recorder(lambda m: None), + ), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + user=pretend.stub(record_event=pretend.call_recorder(lambda **kw: None)), + remote_addr="0.0.0.0", + path="some-path", + ) + + delete_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + provider_id=pretend.stub(data="some-id"), + ) + delete_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: delete_provider_form_obj + ) + monkeypatch.setattr(views, "DeleteProviderForm", delete_provider_form_cls) + + view = views.ManageAccountPublishingViews(request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageAccountPublishingViews, "default_response", default_response + ) + + assert view.delete_pending_oidc_provider().__class__ == HTTPSeeOther + + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.delete_pending_provider.attempt", + ), + pretend.call( + "warehouse.oidc.delete_pending_provider.ok", + tags=["provider:some-provider"], + ), + ] + assert delete_provider_form_cls.calls == [pretend.call(request.POST)] + assert delete_provider_form_obj.validate.calls == [pretend.call()] + assert request.session.flash.calls == [ + pretend.call( + "Removed provider for project 'some-project-name'", queue="success" + ) + ] + assert request.user.record_event.calls == [ + pretend.call( + tag=EventTag.Account.PendingOIDCProviderRemoved, + ip_address="0.0.0.0", + additional={ + "project": "some-project-name", + "provider": "some-provider", + "id": str(pending_provider.id), + "specifier": str(pending_provider), + }, + ) + ] + assert request.db.delete.calls == [pretend.call(pending_provider)] diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 587361f49473..fb11d3f9411a 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -9959,10 +9959,9 @@ def test_add_github_oidc_provider_oidc_not_enabled(self): def test_add_github_oidc_provider_admin_disabled(self, monkeypatch): project = pretend.stub() - metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) request = pretend.stub( registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), - find_service=lambda *a, **kw: metrics, + find_service=lambda *a, **kw: None, flags=pretend.stub(enabled=pretend.call_recorder(lambda f: True)), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), _=lambda s: s, @@ -9975,11 +9974,6 @@ def test_add_github_oidc_provider_admin_disabled(self, monkeypatch): ) assert view.add_github_oidc_provider() == default_response - assert view.metrics.increment.calls == [ - pretend.call( - "warehouse.oidc.add_provider.attempt", tags=["provider:GitHub"] - ), - ] assert request.session.flash.calls == [ pretend.call( ( @@ -10336,10 +10330,9 @@ def test_delete_oidc_provider_oidc_not_enabled(self): def test_delete_oidc_provider_admin_disabled(self, monkeypatch): project = pretend.stub() - metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) request = pretend.stub( registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), - find_service=lambda *a, **kw: metrics, + find_service=lambda *a, **kw: None, flags=pretend.stub(enabled=pretend.call_recorder(lambda f: True)), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), ) @@ -10351,11 +10344,6 @@ def test_delete_oidc_provider_admin_disabled(self, monkeypatch): ) assert view.delete_oidc_provider() == default_response - assert view.metrics.increment.calls == [ - pretend.call( - "warehouse.oidc.delete_provider.attempt", - ), - ] assert request.session.flash.calls == [ pretend.call( ( diff --git a/tests/unit/oidc/test_forms.py b/tests/unit/oidc/test_forms.py index 8558c9543a73..93ea5ed9024c 100644 --- a/tests/unit/oidc/test_forms.py +++ b/tests/unit/oidc/test_forms.py @@ -14,12 +14,51 @@ import pytest import wtforms -from requests import HTTPError, Timeout +from requests import ConnectionError, HTTPError, Timeout from webob.multidict import MultiDict from warehouse.oidc import forms +class TestPendingGitHubProviderForm: + def test_creation(self): + project_factory = pretend.stub() + form = forms.PendingGitHubProviderForm( + api_token="fake-token", project_factory=project_factory + ) + + assert form._project_factory == project_factory + + def test_validate_project_name_already_in_use(self): + project_factory = ["some-project"] + form = forms.PendingGitHubProviderForm( + api_token="fake-token", project_factory=project_factory + ) + + field = pretend.stub(data="some-project") + with pytest.raises(wtforms.validators.ValidationError): + form.validate_project_name(field) + + def test_validate(self, monkeypatch): + data = MultiDict( + { + "owner": "some-owner", + "repository": "some-repo", + "workflow_filename": "some-workflow.yml", + "project_name": "some-project", + } + ) + form = forms.PendingGitHubProviderForm( + MultiDict(data), api_token=pretend.stub(), project_factory=[] + ) + + # We're testing only the basic validation here. + owner_info = {"login": "fake-username", "id": "1234"} + monkeypatch.setattr(form, "_lookup_owner", lambda o: owner_info) + + assert form.validate() + + class TestGitHubProviderForm: @pytest.mark.parametrize( "token, headers", @@ -138,6 +177,7 @@ def test_lookup_owner_http_timeout(self, monkeypatch): get=pretend.raiser(Timeout), Timeout=Timeout, HTTPError=HTTPError, + ConnectionError=ConnectionError, ) monkeypatch.setattr(forms, "requests", requests) @@ -152,6 +192,28 @@ def test_lookup_owner_http_timeout(self, monkeypatch): pretend.call("Timeout from GitHub user lookup API (possibly offline)") ] + def test_lookup_owner_connection_error(self, monkeypatch): + requests = pretend.stub( + get=pretend.raiser(ConnectionError), + Timeout=Timeout, + HTTPError=HTTPError, + ConnectionError=ConnectionError, + ) + monkeypatch.setattr(forms, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(forms, "sentry_sdk", sentry_sdk) + + form = forms.GitHubProviderForm(api_token="fake-token") + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_owner("some-owner") + + assert sentry_sdk.capture_message.calls == [ + pretend.call( + "Connection error from GitHub user lookup API (possibly offline)" + ) + ] + def test_lookup_owner_succeeds(self, monkeypatch): fake_owner_info = pretend.stub() response = pretend.stub( diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 282efd9d6c12..5dad6b80ffdc 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -197,6 +197,9 @@ def add_policy(name, filename): domain=warehouse, ), pretend.call("manage.account", "/manage/account/", domain=warehouse), + pretend.call( + "manage.account.publishing", "/manage/account/publishing/", domain=warehouse + ), pretend.call( "manage.account.two-factor", "/manage/account/two-factor/", diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 5bf7e5fc3b13..4d36e846acee 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -20,11 +20,12 @@ from first import first from pyramid.httpexceptions import ( HTTPMovedPermanently, + HTTPNotFound, HTTPSeeOther, HTTPTooManyRequests, ) from pyramid.security import forget, remember -from pyramid.view import view_config +from pyramid.view import view_config, view_defaults from sqlalchemy.orm.exc import NoResultFound from webauthn.helpers import bytes_to_base64url @@ -67,11 +68,16 @@ send_recovery_code_reminder_email, ) from warehouse.events.tags import EventTag +from warehouse.metrics.interfaces import IMetricsService +from warehouse.oidc.forms import DeleteProviderForm, PendingGitHubProviderForm +from warehouse.oidc.interfaces import TooManyOIDCRegistrations +from warehouse.oidc.models import PendingGitHubProvider, PendingOIDCProvider from warehouse.organizations.interfaces import IOrganizationService from warehouse.organizations.models import OrganizationRole, OrganizationRoleType from warehouse.packaging.models import ( JournalEntry, Project, + ProjectFactory, Release, Role, RoleInvitation, @@ -1288,3 +1294,265 @@ def reauthenticate(request, _form_class=ReAuthenticateForm): ) return resp + + +@view_defaults( + route_name="manage.account.publishing", + renderer="manage/account/publishing.html", + uses_session=True, + require_csrf=True, + require_methods=False, + permission="manage:user", + has_translations=True, + require_reauth=True, +) +class ManageAccountPublishingViews: + def __init__(self, request): + self.request = request + self.oidc_enabled = self.request.registry.settings["warehouse.oidc.enabled"] + self.project_factory = ProjectFactory(request) + self.metrics = self.request.find_service(IMetricsService, context=None) + + @property + def _ratelimiters(self): + return { + "user.oidc": self.request.find_service( + IRateLimiter, name="user_oidc.provider.register" + ), + "ip.oidc": self.request.find_service( + IRateLimiter, name="ip_oidc.provider.register" + ), + } + + def _hit_ratelimits(self): + self._ratelimiters["user.oidc"].hit(self.request.user.id) + self._ratelimiters["ip.oidc"].hit(self.request.remote_addr) + + def _check_ratelimits(self): + if not self._ratelimiters["user.oidc"].test(self.request.user.id): + raise TooManyOIDCRegistrations( + resets_in=self._ratelimiters["user.oidc"].resets_in( + self.request.user.id + ) + ) + + if not self._ratelimiters["ip.oidc"].test(self.request.remote_addr): + raise TooManyOIDCRegistrations( + resets_in=self._ratelimiters["ip.oidc"].resets_in( + self.request.remote_addr + ) + ) + + @property + def pending_github_provider_form(self): + return PendingGitHubProviderForm( + self.request.POST, + api_token=self.request.registry.settings.get("github.token"), + project_factory=self.project_factory, + ) + + @property + def default_response(self): + return { + "oidc_enabled": self.oidc_enabled, + "pending_github_provider_form": self.pending_github_provider_form, + } + + @view_config(request_method="GET") + def manage_publishing(self): + if not self.oidc_enabled: + raise HTTPNotFound + + if self.request.flags.enabled(AdminFlagValue.DISALLOW_OIDC): + self.request.session.flash( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return self.default_response + + return self.default_response + + @view_config( + request_method="POST", request_param=PendingGitHubProviderForm.__params__ + ) + def add_pending_github_oidc_provider(self): + if not self.oidc_enabled: + raise HTTPNotFound + + if self.request.flags.enabled(AdminFlagValue.DISALLOW_OIDC): + self.request.session.flash( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return self.default_response + + self.metrics.increment( + "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"] + ) + + if not self.request.user.has_primary_verified_email: + self.request.session.flash( + self.request._( + "You must have a verified email in order to register a " + "pending OpenID Connect provider. " + "See https://pypi.org/help#openid-connect for details." + ), + queue="error", + ) + return self.default_response + + # Separately from having permission to register pending OIDC providers, + # we limit users to no more than 3 pending providers at once. + if len(self.request.user.pending_oidc_providers) >= 3: + self.request.session.flash( + self.request._( + "You can't register more than 3 pending OpenID Connect " + "providers at once." + ), + queue="error", + ) + return self.default_response + + try: + self._check_ratelimits() + except TooManyOIDCRegistrations as exc: + self.metrics.increment( + "warehouse.oidc.add_pending_provider.ratelimited", + tags=["provider:GitHub"], + ) + return HTTPTooManyRequests( + self.request._( + "There have been too many attempted OpenID Connect registrations. " + "Try again later." + ), + retry_after=exc.resets_in.total_seconds(), + ) + + self._hit_ratelimits() + + response = self.default_response + form = response["pending_github_provider_form"] + + if not form.validate(): + return response + + provider_already_exists = ( + self.request.db.query(PendingGitHubProvider) + .filter_by( + repository_name=form.repository.data, + repository_owner=form.normalized_owner, + workflow_filename=form.workflow_filename.data, + ) + .first() + is not None + ) + + if provider_already_exists: + self.request.session.flash( + self.request._( + "This OpenID Connect provider has already been registered. " + "Please contact PyPI's admins if this wasn't intentional." + ), + queue="error", + ) + return response + + pending_provider = PendingGitHubProvider( + project_name=form.project_name.data, + added_by=self.request.user, + repository_name=form.repository.data, + repository_owner=form.normalized_owner, + repository_owner_id=form.owner_id, + workflow_filename=form.workflow_filename.data, + ) + + self.request.db.add(pending_provider) + + self.request.user.record_event( + tag=EventTag.Account.PendingOIDCProviderAdded, + ip_address=self.request.remote_addr, + additional={ + "project": pending_provider.project_name, + "provider": pending_provider.provider_name, + "id": str(pending_provider.id), + "specifier": str(pending_provider), + }, + ) + + self.request.session.flash( + "Registered a new publishing provider to create " + f"the project '{pending_provider.project_name}'.", + queue="success", + ) + + self.metrics.increment( + "warehouse.oidc.add_pending_provider.ok", tags=["provider:GitHub"] + ) + + return HTTPSeeOther(self.request.path) + + @view_config(request_method="POST", request_param=DeleteProviderForm.__params__) + def delete_pending_oidc_provider(self): + if not self.oidc_enabled: + raise HTTPNotFound + + if self.request.flags.enabled(AdminFlagValue.DISALLOW_OIDC): + self.request.session.flash( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return self.default_response + + self.metrics.increment("warehouse.oidc.delete_pending_provider.attempt") + + form = DeleteProviderForm(self.request.POST) + + if form.validate(): + pending_provider = self.request.db.query(PendingOIDCProvider).get( + form.provider_id.data + ) + + # pending_provider will be `None` here if someone manually + # futzes with the form. + if pending_provider is None: + self.request.session.flash( + "Invalid publisher for user", + queue="error", + ) + return self.default_response + + self.request.session.flash( + f"Removed provider for project '{pending_provider.project_name}'", + queue="success", + ) + + self.metrics.increment( + "warehouse.oidc.delete_pending_provider.ok", + tags=[f"provider:{pending_provider.provider_name}"], + ) + + self.request.user.record_event( + tag=EventTag.Account.PendingOIDCProviderRemoved, + ip_address=self.request.remote_addr, + additional={ + "project": pending_provider.project_name, + "provider": pending_provider.provider_name, + "id": str(pending_provider.id), + "specifier": str(pending_provider), + }, + ) + + self.request.db.delete(pending_provider) + + return HTTPSeeOther(self.request.path) + + return self.default_response diff --git a/warehouse/events/tags.py b/warehouse/events/tags.py index 3ec3b5e740a5..54ba623b587e 100644 --- a/warehouse/events/tags.py +++ b/warehouse/events/tags.py @@ -84,6 +84,8 @@ class Account(EventTagEnum): PasswordReset = "account:password:reset" PasswordResetAttempt = "account:password:reset:attempt" PasswordResetRequest = "account:password:reset:request" + PendingOIDCProviderAdded = "account:oidc:pending-provider-added" + PendingOIDCProviderRemoved = "account:oidc:pending-provider-removed" RecoveryCodesGenerated = "account:recovery_codes:generated" RecoveryCodesRegenerated = "account:recovery_codes:regenerated" RecoveryCodesUsed = "account:recovery_codes:used" diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py index c36acd084d9b..ff19f61e2580 100644 --- a/warehouse/forklift/legacy.py +++ b/warehouse/forklift/legacy.py @@ -63,7 +63,7 @@ ) from warehouse.packaging.tasks import update_bigquery_release_files from warehouse.utils import http, readme -from warehouse.utils.project import add_project, validate_project_name +from warehouse.utils.project import PROJECT_NAME_RE, add_project, validate_project_name from warehouse.utils.security_policy import AuthenticationMethod ONE_MB = 1 * 1024 * 1024 @@ -189,11 +189,6 @@ def _valid_platform_tag(platform_tag): ) -_project_name_re = re.compile( - r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE -) - - _legacy_specifier_re = re.compile(r"^(?P\S+)(?: \((?P\S+)\))?$") @@ -437,7 +432,7 @@ class MetadataForm(forms.Form): validators=[ wtforms.validators.DataRequired(), wtforms.validators.Regexp( - _project_name_re, + PROJECT_NAME_RE, re.IGNORECASE, message=( "Start and end with a letter or numeral containing " diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index f7c8536ad8bd..83b3eaefdf34 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -46,7 +46,7 @@ msgstr "" msgid "Password too long." msgstr "" -#: warehouse/accounts/forms.py:172 warehouse/accounts/views.py:88 +#: warehouse/accounts/forms.py:172 warehouse/accounts/views.py:94 msgid "There have been too many unsuccessful login attempts. Try again later." msgstr "" @@ -98,166 +98,188 @@ msgstr "" msgid "No user found with that username or email" msgstr "" -#: warehouse/accounts/views.py:105 +#: warehouse/accounts/views.py:111 msgid "" "Too many emails have been added to this account without verifying them. " "Check your inbox and follow the verification links. (IP: ${ip})" msgstr "" -#: warehouse/accounts/views.py:117 +#: warehouse/accounts/views.py:123 msgid "" "Too many password resets have been requested for this account without " "completing them. Check your inbox and follow the verification links. (IP:" " ${ip})" msgstr "" -#: warehouse/accounts/views.py:257 warehouse/accounts/views.py:321 -#: warehouse/accounts/views.py:323 warehouse/accounts/views.py:350 -#: warehouse/accounts/views.py:352 warehouse/accounts/views.py:418 +#: warehouse/accounts/views.py:263 warehouse/accounts/views.py:327 +#: warehouse/accounts/views.py:329 warehouse/accounts/views.py:356 +#: warehouse/accounts/views.py:358 warehouse/accounts/views.py:424 msgid "Invalid or expired two factor login." msgstr "" -#: warehouse/accounts/views.py:315 +#: warehouse/accounts/views.py:321 msgid "Already authenticated" msgstr "" -#: warehouse/accounts/views.py:394 +#: warehouse/accounts/views.py:400 msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:448 warehouse/manage/views.py:956 +#: warehouse/accounts/views.py:454 warehouse/manage/views.py:956 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" -#: warehouse/accounts/views.py:534 +#: warehouse/accounts/views.py:540 msgid "" "New user registration temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:654 +#: warehouse/accounts/views.py:660 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:656 +#: warehouse/accounts/views.py:662 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:658 warehouse/accounts/views.py:757 -#: warehouse/accounts/views.py:856 warehouse/accounts/views.py:1025 +#: warehouse/accounts/views.py:664 warehouse/accounts/views.py:763 +#: warehouse/accounts/views.py:862 warehouse/accounts/views.py:1031 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:662 +#: warehouse/accounts/views.py:668 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:667 +#: warehouse/accounts/views.py:673 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:678 +#: warehouse/accounts/views.py:684 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:696 +#: warehouse/accounts/views.py:702 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:725 +#: warehouse/accounts/views.py:731 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:753 +#: warehouse/accounts/views.py:759 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:755 +#: warehouse/accounts/views.py:761 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:761 +#: warehouse/accounts/views.py:767 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:770 +#: warehouse/accounts/views.py:776 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:773 +#: warehouse/accounts/views.py:779 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:790 +#: warehouse/accounts/views.py:796 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:794 +#: warehouse/accounts/views.py:800 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:799 +#: warehouse/accounts/views.py:805 msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:852 +#: warehouse/accounts/views.py:858 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:854 +#: warehouse/accounts/views.py:860 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:860 +#: warehouse/accounts/views.py:866 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:864 +#: warehouse/accounts/views.py:870 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:873 +#: warehouse/accounts/views.py:879 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:924 +#: warehouse/accounts/views.py:930 msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:987 +#: warehouse/accounts/views.py:993 msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1021 +#: warehouse/accounts/views.py:1027 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1023 +#: warehouse/accounts/views.py:1029 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1029 +#: warehouse/accounts/views.py:1035 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1033 +#: warehouse/accounts/views.py:1039 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1048 +#: warehouse/accounts/views.py:1054 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1079 +#: warehouse/accounts/views.py:1085 msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1146 +#: warehouse/accounts/views.py:1152 msgid "You are now ${role} of the '${project_name}' project." msgstr "" +#: warehouse/accounts/views.py:1401 +msgid "" +"You must have a verified email in order to register a pending OpenID " +"Connect provider. See https://pypi.org/help#openid-connect for details." +msgstr "" + +#: warehouse/accounts/views.py:1414 +msgid "You can't register more than 3 pending OpenID Connect providers at once." +msgstr "" + +#: warehouse/accounts/views.py:1430 warehouse/manage/views.py:3107 +msgid "" +"There have been too many attempted OpenID Connect registrations. Try " +"again later." +msgstr "" + +#: warehouse/accounts/views.py:1458 +msgid "" +"This OpenID Connect provider has already been registered. Please contact " +"PyPI's admins if this wasn't intentional." +msgstr "" + #: warehouse/admin/templates/admin/banners/preview.html:15 msgid "Banner Preview" msgstr "" @@ -280,57 +302,57 @@ msgid "" "organization account name." msgstr "" -#: warehouse/manage/forms.py:476 +#: warehouse/manage/forms.py:472 msgid "Select project" msgstr "" -#: warehouse/manage/forms.py:481 +#: warehouse/manage/forms.py:477 warehouse/oidc/forms.py:158 msgid "Specify project name" msgstr "" -#: warehouse/manage/forms.py:484 +#: warehouse/manage/forms.py:480 msgid "" "Start and end with a letter or numeral containing only ASCII numeric and " "'.', '_' and '-'." msgstr "" -#: warehouse/manage/forms.py:491 +#: warehouse/manage/forms.py:487 msgid "This project name has already been used. Choose a different project name." msgstr "" -#: warehouse/manage/forms.py:562 +#: warehouse/manage/forms.py:558 msgid "" "The organization name is too long. Choose a organization name with 100 " "characters or less." msgstr "" -#: warehouse/manage/forms.py:574 +#: warehouse/manage/forms.py:570 msgid "" "The organization URL is too long. Choose a organization URL with 400 " "characters or less." msgstr "" -#: warehouse/manage/forms.py:581 +#: warehouse/manage/forms.py:577 msgid "The organization URL must start with http:// or https://" msgstr "" -#: warehouse/manage/forms.py:592 +#: warehouse/manage/forms.py:588 msgid "" "The organization description is too long. Choose a organization " "description with 400 characters or less." msgstr "" -#: warehouse/manage/forms.py:636 +#: warehouse/manage/forms.py:632 msgid "Choose a team name with 50 characters or less." msgstr "" -#: warehouse/manage/forms.py:642 +#: warehouse/manage/forms.py:638 msgid "" "The team name is invalid. Team names cannot start or end with a space, " "period, underscore, hyphen, or slash. Choose a different team name." msgstr "" -#: warehouse/manage/forms.py:670 +#: warehouse/manage/forms.py:666 msgid "This team name has already been used. Choose a different team name." msgstr "" @@ -376,12 +398,6 @@ msgstr "" msgid "Invitation revoked from '${username}'." msgstr "" -#: warehouse/manage/views.py:3107 -msgid "" -"There have been too many attempted OpenID Connect registrations. Try " -"again later." -msgstr "" - #: warehouse/manage/views.py:4144 msgid "Team '${team_name}' already has ${role_name} role for project" msgstr "" @@ -404,51 +420,63 @@ msgstr "" msgid "Could not find role invitation." msgstr "" -#: warehouse/oidc/forms.py:32 +#: warehouse/oidc/forms.py:33 msgid "Specify GitHub repository owner (username or organization)" msgstr "" -#: warehouse/oidc/forms.py:39 +#: warehouse/oidc/forms.py:40 msgid "Specify repository name" msgstr "" -#: warehouse/oidc/forms.py:41 +#: warehouse/oidc/forms.py:42 msgid "Invalid repository name" msgstr "" -#: warehouse/oidc/forms.py:48 +#: warehouse/oidc/forms.py:49 msgid "Specify workflow filename" msgstr "" -#: warehouse/oidc/forms.py:77 +#: warehouse/oidc/forms.py:78 msgid "Unknown GitHub user or organization." msgstr "" -#: warehouse/oidc/forms.py:87 +#: warehouse/oidc/forms.py:88 msgid "GitHub has rate-limited this action. Try again in a few minutes." msgstr "" -#: warehouse/oidc/forms.py:97 +#: warehouse/oidc/forms.py:98 msgid "Unexpected error from GitHub. Try again." msgstr "" -#: warehouse/oidc/forms.py:104 +#: warehouse/oidc/forms.py:105 +msgid "Unexpected connection error from GitHub. Try again in a few minutes." +msgstr "" + +#: warehouse/oidc/forms.py:115 msgid "Unexpected timeout from GitHub. Try again in a few minutes." msgstr "" -#: warehouse/oidc/forms.py:116 +#: warehouse/oidc/forms.py:127 msgid "Invalid GitHub user or organization name." msgstr "" -#: warehouse/oidc/forms.py:132 +#: warehouse/oidc/forms.py:143 msgid "Workflow name must end with .yml or .yaml" msgstr "" -#: warehouse/oidc/forms.py:137 +#: warehouse/oidc/forms.py:148 msgid "Workflow filename must be a filename only, without directories" msgstr "" -#: warehouse/oidc/forms.py:146 +#: warehouse/oidc/forms.py:161 +msgid "Invalid project name" +msgstr "" + +#: warehouse/oidc/forms.py:175 +msgid "This project name is already in use" +msgstr "" + +#: warehouse/oidc/forms.py:188 msgid "Provider must be specified by ID" msgstr "" @@ -537,67 +565,67 @@ msgstr "" #: warehouse/templates/packaging/detail.html:379 #: warehouse/templates/pages/classifiers.html:25 #: warehouse/templates/pages/help.html:20 -#: warehouse/templates/pages/help.html:214 -#: warehouse/templates/pages/help.html:221 -#: warehouse/templates/pages/help.html:235 -#: warehouse/templates/pages/help.html:251 -#: warehouse/templates/pages/help.html:255 -#: warehouse/templates/pages/help.html:312 -#: warehouse/templates/pages/help.html:339 -#: warehouse/templates/pages/help.html:344 -#: warehouse/templates/pages/help.html:349 +#: warehouse/templates/pages/help.html:216 +#: warehouse/templates/pages/help.html:223 +#: warehouse/templates/pages/help.html:237 +#: warehouse/templates/pages/help.html:253 +#: warehouse/templates/pages/help.html:257 +#: warehouse/templates/pages/help.html:314 +#: warehouse/templates/pages/help.html:341 +#: warehouse/templates/pages/help.html:346 #: warehouse/templates/pages/help.html:351 -#: warehouse/templates/pages/help.html:356 -#: warehouse/templates/pages/help.html:357 +#: warehouse/templates/pages/help.html:353 #: warehouse/templates/pages/help.html:358 -#: warehouse/templates/pages/help.html:362 -#: warehouse/templates/pages/help.html:395 +#: warehouse/templates/pages/help.html:359 +#: warehouse/templates/pages/help.html:360 +#: warehouse/templates/pages/help.html:364 #: warehouse/templates/pages/help.html:397 -#: warehouse/templates/pages/help.html:400 -#: warehouse/templates/pages/help.html:436 -#: warehouse/templates/pages/help.html:441 -#: warehouse/templates/pages/help.html:447 -#: warehouse/templates/pages/help.html:505 -#: warehouse/templates/pages/help.html:529 -#: warehouse/templates/pages/help.html:535 -#: warehouse/templates/pages/help.html:538 -#: warehouse/templates/pages/help.html:540 -#: warehouse/templates/pages/help.html:549 -#: warehouse/templates/pages/help.html:571 -#: warehouse/templates/pages/help.html:578 -#: warehouse/templates/pages/help.html:590 -#: warehouse/templates/pages/help.html:591 +#: warehouse/templates/pages/help.html:399 +#: warehouse/templates/pages/help.html:402 +#: warehouse/templates/pages/help.html:438 +#: warehouse/templates/pages/help.html:443 +#: warehouse/templates/pages/help.html:449 +#: warehouse/templates/pages/help.html:507 +#: warehouse/templates/pages/help.html:547 +#: warehouse/templates/pages/help.html:553 +#: warehouse/templates/pages/help.html:556 +#: warehouse/templates/pages/help.html:558 +#: warehouse/templates/pages/help.html:567 +#: warehouse/templates/pages/help.html:589 #: warehouse/templates/pages/help.html:596 -#: warehouse/templates/pages/help.html:621 -#: warehouse/templates/pages/help.html:634 +#: warehouse/templates/pages/help.html:608 +#: warehouse/templates/pages/help.html:609 +#: warehouse/templates/pages/help.html:614 #: warehouse/templates/pages/help.html:639 -#: warehouse/templates/pages/help.html:651 -#: warehouse/templates/pages/help.html:672 -#: warehouse/templates/pages/help.html:696 -#: warehouse/templates/pages/help.html:703 -#: warehouse/templates/pages/help.html:715 -#: warehouse/templates/pages/help.html:726 -#: warehouse/templates/pages/help.html:731 -#: warehouse/templates/pages/help.html:739 -#: warehouse/templates/pages/help.html:750 -#: warehouse/templates/pages/help.html:795 -#: warehouse/templates/pages/help.html:803 -#: warehouse/templates/pages/help.html:819 -#: warehouse/templates/pages/help.html:824 -#: warehouse/templates/pages/help.html:829 -#: warehouse/templates/pages/help.html:839 -#: warehouse/templates/pages/help.html:848 -#: warehouse/templates/pages/help.html:862 -#: warehouse/templates/pages/help.html:870 -#: warehouse/templates/pages/help.html:878 -#: warehouse/templates/pages/help.html:886 -#: warehouse/templates/pages/help.html:895 -#: warehouse/templates/pages/help.html:915 -#: warehouse/templates/pages/help.html:930 -#: warehouse/templates/pages/help.html:931 -#: warehouse/templates/pages/help.html:932 +#: warehouse/templates/pages/help.html:652 +#: warehouse/templates/pages/help.html:657 +#: warehouse/templates/pages/help.html:669 +#: warehouse/templates/pages/help.html:690 +#: warehouse/templates/pages/help.html:714 +#: warehouse/templates/pages/help.html:721 +#: warehouse/templates/pages/help.html:733 +#: warehouse/templates/pages/help.html:744 +#: warehouse/templates/pages/help.html:749 +#: warehouse/templates/pages/help.html:757 +#: warehouse/templates/pages/help.html:768 +#: warehouse/templates/pages/help.html:813 +#: warehouse/templates/pages/help.html:821 +#: warehouse/templates/pages/help.html:837 +#: warehouse/templates/pages/help.html:842 +#: warehouse/templates/pages/help.html:847 +#: warehouse/templates/pages/help.html:857 +#: warehouse/templates/pages/help.html:866 +#: warehouse/templates/pages/help.html:880 +#: warehouse/templates/pages/help.html:888 +#: warehouse/templates/pages/help.html:896 +#: warehouse/templates/pages/help.html:904 +#: warehouse/templates/pages/help.html:913 #: warehouse/templates/pages/help.html:933 -#: warehouse/templates/pages/help.html:938 +#: warehouse/templates/pages/help.html:948 +#: warehouse/templates/pages/help.html:949 +#: warehouse/templates/pages/help.html:950 +#: warehouse/templates/pages/help.html:951 +#: warehouse/templates/pages/help.html:956 #: warehouse/templates/pages/sponsors.html:33 #: warehouse/templates/pages/sponsors.html:37 #: warehouse/templates/pages/sponsors.html:41 @@ -681,7 +709,7 @@ msgstr "" #: warehouse/templates/base.html:41 warehouse/templates/base.html:55 #: warehouse/templates/base.html:271 #: warehouse/templates/includes/current-user-indicator.html:63 -#: warehouse/templates/pages/help.html:108 +#: warehouse/templates/pages/help.html:109 #: warehouse/templates/pages/sitemap.html:27 msgid "Help" msgstr "" @@ -763,9 +791,9 @@ msgstr "" #: warehouse/templates/base.html:187 #: warehouse/templates/includes/flash-messages.html:30 #: warehouse/templates/includes/session-notifications.html:20 -#: warehouse/templates/manage/account.html:716 -#: warehouse/templates/manage/manage_base.html:305 -#: warehouse/templates/manage/manage_base.html:364 +#: warehouse/templates/manage/account.html:728 +#: warehouse/templates/manage/manage_base.html:313 +#: warehouse/templates/manage/manage_base.html:372 #: warehouse/templates/manage/organization/settings.html:194 #: warehouse/templates/manage/organization/settings.html:249 #: warehouse/templates/manage/project/documentation.html:27 @@ -1063,7 +1091,7 @@ msgstr "" #: warehouse/templates/accounts/login.html:69 #: warehouse/templates/accounts/register.html:110 #: warehouse/templates/accounts/reset-password.html:38 -#: warehouse/templates/manage/manage_base.html:373 +#: warehouse/templates/manage/manage_base.html:381 #: warehouse/templates/re-auth.html:49 msgid "Password" msgstr "" @@ -1086,6 +1114,10 @@ msgstr "" #: warehouse/templates/manage/account.html:369 #: warehouse/templates/manage/account.html:386 #: warehouse/templates/manage/account.html:402 +#: warehouse/templates/manage/account/publishing.html:60 +#: warehouse/templates/manage/account/publishing.html:75 +#: warehouse/templates/manage/account/publishing.html:90 +#: warehouse/templates/manage/account/publishing.html:105 #: warehouse/templates/manage/account/recovery_codes-burn.html:70 #: warehouse/templates/manage/account/token.html:133 #: warehouse/templates/manage/account/token.html:150 @@ -1105,9 +1137,9 @@ msgstr "" #: warehouse/templates/manage/organizations.html:204 #: warehouse/templates/manage/organizations.html:222 #: warehouse/templates/manage/organizations.html:241 -#: warehouse/templates/manage/project/publishing.html:85 -#: warehouse/templates/manage/project/publishing.html:100 -#: warehouse/templates/manage/project/publishing.html:115 +#: warehouse/templates/manage/project/publishing.html:53 +#: warehouse/templates/manage/project/publishing.html:68 +#: warehouse/templates/manage/project/publishing.html:83 #: warehouse/templates/manage/project/roles.html:273 #: warehouse/templates/manage/project/roles.html:284 #: warehouse/templates/manage/project/roles.html:296 @@ -1126,7 +1158,7 @@ msgid "Your password" msgstr "" #: warehouse/templates/accounts/login.html:92 -#: warehouse/templates/manage/manage_base.html:376 +#: warehouse/templates/manage/manage_base.html:384 #: warehouse/templates/re-auth.html:72 msgid "Show password" msgstr "" @@ -2278,7 +2310,7 @@ msgstr "" #: warehouse/templates/includes/current-user-indicator.html:37 #: warehouse/templates/manage/manage_base.html:216 -#: warehouse/templates/manage/manage_base.html:244 +#: warehouse/templates/manage/manage_base.html:252 #: warehouse/templates/manage/projects.html:18 #: warehouse/templates/manage/projects.html:62 msgid "Your projects" @@ -2286,7 +2318,7 @@ msgstr "" #: warehouse/templates/includes/current-user-indicator.html:44 #: warehouse/templates/manage/manage_base.html:223 -#: warehouse/templates/manage/manage_base.html:251 +#: warehouse/templates/manage/manage_base.html:259 #: warehouse/templates/manage/organizations.html:18 #: warehouse/templates/manage/organizations.html:63 msgid "Your organizations" @@ -2296,7 +2328,7 @@ msgstr "" #: warehouse/templates/manage/account.html:17 #: warehouse/templates/manage/account/two-factor.html:17 #: warehouse/templates/manage/manage_base.html:230 -#: warehouse/templates/manage/manage_base.html:258 +#: warehouse/templates/manage/manage_base.html:266 msgid "Account settings" msgstr "" @@ -2326,8 +2358,8 @@ msgstr "" #: warehouse/templates/manage/account.html:199 #: warehouse/templates/manage/account.html:201 #: warehouse/templates/manage/account.html:211 -#: warehouse/templates/manage/manage_base.html:294 -#: warehouse/templates/manage/manage_base.html:296 +#: warehouse/templates/manage/manage_base.html:302 +#: warehouse/templates/manage/manage_base.html:304 #: warehouse/templates/manage/project/release.html:137 #: warehouse/templates/manage/project/releases.html:178 #: warehouse/templates/manage/project/settings.html:111 @@ -2521,6 +2553,7 @@ msgid "Documentation" msgstr "" #: warehouse/templates/includes/manage/manage-project-menu.html:52 +#: warehouse/templates/manage/manage_base.html:237 msgid "Publishing" msgstr "" @@ -2578,7 +2611,7 @@ msgstr "" #: warehouse/templates/includes/packaging/project-data.html:84 #: warehouse/templates/includes/packaging/project-data.html:86 -#: warehouse/templates/pages/help.html:582 +#: warehouse/templates/pages/help.html:600 msgid "Maintainer:" msgstr "" @@ -2878,12 +2911,12 @@ msgid "" msgstr "" #: warehouse/templates/manage/account.html:467 -#: warehouse/templates/manage/account.html:637 +#: warehouse/templates/manage/account.html:649 msgid "Token scope: entire account" msgstr "" #: warehouse/templates/manage/account.html:469 -#: warehouse/templates/manage/account.html:639 +#: warehouse/templates/manage/account.html:651 #, python-format msgid "Token scope: Project %(project_name)s" msgstr "" @@ -3026,76 +3059,96 @@ msgstr "" msgid "Password successfully changed" msgstr "" -#: warehouse/templates/manage/account.html:574 -msgid "Two factor authentication added" +#: warehouse/templates/manage/account.html:575 +#: warehouse/templates/manage/account.html:581 +#: warehouse/templates/manage/account/token.html:158 +msgid "Project:" +msgstr "" + +#: warehouse/templates/manage/account.html:576 +#: warehouse/templates/manage/account.html:582 +#: warehouse/templates/manage/project/history.html:223 +#: warehouse/templates/manage/project/history.html:227 +msgid "Provider:" msgstr "" #: warehouse/templates/manage/account.html:577 -#: warehouse/templates/manage/account.html:587 +#: warehouse/templates/manage/account.html:583 +#: warehouse/templates/manage/project/history.html:224 +#: warehouse/templates/manage/project/history.html:228 +msgid "Publisher:" +msgstr "" + +#: warehouse/templates/manage/account.html:586 +msgid "Two factor authentication added" +msgstr "" + +#: warehouse/templates/manage/account.html:589 +#: warehouse/templates/manage/account.html:599 msgid "" "Method: Security device (WebAuthn)" msgstr "" -#: warehouse/templates/manage/account.html:578 -#: warehouse/templates/manage/account.html:588 +#: warehouse/templates/manage/account.html:590 +#: warehouse/templates/manage/account.html:600 msgid "Device name:" msgstr "" -#: warehouse/templates/manage/account.html:580 -#: warehouse/templates/manage/account.html:590 +#: warehouse/templates/manage/account.html:592 +#: warehouse/templates/manage/account.html:602 msgid "" "Method: Authentication application (TOTP)" msgstr "" -#: warehouse/templates/manage/account.html:584 +#: warehouse/templates/manage/account.html:596 msgid "Two factor authentication removed" msgstr "" -#: warehouse/templates/manage/account.html:595 +#: warehouse/templates/manage/account.html:607 msgid "Recovery codes generated" msgstr "" -#: warehouse/templates/manage/account.html:599 +#: warehouse/templates/manage/account.html:611 msgid "Recovery codes regenerated" msgstr "" -#: warehouse/templates/manage/account.html:603 +#: warehouse/templates/manage/account.html:615 msgid "Recovery code used for login" msgstr "" -#: warehouse/templates/manage/account.html:609 +#: warehouse/templates/manage/account.html:621 msgid "API token added" msgstr "" -#: warehouse/templates/manage/account.html:611 -#: warehouse/templates/manage/account.html:634 +#: warehouse/templates/manage/account.html:623 +#: warehouse/templates/manage/account.html:646 #: warehouse/templates/manage/project/history.html:208 #: warehouse/templates/manage/project/history.html:215 msgid "Token name:" msgstr "" -#: warehouse/templates/manage/account.html:628 +#: warehouse/templates/manage/account.html:640 #: warehouse/templates/manage/project/history.html:210 msgid "API token removed" msgstr "" -#: warehouse/templates/manage/account.html:629 -#: warehouse/templates/manage/account.html:635 +#: warehouse/templates/manage/account.html:641 +#: warehouse/templates/manage/account.html:647 msgid "Unique identifier:" msgstr "" -#: warehouse/templates/manage/account.html:632 +#: warehouse/templates/manage/account.html:644 msgid "API token automatically removed for security reasons" msgstr "" -#: warehouse/templates/manage/account.html:641 +#: warehouse/templates/manage/account.html:653 #, python-format msgid "Reason: Token found at public url" msgstr "" -#: warehouse/templates/manage/account.html:650 +#: warehouse/templates/manage/account.html:662 #, python-format msgid "" "Events appear here as security-related actions occur on your account. If " @@ -3103,24 +3156,24 @@ msgid "" "your account as soon as possible." msgstr "" -#: warehouse/templates/manage/account.html:655 +#: warehouse/templates/manage/account.html:667 msgid "Recent account activity" msgstr "" -#: warehouse/templates/manage/account.html:657 +#: warehouse/templates/manage/account.html:669 #: warehouse/templates/manage/organization/history.html:196 #: warehouse/templates/manage/project/history.html:251 #: warehouse/templates/manage/team/history.html:108 msgid "Event" msgstr "" -#: warehouse/templates/manage/account.html:658 -#: warehouse/templates/manage/account.html:666 +#: warehouse/templates/manage/account.html:670 +#: warehouse/templates/manage/account.html:678 msgid "Date / time" msgstr "" -#: warehouse/templates/manage/account.html:659 -#: warehouse/templates/manage/account.html:670 +#: warehouse/templates/manage/account.html:671 +#: warehouse/templates/manage/account.html:682 #: warehouse/templates/manage/organization/history.html:198 #: warehouse/templates/manage/organization/history.html:210 #: warehouse/templates/manage/project/history.html:253 @@ -3130,19 +3183,19 @@ msgstr "" msgid "IP address" msgstr "" -#: warehouse/templates/manage/account.html:678 +#: warehouse/templates/manage/account.html:690 msgid "Events will appear here as security-related actions occur on your account." msgstr "" -#: warehouse/templates/manage/account.html:685 +#: warehouse/templates/manage/account.html:697 msgid "Delete account" msgstr "" -#: warehouse/templates/manage/account.html:688 +#: warehouse/templates/manage/account.html:700 msgid "Cannot delete account" msgstr "" -#: warehouse/templates/manage/account.html:690 +#: warehouse/templates/manage/account.html:702 #, python-format msgid "" "Your account is currently the sole owner of %(count)s " @@ -3153,7 +3206,7 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: warehouse/templates/manage/account.html:695 +#: warehouse/templates/manage/account.html:707 msgid "" "You must transfer ownership or delete this project before you can delete " "your account." @@ -3163,7 +3216,7 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: warehouse/templates/manage/account.html:705 +#: warehouse/templates/manage/account.html:717 #: warehouse/templates/manage/organization/settings.html:238 #, python-format msgid "" @@ -3171,7 +3224,7 @@ msgid "" "href=\"%(delete_href)s\">delete project" msgstr "" -#: warehouse/templates/manage/account.html:714 +#: warehouse/templates/manage/account.html:726 #: warehouse/templates/manage/account/token.html:166 #: warehouse/templates/manage/organization/settings.html:192 #: warehouse/templates/manage/organization/settings.html:247 @@ -3179,11 +3232,11 @@ msgstr "" msgid "Proceed with caution!" msgstr "" -#: warehouse/templates/manage/account.html:717 +#: warehouse/templates/manage/account.html:729 msgid "You will not be able to recover your account after you delete it" msgstr "" -#: warehouse/templates/manage/account.html:719 +#: warehouse/templates/manage/account.html:731 msgid "Delete your PyPI account" msgstr "" @@ -3248,10 +3301,10 @@ msgstr "" #: warehouse/templates/manage/manage_base.html:64 #: warehouse/templates/manage/manage_base.html:78 +#: warehouse/templates/manage/manage_base.html:513 #: warehouse/templates/manage/organization/roles.html:123 #: warehouse/templates/manage/organization/roles.html:125 #: warehouse/templates/manage/organization/roles.html:130 -#: warehouse/templates/manage/project/publishing.html:44 #: warehouse/templates/manage/project/roles.html:89 #: warehouse/templates/manage/project/roles.html:143 #: warehouse/templates/manage/project/roles.html:148 @@ -3376,38 +3429,62 @@ msgid "Your account" msgstr "" #: warehouse/templates/manage/manage_base.html:211 -#: warehouse/templates/manage/manage_base.html:239 +#: warehouse/templates/manage/manage_base.html:247 msgid "Account navigation" msgstr "" -#: warehouse/templates/manage/manage_base.html:306 -#: warehouse/templates/manage/manage_base.html:365 +#: warehouse/templates/manage/manage_base.html:314 +#: warehouse/templates/manage/manage_base.html:373 msgid "This action cannot be undone!" msgstr "" -#: warehouse/templates/manage/manage_base.html:318 +#: warehouse/templates/manage/manage_base.html:326 msgid "Confirm your username to continue." msgstr "" -#: warehouse/templates/manage/manage_base.html:320 +#: warehouse/templates/manage/manage_base.html:328 #, python-format msgid "Confirm the %(item)s to continue." msgstr "" -#: warehouse/templates/manage/manage_base.html:331 -#: warehouse/templates/manage/manage_base.html:383 +#: warehouse/templates/manage/manage_base.html:339 +#: warehouse/templates/manage/manage_base.html:391 #: warehouse/templates/manage/organization/activate_subscription.html:32 msgid "Cancel" msgstr "" -#: warehouse/templates/manage/manage_base.html:354 +#: warehouse/templates/manage/manage_base.html:362 msgid "close" msgstr "" -#: warehouse/templates/manage/manage_base.html:370 +#: warehouse/templates/manage/manage_base.html:378 msgid "Enter your password to continue." msgstr "" +#: warehouse/templates/manage/manage_base.html:460 +msgid "OpenID Connect Publisher Management" +msgstr "" + +#: warehouse/templates/manage/manage_base.html:465 +msgid "" +"OpenID Connect (OIDC) provides a flexible, credential-free mechanism for " +"delegating publishing authority for a PyPI package to a third party " +"service, like GitHub Actions." +msgstr "" + +#: warehouse/templates/manage/manage_base.html:473 +msgid "" +"PyPI users and projects can use trusted publishers to automate their " +"release processes, without needing to use API tokens or passwords." +msgstr "" + +#: warehouse/templates/manage/manage_base.html:480 +#, python-format +msgid "" +"You can read more about OpenID Connect and how to use it here." +msgstr "" + #: warehouse/templates/manage/organizations.html:23 #: warehouse/templates/manage/projects.html:23 msgid "Pending invitations" @@ -3468,10 +3545,11 @@ msgstr "" msgid "Manager" msgstr "" +#: warehouse/templates/manage/account/publishing.html:73 #: warehouse/templates/manage/organization/roles.html:48 #: warehouse/templates/manage/organization/roles.html:93 #: warehouse/templates/manage/organizations.html:85 -#: warehouse/templates/manage/project/publishing.html:83 +#: warehouse/templates/manage/project/publishing.html:51 #: warehouse/templates/manage/project/roles.html:50 #: warehouse/templates/manage/project/roles.html:85 #: warehouse/templates/manage/project/roles.html:115 @@ -3694,6 +3772,106 @@ msgid "" "rel=\"noopener\">Python Packaging User Guide" msgstr "" +#: warehouse/templates/manage/account/publishing.html:30 +msgid "Add a new pending provider" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:33 +msgid "You can use this page to register \"pending\" OpenID Connect providers." +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:40 +#, python-format +msgid "" +"These providers behave similarly to OpenID Connect providers registered " +"against specific projects, except that they allow users to " +"create the project if it doesn't already exist. Once the" +" project is created, the \"pending\" provider becomes an ordinary OpenID " +"Connect provider. You can read more about \"pending\" and ordinary OpenID" +" Connect providers here." +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:58 +msgid "PyPI Project Name" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:63 +msgid "project name" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:65 +msgid "The project (on PyPI) that will be created when this provider is used" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:78 +#: warehouse/templates/manage/project/publishing.html:56 +msgid "owner" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:80 +msgid "The GitHub organization name or GitHub username that owns the repository" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:88 +#: warehouse/templates/manage/project/publishing.html:66 +msgid "Repository name" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:93 +#: warehouse/templates/manage/project/publishing.html:71 +msgid "repository" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:95 +msgid "The name of the GitHub repository that contains the publishing workflow" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:103 +#: warehouse/templates/manage/project/publishing.html:81 +msgid "Workflow name" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:108 +#: warehouse/templates/manage/project/publishing.html:86 +msgid "workflow.yml" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:110 +msgid "" +"The filename of the publishing workflow. This file should exist in the " +".github/workflows/ directory in the repository configured " +"above." +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:117 +#: warehouse/templates/manage/project/publishing.html:95 +#: warehouse/templates/manage/project/roles.html:320 +#: warehouse/templates/manage/team/roles.html:123 +msgid "Add" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:121 +msgid "Manage pending providers" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:126 +msgid "Pending project name" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:127 +#: warehouse/templates/manage/project/publishing.html:107 +msgid "Publisher" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:128 +msgid "Publishing workflow URL" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:139 +#: warehouse/templates/manage/project/publishing.html:119 +msgid "No publishers are currently configured." +msgstr "" + #: warehouse/templates/manage/account/recovery_codes-burn.html:34 msgid "" "In order to verify that you have safely stored your recovery codes for " @@ -3888,10 +4066,6 @@ msgstr "" msgid "Select scope..." msgstr "" -#: warehouse/templates/manage/account/token.html:158 -msgid "Project:" -msgstr "" - #: warehouse/templates/manage/account/token.html:167 msgid "" "An API token scoped to your entire account will have upload permissions " @@ -4801,16 +4975,6 @@ msgstr "" msgid "OpenID Connect provider added" msgstr "" -#: warehouse/templates/manage/project/history.html:223 -#: warehouse/templates/manage/project/history.html:227 -msgid "Provider:" -msgstr "" - -#: warehouse/templates/manage/project/history.html:224 -#: warehouse/templates/manage/project/history.html:228 -msgid "Publisher:" -msgstr "" - #: warehouse/templates/manage/project/history.html:226 msgid "OpenID Connect provider removed" msgstr "" @@ -4840,95 +5004,42 @@ msgstr "" msgid "Back to projects" msgstr "" -#: warehouse/templates/manage/project/publishing.html:20 -#: warehouse/templates/manage/project/publishing.html:51 -msgid "OpenID Connect publisher management" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:54 -msgid "" -"OpenID Connect provides a flexible, credential-free mechanism for " -"delegating publishing authority for a PyPI package to a third party " -"service, like GitHub Actions." -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:62 -msgid "" -"PyPI projects can use trusted OpenID Connect publishers to automate their" -" release processes, without having to explicitly provision or manage API " -"tokens." -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:68 +#: warehouse/templates/manage/project/publishing.html:36 msgid "Add a new provider" msgstr "" -#: warehouse/templates/manage/project/publishing.html:72 +#: warehouse/templates/manage/project/publishing.html:40 #, python-format msgid "" "Read more about GitHub's OpenID Connect provider here." msgstr "" -#: warehouse/templates/manage/project/publishing.html:88 -msgid "owner" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:90 +#: warehouse/templates/manage/project/publishing.html:58 msgid "The organization name or username that owns the repository" msgstr "" -#: warehouse/templates/manage/project/publishing.html:98 -msgid "Repository name" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:103 -msgid "repository" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:105 +#: warehouse/templates/manage/project/publishing.html:73 msgid "The name of the repository that contains the publishing workflow" msgstr "" -#: warehouse/templates/manage/project/publishing.html:113 -msgid "Workflow name" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:118 -msgid "workflow.yml" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:120 +#: warehouse/templates/manage/project/publishing.html:88 msgid "The filename of the publishing workflow" msgstr "" -#: warehouse/templates/manage/project/publishing.html:127 -#: warehouse/templates/manage/project/roles.html:320 -#: warehouse/templates/manage/team/roles.html:123 -msgid "Add" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:131 +#: warehouse/templates/manage/project/publishing.html:99 msgid "Manage current providers" msgstr "" -#: warehouse/templates/manage/project/publishing.html:135 +#: warehouse/templates/manage/project/publishing.html:103 #, python-format msgid "OpenID Connect publishers associated with %(project_name)s" msgstr "" -#: warehouse/templates/manage/project/publishing.html:139 -msgid "Publisher" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:140 +#: warehouse/templates/manage/project/publishing.html:108 msgid "URL" msgstr "" -#: warehouse/templates/manage/project/publishing.html:151 -msgid "No publishers are currently configured." -msgstr "" - #: warehouse/templates/manage/project/release.html:18 #, python-format msgid "Manage '%(project_name)s' – release version %(version)s" @@ -5231,7 +5342,7 @@ msgid "Project Roles" msgstr "" #: warehouse/templates/manage/project/roles.html:46 -#: warehouse/templates/pages/help.html:581 +#: warehouse/templates/pages/help.html:599 msgid "There are two possible roles for collaborators:" msgstr "" @@ -5970,186 +6081,190 @@ msgstr "" msgid "How do I change my PyPI username?" msgstr "" -#: warehouse/templates/pages/help.html:71 -msgid "How can I run a mirror of PyPI?" +#: warehouse/templates/pages/help.html:70 +msgid "How can I use OpenID connect to authenticate with PyPI?" msgstr "" #: warehouse/templates/pages/help.html:72 -msgid "Does PyPI have APIs I can use?" +msgid "How can I run a mirror of PyPI?" msgstr "" #: warehouse/templates/pages/help.html:73 -msgid "How do I get notified when a new version of a project is released?" +msgid "Does PyPI have APIs I can use?" msgstr "" #: warehouse/templates/pages/help.html:74 +msgid "How do I get notified when a new version of a project is released?" +msgstr "" + +#: warehouse/templates/pages/help.html:75 msgid "" "Where can I see statistics about PyPI, downloads, and project/package " "usage?" msgstr "" -#: warehouse/templates/pages/help.html:75 +#: warehouse/templates/pages/help.html:76 msgid "What are the file hashes used for, and how can I verify them?" msgstr "" -#: warehouse/templates/pages/help.html:77 +#: warehouse/templates/pages/help.html:78 msgid "I forgot my PyPI password. Can you help me?" msgstr "" -#: warehouse/templates/pages/help.html:78 +#: warehouse/templates/pages/help.html:79 msgid "I've lost access to my PyPI account. Can you help me?" msgstr "" -#: warehouse/templates/pages/help.html:79 +#: warehouse/templates/pages/help.html:80 msgid "" "Why am I getting a \"Invalid or non-existent authentication " "information.\" error when uploading files?" msgstr "" -#: warehouse/templates/pages/help.html:80 +#: warehouse/templates/pages/help.html:81 msgid "" "Why am I getting \"No matching distribution found\" or \"Could not fetch " "URL\" errors during pip install?" msgstr "" -#: warehouse/templates/pages/help.html:81 +#: warehouse/templates/pages/help.html:82 msgid "I am having trouble using the PyPI website. Can you help me?" msgstr "" -#: warehouse/templates/pages/help.html:82 +#: warehouse/templates/pages/help.html:83 msgid "Why can't I manually upload files to PyPI, through the browser interface?" msgstr "" -#: warehouse/templates/pages/help.html:83 +#: warehouse/templates/pages/help.html:84 msgid "How can I publish my private packages to PyPI?" msgstr "" -#: warehouse/templates/pages/help.html:84 +#: warehouse/templates/pages/help.html:85 msgid "Why did my package or user registration get blocked?" msgstr "" -#: warehouse/templates/pages/help.html:85 +#: warehouse/templates/pages/help.html:86 msgid "How do I get a file size limit exemption or increase for my project?" msgstr "" -#: warehouse/templates/pages/help.html:86 +#: warehouse/templates/pages/help.html:87 msgid "" "How do I get a total project size limit exemption or increase for my " "project?" msgstr "" -#: warehouse/templates/pages/help.html:87 +#: warehouse/templates/pages/help.html:88 msgid "" "Where does PyPI get its data on project vulnerabilities from, and how can" " I correct it?" msgstr "" -#: warehouse/templates/pages/help.html:88 +#: warehouse/templates/pages/help.html:89 msgid "Why am I getting \"the description failed to render\" error?" msgstr "" -#: warehouse/templates/pages/help.html:90 +#: warehouse/templates/pages/help.html:91 msgid "" "Why am I getting a \"Filename or contents already exists\" or \"Filename " "has been previously used\" error?" msgstr "" -#: warehouse/templates/pages/help.html:91 +#: warehouse/templates/pages/help.html:92 msgid "Why isn't my desired project name available?" msgstr "" -#: warehouse/templates/pages/help.html:92 +#: warehouse/templates/pages/help.html:93 msgid "How do I claim an abandoned or previously registered project name?" msgstr "" -#: warehouse/templates/pages/help.html:93 +#: warehouse/templates/pages/help.html:94 msgid "What collaborator roles are available for a project on PyPI?" msgstr "" -#: warehouse/templates/pages/help.html:94 +#: warehouse/templates/pages/help.html:95 msgid "How do I become an owner/maintainer of a project on PyPI?" msgstr "" -#: warehouse/templates/pages/help.html:95 +#: warehouse/templates/pages/help.html:96 msgid "How can I upload a project description in a different format?" msgstr "" -#: warehouse/templates/pages/help.html:96 +#: warehouse/templates/pages/help.html:97 msgid "How do I request a new trove classifier?" msgstr "" -#: warehouse/templates/pages/help.html:97 +#: warehouse/templates/pages/help.html:98 msgid "Where can I report a bug or provide feedback about PyPI?" msgstr "" -#: warehouse/templates/pages/help.html:99 +#: warehouse/templates/pages/help.html:100 msgid "Who maintains PyPI?" msgstr "" -#: warehouse/templates/pages/help.html:100 +#: warehouse/templates/pages/help.html:101 msgid "What powers PyPI?" msgstr "" -#: warehouse/templates/pages/help.html:101 +#: warehouse/templates/pages/help.html:102 msgid "Can I depend on PyPI being available?" msgstr "" -#: warehouse/templates/pages/help.html:102 +#: warehouse/templates/pages/help.html:103 msgid "How can I contribute to PyPI?" msgstr "" -#: warehouse/templates/pages/help.html:103 +#: warehouse/templates/pages/help.html:104 msgid "How do I keep up with upcoming changes to PyPI?" msgstr "" -#: warehouse/templates/pages/help.html:104 +#: warehouse/templates/pages/help.html:105 msgid "How can I get a list of PyPI's IP addresses?" msgstr "" -#: warehouse/templates/pages/help.html:105 +#: warehouse/templates/pages/help.html:106 msgid "" "What does the \"beta feature\" badge mean? What are Warehouse's current " "beta features?" msgstr "" -#: warehouse/templates/pages/help.html:106 +#: warehouse/templates/pages/help.html:107 msgid "How do I pronounce \"PyPI\"?" msgstr "" -#: warehouse/templates/pages/help.html:113 +#: warehouse/templates/pages/help.html:114 msgid "Common questions" msgstr "" -#: warehouse/templates/pages/help.html:116 -#: warehouse/templates/pages/help.html:202 +#: warehouse/templates/pages/help.html:117 +#: warehouse/templates/pages/help.html:204 msgid "Basics" msgstr "" -#: warehouse/templates/pages/help.html:127 +#: warehouse/templates/pages/help.html:128 msgid "My Account" msgstr "" -#: warehouse/templates/pages/help.html:145 -#: warehouse/templates/pages/help.html:526 +#: warehouse/templates/pages/help.html:147 +#: warehouse/templates/pages/help.html:544 msgid "Integrating" msgstr "" -#: warehouse/templates/pages/help.html:156 -#: warehouse/templates/pages/help.html:563 +#: warehouse/templates/pages/help.html:158 +#: warehouse/templates/pages/help.html:581 msgid "Administration of projects on PyPI" msgstr "" -#: warehouse/templates/pages/help.html:171 -#: warehouse/templates/pages/help.html:647 +#: warehouse/templates/pages/help.html:173 +#: warehouse/templates/pages/help.html:665 msgid "Troubleshooting" msgstr "" -#: warehouse/templates/pages/help.html:188 -#: warehouse/templates/pages/help.html:815 +#: warehouse/templates/pages/help.html:190 +#: warehouse/templates/pages/help.html:833 msgid "About" msgstr "" -#: warehouse/templates/pages/help.html:205 +#: warehouse/templates/pages/help.html:207 #, python-format msgid "" "

We use a number of terms to describe software available on PyPI, like " @@ -6169,7 +6284,7 @@ msgid "" "href=\"%(wheel_href)s\">wheel.

" msgstr "" -#: warehouse/templates/pages/help.html:214 +#: warehouse/templates/pages/help.html:216 #, python-format msgid "" "To learn how to install a file from PyPI, visit the Python Packaging User Guide." msgstr "" -#: warehouse/templates/pages/help.html:221 +#: warehouse/templates/pages/help.html:223 #, python-format msgid "" "For full instructions on configuring, packaging and distributing your " @@ -6189,7 +6304,7 @@ msgid "" "target=\"_blank\" rel=\"noopener\">Python Packaging User Guide." msgstr "" -#: warehouse/templates/pages/help.html:228 +#: warehouse/templates/pages/help.html:230 #, python-format msgid "" "Classifiers are used to categorize projects on PyPI. See PyPI itself has not suffered a breach. This is a protective measure " @@ -6277,7 +6392,7 @@ msgid "" "href=\"%(reset_pwd_href)s\">reset your password.

" msgstr "" -#: warehouse/templates/pages/help.html:290 +#: warehouse/templates/pages/help.html:292 #, python-format msgid "" "

All PyPI user events are stored under security history in account " @@ -6287,7 +6402,7 @@ msgid "" "href=\"mailto:%(admin_email)s\">%(admin_email)s

" msgstr "" -#: warehouse/templates/pages/help.html:302 +#: warehouse/templates/pages/help.html:304 msgid "" "

A PyPI API token linked to your account was posted on a public " "website. It was automatically revoked, but before regenerating a new one," @@ -6296,7 +6411,7 @@ msgid "" "applies too.

" msgstr "" -#: warehouse/templates/pages/help.html:312 +#: warehouse/templates/pages/help.html:314 #, python-format msgid "" "

Two factor authentication (2FA) makes your account more secure by " @@ -6315,7 +6430,7 @@ msgid "" "rel=\"noopener\">discuss.python.org.

" msgstr "" -#: warehouse/templates/pages/help.html:339 +#: warehouse/templates/pages/help.html:341 #, python-format msgid "" "PyPI users can set up two-factor authentication using any authentication " @@ -6324,21 +6439,21 @@ msgid "" "password\">TOTP standard." msgstr "" -#: warehouse/templates/pages/help.html:340 +#: warehouse/templates/pages/help.html:342 msgid "" "TOTP authentication " "applications generate a regularly changing authentication code to use " "when logging into your account." msgstr "" -#: warehouse/templates/pages/help.html:341 +#: warehouse/templates/pages/help.html:343 msgid "" "Because TOTP is an " "open standard, there are many applications that are compatible with your " "PyPI account. Popular applications include:" msgstr "" -#: warehouse/templates/pages/help.html:344 +#: warehouse/templates/pages/help.html:346 #, python-format msgid "" "Google Authenticator for iOS" msgstr "" -#: warehouse/templates/pages/help.html:347 #: warehouse/templates/pages/help.html:349 -#: warehouse/templates/pages/help.html:354 +#: warehouse/templates/pages/help.html:351 #: warehouse/templates/pages/help.html:356 +#: warehouse/templates/pages/help.html:358 msgid "(proprietary)" msgstr "" -#: warehouse/templates/pages/help.html:351 +#: warehouse/templates/pages/help.html:353 #, python-format msgid "" "Duo Mobile for iOS" msgstr "" -#: warehouse/templates/pages/help.html:357 -#: warehouse/templates/pages/help.html:358 +#: warehouse/templates/pages/help.html:359 +#: warehouse/templates/pages/help.html:360 msgid "(open source)" msgstr "" -#: warehouse/templates/pages/help.html:362 +#: warehouse/templates/pages/help.html:364 #, python-format msgid "" "Some password managers (e.g. 2FA with an " "authentication application:" msgstr "" -#: warehouse/templates/pages/help.html:372 +#: warehouse/templates/pages/help.html:374 msgid "" "Open an authentication (TOTP) application" msgstr "" -#: warehouse/templates/pages/help.html:373 +#: warehouse/templates/pages/help.html:375 msgid "" "Log in to your PyPI account, go to your account settings, and choose " "\"Add 2FA with " "authentication application\"" msgstr "" -#: warehouse/templates/pages/help.html:374 +#: warehouse/templates/pages/help.html:376 msgid "" "PyPI will generate a secret key, specific to your account. This is " "displayed as a QR code, and as a text code." msgstr "" -#: warehouse/templates/pages/help.html:375 +#: warehouse/templates/pages/help.html:377 msgid "" "Scan the QR code with your authentication application, or type it in " "manually. The method of input will depend on the application you have " "chosen." msgstr "" -#: warehouse/templates/pages/help.html:376 +#: warehouse/templates/pages/help.html:378 msgid "" "Your application will generate an authentication code - use this to " "verify your set up on PyPI" msgstr "" -#: warehouse/templates/pages/help.html:379 +#: warehouse/templates/pages/help.html:381 msgid "" "The PyPI server and your application now share your PyPI secret key, " "allowing your application to generate valid authentication codes for your" " PyPI account." msgstr "" -#: warehouse/templates/pages/help.html:381 -#: warehouse/templates/pages/help.html:423 +#: warehouse/templates/pages/help.html:383 +#: warehouse/templates/pages/help.html:425 msgid "Next time you log in to PyPI you'll need to:" msgstr "" -#: warehouse/templates/pages/help.html:383 -#: warehouse/templates/pages/help.html:475 +#: warehouse/templates/pages/help.html:385 +#: warehouse/templates/pages/help.html:477 msgid "Provide your username and password, as normal" msgstr "" -#: warehouse/templates/pages/help.html:384 +#: warehouse/templates/pages/help.html:386 msgid "Open your authentication application to generate an authentication code" msgstr "" -#: warehouse/templates/pages/help.html:385 +#: warehouse/templates/pages/help.html:387 msgid "Use this code to finish logging into PyPI" msgstr "" -#: warehouse/templates/pages/help.html:391 +#: warehouse/templates/pages/help.html:393 msgid "" "A security device is a USB key or other " "device that generates a one-time password and sends that password to " @@ -6448,11 +6563,11 @@ msgid "" "user." msgstr "" -#: warehouse/templates/pages/help.html:393 +#: warehouse/templates/pages/help.html:395 msgid "To set up two factor authentication with a USB key, you'll need:" msgstr "" -#: warehouse/templates/pages/help.html:395 +#: warehouse/templates/pages/help.html:397 #, python-format msgid "" "To use a :" msgstr "" -#: warehouse/templates/pages/help.html:400 +#: warehouse/templates/pages/help.html:402 #, python-format msgid "" "Popular keys include Thetis." msgstr "" -#: warehouse/templates/pages/help.html:407 +#: warehouse/templates/pages/help.html:409 msgid "" "Note that some older Yubico USB keys do not follow the FIDO " "specification, and will therefore not work with PyPI" msgstr "" -#: warehouse/templates/pages/help.html:412 +#: warehouse/templates/pages/help.html:414 msgid "Follow these steps:" msgstr "" -#: warehouse/templates/pages/help.html:414 +#: warehouse/templates/pages/help.html:416 msgid "" "
  • Log in to your PyPI account, go to your account settings, and choose " "\"Add 2FA with security " @@ -6503,19 +6618,19 @@ msgid "" "
  • Insert and touch your USB key, as instructed by your browser
  • " msgstr "" -#: warehouse/templates/pages/help.html:421 +#: warehouse/templates/pages/help.html:423 msgid "" "Once complete, your USB key will be registered to your PyPI account and " "can be used during the log in process." msgstr "" -#: warehouse/templates/pages/help.html:425 +#: warehouse/templates/pages/help.html:427 msgid "" "
  • Provide your username and password, as normal
  • Insert and " "touch your USB key to finish logging into PyPI
  • " msgstr "" -#: warehouse/templates/pages/help.html:436 +#: warehouse/templates/pages/help.html:438 #, python-format msgid "" "There is a growing ecosystem of mobile phones to act as security devices." msgstr "" -#: warehouse/templates/pages/help.html:447 +#: warehouse/templates/pages/help.html:449 #, python-format msgid "" "As PyPI's two factor implementation follows the authentication " "application or security device, you can use " "these codes to sign into PyPI." msgstr "" -#: warehouse/templates/pages/help.html:459 +#: warehouse/templates/pages/help.html:461 msgid "" "Recovery codes are one time use. They are not a " "substitute for a authentication application or API tokens provide an alternative way (instead of username and " "password) to authenticate when uploading packages to " @@ -6613,41 +6728,41 @@ msgid "" " possible.

    " msgstr "" -#: warehouse/templates/pages/help.html:490 +#: warehouse/templates/pages/help.html:492 msgid "To make an API token:" msgstr "" -#: warehouse/templates/pages/help.html:493 +#: warehouse/templates/pages/help.html:495 msgid "Verify your email address" msgstr "" -#: warehouse/templates/pages/help.html:493 +#: warehouse/templates/pages/help.html:495 #, python-format msgid "(check your
    account settings)" msgstr "" -#: warehouse/templates/pages/help.html:494 +#: warehouse/templates/pages/help.html:496 #, python-format msgid "" "In your account settings, go to the API tokens " "section and select \"Add API token\"" msgstr "" -#: warehouse/templates/pages/help.html:497 +#: warehouse/templates/pages/help.html:499 msgid "To use an API token:" msgstr "" -#: warehouse/templates/pages/help.html:500 +#: warehouse/templates/pages/help.html:502 msgid "Set your username to __token__" msgstr "" -#: warehouse/templates/pages/help.html:501 +#: warehouse/templates/pages/help.html:503 msgid "" "Set your password to the token value, including the pypi- " "prefix" msgstr "" -#: warehouse/templates/pages/help.html:505 +#: warehouse/templates/pages/help.html:507 #, python-format msgid "" "Where you edit or add these values will depend on your individual use " @@ -6659,14 +6774,14 @@ msgid "" "rel=\"noopener\">.travis.yml if you are using Travis)." msgstr "" -#: warehouse/templates/pages/help.html:509 +#: warehouse/templates/pages/help.html:511 msgid "" "Advanced users may wish to inspect their token by decoding it with " "base64, and checking the output against the unique identifier displayed " "on PyPI." msgstr "" -#: warehouse/templates/pages/help.html:513 +#: warehouse/templates/pages/help.html:515 msgid "" "

    PyPI asks you to confirm your password before you want to perform a " "sensitive action. Sensitive actions include things like adding or " @@ -6677,26 +6792,43 @@ msgid "" "actions on your personal, password-protected computer.

    " msgstr "" -#: warehouse/templates/pages/help.html:520 +#: warehouse/templates/pages/help.html:522 msgid "PyPI does not currently support changing a username." msgstr "" -#: warehouse/templates/pages/help.html:521 +#: warehouse/templates/pages/help.html:523 msgid "" "Instead, you can create a new account with the desired username, add the " "new account as a maintainer of all the projects your old account owns, " "and then delete the old account, which will have the same effect." msgstr "" -#: warehouse/templates/pages/help.html:529 +#: warehouse/templates/pages/help.html:527 +#, python-format +msgid "" +"PyPI users and projects can use OpenID Connect" +" (OIDC) to delegate publishing authority for a PyPI package to a trusted " +"third party service, eliminating the need to use API tokens or passwords." +msgstr "" + +#: warehouse/templates/pages/help.html:534 +msgid "" +"Using OIDC to authenticate when publishing is done by registering " +"\"providers,\" which correspond to services with OIDC identities like " +"GitHub Actions. Existing projects can add or remove OIDC providers at any" +" time. OIDC providers can also be created in advance for projects that " +"don't exist yet." +msgstr "" + +#: warehouse/templates/pages/help.html:547 msgid "Yes, including RSS feeds of new packages and new releases." msgstr "" -#: warehouse/templates/pages/help.html:529 +#: warehouse/templates/pages/help.html:547 msgid "See the API reference." msgstr "" -#: warehouse/templates/pages/help.html:532 +#: warehouse/templates/pages/help.html:550 #, python-format msgid "" "If you need to run your own mirror of PyPI, the GitHub apps." msgstr "" -#: warehouse/templates/pages/help.html:538 +#: warehouse/templates/pages/help.html:556 #, python-format msgid "" "You can analyze PyPI project/package metadata and via our public dataset on Google BigQuery." msgstr "" -#: warehouse/templates/pages/help.html:540 +#: warehouse/templates/pages/help.html:558 #, python-format msgid "" "other relevant factors." msgstr "" -#: warehouse/templates/pages/help.html:549 +#: warehouse/templates/pages/help.html:567 #, python-format msgid "" "For recent statistics on uptime and performance, see ." msgstr "" -#: warehouse/templates/pages/help.html:552 +#: warehouse/templates/pages/help.html:570 msgid "" "For each package hosted on PyPI, there are corresponding hashes for that " "file. These hashes can be used to verify that the file you are " @@ -6757,7 +6889,7 @@ msgid "" "from the JSON API. Here is an example of generating the hashes:" msgstr "" -#: warehouse/templates/pages/help.html:559 +#: warehouse/templates/pages/help.html:577 msgid "" "In practice, it would only be necessary to verify one of the hashes. It " "is not recommended to use the MD5 hash because of known security issues " @@ -6765,7 +6897,7 @@ msgid "" " only." msgstr "" -#: warehouse/templates/pages/help.html:566 +#: warehouse/templates/pages/help.html:584 #, python-format msgid "" "PyPI does not support publishing private packages. If you need to publish" @@ -6773,7 +6905,7 @@ msgid "" "run your own deployment of the devpi project." msgstr "" -#: warehouse/templates/pages/help.html:569 +#: warehouse/templates/pages/help.html:587 msgid "" "Your publishing tool may return an error that your new project can't be " "created with your desired name, despite no evidence of a project or " @@ -6781,7 +6913,7 @@ msgid "" "reasons this may occur:" msgstr "" -#: warehouse/templates/pages/help.html:571 +#: warehouse/templates/pages/help.html:589 #, python-format msgid "" "The project name conflicts with a module from any major version from 2.5 to present." msgstr "" -#: warehouse/templates/pages/help.html:572 +#: warehouse/templates/pages/help.html:590 msgid "" "The project name is too similar to an existing project and may be " "confusable." msgstr "" -#: warehouse/templates/pages/help.html:573 +#: warehouse/templates/pages/help.html:591 #, python-format msgid "" "The project name has been explicitly prohibited by the PyPI " @@ -6804,18 +6936,18 @@ msgid "" "with a malicious package." msgstr "" -#: warehouse/templates/pages/help.html:574 +#: warehouse/templates/pages/help.html:592 msgid "" "The project name has been registered by another user, but no releases " "have been created." msgstr "" -#: warehouse/templates/pages/help.html:574 +#: warehouse/templates/pages/help.html:592 #, python-format msgid "See %(anchor_text)s" msgstr "" -#: warehouse/templates/pages/help.html:578 +#: warehouse/templates/pages/help.html:596 #, python-format msgid "" "Follow the PEP 541." msgstr "" -#: warehouse/templates/pages/help.html:582 +#: warehouse/templates/pages/help.html:600 msgid "" "Can upload releases for a package. Cannot add collaborators. Cannot " "delete files, releases, or the project." msgstr "" -#: warehouse/templates/pages/help.html:583 +#: warehouse/templates/pages/help.html:601 msgid "Owner:" msgstr "" -#: warehouse/templates/pages/help.html:583 +#: warehouse/templates/pages/help.html:601 msgid "" "Can upload releases. Can add other collaborators. Can delete files, " "releases, or the entire project." msgstr "" -#: warehouse/templates/pages/help.html:586 +#: warehouse/templates/pages/help.html:604 msgid "" "Only the current owners of a project have the ability to add new owners " "or maintainers. If you need to request ownership, you should contact the " @@ -6848,12 +6980,12 @@ msgid "" "project page." msgstr "" -#: warehouse/templates/pages/help.html:587 +#: warehouse/templates/pages/help.html:605 #, python-format msgid "If the owner is unresponsive, see %(anchor_text)s" msgstr "" -#: warehouse/templates/pages/help.html:590 +#: warehouse/templates/pages/help.html:608 #, python-format msgid "" "By default, an upload's description will render with file an issue and tell us:" msgstr "" -#: warehouse/templates/pages/help.html:605 -#: warehouse/templates/pages/help.html:626 +#: warehouse/templates/pages/help.html:623 +#: warehouse/templates/pages/help.html:644 msgid "A link to your project on PyPI (or Test PyPI)" msgstr "" -#: warehouse/templates/pages/help.html:606 +#: warehouse/templates/pages/help.html:624 msgid "The size of your release, in megabytes" msgstr "" -#: warehouse/templates/pages/help.html:607 +#: warehouse/templates/pages/help.html:625 msgid "Which index/indexes you need the increase for (PyPI, Test PyPI, or both)" msgstr "" -#: warehouse/templates/pages/help.html:608 -#: warehouse/templates/pages/help.html:628 +#: warehouse/templates/pages/help.html:626 +#: warehouse/templates/pages/help.html:646 msgid "" "A brief description of your project, including the reason for the " "additional size." msgstr "" -#: warehouse/templates/pages/help.html:614 +#: warehouse/templates/pages/help.html:632 msgid "" "If you can't upload your project's release to PyPI because you're hitting" " the project size limit, first remove any unnecessary releases or " "individual files to lower your overall project size." msgstr "" -#: warehouse/templates/pages/help.html:621 +#: warehouse/templates/pages/help.html:639 #, python-format msgid "" "If that is not possible, we can sometimes increase your limit. File an issue and tell us:" msgstr "" -#: warehouse/templates/pages/help.html:627 +#: warehouse/templates/pages/help.html:645 msgid "The total size of your project, in gigabytes" msgstr "" -#: warehouse/templates/pages/help.html:634 +#: warehouse/templates/pages/help.html:652 #, python-format msgid "" "PyPI receives reports on vulnerabilities in the packages hosted on it " @@ -6935,7 +7067,7 @@ msgid "" "Advisory Database." msgstr "" -#: warehouse/templates/pages/help.html:639 +#: warehouse/templates/pages/help.html:657 #, python-format msgid "" "If you believe vulnerability data for your project is invalid or " @@ -6943,7 +7075,7 @@ msgid "" "target=\"_blank\" rel=\"noopener\">file an issue with details." msgstr "" -#: warehouse/templates/pages/help.html:651 +#: warehouse/templates/pages/help.html:669 #, python-format msgid "" "PyPI will reject uploads if the package description fails to render. You " @@ -6951,41 +7083,41 @@ msgid "" "command to locally check a description for validity." msgstr "" -#: warehouse/templates/pages/help.html:657 +#: warehouse/templates/pages/help.html:675 msgid "" "If you've forgotten your PyPI password but you remember your email " "address or username, follow these steps to reset your password:" msgstr "" -#: warehouse/templates/pages/help.html:659 +#: warehouse/templates/pages/help.html:677 #, python-format msgid "Go to reset your password." msgstr "" -#: warehouse/templates/pages/help.html:660 +#: warehouse/templates/pages/help.html:678 msgid "Enter the email address or username you used for PyPI and submit the form." msgstr "" -#: warehouse/templates/pages/help.html:661 +#: warehouse/templates/pages/help.html:679 msgid "You'll receive an email with a password reset link." msgstr "" -#: warehouse/templates/pages/help.html:666 +#: warehouse/templates/pages/help.html:684 msgid "If you've lost access to your PyPI account due to:" msgstr "" -#: warehouse/templates/pages/help.html:668 +#: warehouse/templates/pages/help.html:686 msgid "Lost access to the email address associated with your account" msgstr "" -#: warehouse/templates/pages/help.html:669 +#: warehouse/templates/pages/help.html:687 msgid "" "Lost two factor authentication application, device, and recovery " "codes" msgstr "" -#: warehouse/templates/pages/help.html:672 +#: warehouse/templates/pages/help.html:690 #, python-format msgid "" "You can proceed to API Token for uploads:" msgstr "" -#: warehouse/templates/pages/help.html:686 +#: warehouse/templates/pages/help.html:704 msgid "Ensure that your API Token is valid and has not been revoked." msgstr "" -#: warehouse/templates/pages/help.html:687 +#: warehouse/templates/pages/help.html:705 msgid "" "Ensure that your API Token is properly " "formatted and does not contain any trailing characters such as " "newlines." msgstr "" -#: warehouse/templates/pages/help.html:688 +#: warehouse/templates/pages/help.html:706 msgid "Ensure that the username you are using is __token__." msgstr "" -#: warehouse/templates/pages/help.html:690 +#: warehouse/templates/pages/help.html:708 msgid "" "In both cases, remember that PyPI and TestPyPI each require you to create" " an account, so your credentials may be different." msgstr "" -#: warehouse/templates/pages/help.html:692 +#: warehouse/templates/pages/help.html:710 msgid "" "If you're using Windows and trying to paste your password or token in the" " Command Prompt or PowerShell, note that Ctrl-V and Shift+Insert won't " @@ -7040,7 +7172,7 @@ msgid "" "enable \"Use Ctrl+Shift+C/V as Copy/Paste\" in \"Properties\"." msgstr "" -#: warehouse/templates/pages/help.html:696 +#: warehouse/templates/pages/help.html:714 #, python-format msgid "" "This is a Learn why on the PSF blog." msgstr "" -#: warehouse/templates/pages/help.html:710 +#: warehouse/templates/pages/help.html:728 #, python-format msgid "" "If you are having trouble with %(command)s and get a " @@ -7069,7 +7201,7 @@ msgid "" "information:" msgstr "" -#: warehouse/templates/pages/help.html:712 +#: warehouse/templates/pages/help.html:730 msgid "" "If you see an error like There was a problem confirming the ssl " "certificate or tlsv1 alert protocol version or " @@ -7077,7 +7209,7 @@ msgid "" "PyPI with a newer TLS support library." msgstr "" -#: warehouse/templates/pages/help.html:713 +#: warehouse/templates/pages/help.html:731 msgid "" "The specific steps you need to take will depend on your operating system " "version, where your installation of Python originated (python.org, your " @@ -7085,7 +7217,7 @@ msgid "" " Python, setuptools, and pip." msgstr "" -#: warehouse/templates/pages/help.html:715 +#: warehouse/templates/pages/help.html:733 #, python-format msgid "" "For help, go to %(command)s." msgstr "" -#: warehouse/templates/pages/help.html:726 +#: warehouse/templates/pages/help.html:744 #, python-format msgid "" "We take , so we can try to fix the problem, for you and others." msgstr "" -#: warehouse/templates/pages/help.html:739 +#: warehouse/templates/pages/help.html:757 #, python-format msgid "" "In a previous version of PyPI, it used to be possible for maintainers to " @@ -7124,7 +7256,7 @@ msgid "" "rel=\"noopener\">use twine to upload your project to PyPI." msgstr "" -#: warehouse/templates/pages/help.html:748 +#: warehouse/templates/pages/help.html:766 msgid "" "Spammers return to PyPI with some regularity hoping to place their Search" " Engine Optimized phishing, scam, and click-farming content on the site. " @@ -7133,7 +7265,7 @@ msgid "" "prime target." msgstr "" -#: warehouse/templates/pages/help.html:750 +#: warehouse/templates/pages/help.html:768 #, python-format msgid "" "When the PyPI administrators are overwhelmed by spam or " @@ -7144,35 +7276,35 @@ msgid "" "have updated it with reasoning for the intervention." msgstr "" -#: warehouse/templates/pages/help.html:759 +#: warehouse/templates/pages/help.html:777 msgid "PyPI will return these errors for one of these reasons:" msgstr "" -#: warehouse/templates/pages/help.html:761 +#: warehouse/templates/pages/help.html:779 msgid "Filename has been used and file exists" msgstr "" -#: warehouse/templates/pages/help.html:762 +#: warehouse/templates/pages/help.html:780 msgid "Filename has been used but file no longer exists" msgstr "" -#: warehouse/templates/pages/help.html:763 +#: warehouse/templates/pages/help.html:781 msgid "A file with the exact same content exists" msgstr "" -#: warehouse/templates/pages/help.html:766 +#: warehouse/templates/pages/help.html:784 msgid "" "PyPI does not allow for a filename to be reused, even once a project has " "been deleted and recreated." msgstr "" -#: warehouse/templates/pages/help.html:772 +#: warehouse/templates/pages/help.html:790 msgid "" "A distribution filename on PyPI consists of the combination of project " "name, version number, and distribution type." msgstr "" -#: warehouse/templates/pages/help.html:778 +#: warehouse/templates/pages/help.html:796 msgid "" "This ensures that a given distribution for a given release for a given " "project will always resolve to the same file, and cannot be " @@ -7180,14 +7312,14 @@ msgid "" " party (it can only be removed)." msgstr "" -#: warehouse/templates/pages/help.html:786 +#: warehouse/templates/pages/help.html:804 msgid "" "To avoid this situation in most cases, you will need to change the " "version number to one that you haven't previously uploaded to PyPI, " "rebuild the distribution, and then upload the new distribution." msgstr "" -#: warehouse/templates/pages/help.html:795 +#: warehouse/templates/pages/help.html:813 #, python-format msgid "" "If you would like to request a new trove classifier file a pull request " @@ -7196,7 +7328,7 @@ msgid "" " to include a brief justification of why it is important." msgstr "" -#: warehouse/templates/pages/help.html:803 +#: warehouse/templates/pages/help.html:821 #, python-format msgid "" "If you're experiencing an issue with PyPI itself, we welcome " @@ -7207,14 +7339,14 @@ msgid "" " first check that a similar issue does not already exist." msgstr "" -#: warehouse/templates/pages/help.html:810 +#: warehouse/templates/pages/help.html:828 msgid "" "If you are having an issue is with a specific package installed from " "PyPI, you should reach out to the maintainers of that project directly " "instead." msgstr "" -#: warehouse/templates/pages/help.html:819 +#: warehouse/templates/pages/help.html:837 #, python-format msgid "" "PyPI is powered by the Warehouse project; ." msgstr "" -#: warehouse/templates/pages/help.html:846 +#: warehouse/templates/pages/help.html:864 msgid "" "As of April 16, 2018, PyPI.org is at \"production\" status, meaning that " "it has moved out of beta and replaced the old site (pypi.python.org). It " "is now robust, tested, and ready for expected browser and API traffic." msgstr "" -#: warehouse/templates/pages/help.html:848 +#: warehouse/templates/pages/help.html:866 #, python-format msgid "" "PyPI is heavily cached and distributed via private index." msgstr "" -#: warehouse/templates/pages/help.html:862 +#: warehouse/templates/pages/help.html:880 #, python-format msgid "" "We have a huge amount of work to do to continue to maintain and improve " @@ -7287,22 +7419,22 @@ msgid "" "target=\"_blank\" rel=\"noopener\">the Warehouse project)." msgstr "" -#: warehouse/templates/pages/help.html:867 +#: warehouse/templates/pages/help.html:885 msgid "Financial:" msgstr "" -#: warehouse/templates/pages/help.html:867 +#: warehouse/templates/pages/help.html:885 #, python-format msgid "" "We would deeply appreciate your donations to fund " "development and maintenance." msgstr "" -#: warehouse/templates/pages/help.html:868 +#: warehouse/templates/pages/help.html:886 msgid "Development:" msgstr "" -#: warehouse/templates/pages/help.html:868 +#: warehouse/templates/pages/help.html:886 msgid "" "Warehouse is open source, and we would love to see some new faces working" " on the project. You do not need to be an experienced " @@ -7310,7 +7442,7 @@ msgid "" " you make your first open source pull request!" msgstr "" -#: warehouse/templates/pages/help.html:870 +#: warehouse/templates/pages/help.html:888 #, python-format msgid "" "If you have skills in Python, ElasticSearch, HTML, SCSS, JavaScript, or " @@ -7324,7 +7456,7 @@ msgid "" "here." msgstr "" -#: warehouse/templates/pages/help.html:878 +#: warehouse/templates/pages/help.html:896 #, python-format msgid "" "Issues are grouped into Python packaging forum on Discourse." msgstr "" -#: warehouse/templates/pages/help.html:895 +#: warehouse/templates/pages/help.html:913 #, python-format msgid "" "Changes to PyPI are generally announced on both the %(href)s." msgstr "" -#: warehouse/templates/pages/help.html:906 +#: warehouse/templates/pages/help.html:924 #, python-format msgid "" "More information about this list can be found here: %(href)s." msgstr "" -#: warehouse/templates/pages/help.html:910 +#: warehouse/templates/pages/help.html:928 msgid "" "When Warehouse's maintainers are deploying new features, at first we mark" " them with a small \"beta feature\" symbol to tell you: this should " @@ -7382,11 +7514,11 @@ msgid "" "functionality." msgstr "" -#: warehouse/templates/pages/help.html:911 +#: warehouse/templates/pages/help.html:929 msgid "Currently, no features are in beta." msgstr "" -#: warehouse/templates/pages/help.html:915 +#: warehouse/templates/pages/help.html:933 #, python-format msgid "" "\"PyPI\" should be pronounced like \"pie pea eye\", specifically with the" @@ -7396,39 +7528,39 @@ msgid "" "implementation of the Python language." msgstr "" -#: warehouse/templates/pages/help.html:927 +#: warehouse/templates/pages/help.html:945 msgid "Resources" msgstr "" -#: warehouse/templates/pages/help.html:928 +#: warehouse/templates/pages/help.html:946 msgid "Looking for something else? Perhaps these links will help:" msgstr "" -#: warehouse/templates/pages/help.html:930 +#: warehouse/templates/pages/help.html:948 msgid "Python Packaging User Guide" msgstr "" -#: warehouse/templates/pages/help.html:931 +#: warehouse/templates/pages/help.html:949 msgid "Python documentation" msgstr "" -#: warehouse/templates/pages/help.html:932 +#: warehouse/templates/pages/help.html:950 msgid "(main Python website)" msgstr "" -#: warehouse/templates/pages/help.html:933 +#: warehouse/templates/pages/help.html:951 msgid "Python community page" msgstr "" -#: warehouse/templates/pages/help.html:933 +#: warehouse/templates/pages/help.html:951 msgid "(lists IRC channels, mailing lists, etc.)" msgstr "" -#: warehouse/templates/pages/help.html:936 +#: warehouse/templates/pages/help.html:954 msgid "Contact" msgstr "" -#: warehouse/templates/pages/help.html:938 +#: warehouse/templates/pages/help.html:956 #, python-format msgid "" "The {% trans %}Security history{% endtrans %} {% elif event.tag == EventTag.Account.PasswordChange %} {% trans %}Password successfully changed{% endtrans %} + {% elif event.tag == EventTag.Account.PendingOIDCProviderAdded %} + Pending OpenID Connect provider added + {% trans %}Project:{% endtrans %} {{ event.additional.project }} + {% trans %}Provider:{% endtrans %} {{ event.additional.provider }} + {% trans %}Publisher:{% endtrans %} {{ event.additional.specifier }} + + {% elif event.tag == EventTag.Account.PendingOIDCProviderRemoved %} + Pending OpenID Connect provider removed + {% trans %}Project:{% endtrans %} {{ event.additional.project }} + {% trans %}Provider:{% endtrans %} {{ event.additional.provider }} + {% trans %}Publisher:{% endtrans %} {{ event.additional.specifier }} + {% elif event.tag == EventTag.Account.TwoFactorMethodAdded %} {% trans %}Two factor authentication added{% endtrans %}
    diff --git a/warehouse/templates/manage/account/publishing.html b/warehouse/templates/manage/account/publishing.html new file mode 100644 index 000000000000..5656c82b19bc --- /dev/null +++ b/warehouse/templates/manage/account/publishing.html @@ -0,0 +1,142 @@ +{# + # 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. +-#} +{% extends "manage/manage_base.html" %} + +{% set user = request.user %} +{% set pending_oidc_enabled = user.has_primary_verified_email %} + +{% set active_tab = 'publishing' %} + +{% block title %} + {{ oidc_title() }} +{% endblock %} + +{% block main %} +

    {{ oidc_title() }}

    + + {{ oidc_desc() }} + +

    {% trans %}Add a new pending provider{% endtrans %}

    + +

    + {% trans %} + You can use this page to register "pending" OpenID Connect providers. + {% endtrans %} +

    + + +

    + {% trans href="/help#openid-connect" %} + These providers behave similarly to OpenID Connect providers registered + against specific projects, except that they allow users to create + the project if it doesn't already exist. Once the project is created, + the "pending" provider becomes an ordinary OpenID Connect provider. + You can read more about "pending" and ordinary OpenID Connect providers + here. + {% endtrans %} +

    + +

    GitHub

    + + {{ form_error_anchor(pending_github_provider_form) }} +
    + + {{ form_errors(pending_github_provider_form) }} +
    + + {{ pending_github_provider_form.project_name(placeholder=gettext("project name"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", aria_describedby="project_name-errors") }} +

    + {% trans %}The project (on PyPI) that will be created when this provider is used{% endtrans %} +

    +
    + {{ field_errors(pending_github_provider_form.project_name) }} +
    +
    +
    + + {{ pending_github_provider_form.owner(placeholder=gettext("owner"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", aria_describedby="owner-errors") }} +

    + {% trans %}The GitHub organization name or GitHub username that owns the repository{% endtrans %} +

    +
    + {{ field_errors(pending_github_provider_form.owner) }} +
    +
    +
    + + {{ pending_github_provider_form.repository(placeholder=gettext("repository"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", **{"aria-describedby":"repository-errors"}) }} +

    + {% trans %}The name of the GitHub repository that contains the publishing workflow{% endtrans %} +

    +
    + {{ field_errors(pending_github_provider_form.repository) }} +
    +
    +
    + + {{ pending_github_provider_form.workflow_filename(placeholder=gettext("workflow.yml"), class_="form-group__field", autocomplete="off", **{"aria-describedby":"workflow_filename-errors"}) }} +

    + {% trans %}The filename of the publishing workflow. This file should exist in the .github/workflows/ directory in the repository configured above.{% endtrans %} +

    +
    + {{ field_errors(pending_github_provider_form.workflow_filename) }} +
    +
    +
    + +
    +
    + +

    {% trans %}Manage pending providers{% endtrans %}

    + {% if user.pending_oidc_providers %} + + + + + + + + + + + {% for provider in user.pending_oidc_providers %} + {{ oidc_provider_row(provider) }} + {% endfor %} + +
    {% trans %}Pending project name{% endtrans %}{% trans %}Publisher{% endtrans %}{% trans %}Publishing workflow URL{% endtrans %}
    + {% else %} +

    {% trans %}No publishers are currently configured.{% endtrans %}

    + {% endif %} + +{% endblock %} diff --git a/warehouse/templates/manage/manage_base.html b/warehouse/templates/manage/manage_base.html index 270fb218f6ea..3a610313610a 100644 --- a/warehouse/templates/manage/manage_base.html +++ b/warehouse/templates/manage/manage_base.html @@ -230,6 +230,14 @@

    {% trans %}Two factor authentication (2FA){% endtrans %} + {% if request.registry.settings["warehouse.oidc.enabled"] %} +
  • + + + {% trans %}Publishing{% endtrans %} + +
  • + {% endif %} @@ -447,3 +455,62 @@

    {% endif %} {% endmacro %} + +{% macro oidc_title() %} + {% trans %}OpenID Connect Publisher Management{% endtrans %} +{% endmacro %} + +{% macro oidc_desc() %} +

    + {% trans %} + OpenID Connect (OIDC) provides a flexible, credential-free mechanism for delegating + publishing authority for a PyPI package to a third party service, + like GitHub Actions. + {% endtrans %} +

    + +

    + {% trans %} + PyPI users and projects can use trusted publishers to automate + their release processes, without needing to use API tokens or passwords. + {% endtrans %} +

    + +

    + {% trans href="/help#openid-connect" %} + You can read more about OpenID Connect and how to use it + here. + {% endtrans %} +

    +{% endmacro %} + +{% macro oidc_provider_row(provider) -%} +{# project_name is only defined for pending OIDC providers -#} +{% if provider.project_name is defined %} +{% set delete_route = request.route_path('manage.account.publishing') %} +{% else %} +{% set delete_route = request.route_path('manage.project.settings.publishing', project_name=project.name) %} +{% endif %} + +
    + + +
    + + + {% if provider.project_name is defined %} + + {{ provider.project_name }} + + {% endif %} + + {{ provider.provider_name }} + + + {{ provider.provider_url }} + + + + + +{%- endmacro %} diff --git a/warehouse/templates/manage/project/publishing.html b/warehouse/templates/manage/project/publishing.html index c486dbad7774..24874a239864 100644 --- a/warehouse/templates/manage/project/publishing.html +++ b/warehouse/templates/manage/project/publishing.html @@ -17,7 +17,7 @@ {% set active_tab = 'publishing' %} {% block title %} - {% trans %}OpenID Connect publisher management{% endtrans %} + {{ oidc_title() }} {% endblock %} {% block main %} @@ -27,43 +27,11 @@ {% set title = "PyPI" %} {% endif %} -{% macro provider_row(provider) -%} -
    - - -
    - - - - {{ provider.provider_name }} - - - {{ provider.provider_url }} - - - - - -{%- endmacro %} -
    -

    {% trans %}OpenID Connect publisher management{% endtrans %}

    - -

    - {% trans %} - OpenID Connect provides a flexible, credential-free mechanism for delegating - publishing authority for a PyPI package to a third party service, - like GitHub Actions. - {% endtrans %} -

    +

    {{ oidc_title() }}

    -

    - {% trans %} - PyPI projects can use trusted OpenID Connect publishers to automate their release - processes, without having to explicitly provision or manage API tokens. - {% endtrans %} -

    + {{ oidc_desc() }}

    {% trans %}Add a new provider{% endtrans %}

    GitHub

    @@ -143,7 +111,7 @@

    {% trans %}Manage current providers{% endtrans %}

    {% for provider in project.oidc_providers %} - {{ provider_row(provider) }} + {{ oidc_provider_row(provider) }} {% endfor %} diff --git a/warehouse/templates/pages/help.html b/warehouse/templates/pages/help.html index 84acd16f9289..cb63c938fbc1 100644 --- a/warehouse/templates/pages/help.html +++ b/warehouse/templates/pages/help.html @@ -67,6 +67,7 @@ {% macro apitoken() %}{% trans %}How can I use API tokens to authenticate with PyPI?{% endtrans %}{% endmacro %} {% macro sensitiveactions() %}{% trans %}Why do certain actions require me to confirm my password?{% endtrans %}{% endmacro %} {% macro username_change() %}{% trans %}How do I change my PyPI username?{% endtrans %}{% endmacro %} +{% macro openid_connect() %}{% trans %}How can I use OpenID connect to authenticate with PyPI?{% endtrans %}{% endmacro %} {% macro mirroring() %}{% trans %}How can I run a mirror of PyPI?{% endtrans %}{% endmacro %} {% macro APIs() %}{% trans %}Does PyPI have APIs I can use?{% endtrans %}{% endmacro %} @@ -138,6 +139,7 @@

    {% trans %}My Account{% endtrans %}

  • {{ apitoken() }}
  • {{ sensitiveactions() }}
  • {{ username_change() }}
  • +
  • {{ openid_connect() }}
  • @@ -520,6 +522,22 @@

    {{ username_change() }}

    {% trans %}PyPI does not currently support changing a username.{% endtrans %}

    {% trans %}Instead, you can create a new account with the desired username, add the new account as a maintainer of all the projects your old account owns, and then delete the old account, which will have the same effect.{% endtrans %}

    +

    {{ openid_connect() }}

    +

    + {% trans openid="https://openid.net/connect/", apitoken="#apitoken" %} + PyPI users and projects can use OpenID Connect (OIDC) to delegate + publishing authority for a PyPI package to a trusted third party service, eliminating + the need to use API tokens or passwords. + {% endtrans %} +

    +

    + {% trans %} + Using OIDC to authenticate when publishing is done by registering "providers," + which correspond to services with OIDC identities like GitHub Actions. + Existing projects can add or remove OIDC providers at any time. OIDC providers + can also be created in advance for projects that don't exist yet. + {% endtrans %} +

    @@ -547,7 +565,7 @@

    {{ statistics() }}

    {% endtrans %}

    {% trans href='https://status.python.org/', title=gettext('External link') %}For recent statistics on uptime and performance, see our status page.{% endtrans %}

    - +

    {{ verify_hashes() }}

    {% trans %}For each package hosted on PyPI, there are corresponding hashes for that file. These hashes can be used to verify that the file you are downloading is the same one that the project maintainer uploaded. This is especially useful if downloading packages from a mirror. The hashes can be obtained from the project page in the "Download Files" section or from the JSON API. Here is an example of generating the hashes:{% endtrans %}

    import hashlib
    diff --git a/warehouse/utils/project.py b/warehouse/utils/project.py
    index b9541586921a..8316bb559cf3 100644
    --- a/warehouse/utils/project.py
    +++ b/warehouse/utils/project.py
    @@ -10,6 +10,8 @@
     # See the License for the specific language governing permissions and
     # limitations under the License.
     
    +import re
    +
     from itertools import chain
     
     import stdlib_list
    @@ -56,6 +58,10 @@ def _namespace_stdlib_list(module_list):
         )
     }
     
    +PROJECT_NAME_RE = re.compile(
    +    r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE
    +)
    +
     
     def validate_project_name(name, request):
         """