tiny-service-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tiny_service_core-0.1.0/PKG-INFO +115 -0
- tiny_service_core-0.1.0/README.md +64 -0
- tiny_service_core-0.1.0/pyproject.toml +136 -0
- tiny_service_core-0.1.0/src/core/__init__.py +53 -0
- tiny_service_core-0.1.0/src/core/app.py +50 -0
- tiny_service_core-0.1.0/src/core/config.py +78 -0
- tiny_service_core-0.1.0/src/core/crypto.py +5 -0
- tiny_service_core-0.1.0/src/core/deps/__init__.py +28 -0
- tiny_service_core-0.1.0/src/core/deps/auth.py +31 -0
- tiny_service_core-0.1.0/src/core/deps/cache.py +11 -0
- tiny_service_core-0.1.0/src/core/deps/db.py +20 -0
- tiny_service_core-0.1.0/src/core/deps/http_client.py +11 -0
- tiny_service_core-0.1.0/src/core/excs.py +11 -0
- tiny_service_core-0.1.0/src/core/lifespan.py +55 -0
- tiny_service_core-0.1.0/src/core/lock.py +19 -0
- tiny_service_core-0.1.0/src/core/main.py +7 -0
- tiny_service_core-0.1.0/src/core/sentry.py +20 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: tiny-service-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 🪐 Shared functionality for tiny microservices
|
|
5
|
+
Keywords: async,fastapi,microservice,core
|
|
6
|
+
Author: Aliaksei Urbanski
|
|
7
|
+
Author-email: Aliaksei Urbanski <aliaksei.urbanski@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Framework :: Pydantic
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Internet
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
20
|
+
Classifier: Topic :: Software Development
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Classifier: Programming Language :: Python
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
29
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
30
|
+
Requires-Dist: fastapi
|
|
31
|
+
Requires-Dist: pydantic-settings
|
|
32
|
+
Requires-Dist: sentry-sdk[fastapi]
|
|
33
|
+
Requires-Dist: redis ; extra == 'cache'
|
|
34
|
+
Requires-Dist: alembic ; extra == 'db'
|
|
35
|
+
Requires-Dist: asyncpg ; extra == 'db'
|
|
36
|
+
Requires-Dist: sentry-sdk[asyncpg,sqlalchemy] ; extra == 'db'
|
|
37
|
+
Requires-Dist: sqlmodel ; extra == 'db'
|
|
38
|
+
Requires-Dist: fastapi[standard] ; extra == 'dev'
|
|
39
|
+
Requires-Dist: httpx ; extra == 'http-client'
|
|
40
|
+
Requires-Dist: sentry-sdk[httpx] ; extra == 'http-client'
|
|
41
|
+
Maintainer: Aliaksei Urbanski
|
|
42
|
+
Maintainer-email: Aliaksei Urbanski <aliaksei.urbanski@gmail.com>
|
|
43
|
+
Requires-Python: >=3.12
|
|
44
|
+
Project-URL: Issues, https://github.com/Jamim/tiny-service-core/issues
|
|
45
|
+
Project-URL: Repository, https://github.com/Jamim/tiny-service-core
|
|
46
|
+
Provides-Extra: cache
|
|
47
|
+
Provides-Extra: db
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Provides-Extra: http-client
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# 🪐 Tiny Service Core
|
|
53
|
+
|
|
54
|
+
[![CI][ci-badge]][ci]
|
|
55
|
+
[![Coverage][cov-badge]][cov]
|
|
56
|
+
[![License][license-badge]][license]
|
|
57
|
+
[![Version][ver-badge]][pypi]
|
|
58
|
+
[![Python][py-badge]][pypi]
|
|
59
|
+
|
|
60
|
+
This library implements common functions that allows
|
|
61
|
+
you to build tiny microservices with less boilerplate.
|
|
62
|
+
|
|
63
|
+
[ci-badge]: https://img.shields.io/github/actions/workflow/status/Jamim/tiny-service-core/ci.yml.svg
|
|
64
|
+
[ci]: https://github.com/Jamim/tiny-service-core/actions/workflows/ci.yml
|
|
65
|
+
[cov-badge]: https://codecov.io/github/Jamim/tiny-service-core/graph/badge.svg
|
|
66
|
+
[cov]: https://app.codecov.io/github/Jamim/tiny-service-core
|
|
67
|
+
[license-badge]: https://img.shields.io/github/license/Jamim/tiny-service-core
|
|
68
|
+
[ver-badge]: https://img.shields.io/pypi/v/tiny-service-core
|
|
69
|
+
[pypi]: https://pypi.org/project/tiny-service-core/
|
|
70
|
+
[py-badge]: https://img.shields.io/pypi/pyversions/tiny-service-core
|
|
71
|
+
[license]: https://github.com/Jamim/tiny-service-core/blob/main/LICENSE
|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
Install with `pip`, including optional dependencies:
|
|
76
|
+
|
|
77
|
+
```shell
|
|
78
|
+
pip install tiny-service-core[cache,db,http-client]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Clearly, you can use any other Python package manager instead.
|
|
82
|
+
I personally prefer using [uv][uv] nowadays.
|
|
83
|
+
|
|
84
|
+
```shell
|
|
85
|
+
uv add tiny-service-core --extra cache --extra db --extra http-client
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
[uv]: https://docs.astral.sh/uv/
|
|
89
|
+
|
|
90
|
+
## Dependencies
|
|
91
|
+
|
|
92
|
+
Tiny Service Core is highly opinionated.
|
|
93
|
+
Current version glues together these components:
|
|
94
|
+
- [FastAPI][fastapi]
|
|
95
|
+
- [pydantic-settings][pydantic-settings]
|
|
96
|
+
- [Sentry][sentry]
|
|
97
|
+
- with `cache` [extra][installing-extras]
|
|
98
|
+
* [Redis][redis]
|
|
99
|
+
- `db`
|
|
100
|
+
* [SQLModel][sqlmodel]
|
|
101
|
+
* [asyncpg][asyncpg]
|
|
102
|
+
* [Alembic][alembic]
|
|
103
|
+
- `http-client`
|
|
104
|
+
* [HTTPX][httpx]
|
|
105
|
+
|
|
106
|
+
[fastapi]: https://fastapi.tiangolo.com
|
|
107
|
+
[pydantic-settings]: https://docs.pydantic.dev/latest/concepts/pydantic_settings/
|
|
108
|
+
[sentry]: https://docs.sentry.io/platforms/python/
|
|
109
|
+
[redis]: https://github.com/redis/redis-py
|
|
110
|
+
[sqlmodel]: https://sqlmodel.tiangolo.com
|
|
111
|
+
[asyncpg]: https://github.com/MagicStack/asyncpg
|
|
112
|
+
[alembic]: https://alembic.sqlalchemy.org
|
|
113
|
+
[httpx]: https://www.python-httpx.org
|
|
114
|
+
|
|
115
|
+
[installing-extras]: https://packaging.python.org/en/latest/tutorials/installing-packages/#installing-extras
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# 🪐 Tiny Service Core
|
|
2
|
+
|
|
3
|
+
[![CI][ci-badge]][ci]
|
|
4
|
+
[![Coverage][cov-badge]][cov]
|
|
5
|
+
[![License][license-badge]][license]
|
|
6
|
+
[![Version][ver-badge]][pypi]
|
|
7
|
+
[![Python][py-badge]][pypi]
|
|
8
|
+
|
|
9
|
+
This library implements common functions that allows
|
|
10
|
+
you to build tiny microservices with less boilerplate.
|
|
11
|
+
|
|
12
|
+
[ci-badge]: https://img.shields.io/github/actions/workflow/status/Jamim/tiny-service-core/ci.yml.svg
|
|
13
|
+
[ci]: https://github.com/Jamim/tiny-service-core/actions/workflows/ci.yml
|
|
14
|
+
[cov-badge]: https://codecov.io/github/Jamim/tiny-service-core/graph/badge.svg
|
|
15
|
+
[cov]: https://app.codecov.io/github/Jamim/tiny-service-core
|
|
16
|
+
[license-badge]: https://img.shields.io/github/license/Jamim/tiny-service-core
|
|
17
|
+
[ver-badge]: https://img.shields.io/pypi/v/tiny-service-core
|
|
18
|
+
[pypi]: https://pypi.org/project/tiny-service-core/
|
|
19
|
+
[py-badge]: https://img.shields.io/pypi/pyversions/tiny-service-core
|
|
20
|
+
[license]: https://github.com/Jamim/tiny-service-core/blob/main/LICENSE
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Install with `pip`, including optional dependencies:
|
|
25
|
+
|
|
26
|
+
```shell
|
|
27
|
+
pip install tiny-service-core[cache,db,http-client]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Clearly, you can use any other Python package manager instead.
|
|
31
|
+
I personally prefer using [uv][uv] nowadays.
|
|
32
|
+
|
|
33
|
+
```shell
|
|
34
|
+
uv add tiny-service-core --extra cache --extra db --extra http-client
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
[uv]: https://docs.astral.sh/uv/
|
|
38
|
+
|
|
39
|
+
## Dependencies
|
|
40
|
+
|
|
41
|
+
Tiny Service Core is highly opinionated.
|
|
42
|
+
Current version glues together these components:
|
|
43
|
+
- [FastAPI][fastapi]
|
|
44
|
+
- [pydantic-settings][pydantic-settings]
|
|
45
|
+
- [Sentry][sentry]
|
|
46
|
+
- with `cache` [extra][installing-extras]
|
|
47
|
+
* [Redis][redis]
|
|
48
|
+
- `db`
|
|
49
|
+
* [SQLModel][sqlmodel]
|
|
50
|
+
* [asyncpg][asyncpg]
|
|
51
|
+
* [Alembic][alembic]
|
|
52
|
+
- `http-client`
|
|
53
|
+
* [HTTPX][httpx]
|
|
54
|
+
|
|
55
|
+
[fastapi]: https://fastapi.tiangolo.com
|
|
56
|
+
[pydantic-settings]: https://docs.pydantic.dev/latest/concepts/pydantic_settings/
|
|
57
|
+
[sentry]: https://docs.sentry.io/platforms/python/
|
|
58
|
+
[redis]: https://github.com/redis/redis-py
|
|
59
|
+
[sqlmodel]: https://sqlmodel.tiangolo.com
|
|
60
|
+
[asyncpg]: https://github.com/MagicStack/asyncpg
|
|
61
|
+
[alembic]: https://alembic.sqlalchemy.org
|
|
62
|
+
[httpx]: https://www.python-httpx.org
|
|
63
|
+
|
|
64
|
+
[installing-extras]: https://packaging.python.org/en/latest/tutorials/installing-packages/#installing-extras
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tiny-service-core"
|
|
3
|
+
description = "🪐 Shared functionality for tiny microservices"
|
|
4
|
+
|
|
5
|
+
# https://github.com/astral-sh/uv/issues/11718
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = { text = "MIT" }
|
|
10
|
+
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Aliaksei Urbanski", email = "aliaksei.urbanski@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
maintainers = [
|
|
15
|
+
{ name = "Aliaksei Urbanski", email = "aliaksei.urbanski@gmail.com" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
classifiers = [
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Environment :: Web Environment",
|
|
22
|
+
"Framework :: AsyncIO",
|
|
23
|
+
"Framework :: FastAPI",
|
|
24
|
+
"Framework :: Pydantic",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Topic :: Internet",
|
|
28
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
29
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
30
|
+
"Topic :: Software Development",
|
|
31
|
+
"Topic :: Software Development :: Libraries",
|
|
32
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
33
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
"Programming Language :: Python",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Programming Language :: Python :: 3.14",
|
|
39
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
40
|
+
]
|
|
41
|
+
keywords = ["async", "fastapi", "microservice", "core"]
|
|
42
|
+
|
|
43
|
+
requires-python = ">=3.12"
|
|
44
|
+
dependencies = [
|
|
45
|
+
"fastapi",
|
|
46
|
+
"pydantic-settings",
|
|
47
|
+
"sentry-sdk[fastapi]",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.optional-dependencies]
|
|
51
|
+
dev = [
|
|
52
|
+
"fastapi[standard]",
|
|
53
|
+
]
|
|
54
|
+
cache = [
|
|
55
|
+
"redis",
|
|
56
|
+
]
|
|
57
|
+
db = [
|
|
58
|
+
"alembic",
|
|
59
|
+
"asyncpg",
|
|
60
|
+
"sentry-sdk[asyncpg,sqlalchemy]",
|
|
61
|
+
"sqlmodel",
|
|
62
|
+
]
|
|
63
|
+
http-client = [
|
|
64
|
+
"httpx",
|
|
65
|
+
"sentry-sdk[httpx]",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[dependency-groups]
|
|
69
|
+
lint = [
|
|
70
|
+
"mypy",
|
|
71
|
+
"ruff",
|
|
72
|
+
]
|
|
73
|
+
test = [
|
|
74
|
+
"pytest",
|
|
75
|
+
"pytest-cov",
|
|
76
|
+
"pytest-env",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[project.urls]
|
|
80
|
+
Repository = "https://github.com/Jamim/tiny-service-core"
|
|
81
|
+
Issues = "https://github.com/Jamim/tiny-service-core/issues"
|
|
82
|
+
|
|
83
|
+
[build-system]
|
|
84
|
+
requires = ["uv_build>=0.9.15,<0.10.0"]
|
|
85
|
+
build-backend = "uv_build"
|
|
86
|
+
|
|
87
|
+
[tool.uv.build-backend]
|
|
88
|
+
module-name = "core"
|
|
89
|
+
|
|
90
|
+
[tool.ruff]
|
|
91
|
+
line-length = 79
|
|
92
|
+
|
|
93
|
+
[tool.ruff.format]
|
|
94
|
+
quote-style = "single"
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint]
|
|
97
|
+
select = [
|
|
98
|
+
# pycodestyle
|
|
99
|
+
"E",
|
|
100
|
+
# Pyflakes
|
|
101
|
+
"F",
|
|
102
|
+
# pyupgrade
|
|
103
|
+
"UP",
|
|
104
|
+
# flake8-bugbear
|
|
105
|
+
"B",
|
|
106
|
+
# flake8-simplify
|
|
107
|
+
"SIM",
|
|
108
|
+
# isort
|
|
109
|
+
"I",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
[tool.ruff.lint.per-file-ignores]
|
|
113
|
+
"src/core/__init__.py" = ["F401", "E402"]
|
|
114
|
+
"src/core/deps/__init__.py" = ["F401"]
|
|
115
|
+
|
|
116
|
+
[tool.ruff.lint.isort]
|
|
117
|
+
known-local-folder = ["core"]
|
|
118
|
+
|
|
119
|
+
[tool.mypy]
|
|
120
|
+
mypy_path = "stubs"
|
|
121
|
+
files = "src/**/*.py"
|
|
122
|
+
plugins = "pydantic.mypy"
|
|
123
|
+
strict = true
|
|
124
|
+
|
|
125
|
+
[tool.pytest.ini_options]
|
|
126
|
+
addopts = "--cov core"
|
|
127
|
+
|
|
128
|
+
[tool.pytest_env]
|
|
129
|
+
APP_PROJ = "tiny-service"
|
|
130
|
+
APP_NAME = "core"
|
|
131
|
+
APP_ENV = "test"
|
|
132
|
+
APP_KEY = "42"
|
|
133
|
+
DB_URL = "postgresql+asyncpg://foo:bar@127.0.0.1/dummy"
|
|
134
|
+
|
|
135
|
+
[tool.coverage.run]
|
|
136
|
+
branch = true
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .config import CoreSettings
|
|
8
|
+
|
|
9
|
+
settings_class: type[CoreSettings]
|
|
10
|
+
try:
|
|
11
|
+
from app.config import Settings
|
|
12
|
+
except ImportError:
|
|
13
|
+
settings_class = CoreSettings
|
|
14
|
+
else:
|
|
15
|
+
if issubclass(Settings, CoreSettings):
|
|
16
|
+
settings_class = cast(type[CoreSettings], Settings)
|
|
17
|
+
else:
|
|
18
|
+
settings_class = type('CombinedSettings', (Settings, CoreSettings), {})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
settings = settings_class()
|
|
22
|
+
logger = logging.getLogger(settings.app_title)
|
|
23
|
+
|
|
24
|
+
from .app import make_app
|
|
25
|
+
from .crypto import get_key_hash
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(app: FastAPI, **kwargs: Any) -> None:
|
|
29
|
+
import uvicorn
|
|
30
|
+
|
|
31
|
+
kwargs.setdefault('host', settings.app_host)
|
|
32
|
+
kwargs.setdefault('port', settings.app_port)
|
|
33
|
+
|
|
34
|
+
uvicorn.run(app, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
'get_key_hash',
|
|
39
|
+
'logger',
|
|
40
|
+
'make_app',
|
|
41
|
+
'run',
|
|
42
|
+
'settings',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from .lock import Lock
|
|
47
|
+
except ImportError:
|
|
48
|
+
pass
|
|
49
|
+
else:
|
|
50
|
+
__all__.append('Lock')
|
|
51
|
+
|
|
52
|
+
__name__ = 'tiny-service-core'
|
|
53
|
+
__version__ = version(__name__)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Any, Literal
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, FastAPI, params
|
|
4
|
+
|
|
5
|
+
from . import settings
|
|
6
|
+
from .deps import VerifyClient
|
|
7
|
+
from .lifespan import lifespan
|
|
8
|
+
from .sentry import init_sentry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_app(**kwargs: Any) -> FastAPI:
|
|
12
|
+
init_sentry()
|
|
13
|
+
|
|
14
|
+
slug = settings.app_name
|
|
15
|
+
|
|
16
|
+
app = FastAPI(
|
|
17
|
+
lifespan=lifespan,
|
|
18
|
+
docs_url=f'/{slug}/docs',
|
|
19
|
+
redoc_url=f'/{slug}/redoc',
|
|
20
|
+
openapi_url=(
|
|
21
|
+
None
|
|
22
|
+
if settings.app_env == 'production'
|
|
23
|
+
else f'/{slug}/openapi.json'
|
|
24
|
+
),
|
|
25
|
+
title=settings.app_title,
|
|
26
|
+
**kwargs,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
add_router(app, 'public', f'/api/v1/{slug}')
|
|
30
|
+
add_router(app, 'internal', f'/internal/{slug}', [VerifyClient])
|
|
31
|
+
|
|
32
|
+
return app
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def add_router(
|
|
36
|
+
app: FastAPI,
|
|
37
|
+
router_type: Literal['public', 'internal'],
|
|
38
|
+
prefix: str,
|
|
39
|
+
dependencies: list[params.Depends] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
from app import api
|
|
42
|
+
|
|
43
|
+
func_name = f'add_{router_type}_api'
|
|
44
|
+
if not hasattr(api, func_name):
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
router = APIRouter(prefix=prefix, dependencies=dependencies)
|
|
48
|
+
getattr(api, func_name)(router)
|
|
49
|
+
|
|
50
|
+
app.include_router(router)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Annotated, Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, field_validator
|
|
4
|
+
from pydantic_settings import BaseSettings, NoDecode
|
|
5
|
+
|
|
6
|
+
SLUG_PATTERN = r'^[a-z0-9-]+$'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AppSettings(BaseSettings):
|
|
10
|
+
@property
|
|
11
|
+
def app_title(self) -> str:
|
|
12
|
+
return f'{self.app_proj}-{self.app_name}'
|
|
13
|
+
|
|
14
|
+
app_proj: str = Field(pattern=SLUG_PATTERN)
|
|
15
|
+
app_name: str = Field(pattern=SLUG_PATTERN)
|
|
16
|
+
|
|
17
|
+
app_env: Literal['production', 'staging', 'local', 'test']
|
|
18
|
+
app_key: str
|
|
19
|
+
|
|
20
|
+
app_host: str = '127.0.0.1'
|
|
21
|
+
app_port: int = 8080
|
|
22
|
+
|
|
23
|
+
app_client_key_hashes: Annotated[list[str], NoDecode] = []
|
|
24
|
+
|
|
25
|
+
@field_validator('app_client_key_hashes', mode='before')
|
|
26
|
+
@classmethod
|
|
27
|
+
def split_hashes(cls, hashes: str | list[str]) -> list[str]:
|
|
28
|
+
if isinstance(hashes, str):
|
|
29
|
+
hashes = hashes.split(',')
|
|
30
|
+
return hashes
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SentrySettings(BaseSettings):
|
|
34
|
+
sentry_dsn: str | None = None
|
|
35
|
+
traces_sample_rate: float = 1.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CacheSettings(BaseSettings):
|
|
39
|
+
cache_url: str = 'redis://localhost:6379'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DBSettings(BaseSettings):
|
|
43
|
+
db_url: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HTTPClientSettings(BaseSettings):
|
|
47
|
+
http_retries: int = 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
OPTIONAL_SETTINGS: dict[str, type[BaseSettings]] = {
|
|
51
|
+
'redis': CacheSettings,
|
|
52
|
+
'sqlmodel': DBSettings,
|
|
53
|
+
'httpx': HTTPClientSettings,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
settings_bases = [AppSettings, SentrySettings]
|
|
57
|
+
|
|
58
|
+
for module, base in OPTIONAL_SETTINGS.items():
|
|
59
|
+
try:
|
|
60
|
+
__import__(module)
|
|
61
|
+
except ImportError:
|
|
62
|
+
pass
|
|
63
|
+
else:
|
|
64
|
+
settings_bases.append(base)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
|
|
69
|
+
class CoreSettings(
|
|
70
|
+
AppSettings,
|
|
71
|
+
SentrySettings,
|
|
72
|
+
CacheSettings,
|
|
73
|
+
DBSettings,
|
|
74
|
+
HTTPClientSettings,
|
|
75
|
+
): ...
|
|
76
|
+
else:
|
|
77
|
+
|
|
78
|
+
class CoreSettings(*settings_bases): ...
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .auth import AppClientKey as Token
|
|
2
|
+
from .auth import VerifyClient
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
'Token',
|
|
6
|
+
'VerifyClient',
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from .cache import Cache
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
else:
|
|
14
|
+
__all__.append('Cache')
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from .db import Session
|
|
18
|
+
except ImportError:
|
|
19
|
+
pass
|
|
20
|
+
else:
|
|
21
|
+
__all__.append('Session')
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from .http_client import HTTPClient
|
|
25
|
+
except ImportError:
|
|
26
|
+
pass
|
|
27
|
+
else:
|
|
28
|
+
__all__.append('HTTPClient')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, HTTPException, status
|
|
5
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
6
|
+
|
|
7
|
+
import core
|
|
8
|
+
from ..crypto import get_key_hash
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_app_client_key(
|
|
12
|
+
credentials: Annotated[
|
|
13
|
+
HTTPAuthorizationCredentials, Depends(HTTPBearer())
|
|
14
|
+
],
|
|
15
|
+
) -> str:
|
|
16
|
+
return credentials.credentials
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
AppClientKey = Annotated[str, Depends(get_app_client_key)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_app_client_key(app_key: AppClientKey) -> None:
|
|
23
|
+
key_hash = get_key_hash(app_key)
|
|
24
|
+
for allowed_key_hash in core.settings.app_client_key_hashes:
|
|
25
|
+
if hmac.compare_digest(key_hash, allowed_key_hash):
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, 'App key is not valid')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
VerifyClient = Depends(verify_app_client_key)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Annotated, cast
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, Request
|
|
4
|
+
from redis.asyncio.client import Redis
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_cache(request: Request) -> Redis:
|
|
8
|
+
return cast(Redis, request.app.state.cache)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
Cache = Annotated[Redis, Depends(get_cache)]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from typing import Annotated, cast
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, Request
|
|
5
|
+
from sqlalchemy.orm import sessionmaker
|
|
6
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def get_session(request: Request) -> AsyncGenerator[AsyncSession]:
|
|
10
|
+
async_session = sessionmaker(
|
|
11
|
+
request.app.state.db,
|
|
12
|
+
class_=AsyncSession,
|
|
13
|
+
expire_on_commit=False,
|
|
14
|
+
)
|
|
15
|
+
session = cast(AsyncSession, async_session())
|
|
16
|
+
async with session:
|
|
17
|
+
yield session
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
Session = Annotated[AsyncSession, Depends(get_session)]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Annotated, cast
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, Request
|
|
4
|
+
from httpx import AsyncClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_http_client(request: Request) -> AsyncClient:
|
|
8
|
+
return cast(AsyncClient, request.app.state.http_client)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
HTTPClient = Annotated[AsyncClient, Depends(get_http_client)]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from fastapi import HTTPException, status
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LimitExceeded(HTTPException):
|
|
5
|
+
def __init__(self) -> None:
|
|
6
|
+
super().__init__(status.HTTP_403_FORBIDDEN, 'Limit exceeded')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NotFound(HTTPException):
|
|
10
|
+
def __init__(self, name: str) -> None:
|
|
11
|
+
super().__init__(status.HTTP_404_NOT_FOUND, f'{name} not found')
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from starlette.datastructures import State
|
|
8
|
+
|
|
9
|
+
from . import settings
|
|
10
|
+
|
|
11
|
+
type CleanUpFunc = Callable[[], Awaitable[None]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def init_cache(state: State, cache_url: str) -> CleanUpFunc:
|
|
15
|
+
from redis.asyncio.client import Redis
|
|
16
|
+
|
|
17
|
+
state.cache = Redis.from_url(cache_url)
|
|
18
|
+
return cast(CleanUpFunc, state.cache.aclose)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def init_db(state: State, db_url: str) -> CleanUpFunc:
|
|
22
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
23
|
+
|
|
24
|
+
state.db = create_async_engine(db_url)
|
|
25
|
+
return cast(CleanUpFunc, state.db.dispose)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_http_client(state: State, retries: int) -> CleanUpFunc:
|
|
29
|
+
from httpx import AsyncClient, AsyncHTTPTransport
|
|
30
|
+
|
|
31
|
+
state.http_client = AsyncClient(
|
|
32
|
+
transport=AsyncHTTPTransport(retries=retries)
|
|
33
|
+
)
|
|
34
|
+
return cast(CleanUpFunc, state.http_client.aclose)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
INIT_FUNCS: dict[str, Callable[[State, Any], CleanUpFunc]] = {
|
|
38
|
+
'cache_url': init_cache,
|
|
39
|
+
'db_url': init_db,
|
|
40
|
+
'http_retries': init_http_client,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@asynccontextmanager
|
|
45
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
46
|
+
clean_up_funcs: list[CleanUpFunc] = []
|
|
47
|
+
|
|
48
|
+
for attr, init_func in INIT_FUNCS.items():
|
|
49
|
+
value = getattr(settings, attr, None)
|
|
50
|
+
if value:
|
|
51
|
+
clean_up_funcs.append(init_func(app.state, value))
|
|
52
|
+
|
|
53
|
+
yield
|
|
54
|
+
|
|
55
|
+
await asyncio.gather(*(func() for func in clean_up_funcs))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from redis.asyncio.client import Redis
|
|
2
|
+
from redis.asyncio.lock import Lock as RedisLock
|
|
3
|
+
|
|
4
|
+
from . import settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Lock:
|
|
8
|
+
prefix: str = f'{settings.app_name}:lock' # type: ignore[has-type]
|
|
9
|
+
template: str = ''
|
|
10
|
+
timeout: float | None = None
|
|
11
|
+
|
|
12
|
+
# https://github.com/python/mypy/issues/15182
|
|
13
|
+
def __new__( # type: ignore[misc]
|
|
14
|
+
cls,
|
|
15
|
+
cache: Redis,
|
|
16
|
+
*args: str,
|
|
17
|
+
) -> RedisLock:
|
|
18
|
+
name = f'{cls.prefix}:{cls.template.format(*args)}'
|
|
19
|
+
return cache.lock(name, timeout=cls.timeout)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from . import settings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def init_sentry() -> None:
|
|
5
|
+
if not settings.sentry_dsn:
|
|
6
|
+
return
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import sentry_sdk
|
|
11
|
+
from sentry_sdk.integrations.logging import LoggingIntegration
|
|
12
|
+
|
|
13
|
+
logging.captureWarnings(True)
|
|
14
|
+
|
|
15
|
+
sentry_sdk.init(
|
|
16
|
+
dsn=settings.sentry_dsn,
|
|
17
|
+
environment=settings.app_env,
|
|
18
|
+
traces_sample_rate=settings.traces_sample_rate,
|
|
19
|
+
integrations=[LoggingIntegration(event_level=logging.WARNING)],
|
|
20
|
+
)
|