diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py
index 2a12379da170..f948d6cbe899 100644
--- a/tests/common/db/packaging.py
+++ b/tests/common/db/packaging.py
@@ -20,6 +20,7 @@
from warehouse.observations.models import ObservationKind
from warehouse.packaging.models import (
+ AlternateRepository,
Dependency,
DependencyKind,
Description,
@@ -200,3 +201,13 @@ class Meta:
)
name = factory.Faker("pystr", max_chars=12)
prohibited_by = factory.SubFactory(UserFactory)
+
+
+class AlternateRepositoryFactory(WarehouseFactory):
+ class Meta:
+ model = AlternateRepository
+
+ name = factory.Faker("word")
+ url = factory.Faker("uri")
+ description = factory.Faker("text")
+ project = factory.SubFactory(ProjectFactory)
diff --git a/tests/unit/api/test_simple.py b/tests/unit/api/test_simple.py
index 9937038dd145..ec2367dded73 100644
--- a/tests/unit/api/test_simple.py
+++ b/tests/unit/api/test_simple.py
@@ -18,10 +18,11 @@
from pyramid.testing import DummyRequest
from warehouse.api import simple
-from warehouse.packaging.utils import API_VERSION
+from warehouse.packaging.utils import API_VERSION, _valid_simple_detail_context
from ...common.db.accounts import UserFactory
from ...common.db.packaging import (
+ AlternateRepositoryFactory,
FileFactory,
JournalEntryFactory,
ProjectFactory,
@@ -48,29 +49,30 @@ def test_defaults_text_html(self, header):
default to text/html.
"""
request = DummyRequest(accept=header)
- assert simple._select_content_type(request) == "text/html"
+ assert simple._select_content_type(request) == simple.MIME_TEXT_HTML
@pytest.mark.parametrize(
("header", "expected"),
[
- ("text/html", "text/html"),
+ (simple.MIME_TEXT_HTML, simple.MIME_TEXT_HTML),
(
- "application/vnd.pypi.simple.v1+html",
- "application/vnd.pypi.simple.v1+html",
+ simple.MIME_PYPI_SIMPLE_V1_HTML,
+ simple.MIME_PYPI_SIMPLE_V1_HTML,
),
(
- "application/vnd.pypi.simple.v1+json",
- "application/vnd.pypi.simple.v1+json",
+ simple.MIME_PYPI_SIMPLE_V1_JSON,
+ simple.MIME_PYPI_SIMPLE_V1_JSON,
),
(
- "text/html, application/vnd.pypi.simple.v1+html, "
- "application/vnd.pypi.simple.v1+json",
- "text/html",
+ f"{simple.MIME_TEXT_HTML}, {simple.MIME_PYPI_SIMPLE_V1_HTML}, "
+ f"{simple.MIME_PYPI_SIMPLE_V1_JSON}",
+ simple.MIME_TEXT_HTML,
),
(
- "text/html;q=0.01, application/vnd.pypi.simple.v1+html;q=0.2, "
- "application/vnd.pypi.simple.v1+json",
- "application/vnd.pypi.simple.v1+json",
+ f"{simple.MIME_TEXT_HTML};q=0.01, "
+ f"{simple.MIME_PYPI_SIMPLE_V1_HTML};q=0.2, "
+ f"{simple.MIME_PYPI_SIMPLE_V1_JSON}",
+ simple.MIME_PYPI_SIMPLE_V1_JSON,
),
],
)
@@ -80,9 +82,9 @@ def test_selects(self, header, expected):
CONTENT_TYPE_PARAMS = [
- ("text/html", None),
- ("application/vnd.pypi.simple.v1+html", None),
- ("application/vnd.pypi.simple.v1+json", "json"),
+ (simple.MIME_TEXT_HTML, None),
+ (simple.MIME_PYPI_SIMPLE_V1_HTML, None),
+ (simple.MIME_PYPI_SIMPLE_V1_JSON, "json"),
]
@@ -211,12 +213,15 @@ def test_no_files_no_serial(self, db_request, content_type, renderer_override):
user = UserFactory.create()
JournalEntryFactory.create(submitted_by=user)
- assert simple.simple_detail(project, db_request) == {
+ context = {
"meta": {"_last-serial": 0, "api-version": API_VERSION},
"name": project.normalized_name,
"files": [],
"versions": [],
+ "alternate-locations": [],
}
+ context = _update_context(context, content_type, renderer_override)
+ assert simple.simple_detail(project, db_request) == context
assert db_request.response.headers["X-PyPI-Last-Serial"] == "0"
assert db_request.response.content_type == content_type
@@ -235,13 +240,20 @@ def test_no_files_with_serial(self, db_request, content_type, renderer_override)
db_request.matchdict["name"] = project.normalized_name
user = UserFactory.create()
je = JournalEntryFactory.create(name=project.name, submitted_by=user)
+ als = [
+ AlternateRepositoryFactory.create(project=project),
+ AlternateRepositoryFactory.create(project=project),
+ ]
- assert simple.simple_detail(project, db_request) == {
+ context = {
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
"name": project.normalized_name,
"files": [],
"versions": [],
+ "alternate-locations": sorted(al.url for al in als),
}
+ context = _update_context(context, content_type, renderer_override)
+ assert simple.simple_detail(project, db_request) == context
assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
assert db_request.response.content_type == content_type
@@ -271,7 +283,7 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override)
user = UserFactory.create()
JournalEntryFactory.create(submitted_by=user)
- assert simple.simple_detail(project, db_request) == {
+ context = {
"meta": {"_last-serial": 0, "api-version": API_VERSION},
"name": project.normalized_name,
"versions": release_versions,
@@ -289,7 +301,10 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override)
}
for f in files
],
+ "alternate-locations": [],
}
+ context = _update_context(context, content_type, renderer_override)
+ assert simple.simple_detail(project, db_request) == context
assert db_request.response.headers["X-PyPI-Last-Serial"] == "0"
assert db_request.response.content_type == content_type
@@ -319,7 +334,7 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid
user = UserFactory.create()
je = JournalEntryFactory.create(name=project.name, submitted_by=user)
- assert simple.simple_detail(project, db_request) == {
+ context = {
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
"name": project.normalized_name,
"versions": release_versions,
@@ -337,7 +352,10 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid
}
for f in files
],
+ "alternate-locations": [],
}
+ context = _update_context(context, content_type, renderer_override)
+ assert simple.simple_detail(project, db_request) == context
assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
assert db_request.response.content_type == content_type
@@ -404,7 +422,7 @@ def test_with_files_with_version_multi_digit(
user = UserFactory.create()
je = JournalEntryFactory.create(name=project.name, submitted_by=user)
- assert simple.simple_detail(project, db_request) == {
+ context = {
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
"name": project.normalized_name,
"versions": release_versions,
@@ -430,7 +448,10 @@ def test_with_files_with_version_multi_digit(
}
for f in files
],
+ "alternate-locations": [],
}
+ context = _update_context(context, content_type, renderer_override)
+ assert simple.simple_detail(project, db_request) == context
assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
assert db_request.response.content_type == content_type
@@ -439,6 +460,15 @@ def test_with_files_with_version_multi_digit(
if renderer_override is not None:
assert db_request.override_renderer == renderer_override
+
+def _update_context(context, content_type, renderer_override):
+ if renderer_override != "json" or content_type in [
+ simple.MIME_TEXT_HTML,
+ simple.MIME_PYPI_SIMPLE_V1_HTML,
+ ]:
+ return _valid_simple_detail_context(context)
+ return context
+
def test_with_files_quarantined_omitted_from_index(self, db_request):
db_request.accept = "text/html"
project = ProjectFactory.create(lifecycle_status="quarantine-enter")
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
index 09a470a58e5e..fb16a5a325ec 100644
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -82,6 +82,7 @@
TeamRoleFactory,
)
from ...common.db.packaging import (
+ AlternateRepositoryFactory,
FileEventFactory,
FileFactory,
JournalEntryFactory,
@@ -2606,6 +2607,7 @@ def test_manage_project_settings(self, enabled, monkeypatch):
view = views.ManageProjectSettingsViews(project, request)
form = pretend.stub()
view.transfer_organization_project_form_class = lambda *a, **kw: form
+ view.add_alternate_repository_form_class = lambda *a, **kw: form
user_organizations = pretend.call_recorder(
lambda *a, **kw: {
@@ -2621,6 +2623,7 @@ def test_manage_project_settings(self, enabled, monkeypatch):
"MAX_FILESIZE": MAX_FILESIZE,
"MAX_PROJECT_SIZE": MAX_PROJECT_SIZE,
"transfer_organization_project_form": form,
+ "add_alternate_repository_form_class": form,
}
def test_manage_project_settings_in_organization_managed(self, monkeypatch):
@@ -2633,6 +2636,7 @@ def test_manage_project_settings_in_organization_managed(self, monkeypatch):
view.transfer_organization_project_form_class = pretend.call_recorder(
lambda *a, **kw: form
)
+ view.add_alternate_repository_form_class = lambda *a, **kw: form
user_organizations = pretend.call_recorder(
lambda *a, **kw: {
@@ -2648,6 +2652,7 @@ def test_manage_project_settings_in_organization_managed(self, monkeypatch):
"MAX_FILESIZE": MAX_FILESIZE,
"MAX_PROJECT_SIZE": MAX_PROJECT_SIZE,
"transfer_organization_project_form": form,
+ "add_alternate_repository_form_class": form,
}
assert view.transfer_organization_project_form_class.calls == [
pretend.call(organization_choices={"owned-org"})
@@ -2663,6 +2668,7 @@ def test_manage_project_settings_in_organization_owned(self, monkeypatch):
view.transfer_organization_project_form_class = pretend.call_recorder(
lambda *a, **kw: form
)
+ view.add_alternate_repository_form_class = lambda *a, **kw: form
user_organizations = pretend.call_recorder(
lambda *a, **kw: {
@@ -2678,11 +2684,266 @@ def test_manage_project_settings_in_organization_owned(self, monkeypatch):
"MAX_FILESIZE": MAX_FILESIZE,
"MAX_PROJECT_SIZE": MAX_PROJECT_SIZE,
"transfer_organization_project_form": form,
+ "add_alternate_repository_form_class": form,
}
assert view.transfer_organization_project_form_class.calls == [
pretend.call(organization_choices={"managed-org"})
]
+ def test_add_alternate_repository(self, monkeypatch, db_request):
+ project = ProjectFactory.create(name="foo")
+
+ db_request.POST = MultiDict(
+ {
+ "display_name": "foo alt repo",
+ "link_url": "https://example.org",
+ "description": "foo alt repo descr",
+ "alternate_repository_location": "add",
+ }
+ )
+ db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False))
+ db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.user = UserFactory.create()
+
+ RoleFactory.create(project=project, user=db_request.user, role_name="Owner")
+
+ add_alternate_repository_form_class = pretend.call_recorder(
+ views.AddAlternateRepositoryForm
+ )
+ monkeypatch.setattr(
+ views,
+ "AddAlternateRepositoryForm",
+ add_alternate_repository_form_class,
+ )
+
+ settings_views = views.ManageProjectSettingsViews(project, db_request)
+ result = settings_views.add_project_alternate_repository()
+
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == "/the-redirect"
+ assert db_request.session.flash.calls == [
+ pretend.call("Added alternate repository 'foo alt repo'", queue="success")
+ ]
+ assert db_request.route_path.calls == [
+ pretend.call("manage.project.settings", project_name="foo")
+ ]
+ assert add_alternate_repository_form_class.calls == [
+ pretend.call(db_request.POST)
+ ]
+
+ def test_add_alternate_repository_invalid(self, monkeypatch, db_request):
+ project = ProjectFactory.create(name="foo")
+
+ db_request.POST = MultiDict(
+ {
+ "display_name": "foo alt repo",
+ "link_url": "invalid link",
+ "description": "foo alt repo descr",
+ "alternate_repository_location": "add",
+ }
+ )
+ db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False))
+ db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.user = UserFactory.create()
+
+ RoleFactory.create(project=project, user=db_request.user, role_name="Owner")
+
+ add_alternate_repository_form_class = pretend.call_recorder(
+ views.AddAlternateRepositoryForm
+ )
+ monkeypatch.setattr(
+ views,
+ "AddAlternateRepositoryForm",
+ add_alternate_repository_form_class,
+ )
+
+ settings_views = views.ManageProjectSettingsViews(project, db_request)
+ result = settings_views.add_project_alternate_repository()
+
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == "/the-redirect"
+ assert db_request.session.flash.calls == [
+ pretend.call("Invalid alternate repository location details", queue="error")
+ ]
+ assert db_request.route_path.calls == [
+ pretend.call("manage.project.settings", project_name="foo")
+ ]
+ assert add_alternate_repository_form_class.calls == [
+ pretend.call(db_request.POST)
+ ]
+
+ def test_delete_alternate_repository(self, db_request):
+ project = ProjectFactory.create(name="foo")
+ alt_repo = AlternateRepositoryFactory.create(project=project)
+
+ db_request.POST = MultiDict(
+ {
+ "alternate_repository_id": str(alt_repo.id),
+ "confirm_alternate_repository_name": alt_repo.name,
+ "alternate_repository_location": "delete",
+ }
+ )
+ db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False))
+ db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.user = UserFactory.create()
+
+ RoleFactory.create(project=project, user=db_request.user, role_name="Owner")
+
+ settings_views = views.ManageProjectSettingsViews(project, db_request)
+ result = settings_views.delete_project_alternate_repository()
+
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == "/the-redirect"
+ assert db_request.session.flash.calls == [
+ pretend.call(
+ f"Deleted alternate repository '{alt_repo.name}'", queue="success"
+ )
+ ]
+ assert db_request.route_path.calls == [
+ pretend.call("manage.project.settings", project_name="foo")
+ ]
+
+ @pytest.mark.parametrize("alt_repo_id", [None, "", "blah"])
+ def test_delete_alternate_repository_invalid_id(self, db_request, alt_repo_id):
+ project = ProjectFactory.create(name="foo")
+ alt_repo = AlternateRepositoryFactory.create(project=project)
+
+ db_request.POST = MultiDict(
+ {
+ "alternate_repository_id": alt_repo_id,
+ "confirm_alternate_repository_name": alt_repo.name,
+ "alternate_repository_location": "delete",
+ }
+ )
+ db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False))
+ db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.user = UserFactory.create()
+
+ RoleFactory.create(project=project, user=db_request.user, role_name="Owner")
+
+ settings_views = views.ManageProjectSettingsViews(project, db_request)
+ result = settings_views.delete_project_alternate_repository()
+
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == "/the-redirect"
+ assert db_request.session.flash.calls == [
+ pretend.call("Invalid alternate repository id", queue="error")
+ ]
+ assert db_request.route_path.calls == [
+ pretend.call("manage.project.settings", project_name="foo")
+ ]
+
+ def test_delete_alternate_repository_wrong_id(self, db_request):
+ project = ProjectFactory.create(name="foo")
+ alt_repo = AlternateRepositoryFactory.create(project=project)
+
+ db_request.POST = MultiDict(
+ {
+ "alternate_repository_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ "confirm_alternate_repository_name": alt_repo.name,
+ "alternate_repository_location": "delete",
+ }
+ )
+ db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False))
+ db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.user = UserFactory.create()
+
+ RoleFactory.create(project=project, user=db_request.user, role_name="Owner")
+
+ settings_views = views.ManageProjectSettingsViews(project, db_request)
+ result = settings_views.delete_project_alternate_repository()
+
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == "/the-redirect"
+ assert db_request.session.flash.calls == [
+ pretend.call("Invalid alternate repository for project", queue="error")
+ ]
+ assert db_request.route_path.calls == [
+ pretend.call("manage.project.settings", project_name="foo")
+ ]
+
+ def test_delete_alternate_repository_no_confirm(self, db_request):
+ project = ProjectFactory.create(name="foo")
+ alt_repo = AlternateRepositoryFactory.create(project=project)
+
+ db_request.POST = MultiDict(
+ {
+ "alternate_repository_id": str(alt_repo.id),
+ "alternate_repository_location": "delete",
+ }
+ )
+ db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False))
+ db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.user = UserFactory.create()
+
+ RoleFactory.create(project=project, user=db_request.user, role_name="Owner")
+
+ settings_views = views.ManageProjectSettingsViews(project, db_request)
+ result = settings_views.delete_project_alternate_repository()
+
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == "/the-redirect"
+ assert db_request.session.flash.calls == [
+ pretend.call("Confirm the request", queue="error")
+ ]
+ assert db_request.route_path.calls == [
+ pretend.call("manage.project.settings", project_name="foo")
+ ]
+
+ def test_delete_alternate_repository_wrong_confirm(self, db_request):
+ project = ProjectFactory.create(name="foo")
+ alt_repo = AlternateRepositoryFactory.create(project=project)
+
+ db_request.POST = MultiDict(
+ {
+ "alternate_repository_id": str(alt_repo.id),
+ "confirm_alternate_repository_name": f"invalid-confirm-{alt_repo.name}",
+ "alternate_repository_location": "delete",
+ }
+ )
+ db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False))
+ db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.user = UserFactory.create()
+
+ RoleFactory.create(project=project, user=db_request.user, role_name="Owner")
+
+ settings_views = views.ManageProjectSettingsViews(project, db_request)
+ result = settings_views.delete_project_alternate_repository()
+
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == "/the-redirect"
+ assert db_request.session.flash.calls == [
+ pretend.call(
+ f"Could not delete alternate repository - "
+ f"invalid-confirm-{alt_repo.name} is not the same as {alt_repo.name}",
+ queue="error",
+ )
+ ]
+ assert db_request.route_path.calls == [
+ pretend.call("manage.project.settings", project_name="foo")
+ ]
+
def test_remove_organization_project_no_confirm(self):
user = pretend.stub()
project = pretend.stub(
diff --git a/tests/unit/packaging/test_init.py b/tests/unit/packaging/test_init.py
index 823bfce89647..ad0efbee2ac9 100644
--- a/tests/unit/packaging/test_init.py
+++ b/tests/unit/packaging/test_init.py
@@ -25,7 +25,7 @@
IProjectService,
ISimpleStorage,
)
-from warehouse.packaging.models import File, Project, Release, Role
+from warehouse.packaging.models import AlternateRepository, File, Project, Release, Role
from warehouse.packaging.services import project_service_factory
from warehouse.packaging.tasks import ( # sync_bigquery_release_files,
check_file_cache_tasks_outstanding,
@@ -159,6 +159,13 @@ def key_factory(keystring, iterate_on=None, if_attr_exists=None):
key_factory("project/{itr.normalized_name}", iterate_on="projects"),
],
),
+ pretend.call(
+ AlternateRepository,
+ cache_keys=["project/{obj.project.normalized_name}"],
+ purge_keys=[
+ key_factory("project/{obj.project.normalized_name}"),
+ ],
+ ),
]
if with_bq_sync:
diff --git a/tests/unit/packaging/test_utils.py b/tests/unit/packaging/test_utils.py
index afa7bd2056fe..e256ca0300e1 100644
--- a/tests/unit/packaging/test_utils.py
+++ b/tests/unit/packaging/test_utils.py
@@ -16,7 +16,11 @@
import pretend
from warehouse.packaging.interfaces import ISimpleStorage
-from warehouse.packaging.utils import _simple_detail, render_simple_detail
+from warehouse.packaging.utils import (
+ _simple_detail,
+ _valid_simple_detail_context,
+ render_simple_detail,
+)
from ...common.db.packaging import FileFactory, ProjectFactory, ReleaseFactory
@@ -50,9 +54,9 @@ def test_render_simple_detail(db_request, monkeypatch, jinja):
db_request.route_url = lambda *a, **kw: "the-url"
template = jinja.get_template("templates/api/simple/detail.html")
- expected_content = template.render(
- **_simple_detail(project, db_request), request=db_request
- ).encode("utf-8")
+ context = _simple_detail(project, db_request)
+ context = _valid_simple_detail_context(context)
+ expected_content = template.render(**context, request=db_request).encode("utf-8")
content_hash, path = render_simple_detail(project, db_request)
@@ -107,9 +111,9 @@ def __exit__(self, type, value, traceback):
monkeypatch.setattr(tempfile, "NamedTemporaryFile", FakeNamedTemporaryFile)
template = jinja.get_template("templates/api/simple/detail.html")
- expected_content = template.render(
- **_simple_detail(project, db_request), request=db_request
- ).encode("utf-8")
+ context = _simple_detail(project, db_request)
+ context = _valid_simple_detail_context(context)
+ expected_content = template.render(**context, request=db_request).encode("utf-8")
content_hash, path = render_simple_detail(project, db_request, store=True)
diff --git a/warehouse/api/simple.py b/warehouse/api/simple.py
index faa7bf3b6dc9..a7882af67c4b 100644
--- a/warehouse/api/simple.py
+++ b/warehouse/api/simple.py
@@ -19,9 +19,17 @@
from warehouse.cache.http import add_vary, cache_control
from warehouse.cache.origin import origin_cache
from warehouse.packaging.models import JournalEntry, Project
-from warehouse.packaging.utils import _simple_detail, _simple_index
+from warehouse.packaging.utils import (
+ _simple_detail,
+ _simple_index,
+ _valid_simple_detail_context,
+)
from warehouse.utils.cors import _CORS_HEADERS
+MIME_TEXT_HTML = "text/html"
+MIME_PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
+MIME_PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
+
def _select_content_type(request: Request) -> str:
# The way this works, is this will return a list of
@@ -36,16 +44,16 @@ def _select_content_type(request: Request) -> str:
# match, it will be an empty list.
offers = request.accept.acceptable_offers(
[
- "text/html",
- "application/vnd.pypi.simple.v1+html",
- "application/vnd.pypi.simple.v1+json",
+ MIME_TEXT_HTML,
+ MIME_PYPI_SIMPLE_V1_HTML,
+ MIME_PYPI_SIMPLE_V1_JSON,
]
)
# Default case, we want to return whatevr we want to return
# by default when there is no Accept header.
if not offers:
- return "text/html"
+ return MIME_TEXT_HTML
# We've selected a list of acceptable offers, so we'll take
# the first one as our return type.
else:
@@ -69,7 +77,7 @@ def simple_index(request):
# Determine what our content-type should be, and setup our request
# to return the correct content types.
request.response.content_type = _select_content_type(request)
- if request.response.content_type == "application/vnd.pypi.simple.v1+json":
+ if request.response.content_type == MIME_PYPI_SIMPLE_V1_JSON:
request.override_renderer = "json"
# Apply CORS headers.
@@ -109,7 +117,7 @@ def simple_detail(project, request):
# Determine what our content-type should be, and setup our request
# to return the correct content types.
request.response.content_type = _select_content_type(request)
- if request.response.content_type == "application/vnd.pypi.simple.v1+json":
+ if request.response.content_type == MIME_PYPI_SIMPLE_V1_JSON:
request.override_renderer = "json"
# Apply CORS headers.
@@ -118,4 +126,10 @@ def simple_detail(project, request):
# Get the latest serial number for this project.
request.response.headers["X-PyPI-Last-Serial"] = str(project.last_serial)
- return _simple_detail(project, request)
+ context = _simple_detail(project, request)
+
+ # Modify the Jinja context to use valid variable name
+ if request.response.content_type != MIME_PYPI_SIMPLE_V1_JSON:
+ context = _valid_simple_detail_context(context)
+
+ return context
diff --git a/warehouse/events/tags.py b/warehouse/events/tags.py
index e26c53f039a8..61e3161c8e37 100644
--- a/warehouse/events/tags.py
+++ b/warehouse/events/tags.py
@@ -102,6 +102,8 @@ class Account(EventTagEnum):
TwoFactorMethodAdded = "account:two_factor:method_added"
TwoFactorMethodRemoved = "account:two_factor:method_removed"
EmailSent = "account:email:sent"
+ AlternateRepositoryAdd = "account:alternate_repository:add"
+ AlternateRepositoryDelete = "account:alternate_repository:delete"
# The following tags are no longer used when recording events.
# ReauthenticateFailure = "account:reauthenticate:failure"
# RoleAccepted = "account:role:accepted"
@@ -138,6 +140,8 @@ class Project(EventTagEnum):
TeamProjectRoleAdd = "project:team_project_role:add"
TeamProjectRoleChange = "project:team_project_role:change"
TeamProjectRoleRemove = "project:team_project_role:remove"
+ AlternateRepositoryAdd = "project:alternate_repository:add"
+ AlternateRepositoryDelete = "project:alternate_repository:delete"
# The following tags are no longer used when recording events.
# RoleAccepted = "project:role:accepted"
# RoleDelete = "project:role:delete"
diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot
index 5a445e618f7b..29f7ffbae55e 100644
--- a/warehouse/locale/messages.pot
+++ b/warehouse/locale/messages.pot
@@ -91,6 +91,7 @@ msgid ""
msgstr ""
#: warehouse/accounts/forms.py:388 warehouse/manage/forms.py:139
+#: warehouse/manage/forms.py:728
msgid "The name is too long. Choose a name with 100 characters or less."
msgstr ""
@@ -151,7 +152,7 @@ msgstr ""
msgid "Successful WebAuthn assertion"
msgstr ""
-#: warehouse/accounts/views.py:569 warehouse/manage/views/__init__.py:870
+#: warehouse/accounts/views.py:569 warehouse/manage/views/__init__.py:873
msgid "Recovery code accepted. The supplied code cannot be used again."
msgstr ""
@@ -285,7 +286,7 @@ msgid "You are now ${role} of the '${project_name}' project."
msgstr ""
#: warehouse/accounts/views.py:1548 warehouse/accounts/views.py:1791
-#: warehouse/manage/views/__init__.py:1249
+#: warehouse/manage/views/__init__.py:1425
msgid ""
"Trusted publishing is temporarily disabled. See https://pypi.org/help"
"#admin-intervention for details."
@@ -305,19 +306,19 @@ msgstr ""
msgid "You can't register more than 3 pending trusted publishers at once."
msgstr ""
-#: warehouse/accounts/views.py:1614 warehouse/manage/views/__init__.py:1304
-#: warehouse/manage/views/__init__.py:1417
-#: warehouse/manage/views/__init__.py:1529
-#: warehouse/manage/views/__init__.py:1639
+#: warehouse/accounts/views.py:1614 warehouse/manage/views/__init__.py:1480
+#: warehouse/manage/views/__init__.py:1593
+#: warehouse/manage/views/__init__.py:1705
+#: warehouse/manage/views/__init__.py:1815
msgid ""
"There have been too many attempted trusted publisher registrations. Try "
"again later."
msgstr ""
-#: warehouse/accounts/views.py:1625 warehouse/manage/views/__init__.py:1318
-#: warehouse/manage/views/__init__.py:1431
-#: warehouse/manage/views/__init__.py:1543
-#: warehouse/manage/views/__init__.py:1653
+#: warehouse/accounts/views.py:1625 warehouse/manage/views/__init__.py:1494
+#: warehouse/manage/views/__init__.py:1607
+#: warehouse/manage/views/__init__.py:1719
+#: warehouse/manage/views/__init__.py:1829
msgid "The trusted publisher could not be registered"
msgstr ""
@@ -427,130 +428,175 @@ msgstr ""
msgid "This team name has already been used. Choose a different team name."
msgstr ""
-#: warehouse/manage/views/__init__.py:282
+#: warehouse/manage/forms.py:724
+msgid "Specify your alternate repository name"
+msgstr ""
+
+#: warehouse/manage/forms.py:738
+msgid "Specify your alternate repository URL"
+msgstr ""
+
+#: warehouse/manage/forms.py:742
+msgid "The URL is too long. Choose a URL with 400 characters or less."
+msgstr ""
+
+#: warehouse/manage/forms.py:756
+msgid ""
+"The description is too long. Choose a description with 400 characters or "
+"less."
+msgstr ""
+
+#: warehouse/manage/views/__init__.py:285
msgid "Account details updated"
msgstr ""
-#: warehouse/manage/views/__init__.py:312
+#: warehouse/manage/views/__init__.py:315
msgid "Email ${email_address} added - check your email for a verification link"
msgstr ""
-#: warehouse/manage/views/__init__.py:818
+#: warehouse/manage/views/__init__.py:821
msgid "Recovery codes already generated"
msgstr ""
-#: warehouse/manage/views/__init__.py:819
+#: warehouse/manage/views/__init__.py:822
msgid "Generating new recovery codes will invalidate your existing codes."
msgstr ""
-#: warehouse/manage/views/__init__.py:928
+#: warehouse/manage/views/__init__.py:931
msgid "Verify your email to create an API token."
msgstr ""
-#: warehouse/manage/views/__init__.py:1028
+#: warehouse/manage/views/__init__.py:1031
msgid "API Token does not exist."
msgstr ""
-#: warehouse/manage/views/__init__.py:1060
+#: warehouse/manage/views/__init__.py:1063
msgid "Invalid credentials. Try again"
msgstr ""
-#: warehouse/manage/views/__init__.py:1285
+#: warehouse/manage/views/__init__.py:1182
+msgid "Invalid alternate repository location details"
+msgstr ""
+
+#: warehouse/manage/views/__init__.py:1227
+msgid "Added alternate repository '${name}'"
+msgstr ""
+
+#: warehouse/manage/views/__init__.py:1261
+#: warehouse/manage/views/__init__.py:2162
+#: warehouse/manage/views/__init__.py:2247
+#: warehouse/manage/views/__init__.py:2348
+#: warehouse/manage/views/__init__.py:2448
+msgid "Confirm the request"
+msgstr ""
+
+#: warehouse/manage/views/__init__.py:1273
+msgid "Invalid alternate repository id"
+msgstr ""
+
+#: warehouse/manage/views/__init__.py:1284
+msgid "Invalid alternate repository for project"
+msgstr ""
+
+#: warehouse/manage/views/__init__.py:1292
+msgid ""
+"Could not delete alternate repository - ${confirm} is not the same as "
+"${alt_repo_name}"
+msgstr ""
+
+#: warehouse/manage/views/__init__.py:1330
+msgid "Deleted alternate repository '${name}'"
+msgstr ""
+
+#: warehouse/manage/views/__init__.py:1461
msgid ""
"GitHub-based trusted publishing is temporarily disabled. See "
"https://pypi.org/help#admin-intervention for details."
msgstr ""
-#: warehouse/manage/views/__init__.py:1398
+#: warehouse/manage/views/__init__.py:1574
msgid ""
"GitLab-based trusted publishing is temporarily disabled. See "
"https://pypi.org/help#admin-intervention for details."
msgstr ""
-#: warehouse/manage/views/__init__.py:1510
+#: warehouse/manage/views/__init__.py:1686
msgid ""
"Google-based trusted publishing is temporarily disabled. See "
"https://pypi.org/help#admin-intervention for details."
msgstr ""
-#: warehouse/manage/views/__init__.py:1619
+#: warehouse/manage/views/__init__.py:1795
msgid ""
"ActiveState-based trusted publishing is temporarily disabled. See "
"https://pypi.org/help#admin-intervention for details."
msgstr ""
-#: warehouse/manage/views/__init__.py:1854
-#: warehouse/manage/views/__init__.py:2155
-#: warehouse/manage/views/__init__.py:2263
+#: warehouse/manage/views/__init__.py:2030
+#: warehouse/manage/views/__init__.py:2331
+#: warehouse/manage/views/__init__.py:2439
msgid ""
"Project deletion temporarily disabled. See https://pypi.org/help#admin-"
"intervention for details."
msgstr ""
-#: warehouse/manage/views/__init__.py:1986
-#: warehouse/manage/views/__init__.py:2071
-#: warehouse/manage/views/__init__.py:2172
-#: warehouse/manage/views/__init__.py:2272
-msgid "Confirm the request"
-msgstr ""
-
-#: warehouse/manage/views/__init__.py:1998
+#: warehouse/manage/views/__init__.py:2174
msgid "Could not yank release - "
msgstr ""
-#: warehouse/manage/views/__init__.py:2083
+#: warehouse/manage/views/__init__.py:2259
msgid "Could not un-yank release - "
msgstr ""
-#: warehouse/manage/views/__init__.py:2184
+#: warehouse/manage/views/__init__.py:2360
msgid "Could not delete release - "
msgstr ""
-#: warehouse/manage/views/__init__.py:2284
+#: warehouse/manage/views/__init__.py:2460
msgid "Could not find file"
msgstr ""
-#: warehouse/manage/views/__init__.py:2288
+#: warehouse/manage/views/__init__.py:2464
msgid "Could not delete file - "
msgstr ""
-#: warehouse/manage/views/__init__.py:2438
+#: warehouse/manage/views/__init__.py:2614
msgid "Team '${team_name}' already has ${role_name} role for project"
msgstr ""
-#: warehouse/manage/views/__init__.py:2545
+#: warehouse/manage/views/__init__.py:2721
msgid "User '${username}' already has ${role_name} role for project"
msgstr ""
-#: warehouse/manage/views/__init__.py:2612
+#: warehouse/manage/views/__init__.py:2788
msgid "${username} is now ${role} of the '${project_name}' project."
msgstr ""
-#: warehouse/manage/views/__init__.py:2644
+#: warehouse/manage/views/__init__.py:2820
msgid ""
"User '${username}' does not have a verified primary email address and "
"cannot be added as a ${role_name} for project"
msgstr ""
-#: warehouse/manage/views/__init__.py:2657
+#: warehouse/manage/views/__init__.py:2833
#: warehouse/manage/views/organizations.py:878
msgid "User '${username}' already has an active invite. Please try again later."
msgstr ""
-#: warehouse/manage/views/__init__.py:2722
+#: warehouse/manage/views/__init__.py:2898
#: warehouse/manage/views/organizations.py:943
msgid "Invitation sent to '${username}'"
msgstr ""
-#: warehouse/manage/views/__init__.py:2755
+#: warehouse/manage/views/__init__.py:2931
msgid "Could not find role invitation."
msgstr ""
-#: warehouse/manage/views/__init__.py:2766
+#: warehouse/manage/views/__init__.py:2942
msgid "Invitation already expired."
msgstr ""
-#: warehouse/manage/views/__init__.py:2798
+#: warehouse/manage/views/__init__.py:2974
#: warehouse/manage/views/organizations.py:1130
msgid "Invitation revoked from '${username}'."
msgstr ""
@@ -1034,7 +1080,7 @@ msgstr ""
#: warehouse/templates/manage/project/release.html:188
#: warehouse/templates/manage/project/settings.html:87
#: warehouse/templates/manage/project/settings.html:136
-#: warehouse/templates/manage/project/settings.html:208
+#: warehouse/templates/manage/project/settings.html:357
#: warehouse/templates/manage/team/settings.html:84
msgid "Warning"
msgstr ""
@@ -1400,6 +1446,9 @@ msgstr ""
#: warehouse/templates/manage/project/roles.html:328
#: warehouse/templates/manage/project/roles.html:359
#: warehouse/templates/manage/project/roles.html:380
+#: warehouse/templates/manage/project/settings.html:287
+#: warehouse/templates/manage/project/settings.html:307
+#: warehouse/templates/manage/project/settings.html:327
#: warehouse/templates/manage/team/roles.html:106
#: warehouse/templates/manage/team/settings.html:35
#: warehouse/templates/packaging/submit-malware-observation.html:58
@@ -1650,6 +1699,13 @@ msgstr ""
#: warehouse/templates/accounts/register.html:47
#: warehouse/templates/manage/account.html:139
#: warehouse/templates/manage/account.html:480
+#: warehouse/templates/manage/project/history.html:301
+#: warehouse/templates/manage/project/history.html:312
+#: warehouse/templates/manage/project/history.html:323
+#: warehouse/templates/manage/project/history.html:334
+#: warehouse/templates/manage/project/settings.html:224
+#: warehouse/templates/manage/project/settings.html:285
+#: warehouse/templates/manage/project/settings.html:291
#: warehouse/templates/manage/unverified-account.html:112
msgid "Name"
msgstr ""
@@ -3578,7 +3634,7 @@ msgstr ""
#: warehouse/templates/manage/account.html:780
#: warehouse/templates/manage/organization/history.html:201
-#: warehouse/templates/manage/project/history.html:304
+#: warehouse/templates/manage/project/history.html:352
#: warehouse/templates/manage/team/history.html:108
#: warehouse/templates/manage/unverified-account.html:466
msgid "Event"
@@ -3587,8 +3643,8 @@ msgstr ""
#: warehouse/templates/manage/account.html:781
#: warehouse/templates/manage/organization/history.html:202
#: warehouse/templates/manage/organization/history.html:211
-#: warehouse/templates/manage/project/history.html:305
-#: warehouse/templates/manage/project/history.html:314
+#: warehouse/templates/manage/project/history.html:353
+#: warehouse/templates/manage/project/history.html:362
#: warehouse/templates/manage/team/history.html:109
#: warehouse/templates/manage/team/history.html:118
#: warehouse/templates/manage/unverified-account.html:467
@@ -3615,7 +3671,7 @@ msgstr ""
#: warehouse/templates/manage/account.html:795
#: warehouse/templates/manage/organization/history.html:217
-#: warehouse/templates/manage/project/history.html:320
+#: warehouse/templates/manage/project/history.html:368
#: warehouse/templates/manage/team/history.html:124
#: warehouse/templates/manage/unverified-account.html:481
msgid "Device Info"
@@ -3948,6 +4004,8 @@ msgstr ""
#: warehouse/templates/manage/project/history.html:137
#: warehouse/templates/manage/project/history.html:182
#: warehouse/templates/manage/project/history.html:208
+#: warehouse/templates/manage/project/history.html:299
+#: warehouse/templates/manage/project/history.html:321
#: warehouse/templates/manage/team/history.html:88
msgid "Added by:"
msgstr ""
@@ -4400,6 +4458,7 @@ msgstr ""
#: warehouse/templates/manage/project/publishing.html:275
#: warehouse/templates/manage/project/publishing.html:357
#: warehouse/templates/manage/project/roles.html:341
+#: warehouse/templates/manage/project/settings.html:348
#: warehouse/templates/manage/team/roles.html:131
msgid "Add"
msgstr ""
@@ -5136,6 +5195,8 @@ msgid "Created by:"
msgstr ""
#: warehouse/templates/manage/organization/history.html:144
+#: warehouse/templates/manage/project/history.html:310
+#: warehouse/templates/manage/project/history.html:332
#: warehouse/templates/manage/team/history.html:76
msgid "Deleted by:"
msgstr ""
@@ -5173,7 +5234,7 @@ msgid "Revoked by:"
msgstr ""
#: warehouse/templates/manage/organization/history.html:198
-#: warehouse/templates/manage/project/history.html:301
+#: warehouse/templates/manage/project/history.html:349
#: warehouse/templates/manage/team/history.html:105
#, python-format
msgid "Security history for %(source_name)s"
@@ -5439,6 +5500,7 @@ msgstr ""
#: warehouse/templates/manage/organization/roles.html:252
#: warehouse/templates/manage/project/release.html:112
#: warehouse/templates/manage/project/releases.html:109
+#: warehouse/templates/manage/project/settings.html:252
msgid "Delete"
msgstr ""
@@ -5826,11 +5888,31 @@ msgstr ""
msgid "Disabled by:"
msgstr ""
-#: warehouse/templates/manage/project/history.html:306
+#: warehouse/templates/manage/project/history.html:297
+#: warehouse/templates/manage/project/history.html:319
+msgid "Project alternate repository added"
+msgstr ""
+
+#: warehouse/templates/manage/project/history.html:302
+#: warehouse/templates/manage/project/history.html:313
+#: warehouse/templates/manage/project/history.html:324
+#: warehouse/templates/manage/project/history.html:335
+#: warehouse/templates/manage/project/settings.html:225
+#: warehouse/templates/manage/project/settings.html:305
+#: warehouse/templates/manage/project/settings.html:311
+msgid "Url"
+msgstr ""
+
+#: warehouse/templates/manage/project/history.html:308
+#: warehouse/templates/manage/project/history.html:330
+msgid "Project alternate repository deleted"
+msgstr ""
+
+#: warehouse/templates/manage/project/history.html:354
msgid "Additional info"
msgstr ""
-#: warehouse/templates/manage/project/history.html:318
+#: warehouse/templates/manage/project/history.html:366
#: warehouse/templates/manage/team/history.html:122
msgid "Location info"
msgstr ""
@@ -6417,7 +6499,7 @@ msgstr ""
#: warehouse/templates/manage/project/settings.html:108
#: warehouse/templates/manage/project/settings.html:179
-#: warehouse/templates/manage/project/settings.html:246
+#: warehouse/templates/manage/project/settings.html:395
msgid "Project Name"
msgstr ""
@@ -6504,16 +6586,74 @@ msgstr ""
msgid "You are not an owner or manager of any organizations."
msgstr ""
-#: warehouse/templates/manage/project/settings.html:206
-#: warehouse/templates/manage/project/settings.html:246
-msgid "Delete project"
+#: warehouse/templates/manage/project/settings.html:205
+msgid "Alternate repository locations"
msgstr ""
#: warehouse/templates/manage/project/settings.html:209
+#, python-format
+msgid ""
+"Provisional support for PEP 708 \"Alternate "
+"Locations\" Metadata."
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:213
+#, python-format
+msgid ""
+"Implementation may change, consider subscribing to pypi-announce to be notified of "
+"changes."
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:220
+#, python-format
+msgid "Alternate repository locations for %(project_name)s"
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:226
+msgid "Description"
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:245
+#, python-format
+msgid "Delete %(name)s from this project."
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:253
+msgid "Alternate Repository Name"
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:268
+msgid "There are no alternate repositories for this project, yet."
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:272
+msgid "Get started by adding an alternate repository below."
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:278
+msgid "Add alternate repository location"
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:325
+msgid "Alternate repository description"
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:331
+#: warehouse/templates/manage/project/settings.html:342
+msgid "Description of the purpose or content of the alternate repository."
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:355
+#: warehouse/templates/manage/project/settings.html:395
+msgid "Delete project"
+msgstr ""
+
+#: warehouse/templates/manage/project/settings.html:358
msgid "Deleting this project will:"
msgstr ""
-#: warehouse/templates/manage/project/settings.html:214
+#: warehouse/templates/manage/project/settings.html:363
#, python-format
msgid ""
"Irreversibly delete the project along with %(count)s"
@@ -6524,15 +6664,15 @@ msgid_plural ""
msgstr[0] ""
msgstr[1] ""
-#: warehouse/templates/manage/project/settings.html:220
+#: warehouse/templates/manage/project/settings.html:369
msgid "Irreversibly delete the project"
msgstr ""
-#: warehouse/templates/manage/project/settings.html:224
+#: warehouse/templates/manage/project/settings.html:373
msgid "Make the project name available to any other PyPI user"
msgstr ""
-#: warehouse/templates/manage/project/settings.html:226
+#: warehouse/templates/manage/project/settings.html:375
msgid ""
"This user will be able to make new releases under this project name, so "
"long as the distribution filenames do not match filenames from a "
diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py
index aed856dffa0f..e2588ec576c7 100644
--- a/warehouse/manage/forms.py
+++ b/warehouse/manage/forms.py
@@ -711,3 +711,52 @@ def validate_name(self, field):
class CreateTeamForm(SaveTeamForm):
__params__ = SaveTeamForm.__params__
+
+
+class AddAlternateRepositoryForm(forms.Form):
+ """Form to add an Alternate Repository Location for a Project."""
+
+ __params__ = ["display_name", "link_url", "description"]
+
+ display_name = wtforms.StringField(
+ validators=[
+ wtforms.validators.InputRequired(
+ message=_("Specify your alternate repository name"),
+ ),
+ wtforms.validators.Length(
+ max=100,
+ message=_(
+ "The name is too long. "
+ "Choose a name with 100 characters or less."
+ ),
+ ),
+ ]
+ )
+ link_url = wtforms.URLField(
+ validators=[
+ wtforms.validators.InputRequired(
+ message=_("Specify your alternate repository URL"),
+ ),
+ wtforms.validators.Length(
+ max=400,
+ message=_(
+ "The URL is too long. Choose a URL with 400 characters or less."
+ ),
+ ),
+ forms.URIValidator(),
+ ]
+ )
+ description = wtforms.TextAreaField(
+ validators=[
+ wtforms.validators.InputRequired(
+ message="Describe the purpose and content of the alternate repository."
+ ),
+ wtforms.validators.Length(
+ max=400,
+ message=_(
+ "The description is too long. "
+ "Choose a description with 400 characters or less."
+ ),
+ ),
+ ]
+ )
diff --git a/warehouse/manage/views/__init__.py b/warehouse/manage/views/__init__.py
index 9e8cb7cf5175..09653006b703 100644
--- a/warehouse/manage/views/__init__.py
+++ b/warehouse/manage/views/__init__.py
@@ -12,6 +12,7 @@
import base64
import io
+import uuid
import pyqrcode
@@ -75,6 +76,7 @@
from warehouse.macaroons import caveats
from warehouse.macaroons.interfaces import IMacaroonService
from warehouse.manage.forms import (
+ AddAlternateRepositoryForm,
AddEmailForm,
ChangePasswordForm,
ChangeRoleForm,
@@ -126,6 +128,7 @@
TeamRole,
)
from warehouse.packaging.models import (
+ AlternateRepository,
File,
JournalEntry,
Project,
@@ -1123,6 +1126,7 @@ def __init__(self, project, request):
self.project = project
self.request = request
self.transfer_organization_project_form_class = TransferOrganizationProjectForm
+ self.add_alternate_repository_form_class = AddAlternateRepositoryForm
@view_config(request_method="GET")
def manage_project_settings(self):
@@ -1149,6 +1153,8 @@ def manage_project_settings(self):
active_organizations_owned | active_organizations_managed
) - current_organization
+ add_alt_repo_form = self.add_alternate_repository_form_class()
+
return {
"project": self.project,
"MAX_FILESIZE": MAX_FILESIZE,
@@ -1158,8 +1164,178 @@ def manage_project_settings(self):
organization_choices=organization_choices,
)
),
+ "add_alternate_repository_form_class": add_alt_repo_form,
}
+ @view_config(
+ request_method="POST",
+ request_param=AddAlternateRepositoryForm.__params__
+ + ["alternate_repository_location=add"],
+ require_reauth=True,
+ permission=Permissions.ProjectsWrite,
+ )
+ def add_project_alternate_repository(self):
+ form = self.add_alternate_repository_form_class(self.request.POST)
+
+ if not form.validate():
+ self.request.session.flash(
+ self.request._("Invalid alternate repository location details"),
+ queue="error",
+ )
+ return HTTPSeeOther(
+ self.request.route_path(
+ "manage.project.settings",
+ project_name=self.project.name,
+ )
+ )
+
+ # add the alternate repository location entry
+ alt_repo = AlternateRepository(
+ project=self.project,
+ name=form.display_name.data,
+ url=form.link_url.data,
+ description=form.description.data,
+ )
+ self.request.db.add(alt_repo)
+ self.request.db.add(
+ JournalEntry(
+ name=alt_repo.name,
+ action=f"add alternate repository {alt_repo.name} "
+ f"to project {self.project.name}",
+ submitted_by=self.request.user,
+ )
+ )
+ self.project.record_event(
+ tag=EventTag.Project.AlternateRepositoryAdd,
+ request=self.request,
+ additional={
+ "added_by": self.request.user.username,
+ "display_name": alt_repo.name,
+ "link_url": alt_repo.url,
+ },
+ )
+ self.request.user.record_event(
+ tag=EventTag.Account.AlternateRepositoryAdd,
+ request=self.request,
+ additional={
+ "added_by": self.request.user.username,
+ "display_name": alt_repo.name,
+ "link_url": alt_repo.url,
+ },
+ )
+ self.request.session.flash(
+ self.request._(
+ "Added alternate repository '${name}'",
+ mapping={"name": alt_repo.name},
+ ),
+ queue="success",
+ )
+
+ return HTTPSeeOther(
+ self.request.route_path(
+ "manage.project.settings",
+ project_name=self.project.name,
+ )
+ )
+
+ @view_config(
+ request_method="POST",
+ request_param=[
+ "alternate_repository_id",
+ "alternate_repository_location=delete",
+ ],
+ require_reauth=True,
+ permission=Permissions.ProjectsWrite,
+ )
+ def delete_project_alternate_repository(self):
+ confirm_name = self.request.POST.get("confirm_alternate_repository_name")
+ resp_inst = HTTPSeeOther(
+ self.request.route_path(
+ "manage.project.settings", project_name=self.project.name
+ )
+ )
+
+ # Must confirm alt repo name to delete.
+ if not confirm_name:
+ self.request.session.flash(
+ self.request._("Confirm the request"), queue="error"
+ )
+ return resp_inst
+
+ # Must provide a valid alt repo id.
+ alternate_repository_id = self.request.POST.get("alternate_repository_id", "")
+ try:
+ uuid.UUID(str(alternate_repository_id))
+ except ValueError:
+ alternate_repository_id = None
+ if not alternate_repository_id:
+ self.request.session.flash(
+ self.request._("Invalid alternate repository id"),
+ queue="error",
+ )
+ return resp_inst
+
+ # The provided alt repo id must be related to this project.
+ alt_repo: AlternateRepository = self.request.db.get(
+ AlternateRepository, alternate_repository_id
+ )
+ if not alt_repo or alt_repo not in self.project.alternate_repositories:
+ self.request.session.flash(
+ self.request._("Invalid alternate repository for project"),
+ queue="error",
+ )
+ return resp_inst
+
+ # The confirmed alt repo name must match the provided alt repo id.
+ if confirm_name != alt_repo.name:
+ self.request.session.flash(
+ self.request._(
+ "Could not delete alternate repository - "
+ "${confirm} is not the same as ${alt_repo_name}",
+ mapping={"confirm": confirm_name, "alt_repo_name": alt_repo.name},
+ ),
+ queue="error",
+ )
+ return resp_inst
+
+ # delete the alternate repository location entry
+ self.request.db.delete(alt_repo)
+ self.request.db.add(
+ JournalEntry(
+ name=alt_repo.name,
+ action=f"deleted alternate repository {alt_repo.name} "
+ f"from project {self.project.name}",
+ submitted_by=self.request.user,
+ )
+ )
+ self.project.record_event(
+ tag=EventTag.Project.AlternateRepositoryDelete,
+ request=self.request,
+ additional={
+ "deleted_by": self.request.user.username,
+ "display_name": alt_repo.name,
+ "link_url": alt_repo.url,
+ },
+ )
+ self.request.user.record_event(
+ tag=EventTag.Account.AlternateRepositoryDelete,
+ request=self.request,
+ additional={
+ "deleted_by": self.request.user.username,
+ "display_name": alt_repo.name,
+ "link_url": alt_repo.url,
+ },
+ )
+ self.request.session.flash(
+ self.request._(
+ "Deleted alternate repository '${name}'",
+ mapping={"name": alt_repo.name},
+ ),
+ queue="success",
+ )
+
+ return resp_inst
+
@view_defaults(
context=Project,
diff --git a/warehouse/migrations/versions/a8050411bc65_add_project_alternate_repositories_table.py b/warehouse/migrations/versions/a8050411bc65_add_project_alternate_repositories_table.py
new file mode 100644
index 000000000000..a7e401cd2047
--- /dev/null
+++ b/warehouse/migrations/versions/a8050411bc65_add_project_alternate_repositories_table.py
@@ -0,0 +1,51 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+add project alternate repositories table
+
+Revision ID: a8050411bc65
+Revises:
+Create Date: 2024-04-25 00:26:09.199573
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "a8050411bc65"
+down_revision = "0b74ed7d4880"
+
+
+def upgrade():
+ op.create_table(
+ "alternate_repositories",
+ sa.Column("project_id", sa.UUID(), nullable=False),
+ sa.Column("name", sa.String(), nullable=False),
+ sa.Column("url", sa.String(), nullable=False),
+ sa.Column("description", sa.String(), nullable=False),
+ sa.Column(
+ "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
+ ),
+ sa.CheckConstraint(
+ "url ~* '^https?://.+'::text", name="alternate_repository_valid_url"
+ ),
+ sa.ForeignKeyConstraint(
+ ["project_id"], ["projects.id"], onupdate="CASCADE", ondelete="CASCADE"
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("project_id", "name"),
+ sa.UniqueConstraint("project_id", "url"),
+ )
+
+
+def downgrade():
+ op.drop_table("alternate_repositories")
diff --git a/warehouse/packaging/__init__.py b/warehouse/packaging/__init__.py
index a219800967b8..21f4838350b6 100644
--- a/warehouse/packaging/__init__.py
+++ b/warehouse/packaging/__init__.py
@@ -24,7 +24,7 @@
IProjectService,
ISimpleStorage,
)
-from warehouse.packaging.models import File, Project, Release, Role
+from warehouse.packaging.models import AlternateRepository, File, Project, Release, Role
from warehouse.packaging.services import project_service_factory
from warehouse.packaging.tasks import (
check_file_cache_tasks_outstanding,
@@ -178,6 +178,13 @@ def includeme(config):
key_factory("project/{itr.normalized_name}", iterate_on="projects"),
],
)
+ config.register_origin_cache_keys(
+ AlternateRepository,
+ cache_keys=["project/{obj.project.normalized_name}"],
+ purge_keys=[
+ key_factory("project/{obj.project.normalized_name}"),
+ ],
+ )
config.add_periodic_task(crontab(minute="*/1"), check_file_cache_tasks_outstanding)
diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py
index 4734dbfc954a..3f6f397d8ae9 100644
--- a/warehouse/packaging/models.py
+++ b/warehouse/packaging/models.py
@@ -233,6 +233,11 @@ class Project(SitemapMixin, HasEvents, HasObservations, db.Model):
order_by=lambda: Release._pypi_ordering.desc(),
passive_deletes=True,
)
+ alternate_repositories: Mapped[list[AlternateRepository]] = orm.relationship(
+ cascade="all, delete-orphan",
+ back_populates="project",
+ passive_deletes=True,
+ )
__table_args__ = (
CheckConstraint(
@@ -1025,3 +1030,34 @@ class ProjectMacaroonWarningAssociation(db.Model):
ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True,
)
+
+
+class AlternateRepository(db.Model):
+ """
+ Store an alternate repository name, url, description for a project.
+ One project can have zero, one, or more alternate repositories.
+
+ For each project, ensures the url and name are unique.
+ Urls must start with http(s).
+ """
+
+ __tablename__ = "alternate_repositories"
+ __table_args__ = (
+ UniqueConstraint("project_id", "url"),
+ UniqueConstraint("project_id", "name"),
+ CheckConstraint(
+ "url ~* '^https?://.+'::text",
+ name="alternate_repository_valid_url",
+ ),
+ )
+
+ __repr__ = make_repr("name", "url")
+
+ project_id: Mapped[UUID] = mapped_column(
+ ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
+ )
+ project: Mapped[Project] = orm.relationship(back_populates="alternate_repositories")
+
+ name: Mapped[str]
+ url: Mapped[str]
+ description: Mapped[str]
diff --git a/warehouse/packaging/utils.py b/warehouse/packaging/utils.py
index 30c85b3feb50..282c45747f75 100644
--- a/warehouse/packaging/utils.py
+++ b/warehouse/packaging/utils.py
@@ -22,7 +22,7 @@
from warehouse.packaging.interfaces import ISimpleStorage
from warehouse.packaging.models import File, LifecycleStatus, Project, Release
-API_VERSION = "1.1"
+API_VERSION = "1.2"
def _simple_index(request, serial):
@@ -63,11 +63,15 @@ def _simple_detail(project, request):
versions = sorted(
{f.release.version for f in files}, key=packaging_legacy.version.parse
)
+ alternate_repositories = sorted(
+ alt_repo.url for alt_repo in project.alternate_repositories
+ )
return {
"meta": {"api-version": API_VERSION, "_last-serial": project.last_serial},
"name": project.normalized_name,
"versions": versions,
+ "alternate-locations": alternate_repositories,
"files": [
{
"filename": file.filename,
@@ -105,6 +109,7 @@ def _simple_detail(project, request):
def render_simple_detail(project, request, store=False):
context = _simple_detail(project, request)
+ context = _valid_simple_detail_context(context)
env = request.registry.queryUtility(IJinja2Environment, name=".jinja2")
template = env.get_template("templates/api/simple/detail.html")
@@ -144,3 +149,8 @@ def render_simple_detail(project, request, store=False):
)
return (content_hash, simple_detail_path)
+
+
+def _valid_simple_detail_context(context: dict) -> dict:
+ context["alternate_locations"] = context.pop("alternate-locations", [])
+ return context
diff --git a/warehouse/templates/api/simple/detail.html b/warehouse/templates/api/simple/detail.html
index 24b0042c5863..28a8f49c815d 100644
--- a/warehouse/templates/api/simple/detail.html
+++ b/warehouse/templates/api/simple/detail.html
@@ -15,6 +15,9 @@
+ {% for alt_repo in alternate_locations %}
+
+ {% endfor %}
+ {% trans pep_url="https://peps.python.org/pep-0708/#alternate-locations-metadata"
+ %}Provisional support for PEP 708 "Alternate Locations" Metadata. {% endtrans %}
+
+ {% trans pypi_announce_url="https://mail.python.org/mailman3/lists/pypi-announce.python.org/"
+ %}Implementation may change, consider subscribing to pypi-announce to be notified of changes.{% endtrans %}
+ {% trans %}Security history{% endtrans %}
{% trans %}Disabled by:{% endtrans %} {{ event.additional.modified_by }}
+
+ {# Display Project Alternate Repository events #}
+ {% elif event.tag == EventTag.Account.AlternateRepositoryAdd %}
+ {% trans%}Project alternate repository added{% endtrans %}
+
+ {% trans %}Added by:{% endtrans %} {{ event.additional.added_by }}
+
+ {% trans %}Name{% endtrans %}: {{ event.additional.display_name }}
+ {% trans %}Url{% endtrans %}:
+
+ {{ event.additional.link_url }}
+
+
+ {% elif event.tag == EventTag.Account.AlternateRepositoryDelete %}
+ {% trans%}Project alternate repository deleted{% endtrans %}
+
+ {% trans %}Deleted by:{% endtrans %} {{ event.additional.deleted_by }}
+
+ {% trans %}Name{% endtrans %}: {{ event.additional.display_name }}
+ {% trans %}Url{% endtrans %}:
+
+ {{ event.additional.link_url }}
+
+
+ {% elif event.tag == EventTag.Project.AlternateRepositoryAdd %}
+ {% trans%}Project alternate repository added{% endtrans %}
+
+ {% trans %}Added by:{% endtrans %} {{ event.additional.added_by }}
+
+ {% trans %}Name{% endtrans %}: {{ event.additional.display_name }}
+ {% trans %}Url{% endtrans %}:
+
+ {{ event.additional.link_url }}
+
+
+ {% elif event.tag == EventTag.Project.AlternateRepositoryDelete %}
+ {% trans%}Project alternate repository deleted{% endtrans %}
+
+ {% trans %}Deleted by:{% endtrans %} {{ event.additional.deleted_by }}
+
+ {% trans %}Name{% endtrans %}: {{ event.additional.display_name }}
+ {% trans %}Url{% endtrans %}:
+
+ {{ event.additional.link_url }}
+
+
+
+ {# Show the name of tags that are not catered for above #}
{% else %}
- {{ event.tag }}
+ {{ event.tag }}
{% endif %}
{%- endmacro %}
diff --git a/warehouse/templates/manage/project/settings.html b/warehouse/templates/manage/project/settings.html
index f180fd43345d..609b684067f6 100644
--- a/warehouse/templates/manage/project/settings.html
+++ b/warehouse/templates/manage/project/settings.html
@@ -202,6 +202,155 @@
{% endif %}
+ {% trans %}Alternate repository locations{% endtrans %}
+
+
{% trans %}Name{% endtrans %} | +{% trans %}Url{% endtrans %} | +{% trans %}Description{% endtrans %} | ++ |
---|---|---|---|
+ {{ alternate_repository.name }} + | ++ + {{ alternate_repository.url }} + + | ++ {{ alternate_repository.description }} + | ++ {% set extra_description %}{% trans name=alternate_repository.name %}Delete {{ name }} from this + project.{% endtrans %}{% endset %} + {% set extra_fields %} + + + {% endset %} + {{ confirm_button( + gettext("Delete"), + gettext("Alternate Repository Name"), + "alternate_repository_name", + alternate_repository.name, + extra_description=extra_description, + action=request.route_path('manage.project.settings', project_name=project.name), + extra_fields=extra_fields, + tooltip=extra_description + ) }} + | +
+ {% trans %}There are no alternate repositories for this project, yet.{% endtrans %} +
+ {% if request.has_permission(Permissions.ProjectsWrite) %} ++ {% trans %}Get started by adding an alternate repository below.{% endtrans %} +
+ {% endif %} + {% endif %} + +