Repository: pydicom/pydicom

Issue:
From pydicom.future import ...
(The title line is a little flippant, but ...)

... as mentioned in #1014, it could be good to have the ability to turn on future config defaults, or any other change in behavior coming in future versions (generally would be for things changing with major *pydicom* version changes).

As I noted in my comment, Python, for example, has a switch to turn all warnings into errors.  We could perhaps do something similar with a config function, to set all config values to their upcoming defaults.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/pydicom/pydicom/pull/1231?src=pr&el=h1) Report
> Merging [#1231](https://codecov.io/gh/pydicom/pydicom/pull/1231?src=pr&el=desc) into [master](https://codecov.io/gh/pydicom/pydicom/commit/ba93db18ef7d91755e5def6fd073ecdd2a8b5c79?el=desc) will **increase** coverage by `0.25%`.
> The diff coverage is `97.23%`.

[![Impacted file tree graph](https://codecov.io/gh/pydicom/pydicom/pull/1231/graphs/tree.svg?width=650&height=150&src=pr&token=YciLV15Lwr)](https://codecov.io/gh/pydicom/pydicom/pull/1231?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1231      +/-   ##
==========================================
+ Coverage   94.86%   95.12%   +0.25%     
==========================================
  Files          57       57              
  Lines        9217     9214       -3     
==========================================
+ Hits         8744     8765      +21     
+ Misses        473      449      -24     
```


| [Impacted Files](https://codecov.io/gh/pydicom/pydicom/pull/1231?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pydicom/config.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9jb25maWcucHk=) | `95.83% <81.25%> (-4.17%)` | :arrow_down: |
| [pydicom/dataset.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9kYXRhc2V0LnB5) | `97.51% <95.80%> (-0.84%)` | :arrow_down: |
| [pydicom/dataelem.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9kYXRhZWxlbS5weQ==) | `97.65% <97.77%> (+1.71%)` | :arrow_up: |
| [pydicom/valuerep.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS92YWx1ZXJlcC5weQ==) | `98.00% <97.77%> (+5.86%)` | :arrow_up: |
| [pydicom/\_dicom\_dict.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9fZGljb21fZGljdC5weQ==) | `100.00% <100.00%> (ø)` | |
| [pydicom/\_private\_dict.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9fcHJpdmF0ZV9kaWN0LnB5) | `100.00% <100.00%> (ø)` | |
| [pydicom/\_version.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9fdmVyc2lvbi5weQ==) | `100.00% <100.00%> (ø)` | |
| [pydicom/compat.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9jb21wYXQucHk=) | `45.45% <100.00%> (+45.45%)` | :arrow_up: |
| [pydicom/datadict.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9kYXRhZGljdC5weQ==) | `100.00% <100.00%> (ø)` | |
| [pydicom/filereader.py](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree#diff-cHlkaWNvbS9maWxlcmVhZGVyLnB5) | `93.76% <100.00%> (-0.07%)` | :arrow_down: |
| ... and [7 more](https://codecov.io/gh/pydicom/pydicom/pull/1231/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pydicom/pydicom/pull/1231?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pydicom/pydicom/pull/1231?src=pr&el=footer). Last update [ba93db1...68beaac](https://codecov.io/gh/pydicom/pydicom/pull/1231?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

darcymason: Added link to new documentation section in opening post of this PR.

Patch:
diff --git a/doc/old/best_practices.rst b/doc/old/best_practices.rst
new file mode 100644
index 000000000..056934686
--- /dev/null
+++ b/doc/old/best_practices.rst
@@ -0,0 +1,111 @@
+.. _best_practices:
+.. title:: Best Practices
+
+Best Practices
+==============
+
+.. currentmodule:: pydicom
+
+.. rubric:: Future-proof your code, and help ensure valid DICOM
+
+Introduction
+------------
+
+There are some features of *pydicom* that allow you to help check your code
+for more strict DICOM practices, and to future-proof against major 
+*pydicom* version changes.
+
+It is recommended that you turn on two features if you can: 
+enforcement of valid DICOM, and a flag to enable "future" pydicom changes.
+
+Enforcing Valid DICOM
+---------------------
+
+*pydicom* has a configuration option to help enforce valid DICOM: 
+:attr:`~pydicom.config.enforce_valid_values`.
+
+If set to `True`, some non-standard DICOM will result in *pydicom* raising
+an error rather than assuming a default behavior or issuing a warning.
+
+This flag does not guarantee strict DICOM results, but is a help for 
+some specific situations that have been added to *pydicom* over time.
+
+In some cases, especially if you are dealing with files that do not 
+strictly adhere to the DICOM Standard, you may need to disable this flag.
+
+To turn on the flag in your code:
+
+.. code-block:: python
+
+  from pydicom import config
+  config.enforce_valid_values = True
+
+Note that you *must not* use 
+:code:`from pydicom.config import enforce_valid_values`.
+That makes the `enforce_valid_values` variable local only to that module,
+so *pydicom* would not see your change to its value.
+
+Future-proofing your code
+-------------------------
+
+*pydicom*, like all software, must balance its evolution with not breaking
+existing code using the library. Sometimes, major changes are necessary
+to make significant improvements to the library.
+
+To help you protect your code against future changes, *pydicom* allows you
+to flag that it should behave as it will for any known upcoming 
+major changes.
+
+Running your code with this turned on will help identify any parts of 
+your code that are not compatible with the known changes in the next major
+version of *pydicom*.
+
+The simplest way to set this behavior is to set an environment variable
+``PYDICOM_FUTURE`` to "True". For example to temporarily turn it on in the
+current terminal session:
+
+.. code-block::
+  
+  SET PYDICOM_FUTURE=True           (Windows)
+
+  export PYDICOM_FUTURE=True        (many linux environments)
+
+If you wish to turn off the behavior, you can
+either remove the environment variable, or set it to "False". See your
+operating system documentation for more details on setting or removing
+environment variables.
+
+The other way to enable the future behavior is to turn it on at run-time
+using the :func:`~pydicom.config.future_behavior` function:
+
+.. code-block:: python
+
+  from pydicom import config
+  config.future_behavior()
+
+If you needed to turn the future behavior off again at run-time, call
+:func:`~pydicom.config.future_behavior` with False:
+
+.. code-block:: python
+
+  config.future_behavior(False)
+
+
+Limiting the *pydicom* major version in your package
+----------------------------------------------------
+
+Another way to avoid breaking changes in future *pydicom* versions is to
+limit the version of *pydicom* that your code uses.
+
+If you follow standard Python packaging recommendations, you can add a line
+to your `requirements.txt` file to limit the *pydicom* version to the current
+major version.  E.g. a line like:
+
+.. code-block::
+
+  pydicom >=2.0,<3.0
+
+in the `requirements.txt` file will ensure that those installing your package
+will get the same major version (in the example, version 2) of *pydicom*
+that you have developed the code for. This works best if your package is
+installed in a virtual environment.
\ No newline at end of file
diff --git a/doc/old/pydicom_user_guide.rst b/doc/old/pydicom_user_guide.rst
index d925b3e7e..a3e333447 100644
--- a/doc/old/pydicom_user_guide.rst
+++ b/doc/old/pydicom_user_guide.rst
@@ -14,4 +14,5 @@ pydicom User Guide
    image_data_handlers.rst
    viewing_images.rst
    private_data_elements.rst
+   best_practices.rst
    python2_support.rst
diff --git a/doc/reference/config.rst b/doc/reference/config.rst
index ad9d3ddfc..50dd54e4a 100644
--- a/doc/reference/config.rst
+++ b/doc/reference/config.rst
@@ -16,6 +16,7 @@ Configuration Options (:mod:`pydicom.config`)
    datetime_conversion
    debug
    enforce_valid_values
+   future_behavior
    overlay_data_handlers
    pixel_data_handlers
    reset_data_element_callback
diff --git a/doc/release_notes/v2.1.0.rst b/doc/release_notes/v2.1.0.rst
index 1d8fe9a34..c5fb2874f 100644
--- a/doc/release_notes/v2.1.0.rst
+++ b/doc/release_notes/v2.1.0.rst
@@ -56,6 +56,9 @@ Enhancements
   :class:`~pydicom.fileset.FileSet` class (:issue:`9`, :issue:`243`,
   :issue:`1093`)
 * Added :attr:`~pydicom.uid.AllTransferSyntaxes`
+* Added option to turn on *pydicom* future breaking behavior to allow user code
+  to check itself against the next major version release.  Set environment
+  variable "PYDICOM_FUTURE" to "True" or call :func:`~pydicom.config.future_behavior`
 * Added another signature to the `bulk_data_uri_handler` in `from_json` to
   allow for the communication of not just the URI but also the tag and VR
   to the handler. Previous handlers will work as expected, new signature
diff --git a/pydicom/compat.py b/pydicom/compat.py
index e59528b93..afeaf1376 100644
--- a/pydicom/compat.py
+++ b/pydicom/compat.py
@@ -4,6 +4,13 @@
 # Python 2 only - warn and mark this as deprecated.
 import warnings
 
+from pydicom import config
+
+if config._use_future:
+    raise ImportError(
+        "Pydicom Future Error: compat module will be removed in pydicom 3.0"
+    )
+
 warnings.warn(
     "Starting in pydicom 3.0, the compat module (used for Python 2)"
     " will be removed",
diff --git a/pydicom/config.py b/pydicom/config.py
index 11037b527..af00b526f 100644
--- a/pydicom/config.py
+++ b/pydicom/config.py
@@ -4,7 +4,8 @@
 # doc strings following items are picked up by sphinx for documentation
 
 import logging
-
+import os
+from typing import Optional
 
 have_numpy = True
 try:
@@ -71,8 +72,10 @@ def DS_numpy(use_numpy=True):
     global use_DS_numpy
 
     if use_DS_decimal and use_numpy:
-        raise ValueError("Cannot use numpy arrays to read DS elements"
-                         "if `use_DS_decimal` is True")
+        raise ValueError(
+            "Cannot use numpy arrays to read DS elements"
+            "if `use_DS_decimal` is True"
+        )
     use_DS_numpy = use_numpy
 
 
@@ -100,10 +103,12 @@ def DS_decimal(use_Decimal_boolean=True):
     use_DS_decimal = use_Decimal_boolean
 
     if use_DS_decimal and use_DS_numpy:
-        raise ValueError("Cannot set use_DS_decimal True "
-                         "if use_DS_numpy is True")
+        raise ValueError(
+            "Cannot set use_DS_decimal True " "if use_DS_numpy is True"
+        )
 
     import pydicom.valuerep
+
     if use_DS_decimal:
         pydicom.valuerep.DSclass = pydicom.valuerep.DSdecimal
     else:
@@ -181,7 +186,7 @@ displaying the file meta information data elements
 """
 
 # Logging system and debug function to change logging level
-logger = logging.getLogger('pydicom')
+logger = logging.getLogger("pydicom")
 logger.addHandler(logging.NullHandler())
 
 import pydicom.overlays.numpy_handler as overlay_np  # noqa
@@ -385,3 +390,52 @@ def debug(debug_on=True, default_handler=True):
 
 # force level=WARNING, in case logging default is set differently (issue 103)
 debug(False, False)
+
+_use_future = False
+_use_future_env = os.getenv("PYDICOM_FUTURE")
+
+if _use_future_env:
+    if _use_future_env.lower() in ["true", "yes", "on", "1"]:
+        _use_future = True
+    elif _use_future_env.lower() in ["false", "no", "off", "0"]:
+        _use_future = False
+    else:
+        raise ValueError(
+            "Unknown setting for environment variable "
+            "PYDICOM_FUTURE. Use True or False."
+        )
+
+
+def future_behavior(enable_future: bool = True) -> None:
+    """Imitate the behavior for the next major version of *pydicom*.
+
+    .. versionadded:: 2.1
+
+    This can be used to ensure your code is "future-proof" for known
+    upcoming changes in the next major version of *pydicom*. Typically,
+    deprecations become errors, and default values of config flags may change.
+
+    Parameters
+    ----------
+    enable_future: bool
+        Set ``True`` (default) to emulate future pydicom behavior,
+        ``False`` to reset to current pydicom behavior.
+
+    See also
+    --------
+    :attr:`~pydicom.config.INVALID_KEYWORD_BEHAVIOR`
+    :attr:`~pydicom.config.INVALID_KEY_BEHAVIOR`
+
+    """
+    global _use_future, INVALID_KEYWORD_BEHAVIOR
+
+    if enable_future:
+        _use_future = True
+        INVALID_KEYWORD_BEHAVIOR = "RAISE"
+    else:
+        _use_future = False
+        INVALID_KEYWORD_BEHAVIOR = "WARN"
+
+
+if _use_future:
+    future_behavior()
diff --git a/pydicom/dataset.py b/pydicom/dataset.py
index 8dfcd8261..7a1603ff1 100644
--- a/pydicom/dataset.py
+++ b/pydicom/dataset.py
@@ -1988,6 +1988,12 @@ class Dataset(Dict[BaseTag, _DatasetValue]):
 
     def _set_file_meta(self, value: Optional["Dataset"]) -> None:
         if value is not None and not isinstance(value, FileMetaDataset):
+            if config._use_future:
+                raise TypeError(
+                    "Pydicom Future: Dataset.file_meta must be an instance "
+                    "of FileMetaDataset"
+                )
+
             FileMetaDataset.validate(value)
             warnings.warn(
                 "Starting in pydicom 3.0, Dataset.file_meta must be a "


Test patch:
diff --git a/pydicom/tests/test_config.py b/pydicom/tests/test_config.py
index c9d64fbdb..93b91b3b7 100644
--- a/pydicom/tests/test_config.py
+++ b/pydicom/tests/test_config.py
@@ -3,23 +3,27 @@
 
 import logging
 import sys
+import os
+import importlib
 
 import pytest
 
 from pydicom import dcmread
 from pydicom.config import debug
 from pydicom.data import get_testdata_file
-
+from pydicom import config
+from pydicom.dataset import Dataset
 
 DS_PATH = get_testdata_file("CT_small.dcm")
-PYTEST = [int(x) for x in pytest.__version__.split('.')]
+PYTEST = [int(x) for x in pytest.__version__.split(".")]
 
 
-@pytest.mark.skipif(PYTEST[:2] < [3, 4], reason='no caplog')
+@pytest.mark.skipif(PYTEST[:2] < [3, 4], reason="no caplog")
 class TestDebug:
     """Tests for config.debug()."""
+
     def setup(self):
-        self.logger = logging.getLogger('pydicom')
+        self.logger = logging.getLogger("pydicom")
 
     def teardown(self):
         # Reset to just NullHandler
@@ -30,7 +34,7 @@ class TestDebug:
         assert 1 == len(self.logger.handlers)
         assert isinstance(self.logger.handlers[0], logging.NullHandler)
 
-        with caplog.at_level(logging.DEBUG, logger='pydicom'):
+        with caplog.at_level(logging.DEBUG, logger="pydicom"):
             ds = dcmread(DS_PATH)
 
             assert "Call to dcmread()" not in caplog.text
@@ -44,7 +48,7 @@ class TestDebug:
         assert 1 == len(self.logger.handlers)
         assert isinstance(self.logger.handlers[0], logging.NullHandler)
 
-        with caplog.at_level(logging.DEBUG, logger='pydicom'):
+        with caplog.at_level(logging.DEBUG, logger="pydicom"):
             ds = dcmread(DS_PATH)
 
             assert "Call to dcmread()" in caplog.text
@@ -63,7 +67,7 @@ class TestDebug:
         assert 1 == len(self.logger.handlers)
         assert isinstance(self.logger.handlers[0], logging.NullHandler)
 
-        with caplog.at_level(logging.DEBUG, logger='pydicom'):
+        with caplog.at_level(logging.DEBUG, logger="pydicom"):
             ds = dcmread(DS_PATH)
 
             assert "Call to dcmread()" not in caplog.text
@@ -78,7 +82,7 @@ class TestDebug:
         assert isinstance(self.logger.handlers[0], logging.NullHandler)
         assert isinstance(self.logger.handlers[1], logging.StreamHandler)
 
-        with caplog.at_level(logging.DEBUG, logger='pydicom'):
+        with caplog.at_level(logging.DEBUG, logger="pydicom"):
             ds = dcmread(DS_PATH)
 
             assert "Call to dcmread()" in caplog.text
@@ -98,10 +102,45 @@ class TestDebug:
         assert isinstance(self.logger.handlers[0], logging.NullHandler)
         assert isinstance(self.logger.handlers[1], logging.StreamHandler)
 
-        with caplog.at_level(logging.DEBUG, logger='pydicom'):
+        with caplog.at_level(logging.DEBUG, logger="pydicom"):
             ds = dcmread(DS_PATH)
 
             assert "Call to dcmread()" not in caplog.text
             assert "Reading File Meta Information preamble..." in caplog.text
             assert "Reading File Meta Information prefix..." in caplog.text
             assert "00000080: 'DICM' prefix found" in caplog.text
+
+
+@pytest.fixture(scope="function", params=["config", "env"])
+def future_setter(request, monkeypatch):
+    if request.param == "config":
+        config.future_behavior()
+        yield
+    else:
+        monkeypatch.setenv("PYDICOM_FUTURE", "True")
+        importlib.reload(config)
+        yield
+
+    config.future_behavior(False)
+
+
+class TestFuture:
+    def test_reload(self):
+        importlib.reload(config)
+        assert not config._use_future
+
+    def test_compat_import(self, future_setter):
+        """Import error if pydicom future behavior is set"""
+        with pytest.raises(ImportError):
+            import pydicom.compat
+
+    def test_file_meta_class(self, future_setter):
+        """FileMetaDataset required type in pydicom future behavior"""
+        ds = Dataset()
+        with pytest.raises(TypeError):
+            ds.file_meta = Dataset()
+
+    def test_invalid_keyword_raise(self, future_setter):
+        ds = Dataset()
+        with pytest.raises(ValueError):
+            ds.bitsStored = 42