Repository: pypi/warehouse

Issue:
"no verified email addresses" error message generates wrong URL for pypi.org help
I'm trying to upload a package using `python3 setup.py sdist upload`. It fails:
```
error: Upload failed (400): User 'whitequark' has no verified email addresses, please verify at least one address before registering a new project on PyPI. See https://upload.pypi.org/help/#verified-email for more information.
```

Sure. The document it links to says:
> You can manage your account's email addresses in your [Profile](https://upload.pypi.org/manage/account/). This also allows for sending a new confirmation email for users who signed up in the past, before we began enforcing this policy.

The Profile link is broken and leads to a 404 page.

Sure. Maybe I could reset my password instead. The help page says:

> You should be able to [reset your password](https://upload.pypi.org/account/reset-password/) on the site; you'll receive an email with a password reset link.

The reset password link is also broken and leads to a 404 page.

In addition there are no account management functions whatsoever linked from anywhere on [upload.pypi.org](https://upload.pypi.org).


Discussion:
Ah, the link to Profile should be to pypi.org, not upload.pypi.org.

That worked, thanks! 
Looks like this is a bug in how we're generating URLs in our error messages. Thanks for the report!
~I think the URL in the error is fine? At least, it correctly leads me to the help page. It's the others that return 404.~ Nevermind, I just figured out that https://pypi.org/help/#verified-email has the correct URLs.
The help page is actually at https://pypi.org/help/, the upload domain shouldn't be displaying it.
This affected another user: https://github.com/pypa/packaging-problems/issues/137#issuecomment-379971088

Resolving patch:
diff --git a/warehouse/forklift/__init__.py b/warehouse/forklift/__init__.py
--- a/warehouse/forklift/__init__.py
+++ b/warehouse/forklift/__init__.py
@@ -15,6 +15,11 @@
 #       project.
 
 
+def _help_url(request, **kwargs):
+    warehouse_domain = request.registry.settings.get("warehouse.domain")
+    return request.route_url('help', _host=warehouse_domain, **kwargs)
+
+
 def includeme(config):
     # We need to get the value of the Warehouse and Forklift domains, we'll use
     # these to segregate the Warehouse routes from the Forklift routes until
@@ -45,6 +50,7 @@ def includeme(config):
         "doc_upload",
         domain=forklift,
     )
+    config.add_request_method(_help_url, name="help_url")
 
     if forklift:
         config.add_template_view(
diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py
--- a/warehouse/forklift/legacy.py
+++ b/warehouse/forklift/legacy.py
@@ -820,8 +820,8 @@ def file_upload(request):
                 ("New Project Registration Temporarily Disabled "
                  "See {projecthelp} for details")
                 .format(
-                    projecthelp=request.route_url(
-                        'help', _anchor='admin-intervention')),
+                    projecthelp=request.help_url(_anchor='admin-intervention'),
+                ),
             ) from None
 
         # Ensure that user has at least one verified email address. This should
@@ -835,10 +835,9 @@ def file_upload(request):
                  "please verify at least one address before registering "
                  "a new project on PyPI. See {projecthelp} "
                  "for more information.").format(
-                     request.user.username,
-                     projecthelp=request.route_url(
-                         'help', _anchor='verified-email'
-                     )),
+                    request.user.username,
+                    projecthelp=request.help_url(_anchor='verified-email'),
+                ),
             ) from None
 
         # Before we create the project, we're going to check our blacklist to
@@ -853,8 +852,8 @@ def file_upload(request):
                  "See {projecthelp} "
                  "for more information.").format(
                     name=form.name.data,
-                    projecthelp=request.route_url(
-                        'help', _anchor='project-name')),
+                    projecthelp=request.help_url(_anchor='project-name'),
+                ),
             ) from None
 
         # Also check for collisions with Python Standard Library modules.
@@ -865,9 +864,9 @@ def file_upload(request):
                 ("The name {name!r} is not allowed (conflict with Python "
                  "Standard Library module name). See "
                  "{projecthelp} for more information.").format(
-                     name=form.name.data,
-                     projecthelp=request.route_url(
-                         'help', _anchor='project-name')),
+                    name=form.name.data,
+                    projecthelp=request.help_url(_anchor='project-name')
+                )
             ) from None
 
         # The project doesn't exist in our database, so we'll add it along with
