Repository: pallets/werkzeug (python)

Issue:
`parse_options_header` rejects q values "0" and "1"
provide an accept header such as `en;q=0` or `en;q=1` -> 

q values 0 or 1 are dropped.  The [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-quality-values) indicates that the decimal is optional however we are rejecting q values that do not contain a decimal

Environment:

- Python version: 3.11.4
- Werkzeug version: 2.3.6


Pull request:
Fix qvalue parsing
Fixes #2751. Introduce proper regex for quality values as specified by [RFC9110](https://httpwg.org/specs/rfc9110.html#quality.values) 

- fixes #2751 

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [ ] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [ ] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index a749a212..3040dd45 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -12,6 +12,7 @@ Unreleased
     enforcement. :issue:`2734`
 -   Fix empty file streaming when testing. :issue:`2740`
 -   Clearer error message when URL rule does not start with slash. :pr:`2750`
+-   ``Accept`` ``q`` value can be a float without a decimal part. :issue:`2751`
 
 
 Version 2.3.6
diff --git a/src/werkzeug/_internal.py b/src/werkzeug/_internal.py
index 203ccf15..6ed4d302 100644
--- a/src/werkzeug/_internal.py
+++ b/src/werkzeug/_internal.py
@@ -313,7 +313,6 @@ def _decode_idna(domain: str) -> str:
 
 
 _plain_int_re = re.compile(r"-?\d+", re.ASCII)
-_plain_float_re = re.compile(r"-?\d+\.\d+", re.ASCII)
 
 
 def _plain_int(value: str) -> int:
@@ -329,19 +328,3 @@ def _plain_int(value: str) -> int:
         raise ValueError
 
     return int(value)
-
-
-def _plain_float(value: str) -> float:
-    """Parse a float only if it is only ASCII digits and ``-``, and contains digits
-    before and after the ``.``.
-
-    This disallows ``+``, ``_``, non-ASCII digits, and ``.123``, which are accepted by
-    ``float`` but are not allowed in HTTP header values.
-
-    Any leading or trailing whitespace is stripped
-    """
-    value = value.strip()
-    if _plain_float_re.fullmatch(value) is None:
-        raise ValueError
-
-    return float(value)
diff --git a/src/werkzeug/http.py b/src/werkzeug/http.py
index 72dc3f3e..07d1fd49 100644
--- a/src/werkzeug/http.py
+++ b/src/werkzeug/http.py
@@ -18,7 +18,6 @@ from urllib.parse import unquote
 from urllib.request import parse_http_list as _parse_list_header
 
 from ._internal import _dt_as_utc
-from ._internal import _plain_float
 from ._internal import _plain_int
 
 if t.TYPE_CHECKING:
@@ -614,6 +613,7 @@ def parse_options_header(value: str | None) -> tuple[str, dict[str, str]]:
     return value, options
 
 
+_q_value_re = re.compile(r"-?\d+(\.\d+)?", re.ASCII)
 _TAnyAccept = t.TypeVar("_TAnyAccept", bound="ds.Accept")
 
 
@@ -656,13 +656,15 @@ def parse_accept_header(
         item, options = parse_options_header(item)
 
         if "q" in options:
-            try:
-                # pop q, remaining options are reconstructed
-                q = _plain_float(options.pop("q"))
-            except ValueError:
+            # pop q, remaining options are reconstructed
+            q_str = options.pop("q").strip()
+
+            if _q_value_re.fullmatch(q_str) is None:
                 # ignore an invalid q
                 continue
 
+            q = float(q_str)
+
             if q < 0 or q > 1:
                 # ignore an invalid q
                 continue


Test patch:
diff --git a/tests/test_http.py b/tests/test_http.py
index 2c6c0b29..bbd51ba3 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -776,6 +776,12 @@ def test_accept_invalid_float(value):
     assert list(a.values()) == ["en"]
 
 
+def test_accept_valid_int_one_zero():
+    assert http.parse_accept_header("en;q=1") == http.parse_accept_header("en;q=1.0")
+    assert http.parse_accept_header("en;q=0") == http.parse_accept_header("en;q=0.0")
+    assert http.parse_accept_header("en;q=5") == http.parse_accept_header("en;q=5.0")
+
+
 @pytest.mark.parametrize("value", ["🯱🯲🯳", "+1-", "1-1_23"])
 def test_range_invalid_int(value):
     assert http.parse_range_header(value) is None