astanin: This is a breaking change, so it will be postponed at least until the next major release.

What is the reason to remove PRESERVE_WHITESPACE macro? I'm pretty sure some users rely on it.
vrza: The reasons are cleanliness of design and clear user facing API, with no [monkey patching](https://en.wikipedia.org/wiki/Monkey_patch) necessary.

To explain in detail, `PRESERVE_WHITESPACE` is not a macro, but rather a constant defined as a property of the `tabulate` module. In order to change the whitespace behavior this constant affects, the consumer must monkey patch the tabulate module. You can see an example of this in [this unit test](https://github.com/astanin/python-tabulate/blob/bfc922cb04aa4a8bfe6360a50ac774d513605043/test/test_output.py#L1601).

Monkey patching alters the default module state and this can lead to problems. For example, if one user function monkey patches the module at runtime, and doesn't patch it back to its original state after use, a second user function, called subsequently, that's relying on the default behavior, might fail or produce unexpected results.

You can see this in action by splitting the `test_preserve_whitespace` unit test into two tests (and it really does contain two distinct tests, hiding inside one function):

```
def test_preserve_whitespace_true():
    "Output: Default table output, but with preserved leading whitespace."
    tabulate_module.PRESERVE_WHITESPACE = True
    table_headers = ["h1", "h2", "h3"]
    test_table = [["  foo", " bar   ", "foo"]]
    expected = "\n".join(
        ["h1     h2       h3", "-----  -------  ----", "  foo   bar     foo"]
    )
    result = tabulate(test_table, table_headers)
    assert_equal(expected, result)

def test_preserve_whitespace_default():
    table_headers = ["h1", "h2", "h3"]
    test_table = [["  foo", " bar   ", "foo"]]
    expected = "\n".join(["h1    h2    h3", "----  ----  ----", "foo   bar   foo"])
    result = tabulate(test_table, table_headers)
    assert_equal(expected, result)
```
    
If `test_preserve_whitespace_default` runs first, and `test_preserve_whitespace_true` second, both tests will pass. However, if the `test_preserve_whitespace_true` runs first, then `test_preserve_whitespace_default` will fail, because the first test monkey patches the tabulate module and doesn't patch it back after use.

You can read about more monkey patching pitfalls in [the wikipedia article.](https://en.wikipedia.org/wiki/Monkey_patch). For these reasons, it's not a good idea to make monkey patching a standard way of using the library. This is why I moved the `preserve_whitespace` tunable to the constructor, as an optional argument with a default value, resulting in a cleaner API which is less error-prone to use. The functionality is still there, it is just exposed to the user in a more user-friendly style.

Indeed, this change will break user programs that rely on monkey patching the `tabulate.PRESERVE_WHITESPACE` property, and as such, according to semantic versioning guidelines, seems to warrant a major version bump.
astanin: Related: #37 
astanin: I generally agree with you. I'm going to integrate your changes in the 0.9 release, whenever it can be.
diff --git a/tabulate.py b/tabulate.py
index 55b896e..541cf29 100644
--- a/tabulate.py
+++ b/tabulate.py
@@ -68,9 +68,6 @@ __version__ = "0.8.10"
 # minimum extra space in headers
 MIN_PADDING = 2
 
-# Whether or not to preserve leading/trailing whitespace in data.
-PRESERVE_WHITESPACE = False
-
 _DEFAULT_FLOATFMT = "g"
 _DEFAULT_MISSINGVAL = ""
 # default align will be overwritten by "left", "center" or "decimal"
@@ -810,13 +807,13 @@ def _choose_width_fn(has_invisible, enable_widechars, is_multiline):
     return width_fn
 
 
-def _align_column_choose_padfn(strings, alignment, has_invisible):
+def _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace):
     if alignment == "right":
-        if not PRESERVE_WHITESPACE:
+        if not preserve_whitespace:
             strings = [s.strip() for s in strings]
         padfn = _padleft
     elif alignment == "center":
-        if not PRESERVE_WHITESPACE:
+        if not preserve_whitespace:
             strings = [s.strip() for s in strings]
         padfn = _padboth
     elif alignment == "decimal":
@@ -830,7 +827,7 @@ def _align_column_choose_padfn(strings, alignment, has_invisible):
     elif not alignment:
         padfn = _padnone
     else:
-        if not PRESERVE_WHITESPACE:
+        if not preserve_whitespace:
             strings = [s.strip() for s in strings]
         padfn = _padright
     return strings, padfn
@@ -873,9 +870,10 @@ def _align_column(
     has_invisible=True,
     enable_widechars=False,
     is_multiline=False,
+    preserve_whitespace=False
 ):
     """[string] -> [padded_string]"""
-    strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible)
+    strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace)
     width_fn = _align_column_choose_width_fn(
         has_invisible, enable_widechars, is_multiline
     )
