DEV Community

Livio Ribeiro
Livio Ribeiro

Posted on

Announcing Selva web framework

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

And then run uvicorn:

uvicorn selva.run:app --reload
Enter fullscreen mode Exit fullscreen mode

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!")
Enter fullscreen mode Exit fullscreen mode

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!"})
Enter fullscreen mode Exit fullscreen mode

The method hello will respond in the path localhost:8000/api/hello:

$ curl http://localhost:8000/api/hello
{"message": "Hello, World!"}
Enter fullscreen mode Exit fullscreen mode

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}!"})
Enter fullscreen mode Exit fullscreen mode

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()})
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# settings_dev.yaml
dev_property: dev value
mapping:
  property: dev nested value
Enter fullscreen mode Exit fullscreen mode
# final settings
property: value
mapping:
  property: dev nested value
dev_property: dev value
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)