Repository: encode/uvicorn

Issue:
allow uvicorn.run to also accept app-dir kwarg
### Checklist

- [x] There are no similar issues or pull requests for this yet.
- [x] 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.

The issue is: I have a script using uvicorn.run, but I can only run it from a specific cwd, else it will fail with ImportError.

## Describe the solution you would like.

I would like the --app-dir functionality to be available in uvicorn.run() as well.
I.e.: to be able to run a script containing a call to uvicorn.run regardless of the cwd.

## Describe alternatives you considered

Doing it myself, i.e. calling sys.path.insert(0, abs_path_to_my_project_root_folder), but it feels to me that if this functionality is implemented in main() but not in run() (which from reading the code are the same thing) there must be something wrong with the uvicorn code.

## Additional context

https://github.com/encode/uvicorn/issues/549
https://github.com/encode/uvicorn/pull/619
https://github.com/encode/uvicorn/commit/77468df7697ec2f2962e77d7a02e0bc22b6f9c79

My guess is that this commit implemented the behaviour in the wrong place. Instead of 'consuming' the app_dir argument inside main(), it should have been passed down as-is to run() where it will be processed.

(I volunteer to do the job, if we agree that there is a problem) 


Discussion:
Yes, please! I just ran into this trying to run uvicorn programmatically, with a simple directory structure like:
```
manage.py          # contains `uvicorn.run(...)`
app/
    __init__.py    # empty
    main.py        # contains `app = FastAPI(...)`
```

My uvicorn invocation is in `manage.py`, and my `app` callable (a FastAPI instance) is in `main.py`, but I can't find any way to specify that the module path that should be loaded is actually `app.main`. Being able to pass `app_dir='app/'` to `uvicorn.run` would then let me specify `main:app` as the module:callable. Right now, that's impossible as far as I can tell.
PR is already created.

Waiting for reviews.
@command-tab even without this PR, it is not "impossible", you can also easily do the same thing yourself:

```python
sys.path.append('app')  # or possibly something more absolute like os.path.abspath(os.path.join(__file__, '..', 'app'))
uvicorn.run('main:app')
```

Resolving patch:
diff --git a/uvicorn/main.py b/uvicorn/main.py
--- a/uvicorn/main.py
+++ b/uvicorn/main.py
@@ -320,7 +320,6 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
 )
 @click.option(
     "--app-dir",
-    "app_dir",
     default=".",
     show_default=True,
     help="Look for APP in the specified directory, by adding this to the PYTHONPATH."
@@ -379,8 +378,6 @@ def main(
     app_dir: str,
     factory: bool,
 ) -> None:
-    sys.path.insert(0, app_dir)
-
     kwargs = {
         "host": host,
         "port": port,
@@ -424,11 +421,16 @@ def main(
         "headers": [header.split(":", 1) for header in headers],
         "use_colors": use_colors,
         "factory": factory,
+        "app_dir": app_dir,
     }
     run(app, **kwargs)
 
 
 def run(app: typing.Union[ASGIApplication, str], **kwargs: typing.Any) -> None:
+    app_dir = kwargs.pop("app_dir", None)
+    if app_dir is not None:
+        sys.path.insert(0, app_dir)
+
     config = Config(app, **kwargs)
     server = Server(config=config)
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -3,6 +3,7 @@
 import platform
 import sys
 from pathlib import Path
+from textwrap import dedent
 from unittest import mock
 
 import pytest
@@ -159,3 +160,25 @@ def test_mistmatch_env_variables(load_env_h11_protocol: None):
         runner.invoke(cli, ["tests.test_cli:App", "--http=httptools"])
         _, kwargs = mock_run.call_args
         assert kwargs["http"] == "httptools"
+
+
+def test_app_dir(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
+    app_dir = tmp_path / "dir" / "app_dir"
+    app_file = app_dir / "main.py"
+    app_dir.mkdir(parents=True)
+    app_file.touch()
+    app_file.write_text(
+        dedent(
+            """
+            async def app(scope, receive, send):
+                ...
+            """
+        )
+    )
+    runner = CliRunner()
+    with mock.patch.object(Server, "run") as mock_run:
+        result = runner.invoke(cli, ["main:app", "--app-dir", f"{str(app_dir)}"])
+
+    assert result.exit_code == 3
+    mock_run.assert_called_once()
+    assert sys.path[0] == str(app_dir)