Repository: pandas-dev/pandas (python)

Issue:
BUG: to_csv() quotechar/escapechar behavior differs from csv module
### Pandas version checks

- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the [latest version](https://pandas.pydata.org/docs/whatsnew/index.html) of pandas.

- [ ] I have confirmed this bug exists on the [main branch](https://pandas.pydata.org/docs/dev/getting_started/install.html#installing-the-development-version-of-pandas) of pandas.


### Reproducible Example

```python
import pandas as pd
import csv
import sys

data = [['a', 'b"c', 'def"'], ['a2', None, '"c']]

# no escaping
df = pd.DataFrame(data)
print(df.to_csv(sep='\t', index=False, header=False, quotechar='"', escapechar='\\', quoting=csv.QUOTE_NONE))
print(df.to_csv(sep='\t', index=False, header=False, quotechar='"', escapechar='\\', quoting=csv.QUOTE_NONE, doublequote=False))

# escaping
csv_writer = csv.writer(sys.stdout, delimiter='\t', quotechar='"', escapechar='\\', quoting=csv.QUOTE_NONE)
for r in data:
    _ = csv_writer.writerow(r)
```

### Issue Description

`to_csv()` doesn't escape `quotechar` when `quoting=csv.QUOTE_NONE`.
````
a       b"c     def"
a2              "c
````

### Expected Behavior

`quotechar` gets escaped using `escapechar` even when `quoting=csv.QUOTE_NONE`.
This is the behavior of the csv module.
````
a       b\"c    def\"
a2              \"c
````

### Installed Versions

<details>

INSTALLED VERSIONS
------------------
commit                : 0691c5cf90477d3503834d983f69350f250a6ff7
python                : 3.11.2
python-bits           : 64
OS                    : Windows
OS-release            : 10
Version               : 10.0.22621
machine               : AMD64
processor             : Intel64 Family 6 Model 140 Stepping 1, GenuineIntel
byteorder             : little
LC_ALL                : None
LANG                  : en_US.UTF-8
LOCALE                : English_United States.1252

pandas                : 2.2.3
numpy                 : 1.26.2
pytz                  : 2023.3.post1
dateutil              : 2.8.2
pip                   : 25.0.1
Cython                : None
sphinx                : None
IPython               : 8.24.0
adbc-driver-postgresql: None
adbc-driver-sqlite    : None
bs4                   : 4.12.3
blosc                 : None
bottleneck            : None
dataframe-api-compat  : None
fastparquet           : None
fsspec                : None
html5lib              : None
hypothesis            : None
gcsfs                 : None
jinja2                : None
lxml.etree            : None
matplotlib            : None
numba                 : None
numexpr               : None
odfpy                 : None
openpyxl              : 3.1.5
pandas_gbq            : None
psycopg2              : 2.9.9
pymysql               : None
pyarrow               : None
pyreadstat            : None
pytest                : None
python-calamine       : None
pyxlsb                : None
s3fs                  : None
scipy                 : None
sqlalchemy            : 2.0.23
tables                : None
tabulate              : None
xarray                : None
xlrd                  : None
xlsxwriter            : None
zstandard             : None
tzdata                : 2023.3
qtpy                  : None
pyqt5                 : None

</details>


Pull request:
BUGFIX: escape `quotechar` when `escapechar` is not None (even if quoting=csv.QUOTE_NONE)
- [x] closes #61407 (Replace xxxx with the GitHub issue number)
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [ ] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [x] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
---
Found the issue on `CSVFormatter._initialize_quotechar`, wherein it only returns quotechar when `self.quoting` is not `csvlib.QUOTE_NONE`:
```python
if self.quoting != csvlib.QUOTE_NONE:
    # prevents crash in _csv
    return quotechar
return None
```
to follow the same behavior of `csv.writer` (escape when `escapechar is not None`), I improved the if function to **return quotechar when `self.escapechar is not None`**

in the `CSVFormatter.__init__`, moved the initialization of `self.escapechar` higher than `self.quotechar` since the initial solution was returning errors from calling `self.escapechar` before its initialization

initially marked as draft to let the CIs run & check if there are tests affected by this change

Patch:
diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst
index 76f6eab97c..83e64ba263 100644
--- a/doc/source/whatsnew/v3.0.0.rst
+++ b/doc/source/whatsnew/v3.0.0.rst
@@ -52,6 +52,7 @@ Other enhancements
 - :class:`Rolling` and :class:`Expanding` now support ``pipe`` method (:issue:`57076`)
 - :class:`Series` now supports the Arrow PyCapsule Interface for export (:issue:`59518`)
 - :func:`DataFrame.to_excel` argument ``merge_cells`` now accepts a value of ``"columns"`` to only merge :class:`MultiIndex` column header header cells (:issue:`35384`)
+- :func:`set_option` now accepts a dictionary of options, simplifying configuration of multiple settings at once (:issue:`61093`)
 - :meth:`DataFrame.corrwith` now accepts ``min_periods`` as optional arguments, as in :meth:`DataFrame.corr` and :meth:`Series.corr` (:issue:`9490`)
 - :meth:`DataFrame.cummin`, :meth:`DataFrame.cummax`, :meth:`DataFrame.cumprod` and :meth:`DataFrame.cumsum` methods now have a ``numeric_only`` parameter (:issue:`53072`)
 - :meth:`DataFrame.ewm` now allows ``adjust=False`` when ``times`` is provided (:issue:`54328`)
@@ -790,6 +791,7 @@ I/O
 - Bug in :meth:`read_stata` where extreme value integers were incorrectly interpreted as missing for format versions 111 and prior (:issue:`58130`)
 - Bug in :meth:`read_stata` where the missing code for double was not recognised for format versions 105 and prior (:issue:`58149`)
 - Bug in :meth:`set_option` where setting the pandas option ``display.html.use_mathjax`` to ``False`` has no effect (:issue:`59884`)
+- Bug in :meth:`to_csv` where ``quotechar``` is not escaped when ``escapechar`` is not None (:issue:`61407`)
 - Bug in :meth:`to_excel` where :class:`MultiIndex` columns would be merged to a single row when ``merge_cells=False`` is passed (:issue:`60274`)
 
 Period
diff --git a/pandas/_config/config.py b/pandas/_config/config.py
index ce53e05608..d42d90d44f 100644
--- a/pandas/_config/config.py
+++ b/pandas/_config/config.py
@@ -199,9 +199,9 @@ def set_option(*args) -> None:
 
     Parameters
     ----------
-    *args : str | object
-        Arguments provided in pairs, which will be interpreted as (pattern, value)
-        pairs.
+    *args : str | object | dict
+        Arguments provided in pairs, which will be interpreted as (pattern, value),
+        or as a single dictionary containing multiple option-value pairs.
         pattern: str
         Regexp which should match a single option
         value: object
@@ -239,6 +239,8 @@ def set_option(*args) -> None:
 
     Examples
     --------
+    Option-Value Pair Input:
+
     >>> pd.set_option("display.max_columns", 4)
     >>> df = pd.DataFrame([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
     >>> df
@@ -247,8 +249,23 @@ def set_option(*args) -> None:
     1  6  7  ...  9  10
     [2 rows x 5 columns]
     >>> pd.reset_option("display.max_columns")
+
+    Dictionary Input:
+
+    >>> pd.set_option({"display.max_columns": 4, "display.precision": 1})
+    >>> df = pd.DataFrame([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
+    >>> df
+    0  1  ...  3   4
+    0  1  2  ...  4   5
+    1  6  7  ...  9  10
+    [2 rows x 5 columns]
+    >>> pd.reset_option("display.max_columns")
+    >>> pd.reset_option("display.precision")
     """
-    # must at least 1 arg deal with constraints later
+    # Handle dictionary input
+    if len(args) == 1 and isinstance(args[0], dict):
+        args = tuple(kv for item in args[0].items() for kv in item)
+
     nargs = len(args)
     if not nargs or nargs % 2 != 0:
         raise ValueError("Must provide an even number of non-keyword arguments")
@@ -440,9 +457,10 @@ def option_context(*args) -> Generator[None]:
 
     Parameters
     ----------
-    *args : str | object
+    *args : str | object | dict
         An even amount of arguments provided in pairs which will be
-        interpreted as (pattern, value) pairs.
+        interpreted as (pattern, value) pairs. Alternatively, a single
+        dictionary of {pattern: value} may be provided.
 
     Returns
     -------
@@ -471,7 +489,12 @@ def option_context(*args) -> Generator[None]:
     >>> from pandas import option_context
     >>> with option_context("display.max_rows", 10, "display.max_columns", 5):
     ...     pass
+    >>> with option_context({"display.max_rows": 10, "display.max_columns": 5}):
+    ...     pass
     """
+    if len(args) == 1 and isinstance(args[0], dict):
+        args = tuple(kv for item in args[0].items() for kv in item)
+
     if len(args) % 2 != 0 or len(args) < 2:
         raise ValueError(
             "Provide an even amount of arguments as "
diff --git a/pandas/io/formats/csvs.py b/pandas/io/formats/csvs.py
index 75bcb51ef4..1b9eb6303f 100644
--- a/pandas/io/formats/csvs.py
+++ b/pandas/io/formats/csvs.py
@@ -90,9 +90,9 @@ class CSVFormatter:
         self.index_label = self._initialize_index_label(index_label)
         self.errors = errors
         self.quoting = quoting or csvlib.QUOTE_MINIMAL
-        self.quotechar = self._initialize_quotechar(quotechar)
         self.doublequote = doublequote
         self.escapechar = escapechar
+        self.quotechar = self._initialize_quotechar(quotechar)
         self.lineterminator = lineterminator or os.linesep
         self.date_format = date_format
         self.cols = self._initialize_columns(cols)
@@ -141,7 +141,7 @@ class CSVFormatter:
         return [""] if index_label is None else [index_label]
 
     def _initialize_quotechar(self, quotechar: str | None) -> str | None:
-        if self.quoting != csvlib.QUOTE_NONE:
+        if self.quoting != csvlib.QUOTE_NONE or self.escapechar is not None:
             # prevents crash in _csv
             return quotechar
         return None


Test patch:
diff --git a/pandas/tests/config/test_config.py b/pandas/tests/config/test_config.py
index aaf6178866..a6bc40469c 100644
--- a/pandas/tests/config/test_config.py
+++ b/pandas/tests/config/test_config.py
@@ -195,6 +195,24 @@ class TestConfig:
         assert cf.get_option("b.c") is None
         assert cf.get_option("b.b") == 10.0
 
+    def test_set_option_dict(self):
+        # GH 61093
+
+        cf.register_option("a", 1, "doc")
+        cf.register_option("b.c", "hullo", "doc2")
+        cf.register_option("b.b", None, "doc2")
+
+        assert cf.get_option("a") == 1
+        assert cf.get_option("b.c") == "hullo"
+        assert cf.get_option("b.b") is None
+
+        options_dict = {"a": "2", "b.c": None, "b.b": 10.0}
+        cf.set_option(options_dict)
+
+        assert cf.get_option("a") == "2"
+        assert cf.get_option("b.c") is None
+        assert cf.get_option("b.b") == 10.0
+
     def test_validation(self):
         cf.register_option("a", 1, "doc", validator=cf.is_int)
         cf.register_option("d", 1, "doc", validator=cf.is_nonnegative_int)
@@ -377,6 +395,33 @@ class TestConfig:
 
         f()
 
+    def test_set_ContextManager_dict(self):
+        def eq(val):
+            assert cf.get_option("a") == val
+            assert cf.get_option("b.c") == val
+
+        cf.register_option("a", 0)
+        cf.register_option("b.c", 0)
+
+        eq(0)
+        with cf.option_context({"a": 15, "b.c": 15}):
+            eq(15)
+            with cf.option_context({"a": 25, "b.c": 25}):
+                eq(25)
+            eq(15)
+        eq(0)
+
+        cf.set_option("a", 17)
+        cf.set_option("b.c", 17)
+        eq(17)
+
+        # Test that option_context can be used as a decorator too
+        @cf.option_context({"a": 123, "b.c": 123})
+        def f():
+            eq(123)
+
+        f()
+
     def test_attribute_access(self):
         holder = []
 
diff --git a/pandas/tests/frame/methods/test_to_csv.py b/pandas/tests/frame/methods/test_to_csv.py
index 9eafc69013..34d120145b 100644
--- a/pandas/tests/frame/methods/test_to_csv.py
+++ b/pandas/tests/frame/methods/test_to_csv.py
@@ -1450,3 +1450,22 @@ class TestDataFrameToCSV:
             RuntimeWarning, match=msg, raise_on_extra_warnings=False
         ):
             df.to_csv(tar_path, mode="a")
+
+    def test_to_csv_escape_quotechar(self):
+        # GH61514
+        df = DataFrame(
+            {
+                "col_a": ["a", "a2"],
+                "col_b": ['b"c', None],
+                "col_c": ['de,f"', '"c'],
+            }
+        )
+
+        result = df.to_csv(quotechar='"', escapechar="\\", quoting=csv.QUOTE_NONE)
+        expected_rows = [
+            ",col_a,col_b,col_c",
+            '0,a,b\\"c,de\\,f\\"',
+            '1,a2,,\\"c',
+        ]
+        expected = tm.convert_rows_list_to_csv_str(expected_rows)
+        assert result == expected