Repository: litestar-org/litestar

Issue:
Move `requests` to `testing` extra
I was inspecting starlight dependencies and was confused to see `requests` being required without any obvious reason.

I found #13 then, but I disagree with the resolution since I believe libs required for testing purposes should not be installed for normal use. Now it only one lib (with several dependencies), but imagine if more dependencies would be added for testing.

#### What I propose:
1. Move requests from required dependencies to `testing` extra (`pip install starlight[testing]`)
1. Remove import of `starlite.testing` from `starlite` package
2. When starlight is imported explicitly (`from starlight import testint`), check for requests installed. if not, raise `RuntimeError("To access starlight.testing install starlight with [testing] extra")`

How would `pyproject.toml` of end user look like:
```toml
[tool.poetry.dependencies]
python = "^3.10"
starlite = "^1.3.9"

[tool.poetry.dev-dependencies]
starlite = {extras = ["testing"], version = "*"}  # whatever version is installed + testing dependencies
pytest = "^5.2"
```


I can send a PR if changes are welcomed.


Discussion:
Hi @Bobronium. This will be a breaking change because the test client etc. are returned from the bottom level `__init__` at present.
@Goldziher, indeed. How big of an issue is that? Is that the only issue you see in the proposal?
Well it is. I understand if you want to make it an extra to remove it from the production code - that's fine. But a breaking change is a problem. Is there a way to add it to extra and have it as the default install path? Otherwise I think this is a no go TBH. The other issue is documentation - it needs to be made clear that this is required for the testing component etc. 
We can overcome this by declaring `__getattr__` in __init__.py ([PEP 562](https://peps.python.org/pep-0562/)) to make smooth transition then:
`cat starlight/__init__.py`
```py
...

def __getattr__(name: str):
    if name not in {"TestClient", "create_test_client", "create_test_request"}:
        raise AttributeError()
    import warnings
    warnings.warn(f"Importing {name} from {__package__} is deprecated, use `from startlite.testing import {name}` instead", DeprecationWarning, stacklevel=2)
    from . import testing
    attr = globals()[name] = getattr(testing, name)
    return attr
```
`python -c "from starlite import TestClient; print(TestClient)"`
```
<string>:1: DeprecationWarning: Importing TestClient from starlite is deprecated, use `from startlite.testing impory TestClient` instead
<class 'starlite.testing.TestClient'>
```



go ahead and make a PR if it's not a big issue. @peterschutt and myself will check it out - I am apprehensive about breaking code for users TBH, but i am will to test it first.

Thanks for contributing!
So, should I close this issue?
If you don't consider this an issue, sure. 

I'm still positive on implementing this. Just haven't have time to do it yet.
If you're going to implement it, I'll keep it open. 

Resolving patch:
diff --git a/starlite/__init__.py b/starlite/__init__.py
--- a/starlite/__init__.py
+++ b/starlite/__init__.py
@@ -1,3 +1,5 @@
+from typing import TYPE_CHECKING, Any
+
 from starlite.datastructures import File, Redirect, State, Stream, Template
 
 from .app import Starlite
@@ -53,9 +55,12 @@
 from .response import Response
 from .router import Router
 from .routes import BaseRoute, HTTPRoute, WebSocketRoute
-from .testing import TestClient, create_test_client, create_test_request
 from .types import MiddlewareProtocol, Partial, ResponseHeader
 
+if TYPE_CHECKING:
+    from .testing import TestClient, create_test_client, create_test_request
+
+
 __all__ = [
     "ASGIRouteHandler",
     "AbstractAuthenticationMiddleware",
@@ -121,3 +126,17 @@
     "route",
     "websocket",
 ]
+
+_dynamic_imports = {"TestClient", "create_test_client", "create_test_request"}
+
+
+# pylint: disable=import-outside-toplevel
+def __getattr__(name: str) -> Any:
+    """Provide lazy importing as per https://peps.python.org/pep-0562/"""
+    if name not in _dynamic_imports:
+        raise AttributeError(f"Module {__package__} has no attribute {name}")
+
+    from . import testing
+
+    attr = globals()[name] = getattr(testing, name)
+    return attr


Test patch:
diff --git a/starlite/testing.py b/starlite/testing.py
--- a/starlite/testing.py
+++ b/starlite/testing.py
@@ -4,7 +4,15 @@
 from orjson import dumps
 from pydantic import BaseModel
 from pydantic.typing import AnyCallable
-from requests.models import RequestEncodingMixin
+
+try:
+    from requests.models import RequestEncodingMixin
+except ImportError:  # pragma: no cover
+    from starlite.exceptions import MissingDependencyException
+
+    raise MissingDependencyException(
+        "To use starlite.testing, intall starlite with 'testing' extra, e.g. `pip install starlite[testing]`"
+    )
 from starlette.middleware import Middleware
 from starlette.middleware.base import BaseHTTPMiddleware
 from starlette.testclient import TestClient as StarletteTestClient
@@ -35,6 +43,12 @@
     MiddlewareProtocol,
 )
 
+__all__ = [
+    "TestClient",
+    "create_test_client",
+    "create_test_request",
+]
+
 
 class RequestEncoder(RequestEncodingMixin):
     def multipart_encode(self, data: Dict[str, Any]) -> Tuple[bytes, str]:
diff --git a/tests/test_imports.py b/tests/test_imports.py
new file mode 100644
--- /dev/null
+++ b/tests/test_imports.py
@@ -0,0 +1,41 @@
+import subprocess
+import sys
+
+import pytest
+
+
+@pytest.mark.skipif("starlite" in sys.modules, reason="This tests expects starlite to be imported for the first time")
+def test_testing_import_deprecation() -> None:
+    import starlite
+
+    dynamic_imports_names = {"TestClient", "create_test_client", "create_test_request"}
+    assert dynamic_imports_names & starlite.__dict__.keys() == set()
+    assert "starlite.testing" not in sys.modules
+    assert "requests" not in sys.modules
+    assert "requests.models" not in sys.modules
+
+    dynamic_imports = {}
+    for dynamic_import_name in dynamic_imports_names:
+        dynamic_imports[dynamic_import_name] = getattr(starlite, dynamic_import_name)
+
+    assert dynamic_imports_names & starlite.__dict__.keys() == dynamic_imports_names
+    assert "starlite.testing" in sys.modules
+    assert "requests.models" in sys.modules
+
+    from starlite import testing
+
+    assert dynamic_imports == {
+        "TestClient": testing.TestClient,
+        "create_test_client": testing.create_test_client,
+        "create_test_request": testing.create_test_request,
+    }
+
+
+@pytest.mark.skipif(
+    "starlite" not in sys.modules, reason="Was able to run previous test, no need to launch subprocess for that"
+)
+def test_testing_import_deprecation_in_subprocess() -> None:
+    """Run test_testing_import_deprecation in subprocess so it can test importing starlite for the first time"""
+    subprocess.check_call(
+        [sys.executable, "-m", "pytest", f"{__file__}::{test_testing_import_deprecation.__name__}"], timeout=5
+    )