Skip to content

Commit

Permalink
Feature/jinja (#36)
Browse files Browse the repository at this point in the history
add jinja2
  • Loading branch information
livioribeiro authored Nov 30, 2023
1 parent 95b162d commit c257d8d
Show file tree
Hide file tree
Showing 21 changed files with 487 additions and 72 deletions.
95 changes: 95 additions & 0 deletions docs/templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
Selva offers support for Jinja templates.

To use templates, first install the `jinja` extra:

```shell
pip install selva[jinja]
```

Template files are located in the `resources/templates directory`:

```
project/
├── application/
│ └── ...
└── resources/
└── templates/
├── index.html
└── ...
```

To render templates, inject the `selva.web.templates.Template` dependency and call its `respond` method:

=== "application.py"

```python
from typing import Annotated
from selva.di import Inject
from selva.web import controller, get
from selva.web.templates import Template


@controller
class Controller:
template: Annotated[Template, Inject]

@get
async def index(self, request):
context = {"title": "Index"}
await self.template.respond(request.response, "index.html", context)
```

=== "resources/templates/index.html"

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Jinja Templates</title>
</head>
<body>
<h1>{{ title }}</h1>
</body>
</html>
```

## Configuration

Jinja can be configured through the `settings.yaml`. For exmaple, to activate extensions:

```yaml
templates:
jinja:
extensions:
- jinja2.ext.i18n
- jinja2.ext.debug
```
Full settings list:
```yaml
templates:
jinja:
block_start_string:
block_end_string:
variable_start_string:
variable_end_string:
comment_start_string:
comment_end_string:
line_statement_prefix:
line_comment_prefix:
trim_blocks:
lstrip_blocks:
newline_sequence:
keep_trailing_newline:
extensions:
optimized:
undefined:
finalize:
autoescape:
loader:
cache_size:
auto_reload:
bytecode_cache:
```
20 changes: 20 additions & 0 deletions examples/templates/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Annotated

from asgikit.requests import Request

from selva.di import Inject
from selva.web import controller, get
from selva.web.templates import Template


@controller
class Controller:
template: Annotated[Template, Inject]

@get
async def index(
self,
request: Request,
):
context = dict(title="Selva", heading="Heading")
await self.template.respond(request.response, "index.html", context)
5 changes: 5 additions & 0 deletions examples/templates/configuration/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
templates:
jinja:
extensions:
- jinja2.ext.i18n
- jinja2.ext.debug
13 changes: 13 additions & 0 deletions examples/templates/resources/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
</head>
<body>
<h1>{{ heading }}</h1>
<pre>
{% debug %}
</pre>
</body>
</html>
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ nav:
- tutorial.md
- controllers.md
- routing.md
- templates.md
- configuration.md
- middleware.md
- logging.md
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ python = "^3.11"
asgikit = "^0.5"
pydantic = "^2.4"
loguru = "^0.7"
strictyaml = "^1.7"
python-dotenv = "^1.0.0"
jinja2 = { version = "^3.1", optional = true }
strictyaml = "^1.7"

[tool.poetry.extras]
jinja = ["jinja2"]

[tool.poetry.group.dev]
optional = true
Expand Down
8 changes: 6 additions & 2 deletions src/selva/_util/import_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ def import_item(name: str):
match name.rsplit(".", 1):
case [module_name, item_name]:
module = import_module(module_name)
return getattr(module, item_name)
if item := getattr(module, item_name, None):
return item
raise ImportError(
f"module '{module.__name__}' does not have item '{item_name}'"
)
case _:
raise ValueError("name must be in 'module.item' format")
raise ImportError("name must be in 'module.item' format")
5 changes: 5 additions & 0 deletions src/selva/configuration/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@
"enable": [],
"disable": [],
},
"templates": {
"jinja": {
"path": "resources/templates",
},
},
}
29 changes: 16 additions & 13 deletions src/selva/configuration/environment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
import re
from collections.abc import Mapping, Sequence
from typing import Any

RE_VARIABLE = re.compile(
r"""
Expand All @@ -22,7 +23,7 @@
SELVA_PREFIX = "SELVA__"


def parse_settings_from_env(source: dict[str, str]) -> dict:
def parse_settings_from_env(source: Mapping[str, str]) -> dict:
result = {}

for name, value in source.items():
Expand Down Expand Up @@ -50,7 +51,7 @@ def parse_settings_from_env(source: dict[str, str]) -> dict:
return result


def replace_variables_with_env(settings: str, environ: dict[str, str]):
def replace_variables_with_env(settings: str, environ: Mapping[str, str]):
for match in RE_VARIABLE.finditer(settings):
name = match.group("name")

Expand All @@ -69,14 +70,16 @@ def replace_variables_with_env(settings: str, environ: dict[str, str]):
return settings


def replace_variables_recursive(settings: dict | list | str, environ: dict):
if isinstance(settings, dict):
for key, value in settings.items():
settings[key] = replace_variables_recursive(value, environ)
return settings
elif isinstance(settings, list):
return [replace_variables_recursive(value, environ) for value in settings]
elif isinstance(settings, str):
return replace_variables_with_env(settings, environ)
def replace_variables_recursive(
data: Mapping | Sequence | str | Any, environ: Mapping[str, str]
):
if isinstance(data, dict):
for key, value in data.items():
data[key] = replace_variables_recursive(value, environ)
return data
elif isinstance(data, list):
return [replace_variables_recursive(value, environ) for value in data]
elif isinstance(data, str):
return replace_variables_with_env(data, environ)
else:
raise TypeError("settings should contain only str, list or dict")
return data
37 changes: 29 additions & 8 deletions src/selva/configuration/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import copy
import os
from collections import UserDict
from copy import deepcopy
from functools import cache
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -38,23 +40,35 @@ def __getattr__(self, item: str):
except KeyError:
raise AttributeError(item)

def __copy__(self):
return Settings(copy.copy(self.data))

def __deepcopy__(self, memodict):
data = copy.deepcopy(self.data, memodict)
return Settings(data)


class SettingsError(Exception):
def __init__(self, path: Path):
super().__init__(f"cannot load settings from {path}")
self.path = path


@cache
def get_settings() -> Settings:
return _get_settings_nocache()


def _get_settings_nocache() -> Settings:
# get default settings
settings = deepcopy(default_settings)

# merge with main settings file (settings.yaml)
merge_recursive(settings, get_settings_for_profile())

# merge with environment settings file (settings_$SELVA_ENV.yaml)
if active_env := os.getenv(SELVA_PROFILE):
merge_recursive(settings, get_settings_for_profile(active_env))
# merge with environment settings file (settings_$SELVA_PROFILE.yaml)
if active_profile := os.getenv(SELVA_PROFILE):
merge_recursive(settings, get_settings_for_profile(active_profile))

# merge with environment variables (SELVA_*)
from_env_vars = parse_settings_from_env(os.environ)
Expand All @@ -64,23 +78,30 @@ def get_settings() -> Settings:
return Settings(settings)


def get_settings_for_profile(env: str = None) -> dict[str, Any]:
def get_settings_for_profile(profile: str = None) -> dict[str, Any]:
settings_file = os.getenv(SETTINGS_FILE_ENV, DEFAULT_SETTINGS_FILE)
settings_dir_path = Path(os.getenv(SETTINGS_DIR_ENV, DEFAULT_SETTINGS_DIR))
settings_file_path = settings_dir_path / settings_file

if env is not None:
if profile:
settings_file_path = settings_file_path.with_stem(
f"{settings_file_path.stem}_{env}"
f"{settings_file_path.stem}_{profile}"
)

settings_file_path = settings_file_path.absolute()

try:
settings_yaml = settings_file_path.read_text("utf-8")
return strictyaml.load(settings_yaml).data
logger.debug("settings loaded from {}", settings_file_path)
return strictyaml.dirty_load(settings_yaml, allow_flow_style=True).data or {}
except FileNotFoundError:
logger.info("settings file not found: {}", settings_file_path)
if profile:
logger.warning(
"no settings file found for profile '{}' at {}",
profile,
settings_file_path,
)

return {}
except Exception as err:
raise SettingsError(settings_file_path) from err
Expand Down
4 changes: 3 additions & 1 deletion src/selva/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from dotenv import load_dotenv

from selva.configuration.settings import get_settings
from selva.web.application import Selva

dotenv_path = os.getenv("SELVA_DOTENV", os.path.join(os.getcwd(), ".env"))
load_dotenv(dotenv_path)

app = Selva()
settings = get_settings()
app = Selva(settings)
15 changes: 12 additions & 3 deletions src/selva/web/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from selva._util.base_types import get_base_types
from selva._util.import_item import import_item
from selva._util.maybe_async import maybe_async
from selva.configuration.settings import Settings, get_settings
from selva.configuration.settings import Settings, _get_settings_nocache
from selva.di.container import Container
from selva.di.decorator import DI_SERVICE_ATTRIBUTE
from selva.web.converter import (
Expand Down Expand Up @@ -58,12 +58,12 @@ class Selva:
Other modules and classes can be registered using the "register" method
"""

def __init__(self):
def __init__(self, settings: Settings):
self.di = Container()
self.router = Router()
self.handler = self._process_request

self.settings = get_settings()
self.settings = settings
self.di.define(Settings, self.settings)

self.di.define(Router, self.router)
Expand All @@ -74,6 +74,15 @@ def __init__(self):
param_converter_impl,
)

try:
import jinja2

from selva.web import templates

self.di.scan(templates)
except ImportError:
pass

components = self.settings.components
self.di.scan(components)
self._register_components(components)
Expand Down
1 change: 1 addition & 0 deletions src/selva/web/templates/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from selva.web.templates.template import Template
Loading

0 comments on commit c257d8d

Please sign in to comment.