I'd like to announce Selva web framework!
I've been working on it for quite a while, did a lot of back and forth, changed how some things work, then changed back... until I finally felt it was good enough to do a public announcement.
The framework uses under the hood another project of mine, asgikit, which is a toolkit that provides just the bare minimum to create an asgi application.
Table of Contents
Quickstart
First install the required dependencies:
pip install selva uvicorn[standard]
Create the application.py
file:
from asgikit.requests import Request
from selva.web import controller, get
@controller
class Controller:
@get
async def hello(self, request: Request):
response = request.response
await response.start()
await response.write("Hello, World!", end_response=True)
And then run uvicorn:
uvicorn selva.run:app --reload
What's going on?
First, you do not need to create an application, you just use selva.run:app
. Selva will look for a module or package named application
and scan it. Controllers will be registered in the routing system and services will be registered with the dependency injection system. Controllers are also services and take part in the dependency injection system as well.
But the most notable difference from other tools is that, in the request handler, instead of returning a response, you write to the response. This behavior comes from asgikit
and is similar to how Go's net/http
works.
Asgikit provides several helper functions to handle common use cases. The example above could be written as:
from asgikit.requests import Request
from asgikit.responses import respond_text
from selva.web import controller, get
@controller
class Controller:
@get
async def hello(self, request: Request):
await respond_text(request.response, "Hello, World!")
Why is that?
In the common way of returning a response, extensibility is achieved by extending the usual Response
class. Now you have to deal with inheritance and need to know, to some extent, the inner workings of the response class.
With the approach used by Asgikit/Selva (which was inspired by Go's net/http
), extensibility is achieved through helper functions that only need to know how to write to the response.
Reading the request body uses a similar approach, you use helper function, such as asgikit.requests.read_json
, to read the request body as json.
Controllers and handlers
Controllers are classes that have methods to handle request. They can be decorated with the path they respond to:
from asgikit.requests import Request
from asgikit.responses import respond_json
from selva.web import controller, get
@controller("api")
class Controller:
@get("hello")
async def hello(self, request: Request):
await respond_json(request.response, {"message": "Hello, World!"})
The method hello will respond in the path localhost:8000/api/hello
:
$ curl http://localhost:8000/api/hello
{"message": "Hello, World!"}
Path and query parameters
Path parameters are defined in the handler decorator with the format :parameter
to match path segments (between /
), or *parameter
to match paths with the /
character.
Parameters can be extracted from the request by annotating the handler function arguments, such as selva.web.FromPath
or selva.web.FromQuery
:
from typing import Annotated
from asgikit.requests import Request
from asgikit.responses import respond_json
from selva.web import controller, get, FromPath, FromQuery
@controller("api")
class Controller:
@get("hello/:name")
async def hello_path(self, request: Request, name: Annotated[str, FromPath]):
await respond_json(request.response, {"message": f"Hello, {name}!"})
@get("hello")
async def hello_query(self, request: Request, name: Annotated[str, FromQuery]):
await respond_json(request.response, {"message": f"Hello, {name}!"})
Type conversion is done based on the parameter type and can be extended.
Request body and Pydantic
If you annotate a handler parameter with a type that inherits from pydantic.BaseModel
, or a list[pydantic.BaseModel]
, Selva will read the request body and parse using the pydantic.BaseModel
class from the annotation:
from pydantic import BaseModel
from asgikit.requests import Request
from asgikit.responses import respond_json
from selva.web import controller, post
class MyModel(BaseModel):
property: int
@controller("api")
class Controller:
@post("post")
async def hello(self, request: Request, model: MyModel):
await respond_json(request.response, {"model": model.model_dump_json()})
Dependency injection
Selva comes with a dependency injection system. You just need to decorate your services with @service
and annotate the classes where the service will be injected:
from typing import Annotated
from asgikit.requests import Request
from asgikit.responses import respond_json
from selva.di import service, Inject
from selva.web import controller, get, FromPath
@service
class GreeterService:
def greet(self, name: str) -> str:
return f"Hello, {name}!"
@controller("api")
class Controller:
greeter: Annotated[GreeterService, Inject]
@get("hello/:name")
async def hello(self, request: Request, name: Annotated[str, FromPath]):
message = self.greeter.greet(name)
await respond_json(request.response, {"message": message})
Remember that classes decorated with @controller
are services too and therefore can inject other services.
Factory functions
Services can also be defined by decorating a function with @service
, making them factory functions. The same GreeterService
example could be written could be written like this:
from typing import Annotated
from asgikit.requests import Request
from asgikit.responses import respond_json
from selva.di import service, Inject
from selva.web import controller, get, FromPath
class GreeterService:
def greet(self, name: str) -> str:
return f"Hello, {name}!"
@service
def greeter_service_factory() -> GreeterService:
return GreeterService()
@controller("api")
class Controller:
greeter: Annotated[GreeterService, Inject]
@get("hello/:name")
async def hello(self, request: Request, name: Annotated[str, FromPath]):
message = self.greeter.greet(name)
await respond_json(request.response, {"message": message})
Configuration
Selva uses YAML files the provide configuration settings to the application. These files are located in the configuration
directory:
project/
├── application/
│ ├── __init__.py
│ ├── controller.py
│ └── service.py
└── configuration/
├── settings.yaml
├── settings_dev.yaml
└── settings_prod.yaml
The settings files are parsed using strictyaml, so only a subset of the yaml spec is used. This decisions is to make the settings files safer to parse and more consistent.
Selva provides a way to reference environment variables in the settings files:
property: ${PROPERTY}
with_default: ${PROPERTY:default}
Accessing the configuration
You can access the configuration by injection the class selva.configuration.Settings
:
## settings.yaml
# message: Hello, World!
from typing import Annotated
from asgikit.requests import Request
from asgikit.responses import respond_json
from selva.configuration import Settings
from selva.di import Inject
from selva.web import controller, get
@controller("api")
class Controller:
settings: Annotated[Settings, Inject]
@get("hello")
async def hello(self, request: Request):
await respond_json(request.response, {"message": self.settings.message})
Typed configuration
You can use Pydantic and the di system to provided typed settings to your services:
## settings.yaml
# application:
# value: 42
from typing import Annotated
from pydantic import BaseModel
from asgikit.requests import Request
from asgikit.responses import respond_json
from selva.configuration import Settings
from selva.di import service, Inject
from selva.web import controller, get
class MySettings(BaseModel):
value: int
@service
def my_settings_factory(settings: Settings) -> MySettings:
return MySettings.model_validate(settings.application)
@controller("api")
class Controller:
settings: Annotated[MySettings, Inject]
@get("hello")
async def hello(self, request: Request):
await respond_json(request.response, {"value": self.settings.value})
Configuration profiles
We can create different configuration profiles that can be activated by settings the environment variable SELVA_PROFILE
. When a profile is set, the framework will look for a file named configuration/settings_${PROFILE}.yaml
and merge its settings into the main settings. For example, if we define SELVA_PROFILE=dev
, the file configuration/settings_dev.yaml
will be loaded.
# settings.yaml
property: value
mapping:
property: nested value
# settings_dev.yaml
dev_property: dev value
mapping:
property: dev nested value
# final settings
property: value
mapping:
property: dev nested value
dev_property: dev value
Logging
Selva uses loguru for logging and provides some facilities on top of it: it configures by default an interceptor for the stardand logging
module, so all logs go through loguru, and a custom filter that allows settings the log level for individual python packages in the yaml configuration.
# settings.yaml
logging:
# log level to be used when not specified by package
root: WARNING
# specification of log level by package
level:
application: ERROR
application.service: WARNING
sqlalchemy: INFO
sqlalchemy.orm: DEBUG
If a package has disabled loguru (logger.disable("package")
) you can enable it again in the configuration file, and you can disable logging for a package as well:
logging:
enable:
- package
disable:
- other_package
Final words
Selva is still in its infancy, and there is work to do, more tests need to be written, documentation can be improved, but I believe it is a great tool and I hope it can have its place in the Python ecosystem.
Let me know in the comments what you think about Selva.
And by the way, "Selva" is portuguese for "Jungle" :)
Top comments (0)