Repository: litestar-org/litestar

Issue:
Bug: `--app-dir` option does not work/fails during autodiscovery.
### Description

Hi! I'm trying to use the Litestar CLI with the following invocation:

`litestar --app-dir src --app litestar_test:app`

with the following project structure:

```
.
├── pyproject.toml
└── src
    └── litestar_test
        └── __init__.py
```

where `__init__.py` contains:

```python
from litestar import Litestar

app = Litestar()
```

However I'm getting the following error:

```
Using Litestar app from env: 'litestar_test:app'
Traceback (most recent call last):
  File "/home/sykloid/src/litestar_test/.venv/bin/litestar", line 8, in <module>
    sys.exit(run_cli())
             ^^^^^^^^^
  File "/home/sykloid/src/litestar_test/.venv/lib/python3.11/site-packages/litestar/__main__.py", line 6, in run_cli
    litestar_group()
  File "/home/sykloid/src/litestar_test/.venv/lib/python3.11/site-packages/click/core.py", line 1157, in __call__
    return self.main(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/sykloid/src/litestar_test/.venv/lib/python3.11/site-packages/rich_click/rich_group.py", line 21, in main
    rv = super().main(*args, standalone_mode=False, **kwargs)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/sykloid/src/litestar_test/.venv/lib/python3.11/site-packages/click/core.py", line 1077, in main
    with self.make_context(prog_name, args, **extra) as ctx:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/sykloid/src/litestar_test/.venv/lib/python3.11/site-packages/litestar/cli/_utils.py", line 237, in make_context
    self._prepare(ctx)
  File "/home/sykloid/src/litestar_test/.venv/lib/python3.11/site-packages/litestar/cli/_utils.py", line 218, in _prepare    env = ctx\.obj = LitestarEnv.from_env(ctx.params.get("app_path"), ctx.params.get("app_dir"))
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/sykloid/src/litestar_test/.venv/lib/python3.11/site-packages/litestar/cli/_utils.py", line 114, in from_env    loaded_ap\p = _load_app_from_path(app_path)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/sykloid/src/litestar_test/.venv/lib/python3.11/site-packages/litestar/cli/_utils.py", line 290, in _load_app_from_path
    module = importlib.import_module(module_path)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/sykloid/.rye/py/cpython@3.11.3/install/lib/python3.11/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1206, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1178, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1142, in _find_and_load_unlocked
ModuleNotFoundError: No module named 'litestar_test'
```

My guess is that this is because even though `sys.path.append(app_dir)` is happening in `litestar.cli.main.litestar_group`, the call to `litestar.cli._utils.LitestarEnv.from_env` is happening before that, and `app_dir` is not added to `sys.path` in that case.

I was able to verify that the following changes worked:

1. Manually adding `src` to `PYTHONPATH` (obviously)
2. Modifying `litestar.cli._utils.LitestarEnv.from_env` to take an extra parameter `app_dir`, and calling it as `env = ctx.obj = LitestarEnv.from_env(ctx.params.get("app_path"), ctx.params.get("app_dir"))` in `LitestarExtensionGroup.prepare`

### URL to code causing the issue

_No response_

### MCVE

_No response_

### Steps to reproduce

```bash
`litestar --app-dir src --app litestar_test:app info`
```


### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

2.0.1

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---

## Funding
* If you would like to see an issue prioritized, make a pledge towards it!
* We receive the pledge once the issue is completed & verified

<a href="https://polar.sh/litestar-org/litestar/issues/2266">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2266/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2266/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->

StaticFilesConfig and virtual directories
I'm trying to write a ``FileSystemProtocol`` to load files from the package data using [importlib_resources](https://importlib-resources.readthedocs.io/en/latest/using.html#). But because ``directories`` is defined as ``DirectoryPath``, pydantic checks if the given directories exist in the local filesystem. 

This is not generally true, especially in any kind of virtual filesystem (e.g. a zipped package). I think this condition should be relaxed to support virtual filesystems.

https://github.com/starlite-api/starlite/blob/9bb6dcd57c10a591377cf8e3a537e9292566d5b9/starlite/config/static_files.py#L32


Discussion:

I agree. Do you want to submit a PR?

Resolving patch:
diff --git a/litestar/cli/_utils.py b/litestar/cli/_utils.py
--- a/litestar/cli/_utils.py
+++ b/litestar/cli/_utils.py
@@ -91,12 +91,12 @@ class LitestarEnv:
     is_app_factory: bool = False
 
     @classmethod
-    def from_env(cls, app_path: str | None) -> LitestarEnv:
+    def from_env(cls, app_path: str | None, app_dir: Path | None = None) -> LitestarEnv:
         """Load environment variables.
 
         If ``python-dotenv`` is installed, use it to populate environment first
         """
-        cwd = Path().cwd()
+        cwd = Path().cwd() if app_dir is None else app_dir
         cwd_str_path = str(cwd)
         if cwd_str_path not in sys.path:
             sys.path.append(cwd_str_path)
@@ -212,7 +212,7 @@ def _prepare(self, ctx: Context) -> None:
             env: LitestarEnv | None = ctx.obj
         else:
             try:
-                env = ctx.obj = LitestarEnv.from_env(ctx.params.get("app_path"))
+                env = ctx.obj = LitestarEnv.from_env(ctx.params.get("app_path"), ctx.params.get("app_dir"))
             except LitestarCLIException:
                 env = None
 
diff --git a/litestar/cli/main.py b/litestar/cli/main.py
--- a/litestar/cli/main.py
+++ b/litestar/cli/main.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import sys
 from pathlib import Path
 from typing import TYPE_CHECKING
 
@@ -47,10 +46,8 @@
 @pass_context
 def litestar_group(ctx: Context, app_path: str | None, app_dir: Path | None = None) -> None:
     """Litestar CLI."""
-    sys.path.append(str(app_dir))
-
     if ctx.obj is None:  # env has not been loaded yet, so we can lazy load it
-        ctx.obj = lambda: LitestarEnv.from_env(app_path)
+        ctx.obj = lambda: LitestarEnv.from_env(app_path, app_dir=app_dir)
 
 
 # add sub commands here


Test patch:
diff --git a/tests/unit/test_cli/test_cli.py b/tests/unit/test_cli/test_cli.py
--- a/tests/unit/test_cli/test_cli.py
+++ b/tests/unit/test_cli/test_cli.py
@@ -3,6 +3,9 @@
 from typing import TYPE_CHECKING
 from unittest.mock import MagicMock
 
+from tests.unit.test_cli import CREATE_APP_FILE_CONTENT
+from tests.unit.test_cli.conftest import CreateAppFileFixture
+
 try:
     from rich_click import group
 except ImportError:
@@ -38,6 +41,25 @@ def test_info_command(mocker: "MockerFixture", runner: "CliRunner", app_file: "P
     mock.assert_called_once()
 
 
+def test_info_command_with_app_dir(
+    mocker: "MockerFixture", runner: "CliRunner", create_app_file: CreateAppFileFixture
+) -> None:
+    app_file = "main.py"
+    app_file_without_extension = app_file.split(".")[0]
+    create_app_file(
+        file=app_file,
+        directory="src",
+        content=CREATE_APP_FILE_CONTENT,
+        subdir="info_with_app_dir",
+        init_content=f"from .{app_file_without_extension} import create_app",
+    )
+    mock = mocker.patch("litestar.cli.commands.core.show_app_info")
+    result = runner.invoke(cli_command, ["--app", "info_with_app_dir:create_app", "--app-dir", "src", "info"])
+
+    assert result.exception is None
+    mock.assert_called_once()
+
+
 def test_register_commands_from_entrypoint(mocker: "MockerFixture", runner: "CliRunner", app_file: "Path") -> None:
     mock_command_callback = MagicMock()
 
diff --git a/tests/unit/test_cli/test_core_commands.py b/tests/unit/test_cli/test_core_commands.py
--- a/tests/unit/test_cli/test_core_commands.py
+++ b/tests/unit/test_cli/test_core_commands.py
@@ -127,7 +127,7 @@ def test_run_command(
         else:
             args.extend([f"--reload-dir={s}" for s in reload_dir])
 
-    path = create_app_file(custom_app_file or "app.py", subdir=app_dir)
+    path = create_app_file(custom_app_file or "app.py", directory=app_dir)
 
     result = runner.invoke(cli_command, args)
 
diff --git a/tests/unit/test_cli/test_env_resolution.py b/tests/unit/test_cli/test_env_resolution.py
--- a/tests/unit/test_cli/test_env_resolution.py
+++ b/tests/unit/test_cli/test_env_resolution.py
@@ -114,3 +114,32 @@ def test_env_from_env_autodiscover_from_files_ignore_paths(
 
     with pytest.raises(ClickException):
         LitestarEnv.from_env(None)
+
+
+@pytest.mark.parametrize("use_file_in_app_path", [True, False])
+def test_env_using_app_dir(
+    app_file_content: str, app_file_app_name: str, create_app_file: CreateAppFileFixture, use_file_in_app_path: bool
+) -> None:
+    app_file = "main.py"
+    app_file_without_extension = app_file.split(".")[0]
+    tmp_file_path = create_app_file(
+        file=app_file,
+        directory="src",
+        content=app_file_content,
+        subdir=f"litestar_test_{app_file_app_name}",
+        init_content=f"from .{app_file_without_extension} import {app_file_app_name}",
+    )
+    app_path_components = [f"litestar_test_{app_file_app_name}"]
+    if use_file_in_app_path:
+        app_path_components.append(app_file_without_extension)
+
+    app_path = f"{'.'.join(app_path_components)}:{app_file_app_name}"
+    env = LitestarEnv.from_env(app_path, app_dir=Path().cwd() / "src")
+
+    dotted_path = _path_to_dotted_path(tmp_file_path.relative_to(Path.cwd()))
+
+    assert isinstance(env.app, Litestar)
+    dotted_path = dotted_path.replace("src.", "")
+    if not use_file_in_app_path:
+        dotted_path = dotted_path.replace(".main", "")
+    assert env.app_path == f"{dotted_path}:{app_file_app_name}"