@@ -905,8 +904,10 @@ def file_upload(request):
             HTTPForbidden,
             ("The user '{0}' is not allowed to upload to project '{1}'. "
              "See {2} for more information.")
-            .format(request.user.username, project.name, request.route_url(
-                'help', _anchor='project-name')
+            .format(
+                request.user.username,
+                project.name,
+                request.help_url(_anchor='project-name')
             )
         )
 
@@ -1073,9 +1074,7 @@ def file_upload(request):
                             name=project.name,
                             limit=file_size_limit // (1024 * 1024)) +
                         "See " +
-                        request.route_url(
-                            'help', _anchor='file-size-limit'
-                        ),
+                        request.help_url(_anchor='file-size-limit'),
                     )
                 fp.write(chunk)
                 for hasher in file_hashes.values():
@@ -1118,9 +1117,7 @@ def file_upload(request):
                 # ref: https://github.com/pypa/warehouse/issues/3482
                 # ref: https://github.com/pypa/twine/issues/332
                 "File already exists. See " +
-                request.route_url(
-                    'help', _anchor='file-name-reuse'
-                )
+                request.help_url(_anchor='file-name-reuse')
             )
 
         # Check to see if the file that was uploaded exists in our filename log
@@ -1132,9 +1129,7 @@ def file_upload(request):
                 HTTPBadRequest,
                 "This filename has previously been used, you should use a "
                 "different version. "
-                "See " + request.route_url(
-                    'help', _anchor='file-name-reuse'
-                ),
+                "See " + request.help_url(_anchor='file-name-reuse'),
             )
 
         # Check to see if uploading this file would create a duplicate sdist


Test patch:
diff --git a/tests/unit/forklift/test_init.py b/tests/unit/forklift/test_init.py
--- a/tests/unit/forklift/test_init.py
+++ b/tests/unit/forklift/test_init.py
@@ -13,51 +13,84 @@
 import pretend
 import pytest
 
-from warehouse.forklift import includeme
+from warehouse import forklift
 
 
-@pytest.mark.parametrize("forklift", [None, "upload.pypi.io"])
-def test_includeme(forklift):
+@pytest.mark.parametrize("forklift_domain", [None, "upload.pypi.io"])
+def test_includeme(forklift_domain, monkeypatch):
     settings = {}
-    if forklift:
-        settings["forklift.domain"] = forklift
+    if forklift_domain:
+        settings["forklift.domain"] = forklift_domain
+
+    _help_url = pretend.stub()
+    monkeypatch.setattr(forklift, '_help_url', _help_url)
 
     config = pretend.stub(
         get_settings=lambda: settings,
         include=pretend.call_recorder(lambda n: None),
         add_legacy_action_route=pretend.call_recorder(lambda *a, **k: None),
         add_template_view=pretend.call_recorder(lambda *a, **kw: None),
+        add_request_method=pretend.call_recorder(lambda *a, **kw: None),
     )
 
-    includeme(config)
+    forklift.includeme(config)
 
