Repository: litestar-org/litestar

Issue:
Enhancement: Make `litestar` CLI part of the base package
### Summary

Move the CLI dependencies into the core package

### Basic Example

`pip install litestar` should include the Litestar CLI

`pip install litestar[standard]` should continue to do the same thing (base + uvicorn, etc.)

### Drawbacks and Impact

- Heavier package with click-rich (click + rich) installed

### Unresolved questions

_No response_

###

Flask, Django, etc. do this; It makes sense for us. Relevant Discord forum post:
https://discord.com/channels/919193495116337154/1152738535397404752

<!-- 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/2318">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2318/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2318/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
I can look into this.

Resolving patch:
diff --git a/litestar/cli/_utils.py b/litestar/cli/_utils.py
--- a/litestar/cli/_utils.py
+++ b/litestar/cli/_utils.py
@@ -20,13 +20,20 @@
 from litestar.utils import get_name
 
 RICH_CLICK_INSTALLED = False
-try:
+with contextlib.suppress(ImportError):
     import rich_click  # noqa: F401
 
     RICH_CLICK_INSTALLED = True
-except ImportError:
-    pass
+UVICORN_INSTALLED = False
+with contextlib.suppress(ImportError):
+    import uvicorn  # noqa: F401
 
+    UVICORN_INSTALLED = True
+JSBEAUTIFIER_INSTALLED = False
+with contextlib.suppress(ImportError):
+    import jsbeautifier  # noqa: F401
+
+    JSBEAUTIFIER_INSTALLED = True
 if TYPE_CHECKING or not RICH_CLICK_INSTALLED:  # pragma: no cover
     from click import ClickException, Command, Context, Group, pass_context
 else:
