Repository: Neoteroi/BlackSheep
Pull request #335 (2023-04-24 19:29:43)

v2.0a6

codecov-commenter: ## [Codecov](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) Report
Patch coverage: **`83.33`**% and project coverage change: **`-0.18`** :warning:
> Comparison is base [(`c7adfb2`)](https://codecov.io/gh/Neoteroi/BlackSheep/commit/c7adfb2885d55fb14f874bdd5c616c99343aaf51?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.89% compared to head [(`5d04e8c`)](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.71%.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #335      +/-   ##
==========================================
- Coverage   97.89%   97.71%   -0.18%     
==========================================
  Files          65       66       +1     
  Lines        6116     6167      +51     
==========================================
+ Hits         5987     6026      +39     
- Misses        129      141      +12     
```


| [Impacted Files](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) | Coverage Δ | |
|---|---|---|
| [blacksheep/common/files/asyncfs.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jb21tb24vZmlsZXMvYXN5bmNmcy5weQ==) | `100.00% <ø> (ø)` | |
| [blacksheep/server/authentication/jwt.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvYXV0aGVudGljYXRpb24vand0LnB5) | `100.00% <ø> (ø)` | |
| [blacksheep/server/authorization/\_\_init\_\_.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvYXV0aG9yaXphdGlvbi9fX2luaXRfXy5weQ==) | `100.00% <ø> (ø)` | |
| [blacksheep/client/pool.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9jbGllbnQvcG9vbC5weQ==) | `97.75% <50.00%> (-2.25%)` | :arrow_down: |
| [blacksheep/utils/meta.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC91dGlscy9tZXRhLnB5) | `53.33% <53.33%> (ø)` | |
| [blacksheep/server/env.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvZW52LnB5) | `90.00% <84.61%> (-10.00%)` | :arrow_down: |
| [blacksheep/server/application.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvYXBwbGljYXRpb24ucHk=) | `92.15% <94.44%> (-0.04%)` | :arrow_down: |
| [blacksheep/server/compression.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvY29tcHJlc3Npb24ucHk=) | `94.11% <100.00%> (ø)` | |
| [blacksheep/server/normalization.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvbm9ybWFsaXphdGlvbi5weQ==) | `99.64% <100.00%> (ø)` | |
| [blacksheep/server/routing.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvcm91dGluZy5weQ==) | `100.00% <100.00%> (ø)` | |
| ... and [2 more](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/Neoteroi/BlackSheep/pull/335?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).


Patch:
diff --git a/.github/workflows/build-manylinux-wheels.sh b/.github/workflows/build-manylinux-wheels.sh
index 4346587..3691c91 100755
--- a/.github/workflows/build-manylinux-wheels.sh
+++ b/.github/workflows/build-manylinux-wheels.sh
@@ -13,11 +13,11 @@ fi
 # Compile wheels
 PYTHON="/opt/python/${ML_PYTHON_VERSION}/bin/python"
 PIP="/opt/python/${ML_PYTHON_VERSION}/bin/pip"
-"${PIP}" install --upgrade setuptools pip wheel
+"${PIP}" install --upgrade build
 cd "${GITHUB_WORKSPACE}"
 
 rm -rf dist/
-"${PYTHON}" setup.py bdist_wheel
+"${PYTHON}" -m build
 
 # Bundle external shared libraries into the wheels.
 for whl in "${GITHUB_WORKSPACE}"/dist/*.whl; do
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index b9a1c47..4a55b06 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -66,11 +66,6 @@ jobs:
           flake8 blacksheep
           flake8 tests
 
-      - name: Test Hypercorn
-        run: |
-          SET ASGI_SERVER=hypercorn
-          pytest itests/test_server.py
-
   build:
     strategy:
       fail-fast: false
@@ -124,7 +119,7 @@ jobs:
           if [ "${PYVER:0:-2}" == "Python 3.10" ]; then
             echo "Skipping Hypercorn tests, since it fails with Python 3.10.0 on Ubuntu (2021/10/24)"
           else
-            ASGI_SERVER=hypercorn pytest itests/test_server.py
+            APP_DEFAULT_ROUTER=false ASGI_SERVER=hypercorn pytest itests/test_server.py
           fi
 
       - name: Upload pytest test results
@@ -202,7 +197,7 @@ jobs:
       - name: Build wheels (non-linux)
         if: "!startsWith(matrix.os, 'ubuntu')"
         run: |
-          python setup.py bdist_wheel
+          python -m build
 
       - uses: actions/upload-artifact@v1
         with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 516f12c..91c234b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [2.0a6] - 2023-04-24 :crown:
+
+- Adds support for automatic import of modules defined under `controllers` and
+  `routes` packages, relatively to where the `Application` class is
+  instantiated. Fix #334.
+- Adds a `GzipMiddleware` that can be used to enable `gzip` compression, using
+  the built-in module. Contributed by @tyzhnenko :sparkles:
+- Improves how tags are generated for OpenAPI Documentation: adds the
+  possibility to document tags explicitly and control their order, otherwise
+  sorts them alphabetically by default, when using controllers or specifying
+  tags for routes. Contributed by @tyzhnenko :sparkles:
+- Adds a strategy to control features depending on application environment:
+  `is_development`, `is_production` depending on `APP_ENV` environment
+  variable. For more information, see [_Defining application
+  environment_](https://www.neoteroi.dev/blacksheep/settings/#defining-application-environment).
+- Makes the client `ConnectionPools` a context manager, its `__exit__` method
+  closes all its `TCP-IP` connections.
+- Replaces `chardet` and `cchardet` with `charset-normalizer`. Contributed by
+  @mementum.
+- Upgrades all dependencies.
+- Adopts `pyproject.toml`.
+
 ## [2.0a5] - 2023-04-02 :fish:
 
 - Adds support for user defined filters for server routes (`RouteFilter` class).
diff --git a/Makefile b/Makefile
index ba97bb6..10aa852 100644
--- a/Makefile
+++ b/Makefile
@@ -37,20 +37,20 @@ annotate:
 	cython blacksheep/baseapp.pyx -a
 
 
-artifacts: test
-	python setup.py sdist
+build: test
+	python -m build
 
 
 prepforbuild:
-	pip install --upgrade twine setuptools wheel
+	pip install --upgrade build
 
 
 testrelease:
-	twine upload --repository-url https://test.pypi.org/legacy/ dist/*
+	twine upload -r testpypi dist/*
 
 
 release: clean compile artifacts
-	twine upload --repository-url https://upload.pypi.org/legacy/ dist/*
+	twine upload -r pypi dist/*
 
 
 test:
diff --git a/blacksheep/client/pool.py b/blacksheep/client/pool.py
index 55dcb95..3a56a3b 100644
--- a/blacksheep/client/pool.py
+++ b/blacksheep/client/pool.py
@@ -130,6 +130,12 @@ class ConnectionPools:
             self._pools[key] = new_pool
             return new_pool
 
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.dispose()
+
     def dispose(self):
         for pool in self._pools.values():
             pool.dispose()
diff --git a/blacksheep/common/files/asyncfs.py b/blacksheep/common/files/asyncfs.py
index 2f339fb..e7cc2ca 100644
--- a/blacksheep/common/files/asyncfs.py
+++ b/blacksheep/common/files/asyncfs.py
@@ -28,7 +28,7 @@ class FileContext(PoolClient):
         file_path: str,
         *,
         loop: Optional[AbstractEventLoop] = None,
-        mode: str = "rb"
+        mode: str = "rb",
     ):
         super().__init__(loop)
         self._file_path = file_path
diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py
index e004d4e..13f6d5a 100644
--- a/blacksheep/server/application.py
+++ b/blacksheep/server/application.py
@@ -58,13 +58,16 @@ from blacksheep.server.responses import _ensure_bytes
 from blacksheep.server.routing import (
     MountRegistry,
     RegisteredRoute,
+    RouteMethod,
     Router,
     RoutesRegistry,
 )
+from blacksheep.server.routing import router as default_router
 from blacksheep.server.websocket import WebSocket
 from blacksheep.sessions import SessionMiddleware, SessionSerializer
 from blacksheep.settings.di import di_settings
 from blacksheep.utils import ensure_bytes, join_fragments
+from blacksheep.utils.meta import get_parent_file, import_child_modules
 
 
 def get_default_headers_middleware(
@@ -152,7 +155,7 @@ class ApplicationSyncEvent(ApplicationEvent):
 
 
 class ApplicationStartupError(RuntimeError):
-    ...
+    """Base class for errors occurring when an application starts."""
 
 
 class ApplicationAlreadyStartedCORSError(TypeError):
@@ -173,24 +176,20 @@ class Application(BaseApplication):
         *,
         router: Optional[Router] = None,
         services: Optional[ContainerProtocol] = None,
-        debug: bool = False,
-        show_error_details: Optional[bool] = None,
+        show_error_details: bool = False,
         mount: Optional[MountRegistry] = None,
     ):
         env_settings = EnvironmentSettings()
         if router is None:
-            router = Router()
+            router = default_router if env_settings.use_default_router else Router()
         if services is None:
             services = di_settings.get_default_container()
-        if show_error_details is None:
-            show_error_details = env_settings.show_error_details
         if mount is None:
             mount = MountRegistry(env_settings.mount_auto_events)
-        super().__init__(show_error_details, router)
+        super().__init__(show_error_details or env_settings.show_error_details, router)
 
         assert services is not None
         self._services: ContainerProtocol = services
-        self.debug = debug
         self.middlewares: List[Callable[..., Awaitable[Response]]] = []
         self._default_headers: Optional[Tuple[Tuple[str, str], ...]] = None
         self._middlewares_configured = False
@@ -208,6 +207,11 @@ class Application(BaseApplication):
         self._session_middleware: Optional[SessionMiddleware] = None
         self.base_path: str = ""
         self._mount_registry = mount
+        parent_file = get_parent_file()
+
+        if parent_file:
+            _auto_import_controllers(parent_file)
+            _auto_import_routes(parent_file)
 
     @property
     def services(self) -> ContainerProtocol:
@@ -730,7 +734,10 @@ class Application(BaseApplication):
 
     async def _handle_websocket(self, scope, receive, send):
         ws = WebSocket(scope, receive, send)
-        route = self.router.get_ws_match(scope["path"])
+        # TODO: support filters
+        route = self.router.get_match_by_method_and_path(
+            RouteMethod.GET_WS, scope["path"]
+        )
 
         if route:
             ws.route_values = route.values
@@ -822,3 +829,18 @@ class MountMixin:
                 return await route.handler(scope, receive, send)
 
         return await super().__call__(scope, receive, send)  # type: ignore
+
+
+def _auto_import(parent_file: str, folder_name):
+    parent_folder = Path(parent_file).parent
+    controllers_path = parent_folder / folder_name
+    if controllers_path.exists():
+        import_child_modules(controllers_path)
+
+
+def _auto_import_controllers(parent_file: str):
+    _auto_import(parent_file, "controllers")
+
+
+def _auto_import_routes(parent_file: str):
+    _auto_import(parent_file, "routes")
diff --git a/blacksheep/server/authentication/jwt.py b/blacksheep/server/authentication/jwt.py
index 24ddba4..7645a55 100644
--- a/blacksheep/server/authentication/jwt.py
+++ b/blacksheep/server/authentication/jwt.py
@@ -32,7 +32,7 @@ class JWTBearerAuthentication(AuthenticationHandler):
         keys_provider: Optional[KeysProvider] = None,
         keys_url: Optional[str] = None,
         cache_time: float = 10800,
-        auth_mode: str = "JWT Bearer"
+        auth_mode: str = "JWT Bearer",
     ):
         """
         Creates a new instance of JWTBearerAuthentication, which tries to
diff --git a/blacksheep/server/authorization/__init__.py b/blacksheep/server/authorization/__init__.py
index e488058..723c300 100644
--- a/blacksheep/server/authorization/__init__.py
+++ b/blacksheep/server/authorization/__init__.py
@@ -24,7 +24,7 @@ __all__ = (
 def auth(
     policy: Optional[str] = "authenticated",
     *,
-    authentication_schemes: Optional[Sequence[str]] = None
+    authentication_schemes: Optional[Sequence[str]] = None,
 ) -> Callable[..., Any]:
     """
     Configures authorization for a decorated request handler, optionally with a policy.
diff --git a/blacksheep/server/gzip.py b/blacksheep/server/compression.py
similarity index 89%
rename from blacksheep/server/gzip.py
rename to blacksheep/server/compression.py
index 10fab7b..24a5dba 100644
--- a/blacksheep/server/gzip.py
+++ b/blacksheep/server/compression.py
@@ -75,9 +75,7 @@ class GzipMiddleware:
             return any(_type in content_type for _type in self.handled_types)
 
         def is_handled_encoding() -> bool:
-            return request.headers is not None and b"gzip" in (
-                request.headers.get_single(b"accept-encoding") or ""
-            )
+            return b"gzip" in (request.get_first_header(b"accept-encoding") or "")
 
         def is_handled_response_content() -> bool:
             if response is None or response.content is None:
@@ -93,23 +91,13 @@ class GzipMiddleware:
                 and _is_handled_type(response.content.type)
             )
 
-            return all(
-                (
-                    body_pass,
-                    content_type_pass,
-                )
-            )
+            return body_pass and content_type_pass
 
-        return all(
-            (
-                is_handled_encoding(),
-                is_handled_response_content(),
-            )
-        )
+        return is_handled_encoding() and is_handled_response_content()
 
     async def __call__(
         self, request: Request, handler: Callable[[Request], Awaitable[Response]]
-    ) -> Response:
+    ) -> Optional[Response]:
         response = ensure_response(await handler(request))
 
         if response is None or response.content is None:
@@ -147,6 +135,6 @@ def use_gzip_compression(
     if handler is None:
         handler = GzipMiddleware()
 
-    app.middlewares.append(handler)
+    app.middlewares.append(handler)  # type: ignore
 
     return handler
diff --git a/blacksheep/server/env.py b/blacksheep/server/env.py
index 4adc2a3..51ddeae 100644
--- a/blacksheep/server/env.py
+++ b/blacksheep/server/env.py
@@ -1,16 +1,41 @@
 import os
+from dataclasses import dataclass
 
+from blacksheep.utils import truthy
 
-def truthy(value: str, default: bool = False) -> bool:
-    if not value:
-        return default
-    return value.upper() in {"1", "TRUE"}
 
+def get_env() -> str:
+    return os.environ.get("APP_ENV", "production")
 
+
+def is_development() -> bool:
+    """
+    Returns a value indicating whether the application is running for local development.
+    This method checks if an `APP_ENV` environment variable is set and its lowercase
+    value is either "local", "dev", or "development".
+    """
+    return get_env().lower() in {"local", "dev", "development"}
+
+
+def is_production() -> bool:
+    """
+    Returns a value indicating whether the application is running for the production
+    environment (default is true).
+    This method checks if an `APP_ENV` environment variable is set and its lowercase
+    value is either "prod" or "production".
+    """
+    return get_env().lower() in {"prod", "production"}
+
+
+@dataclass(init=False)
 class EnvironmentSettings:
+    env: str
     show_error_details: bool
     mount_auto_events: bool
+    use_default_router: bool
 
     def __init__(self) -> None:
+        self.env = get_env()
         self.show_error_details = truthy(os.environ.get("APP_SHOW_ERROR_DETAILS", ""))
         self.mount_auto_events = truthy(os.environ.get("APP_MOUNT_AUTO_EVENTS", ""))
+        self.use_default_router = truthy(os.environ.get("APP_DEFAULT_ROUTER", "1"))
diff --git a/blacksheep/server/normalization.py b/blacksheep/server/normalization.py
index df5b324..bcdcc3e 100644
--- a/blacksheep/server/normalization.py
+++ b/blacksheep/server/normalization.py
@@ -112,7 +112,7 @@ def _get_method_annotations_base(method):
 # endregion
 
 
-def ensure_response(result) -> Response:
+def ensure_response(result) -> Optional[Response]:
     """
     When a request handler returns a result that is not an instance of Response,
     this method normalizes the output of the method to be either `None`. or an instance
diff --git a/blacksheep/server/routing.py b/blacksheep/server/routing.py
index ad43e79..b069385 100644
--- a/blacksheep/server/routing.py
+++ b/blacksheep/server/routing.py
@@ -16,17 +16,6 @@ from blacksheep.common.types import (
 from blacksheep.messages import Request
 from blacksheep.utils import ensure_bytes, ensure_str
 
-__all__ = [
-    "HTTPMethod",
-    "Router",
-    "Route",
-    "RouteMatch",
-    "RouteDuplicate",
-    "RegisteredRoute",
-    "RoutesRegistry",
-    "RouteMethod",
-]
-
 
 class RouteMethod:
     GET = "GET"
@@ -743,10 +732,6 @@ class Router(RouterBase):
 
         return RouteMatch(self._fallback, None)
 
-    def get_ws_match(self, value: AnyStr) -> Optional[RouteMatch]:
-        # TODO: this does not suppor filters
-        return self.get_match_by_method_and_path(RouteMethod.GET_WS, value)
-
     @lru_cache(maxsize=1200)
     def get_matching_route(self, method: AnyStr, value: AnyStr) -> Optional[Route]:
         for route in self.routes[ensure_bytes(method)]:
@@ -843,3 +828,21 @@ class MountRegistry:
 
 # For backward compatibility
 Mount = MountRegistry
+
+
+# singleton router used to store initial configuration,
+# before the application starts
+# this is used as *default* router, but it can be overridden
+router = Router()
+
+
+head = router.head
+get = router.get
+post = router.post
+put = router.put
+patch = router.patch
+delete = router.delete
+trace = router.trace
+options = router.options
+connect = router.connect
+ws = router.ws
diff --git a/blacksheep/server/security/hsts.py b/blacksheep/server/security/hsts.py
index 46245a0..d3a9a0e 100644
--- a/blacksheep/server/security/hsts.py
+++ b/blacksheep/server/security/hsts.py
@@ -1,7 +1,7 @@
 from blacksheep.messages import Request, Response
 
 
-def _get_hsts_value(max_age: int, include_subdomains: bool) -> bytes:
+def write_hsts_header_value(max_age: int, include_subdomains: bool) -> bytes:
     value = f"max-age={max_age};"
 
     if include_subdomains:
@@ -21,7 +21,7 @@ class HSTSMiddleware:
         max_age: int = 31536000,
         include_subdomains: bool = True,
     ) -> None:
-        self._value = _get_hsts_value(max_age, include_subdomains)
+        self._value = write_hsts_header_value(max_age, include_subdomains)
 
     async def __call__(self, request: Request, handler):
         response: Response = await handler(request)
diff --git a/blacksheep/utils/__init__.py b/blacksheep/utils/__init__.py
index 0f79bd3..59ce661 100644
--- a/blacksheep/utils/__init__.py
+++ b/blacksheep/utils/__init__.py
@@ -37,3 +37,9 @@ def get_class_hierarchy(cls: Type[T]) -> Tuple[Type[T], ...]:
 
 def get_class_instance_hierarchy(instance: T) -> Tuple[Type[T], ...]:
     return get_class_hierarchy(type(instance))
+
+
+def truthy(value: str, default: bool = False) -> bool:
+    if not value:
+        return default
+    return value.upper() in {"1", "TRUE"}
diff --git a/blacksheep/utils/meta.py b/blacksheep/utils/meta.py
new file mode 100644
index 0000000..e65da02
--- /dev/null
+++ b/blacksheep/utils/meta.py
@@ -0,0 +1,30 @@
+import glob
+import inspect
+import os
+from pathlib import Path
+
+
+def get_parent_file():
+    """
+    Returns __file__ of the caller's parent module.
+    """
+    try:
+        return inspect.stack()[2][1]
+    except IndexError:
+        return ""
+
+
+def import_child_modules(root_path: Path):
+    """
+    Import automatically all modules defined
+    under a certain package path.
+    """
+    path = str(root_path)
+    modules = [
+        os.path.basename(f)[:-3]
+        for f in glob.glob(path + "/*.py")
+        if not os.path.basename(f).startswith("_")
+    ]
+    stripped_path = os.path.relpath(path).replace("/", ".")
+    for module in modules:
+        __import__(stripped_path + "." + module)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..3d038fe
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,51 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "blacksheep"
+version = "2.0a6"
+authors = [
+    { name = "Roberto Prevato", email = "roberto.prevato@gmail.com" },
+]
+description = "Fast web framework for Python asyncio"
+readme = "README.md"
+requires-python = ">=3.7"
+classifiers = [
+    "Development Status :: 3 - Alpha",
+    "License :: OSI Approved :: MIT License",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Environment :: Web Environment",
+    "Operating System :: OS Independent",
+    "Framework :: AsyncIO",
+]
+keywords = ["blacksheep", "web framework", "asyncio"]
+dependencies = [
+    "httptools>=0.5",
+    "certifi>=2022.9.24",
+    "charset-normalizer~=3.1.0",
+    "guardpost~=1.0.0",
+    "rodi~=2.0.2",
+    "essentials>=1.1.4,<2.0",
+    "essentials-openapi>=1.0.6,<1.1",
+    "python-dateutil~=2.8.2",
+    "itsdangerous~=2.1.2",
+]
+
+[tool.setuptools]
+packages = ["blacksheep"]
+
+[project.optional-dependencies]
+jinja = ["Jinja2~=3.1.2"]
+full = [
+    "cryptography~=38.0.1",
+    "PyJWT~=2.6.0",
+    "websockets~=10.3",
+]
+
+[project.urls]
+"Homepage" = "https://github.com/Neoteroi/BlackSheep"
+"Bug Tracker" = "https://github.com/Neoteroi/BlackSheep/issues"
diff --git a/setup.py b/setup.py
index cf37a72..82a6e3c 100644
--- a/setup.py
+++ b/setup.py
@@ -1,57 +1,9 @@
-from setuptools import setup, Extension
-
-
-def readme():
-    with open("README.md") as f:
-        return f.read()
-
+from setuptools import Extension, setup
 
 COMPILE_ARGS = ["-O2"]
 
 
 setup(
-    name="blacksheep",
-    version="2.0a5",
-    description="Fast web framework for Python asyncio",
-    long_description=readme(),
-    long_description_content_type="text/markdown",
-    classifiers=[
-        "Development Status :: 3 - Alpha",
-        "License :: OSI Approved :: MIT License",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Programming Language :: Python :: 3.11",
-        "Environment :: Web Environment",
-        "Operating System :: OS Independent",
-        "Framework :: AsyncIO",
-    ],
-    setup_requires=["wheel"],
-    url="https://github.com/Neoteroi/BlackSheep",
-    author="Roberto Prevato",
-    author_email="roberto.prevato@gmail.com",
-    keywords="BlackSheep web framework",
-    platforms=["*nix"],
-    license="MIT",
-    packages=[
-        "blacksheep",
-        "blacksheep.server",
-        "blacksheep.server.authentication",
-        "blacksheep.server.authorization",
-        "blacksheep.server.files",
-        "blacksheep.server.headers",
-        "blacksheep.server.remotes",
-        "blacksheep.server.res",
-        "blacksheep.server.openapi",
-        "blacksheep.server.security",
-        "blacksheep.settings",
-        "blacksheep.client",
-        "blacksheep.common",
-        "blacksheep.common.files",
-        "blacksheep.sessions",
-        "blacksheep.testing",
-        "blacksheep.utils",
-    ],
     ext_modules=[
         Extension(
             "blacksheep.url",
@@ -93,28 +45,5 @@ setup(
             ["blacksheep/baseapp.c"],
             extra_compile_args=COMPILE_ARGS,
         ),
-    ],
-    install_requires=[
-        "httptools>=0.5",
-        "certifi>=2022.9.24",
-        "charset-normalizer~=3.1.0",
-        "guardpost~=1.0.0",
-        "rodi~=2.0.2",
-        "essentials>=1.1.4,<2.0",
-        "essentials-openapi>=1.0.6,<1.1",
-        "python-dateutil~=2.8.2",
-        "itsdangerous~=2.1.2",
-    ],
-    extras_require={
-        "full": [
-            "cryptography~=38.0.1",
-            "PyJWT~=2.6.0",
-            "websockets~=10.3",
-        ],
-        "jinja": [
-            "Jinja2~=3.1.2",
-        ],
-    },
-    include_package_data=True,
-    zip_safe=False,
+    ]
 )


Test patch:
diff --git a/itests/app_1.py b/itests/app_1.py
index 37f4478..9c5894f 100644
--- a/itests/app_1.py
+++ b/itests/app_1.py
@@ -17,7 +17,7 @@ from blacksheep import (
     json,
     text,
 )
-from blacksheep.server.gzip import use_gzip_compression
+from blacksheep.server.compression import use_gzip_compression
 from itests.utils import CrashTest, ensure_folder
 
 app = Application(show_error_details=True)
diff --git a/itests/app_2.py b/itests/app_2.py
index d928302..1930793 100644
--- a/itests/app_2.py
+++ b/itests/app_2.py
@@ -29,8 +29,8 @@ from blacksheep.server.bindings import (
     FromQuery,
     FromServices,
 )
+from blacksheep.server.compression import use_gzip_compression
 from blacksheep.server.controllers import APIController
-from blacksheep.server.gzip import use_gzip_compression
 from blacksheep.server.openapi.common import (
     ContentInfo,
     EndpointDocs,
diff --git a/itests/app_3.py b/itests/app_3.py
index 849b7e8..4445730 100644
--- a/itests/app_3.py
+++ b/itests/app_3.py
@@ -1,7 +1,7 @@
 import uvicorn
 
 from blacksheep.server import Application
-from blacksheep.server.gzip import use_gzip_compression
+from blacksheep.server.compression import use_gzip_compression
 from blacksheep.server.responses import json
 
 application = Application(show_error_details=True)
diff --git a/itests/app_4.py b/itests/app_4.py
index 358cd55..b86e525 100644
--- a/itests/app_4.py
+++ b/itests/app_4.py
@@ -9,7 +9,7 @@ import uvicorn
 from blacksheep import JSONContent, Response
 from blacksheep.server import Application
 from blacksheep.server.bindings import FromJSON
-from blacksheep.server.gzip import use_gzip_compression
+from blacksheep.server.compression import use_gzip_compression
 from blacksheep.server.responses import json
 from blacksheep.server.websocket import WebSocket
 from blacksheep.settings.json import default_json_dumps, json_settings
diff --git a/tests/conftest.py b/tests/conftest.py
index e32ac2c..f90bf61 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -7,6 +7,7 @@ from blacksheep.settings.html import html_settings
 from tests.utils.application import FakeApplication
 
 # configures default Jinja settings for tests
+os.environ["APP_DEFAULT_ROUTER"] = "0"
 os.environ["APP_JINJA_PACKAGE_NAME"] = "tests.testapp"
 os.environ["APP_JINJA_PACKAGE_PATH"] = "templates"
 
diff --git a/tests/test_gzip.py b/tests/test_gzip.py
index 5781387..8d30342 100644
--- a/tests/test_gzip.py
+++ b/tests/test_gzip.py
@@ -2,7 +2,7 @@ import gzip
 
 import pytest
 
-from blacksheep.server.gzip import GzipMiddleware
+from blacksheep.server.compression import GzipMiddleware
 from blacksheep.testing.helpers import get_example_scope
 from blacksheep.testing.messages import MockReceive, MockSend
 
diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py
index 3f2a864..acc5642 100644
--- a/tests/test_openapi_v3.py
+++ b/tests/test_openapi_v3.py
@@ -13,7 +13,7 @@ from pydantic.types import NegativeFloat, PositiveInt, condecimal, confloat, con
 
 from blacksheep.server.application import Application
 from blacksheep.server.bindings import FromForm
-from blacksheep.server.controllers import APIController, get, post
+from blacksheep.server.controllers import APIController
 from blacksheep.server.openapi.common import (
     ContentInfo,
     EndpointDocs,
@@ -193,8 +193,7 @@ def get_app() -> Application:
 
 
 def get_cats_api() -> Application:
-    app = Application()
-    app.controllers_router = RoutesRegistry()
+    app = get_app()
     get = app.router.get
     post = app.router.post
     delete = app.router.delete
@@ -241,7 +240,7 @@ def serializer() -> Serializer:
 
 @pytest.mark.asyncio
 async def test_raises_for_started_app(docs):
-    app = Application()
+    app = get_app()
 
     await app.start()
 
@@ -413,7 +412,7 @@ def test_dates_handling(docs: OpenAPIHandler, serializer: Serializer):
 
     assert isinstance(schema, Schema)
 
-    yaml = serializer.to_yaml(docs.generate_documentation(Application()))
+    yaml = serializer.to_yaml(docs.generate_documentation(get_app()))
 
     assert (
         yaml.strip()
@@ -454,7 +453,7 @@ def test_register_schema_for_generic_with_list(
 
     assert isinstance(schema, Schema)
 
-    yaml = serializer.to_yaml(docs.generate_documentation(Application()))
+    yaml = serializer.to_yaml(docs.generate_documentation(get_app()))
 
     assert (
         yaml.strip()
@@ -518,7 +517,7 @@ def test_register_schema_for_multiple_generic_with_list(
     schema = docs.components.schemas["PaginatedSetOfUfo"]
     assert isinstance(schema, Schema)
 
-    yaml = serializer.to_yaml(docs.generate_documentation(Application()))
+    yaml = serializer.to_yaml(docs.generate_documentation(get_app()))
 
     assert (
         yaml.strip()
@@ -606,7 +605,7 @@ def test_register_schema_for_generic_with_property(
 
     assert isinstance(schema, Schema)
 
-    yaml = serializer.to_yaml(docs.generate_documentation(Application()))
+    yaml = serializer.to_yaml(docs.generate_documentation(get_app()))
 
     assert (
         yaml.strip()
@@ -664,7 +663,7 @@ def test_register_schema_for_generic_sub_property(
 
     assert isinstance(schema, Schema)
 
-    yaml = serializer.to_yaml(docs.generate_documentation(Application()))
+    yaml = serializer.to_yaml(docs.generate_documentation(get_app()))
 
     assert (
         yaml.strip()
@@ -805,6 +804,7 @@ async def test_register_schema_for_generic_with_list_reusing_ref(
     docs: OpenAPIHandler, serializer: Serializer
 ):
     app = get_app()
+    docs.bind_app(app)
 
     @docs(tags=["B tag"])
     @app.route("/one")
@@ -816,7 +816,6 @@ async def test_register_schema_for_generic_with_list_reusing_ref(
     def two() -> PaginatedSet[Cat]:
         ...
 
-    docs.bind_app(app)
     await app.start()
 
     yaml = serializer.to_yaml(docs.generate_documentation(app))
@@ -2607,7 +2606,9 @@ tags:
 
 @pytest.mark.asyncio
 async def test_sorting_api_controllers_tags(serializer: Serializer):
-    app = Application()
+    app = get_app()
+    get = app.controllers_router.get
+    post = app.controllers_router.post
 
     docs = OpenAPIHandler(info=Info(title="Example API", version="0.0.1"))
     docs.bind_app(app)