-    config.include.calls == [pretend.call(".action_routing")]
-    config.add_legacy_action_route.calls == [
+    assert config.include.calls == [pretend.call(".action_routing")]
+    assert config.add_legacy_action_route.calls == [
         pretend.call(
             "forklift.legacy.file_upload",
             "file_upload",
-            domain=forklift,
+            domain=forklift_domain,
+        ),
+        pretend.call(
+            "forklift.legacy.submit",
+            "submit",
+            domain=forklift_domain,
         ),
-        pretend.call("forklift.legacy.submit", "submit", domain=forklift),
         pretend.call(
             "forklift.legacy.submit_pkg_info",
             "submit_pkg_info",
-            domain=forklift,
+            domain=forklift_domain,
         ),
         pretend.call(
             "forklift.legacy.doc_upload",
             "doc_upload",
-            domain=forklift,
+            domain=forklift_domain,
         ),
     ]
-    if forklift:
-        config.add_template_view.calls == [
+    assert config.add_request_method.calls == [
+        pretend.call(_help_url, name='help_url'),
+    ]
+    if forklift_domain:
+        assert config.add_template_view.calls == [
             pretend.call(
                 "forklift.index",
                 "/",
                 "upload.html",
-                route_kw={"domain": forklift},
+                route_kw={"domain": forklift_domain},
+            ),
+            pretend.call(
+                'forklift.legacy.invalid_request',
+                '/legacy/',
+                'upload.html',
+                route_kw={'domain': 'upload.pypi.io'},
             ),
         ]
     else:
-        config.add_template_view.calls == []
+        assert config.add_template_view.calls == []
+
+
+def test_help_url():
+    warehouse_domain = pretend.stub()
+    result = pretend.stub()
+    request = pretend.stub(
+        route_url=pretend.call_recorder(lambda *a, **kw: result),
+        registry=pretend.stub(
+            settings={'warehouse.domain': warehouse_domain},
+        ),
+    )
+
+    assert forklift._help_url(request, _anchor='foo') == result
+    assert request.route_url.calls == [
+        pretend.call('help', _host=warehouse_domain, _anchor='foo'),
+    ]
diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py
--- a/tests/unit/forklift/test_legacy.py
+++ b/tests/unit/forklift/test_legacy.py
@@ -959,8 +959,8 @@ def test_fails_with_invalid_names(self, pyramid_config, db_request, name):
             ),
         })
 
-        db_request.route_url = pretend.call_recorder(
-            lambda route, **kw: "/the/help/url/"
+        db_request.help_url = pretend.call_recorder(
+            lambda **kw: "/the/help/url/"
         )
 
         with pytest.raises(HTTPBadRequest) as excinfo:
@@ -968,8 +968,8 @@ def test_fails_with_invalid_names(self, pyramid_config, db_request, name):
 
         resp = excinfo.value
 
-        assert db_request.route_url.calls == [
-            pretend.call('help', _anchor='project-name')
+        assert db_request.help_url.calls == [
+            pretend.call(_anchor='project-name')
         ]
 
         assert resp.status_code == 400
@@ -1005,8 +1005,8 @@ def test_fails_with_stdlib_names(self, pyramid_config, db_request, name):
             ),
         })
 
-        db_request.route_url = pretend.call_recorder(
-            lambda route, **kw: "/the/help/url/"
+        db_request.help_url = pretend.call_recorder(
+            lambda **kw: "/the/help/url/"
         )
 
         with pytest.raises(HTTPBadRequest) as excinfo:
@@ -1014,8 +1014,8 @@ def test_fails_with_stdlib_names(self, pyramid_config, db_request, name):
 
         resp = excinfo.value
 
-        assert db_request.route_url.calls == [
-            pretend.call('help', _anchor='project-name')
+        assert db_request.help_url.calls == [
+            pretend.call(_anchor='project-name')
         ]
 
         assert resp.status_code == 400
@@ -1045,8 +1045,8 @@ def test_fails_with_admin_flag_set(self, pyramid_config, db_request):
             ),
         })
 
-        db_request.route_url = pretend.call_recorder(
-            lambda route, **kw: "/the/help/url/"
+        db_request.help_url = pretend.call_recorder(
+            lambda **kw: "/the/help/url/"
         )
 
         with pytest.raises(HTTPForbidden) as excinfo:
@@ -1650,8 +1650,8 @@ def test_upload_fails_with_too_large_file(self, pyramid_config,
                 type="application/tar",
             ),
         })
