Repository: praw-dev/praw
Pull request #1290 (2020-01-12 05:25:51)

Implementing configurable interpolation
## Feature Summary and Justification

This feature provides a new initialization argument to the Reddit class called 'interpolation' which takes the values 'basic' and 'extended' use it to configure ConfigParser.

By default interpolation remains disabled to maintain backwards compatibility.

## References

* https://www.reddit.com/r/redditdev/comments/en8j9y/praw_using_interpolation_in_prawini/


Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 5cbc6f51..ff533c7a 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -55,4 +55,5 @@ Source Contributors
 - Jack Steel `@jackodsteel <https://github.com/jackodsteel>`_
 - David Mirch `@fwump38 <https://github.com/fwump38>`_
 - PythonCoderAS `@PythonCoderAS <https://github.com/PythonCoderAS>`_
+- Michael Cetrulo <contact@michael-cetrulo.com> `@git2samus <https://github.com/git2samus>`_
 <!-- - Add "Name <email (optional)> and github profile link" above this line. -->
diff --git a/CHANGES.rst b/CHANGES.rst
index e492e0b7..3cf10b7a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,11 @@ Change Log
 Unreleased
 ----------
 
+**Added**
+
+* ``config_interpolation`` parameter for :class:`.Reddit` supporting basic and
+  extended modes.
+
 **Removed**
 
 * Converting :class:`.APIException` to string will no longer escape unicode
diff --git a/docs/getting_started/configuration/prawini.rst b/docs/getting_started/configuration/prawini.rst
index 4b9f62a4..ee459bf3 100644
--- a/docs/getting_started/configuration/prawini.rst
+++ b/docs/getting_started/configuration/prawini.rst
@@ -90,3 +90,41 @@ example, to use the settings defined for ``bot2`` as shown above, initialize
 
 A site can also be selected via a ``praw_site`` environment variable. This
 approach has precedence over the ``site_name`` parameter described above.
+
+Using Interpolation
+-------------------
+
+By default PRAW doesn't apply any interpolation on the config file but this can
+be changed with the ``config_interpolation`` parameter which can be set to
+"basic" or "extended".
+
+This can be useful to separate the components of the ``user_agent`` into
+individual variables, for example:
+
+.. _interpolation_site_example:
+.. code-block:: ini
+
+   [bot1]
+   bot_name=MyBot
+   bot_version=1.2.3
+   bot_author=MyUser
+   user_agent=script:%(bot_name)s:v%(bot_version)s (by /u/%(bot_author)s)
+
+This uses basic interpolation thus :class:`.Reddit` need to be initialized as
+follows:
+
+.. code-block:: python
+
+   reddit = praw.Reddit('bot1', config_interpolation='basic')
+
+Then the value of ``reddit.config.user_agent`` will be
+``script:MyBot:v1.2.3 (by /u/MyUser)``.
+
+See `Interpolation of values
+<https://docs.python.org/3/library/configparser.html#interpolation-of-values>`_
+for details.
+
+.. warning:: The ConfigParser instance is cached internally at the class level,
+             it is shared across all instances of :class:`.Reddit` and once set
+             it's not overridden by future invocations.
+
diff --git a/praw/config.py b/praw/config.py
index 7884fe6e..14487cb8 100644
--- a/praw/config.py
+++ b/praw/config.py
@@ -3,6 +3,7 @@ import configparser
 import os
 import sys
 from threading import Lock
+from typing import Optional
 
 from .exceptions import ClientException
 
@@ -23,6 +24,10 @@ class Config:
     CONFIG = None
     CONFIG_NOT_SET = _NotSet()  # Represents a config value that is not set.
     LOCK = Lock()
+    INTERPOLATION_LEVEL = {
+        "basic": configparser.BasicInterpolation,
+        "extended": configparser.ExtendedInterpolation,
+    }
 
     @staticmethod
     def _config_boolean(item):
@@ -31,9 +36,15 @@ class Config:
         return item.lower() in {"1", "yes", "true", "on"}
 
     @classmethod
-    def _load_config(cls):
+    def _load_config(cls, config_interpolation: Optional[str] = None):
         """Attempt to load settings from various praw.ini files."""
-        config = configparser.RawConfigParser()
+        if config_interpolation is not None:
+            interpolator_class = cls.INTERPOLATION_LEVEL[
+                config_interpolation
+            ]()
+        else:
+            interpolator_class = None
+        config = configparser.ConfigParser(interpolation=interpolator_class)
         module_dir = os.path.dirname(sys.modules[__name__].__file__)
         if "APPDATA" in os.environ:  # Windows
             os_config_path = os.environ["APPDATA"]
@@ -56,11 +67,16 @@ class Config:
             raise ClientException("No short domain specified.")
         return self._short_url
 
-    def __init__(self, site_name: str, **settings: str):
+    def __init__(
+        self,
+        site_name: str,
+        config_interpolation: Optional[str] = None,
+        **settings: str
+    ):
         """Initialize a Config instance."""
         with Config.LOCK:
             if Config.CONFIG is None:
