Repository: encode/starlette

Issue:
StaticFiles support for directories other than "statics"
### Checklist

<!-- Please make sure you check all these items before submitting your feature request. -->

- [/] There are no similar issues or pull requests for this yet.
- [X - tried to get feedback but no replies] I discussed this idea on the [community chat](https://gitter.im/encode/community) and feedback is positive.

### Is your feature related to a problem? Please describe.

I want to be able to serve static files from arbitrary locations within a python package but I can't because currently it looks in a `statics` folder which is hardcoded.

## Describe the solution you would like.

I'd like to be able to pass as a parameter the path within the python package to look in for the static files.

## Describe alternatives you considered

<!-- Please describe any alternative solutions or features you've considered to solve
your problem and why they wouldn't solve it. -->

I've considered changing the location of my packaged files to `statics` but this will have knock on effects across other systems, and `statics` itself is non-standard as far as I can tell.

## Additional context

<!-- Provide any additional context, screenshots, tracebacks, etc. about the feature here. -->


Discussion:
florimondmanca: Hi @aminalaee 

As I understand it, with this PR `StaticFiles(packages=["external"], directory="static")` would look for static assets in `/path/to/external/static`, correct?

But I don't think that's what we want. Currently, the behavior is that `StaticFiles(directory=..., packages=...)` adds `directory` _and_ one directory per package — looking at how it populates `directories` in the function at hand here.

I'm wondering if we should be considering this API instead:

```python
StaticFiles(
    directory=...,
    packages=[
        "bootstrap4",  # Default, resolves to bootstrap4/statics
        ("external", "static"),  # Explicit subdir, resolves to "external/static"
    ],
)
```

And then do:

```python
for package in packages or []:
    try:
        package, statics_directory = package
    except ValueError:
        statics_directory = "statics"

    ...
```
aminalaee: Yes you are right. I have mixed `directory` and `packages` and the API will be:

```python
StaticFiles(
    directory="statics",
    packages=["bootstrap"],
)
```
And this will look for "statics" directory inside the "bootstrap" package.

There's a point we didn't take into account, we don't allow sub directories.
Maybe we should consider this too, `directory` is independant as you said:

`StaticFiles(packages=["bootstrap.dir.statics"])`

I'm happy to change this PR if we have a conclusion.
florimondmanca: @aminalaee My API suggestion is on the same line than your second suggestion, yes — allowing to specify a per-package target sub-directory. I'd say let's do a single path item and avoid the problem of dealing with OS-dependent path representation for now, which is why I suggested a "string or 2-tuple" representation. WDYT?
aminalaee: @florimondmanca I was thinking to handle the sub-directories in an OS-independant way before another issue comes up.

I think that makes sense for now, I'll make `directory` independent and go with the tuple option.

florimondmanca: I left a comment on #1265 in case anyone has practical feedback on this, before we merge and include this in the next Starlette release.
paulmsmith: @florimondmanca That looks like it would solve the issue myself and @bram2000 were having in [#1265](https://github.com/encode/starlette/issues/1265) 👍🏻 

Patch:
diff --git a/docs/staticfiles.md b/docs/staticfiles.md
index d8786af..3591b4f 100644
--- a/docs/staticfiles.md
+++ b/docs/staticfiles.md
@@ -6,7 +6,7 @@ Starlette also includes a `StaticFiles` class for serving files in a given direc
 Signature: `StaticFiles(directory=None, packages=None, check_dir=True)`
 
 * `directory` - A string or [os.Pathlike][pathlike] denoting a directory path.
-* `packages` - A list of strings of python packages.
+* `packages` - A list of strings or list of tuples of strings of python packages.
 * `html` - Run in HTML mode. Automatically loads `index.html` for directories if such file exist.
 * `check_dir` - Ensure that the directory exists upon instantiation. Defaults to `True`.
 
@@ -48,6 +48,16 @@ routes=[
 app = Starlette(routes=routes)
 ```
 
+By default `StaticFiles` will look for `statics` directory in each package,
+you can change the default directory by specifying a tuple of strings.
+
+```python
+routes=[
+    ...
+    Mount('/static', app=StaticFiles(packages=[('bootstrap4', 'static')]), name="static"),
+]
+```
+
 You may prefer to include static files directly inside the "static" directory
 rather than using Python packaging to include static files, but it can be useful
 for bundling up reusable components.
diff --git a/starlette/staticfiles.py b/starlette/staticfiles.py
index f705753..76e4353 100644
--- a/starlette/staticfiles.py
+++ b/starlette/staticfiles.py
@@ -40,7 +40,7 @@ class StaticFiles:
         self,
         *,
         directory: PathLike = None,
-        packages: typing.List[str] = None,
+        packages: typing.List[typing.Union[str, typing.Tuple[str, str]]] = None,
         html: bool = False,
         check_dir: bool = True,
     ) -> None:
@@ -53,7 +53,9 @@ class StaticFiles:
             raise RuntimeError(f"Directory '{directory}' does not exist")
 
     def get_directories(
-        self, directory: PathLike = None, packages: typing.List[str] = None
+        self,
+        directory: PathLike = None,
+        packages: typing.List[typing.Union[str, typing.Tuple[str, str]]] = None,
     ) -> typing.List[PathLike]:
         """
         Given `directory` and `packages` arguments, return a list of all the
@@ -64,17 +66,19 @@ class StaticFiles:
             directories.append(directory)
 
         for package in packages or []:
+            if isinstance(package, tuple):
+                package, statics_dir = package
+            else:
+                statics_dir = "statics"
             spec = importlib.util.find_spec(package)
             assert spec is not None, f"Package {package!r} could not be found."
-            assert (
-                spec.origin is not None
-            ), f"Directory 'statics' in package {package!r} could not be found."
+            assert spec.origin is not None, f"Package {package!r} could not be found."
             package_directory = os.path.normpath(
-                os.path.join(spec.origin, "..", "statics")
+                os.path.join(spec.origin, "..", statics_dir)
             )
             assert os.path.isdir(
                 package_directory
-            ), f"Directory 'statics' in package {package!r} could not be found."
+            ), f"Directory '{statics_dir!r}' in package {package!r} could not be found."
             directories.append(package_directory)
 
         return directories


Test patch:
diff --git a/tests/test_staticfiles.py b/tests/test_staticfiles.py
index 8057af6..bd7e0de 100644
--- a/tests/test_staticfiles.py
+++ b/tests/test_staticfiles.py
@@ -67,6 +67,12 @@ def test_staticfiles_with_package(test_client_factory):
     assert response.status_code == 200
     assert response.text == "123\n"
 
+    app = StaticFiles(packages=[("tests", "statics")])
+    client = test_client_factory(app)
+    response = client.get("/example.txt")
+    assert response.status_code == 200
+    assert response.text == "123\n"
+
 
 def test_staticfiles_post(tmpdir, test_client_factory):
     path = os.path.join(tmpdir, "example.txt")