Repository: falconry/falcon

Issue:
Unintended sharing of `MultipartParseOptions._DEFAULT_HANDLERS` between instances
I think we should initialize `media_handlers` in `MultipartParseOptions.__init__()` with a copy `MultipartParseOptions._DEFAULT_HANDLERS`, not just assign reference:
```python
>>> from falcon.media.multipart import MultipartParseOptions
>>> mo1 = MultipartParseOptions()
>>> mo2 = MultipartParseOptions()
>>> sorted(mo1.media_handlers)
['application/json', 'application/x-www-form-urlencoded']
>>> mo1.media_handlers.pop('application/x-www-form-urlencoded')
<falcon.media.urlencoded.URLEncodedFormHandler object at 0x7dce2c618f50>
>>> mo1.media_handlers
{'application/json': <falcon.media.json.JSONHandler object at 0x7dce2c7fa990>}
>>> mo2.media_handlers
{'application/json': <falcon.media.json.JSONHandler object at 0x7dce2c7fa990>}
>>> MultipartParseOptions._DEFAULT_HANDLERS
{'application/json': <falcon.media.json.JSONHandler object at 0x7dce2c7fa990>}
```


Discussion:
Hi! I tried fixing the issue using deepcopy method and I'm seeing desired results in the shell.
But while writing the tests, the `media_handlers` keys of both mo1 and mo2 are getting passed as reference. The shell behavior does not replicate there.

I tried modifying `_DEFAULT_HANDLERS` in handlers.py as well. 

Could you please give me a hint where am I going wrong?
Hi @VibhinnS!
And thanks for looking into this!

It is hard for me to guess exactly what didn't work in your tests vs the interpreter shell without seeing the code. Could you maybe create a Draft PR or share a code Gist? (Or, in a pinch, just paste some code here in the comment thread.)