-        db_request.route_url = pretend.call_recorder(
-            lambda route, **kw: "/the/help/url/"
+        db_request.help_url = pretend.call_recorder(
+            lambda **kw: "/the/help/url/"
         )
 
         with pytest.raises(HTTPBadRequest) as excinfo:
@@ -1659,8 +1659,8 @@ def test_upload_fails_with_too_large_file(self, pyramid_config,
 
         resp = excinfo.value
 
-        assert db_request.route_url.calls == [
-            pretend.call('help', _anchor='file-size-limit')
+        assert db_request.help_url.calls == [
+            pretend.call(_anchor='file-size-limit')
         ]
         assert resp.status_code == 400
         assert resp.status == (
@@ -1732,8 +1732,8 @@ def test_upload_fails_with_previously_used_filename(self, pyramid_config,
         })
 
         db_request.db.add(Filename(filename=filename))
-        db_request.route_url = pretend.call_recorder(
-            lambda route, **kw: "/the/help/url/"
+        db_request.help_url = pretend.call_recorder(
+            lambda **kw: "/the/help/url/"
         )
 
         with pytest.raises(HTTPBadRequest) as excinfo:
@@ -1741,8 +1741,8 @@ def test_upload_fails_with_previously_used_filename(self, pyramid_config,
 
         resp = excinfo.value
 
-        assert db_request.route_url.calls == [
-            pretend.call('help', _anchor='file-name-reuse')
+        assert db_request.help_url.calls == [
+            pretend.call(_anchor='file-name-reuse'),
         ]
         assert resp.status_code == 400
         assert resp.status == (
@@ -1846,16 +1846,16 @@ def test_upload_fails_with_existing_filename_diff_content(self,
                 ),
             ),
         )
-        db_request.route_url = pretend.call_recorder(
-            lambda route, **kw: "/the/help/url/"
+        db_request.help_url = pretend.call_recorder(
+            lambda **kw: "/the/help/url/"
         )
         with pytest.raises(HTTPBadRequest) as excinfo:
             legacy.file_upload(db_request)
 
         resp = excinfo.value
 
-        assert db_request.route_url.calls == [
-            pretend.call('help', _anchor='file-name-reuse')
+        assert db_request.help_url.calls == [
+            pretend.call(_anchor='file-name-reuse')
         ]
         assert resp.status_code == 400
         assert resp.status == "400 File already exists. See /the/help/url/"
@@ -1904,8 +1904,8 @@ def test_upload_fails_with_diff_filename_same_blake2(self,
                 ),
             ),
         )
-        db_request.route_url = pretend.call_recorder(
-            lambda route, **kw: "/the/help/url/"
+        db_request.help_url = pretend.call_recorder(
+            lambda **kw: "/the/help/url/"
         )
 
         with pytest.raises(HTTPBadRequest) as excinfo:
@@ -1913,8 +1913,8 @@ def test_upload_fails_with_diff_filename_same_blake2(self,
 
         resp = excinfo.value
 
-        assert db_request.route_url.calls == [
-            pretend.call('help', _anchor='file-name-reuse')
+        assert db_request.help_url.calls == [
+            pretend.call(_anchor='file-name-reuse')
         ]
         assert resp.status_code == 400
         assert resp.status == "400 File already exists. See /the/help/url/"
@@ -2058,8 +2058,8 @@ def test_upload_fails_without_permission(self, pyramid_config, db_request):
             ),
         })
 
-        db_request.route_url = pretend.call_recorder(
-            lambda route, **kw: "/the/help/url/"
+        db_request.help_url = pretend.call_recorder(
+            lambda **kw: "/the/help/url/"
         )
 
         with pytest.raises(HTTPForbidden) as excinfo:
@@ -2067,8 +2067,8 @@ def test_upload_fails_without_permission(self, pyramid_config, db_request):
 
         resp = excinfo.value
 
-        assert db_request.route_url.calls == [
-            pretend.call('help', _anchor='project-name')
+        assert db_request.help_url.calls == [
+            pretend.call(_anchor='project-name')
         ]
         assert resp.status_code == 403
         assert resp.status == (
@@ -2762,8 +2762,8 @@ def test_upload_requires_verified_email(self, pyramid_config, db_request,
             resp = legacy.file_upload(db_request)
             assert resp.status_code == 200
         else:
-            db_request.route_url = pretend.call_recorder(
-                lambda route, **kw: "/the/help/url/"
+            db_request.help_url = pretend.call_recorder(
+                lambda **kw: "/the/help/url/"
             )
 
             with pytest.raises(HTTPBadRequest) as excinfo:
@@ -2771,8 +2771,8 @@ def test_upload_requires_verified_email(self, pyramid_config, db_request,
 
             resp = excinfo.value
 
-            assert db_request.route_url.calls == [
-                pretend.call('help', _anchor='verified-email')
+            assert db_request.help_url.calls == [
+                pretend.call(_anchor='verified-email')
             ]
             assert resp.status_code == 400
             assert resp.status == (