@@ -37,6 +44,8 @@
 
 __all__ = (
     "RICH_CLICK_INSTALLED",
+    "UVICORN_INSTALLED",
+    "JSBEAUTIFIER_INSTALLED",
     "LoadedApp",
     "LitestarCLIException",
     "LitestarEnv",
diff --git a/litestar/cli/commands/core.py b/litestar/cli/commands/core.py
--- a/litestar/cli/commands/core.py
+++ b/litestar/cli/commands/core.py
@@ -7,13 +7,15 @@
 import sys
 from typing import TYPE_CHECKING, Any
 
-import uvicorn
 from rich.tree import Tree
 
-from litestar.cli._utils import RICH_CLICK_INSTALLED, LitestarEnv, console, show_app_info
+from litestar.cli._utils import RICH_CLICK_INSTALLED, UVICORN_INSTALLED, LitestarEnv, console, show_app_info
 from litestar.routes import HTTPRoute, WebSocketRoute
 from litestar.utils.helpers import unwrap_partial
 
+if UVICORN_INSTALLED:
+    import uvicorn
+
 if TYPE_CHECKING or not RICH_CLICK_INSTALLED:  # pragma: no cover
     import click
     from click import Context, command, option
@@ -95,7 +97,7 @@ def run_command(
     pdb: bool,
     ctx: Context,
 ) -> None:
-    """Run a Litestar app.
+    """Run a Litestar app; requires ``uvicorn``.
 
     The app can be either passed as a module path in the form of <module name>.<submodule>:<app instance or factory>,
     set as an environment variable LITESTAR_APP with the same format or automatically discovered from one of these
@@ -110,6 +112,12 @@ def run_command(
     if pdb:
         os.environ["LITESTAR_PDB"] = "1"
 
+    if not UVICORN_INSTALLED:
+        console.print(
+            r"uvicorn is not installed. Please install the standard group, litestar\[standard], to use this command."
+        )
+        sys.exit(1)
+
     if callable(ctx.obj):
         ctx.obj = ctx.obj()
     else:
@@ -135,7 +143,10 @@ def run_command(
     show_app_info(app)
 
     if workers == 1 and not reload:
-        uvicorn.run(
+        # A guard statement at the beginning of this function prevents uvicorn from being unbound
+        # See "reportUnboundVariable in:
+        # https://microsoft.github.io/pyright/#/configuration?id=type-check-diagnostics-settings
+        uvicorn.run(  # pyright: ignore
             app=env.app_path,
             host=host,
             port=port,
diff --git a/litestar/cli/commands/schema.py b/litestar/cli/commands/schema.py
--- a/litestar/cli/commands/schema.py
+++ b/litestar/cli/commands/schema.py
@@ -2,14 +2,13 @@
 from pathlib import Path
 from typing import TYPE_CHECKING
 
-from jsbeautifier import Beautifier
 from yaml import dump as dump_yaml
 
 from litestar import Litestar
 from litestar._openapi.typescript_converter.converter import (
     convert_openapi_to_typescript,
 )
-from litestar.cli._utils import RICH_CLICK_INSTALLED, LitestarCLIException, LitestarGroup
+from litestar.cli._utils import JSBEAUTIFIER_INSTALLED, RICH_CLICK_INSTALLED, LitestarCLIException, LitestarGroup
 
 if TYPE_CHECKING or not RICH_CLICK_INSTALLED:  # pragma: no cover
     from click import Path as ClickPath
@@ -18,11 +17,13 @@
     from rich_click import Path as ClickPath
     from rich_click import group, option
 
+if JSBEAUTIFIER_INSTALLED:  # pragma: no cover
+    from jsbeautifier import Beautifier
 
-__all__ = ("generate_openapi_schema", "generate_typescript_specs", "schema_group")
+    beautifier = Beautifier()
 
 
-beautifier = Beautifier()
+__all__ = ("generate_openapi_schema", "generate_typescript_specs", "schema_group")
 
 
 @group(cls=LitestarGroup, name="schema")
@@ -64,7 +65,10 @@ def generate_typescript_specs(app: Litestar, output: Path, namespace: str) -> No
     """Generate TypeScript specs from the OpenAPI schema."""
     try:
         specs = convert_openapi_to_typescript(app.openapi_schema, namespace)
-        beautified_output = beautifier.beautify(specs.write())
-        output.write_text(beautified_output)
+        # beautifier will be defined if JSBEAUTIFIER_INSTALLED is True
+        specs_output = (
+            beautifier.beautify(specs.write()) if JSBEAUTIFIER_INSTALLED else specs.write()  # pyright: ignore
+        )
+        output.write_text(specs_output)
     except OSError as e:  # pragma: no cover
         raise LitestarCLIException(f"failed to write schema to path {output}") from e


Test patch:
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
@@ -250,6 +250,24 @@ def test_run_command_pdb(
     assert os.getenv("LITESTAR_PDB") == "1"
 
 
+@pytest.mark.usefixtures("mock_uvicorn_run", "unset_env")
+def test_run_command_without_uvicorn_installed(
+    app_file: Path,
+    runner: CliRunner,
+    monkeypatch: MonkeyPatch,
+    create_app_file: CreateAppFileFixture,
+    mocker: MockerFixture,
+) -> None:
+    mocker.patch("litestar.cli.commands.core.UVICORN_INSTALLED", False)
+    console_print_mock = mocker.patch("litestar.cli.commands.core.console.print")
+    path = create_app_file("_create_app_with_path.py", content=CREATE_APP_FILE_CONTENT)
+    app_path = f"{path.stem}:create_app"
+
+    result = runner.invoke(cli_command, ["--app", app_path, "run"])
+    assert result.exit_code == 1
+    assert any("uvicorn is not installed" in arg for args, kwargs in console_print_mock.call_args_list for arg in args)
+
+
 @pytest.mark.parametrize("short", [True, False])
 def test_version_command(short: bool, runner: CliRunner) -> None:
     result = runner.invoke(cli_command, "version --short" if short else "version")
diff --git a/tests/unit/test_cli/test_schema_commands.py b/tests/unit/test_cli/test_schema_commands.py
--- a/tests/unit/test_cli/test_schema_commands.py
+++ b/tests/unit/test_cli/test_schema_commands.py
@@ -56,3 +56,24 @@ def test_openapi_typescript_command(
     result = runner.invoke(cli_command, command)
     assert result.exit_code == 0
     assert mock_path_write_text.called
+
+
+@pytest.mark.parametrize(
+    "namespace, filename", (("Custom", ""), ("", "custom_specs.ts"), ("Custom", "custom_specs.ts"))
+)
+def test_openapi_typescript_command_without_jsbeautifier(
+    runner: CliRunner, mocker: MockerFixture, monkeypatch: MonkeyPatch, filename: str, namespace: str
+) -> None:
+    monkeypatch.setenv("LITESTAR_APP", "test_apps.openapi_test_app.main:app")
+    mocker.patch("litestar.cli.commands.schema.JSBEAUTIFIER_INSTALLED", False)
+    mock_path_write_text = mocker.patch("pathlib.Path.write_text")
+    command = "schema typescript"
+
+    if namespace:
+        command += f" --namespace {namespace}"
+    if filename:
+        command += f" --output {filename}"
+
+    result = runner.invoke(cli_command, command)
+    assert result.exit_code == 0
+    assert mock_path_write_text.called