**Edit**: and as to general advice how to solve this, I was thinking to make `_DEFAULT_HANDLERS` a tuple of tuples or just a dict, and then initialize a new instance of `MediaHandlers` from that data instead of assignment, but I haven't tested this or looked closer at it more than that.
\`\`\`python
class MultipartParseOptions:
    _DEFAULT_HANDLERS = None
    
    # Unchanged code...

    def __init__(self):
        self.default_charset = 'utf-8'
        self.max_body_part_buffer_size = 1024 * 1024
        self.max_body_part_count = 64
        self.max_body_part_headers_size = 8192
        self.media_handlers = copy.deepcopy(self._DEFAULT_HANDLERS)
\`\`\`

Added `copy.deepcopy()` to `self._DEFAULT_HANDLERS` to ensure `media_handlers` is a deep copy.

shell op - 
\`\`\`python
>>> from falcon.media.multipart import MultipartParseOptions
>>> mo1 = MultipartParseOptions()
>>> mo2 = MultipartParseOptions()
>>> sorted(mo1.media_handlers)
['application/json', 'application/x-www-form-urlencoded']

>>> mo1.media_handlers.pop('application/x-www-form-urlencoded')
<falcon.media.urlencoded.URLEncodedFormHandler object at 0x000001C6A1317530>

>>> mo1.media_handlers
{'application/json': <falcon.media.json.JSONHandler object at 0x000001C6A13174D0>}

>>> mo2.media_handlers
{'application/json': <falcon.media.json.JSONHandler object at 0x000001C6A35D58E0>, 'application/x-www-form-urlencoded': <falcon.media.urlencoded.URLEncodedFormHandler object at 0x000001C6A35D51F0>}

>>> MultipartParseOptions._DEFAULT_HANDLERS
{'application/json': <falcon.media.json.JSONHandler object at 0x000001C6A2F8C5C0>, 'application/x-www-form-urlencoded': <falcon.media.urlencoded.URLEncodedFormHandler object at 0x000001C6A3509550>}
\`\`\`

Wrote a basic test file `test_multipart.py` in the `tests` directory:

\`\`\`python
from falcon.media.multipart import MultipartParseOptions

mo1 = MultipartParseOptions()
mo2 = MultipartParseOptions()

sorted(mo1.media_handlers)
mo1.media_handlers.pop('application/x-www-form-urlencoded')

print(mo1.media_handlers)
print("\n")
print(mo2.media_handlers)
print("\n")
print(MultipartParseOptions._DEFAULT_HANDLERS)
\`\`\`

However, the test returns unexpected results:

\`\`\`python
{'application/json': <falcon.media.json.JSONHandler object at 0x000002006DE227B0>}

{'application/json': <falcon.media.json.JSONHandler object at 0x000002006DE227B0>}

{'application/json': <falcon.media.json.JSONHandler object at 0x000002006DE227B0>}
\`\`\`

That deepcopy is maybe a slight overkill, but it should do the job for prototyping, I see nothing wrong there.

Maybe you are still using another version of Falcon when running your test?

Try in your venv:
```
FALCON_DISABLE_CYTHON=1 pip install -e . pytest
pytest tests/test_your_new_file.py
```
I agree that a simple copy is likely enough, since the handles instance are not expected to be modified inplace
Aite. Let me try with the copy method alone
Hi again @VibhinnS, just checking if you are still working on this issue, and do you need any help?
Hey folks,

If no one doesn't work on this one, I'll take a look.
Whoa it's been a while @myusko! 👋 🇺🇦 

Happy to have your help on this one.



Patch:
diff --git a/docs/_newsfragments/2293.bugfix.rst b/docs/_newsfragments/2293.bugfix.rst
new file mode 100644
index 000000000..de4c6ba44
--- /dev/null
+++ b/docs/_newsfragments/2293.bugfix.rst
@@ -0,0 +1,10 @@
+Customizing
+:attr:`MultipartParseOptions.media_handlers
+<falcon.media.multipart.MultipartParseOptions.media_handlers>` could previously
+lead to unintentionally modifying a shared class variable.
+This has been fixed, and the
+:attr:`~falcon.media.multipart.MultipartParseOptions.media_handlers` attribute
+is now initialized to a fresh copy of handlers for every instance of
+:class:`~falcon.media.multipart.MultipartParseOptions`. To that end, a proper
+:meth:`~falcon.media.Handlers.copy` method has been implemented for the media
+:class:`~falcon.media.Handlers` class.
diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py
index e37d5e3b8..4eeaf4f7f 100644
--- a/falcon/media/handlers.py
+++ b/falcon/media/handlers.py
@@ -164,6 +164,22 @@ def resolve(
 
         return cast(ResolverMethod, resolve)
 
+    def copy(self) -> Handlers:
+        """Create a shallow copy of this instance of handlers.
+
+        The resulting copy contains the same keys and values, but it can be
+        customized separately without affecting the original object.
+
+        Returns:
+            A shallow copy of handlers.
+
+        .. versionadded:: 4.0
+        """
+        # NOTE(vytas): In the unlikely case we are dealing with a subclass,
+        #   return the matching type.
+        handlers_cls = type(self)
+        return handlers_cls(self.data)
+
     @deprecation.deprecated(
         'This undocumented method is no longer supported as part of the public '
         'interface and will be removed in a future release.'
diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py
index 17a990265..340a5aea6 100644
--- a/falcon/media/multipart.py
+++ b/falcon/media/multipart.py
@@ -610,4 +610,7 @@ def __init__(self) -> None:
         self.max_body_part_buffer_size = 1024 * 1024
         self.max_body_part_count = 64
         self.max_body_part_headers_size = 8192
-        self.media_handlers = self._DEFAULT_HANDLERS
+        # NOTE(myusko,vytas): Here we create a copy of _DEFAULT_HANDLERS in
+        #   order to prevent the modification of the class variable whenever
+        #   parse_options.media_handlers are customized.
+        self.media_handlers = self._DEFAULT_HANDLERS.copy()


Test patch:
diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py
index 31c7bbff8..7c408e32c 100644
--- a/tests/test_media_multipart.py
+++ b/tests/test_media_multipart.py
@@ -8,6 +8,7 @@
 import falcon
 from falcon import media
 from falcon import testing
+from falcon.media.multipart import MultipartParseOptions
 from falcon.util import BufferedReader
 
 try:
@@ -849,3 +850,14 @@ async def deserialize_async(self, stream, content_type, content_length):
 
     assert resp.status_code == 200
     assert resp.json == ['', '0x48']
+
+
+def test_multipart_parse_options_default_handlers_unique():
+    parse_options_one = MultipartParseOptions()
+    parse_options_two = MultipartParseOptions()
+
+    parse_options_one.media_handlers.pop(falcon.MEDIA_JSON)
+
+    assert parse_options_one.media_handlers is not parse_options_two.media_handlers
+    assert len(parse_options_one.media_handlers) == 1
+    assert len(parse_options_two.media_handlers) >= 2