Repository: aio-libs/yarl

Issue:
Ability to remove a query value while retaining others
### Is your feature request related to a problem?

I have a situation where I would like to get a query parameter, do something with it, and then pass the URL onto a lower layer _without_ that single query param.

The current API is awkward for this.

### Describe the solution you'd like

It would be nice to be able to pass kwargs to `update_query` with `None` value and have that remove those specific query params.

```python
actual = URL('http://test.com/path?a=1&b=2').update_query(a=None)
expected = URL('http://test.com/path?b=2')
assert actual == expected
```

Today this throws a `TypeError`

### Describe alternatives you've considered

Today you can accomplish this, but its somewhat awkward:
```python
url = URL('http://test.com/path?a=1&b=2')
query = url.query.copy()
del query['a']
actual = url.with_query(query)
expected = URL('http://test.com/path?b=2')
assert actual == expected
```

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aio-libs/yarl/pull/898?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) Report
> Merging [#898](https://app.codecov.io/gh/aio-libs/yarl/pull/898?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) (e906337) into [master](https://app.codecov.io/gh/aio-libs/yarl/commit/be705e8a4820a0e4df378b1c7a247596b872737e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) (be705e8) will **decrease** coverage by `0.51%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master     #898      +/-   ##
==========================================
- Coverage   99.74%   99.23%   -0.51%     
==========================================
  Files           4        4              
  Lines         772      781       +9     
  Branches      219      221       +2     
==========================================
+ Hits          770      775       +5     
- Misses          2        5       +3     
- Partials        0        1       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| unit | `99.23% <100.00%> (-0.38%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/aio-libs/yarl/pull/898?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | Coverage Δ | |
|---|---|---|
| [yarl/\_url.py](https://app.codecov.io/gh/aio-libs/yarl/pull/898?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#diff-eWFybC9fdXJsLnB5) | `99.67% <100.00%> (+<0.01%)` | :arrow_up: |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/aio-libs/yarl/pull/898/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs)

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs)

mjpieters: I really do appreciate that you put in the work for a PR here, but I'd like to first see more discussion about what the API should look like.  Can you please first propose this API change in the issue?

(I'm not 100% convinced that the name `drop_query_keys()` is right here; perhaps `without_query_keys()` is better as you are returning a new URL rather than mutating the existing URL in-place).
hongquan: Thanks. I think `without_query_keys` is a better name.
webknjaz: @mjpieters @hongquan how would you feel about `URL.without_query_params()`? The RFCs usually use this term...
hongquan: Agree with `without_query_params` name.
hongquan: The `lint` job in CI/CD failed because it is setup incorrectly. It doesn't use the proposed code, so it breaks when cannot find the symbols that is proposed to add..
hongquan: @webknjaz I have updated.
webknjaz: Looks like type stubs need updating..
hongquan: @webknjaz I updated type stub.
hongquan: The "Timeline protection" checking job is wrong. It looks for "news" whereas the PR template tells to put to _CHANGES_.
hongquan: @bdraco I changed the behavior and added test cases for Unicode param keys.

Patch:
diff --git a/CHANGES/1010.feature.rst b/CHANGES/1010.feature.rst
new file mode 120000
index 0000000..f6c34fe
--- /dev/null
+++ b/CHANGES/1010.feature.rst
@@ -0,0 +1,1 @@
+898.feature.rst
\ No newline at end of file
diff --git a/CHANGES/774.feature.rst b/CHANGES/774.feature.rst
new file mode 120000
index 0000000..f6c34fe
--- /dev/null
+++ b/CHANGES/774.feature.rst
@@ -0,0 +1,1 @@
+898.feature.rst
\ No newline at end of file
diff --git a/CHANGES/898.feature.rst b/CHANGES/898.feature.rst
new file mode 100644
index 0000000..1521624
--- /dev/null
+++ b/CHANGES/898.feature.rst
@@ -0,0 +1,1 @@
+Added :meth:`URL.without_query_params() <yarl.URL.without_query_params>` method, to drop some parameters from query string -- by :user:`hongquan`.
diff --git a/docs/api.rst b/docs/api.rst
index 84c009d..05a4db6 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -733,6 +733,16 @@ section generates a new :class:`URL` instance.
       Support subclasses of :class:`int` (except :class:`bool`) and :class:`float`
       as a query parameter value.
 
+.. method:: URL.without_query_params(*query_params)
+
+   Return a new URL whose *query* part does not contain specified ``query_params``.
+
+   Accepts :class:`str` for ``query_params``.
+
+   It does nothing if none of specified ``query_params`` are present in the query.
+
+   .. versionadded:: 1.10.0
+
 .. method:: URL.with_fragment(fragment)
 
    Return a new URL with *fragment* replaced, auto-encode *fragment* if needed.
diff --git a/yarl/_url.py b/yarl/_url.py
index dd8b0bb..3e19cae 100644
--- a/yarl/_url.py
+++ b/yarl/_url.py
@@ -1162,6 +1162,19 @@ class URL:
             self._val._replace(query=self._get_str_query(query) or ""), encoded=True
         )
 
+    def without_query_params(self, *query_params: str) -> "URL":
+        """Remove some keys from query part and return new URL."""
+        params_to_remove = set(query_params) & self.query.keys()
+        if not params_to_remove:
+            return self
+        return self.with_query(
+            tuple(
+                (name, value)
+                for name, value in self.query.items()
+                if name not in params_to_remove
+            )
+        )
+
     def with_fragment(self, fragment: Union[str, None]) -> "URL":
         """Return a new URL with fragment replaced.
 


Test patch:
diff --git a/tests/test_url_query.py b/tests/test_url_query.py
index 0a6fe3f..6772ef3 100644
--- a/tests/test_url_query.py
+++ b/tests/test_url_query.py
@@ -1,4 +1,4 @@
-from typing import List, Tuple
+from typing import List, Sequence, Tuple
 from urllib.parse import parse_qs, urlencode
 
 import pytest
@@ -171,3 +171,41 @@ def test_query_from_empty_update_query(
 
     if "b" in original_url.query:
         assert new_url.query["b"] == original_url.query["b"]
+
+
+@pytest.mark.parametrize(
+    ("original_query_string", "keys_to_drop", "expected_query_string"),
+    [
+        ("a=10&b=M%C3%B9a+xu%C3%A2n&u%E1%BB%91ng=cafe", ["a"], "b=Mùa xuân&uống=cafe"),
+        ("a=10&b=M%C3%B9a+xu%C3%A2n", ["b"], "a=10"),
+        ("a=10&b=M%C3%B9a+xu%C3%A2n&c=30", ["b"], "a=10&c=30"),
+        (
+            "a=10&b=M%C3%B9a+xu%C3%A2n&u%E1%BB%91ng=cafe",
+            ["uống"],
+            "a=10&b=Mùa xuân",
+        ),
+        ("a=10&b=M%C3%B9a+xu%C3%A2n", ["a", "b"], ""),
+    ],
+)
+def test_without_query_params(
+    original_query_string: str, keys_to_drop: Sequence[str], expected_query_string: str
+) -> None:
+    url = URL(f"http://example.com?{original_query_string}")
+    new_url = url.without_query_params(*keys_to_drop)
+    assert new_url.query_string == expected_query_string
+    assert new_url is not url
+
+
+@pytest.mark.parametrize(
+    ("original_query_string", "keys_to_drop"),
+    [
+        ("a=10&b=M%C3%B9a+xu%C3%A2n&c=30", ["invalid_key"]),
+        ("a=10&b=M%C3%B9a+xu%C3%A2n", []),
+    ],
+)
+def test_skip_dropping_query_params(
+    original_query_string: str, keys_to_drop: Sequence[str]
+) -> None:
+    url = URL(f"http://example.com?{original_query_string}")
+    new_url = url.without_query_params(*keys_to_drop)
+    assert new_url is url