-                self._load_config()
+                self._load_config(config_interpolation)
 
         self._settings = settings
         self.custom = dict(Config.CONFIG.items(site_name), **settings)
diff --git a/praw/const.py b/praw/const.py
index 2ec25f98..1db8ec36 100644
--- a/praw/const.py
+++ b/praw/const.py
@@ -1,7 +1,7 @@
 """PRAW constants."""
 from .endpoints import API_PATH  # noqa: F401
 
-__version__ = "6.5.2.dev0"
+__version__ = "6.6.0.dev0"
 
 USER_AGENT_FORMAT = "{} PRAW/" + __version__
 
diff --git a/praw/reddit.py b/praw/reddit.py
index b35602b6..c6b33dc4 100644
--- a/praw/reddit.py
+++ b/praw/reddit.py
@@ -93,6 +93,7 @@ class Reddit:
     def __init__(
         self,
         site_name: str = None,
+        config_interpolation: Optional[str] = None,
         requestor_class: Optional[Type[Requestor]] = None,
         requestor_kwargs: Dict[str, Any] = None,
         **config_settings: str
@@ -153,7 +154,9 @@ class Reddit:
 
         try:
             config_section = site_name or os.getenv("praw_site") or "DEFAULT"
-            self.config = Config(config_section, **config_settings)
+            self.config = Config(
+                config_section, config_interpolation, **config_settings
+            )
         except configparser.NoSectionError as exc:
             help_message = (
                 "You provided the name of a praw.ini "


Test patch:
diff --git a/tests/unit/praw.ini b/tests/unit/praw.ini
new file mode 100644
index 00000000..74bc6736
--- /dev/null
+++ b/tests/unit/praw.ini
@@ -0,0 +1,3 @@
+[INTERPOLATION]
+basic_interpolation=%(reddit_url)s
+extended_interpolation=${reddit_url}
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 6cbb8ac5..655bf6b8 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -58,19 +58,19 @@ class TestConfig:
             config = Config("DEFAULT", check_for_updates=value)
             assert config.check_for_updates is True
 
-    @mock.patch("configparser.RawConfigParser")
+    @mock.patch("configparser.ConfigParser")
     def test_load_ini_from_appdata(self, mock_config):
         self._assert_config_read("APPDATA", mock_config)
 
-    @mock.patch("configparser.RawConfigParser")
+    @mock.patch("configparser.ConfigParser")
     def test_load_ini_from_home(self, mock_config):
         self._assert_config_read("HOME", mock_config)
 
-    @mock.patch("configparser.RawConfigParser")
+    @mock.patch("configparser.ConfigParser")
     def test_load_ini_from_xdg_config_home(self, mock_config):
         self._assert_config_read("XDG_CONFIG_HOME", mock_config)
 
-    @mock.patch("configparser.RawConfigParser")
+    @mock.patch("configparser.ConfigParser")
     def test_load_ini_with_no_config_directory(self, mock_config):
         mock_instance = mock_config.return_value
         Config.CONFIG = None  # Force config file reload
@@ -105,3 +105,44 @@ class TestConfig:
     def test_unset_value_has_useful_string_representation(self):
         config = Config("DEFAULT", password=Config.CONFIG_NOT_SET)
         assert str(config.password) == "NotSet"
+
+
+class TestConfigInterpolation:
+    def test_no_interpolation(self):
+        Config.CONFIG = None  # Force config file reload
+        with mock.patch.dict(
+            "os.environ",
+            {
+                "APPDATA": os.path.dirname(__file__),
+                "XDG_CONFIG_HOME": os.path.dirname(__file__),
+            },
+        ):
+            config = Config("INTERPOLATION")
+            assert config.custom["basic_interpolation"] == "%(reddit_url)s"
+            assert config.custom["extended_interpolation"] == "${reddit_url}"
+
+    def test_basic_interpolation(self):
+        Config.CONFIG = None  # Force config file reload
+        with mock.patch.dict(
+            "os.environ",
+            {
+                "APPDATA": os.path.dirname(__file__),
+                "XDG_CONFIG_HOME": os.path.dirname(__file__),
+            },
+        ):
+            config = Config("INTERPOLATION", config_interpolation="basic")
+            assert config.custom["basic_interpolation"] == config.reddit_url
+            assert config.custom["extended_interpolation"] == "${reddit_url}"
+
+    def test_extended_interpolation(self):
+        Config.CONFIG = None  # Force config file reload
+        with mock.patch.dict(
+            "os.environ",
+            {
+                "APPDATA": os.path.dirname(__file__),
+                "XDG_CONFIG_HOME": os.path.dirname(__file__),
+            },
+        ):
+            config = Config("INTERPOLATION", config_interpolation="extended")
+            assert config.custom["basic_interpolation"] == "%(reddit_url)s"
+            assert config.custom["extended_interpolation"] == config.reddit_url