Repository: pallets/werkzeug

Issue:
SharedDataMiddleware fails with IsADirectory
Minimal example:

```python
from flask import Flask
from werkzeug.middleware.shared_data import SharedDataMiddleware

app = Flask(__name__)
app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {'/': ('flask', 'json')})
```

This only seems to happen when using `/` with the tuple syntax to reference a package. When using another mapping like `/test/` it works fine.

```
Traceback (most recent call last):
  File "/home/adrian/dev/flask-react-example/.venv/lib/python3.7/site-packages/flask/app.py", line 2328, in __call__
    return self.wsgi_app(environ, start_response)
  File "/home/adrian/dev/flask-react-example/.venv/lib/python3.7/site-packages/werkzeug/middleware/shared_data.py", line 231, in __call__
    f, mtime, file_size = file_loader()
  File "/home/adrian/dev/flask-react-example/.venv/lib/python3.7/site-packages/werkzeug/middleware/shared_data.py", line 132, in <lambda>
    open(filename, "rb"),
IsADirectoryError: [Errno 21] Is a directory: '/home/adrian/dev/flask-react-example/.venv/lib/python3.7/site-packages/flask/json/'
```


Discussion:
Never mind, this was because I was using `("werkzeug", "debug")` and 0.16.x still has the weird module hack.

---

I get a completely different error:

```pytb
Traceback (most recent call last):
  File "/home/david/Projects/werkzeug/src/werkzeug/serving.py", line 304, in run_wsgi
    execute(self.server.app)
  File "/home/david/Projects/werkzeug/src/werkzeug/serving.py", line 292, in execute
    application_iter = app(environ, start_response)
  File "/home/david/Projects/werkzeug/src/werkzeug/middleware/shared_data.py", line 214, in __call__
    real_filename, file_loader = loader(path[len(search_path) :])
  File "/home/david/Projects/werkzeug/src/werkzeug/middleware/shared_data.py", line 155, in loader
    if not provider.has_resource(path):
  File "/home/david/.virtualenvs/werkzeug/lib/python3.7/site-packages/pkg_resources/__init__.py", line 1404, in has_resource
    return self._has(self._fn(self.module_path, resource_name))
  File "/home/david/.virtualenvs/werkzeug/lib/python3.7/site-packages/pkg_resources/__init__.py", line 1473, in _has
    "Can't perform this operation for unregistered loader type"
NotImplementedError: Can't perform this operation for unregistered loader type
```
This seems to be expected, and it happens even with a `/test/` prefix. You need to point to a file to serve, not the directory itself. For example, for `{"/test": ("werkzeug.debug", "shared")}`, `/test/` fails because it's the directory, not a file, but `/test/console.png` succeeds.

This should probably return a 404 though. Also, we should be depending on [importlib_resources](https://importlib-resources.readthedocs.io/en/latest/), not pkg_resources, for this.
That would mean that packages and normal filesystem paths have different locations - the latter do support pointing to a directory and I think it makes perfect sense that you point this middleware to a directory and not a file...
You do point at a directory, but it only serves files. If you go to the top level, you don't get a directory listing, you need to go to an actual file within the directory.

What do you expect to get when visiting `/`or `/test/` in your example?
OK, after testing it again now I remember the exact issue:

Let's take this example:

```python
from flask import Flask
from werkzeug.middleware.shared_data import SharedDataMiddleware

app = Flask(__name__)
app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {
    '/': ('flask', 'json'),
    '/test/': ('flask', 'json'),
    '/fs/': '/var/empty/',
})
```

- `http://localhost/`: IsADirectoryError :x:
- `http://localhost/test/`: IsADirectoryError :x:
- `http://localhost/fs/`: 404 :heavy_check_mark: 

IMO the first two options should 404 as well to be consistent and not cause an actual exception simply due to a client requesting that URL. 403 would probably be even more suitable for all cases since AFAIK that's what real webserver usually do when you attempt to access a directory that has no directory listing enabled.
Definitely agree they should raise 403/4 instead of 500, thanks for clarifying.

Resolving patch:
diff --git a/src/werkzeug/middleware/shared_data.py b/src/werkzeug/middleware/shared_data.py
--- a/src/werkzeug/middleware/shared_data.py
+++ b/src/werkzeug/middleware/shared_data.py
@@ -10,6 +10,7 @@
 """
 import mimetypes
 import os
+import pkgutil
 import posixpath
 from datetime import datetime
 from io import BytesIO
@@ -139,32 +140,65 @@ def get_file_loader(self, filename):
         return lambda x: (os.path.basename(filename), self._opener(filename))
 
     def get_package_loader(self, package, package_path):
-        from pkg_resources import DefaultProvider, ResourceManager, get_provider
-
         loadtime = datetime.utcnow()
-        provider = get_provider(package)
-        manager = ResourceManager()
-        filesystem_bound = isinstance(provider, DefaultProvider)
+        provider = pkgutil.get_loader(package)
 
-        def loader(path):
-            if path is None:
-                return None, None
+        if hasattr(provider, "get_resource_reader"):
+            # Python 3
+            reader = provider.get_resource_reader(package)
+
+            def loader(path):
+                if path is None:
+                    return None, None
 
-            path = safe_join(package_path, path)
+                path = safe_join(package_path, path)
+                basename = posixpath.basename(path)
 
-            if not provider.has_resource(path):
-                return None, None
+                try:
+                    resource = reader.open_resource(path)
+                except IOError:
+                    return None, None
 
-            basename = posixpath.basename(path)
+                if isinstance(resource, BytesIO):
+                    return (
+                        basename,
+                        lambda: (resource, loadtime, len(resource.getvalue())),
+                    )
 
-            if filesystem_bound:
                 return (
                     basename,
-                    self._opener(provider.get_resource_filename(manager, path)),
+                    lambda: (
+                        resource,
+                        datetime.utcfromtimestamp(os.path.getmtime(resource.name)),
+                        os.path.getsize(resource.name),
+                    ),
                 )
 
-            s = provider.get_resource_string(manager, path)
-            return basename, lambda: (BytesIO(s), loadtime, len(s))
+        else:
+            # Python 2
+            package_filename = provider.get_filename(package)
+            is_filesystem = os.path.exists(package_filename)
+            root = os.path.join(os.path.dirname(package_filename), package_path)
+
+            def loader(path):
+                if path is None:
+                    return None, None
+
+                path = safe_join(root, path)
+                basename = posixpath.basename(path)
+
+                if is_filesystem:
+                    if not os.path.isfile(path):
+                        return None, None
+
+                    return basename, self._opener(path)
+
+                try:
+                    data = provider.get_data(path)
+                except IOError:
+                    return None, None
+
+                return basename, lambda: (BytesIO(data), loadtime, len(data))
 
         return loader
 


Test patch:
diff --git a/tests/middleware/test_shared_data.py b/tests/middleware/test_shared_data.py
--- a/tests/middleware/test_shared_data.py
+++ b/tests/middleware/test_shared_data.py
@@ -61,6 +61,7 @@ def null_application(environ, start_response):
 
         assert b"$(function() {" in contents
 
-        app_iter, status, headers = run_wsgi_app(app, create_environ("/missing"))
-        assert status == "404 NOT FOUND"
-        assert b"".join(app_iter).strip() == b"NOT FOUND"
+        for path in ("/missing", "/pkg", "/pkg/", "/pkg/missing.txt"):
+            app_iter, status, headers = run_wsgi_app(app, create_environ(path))
+            assert status == "404 NOT FOUND"
+            assert b"".join(app_iter).strip() == b"NOT FOUND"