Announcing Selva web framework
Tags: python, development, announcement, backend
Published: 2023-10-28 10:12:59

I'd like to announce [Selva web framework](https://github.com/livioribeiro/selva)!

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](https://pypi.org/project/asgikit/), which is a toolkit that provides just the bare minimum to create an asgi application.

## Table of Contents

* [Quickstart](#quickstart)
  * [What's going on?](#whats-going-on)
  * [Why is that?](#why-is-that)
* [Controllers and handlers](#controllers-and-handlers)
  * [Path and query parameters](#path-and-query-parameters)
  * [Request body and Pydantic](#request-body-and-pydantic)
* [Dependency injection](#dependency-injection)
  * [Factory functions](#factory-functions)
* [Configuration](#configuration)
  * [Accessing the configuration](#accessing-the-configuration)
  * [Typed configuration](#typed-configuration)
  * [Configuration profiles](#configuration-profiles)
* [Logging](#logging)
* [Final words](#final-words)

## Quickstart

First install the required dependencies:

```sh
pip install selva uvicorn[standard]
```

Create the `application.py` file:

```python
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:

```sh
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:

```python
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:

```python
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`:

```python
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:

```python
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:

```python
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:

```python
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](https://pypi.org/project/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:

```yaml
property: ${PROPERTY}
with_default: ${PROPERTY:default}
```

### Accessing the configuration

You can access the configuration by injection the class `selva.configuration.Settings`:

```python

## 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:

```python

## 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.

```yaml
# settings.yaml
property: value
mapping:
  property: nested value
```

```yaml
# settings_dev.yaml
dev_property: dev value
mapping:
  property: dev nested value
```

```yaml
# final settings
property: value
mapping:
  property: dev nested value
dev_property: dev value
```

## Logging

Selva uses [loguru](https://pypi.org/project/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.

```yaml
# 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:

```yaml
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" :)