Repository: falconry/falcon

Issue:
Support pathlib.Path for directory in `App.add_static_route(...)`
It has become trendy to support [`pathlib.Path`](https://docs.python.org/3/library/pathlib.html) objects in addition to, or even instead of, `str` where the argument in question denotes a filesystem path.

We could support and document _directory_ of type [`Path`](https://docs.python.org/3/library/pathlib.html) in [`App.add_static_route(...)`](https://falcon.readthedocs.io/en/stable/api/app.html#falcon.App.add_static_route).

It currently already works implicitly via our invocation of `os.path.normpath` in Python 3.6+, but explodes on 3.5.

We could either fix this for 3.5, or wait for Falcon 4.0 which will presumably drop 3.5 support, and then just document the implicit support, backed by unit tests.


Discussion:
An alternative is to just support it for 3.6+ in v3. Personally I don't think it makes much sense to do something special for 3.5, since it's very marginal at this point

Resolving patch:
diff --git a/falcon/app.py b/falcon/app.py
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -615,7 +615,8 @@ def add_static_route(
                 Note that static routes are matched in LIFO order, and are only
                 attempted after checking dynamic routes and sinks.
 
-            directory (str): The source directory from which to serve files.
+            directory (Union[str, pathlib.Path]): The source directory from
+                which to serve files.
             downloadable (bool): Set to ``True`` to include a
                 Content-Disposition header in the response. The "filename"
                 directive is simply set to the name of the requested file.
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -1,6 +1,7 @@
 from functools import partial
 import io
 import os
+import pathlib
 import re
 
 import falcon
@@ -21,8 +22,8 @@ class StaticRoute:
             Note that static routes are matched in LIFO order, and are only
             attempted after checking dynamic routes and sinks.
 
-        directory (str): The source directory from which to serve files. Must
-            be an absolute path.
+        directory (Union[str, pathlib.Path]): The source directory from which to
+            serve files. Must be an absolute path.
         downloadable (bool): Set to ``True`` to include a
             Content-Disposition header in the response. The "filename"
             directive is simply set to the name of the requested file.
@@ -49,6 +50,10 @@ def __init__(self, prefix, directory, downloadable=False, fallback_filename=None
         if not prefix.startswith('/'):
             raise ValueError("prefix must start with '/'")
 
+        # TODO(vgerak): Remove the check when py3.5 is dropped.
+        if isinstance(directory, pathlib.Path):
+            directory = str(directory)
+
         self._directory = os.path.normpath(directory)
         if not os.path.isabs(self._directory):
             raise ValueError('directory must be an absolute path')


Test patch:
diff --git a/tests/test_static.py b/tests/test_static.py
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -2,6 +2,7 @@
 
 import io
 import os
+import pathlib
 
 import pytest
 
@@ -201,6 +202,30 @@ async def run():
     assert body.decode() == os.path.normpath('/var/www/statics' + expected_path)
 
 
+def test_pathlib_path(asgi, monkeypatch):
+    monkeypatch.setattr(io, 'open', lambda path, mode: io.BytesIO(path.encode()))
+
+    sr = create_sr(asgi, '/static/', pathlib.Path('/var/www/statics'))
+    req_path = '/static/css/test.css'
+
+    req = _util.create_req(asgi, host='test.com', path=req_path, root_path='statics')
+
+    resp = _util.create_resp(asgi)
+
+    if asgi:
+
+        async def run():
+            await sr(req, resp)
+            return await resp.stream.read()
+
+        body = falcon.async_to_sync(run)
+    else:
+        sr(req, resp)
+        body = resp.stream.read()
+
+    assert body.decode() == os.path.normpath('/var/www/statics/css/test.css')
+
+
 def test_lifo(client, monkeypatch):
     monkeypatch.setattr(io, 'open', lambda path, mode: io.BytesIO(path.encode()))