@@ -1224,6 +1222,7 @@ def tabulate(
     showindex="default",
     disable_numparse=False,
     colalign=None,
+    preserve_whitespace=False
 ):
     """Format a fixed width table for pretty printing.
 
@@ -1603,7 +1602,7 @@ def tabulate(
         [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols)
     )
     cols = [
-        _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline)
+        _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline, preserve_whitespace)
         for c, a, minw in zip(cols, aligns, minwidths)
     ]
 

PRESERVE_WHITESPACE should be made an argument
It's currently a module-level global variable. This is assuming that all users of tabulate uses the same PRESERVE_WHITESPACE setting. This will not work well if different use of tabulate needs different settings.

Having an argument for this setting would be more general.
diff --git a/test/test_output.py b/test/test_output.py
index abab439..660f047 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -1598,18 +1598,16 @@ def test_disable_numparse_list():
 
 def test_preserve_whitespace():
     "Output: Default table output, but with preserved leading whitespace."
-    tabulate_module.PRESERVE_WHITESPACE = True
     table_headers = ["h1", "h2", "h3"]
     test_table = [["  foo", " bar   ", "foo"]]
     expected = "\n".join(
         ["h1     h2       h3", "-----  -------  ----", "  foo   bar     foo"]
     )
-    result = tabulate(test_table, table_headers)
+    result = tabulate(test_table, table_headers, preserve_whitespace=True)
     assert_equal(expected, result)
 
-    tabulate_module.PRESERVE_WHITESPACE = False
     table_headers = ["h1", "h2", "h3"]
     test_table = [["  foo", " bar   ", "foo"]]
     expected = "\n".join(["h1    h2    h3", "----  ----  ----", "foo   bar   foo"])
-    result = tabulate(test_table, table_headers)
+    result = tabulate(test_table, table_headers, preserve_whitespace=False)
     assert_equal(expected, result)

pip install -e .[widechars]
parse_log_pytest
requirements.txt
numpy>=1.16.0 pandas>=1.0.0 pytest
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
exceptiongroup==1.2.2
iniconfig==2.1.0
numpy==2.0.2
packaging==24.2
pandas==2.2.3
pluggy==1.5.0
pytest==8.3.5
python-dateutil==2.9.0.post0
pytz==2025.2
six==1.17.0
-e git+https://github.com/astanin/python-tabulate.git@62203dea1a5cdea16d70099b94a56d9007cc81e8#egg=tabulate
tomli==2.2.1
tzdata==2025.2
wcwidth==0.2.13

name: python-tabulate
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - numpy==2.0.2
      - packaging==24.2
      - pandas==2.2.3
      - pluggy==1.5.0
      - pytest==8.3.5
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - six==1.17.0
      - tomli==2.2.1
      - tzdata==2025.2
      - wcwidth==0.2.13
prefix: /opt/conda/envs/python-tabulate

test/test_output.py::test_preserve_whitespace
test/test_output.py::test_plain test/test_output.py::test_plain_headerless test/test_output.py::test_plain_multiline_headerless test/test_output.py::test_plain_multiline test/test_output.py::test_plain_multiline_with_links test/test_output.py::test_plain_multiline_with_empty_cells test/test_output.py::test_plain_multiline_with_empty_cells_headerless test/test_output.py::test_simple test/test_output.py::test_simple_multiline_2 test/test_output.py::test_simple_headerless test/test_output.py::test_simple_multiline_headerless test/test_output.py::test_simple_multiline test/test_output.py::test_simple_multiline_with_links test/test_output.py::test_simple_multiline_with_empty_cells test/test_output.py::test_simple_multiline_with_empty_cells_headerless test/test_output.py::test_github test/test_output.py::test_grid test/test_output.py::test_grid_wide_characters test/test_output.py::test_grid_headerless test/test_output.py::test_grid_multiline_headerless test/test_output.py::test_grid_multiline test/test_output.py::test_grid_multiline_with_empty_cells test/test_output.py::test_grid_multiline_with_empty_cells_headerless test/test_output.py::test_fancy_grid test/test_output.py::test_fancy_grid_headerless test/test_output.py::test_fancy_grid_multiline_headerless test/test_output.py::test_fancy_grid_multiline test/test_output.py::test_fancy_grid_multiline_with_empty_cells test/test_output.py::test_fancy_grid_multiline_with_empty_cells_headerless test/test_output.py::test_pipe test/test_output.py::test_pipe_headerless test/test_output.py::test_presto test/test_output.py::test_presto_headerless test/test_output.py::test_presto_multiline_headerless test/test_output.py::test_presto_multiline test/test_output.py::test_presto_multiline_with_empty_cells test/test_output.py::test_presto_multiline_with_empty_cells_headerless test/test_output.py::test_orgtbl test/test_output.py::test_orgtbl_headerless test/test_output.py::test_psql test/test_output.py::test_psql_headerless test/test_output.py::test_psql_multiline_headerless test/test_output.py::test_psql_multiline test/test_output.py::test_psql_multiline_with_empty_cells test/test_output.py::test_psql_multiline_with_empty_cells_headerless test/test_output.py::test_pretty test/test_output.py::test_pretty_headerless test/test_output.py::test_pretty_multiline_headerless test/test_output.py::test_pretty_multiline test/test_output.py::test_pretty_multiline_with_links test/test_output.py::test_pretty_multiline_with_empty_cells test/test_output.py::test_pretty_multiline_with_empty_cells_headerless test/test_output.py::test_jira test/test_output.py::test_jira_headerless test/test_output.py::test_rst test/test_output.py::test_rst_with_empty_values_in_first_column test/test_output.py::test_rst_headerless test/test_output.py::test_rst_multiline test/test_output.py::test_rst_multiline_with_links test/test_output.py::test_rst_multiline_with_empty_cells test/test_output.py::test_rst_multiline_with_empty_cells_headerless test/test_output.py::test_mediawiki test/test_output.py::test_mediawiki_headerless test/test_output.py::test_moinmoin test/test_output.py::test_youtrack test/test_output.py::test_moinmoin_headerless test/test_output.py::test_html test/test_output.py::test_unsafehtml test/test_output.py::test_html_headerless test/test_output.py::test_unsafehtml_headerless test/test_output.py::test_latex test/test_output.py::test_latex_raw test/test_output.py::test_latex_headerless test/test_output.py::test_latex_booktabs test/test_output.py::test_latex_booktabs_headerless test/test_output.py::test_textile test/test_output.py::test_textile_with_header test/test_output.py::test_textile_with_center_align test/test_output.py::test_no_data test/test_output.py::test_empty_data test/test_output.py::test_no_data_without_headers test/test_output.py::test_empty_data_without_headers test/test_output.py::test_floatfmt test/test_output.py::test_floatfmt_multi test/test_output.py::test_colalign_multi test/test_output.py::test_float_conversions test/test_output.py::test_missingval test/test_output.py::test_missingval_multi test/test_output.py::test_column_alignment test/test_output.py::test_unaligned_separated test/test_output.py::test_pandas_with_index test/test_output.py::test_pandas_without_index test/test_output.py::test_pandas_rst_with_index test/test_output.py::test_pandas_rst_with_named_index test/test_output.py::test_dict_like_with_index test/test_output.py::test_list_of_lists_with_index test/test_output.py::test_list_of_lists_with_supplied_index test/test_output.py::test_list_of_lists_with_index_firstrow test/test_output.py::test_disable_numparse_default test/test_output.py::test_disable_numparse_true test/test_output.py::test_disable_numparse_list
tabulate.py
tabulate.py