Repository: pypi/warehouse

Issue:
Better error messages when metadata does not validate
Currently, if a kwarg in `setup.py` does not pass validation, the response from Warehouse is not really descriptive enough to indicate what the issue is.

For example, if one has an invalid `url`:
```
    url='foobar',
```

The response looks like:
```
HTTPError: 400 Client Error: home_page: Invalid URI for url: http://pypi.org/legacy/
```

This message is confusing because:

* it refers to both `home_page` and `url`
  * the former of which is what the actual metadata field is called (but even still it's `Home-Page` in the specifications
  * the latter of which is what setuptools calls it;
* the error message from twine includes the repo url, but the issue is with the kwarg value (`foobar`)

A better error message might be something like:
```
HTTPError: 400 Client Error: "foobar" is an invalid value for field "Home-Page",
see https://packaging.python.org/specifications/core-metadata/
```

But the ambiguity between `url` and `home-page` remains. 




Discussion:
Where is this validation taking place? After some searching I couldn't find the place where the error was being generated. Closes thing I found was this snippet in `forklift/legacy.py`
``` python
if not form.validate():
    for field_name in _error_message_order:
        if field_name in form.errors:
            break
    else:
        field_name = sorted(form.errors.keys())[0]

    raise _exc_with_message(
        HTTPBadRequest,
        "{field}: {msgs[0]}".format(
            field=field_name,
            msgs=form.errors[field_name],
        ),
    )
```
@alanbato Great question. It's a bit confusing, only a part of the error message that comes from Warehouse is what's bolded below:

> HTTPError: 400 Client Error: **home_page: Invalid URI** for url: http://pypi.org/legacy/

You've correctly found where the `home_page: <message>` bit comes from. The "Invalid URI" message comes from the form validation here: https://github.com/pypa/warehouse/blob/2a99b6f8b0ffa66a13d52b493c756cecc8307be1/warehouse/forms.py#L34

Resolving patch:
diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py
--- a/warehouse/forklift/legacy.py
+++ b/warehouse/forklift/legacy.py
@@ -274,6 +274,7 @@ class MetadataForm(forms.Form):
 
     # Metadata version
     metadata_version = wtforms.StringField(
+        description="Metadata-Version",
         validators=[
             wtforms.validators.DataRequired(),
             wtforms.validators.AnyOf(
@@ -288,6 +289,7 @@ class MetadataForm(forms.Form):
 
     # Identity Project and Release
     name = wtforms.StringField(
+        description="Name",
         validators=[
             wtforms.validators.DataRequired(),
             wtforms.validators.Regexp(
@@ -301,6 +303,7 @@ class MetadataForm(forms.Form):
         ],
     )
     version = wtforms.StringField(
+        description="Version",
         validators=[
             wtforms.validators.DataRequired(),
             wtforms.validators.Regexp(
@@ -313,6 +316,7 @@ class MetadataForm(forms.Form):
 
     # Additional Release metadata
     summary = wtforms.StringField(
+        description="Summary",
         validators=[
             wtforms.validators.Optional(),
             wtforms.validators.Length(max=512),
@@ -323,37 +327,57 @@ class MetadataForm(forms.Form):
         ],
     )
     description = wtforms.StringField(
+        description="Description",
+        validators=[wtforms.validators.Optional()],
+    )
+    author = wtforms.StringField(
+        description="Author",
         validators=[wtforms.validators.Optional()],
     )
-    author = wtforms.StringField(validators=[wtforms.validators.Optional()])
     author_email = wtforms.StringField(
+        description="Author-email",
         validators=[
             wtforms.validators.Optional(),
             wtforms.validators.Email(),
         ],
     )
     maintainer = wtforms.StringField(
+        description="Maintainer",
         validators=[wtforms.validators.Optional()],
     )
     maintainer_email = wtforms.StringField(
+        description="Maintainer-email",
         validators=[
             wtforms.validators.Optional(),
             wtforms.validators.Email(),
         ],
     )
-    license = wtforms.StringField(validators=[wtforms.validators.Optional()])
-    keywords = wtforms.StringField(validators=[wtforms.validators.Optional()])
-    classifiers = wtforms.fields.SelectMultipleField()
-    platform = wtforms.StringField(validators=[wtforms.validators.Optional()])
+    license = wtforms.StringField(
+        description="License",
+        validators=[wtforms.validators.Optional()],
+    )
+    keywords = wtforms.StringField(
+        description="Keywords",
+        validators=[wtforms.validators.Optional()],
+    )
+    classifiers = wtforms.fields.SelectMultipleField(
+        description="Classifier",
+    )
+    platform = wtforms.StringField(
+        description="Platform",
+        validators=[wtforms.validators.Optional()],
+    )
 
     # URLs
     home_page = wtforms.StringField(
+        description="Home-Page",
         validators=[
             wtforms.validators.Optional(),
             forms.URIValidator(),
         ],
     )
     download_url = wtforms.StringField(
+        description="Download-URL",
         validators=[
             wtforms.validators.Optional(),
             forms.URIValidator(),
@@ -362,6 +386,7 @@ class MetadataForm(forms.Form):
 
     # Dependency Information
     requires_python = wtforms.StringField(
+        description="Requires-Python",
         validators=[
             wtforms.validators.Optional(),
             _validate_pep440_specifier_field,
@@ -384,7 +409,9 @@ class MetadataForm(forms.Form):
             ),
         ]
     )
-    comment = wtforms.StringField(validators=[wtforms.validators.Optional()])
+    comment = wtforms.StringField(
+        validators=[wtforms.validators.Optional()],
+    )
     md5_digest = wtforms.StringField(
         validators=[
             wtforms.validators.Optional(),
@@ -398,7 +425,7 @@ class MetadataForm(forms.Form):
                 re.IGNORECASE,
                 message="Must be a valid, hex encoded, SHA256 message digest.",
             ),
-        ]
+        ],
     )
     blake2_256_digest = wtforms.StringField(
         validators=[
@@ -408,7 +435,7 @@ class MetadataForm(forms.Form):
                 re.IGNORECASE,
                 message="Must be a valid, hex encoded, blake2 message digest.",
             ),
-        ]
+        ],
     )
 
     # Legacy dependency information
@@ -416,7 +443,7 @@ class MetadataForm(forms.Form):
         validators=[
             wtforms.validators.Optional(),
             _validate_legacy_non_dist_req_list,
-        ]
+        ],
     )
     provides = ListField(
         validators=[
@@ -433,24 +460,28 @@ class MetadataForm(forms.Form):
 
     # Newer dependency information
     requires_dist = ListField(
+        description="Requires-Dist",
         validators=[
             wtforms.validators.Optional(),
             _validate_legacy_dist_req_list,
         ],
     )
     provides_dist = ListField(
+        description="Provides-Dist",
         validators=[
             wtforms.validators.Optional(),
             _validate_legacy_dist_req_list,
         ],
     )
     obsoletes_dist = ListField(
+        description="Obsoletes-Dist",
         validators=[
             wtforms.validators.Optional(),
             _validate_legacy_dist_req_list,
         ],
     )
     requires_external = ListField(
+        description="Requires-External",
         validators=[
             wtforms.validators.Optional(),
             _validate_requires_external_list,
@@ -459,6 +490,7 @@ class MetadataForm(forms.Form):
 
     # Newer metadata information
     project_urls = ListField(
+        description="Project-URL",
         validators=[
             wtforms.validators.Optional(),
             _validate_project_url_list,
@@ -649,12 +681,27 @@ def file_upload(request):
         else:
             field_name = sorted(form.errors.keys())[0]
 
+        if field_name in form:
+            if form[field_name].description:
+                error_message = (
+                    "{value!r} is an invalid value for {field}. ".format(
+                        value=form[field_name].data,
+                        field=form[field_name].description) +
+                    "Error: {} ".format(form.errors[field_name][0]) +
+                    "see "
+                    "https://packaging.python.org/specifications/core-metadata"
+                )
+            else:
+                error_message = "{field}: {msgs[0]}".format(
+                    field=field_name,
+                    msgs=form.errors[field_name],
+                )
+        else:
+            error_message = "Error: {}".format(form.errors[field_name][0])
+
         raise _exc_with_message(
             HTTPBadRequest,
-            "{field}: {msgs[0]}".format(
-                field=field_name,
-                msgs=form.errors[field_name],
-            ),
+            error_message,
         )
 
     # Ensure that we have file data in the request.


Test patch:
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
@@ -647,24 +647,45 @@ def test_fails_invalid_version(self, pyramid_config, pyramid_request,
         ("post_data", "message"),
         [
             # metadata_version errors.
-            ({}, "metadata_version: This field is required."),
+            (
+                {},
+                "None is an invalid value for Metadata-Version. "
+                "Error: This field is required. "
+                "see "
+                "https://packaging.python.org/specifications/core-metadata",
+            ),
             (
                 {"metadata_version": "-1"},
-                "metadata_version: Unknown Metadata Version",
+                "'-1' is an invalid value for Metadata-Version. "
+                "Error: Unknown Metadata Version "
+                "see "
+                "https://packaging.python.org/specifications/core-metadata",
             ),
 
             # name errors.
-            ({"metadata_version": "1.2"}, "name: This field is required."),
+            (
+                {"metadata_version": "1.2"},
+                "'' is an invalid value for Name. "
+                "Error: This field is required. "
+                "see "
+                "https://packaging.python.org/specifications/core-metadata",
+            ),
             (
                 {"metadata_version": "1.2", "name": "foo-"},
-                "name: Must start and end with a letter or numeral and "
-                "contain only ascii numeric and '.', '_' and '-'.",
+                "'foo-' is an invalid value for Name. "
+                "Error: Must start and end with a letter or numeral and "
+                "contain only ascii numeric and '.', '_' and '-'. "
+                "see "
+                "https://packaging.python.org/specifications/core-metadata",
             ),
 
             # version errors.
             (
                 {"metadata_version": "1.2", "name": "example"},
-                "version: This field is required.",
+                "'' is an invalid value for Version. "
+                "Error: This field is required. "
+                "see "
+                "https://packaging.python.org/specifications/core-metadata",
             ),
             (
                 {
@@ -672,8 +693,11 @@ def test_fails_invalid_version(self, pyramid_config, pyramid_request,
                     "name": "example",
                     "version": "dog",
                 },
-                "version: Must start and end with a letter or numeral and "
-                "contain only ascii numeric and '.', '_' and '-'.",
+                "'dog' is an invalid value for Version. "
+                "Error: Must start and end with a letter or numeral and "
+                "contain only ascii numeric and '.', '_' and '-'. "
+                "see "
+                "https://packaging.python.org/specifications/core-metadata",
             ),
 
             # filetype/pyversion errors.
@@ -693,8 +717,8 @@ def test_fails_invalid_version(self, pyramid_config, pyramid_request,
                     "version": "1.0",
                     "filetype": "bdist_wat",
                 },
-                "__all__: Python version is required for binary distribution "
-                "uploads.",
+                "Error: Python version is required for binary distribution "
+                "uploads."
             ),
             (
                 {
@@ -715,8 +739,8 @@ def test_fails_invalid_version(self, pyramid_config, pyramid_request,
                     "filetype": "sdist",
                     "pyversion": "1.0",
                 },
-                "__all__: The only valid Python version for a sdist is "
-                "'source'.",
+                "Error: The only valid Python version for a sdist is "
+                "'source'."
             ),
 
             # digest errors.
@@ -727,7 +751,18 @@ def test_fails_invalid_version(self, pyramid_config, pyramid_request,
                     "version": "1.0",
                     "filetype": "sdist",
                 },
-                "__all__: Must include at least one message digest.",
+                "Error: Must include at least one message digest."
+            ),
+            (
+                {
+                    "metadata_version": "1.2",
+                    "name": "example",
+                    "version": "1.0",
+                    "filetype": "sdist",
+                    "sha256_digest": "an invalid sha256 digest",
+                },
+                "sha256_digest: "
+                "Must be a valid, hex encoded, SHA256 message digest."
             ),
 
             # summary errors
@@ -740,7 +775,10 @@ def test_fails_invalid_version(self, pyramid_config, pyramid_request,
                     "md5_digest": "a fake md5 digest",
                     "summary": "A" * 513,
                 },
-                "summary: Field cannot be longer than 512 characters.",
+                "'" + "A" * 513 + "' is an invalid value for Summary. "
+                "Error: Field cannot be longer than 512 characters. "
+                "see "
+                "https://packaging.python.org/specifications/core-metadata",
             ),
             (
                 {
@@ -751,7 +789,10 @@ def test_fails_invalid_version(self, pyramid_config, pyramid_request,
                     "md5_digest": "a fake md5 digest",
                     "summary": "A\nB",
                 },
-                "summary: Multiple lines are not allowed.",
+                ("{!r} is an invalid value for Summary. ".format('A\nB') +
+                 "Error: Multiple lines are not allowed. "
+                 "see "
+                 "https://packaging.python.org/specifications/core-metadata"),
             ),
 
             # classifiers are a FieldStorage
@@ -1263,8 +1304,11 @@ def test_upload_fails_with_invalid_classifier(self, pyramid_config,
 
         assert resp.status_code == 400
         assert resp.status == (
-            "400 classifiers: 'Environment :: Other Environment' is not a "
-            "valid choice for this field"
+            "400 ['Environment :: Other Environment'] "
+            "is an invalid value for Classifier. "
+            "Error: 'Environment :: Other Environment' is not a valid choice "
+            "for this field "
+            "see https://packaging.python.org/specifications/core-metadata"
         )
 
     @pytest.mark.parametrize(