Repository: praw-dev/praw
Pull request #2038 (2024-11-23 01:43:38)

Update Config to use importlib for defaults
Hello team,

I've recenlty been trying to learn more about [zipapps](https://docs.python.org/3/library/zipapp.html),
and so I bundled up one of my personal projects to test things out. My project uses
`praw`, and I kept running into configuration errors. I initially thought the problem
was that my `praw.ini` couldn't be found due to file indirection or security limits with
zipapp.

However, after setting some breakpoints and digging through the `praw.config.Config`
source, I realized that `Config` is looking for the default `praw.ini` on the filesystem
relative to the module source file. While this works on standard installs, in a zipapp,
the source files don't actually exist on disk. The default `praw.ini` isn't found, and
`praw.config._config_boolean()` errors out trying to call `.lower()` on `_NotSet`.

As of Python 3.7, the recommended way to handle "package data" like the default
`praw.ini` file is through the use of [`importlib`](https://docs.python.org/3/library/importlib.resources.html).
This PR restructures `praw.config.Config._load_config()` a bit to use `importlib` and
still handle the rest of the config composition logic.

For an minimal reproducable example, the following bash script will create a minimal
zipapp, first installing `praw` from PyPi, and then installing from source (assuming
that this version of the code is checked out).

```bash
#!/usr/bin/bash
mkdir -p ./build

# Create basic app that uses praw
# Requires user to have a praw.ini that defines "testapp"
cat << EOF > __main__.py
import praw
rdt = praw.Reddit("testapp")
rdt.user.me()
print("Success!")
EOF

# Build zipapp with current version of praw
rm -rf ./build/*
cp __main__.py ./build/
python3 -m pip install --target build praw==7.8.1
python3 -m zipapp --output before.pyz build

# Build zipapp with patched version of praw
# This assumes you're running from a directory adjcent to praw source dir
rm -rf ./build/*
cp __main__.py ./build/
python3 -m pip install --target build ../praw
python3 -m zipapp --output after.pyz build

printf '\n\n\n\n'
echo "====================================================================================="
echo "Running zipapp with current version of praw - will fail ============================="
echo "====================================================================================="
python3 before.pyz

echo "====================================================================================="
echo "Running zipapp with patched version of praw - will succeed =========================="
echo "====================================================================================="
python3 after.pyz
```



Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index b58fba29..97514b17 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -84,5 +84,6 @@ Source Contributors
 - Josh Kim `@jsk56143 <https://github.com/jsk56143>`_
 - Rolf Campbell `@endlisnis <https://github.com/endlisnis>`_
 - zacc `@zacc <https://github.com/zacc>`_
-- c0d3rman `@c0d3rman <https://github.com/c0d3rman>`
+- c0d3rman `@c0d3rman <https://github.com/c0d3rman>`_
+- Joe Kerhin `@jkerhin <https://github.com/jkerhin>`_
 - Add "Name <email (optional)> and github profile link" above this line.
diff --git a/praw/config.py b/praw/config.py
index f6263dcc..cbdfd3d9 100644
--- a/praw/config.py
+++ b/praw/config.py
@@ -3,8 +3,9 @@
 from __future__ import annotations
 
 import configparser
+import importlib
+import importlib.resources
 import os
-import sys
 from pathlib import Path
 from threading import Lock
 from typing import Any
@@ -48,7 +49,8 @@ class Config:
             interpolator_class = None
 
         config = configparser.ConfigParser(interpolation=interpolator_class)
-        module_dir = Path(sys.modules[__name__].__file__).parent
+        with importlib.resources.open_text(__package__, "praw.ini") as hdl:
+            config.read_file(hdl)
 
         if "APPDATA" in os.environ:  # Windows
             os_config_path = Path(os.environ["APPDATA"])
@@ -59,10 +61,10 @@ class Config:
         else:
             os_config_path = None
 
-        locations = [str(module_dir / "praw.ini"), "praw.ini"]
+        locations = ["praw.ini"]
 
         if os_config_path is not None:
-            locations.insert(1, str(os_config_path / "praw.ini"))
+            locations.insert(0, str(os_config_path / "praw.ini"))
 
         config.read(locations)
         cls.CONFIG = config


Test patch:
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 1ef38b4f..35ce4045 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -21,12 +21,10 @@ class TestConfig:
                 del os.environ[env_name]
         os.environ[environment] = "/MOCK"
 
-        module_dir = Path(sys.modules["praw"].__file__).parent
         environ_path = (
             Path("/MOCK") / (".config" if environment == "HOME" else "") / "praw.ini"
         )
         locations = [
-            str(module_dir / "praw.ini"),
             str(environ_path),
             "praw.ini",
         ]
@@ -83,8 +81,7 @@ class TestConfig:
                 prev_environment[key] = os.environ[key]
                 del os.environ[key]
 
-        module_dir = os.path.dirname(sys.modules["praw"].__file__)
-        locations = [os.path.join(module_dir, "praw.ini"), "praw.ini"]
+        locations = ["praw.ini"]
 
         try:
             Config._load_config()