├── .gitignore
├── CHANGES.md
├── Dockerfile
├── LICENSE
├── MANIFEST.in
├── Makefile
├── README.md
├── _docker
└── sentinel
│ ├── Dockerfile
│ ├── entrypoint.sh
│ └── sentinel.conf
├── demo.py
├── docker-compose.memcached.yml
├── docker-compose.redis.yml
├── docker-compose.sentinel.yml
├── docker-compose.yml
├── docs
├── cache.md
├── control.md
├── logger.md
├── scheduler.md
└── settings.md
├── fastapi_plugins
├── __init__.py
├── _redis.py
├── control.py
├── logger.py
├── memcached.py
├── middleware.py
├── plugin.py
├── scheduler.py
├── settings.py
├── utils.py
└── version.py
├── requirements.txt
├── scripts
└── demo_app.py
├── setup.py
├── tests
├── __init__.py
├── conftest.py
├── test_control.py
├── test_logger.py
├── test_memcached.py
├── test_redis.py
├── test_scheduler.py
└── test_settings.py
└── tox.ini
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | MANIFEST
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 | .pytest_cache/
49 |
50 | # Translations
51 | *.mo
52 | *.pot
53 |
54 | # Django stuff:
55 | *.log
56 | local_settings.py
57 | db.sqlite3
58 |
59 | # Flask stuff:
60 | instance/
61 | .webassets-cache
62 |
63 | # Scrapy stuff:
64 | .scrapy
65 |
66 | # Sphinx documentation
67 | docs/_build/
68 |
69 | # PyBuilder
70 | target/
71 |
72 | # Jupyter Notebook
73 | .ipynb_checkpoints
74 |
75 | # pyenv
76 | .python-version
77 |
78 | # celery beat schedule file
79 | celerybeat-schedule
80 |
81 | # SageMath parsed files
82 | *.sage.py
83 |
84 | # Environments
85 | .env
86 | .venv
87 | env/
88 | venv/
89 | ENV/
90 | env.bak/
91 | venv.bak/
92 |
93 | # Spyder project settings
94 | .spyderproject
95 | .spyproject
96 |
97 | # Rope project settings
98 | .ropeproject
99 |
100 | # mkdocs documentation
101 | /site
102 |
103 | # mypy
104 | .mypy_cache/
105 |
106 | .project
107 | .pydevproject
108 | .settings/
109 |
110 |
--------------------------------------------------------------------------------
/CHANGES.md:
--------------------------------------------------------------------------------
1 | # Changes
2 | ## 0.13.2 (2025-02-12)
3 | - `[fix]` `examples` field in `Control` schema
4 | ## 0.13.1 (2024-06-03)
5 | - `[fix]` limit redis version to `>=4.3.0,<5` due to issues with async sentinel
6 | ## 0.13.0 (2024-02-16)
7 | - `[feature]` updates for Pydantic 2
8 | ## 0.12.0 (2023-03-24)
9 | - `[feature]` `Annotated` support
10 | ## 0.11.0 (2022-09-19)
11 | - `[feature]` `redis-py` replaces `aioredis`
12 | ## 0.10.0 (2022-07-07)
13 | - `[feature]` Update `aioredis` to `2.x.x`
14 | - `[feature]` Add `fakeredis` optionally for development purpose
15 | ## 0.9.1 (2022-06-16)
16 | - `[fix]` Fix empty router prefix for control plugin
17 | ## 0.9.0 (2021-09-27)
18 | - `[feature]` Logging plugin
19 | - `[feature]` Middleware interface - register middleware at application
20 | ## 0.8.2 (2021-09-23)
21 | - `[fix]` Fix dependency for aioredis
22 | ## 0.8.1 (2021-03-31)
23 | - `[fix]` Fix settings for Python 3.7
24 | ## 0.8.0 (2021-03-31)
25 | - `[feature]` Settings plugin
26 | ## 0.7.0 (2021-03-29)
27 | - `[feature]` Control plugin with Health, Heartbeat, Environment and Version
28 | ## 0.6.1 (2021-03-24)
29 | - `[fix]` Bump `aiojobs`to get rid of not required dependencies
30 | ## 0.6.0 (2020-11-26)
31 | - `[feature]` Memcached
32 | ## 0.5.0 (2020-11-25)
33 | - [bug] remove `__all__` since no API as such ([#6][i6]).
34 | - [typo] Fix typos in README ([#7][i7]).
35 | - [feature] Add Redis TTL ([#8][i8]).
36 | ## 0.4.2 (2020-11-24)
37 | - [bug] Fix Redis URL ([#4][i4]).
38 | ## 0.4.1 (2020-06-16)
39 | - Refactor requirements
40 | ## 0.4.0 (2020-04-09)
41 | - structure and split dependencies to `extra`
42 | ## 0.3.0 (2020-04-07)
43 | - Scheduler: tasks scheduler based on `aiojobs`
44 | ## 0.2.1 (2020-04-06)
45 | - Redis: pre-start
46 | ## 0.2.0 (2019-12-11)
47 | - Redis: sentinels
48 | ## 0.1.0 (2019-11-20)
49 | - Initial release: simple redis pool client
50 |
51 | [i4]: https://github.com/madkote/fastapi-plugins/pull/4
52 | [i6]: https://github.com/madkote/fastapi-plugins/pull/6
53 | [i7]: https://github.com/madkote/fastapi-plugins/pull/7
54 | [i8]: https://github.com/madkote/fastapi-plugins/issues/8
55 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.8-alpine as demo
2 | LABEL maintainer="madkote(at)bluewin.ch"
3 | RUN apk --update add --no-cache --virtual MY_DEV_PACK alpine-sdk build-base python3-dev
4 | RUN pip3 install fastapi-plugins[all] uvicorn
5 | RUN mkdir -p /usr/src/app
6 | COPY ./scripts/demo_app.py /usr/src/app
7 | WORKDIR /usr/src/app
8 | EXPOSE 8000
9 | CMD ["uvicorn", "--host", "0.0.0.0", "demo_app:app"]
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 RES
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include CHANGES.md
3 | include README.md
4 | global-exclude *.pyc
5 | global-exclude *.pyd
6 | global-exclude *.pyo
7 | global-exclude *.so
8 | global-exclude *.lib
9 | global-exclude *.dll
10 | global-exclude *.a
11 | global-exclude *.obj
12 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | #!make
2 | .PHONY: clean-pyc clean-build
3 | .DEFAULT_GOAL := help
4 |
5 | SHELL = /bin/bash
6 | PYPACKAGE = fastapi_plugins
7 |
8 | help:
9 | @echo ""
10 | @echo " +---------------------------------------+"
11 | @echo " | ${PYPACKAGE} |"
12 | @echo " +---------------------------------------+"
13 | @echo " clean"
14 | @echo " Remove python and build artifacts"
15 | @echo " install"
16 | @echo " Install requirements for development and testing"
17 | @echo " demo"
18 | @echo " Run a simple demo"
19 | @echo ""
20 | @echo " test"
21 | @echo " Run unit tests"
22 | @echo " test-all"
23 | @echo " Run integration tests"
24 | @echo ""
25 |
26 | clean-pyc:
27 | @echo $@
28 | find ./${PYPACKAGE} -name '*.pyc' -exec rm --force {} +
29 | find ./${PYPACKAGE} -name '*.pyo' -exec rm --force {} +
30 | find ./${PYPACKAGE} -name '*~' -exec rm --force {} +
31 |
32 | clean-build:
33 | @echo $@
34 | rm --force --recursive .tox/
35 | rm --force --recursive build/
36 | rm --force --recursive dist/
37 | rm --force --recursive *.egg-info
38 | rm --force --recursive .pytest_cache/
39 |
40 | clean-docker:
41 | @echo $@
42 | docker container prune -f
43 |
44 | clean-pycache:
45 | @echo $@
46 | find . -name '__pycache__' -exec rm -rf {} +
47 |
48 | clean: clean-build clean-docker clean-pyc clean-pycache
49 |
50 | install: clean
51 | @echo $@
52 | pip install --no-cache-dir -U pip setuptools twine wheel
53 | pip install --no-cache-dir -U --force-reinstall -r requirements.txt
54 | rm -rf build *.egg-info
55 | pip uninstall ${PYPACKAGE} -y || true
56 |
57 | demo: clean
58 | @echo $@
59 | python demo.py
60 |
61 | demo-app: clean
62 | @echo $@
63 | uvicorn scripts.demo_app:app
64 |
65 | flake: clean
66 | @echo $@
67 | flake8 --statistics --ignore E252 ${PYPACKAGE} tests scripts setup.py
68 |
69 | bandit: clean
70 | @echo $@
71 | bandit -r ${PYPACKAGE}/ scripts/ demo.py setup.py
72 | bandit -s B101 -r tests/
73 |
74 | test-unit-pytest:
75 | @echo $@
76 | python -m pytest -v -x tests/ --cov=${PYPACKAGE}
77 |
78 | test-unit: clean flake bandit docker-up-test test-unit-pytest docker-down-test
79 | @echo $@
80 |
81 | test-toxtox:
82 | @echo $@
83 | tox -vv
84 |
85 | test-tox: clean docker-up-test test-toxtox docker-down-test
86 | @echo $@
87 |
88 | test: test-unit
89 | @echo $@
90 |
91 | test-all: clean flake bandit docker-up-test test-unit-pytest test-toxtox docker-down-test
92 | @echo $@
93 |
94 | pypi-build: clean test-all
95 | @echo $@
96 | python setup.py sdist bdist_wheel
97 | twine check dist/*
98 |
99 | pypi-upload-test:
100 | @echo $@
101 | python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/*
102 |
103 | pypi-upload:
104 | @echo $@
105 | python -m twine upload dist/*
106 |
107 | docker-build-dev:
108 | @echo $@
109 | docker compose -f docker-compose.memcached.yml -f docker-compose.sentinel.yml build
110 |
111 | docker-up: clean-docker
112 | @echo $@
113 | docker compose build --force-rm --no-cache --pull && docker compose -f docker-compose.yml -f docker-compose.redis.yml -f docker-compose.memcached.yml up --build
114 |
115 | docker-up-dev: clean-docker docker-build-dev
116 | @echo $@
117 | docker compose -f docker-compose.memcached.yml -f docker-compose.sentinel.yml up
118 |
119 | docker-up-test: clean-docker docker-build-dev
120 | @echo $@
121 | docker compose -f docker-compose.memcached.yml -f docker-compose.sentinel.yml up -d
122 | sleep 5
123 | docker ps
124 |
125 | docker-down-test:
126 | @echo $@
127 | docker compose -f docker-compose.memcached.yml -f docker-compose.sentinel.yml down
128 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | Plugins for FastAPI framework, high performance, easy to learn, fast to code, ready for production
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | # fastapi-plugins
20 | FastAPI framework plugins - simple way to share `fastapi` code and utilities across applications.
21 |
22 | The concept is `plugin` - plug a functional utility into your application without or with minimal effort.
23 |
24 | * [Cache](./docs/cache.md)
25 | * [Memcached](./docs/cache.md#memcached)
26 | * [Redis](./docs/cache.md#redis)
27 | * [Scheduler](./docs/scheduler.md)
28 | * [Control](./docs/control.md)
29 | * [Version](./docs/control.md#version)
30 | * [Environment](./docs/control.md#environment)
31 | * [Health](./docs/control.md#health)
32 | * [Heartbeat](./docs/control.md#heartbeat)
33 | * [Application settings/configuration](./docs/settings.md)
34 | * [Logging](./docs/logger.md)
35 | * Celery
36 | * MQ
37 | * and much more is already in progress...
38 |
39 | ## Changes
40 | See [release notes](CHANGES.md)
41 |
42 | ## Installation
43 | * by default contains
44 | * [Redis](./docs/cache.md#redis)
45 | * [Scheduler](./docs/scheduler.md)
46 | * [Control](./docs/control.md)
47 | * [Logging](./docs/logger.md)
48 | * `memcached` adds [Memcached](#memcached)
49 | * `all` add everything above
50 |
51 | ```sh
52 | pip install fastapi-plugins
53 | pip install fastapi-plugins[memcached]
54 | pip install fastapi-plugins[all]
55 | ```
56 |
57 | ## Quick start
58 | ### Plugin
59 | Add information about plugin system.
60 | ### Application settings
61 | Add information about settings.
62 | ### Application configuration
63 | Add information about configuration of an application
64 | ### Complete example
65 | ```python
66 | import fastapi
67 | import fastapi_plugins
68 |
69 | from fastapi_plugins.memcached import MemcachedSettings
70 | from fastapi_plugins.memcached import memcached_plugin, TMemcachedPlugin
71 |
72 | import asyncio
73 | import aiojobs
74 | import aioredis
75 | import contextlib
76 | import logging
77 |
78 | @fastapi_plugins.registered_configuration
79 | class AppSettings(
80 | fastapi_plugins.ControlSettings,
81 | fastapi_plugins.RedisSettings,
82 | fastapi_plugins.SchedulerSettings,
83 | fastapi_plugins.LoggingSettings,
84 | MemcachedSettings,
85 | ):
86 | api_name: str = str(__name__)
87 | logging_level: int = logging.DEBUG
88 | logging_style: fastapi_plugins.LoggingStyle = fastapi_plugins.LoggingStyle.logjson
89 |
90 |
91 | @fastapi_plugins.registered_configuration(name='sentinel')
92 | class AppSettingsSentinel(AppSettings):
93 | redis_type = fastapi_plugins.RedisType.sentinel
94 | redis_sentinels = 'localhost:26379'
95 |
96 |
97 | @contextlib.asynccontextmanager
98 | async def lifespan(app: fastapi.FastAPI):
99 | config = fastapi_plugins.get_config()
100 | await fastapi_plugins.config_plugin.init_app(app, config)
101 | await fastapi_plugins.config_plugin.init()
102 | await fastapi_plugins.log_plugin.init_app(app, config, name=__name__)
103 | await fastapi_plugins.log_plugin.init()
104 | await memcached_plugin.init_app(app, config)
105 | await memcached_plugin.init()
106 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
107 | await fastapi_plugins.redis_plugin.init()
108 | await fastapi_plugins.scheduler_plugin.init_app(app=app, config=config)
109 | await fastapi_plugins.scheduler_plugin.init()
110 | await fastapi_plugins.control_plugin.init_app(
111 | app,
112 | config=config,
113 | version=__version__,
114 | environ=config.model_dump()
115 | )
116 | await fastapi_plugins.control_plugin.init()
117 | yield
118 | await fastapi_plugins.control_plugin.terminate()
119 | await fastapi_plugins.scheduler_plugin.terminate()
120 | await fastapi_plugins.redis_plugin.terminate()
121 | await memcached_plugin.terminate()
122 | await fastapi_plugins.log_plugin.terminate()
123 | await fastapi_plugins.config_plugin.terminate()
124 |
125 |
126 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
127 |
128 |
129 | @app.get("/")
130 | async def root_get(
131 | cache: fastapi_plugins.TRedisPlugin,
132 | conf: fastapi_plugins.TConfigPlugin,
133 | logger: fastapi_plugins.TLoggerPlugin
134 | ) -> typing.Dict:
135 | ping = await cache.ping()
136 | logger.debug('root_get', extra=dict(ping=ping, api_name=conf.api_name))
137 | return dict(ping=ping, api_name=conf.api_name)
138 |
139 |
140 | @app.post("/jobs/schedule/")
141 | async def job_post(
142 | timeout: int=fastapi.Query(..., title='the job sleep time'),
143 | cache: fastapi_plugins.TRedisPlugin,
144 | scheduler: fastapi_plugins.TSchedulerPlugin,
145 | logger: fastapi_plugins.TLoggerPlugin
146 | ) -> str:
147 | async def coro(job_id, timeout, cache):
148 | await cache.set(job_id, 'processing')
149 | try:
150 | await asyncio.sleep(timeout)
151 | if timeout == 8:
152 | logger.critical('Ugly erred job %s' % job_id)
153 | raise Exception('ugly error')
154 | except asyncio.CancelledError:
155 | await cache.set(job_id, 'canceled')
156 | logger.warning('Cancel job %s' % job_id)
157 | except Exception:
158 | await cache.set(job_id, 'erred')
159 | logger.error('Erred job %s' % job_id)
160 | else:
161 | await cache.set(job_id, 'success')
162 | logger.info('Done job %s' % job_id)
163 |
164 | job_id = str(uuid.uuid4()).replace('-', '')
165 | logger = await fastapi_plugins.log_adapter(logger, extra=dict(job_id=job_id, timeout=timeout)) # noqa E501
166 | logger.info('New job %s' % job_id)
167 | await cache.set(job_id, 'pending')
168 | logger.debug('Pending job %s' % job_id)
169 | await scheduler.spawn(coro(job_id, timeout, cache))
170 | return job_id
171 |
172 |
173 | @app.get("/jobs/status/")
174 | async def job_get(
175 | job_id: str=fastapi.Query(..., title='the job id'),
176 | cache: fastapi_plugins.TRedisPlugin,
177 | ) -> typing.Dict:
178 | status = await cache.get(job_id)
179 | if status is None:
180 | raise fastapi.HTTPException(
181 | status_code=starlette.status.HTTP_404_NOT_FOUND,
182 | detail='Job %s not found' % job_id
183 | )
184 | return dict(job_id=job_id, status=status)
185 |
186 |
187 | @app.post("/memcached/demo/")
188 | async def memcached_demo_post(
189 | key: str=fastapi.Query(..., title='the job id'),
190 | cache: fastapi_plugins.TMemcachedPlugin,
191 | ) -> typing.Dict:
192 | await cache.set(key.encode(), str(key + '_value').encode())
193 | value = await cache.get(key.encode())
194 | return dict(ping=(await cache.ping()).decode(), key=key, value=value)
195 | ```
196 |
197 | # Development
198 | Issues and suggestions are welcome through [issues](https://github.com/madkote/fastapi-plugins/issues)
199 |
200 | # License
201 | This project is licensed under the terms of the MIT license.
202 |
--------------------------------------------------------------------------------
/_docker/sentinel/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM redis:5
2 | LABEL maintainer="madkote(at)bluewin.ch"
3 | EXPOSE 26379
4 |
5 | ENV SENTINEL_QUORUM 2
6 | ENV SENTINEL_DOWN_AFTER 5000
7 | ENV SENTINEL_FAILOVER 10000
8 | # ENV SENTINEL_MASTER mymaster
9 |
10 | ADD sentinel.conf /etc/redis/sentinel.conf
11 | RUN chown redis:redis /etc/redis/sentinel.conf
12 |
13 | ADD entrypoint.sh /usr/local/bin/
14 | RUN chmod +x /usr/local/bin/entrypoint.sh
15 | ENTRYPOINT ["entrypoint.sh"]
16 |
17 | # ENV SENTINEL_PORT 26000
18 |
--------------------------------------------------------------------------------
/_docker/sentinel/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | sed -i "s/\$SENTINEL_QUORUM/$SENTINEL_QUORUM/g" /etc/redis/sentinel.conf
4 | sed -i "s/\$SENTINEL_DOWN_AFTER/$SENTINEL_DOWN_AFTER/g" /etc/redis/sentinel.conf
5 | sed -i "s/\$SENTINEL_FAILOVER/$SENTINEL_FAILOVER/g" /etc/redis/sentinel.conf
6 |
7 | redis-server /etc/redis/sentinel.conf --sentinel
8 |
9 | # sed -i "s/\$SENTINEL_PORT/$SENTINEL_PORT/g" /etc/redis/sentinel.conf
10 | # exec docker-entrypoint.sh redis-server /etc/redis/sentinel.conf --sentinel
--------------------------------------------------------------------------------
/_docker/sentinel/sentinel.conf:
--------------------------------------------------------------------------------
1 | port 26379
2 | protected-mode no
3 | dir /tmp
4 | sentinel monitor mymaster redis-master 6379 $SENTINEL_QUORUM
5 | sentinel down-after-milliseconds mymaster $SENTINEL_DOWN_AFTER
6 | sentinel parallel-syncs mymaster 1
7 | sentinel failover-timeout mymaster $SENTINEL_FAILOVER
8 |
--------------------------------------------------------------------------------
/demo.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # demo
4 |
5 | from __future__ import absolute_import
6 |
7 | import asyncio
8 | import logging
9 | import os
10 | import time
11 |
12 | import fastapi
13 | import pydantic_settings
14 |
15 | import fastapi_plugins
16 |
17 |
18 | class OtherSettings(pydantic_settings.BaseSettings):
19 | other: str = 'other'
20 |
21 |
22 | class AppSettings(
23 | OtherSettings,
24 | fastapi_plugins.LoggingSettings,
25 | fastapi_plugins.RedisSettings,
26 | fastapi_plugins.SchedulerSettings
27 | ):
28 | api_name: str = str(__name__)
29 | logging_level: int = logging.INFO
30 |
31 |
32 | async def test_redis():
33 | print('--- do redis test')
34 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
35 | config = fastapi_plugins.RedisSettings()
36 | config = None
37 | config = AppSettings(redis_host='127.0.0.1')
38 | config = AppSettings()
39 |
40 | await fastapi_plugins.redis_plugin.init_app(app=app, config=config)
41 | await fastapi_plugins.redis_plugin.init()
42 | c = await fastapi_plugins.redis_plugin()
43 | print(await c.get('x'))
44 | print(await c.set('x', str(time.time())))
45 | print(await c.get('x'))
46 | await fastapi_plugins.redis_plugin.terminate()
47 | print('---test redis done')
48 |
49 |
50 | async def test_scheduler():
51 | async def coro(name, timeout):
52 | try:
53 | print('> sleep', name, timeout)
54 | await asyncio.sleep(timeout)
55 | print('---> sleep done', name, timeout)
56 | except asyncio.CancelledError as e:
57 | print('coro cancelled', name)
58 | raise e
59 |
60 | print('--- do schedule test')
61 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
62 | config = fastapi_plugins.SchedulerSettings()
63 | config = None
64 | config = AppSettings(aiojobs_limit=100)
65 | config = AppSettings()
66 | # config = AppSettings(aiojobs_limit=1)
67 |
68 | await fastapi_plugins.scheduler_plugin.init_app(app=app, config=config)
69 | await fastapi_plugins.scheduler_plugin.init()
70 | try:
71 | print('- play')
72 | s = await fastapi_plugins.scheduler_plugin()
73 | # import random
74 | for i in range(10):
75 | await s.spawn(coro(str(i), i/10))
76 | # await s.spawn(coro(str(i), i/10 + random.choice([0.1, 0.2, 0.3, 0.4, 0.5]))) # nosec B311
77 | # print('----------')
78 | print('- sleep', 5)
79 | await asyncio.sleep(5.0)
80 | print('- terminate')
81 | finally:
82 | await fastapi_plugins.scheduler_plugin.terminate()
83 | print('---test schedule done')
84 |
85 |
86 | # async def test_scheduler_enable_cancel():
87 | # async def coro(name, timeout):
88 | # try:
89 | # print('> sleep', name, timeout)
90 | # await asyncio.sleep(timeout)
91 | # print('---> sleep done', name, timeout)
92 | # except asyncio.CancelledError as e:
93 | # print('coro cancelled', name)
94 | # raise e
95 | #
96 | # print('--- do schedule test')
97 | # app = fastapi.FastAPI()
98 | # config = fastapi_plugins.SchedulerSettings()
99 | # config = None
100 | # config = AppSettings(aiojobs_limit=100)
101 | # config = AppSettings(aiojobs_enable_cancel=True)
102 | #
103 | # await fastapi_plugins.scheduler_plugin.init_app(app=app, config=config)
104 | # await fastapi_plugins.scheduler_plugin.init()
105 | # try:
106 | # print('- play')
107 | # s = await fastapi_plugins.scheduler_plugin()
108 | # jobs = []
109 | # for i in range(2):
110 | # job = await s.spawn(coro(str(i), 12.75))
111 | # jobs.append(job.id)
112 | # print('- sleep', 2)
113 | # await asyncio.sleep(2.0)
114 | # print('- cancel')
115 | # for job_id in jobs:
116 | # await s.cancel_job(job_id)
117 | # print('- terminate')
118 | # finally:
119 | # await fastapi_plugins.scheduler_plugin.terminate()
120 | # print('---test schedule done')
121 |
122 |
123 | async def test_demo():
124 | async def coro(con, name, timeout):
125 | try:
126 | await con.set(name, '...')
127 | print('> sleep', name, timeout)
128 | await asyncio.sleep(timeout)
129 | await con.set(name, 'done')
130 | print('---> sleep done', name, timeout)
131 | except asyncio.CancelledError as e:
132 | print('coro cancelled', name)
133 | raise e
134 |
135 | print('--- do demo')
136 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
137 | config = AppSettings(logging_style=fastapi_plugins.LoggingStyle.logfmt)
138 |
139 | await fastapi_plugins.log_plugin.init_app(app, config, name=__name__)
140 | await fastapi_plugins.log_plugin.init()
141 | await fastapi_plugins.redis_plugin.init_app(app=app, config=config)
142 | await fastapi_plugins.redis_plugin.init()
143 | await fastapi_plugins.scheduler_plugin.init_app(app=app, config=config)
144 | await fastapi_plugins.scheduler_plugin.init()
145 |
146 | try:
147 | num_jobs = 10
148 | num_sleep = 0.25
149 |
150 | print('- play')
151 | l = await fastapi_plugins.log_plugin()
152 | c = await fastapi_plugins.redis_plugin()
153 | s = await fastapi_plugins.scheduler_plugin()
154 | for i in range(num_jobs):
155 | await s.spawn(coro(c, str(i), i/10))
156 | l.info('- sleep %s' % num_sleep)
157 | # print('- sleep', num_sleep)
158 | await asyncio.sleep(num_sleep)
159 | l.info('- check')
160 | # print('- check')
161 | for i in range(num_jobs):
162 | l.info('%s == %s' % (i, await c.get(str(i))))
163 | # print(i, '==', await c.get(str(i)))
164 | finally:
165 | print('- terminate')
166 | await fastapi_plugins.scheduler_plugin.terminate()
167 | await fastapi_plugins.redis_plugin.terminate()
168 | await fastapi_plugins.log_plugin.terminate()
169 | print('---demo done')
170 |
171 |
172 | async def test_demo_custom_log():
173 | async def coro(con, name, timeout):
174 | try:
175 | await con.set(name, '...')
176 | print('> sleep', name, timeout)
177 | await asyncio.sleep(timeout)
178 | await con.set(name, 'done')
179 | print('---> sleep done', name, timeout)
180 | except asyncio.CancelledError as e:
181 | print('coro cancelled', name)
182 | raise e
183 |
184 | class CustomLoggingSettings(fastapi_plugins.LoggingSettings):
185 | another_format: str = '%(asctime)s %(levelname)-8s %(name)-15s %(message)s'
186 |
187 | class CustomLoggingPlugin(fastapi_plugins.LoggingPlugin):
188 | def _create_logger(
189 | self,
190 | name:str,
191 | config:pydantic_settings.BaseSettings=None
192 | ) -> logging.Logger:
193 | import sys
194 | handler = logging.StreamHandler(stream=sys.stderr)
195 | formatter = logging.Formatter(config.another_format)
196 | logger = logging.getLogger(name)
197 | #
198 | logger.setLevel(config.logging_level)
199 | handler.setLevel(config.logging_level)
200 | handler.setFormatter(formatter)
201 | logger.addHandler(handler)
202 | return logger
203 |
204 | class AppSettings(
205 | OtherSettings,
206 | fastapi_plugins.RedisSettings,
207 | fastapi_plugins.SchedulerSettings,
208 | CustomLoggingSettings
209 | ):
210 | api_name: str = str(__name__)
211 | logging_level: int = logging.INFO
212 |
213 | print('--- do demo')
214 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
215 | config = AppSettings(logging_style=fastapi_plugins.LoggingStyle.logfmt)
216 | mylog_plugin = CustomLoggingPlugin()
217 |
218 | await mylog_plugin.init_app(app, config, name=__name__)
219 | await mylog_plugin.init()
220 | await fastapi_plugins.redis_plugin.init_app(app=app, config=config)
221 | await fastapi_plugins.redis_plugin.init()
222 | await fastapi_plugins.scheduler_plugin.init_app(app=app, config=config)
223 | await fastapi_plugins.scheduler_plugin.init()
224 |
225 | try:
226 | num_jobs = 10
227 | num_sleep = 0.25
228 |
229 | print('- play')
230 | l = await mylog_plugin()
231 | c = await fastapi_plugins.redis_plugin()
232 | s = await fastapi_plugins.scheduler_plugin()
233 | for i in range(num_jobs):
234 | await s.spawn(coro(c, str(i), i/10))
235 | l.info('- sleep %s' % num_sleep)
236 | # print('- sleep', num_sleep)
237 | await asyncio.sleep(num_sleep)
238 | l.info('- check')
239 | # print('- check')
240 | for i in range(num_jobs):
241 | l.info('%s == %s' % (i, await c.get(str(i))))
242 | # print(i, '==', await c.get(str(i)))
243 | finally:
244 | print('- terminate')
245 | await fastapi_plugins.scheduler_plugin.terminate()
246 | await fastapi_plugins.redis_plugin.terminate()
247 | await mylog_plugin.terminate()
248 | print('---demo done')
249 |
250 |
251 | async def test_memcached():
252 | print('---test memcached')
253 | from fastapi_plugins.memcached import MemcachedSettings
254 | from fastapi_plugins.memcached import memcached_plugin
255 |
256 | class MoreSettings(AppSettings, MemcachedSettings):
257 | memcached_prestart_tries: int = 5
258 | memcached_prestart_wait: int = 1
259 |
260 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
261 | config = MoreSettings()
262 | await memcached_plugin.init_app(app=app, config=config)
263 | await memcached_plugin.init()
264 |
265 | c = await memcached_plugin()
266 | print(await c.get(b'x'))
267 | print(await c.set(b'x', str(time.time()).encode()))
268 | print(await c.get(b'x'))
269 | await memcached_plugin.terminate()
270 | print('---test memcached done')
271 |
272 |
273 | # =============================================================================
274 | # ---
275 | # =============================================================================
276 | def main_memcached():
277 | print(os.linesep * 3)
278 | print('=' * 50)
279 | loop = asyncio.get_event_loop()
280 | loop.run_until_complete(test_memcached())
281 |
282 |
283 | def main_redis():
284 | print(os.linesep * 3)
285 | print('=' * 50)
286 | loop = asyncio.get_event_loop()
287 | loop.run_until_complete(test_redis())
288 |
289 |
290 | def main_scheduler():
291 | print(os.linesep * 3)
292 | print('=' * 50)
293 | loop = asyncio.get_event_loop()
294 | loop.run_until_complete(test_scheduler())
295 | # loop.run_until_complete(test_scheduler_enable_cancel())
296 |
297 |
298 | def main_demo():
299 | print(os.linesep * 3)
300 | print('=' * 50)
301 | loop = asyncio.get_event_loop()
302 | loop.run_until_complete(test_demo())
303 |
304 | def main_demo_custom_log():
305 | print(os.linesep * 3)
306 | print('=' * 50)
307 | loop = asyncio.get_event_loop()
308 | loop.run_until_complete(test_demo_custom_log())
309 |
310 |
311 | if __name__ == '__main__':
312 | main_redis()
313 | main_scheduler()
314 | main_demo()
315 | main_demo_custom_log()
316 | #
317 | try:
318 | main_memcached()
319 | except Exception as e:
320 | print(type(e), e)
321 |
322 |
--------------------------------------------------------------------------------
/docker-compose.memcached.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 | services:
3 | memcached:
4 | image: memcached:1.6.18
5 | ports:
6 | - "11211:11211"
7 |
--------------------------------------------------------------------------------
/docker-compose.redis.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 | services:
3 | redis:
4 | image: redis:5
5 | ports:
6 | - "6379:6379"
7 |
--------------------------------------------------------------------------------
/docker-compose.sentinel.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 | services:
3 | redis-master:
4 | image: redis:5
5 | ports:
6 | - "6379:6379"
7 | redis-slave:
8 | image: redis:5
9 | command: redis-server --slaveof redis-master 6379
10 | links:
11 | - redis-master
12 | redis-sentinel:
13 | build:
14 | context: _docker/sentinel
15 | ports:
16 | - "26379:26379"
17 | environment:
18 | - SENTINEL_DOWN_AFTER=5000
19 | - SENTINEL_FAILOVER=5000
20 | links:
21 | - redis-master
22 | - redis-slave
23 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 | services:
3 | demo_fastapi_plugin:
4 | image: demo_fastapi_plugin
5 | environment:
6 | - MEMCACHED_HOST=memcached
7 | - REDIS_TYPE=redis
8 | - REDIS_HOST=redis
9 | - REDIS_PORT=6379
10 | - REDIS_DB=0
11 | # - REDIS_PASSWORD=redis0123
12 | - LOGGING_LEVEL=10 # 0, 10, 20, 30, 40, 50
13 | - LOGGING_STYLE=json # txt, json, logfmt
14 | - LOGGING_FMT= # "%(asctime)s %(levelname) %(message)s"
15 | ports:
16 | - "8000:8000"
17 | build:
18 | context: .
19 | target: demo
20 | dockerfile: Dockerfile
--------------------------------------------------------------------------------
/docs/cache.md:
--------------------------------------------------------------------------------
1 | # Cache
2 | ## Memcached
3 | Valid variable are
4 | * `MEMCACHED_HOST` - Memcached server host.
5 | * `MEMCACHED_PORT` - Memcached server port. Default is `11211`.
6 | * `MEMCACHED_POOL_MINSIZE` - Minimum number of free connection to create in pool. Default is `1`.
7 | * `MEMCACHED_POOL_SIZE` - Maximum number of connection to keep in pool. Default is `10`. Must be greater than `0`. `None` is disallowed.
8 | * `MEMCACHED_PRESTART_TRIES` - The number tries to connect to the a Memcached instance.
9 | * `MEMCACHED_PRESTART_WAIT` - The interval in seconds to wait between connection failures on application start.
10 |
11 | ### Example
12 | ```python
13 | # run with `uvicorn demo_app:app`
14 | import contextlib
15 | import typing
16 | import fastapi
17 | import pydantic
18 |
19 | from fastapi_plugins.memcached import MemcachedSettings
20 | from fastapi_plugins.memcached import MemcachedClient
21 | from fastapi_plugins.memcached import memcached_plugin
22 | from fastapi_plugins.memcached import depends_memcached
23 |
24 | class AppSettings(OtherSettings, MemcachedSettings):
25 | api_name: str = str(__name__)
26 |
27 | @contextlib.asynccontextmanager
28 | async def lifespan(app: fastapi.FastAPI):
29 | config = AppSettings()
30 | await memcached_plugin.init_app(app, config=config)
31 | await memcached_plugin.init()
32 | yield
33 | await memcached_plugin.terminate()
34 |
35 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
36 |
37 | @app.get("/")
38 | async def root_get(
39 | cache: MemcachedClient=fastapi.Depends(depends_memcached),
40 | ) -> typing.Dict:
41 | await cache.set(b'Hello', b'World')
42 | await cache.get(b'Hello')
43 | return dict(ping=await cache.ping())
44 | ```
45 |
46 | ## Redis
47 | Supports
48 | * single instance
49 | * sentinel
50 |
51 | Valid variable are
52 | * `REDIS_TYPE`
53 | * `redis` - single Redis instance
54 | * `sentinel` - Redis cluster
55 | * `REDIS_URL` - URL to connect to Redis server. Example
56 | `redis://user:password@localhost:6379/2`. Supports protocols `redis://`,
57 | `rediss://` (redis over TLS) and `unix://`.
58 | * `REDIS_HOST` - Redis server host.
59 | * `REDIS_PORT` - Redis server port. Default is `6379`.
60 | * `REDIS_PASSWORD` - Redis password for server.
61 | * `REDIS_DB` - Redis db (zero-based number index). Default is `0`.
62 | * `REDIS_CONNECTION_TIMEOUT` - Redis connection timeout. Default is `2`.
63 | * `REDIS_POOL_MINSIZE` - Minimum number of free connection to create in pool. Default is `1`.
64 | * `REDIS_POOL_MAXSIZE` - Maximum number of connection to keep in pool. Default is `10`. Must be greater than `0`. `None` is disallowed.
65 | * `REDIS_TTL` - Default `Time-To-Live` value. Default is `3600`.
66 | * `REDIS_SENTINELS` - List or a tuple of Redis sentinel addresses.
67 | * `REDIS_SENTINEL_MASTER` - The name of the master server in a sentinel configuration. Default is `mymaster`.
68 | * `REDIS_PRESTART_TRIES` - The number tries to connect to the a Redis instance.
69 | * `REDIS_PRESTART_WAIT` - The interval in seconds to wait between connection failures on application start.
70 |
71 | ### Example
72 | ```python
73 | # run with `uvicorn demo_app:app`
74 | import contextlib
75 | import typing
76 | import aioredis
77 | import fastapi
78 | import pydantic
79 | import fastapi_plugins
80 |
81 | class AppSettings(OtherSettings, fastapi_plugins.RedisSettings):
82 | api_name: str = str(__name__)
83 |
84 | @contextlib.asynccontextmanager
85 | async def lifespan(app: fastapi.FastAPI):
86 | config = AppSettings()
87 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
88 | await fastapi_plugins.redis_plugin.init()
89 | yield
90 | await fastapi_plugins.redis_plugin.terminate()
91 |
92 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
93 |
94 |
95 | @app.get("/")
96 | async def root_get(
97 | cache: aioredis.Redis=fastapi.Depends(fastapi_plugins.depends_redis),
98 | ) -> typing.Dict:
99 | return dict(ping=await cache.ping())
100 | ```
101 |
102 | ### Example with Docker Compose - Redis
103 | ```YAML
104 | version: '3.7'
105 | services:
106 | redis:
107 | image: redis
108 | ports:
109 | - "6379:6379"
110 | demo_fastapi_plugin:
111 | image: demo_fastapi_plugin
112 | environment:
113 | - REDIS_TYPE=redis
114 | - REDIS_HOST=redis
115 | - REDIS_PORT=6379
116 | ports:
117 | - "8000:8000"
118 | ```
119 |
120 | ### Example with Docker Compose - Redis Sentinel
121 | ```YAML
122 | version: '3.7'
123 | services:
124 | ...
125 | redis-sentinel:
126 | ports:
127 | - "26379:26379"
128 | environment:
129 | - ...
130 | links:
131 | - redis-master
132 | - redis-slave
133 | demo_fastapi_plugin:
134 | image: demo_fastapi_plugin
135 | environment:
136 | - REDIS_TYPE=sentinel
137 | - REDIS_SENTINELS=redis-sentinel:26379
138 | ports:
139 | - "8000:8000"
140 | ```
--------------------------------------------------------------------------------
/docs/control.md:
--------------------------------------------------------------------------------
1 | # Control
2 | Control you application out of the box with:
3 | * `/control/environ` - return the application's environment
4 | * `/control/health` - return the application's and it's plugins health
5 | * `/control/heartbeat` - return the application's heart beat
6 | * `/control/version` - return the application's version
7 |
8 | Valid variables are:
9 | * `CONTROL_ROUTER_PREFIX` - The router prefix for `control` plugin. Default is `control`.
10 | * `CONTROL_ROUTER_TAG` - The router tag for `control` plugin. Default is `control`.
11 | * `CONTROL_ENABLE_ENVIRON` - The flag to enable or disable `environ` endpoint. Default is `True` - enabled.
12 | * `CONTROL_ENABLE_HEALTH` - The flag to enable or disable `health` endpoint. Default is `True` - enabled.
13 | * `CONTROL_ENABLE_HEARTBEAT` - The flag to enable or disable `heartbeat` endpoint. Default is `True` - enabled.
14 | * `CONTROL_ENABLE_VERSION` - The flag to enable or disable `version` endpoint. Default is `True` - enabled.
15 |
16 | ## Environment
17 | The endpoint `/control/environ` returns the environment variables and their
18 | values used in the application. It is the responsibility of developer to hide
19 | or mask some values such as passwords or any other critical information.
20 |
21 | ## Version
22 | The endpoint `/control/version` returns the version of the application. The
23 | version must be passed explicitly.
24 |
25 | ## Health
26 | The endpoint `/control/health` returns health status of the application, where
27 | all observed plugins should return some details on health check or raise an
28 | exception.
29 |
30 | ```python
31 | class MyPluginWithHealth(
32 | fastapi_plugins.Plugin,
33 | fastapi_plugins.ControlHealthMixin
34 | ):
35 | async def init_app(
36 | self,
37 | app: fastapi.FastAPI,
38 | config: pydantic_settings.BaseSettings=None,
39 | *args,
40 | **kwargs
41 | ) -> None:
42 | self.counter = 0
43 | app.state.MY_PLUGIN_WITH_HELP = self
44 |
45 | async def health(self) -> typing.Dict:
46 | if self.counter > 3:
47 | raise Exception('Health check failed')
48 | else:
49 | self.counter += 1
50 | return dict(myinfo='OK', mytype='counter health')
51 |
52 | @contextlib.asynccontextmanager
53 | async def lifespan(app: fastapi.FastAPI):
54 | config = AppSettings()
55 | myplugin = MyPluginWithHealth()
56 | await myplugin.init_app(app, config=config)
57 | await myplugin.init()
58 | await fastapi_plugins.control_plugin.init_app(
59 | app,
60 | config=config,
61 | version='1.2.3',
62 | environ=config.model_dump(),
63 | )
64 | await fastapi_plugins.control_plugin.init()
65 | yield
66 | await fastapi_plugins.control_plugin.terminate()
67 | await myplugin.terminate()
68 |
69 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
70 |
71 | @app.get("/")
72 | async def root_get(
73 | myextension: MyPluginWithHealth=fastapi.Depends(depends_myplugin),
74 | ) -> typing.Dict:
75 | return dict(ping='pong')
76 | ```
77 |
78 | ## Heartbeat
79 | The endpoint `/control/heartbeat` returns heart beat of the application - simple health without any plugins.
80 |
81 | ## Example
82 | ```python
83 | # run with `uvicorn demo_app:app`
84 | import contextlib
85 | import aioredis
86 | import fastapi
87 | import fastapi_plugins
88 |
89 | class AppSettings(
90 | fastapi_plugins.ControlSettings,
91 | fastapi_plugins.RedisSettings
92 | ):
93 | api_name: str = str(__name__)
94 | #
95 | # control_enable_environ: bool = True
96 | # control_enable_health: bool = True
97 | # control_enable_heartbeat: bool = True
98 | # control_enable_version: bool = True
99 |
100 | @contextlib.asynccontextmanager
101 | async def lifespan(app: fastapi.FastAPI):
102 | config = AppSettings()
103 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
104 | await fastapi_plugins.redis_plugin.init()
105 | await fastapi_plugins.control_plugin.init_app(
106 | app,
107 | config=config,
108 | version='1.2.3',
109 | environ=config.model_dump(),
110 | )
111 | await fastapi_plugins.control_plugin.init()
112 | yield
113 | await fastapi_plugins.control_plugin.terminate()
114 | await fastapi_plugins.redis_plugin.terminate()
115 |
116 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
117 |
118 | @app.get("/")
119 | async def root_get(
120 | cache: aioredis.Redis=fastapi.Depends(fastapi_plugins.depends_redis),
121 | ) -> typing.Dict:
122 | return dict(ping=await cache.ping())
123 | ```
124 |
125 | Control plugin should be initialized as last in order to find all observable
126 | plugins - to report their health.
127 | Parameters `version` and `environ` will return these values on endpoint calls.
128 |
129 | #### Environ
130 | ```bash
131 | curl -X 'GET' 'http://localhost:8000/control/environ' -H 'accept: application/json'
132 |
133 | {
134 | "environ": {
135 | "memcached_host":"localhost",
136 | "memcached_port":11211,
137 | "memcached_pool_size":10,
138 | "memcached_pool_minsize":1,
139 | "memcached_prestart_tries":300,
140 | "memcached_prestart_wait":1,
141 | "aiojobs_close_timeout":0.1,
142 | "aiojobs_limit":100,
143 | "aiojobs_pending_limit":10000,
144 | "redis_type":"redis",
145 | "redis_url":null,
146 | "redis_host":"localhost",
147 | ...
148 | }
149 | }
150 | ```
151 |
152 | #### Version
153 | ```bash
154 | curl -X 'GET' 'http://localhost:8000/control/version' -H 'accept: application/json'
155 |
156 | {
157 | "version": "0.1.2"
158 | }
159 | ```
160 |
161 | #### Health
162 | ```bash
163 | curl -X 'GET' 'http://localhost:8000/control/health' -H 'accept: application/json'
164 |
165 | {
166 | "status": true,
167 | "checks": [
168 | {
169 | "status": true,
170 | "name": "MEMCACHED",
171 | "details": {
172 | "host": "localhost",
173 | "port": 11211,
174 | "version": "1.6.9"
175 | }
176 | },
177 | {
178 | "status": true,
179 | "name": "REDIS",
180 | "details": {
181 | "redis_type": "redis",
182 | "redis_address": "redis://localhost:6379/0",
183 | "redis_pong": "PONG"
184 | }
185 | },
186 | {
187 | "status": true,
188 | "name": "AIOJOBS_SCHEDULER",
189 | "details": {
190 | "jobs": 0,
191 | "active": 0,
192 | "pending": 0,
193 | "limit": 100,
194 | "closed": false
195 | }
196 | }
197 | ]
198 | }
199 | ```
200 |
201 | #### Heartbeat
202 | ```bash
203 | curl -X 'GET' 'http://localhost:8000/control/heartbeat' -H 'accept: application/json'
204 |
205 | {
206 | "is_alive": true
207 | }
208 | ```
209 |
--------------------------------------------------------------------------------
/docs/logger.md:
--------------------------------------------------------------------------------
1 | # Logging
2 | ## Supported formats
3 | * Default
4 | * JSON
5 | * Logfmt
6 |
7 | Shipped plugin will dump all logs to `sys.stdout`. In order to change/add more handlers or
8 | formats, override the plugin and implement missing functionality (you also can provide PR).
9 | For details see below.
10 |
11 | ## Valid variables and values
12 | * `LOGGING_LEVEL` - verbosity level
13 | * any valid level provided by standard `logging` library (e.g. `10`, `20`, `30`, ...)
14 | * `LOGGING_STYLE` - style/format of log records
15 | * `txt` - default `logging` format
16 | * `json` - JSON format
17 | * `logfmt` - `Logfmt` format (key, value)
18 | * `LOGGING_HANDLER` - Handler type for log entries.
19 | * `stdout` - Output log entries to `sys.stdout`.
20 | * `list` - Collect log entries in a queue, **for testing purposes only**.
21 | * `LOGGING_FMT` - logging format for default formatter, e.g. `"%(asctime)s %(levelname) %(message)s"`.
22 | **Note**: this parameter is only valid in conjuction with `LOGGING_STYLE=txt`.
23 |
24 | ## Example
25 | ### Application
26 | ```python
27 | # run with `uvicorn demo_app:app`
28 | import contextlib
29 | import logging
30 | import typing
31 | import aioredis
32 | import fastapi
33 | import pydantic
34 | import fastapi_plugins
35 |
36 | class AppSettings(OtherSettings, fastapi_plugins.LoggingSettings, fastapi_plugins.RedisSettings):
37 | api_name: str = str(__name__)
38 | logging_level: int = logging.DEBUG
39 | logging_style: fastapi_plugins.LoggingStyle = fastapi_plugins.LoggingStyle.logjson
40 |
41 | @contextlib.asynccontextmanager
42 | async def lifespan(app: fastapi.FastAPI):
43 | config = AppSettings()
44 | await fastapi_plugins.log_plugin.init_app(app, config=config, name=__name__)
45 | await fastapi_plugins.log_plugin.init()
46 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
47 | await fastapi_plugins.redis_plugin.init()
48 | yield
49 | await fastapi_plugins.redis_plugin.terminate()
50 | await fastapi_plugins.log_plugin.terminate()
51 |
52 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
53 |
54 | @app.get("/")
55 | async def root_get(
56 | cache: aioredis.Redis=fastapi.Depends(fastapi_plugins.depends_redis),
57 | logger: logging.Logger=fastapi.Depends(fastapi_plugins.depends_logging),
58 | ) -> typing.Dict:
59 | ping = await cache.ping()
60 | logger.debug('root_get', extra=dict(ping=ping))
61 | return dict(ping=ping)
62 | ```
63 |
64 | ### Application with Logging Adapter
65 | ```python
66 | ... as above ...
67 |
68 | @app.post("/jobs/schedule/")
69 | async def job_post(
70 | cache: aioredis.Redis=fastapi.Depends(fastapi_plugins.depends_redis),
71 | logger: logging.Logger=fastapi.Depends(fastapi_plugins.depends_logging)
72 | ) -> str:
73 | async def coro(job_id, cache):
74 | logger.info('Done job %s' % job_id)
75 |
76 | # create a job ID
77 | job_id = str(uuid.uuid4()).replace('-', '')
78 |
79 | # create logging adapter which will contain job ID in every log record
80 | logger = await fastapi_plugins.log_adapter(
81 | logger,
82 | extra=dict(job_id=job_id, other='some static information')
83 | )
84 |
85 | # job_id and other will be now part of log record
86 | logger.info('New job %s' % job_id)
87 | await cache.set(job_id, 'pending')
88 | logger.debug('Pending job %s' % job_id)
89 | # await scheduler.spawn(coro(job_id, cache))
90 | return job_id
91 | ```
92 |
93 | ### Custom formats and handlers
94 | ```python
95 | # run with `uvicorn demo_app:app`
96 | import contextlib
97 | import logging
98 | import typing
99 | import aioredis
100 | import fastapi
101 | import pydantic_settings
102 | import fastapi_plugins
103 |
104 | class CustomLoggingSettings(fastapi_plugins.LoggingSettings):
105 | some_settings: ... = ...
106 |
107 | class CustomLoggingPlugin(fastapi_plugins.LoggingPlugin):
108 | def _create_logger(
109 | self,
110 | name: str,
111 | config: pydantic_settings.BaseSettings=None
112 | ) -> logging.Logger:
113 | import sys
114 | handler = logging.StreamHandler(stream=sys.stderr)
115 | formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(name)-15s %(message)s')
116 | logger = logging.getLogger(name)
117 | #
118 | logger.setLevel(config.logging_level)
119 | handler.setLevel(config.logging_level)
120 | handler.setFormatter(formatter)
121 | logger.addHandler(handler)
122 | return logger
123 |
124 | class AppSettings(OtherSettings, CustomLoggingSettings, fastapi_plugins.RedisSettings):
125 | api_name: str = str(__name__)
126 | logging_level: int = logging.DEBUG
127 |
128 | @contextlib.asynccontextmanager
129 | async def lifespan(app: fastapi.FastAPI):
130 | config = AppSettings()
131 | fastapi_plugins.log_plugin = CustomLoggingPlugin()
132 | await fastapi_plugins.log_plugin.init_app(app, config, name=__name__)
133 | await fastapi_plugins.log_plugin.init()
134 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
135 | await fastapi_plugins.redis_plugin.init()
136 | yield
137 | await fastapi_plugins.redis_plugin.terminate()
138 | await fastapi_plugins.log_plugin.terminate()
139 |
140 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
141 |
142 | @app.get("/")
143 | async def root_get(
144 | cache: aioredis.Redis=fastapi.Depends(fastapi_plugins.depends_redis),
145 | logger: logging.Logger=fastapi.Depends(fastapi_plugins.depends_logging),
146 | ) -> typing.Dict:
147 | ping = await cache.ping()
148 | logger.debug('root_get', extra=dict(ping=ping))
149 | return dict(ping=ping)
150 | ```
151 |
152 | ### Docker Compose
153 | ```YAML
154 | version: '3.7'
155 | services:
156 | redis:
157 | image: redis
158 | ports:
159 | - "6379:6379"
160 | demo_fastapi_plugin:
161 | image: demo_fastapi_plugin
162 | environment:
163 | - LOGGING_LEVEL=10 # 0, 10, 20, 30, 40, 50
164 | - LOGGING_STYLE=json # txt, json, logfmt
165 | - ...
166 | ports:
167 | - "8000:8000"
168 | ```
169 |
--------------------------------------------------------------------------------
/docs/scheduler.md:
--------------------------------------------------------------------------------
1 | # Scheduler
2 | Simple schedule an _awaitable_ job as a task.
3 | * _long_ running `async` functions (e.g. monitor a file a system or events)
4 | * gracefully cancel spawned tasks
5 |
6 | Valid variable are:
7 | * `AIOJOBS_CLOSE_TIMEOUT` - The timeout in seconds before canceling a task.
8 | * `AIOJOBS_LIMIT` - The number of concurrent tasks to be executed.
9 | * `AIOJOBS_PENDING_LIMIT` - The number of pending jobs (waiting fr execution).
10 |
11 |
12 | ```python
13 | '''run with `uvicorn demo_app:app` '''
14 | import ...
15 | import fastapi_plugins
16 |
17 | class AppSettings(OtherSettings, fastapi_plugins.RedisSettings, fastapi_plugins.SchedulerSettings):
18 | api_name: str = str(__name__)
19 |
20 | @contextlib.asynccontextmanager
21 | async def lifespan(app: fastapi.FastAPI):
22 | config = AppSettings()
23 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
24 | await fastapi_plugins.redis_plugin.init()
25 | await fastapi_plugins.scheduler_plugin.init_app(app=app, config=config)
26 | await fastapi_plugins.scheduler_plugin.init()
27 | yield
28 | await fastapi_plugins.scheduler_plugin.terminate()
29 | await fastapi_plugins.redis_plugin.terminate()
30 |
31 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
32 |
33 | @app.post("/jobs/schedule/")
34 | async def job_post(
35 | timeout: int=fastapi.Query(..., title='the job sleep time'),
36 | cache: aioredis.Redis=fastapi.Depends(fastapi_plugins.depends_redis),
37 | scheduler: aiojobs.Scheduler=fastapi.Depends(fastapi_plugins.depends_scheduler), # @IgnorePep8
38 | ) -> str:
39 | async def coro(job_id, timeout, cache):
40 | await cache.set(job_id, 'processing')
41 | try:
42 | await asyncio.sleep(timeout)
43 | if timeout == 8:
44 | raise Exception('ugly error')
45 | except asyncio.CancelledError:
46 | await cache.set(job_id, 'canceled')
47 | except Exception:
48 | await cache.set(job_id, 'erred')
49 | else:
50 | await cache.set(job_id, 'success')
51 |
52 | job_id = str(uuid.uuid4()).replace('-', '')
53 | await cache.set(job_id, 'pending')
54 | await scheduler.spawn(coro(job_id, timeout, cache))
55 | return job_id
56 |
57 | @app.get("/jobs/status/")
58 | async def job_get(
59 | job_id: str=fastapi.Query(..., title='the job id'),
60 | cache: aioredis.Redis=fastapi.Depends(fastapi_plugins.depends_redis),
61 | ) -> typing.Dict:
62 | status = await cache.get(job_id)
63 | if status is None:
64 | raise fastapi.HTTPException(
65 | status_code=starlette.status.HTTP_404_NOT_FOUND,
66 | detail='Job %s not found' % job_id
67 | )
68 | return dict(job_id=job_id, status=status)
69 | ```
--------------------------------------------------------------------------------
/docs/settings.md:
--------------------------------------------------------------------------------
1 | # Settings
2 | The easy way to configure the `FastAPI` application:
3 | * define (various) configuration(s)
4 | * register configuration
5 | * use configuration depending on the environment
6 | * use configuration in router handler if required
7 |
8 | ## Define configuration
9 | It is a good practice to defined various configuration for your application.
10 | ```python
11 | import fastapi_plugins
12 |
13 | class DefaultSettings(fastapi_plugins.RedisSettings):
14 | api_name: str = str(__name__)
15 |
16 | class DockerSettings(DefaultSettings):
17 | redis_type = fastapi_plugins.RedisType.sentinel
18 | redis_sentinels = 'localhost:26379'
19 |
20 | class LocalSettings(DefaultSettings):
21 | pass
22 |
23 | class TestSettings(DefaultSettings):
24 | testing: bool = True
25 |
26 | class MyConfigSettings(DefaultSettings):
27 | custom: bool = True
28 |
29 | ...
30 | ```
31 |
32 | ## Register configuration
33 | Registration with a decorator
34 | ```python
35 | import fastapi_plugins
36 |
37 | class DefaultSettings(fastapi_plugins.RedisSettings):
38 | api_name: str = str(__name__)
39 |
40 | # @fastapi_plugins.registered_configuration_docker
41 | @fastapi_plugins.registered_configuration
42 | class DockerSettings(DefaultSettings):
43 | redis_type = fastapi_plugins.RedisType.sentinel
44 | redis_sentinels = 'localhost:26379'
45 |
46 | @fastapi_plugins.registered_configuration_local
47 | class LocalSettings(DefaultSettings):
48 | pass
49 |
50 | @fastapi_plugins.registered_configuration_test
51 | class TestSettings(DefaultSettings):
52 | testing: bool = True
53 |
54 | @fastapi_plugins.registered_configuration(name='my_config')
55 | class MyConfigSettings(DefaultSettings):
56 | custom: bool = True
57 | ...
58 | ```
59 |
60 | or by a function call
61 | ```python
62 | import fastapi_plugins
63 |
64 | class DefaultSettings(fastapi_plugins.RedisSettings):
65 | api_name: str = str(__name__)
66 |
67 | class DockerSettings(DefaultSettings):
68 | redis_type = fastapi_plugins.RedisType.sentinel
69 | redis_sentinels = 'localhost:26379'
70 |
71 | class LocalSettings(DefaultSettings):
72 | pass
73 |
74 | class TestSettings(DefaultSettings):
75 | testing: bool = True
76 |
77 | class MyConfigSettings(DefaultSettings):
78 | custom: bool = True
79 |
80 | fastapi_plugins.register_config(DockerSettings)
81 | # fastapi_plugins.register_config_docker(DockerSettings)
82 | fastapi_plugins.register_config_local(LocalSettings)
83 | fastapi_plugins.register_config_test(TestSettings)
84 | fastapi_plugins.register_config(MyConfigSettings, 'my_config')
85 | ...
86 | ```
87 |
88 |
89 | ## Application configuration
90 | Next, create application and it's configuration, and register the plugin if needed. The last is optinally,
91 | and is only relevant for use cases, where the configuration values are required for endpoint handlers (see `api_name` below).
92 | ```python
93 | import fastapi
94 | import fastapi_plugins
95 | ...
96 |
97 | @contextlib.asynccontextmanager
98 | async def lifespan(app: fastapi.FastAPI):
99 | config = fastapi_plugins.get_config()
100 | await fastapi_plugins.config_plugin.init_app(app, config)
101 | await fastapi_plugins.config_plugin.init()
102 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
103 | await fastapi_plugins.redis_plugin.init()
104 | await fastapi_plugins.control_plugin.init_app(app, config=config, version=__version__, environ=config.model_dump())
105 | await fastapi_plugins.control_plugin.init()
106 | yield
107 | await fastapi_plugins.control_plugin.terminate()
108 | await fastapi_plugins.redis_plugin.terminate()
109 | await fastapi_plugins.config_plugin.terminate()
110 |
111 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
112 |
113 | @app.get("/")
114 | async def root_get(
115 | cache: aioredis.Redis=fastapi.Depends(fastapi_plugins.depends_redis),
116 | conf: pydantic_settings.BaseSettings=fastapi.Depends(fastapi_plugins.depends_config) # noqa E501
117 | ) -> typing.Dict:
118 | return dict(ping=await cache.ping(), api_name=conf.api_name)
119 | ```
120 |
121 | ## Use configuration
122 | Now, the application will use by default a configuration for `docker` or by user's command any other configuration.
123 | ```bash
124 | uvicorn scripts.demo_app:app
125 | curl -X 'GET' 'http://localhost:8000/control/environ' -H 'accept: application/json'
126 | {
127 | "environ": {
128 | "config_name":"docker",
129 | "redis_type":"sentinel",
130 | ...
131 | }
132 | }
133 |
134 | ...
135 | CONFIG_NAME=local uvicorn scripts.demo_app:app
136 | curl -X 'GET' 'http://localhost:8000/control/environ' -H 'accept: application/json'
137 | {
138 | "environ": {
139 | "config_name":"local",
140 | "redis_type":"redis",
141 | ...
142 | }
143 | }
144 | ```
145 |
146 | It is also usefull with `docker-compose`:
147 | ```yaml
148 | services:
149 | demo_fastapi_plugin:
150 | image: demo_fastapi_plugin
151 | environment:
152 | - CONFIG_NAME=docker
153 |
154 | ...
155 |
156 | demo_fastapi_plugin:
157 | image: demo_fastapi_plugin
158 | environment:
159 | - CONFIG_NAME=docker_sentinel
160 | ```
161 |
--------------------------------------------------------------------------------
/fastapi_plugins/__init__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.__init__
4 | '''
5 | :author: madkote
6 | :contact: madkote(at)bluewin.ch
7 | :copyright: Copyright 2023, madkote
8 |
9 | fastapi_plugins
10 | ---------------
11 | FastAPI plugins
12 | '''
13 |
14 | from __future__ import absolute_import
15 |
16 | from .plugin import * # noqa F401 F403
17 | from .control import * # noqa F401 F403
18 | from .logger import * # noqa F401 F403
19 | from .middleware import * # noqa F401 F403
20 | from ._redis import * # noqa F401 F403
21 | from .scheduler import * # noqa F401 F403
22 | from .settings import * # noqa F401 F403
23 | from .version import VERSION
24 |
25 | # try:
26 | # import aioredis # noqa F401
27 | # except ImportError:
28 | # pass
29 | # else:
30 | # from ._redis import * # noqa F401 F403
31 | #
32 | # try:
33 | # import aiojobs # noqa F401
34 | # except ImportError:
35 | # pass
36 | # else:
37 | # from .scheduler import * # noqa F401 F403
38 |
39 | __author__ = 'madkote '
40 | __version__ = '.'.join(str(x) for x in VERSION)
41 | __copyright__ = 'Copyright 2023, madkote'
42 |
43 | # TODO: provide a generic cache type (redis, memcached, in-memory)
44 | # and share some settings. Module/Sub-Pack cache
45 |
46 | # TODO: databases
47 |
48 | # TODO: mq - activemq, rabbitmq, kafka
49 | # -> publish(topic, message, headers)
50 | # -> consume(topic, callback)
51 |
52 | # TODO: celery
53 |
54 | # TODO: look at fastapi-cache (memcache?) look at mqtt?
55 |
56 | # ... more?
57 |
--------------------------------------------------------------------------------
/fastapi_plugins/_redis.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins._redis
4 |
5 | from __future__ import absolute_import
6 |
7 | import enum
8 | import typing
9 |
10 | import fastapi
11 | import pydantic_settings
12 | import redis.asyncio as aioredis
13 | import redis.asyncio.sentinel as aioredis_sentinel
14 | import starlette.requests
15 | import tenacity
16 |
17 | from .plugin import PluginError
18 | from .plugin import PluginSettings
19 | from .plugin import Plugin
20 |
21 | from .control import ControlHealthMixin
22 | from .utils import Annotated
23 |
24 | __all__ = [
25 | 'RedisError', 'RedisType', 'RedisSettings', 'RedisPlugin',
26 | 'redis_plugin', 'depends_redis', 'TRedisPlugin'
27 | ]
28 |
29 |
30 | class RedisError(PluginError):
31 | pass
32 |
33 |
34 | @enum.unique
35 | class RedisType(str, enum.Enum):
36 | redis = 'redis'
37 | sentinel = 'sentinel'
38 | fakeredis = 'fakeredis'
39 | # cluster = 'cluster'
40 |
41 |
42 | class RedisSettings(PluginSettings):
43 | redis_type: RedisType = RedisType.redis
44 | #
45 | redis_url: typing.Optional[str] = None
46 | redis_host: str = 'localhost'
47 | redis_port: int = 6379
48 | redis_user: typing.Optional[str] = None
49 | redis_password: typing.Optional[str] = None
50 | redis_db: typing.Optional[int] = None
51 | # redis_connection_timeout: int = 2
52 | #
53 | # redis_pool_minsize: int = 1
54 | # redis_pool_maxsize: int = None
55 | redis_max_connections: typing.Optional[int] = None
56 | redis_decode_responses: bool = True
57 | #
58 | redis_ttl: int = 3600
59 | #
60 | # TODO: xxx the customer validator does not work
61 | # redis_sentinels: typing.List = None
62 | redis_sentinels: typing.Optional[str] = None
63 | redis_sentinel_master: str = 'mymaster'
64 | #
65 | # TODO: xxx - should be shared across caches
66 | redis_prestart_tries: int = 60 * 5 # 5 min
67 | redis_prestart_wait: int = 1 # 1 second
68 |
69 | def get_redis_address(self) -> str:
70 | if self.redis_url:
71 | return self.redis_url
72 | elif self.redis_db:
73 | return 'redis://%s:%s/%s' % (
74 | self.redis_host,
75 | self.redis_port,
76 | self.redis_db
77 | )
78 | else:
79 | return 'redis://%s:%s' % (self.redis_host, self.redis_port)
80 |
81 | # TODO: xxx the customer validator does not work
82 | def get_sentinels(self) -> typing.List:
83 | if self.redis_sentinels:
84 | try:
85 | return [
86 | (
87 | _conn.split(':')[0].strip(),
88 | int(_conn.split(':')[1].strip())
89 | )
90 | for _conn in self.redis_sentinels.split(',')
91 | if _conn.strip()
92 | ]
93 | except Exception as e:
94 | raise RuntimeError(
95 | 'bad sentinels string :: %s :: %s :: %s' % (
96 | type(e), str(e), self.redis_sentinels
97 | )
98 | )
99 | else:
100 | return []
101 |
102 |
103 | class RedisPlugin(Plugin, ControlHealthMixin):
104 | DEFAULT_CONFIG_CLASS = RedisSettings
105 |
106 | def _on_init(self) -> None:
107 | self.redis: typing.Union[aioredis.Redis, aioredis_sentinel.Sentinel] = None # noqa E501
108 |
109 | async def _on_call(self) -> typing.Any:
110 | if self.redis is None:
111 | raise RedisError('Redis is not initialized')
112 | #
113 | if self.config.redis_type == RedisType.sentinel:
114 | conn = self.redis.master_for(self.config.redis_sentinel_master)
115 | elif self.config.redis_type == RedisType.redis:
116 | conn = self.redis
117 | elif self.config.redis_type == RedisType.fakeredis:
118 | conn = self.redis
119 | else:
120 | raise NotImplementedError(
121 | 'Redis type %s is not implemented' % self.config.redis_type
122 | )
123 | #
124 | conn.TTL = self.config.redis_ttl
125 | return conn
126 |
127 | async def init_app(
128 | self,
129 | app: fastapi.FastAPI,
130 | config: pydantic_settings.BaseSettings=None
131 | ) -> None:
132 | self.config = config or self.DEFAULT_CONFIG_CLASS()
133 | if self.config is None:
134 | raise RedisError('Redis configuration is not initialized')
135 | elif not isinstance(self.config, self.DEFAULT_CONFIG_CLASS):
136 | raise RedisError('Redis configuration is not valid')
137 | app.state.REDIS = self
138 |
139 | async def init(self):
140 | if self.redis is not None:
141 | raise RedisError('Redis is already initialized')
142 | #
143 | opts = dict(
144 | db=self.config.redis_db,
145 | username=self.config.redis_user,
146 | password=self.config.redis_password,
147 | # minsize=self.config.redis_pool_minsize,
148 | # maxsize=self.config.redis_pool_maxsize,
149 | max_connections=self.config.redis_max_connections,
150 | decode_responses=self.config.redis_decode_responses,
151 | )
152 | #
153 | if self.config.redis_type == RedisType.redis:
154 | address = self.config.get_redis_address()
155 | method = aioredis.from_url
156 | # opts.update(dict(timeout=self.config.redis_connection_timeout))
157 | elif self.config.redis_type == RedisType.fakeredis:
158 | try:
159 | import fakeredis.aioredis
160 | except ImportError:
161 | raise RedisError(f'{self.config.redis_type} requires fakeredis to be installed') # noqa E501
162 | else:
163 | address = self.config.get_redis_address()
164 | method = fakeredis.aioredis.FakeRedis.from_url
165 | elif self.config.redis_type == RedisType.sentinel:
166 | address = self.config.get_sentinels()
167 | method = aioredis_sentinel.Sentinel
168 | else:
169 | raise NotImplementedError(
170 | 'Redis type %s is not implemented' % self.config.redis_type
171 | )
172 | #
173 | if not address:
174 | raise ValueError('Redis address is empty')
175 |
176 | @tenacity.retry(
177 | stop=tenacity.stop_after_attempt(self.config.redis_prestart_tries),
178 | wait=tenacity.wait_fixed(self.config.redis_prestart_wait),
179 | )
180 | async def _inner():
181 | return method(address, **opts)
182 |
183 | self.redis = await _inner()
184 | await self.ping()
185 |
186 | async def terminate(self):
187 | self.config = None
188 | if self.redis is not None:
189 | # del self.redis
190 | self.redis = None
191 | # self.redis.close()
192 | # await self.redis.wait_closed()
193 | # self.redis = None
194 |
195 | async def health(self) -> typing.Dict:
196 | return dict(
197 | redis_type=self.config.redis_type,
198 | redis_address=self.config.get_sentinels() if self.config.redis_type == RedisType.sentinel else self.config.get_redis_address(), # noqa E501
199 | redis_pong=(await self.ping())
200 | )
201 |
202 | async def ping(self):
203 | if self.config.redis_type == RedisType.redis:
204 | return await self.redis.ping()
205 | elif self.config.redis_type == RedisType.fakeredis:
206 | return await self.redis.ping()
207 | elif self.config.redis_type == RedisType.sentinel:
208 | return await self.redis.master_for(self.config.redis_sentinel_master).ping() # noqa E501
209 | else:
210 | raise NotImplementedError(
211 | 'Redis type %s.ping() is not implemented' % self.config.redis_type # noqa E501
212 | )
213 |
214 |
215 | redis_plugin = RedisPlugin()
216 |
217 |
218 | async def depends_redis(
219 | conn: starlette.requests.HTTPConnection
220 | ) -> aioredis.Redis:
221 | return await conn.app.state.REDIS()
222 |
223 |
224 | TRedisPlugin = Annotated[typing.Any, fastapi.Depends(depends_redis)]
225 |
--------------------------------------------------------------------------------
/fastapi_plugins/control.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.control
4 | #
5 | # Health is inspired by
6 | # "https://dzone.com/articles/an-overview-of-health-check-patterns"
7 | #
8 |
9 | from __future__ import absolute_import
10 |
11 | import abc
12 | import asyncio
13 | import pprint
14 | import typing
15 |
16 | import fastapi
17 | import pydantic
18 | import pydantic_settings
19 | import starlette.requests
20 |
21 | from .plugin import PluginError
22 | from .plugin import PluginSettings
23 | from .plugin import Plugin
24 | from .utils import Annotated
25 |
26 | __all__ = [
27 | 'ControlEnviron', 'ControlHealthCheck', 'ControlHealth',
28 | 'ControlHealthError', 'ControlHeartBeat', 'ControlVersion',
29 | #
30 | 'ControlError', 'ControlHealthMixin', 'ControlSettings', 'Controller',
31 | 'ControlPlugin', 'control_plugin', 'depends_control', 'TControlPlugin'
32 | ]
33 |
34 |
35 | DEFAULT_CONTROL_ROUTER_PREFIX = 'control'
36 | DEFAULT_CONTROL_VERSION = '0.0.1'
37 |
38 |
39 | class ControlError(PluginError):
40 | pass
41 |
42 |
43 | class ControlBaseModel(pydantic.BaseModel):
44 | model_config = pydantic_settings.SettingsConfigDict(
45 | use_enum_values=True,
46 | validate_default=True
47 | )
48 |
49 |
50 | class ControlEnviron(ControlBaseModel):
51 | environ: typing.Dict = pydantic.Field(
52 | ...,
53 | title='Environment',
54 | examples=[dict(var1='variable1', var2='variable2')]
55 | )
56 |
57 |
58 | class ControlHealthStatus(ControlBaseModel):
59 | status: bool = pydantic.Field(
60 | ...,
61 | title='Health status',
62 | examples=[True]
63 | )
64 |
65 |
66 | class ControlHealthCheck(ControlHealthStatus):
67 | name: str = pydantic.Field(
68 | ...,
69 | title='Health check name',
70 | min_length=1,
71 | examples=['Redis']
72 | )
73 | details: typing.Dict = pydantic.Field(
74 | ...,
75 | title='Health check details',
76 | examples=[dict(detail1='detail1', detail2='detail2')]
77 | )
78 |
79 |
80 | class ControlHealth(ControlHealthStatus):
81 | checks: typing.List[ControlHealthCheck] = pydantic.Field(
82 | ...,
83 | title='Health checks',
84 | examples=[
85 | ControlHealthCheck(
86 | name='Redis',
87 | status=True,
88 | details=dict(
89 | redis_type='redis',
90 | redis_host='localhost',
91 | )
92 | ).model_dump()
93 | ]
94 | )
95 |
96 |
97 | class ControlHealthError(ControlBaseModel):
98 | detail: ControlHealth = pydantic.Field(
99 | ...,
100 | title='Health error',
101 | examples=[
102 | ControlHealth(
103 | status=False,
104 | checks=[
105 | ControlHealthCheck(
106 | name='Redis',
107 | status=False,
108 | details=dict(error='Some error')
109 | )
110 | ]
111 | )
112 | ]
113 | )
114 |
115 |
116 | class ControlHeartBeat(ControlBaseModel):
117 | is_alive: bool = pydantic.Field(
118 | ...,
119 | title='Alive flag',
120 | examples=[True]
121 | )
122 |
123 |
124 | # class ControlInfo(ControlBaseModel):
125 | # status: str = pydantic.Field(
126 | # 'API is up and running',
127 | # title='status',
128 | # min_length=1,
129 | # exampe='API is up and running'
130 | # )
131 |
132 |
133 | class ControlVersion(ControlBaseModel):
134 | version: str = pydantic.Field(
135 | ...,
136 | title='Version',
137 | min_length=1,
138 | examples=['1.2.3']
139 | )
140 |
141 |
142 | class ControlHealthMixin(object):
143 | @abc.abstractmethod
144 | async def health(self) -> typing.Dict:
145 | pass
146 |
147 |
148 | class Controller(object):
149 | def __init__(
150 | self,
151 | router_prefix: str=DEFAULT_CONTROL_ROUTER_PREFIX,
152 | router_tag: str=DEFAULT_CONTROL_ROUTER_PREFIX,
153 | version: str=DEFAULT_CONTROL_VERSION,
154 | environ: typing.Dict=None,
155 | failfast: bool=True
156 | ):
157 | self.router_prefix = router_prefix
158 | self.router_tag = router_tag
159 | self.version = version
160 | self.environ = environ
161 | self.plugins: typing.List[ControlHealthMixin] = []
162 | self.failfast = failfast
163 |
164 | def patch_app(
165 | self,
166 | app: fastapi.FastAPI,
167 | enable_environ: bool=True,
168 | enable_health: bool=True,
169 | enable_heartbeat: bool=True,
170 | enable_version: bool=True
171 | ) -> None:
172 | #
173 | # register plugins
174 | for name, state in app.state._state.items():
175 | if isinstance(state, ControlHealthMixin):
176 | self.plugins.append((name, state))
177 | #
178 | # register endpoints
179 | if not (enable_environ or enable_health or enable_heartbeat or enable_version): # noqa E501
180 | return
181 |
182 | router_control = fastapi.APIRouter()
183 |
184 | if enable_version:
185 | @router_control.get(
186 | '/version',
187 | summary='Version',
188 | description='Get the version',
189 | response_model=ControlVersion
190 | )
191 | async def version_get() -> ControlVersion:
192 | return ControlVersion(version=await self.get_version())
193 |
194 | if enable_environ:
195 | @router_control.get(
196 | '/environ',
197 | summary='Environment',
198 | description='Get the environment',
199 | response_model=ControlEnviron
200 | )
201 | async def environ_get() -> ControlEnviron:
202 | return ControlEnviron(
203 | environ=dict(**(await self.get_environ()))
204 | )
205 |
206 | if enable_heartbeat:
207 | @router_control.get(
208 | '/heartbeat',
209 | summary='Heart beat',
210 | description='Get the alive signal',
211 | response_model=ControlHeartBeat
212 | )
213 | async def heartbeat_get() -> ControlHeartBeat:
214 | return ControlHeartBeat(is_alive=await self.get_heart_beat())
215 |
216 | if enable_health:
217 | @router_control.get(
218 | '/health',
219 | summary='Health',
220 | description='Get the health',
221 | response_model=ControlHealth,
222 | responses={
223 | starlette.status.HTTP_200_OK: dict(
224 | description='UP and healthy',
225 | model=ControlHealth
226 | ),
227 | starlette.status.HTTP_417_EXPECTATION_FAILED: dict(
228 | description='NOT healthy',
229 | model=ControlHealthError
230 | )
231 | }
232 | )
233 | async def health_get() -> ControlHealth:
234 | health = await self.get_health()
235 | if health.status:
236 | return health
237 | else:
238 | raise fastapi.HTTPException(
239 | status_code=starlette.status.HTTP_417_EXPECTATION_FAILED, # noqa E501
240 | detail=health.model_dump()
241 | )
242 |
243 | #
244 | # register router
245 | app.include_router(
246 | router_control,
247 | prefix='' if not self.router_prefix else '/' + self.router_prefix,
248 | tags=[self.router_tag],
249 | )
250 |
251 | async def register_plugin(self, plugin: ControlHealthMixin):
252 | raise NotImplementedError
253 |
254 | async def get_environ(self) -> typing.Dict:
255 | return self.environ if self.environ is not None else {}
256 |
257 | async def get_health(self) -> ControlHealth:
258 | shared_obj = type('', (), {})()
259 | shared_obj.status = True
260 |
261 | # TODO: implement failfast -> wait()
262 | async def wrappit(name, hfunc) -> ControlHealthCheck:
263 | try:
264 | details = await hfunc or {}
265 | status = True
266 | except Exception as e:
267 | details = dict(error=str(e))
268 | status = False
269 | shared_obj.status = False
270 | finally:
271 | return ControlHealthCheck(
272 | name=name,
273 | status=status,
274 | details=details
275 | )
276 |
277 | # TODO: perform all checks with this function asyncio.wait(fs)
278 | # TODO: perform without shared object
279 | results = await asyncio.gather(
280 | *[wrappit(name, plugin.health()) for name, plugin in self.plugins]
281 | )
282 | return ControlHealth(
283 | status=shared_obj.status,
284 | checks=results
285 | )
286 |
287 | async def get_heart_beat(self) -> bool:
288 | return True
289 |
290 | async def get_version(self) -> str:
291 | return self.version
292 |
293 |
294 | class ControlSettings(PluginSettings):
295 | control_router_prefix: str = DEFAULT_CONTROL_ROUTER_PREFIX
296 | control_router_tag: str = DEFAULT_CONTROL_ROUTER_PREFIX
297 | control_enable_environ: bool = True
298 | control_enable_health: bool = True
299 | control_enable_heartbeat: bool = True
300 | control_enable_version: bool = True
301 |
302 |
303 | class ControlPlugin(Plugin):
304 | DEFAULT_CONFIG_CLASS = ControlSettings
305 |
306 | def _on_init(self) -> None:
307 | self.controller: Controller = None
308 |
309 | async def _on_call(self) -> Controller:
310 | if self.controller is None:
311 | raise ControlError('Control is not initialized')
312 | return self.controller
313 |
314 | async def init_app(
315 | self,
316 | app: fastapi.FastAPI,
317 | config: pydantic_settings.BaseSettings=None,
318 | *,
319 | version: str=DEFAULT_CONTROL_VERSION,
320 | environ: typing.Dict=None
321 | ) -> None:
322 | self.config = config or self.DEFAULT_CONFIG_CLASS()
323 | if self.config is None:
324 | raise ControlError('Control configuration is not initialized')
325 | elif not isinstance(self.config, self.DEFAULT_CONFIG_CLASS):
326 | raise ControlError('Control configuration is not valid')
327 | app.state.PLUGIN_CONTROL = self
328 | #
329 | # initialize here while `app` is available
330 | self.controller = Controller(
331 | router_prefix=self.config.control_router_prefix,
332 | router_tag=self.config.control_router_tag,
333 | version=version,
334 | environ=environ
335 | )
336 | self.controller.patch_app(
337 | app,
338 | enable_environ=self.config.control_enable_environ,
339 | enable_health=self.config.control_enable_health,
340 | enable_heartbeat=self.config.control_enable_heartbeat,
341 | enable_version=self.config.control_enable_version
342 | )
343 |
344 | async def init(self):
345 | if self.controller is None:
346 | raise ControlError('Control cannot be initialized')
347 | if self.config.control_enable_health:
348 | health = await self.controller.get_health()
349 | if not health.status:
350 | print()
351 | print('-' * 79)
352 | pprint.pprint(health.model_dump())
353 | print('-' * 79)
354 | print()
355 | raise ControlError('failed health control')
356 |
357 | async def terminate(self):
358 | self.config = None
359 | self.controller = None
360 |
361 |
362 | control_plugin = ControlPlugin()
363 |
364 |
365 | async def depends_control(
366 | conn: starlette.requests.HTTPConnection
367 | ) -> Controller:
368 | return await conn.app.state.PLUGIN_CONTROL()
369 |
370 |
371 | TControlPlugin = Annotated[Controller, fastapi.Depends(depends_control)]
372 |
--------------------------------------------------------------------------------
/fastapi_plugins/logger.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.logger
4 |
5 | from __future__ import absolute_import
6 |
7 | import datetime
8 | import enum
9 | import logging
10 | import numbers
11 | import queue
12 | import sys
13 | import typing
14 |
15 | import fastapi
16 | import pydantic_settings
17 | import starlette.requests
18 |
19 | from pythonjsonlogger import jsonlogger
20 |
21 | from .plugin import PluginError
22 | from .plugin import PluginSettings
23 | from .plugin import Plugin
24 |
25 | from .control import ControlHealthMixin
26 | from .utils import Annotated
27 |
28 | __all__ = [
29 | 'LoggingError', 'LoggingStyle', 'LoggingHandlerType', 'LoggingSettings',
30 | 'LoggingPlugin', 'log_plugin', 'log_adapter', 'depends_logging',
31 | 'TLoggerPlugin'
32 | ]
33 |
34 |
35 | class QueueHandler(logging.Handler):
36 | def __init__(self, *args, mqueue=None, **kwargs):
37 | super(QueueHandler, self).__init__(*args, **kwargs)
38 | self.mqueue = mqueue if mqueue is not None else queue.Queue()
39 |
40 | def emit(self, record):
41 | self.mqueue.put(self.format(record))
42 |
43 |
44 | class JsonFormatter(jsonlogger.JsonFormatter):
45 | def add_fields(self, log_record, record, message_dict):
46 | super(JsonFormatter, self).add_fields(
47 | log_record,
48 | record,
49 | message_dict
50 | )
51 | if not log_record.get('timestamp'):
52 | # this doesn't use record.created, so it is slightly off
53 | now = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ')
54 | log_record['timestamp'] = now
55 | if log_record.get('level'):
56 | log_record['level'] = log_record['level'].upper()
57 | else:
58 | log_record['level'] = record.levelname
59 | if not log_record.get('name'):
60 | log_record['name'] = record.name
61 |
62 |
63 | class LogfmtFormatter(logging.Formatter):
64 | # from https://github.com/jkakar/logfmt-python
65 | def format_line(self, extra: typing.Dict) -> str:
66 | outarr = []
67 | for k, v in extra.items():
68 | if v is None:
69 | outarr.append('%s=' % k)
70 | continue
71 | if isinstance(v, bool):
72 | v = 'true' if v else 'false'
73 | elif isinstance(v, numbers.Number):
74 | pass
75 | else:
76 | if isinstance(v, (dict, object)):
77 | v = str(v)
78 | v = '"%s"' % v.replace('"', '\\"')
79 | outarr.append('%s=%s' % (k, v))
80 | return ' '.join(outarr)
81 |
82 | def format(self, record):
83 | return ' '.join(
84 | [
85 | 'at=%s' % record.levelname,
86 | 'msg="%s"' % record.getMessage().replace('"', '\\"'),
87 | 'process=%s' % record.processName,
88 | self.format_line(getattr(record, 'context', {})),
89 | ]
90 | ).strip()
91 |
92 |
93 | class LoggerLogfmt(logging.Logger):
94 | def makeRecord(
95 | self,
96 | name,
97 | level,
98 | fn,
99 | lno,
100 | msg,
101 | args,
102 | exc_info,
103 | func=None,
104 | extra=None,
105 | sinfo=None
106 | ):
107 | factory = logging.getLogRecordFactory()
108 | rv = factory(name, level, fn, lno, msg, args, exc_info, func, sinfo)
109 | if extra is not None:
110 | rv.__dict__['context'] = dict(**extra)
111 | return rv
112 |
113 |
114 | class LoggerAdapter(logging.LoggerAdapter):
115 | def process(self, msg, kwargs):
116 | _extra = kwargs.get('extra', {})
117 | kwargs["extra"] = dict(**self.extra)
118 | kwargs["extra"].update(**_extra)
119 | return msg, kwargs
120 |
121 |
122 | class LoggingError(PluginError):
123 | pass
124 |
125 |
126 | @enum.unique
127 | class LoggingStyle(str, enum.Enum):
128 | logfmt = 'logfmt'
129 | logjson = 'json'
130 | logtxt = 'txt'
131 |
132 |
133 | @enum.unique
134 | class LoggingHandlerType(str, enum.Enum):
135 | loglist = 'list'
136 | logstdout = 'stdout'
137 |
138 |
139 | class LoggingSettings(PluginSettings):
140 | logging_level: int = logging.WARNING
141 | logging_style: LoggingStyle = LoggingStyle.logtxt
142 | logging_handler: LoggingHandlerType = LoggingHandlerType.logstdout
143 | logging_fmt: typing.Optional[str] = None
144 |
145 |
146 | class LoggingPlugin(Plugin, ControlHealthMixin):
147 | DEFAULT_CONFIG_CLASS: pydantic_settings.BaseSettings = LoggingSettings
148 |
149 | def _create_logger(
150 | self,
151 | name: str,
152 | config: pydantic_settings.BaseSettings=None
153 | ) -> logging.Logger:
154 | logger_klass = None
155 | #
156 | if config.logging_handler == LoggingHandlerType.logstdout:
157 | handler = logging.StreamHandler(stream=sys.stdout)
158 | elif config.logging_handler == LoggingHandlerType.loglist:
159 | handler = QueueHandler()
160 | #
161 | if config.logging_style == LoggingStyle.logtxt:
162 | formatter = logging.Formatter(fmt=config.logging_fmt)
163 | elif config.logging_style == LoggingStyle.logfmt:
164 | formatter = LogfmtFormatter()
165 | logger_klass = LoggerLogfmt
166 | elif config.logging_style == LoggingStyle.logjson:
167 | formatter = JsonFormatter()
168 | else:
169 | raise LoggingError(
170 | 'unknown logging format style %s' % config.logging_style
171 | )
172 | if logger_klass is not None:
173 | _original_logger_klass = logging.getLoggerClass()
174 | try:
175 | logging.setLoggerClass(logger_klass)
176 | logger = logging.getLogger(name)
177 | finally:
178 | logging.setLoggerClass(_original_logger_klass)
179 | else:
180 | logger = logging.getLogger(name)
181 | #
182 | logger.setLevel(config.logging_level)
183 | handler.setLevel(config.logging_level)
184 | handler.setFormatter(formatter)
185 | logger.addHandler(handler)
186 | return logger
187 |
188 | def _on_init(self) -> None:
189 | self.config = None
190 | self.logger = None
191 |
192 | async def _on_call(self) -> logging.Logger:
193 | if self.logger is None:
194 | raise LoggingError('Logging is not initialized')
195 | return self.logger
196 |
197 | async def init_app(
198 | self,
199 | app: fastapi.FastAPI,
200 | config: pydantic_settings.BaseSettings=None,
201 | *,
202 | name: str=None
203 | ) -> None:
204 | self.config = config or self.DEFAULT_CONFIG_CLASS()
205 | if self.config is None:
206 | raise LoggingError('Logging configuration is not initialized')
207 | elif not isinstance(self.config, self.DEFAULT_CONFIG_CLASS):
208 | raise LoggingError('Logging configuration is not valid')
209 | name = name if name else __name__.split('.')[0]
210 | self.logger = self._create_logger(name, self.config)
211 | app.state.PLUGIN_LOGGER = self
212 |
213 | async def init(self) -> None:
214 | self.logger.info('Logging plugin is ON')
215 |
216 | async def terminate(self) -> None:
217 | self.logger.info('Logging plugin is OFF')
218 | self.config = None
219 | self.logger = None
220 |
221 | async def health(self) -> typing.Dict:
222 | return dict(level=self.logger.level, style=self.config.logging_style)
223 |
224 |
225 | log_plugin = LoggingPlugin()
226 |
227 |
228 | async def log_adapter(
229 | logger: typing.Union[logging.Logger, LoggerAdapter],
230 | extra: typing.Dict=None
231 | ) -> LoggerAdapter:
232 | if extra is None:
233 | extra = {}
234 | if isinstance(logger, logging.Logger):
235 | return LoggerAdapter(logger, extra)
236 | else:
237 | _extra = dict(**logger.extra)
238 | _extra.update(**extra)
239 | return LoggerAdapter(logger.logger, _extra)
240 |
241 |
242 | async def depends_logging(
243 | conn: starlette.requests.HTTPConnection
244 | ) -> logging.Logger:
245 | return await conn.app.state.PLUGIN_LOGGER()
246 |
247 |
248 | TLoggerPlugin = Annotated[logging.Logger, fastapi.Depends(depends_logging)]
249 |
--------------------------------------------------------------------------------
/fastapi_plugins/memcached.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.memcached
4 |
5 | from __future__ import absolute_import
6 |
7 | import typing
8 |
9 | try:
10 | import aiomcache
11 | except ImportError:
12 | raise RuntimeError('aiomcache is not installed')
13 |
14 | import fastapi
15 | import pydantic_settings
16 | import starlette.requests
17 | import tenacity
18 |
19 | from .plugin import PluginError
20 | from .plugin import PluginSettings
21 | from .plugin import Plugin
22 |
23 | from .control import ControlHealthMixin
24 | from .utils import Annotated
25 |
26 | __all__ = [
27 | 'MemcachedError', 'MemcachedSettings', 'MemcachedClient',
28 | 'MemcachedPlugin', 'memcached_plugin', 'depends_memcached',
29 | 'TMemcachedPlugin'
30 | ]
31 |
32 |
33 | class MemcachedError(PluginError):
34 | pass
35 |
36 |
37 | class MemcachedSettings(PluginSettings):
38 | memcached_host: str = 'localhost'
39 | memcached_port: int = 11211
40 | memcached_pool_size: int = 10
41 | memcached_pool_minsize: int = 1
42 | #
43 | # TODO: xxx - should be shared across caches
44 | memcached_prestart_tries: int = 60 * 5 # 5 min
45 | memcached_prestart_wait: int = 1 # 1 second
46 |
47 |
48 | class MemcachedClient(aiomcache.Client):
49 | async def ping(self) -> bytes:
50 | return await self.version()
51 |
52 |
53 | class MemcachedPlugin(Plugin, ControlHealthMixin):
54 | DEFAULT_CONFIG_CLASS = MemcachedSettings
55 |
56 | def _on_init(self) -> None:
57 | self.memcached: MemcachedClient = None
58 |
59 | async def _on_call(self) -> MemcachedClient:
60 | if self.memcached is None:
61 | raise MemcachedError('Memcached is not initialized')
62 | return self.memcached
63 |
64 | async def init_app(
65 | self,
66 | app: fastapi.FastAPI,
67 | config: pydantic_settings.BaseSettings=None
68 | ) -> None:
69 | self.config = config or self.DEFAULT_CONFIG_CLASS()
70 | if self.config is None:
71 | raise MemcachedError('Memcached configuration is not initialized')
72 | elif not isinstance(self.config, self.DEFAULT_CONFIG_CLASS):
73 | raise MemcachedError('Memcached configuration is not valid')
74 | app.state.MEMCACHED = self
75 |
76 | async def init(self):
77 | if self.memcached is not None:
78 | raise MemcachedError('Memcached is already initialized')
79 | self.memcached = MemcachedClient(
80 | host=self.config.memcached_host,
81 | port=self.config.memcached_port,
82 | pool_size=2,
83 | pool_minsize=None
84 | )
85 |
86 | @tenacity.retry(
87 | stop=tenacity.stop_after_attempt(
88 | self.config.memcached_prestart_tries
89 | ),
90 | wait=tenacity.wait_fixed(
91 | self.config.memcached_prestart_wait
92 | ),
93 | )
94 | async def _init_memcached():
95 | await self.memcached.version()
96 |
97 | try:
98 | await _init_memcached()
99 | except Exception as e:
100 | raise MemcachedError(
101 | 'Memcached initialization failed :: %s :: %s' % (type(e), e)
102 | )
103 |
104 | async def terminate(self):
105 | self.config = None
106 | if self.memcached is not None:
107 | await self.memcached.flush_all()
108 | await self.memcached.close()
109 | self.memcached = None
110 |
111 | async def health(self) -> typing.Dict:
112 | return dict(
113 | host=self.config.memcached_host,
114 | port=self.config.memcached_port,
115 | version=(await self.memcached.ping()).decode()
116 | )
117 |
118 |
119 | memcached_plugin = MemcachedPlugin()
120 |
121 |
122 | async def depends_memcached(
123 | conn: starlette.requests.HTTPConnection
124 | ) -> MemcachedClient:
125 | return await conn.app.state.MEMCACHED()
126 |
127 |
128 | TMemcachedPlugin = Annotated[MemcachedClient, fastapi.Depends(depends_memcached)] # noqa E501
129 |
--------------------------------------------------------------------------------
/fastapi_plugins/middleware.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.middleware
4 |
5 | from __future__ import absolute_import
6 |
7 | import typing
8 |
9 | import fastapi
10 | import starlette.middleware.cors
11 |
12 |
13 | def register_middleware(
14 | app: fastapi.FastAPI,
15 | middleware: typing.List[typing.Tuple[type, typing.Any]]=None
16 | ) -> fastapi.FastAPI:
17 | if not middleware:
18 | middleware = [
19 | (
20 | starlette.middleware.cors.CORSMiddleware,
21 | dict(
22 | allow_origins=["*"],
23 | allow_credentials=True,
24 | allow_methods=["*"],
25 | allow_headers=["*"]
26 | )
27 | )
28 | ]
29 | for mw_klass, options in middleware:
30 | app.add_middleware(mw_klass, **options)
31 | return app
32 |
--------------------------------------------------------------------------------
/fastapi_plugins/plugin.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.plugin
4 |
5 | from __future__ import absolute_import
6 |
7 | import abc
8 | import typing
9 |
10 | import fastapi
11 | import pydantic_settings
12 |
13 |
14 | class PluginError(Exception):
15 | pass
16 |
17 |
18 | class PluginSettings(pydantic_settings.BaseSettings):
19 | model_config = pydantic_settings.SettingsConfigDict(
20 | env_prefix='',
21 | use_enum_values=True
22 | )
23 |
24 |
25 | class Plugin:
26 | DEFAULT_CONFIG_CLASS: pydantic_settings.BaseSettings = None
27 |
28 | def __init__(
29 | self,
30 | app: fastapi.FastAPI=None,
31 | config: pydantic_settings.BaseSettings=None
32 | ):
33 | self._on_init()
34 | if app and config:
35 | self.init_app(app, config)
36 |
37 | def __call__(self) -> typing.Any:
38 | return self._on_call()
39 |
40 | def _on_init(self) -> None:
41 | pass
42 |
43 | # def is_initialized(self) -> bool:
44 | # raise NotImplementedError('implement is_initialied()')
45 |
46 | @abc.abstractmethod
47 | async def _on_call(self) -> typing.Any:
48 | raise NotImplementedError('implement _on_call()')
49 |
50 | async def init_app(
51 | self,
52 | app: fastapi.FastAPI,
53 | config: pydantic_settings.BaseSettings=None,
54 | *args,
55 | **kwargs
56 | ) -> None:
57 | pass
58 |
59 | async def init(self):
60 | pass
61 |
62 | async def terminate(self):
63 | pass
64 |
--------------------------------------------------------------------------------
/fastapi_plugins/scheduler.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.scheduler
4 |
5 | from __future__ import absolute_import
6 |
7 | import typing
8 |
9 | import aiojobs
10 | import fastapi
11 | import pydantic_settings
12 | import starlette.requests
13 |
14 | from .plugin import PluginError
15 | from .plugin import PluginSettings
16 | from .plugin import Plugin
17 |
18 | from .control import ControlHealthMixin
19 | from .utils import Annotated
20 | from .version import VERSION
21 |
22 | __all__ = [
23 | 'SchedulerError', 'SchedulerSettings', 'SchedulerPlugin',
24 | 'scheduler_plugin', 'depends_scheduler', 'TSchedulerPlugin'
25 | # 'MadnessScheduler'
26 | ]
27 | __author__ = 'madkote '
28 | __version__ = '.'.join(str(x) for x in VERSION)
29 | __copyright__ = 'Copyright 2021, madkote'
30 |
31 |
32 | class SchedulerError(PluginError):
33 | pass
34 |
35 |
36 | # class MadnessScheduler(aiojobs.Scheduler):
37 | # def __init__(self, *args, **kwargs):
38 | # super(MadnessScheduler, self).__init__(*args, **kwargs)
39 | # self._job_map = {}
40 | #
41 | # async def spawn(self, coro):
42 | # job = await aiojobs.Scheduler.spawn(self, coro)
43 | # self._job_map[job.id] = job
44 | # return job
45 | #
46 | # def _done(self, job) -> None:
47 | # if job.id in self._job_map:
48 | # self._job_map.pop(job.id)
49 | # return aiojobs.Scheduler._done(self, job)
50 | #
51 | # async def cancel_job(self, job_id: str) -> None:
52 | # if job_id in self._job_map:
53 | # await self._job_map[job_id].close()
54 | #
55 | #
56 | # async def create_madness_scheduler(
57 | # *args,
58 | # close_timeout=0.1,
59 | # limit=100,
60 | # pending_limit=10000,
61 | # exception_handler=None
62 | # ) -> MadnessScheduler:
63 | # if exception_handler is not None and not callable(exception_handler):
64 | # raise TypeError(
65 | # 'A callable object or None is expected, got {!r}'.format(
66 | # exception_handler
67 | # )
68 | # )
69 | # loop = asyncio.get_event_loop()
70 | # return MadnessScheduler(
71 | # loop=loop,
72 | # close_timeout=close_timeout,
73 | # limit=limit,
74 | # pending_limit=pending_limit,
75 | # exception_handler=exception_handler
76 | # )
77 |
78 |
79 | # TODO: test settings (values)
80 | # TODO: write unit tests
81 | class SchedulerSettings(PluginSettings):
82 | aiojobs_close_timeout: float = 0.1
83 | aiojobs_limit: int = 100
84 | aiojobs_pending_limit: int = 10000
85 | # aiojobs_enable_cancel: bool = False
86 |
87 |
88 | class SchedulerPlugin(Plugin, ControlHealthMixin):
89 | DEFAULT_CONFIG_CLASS = SchedulerSettings
90 |
91 | def _on_init(self) -> None:
92 | self.scheduler: aiojobs.Scheduler = None
93 |
94 | async def _on_call(self) -> aiojobs.Scheduler:
95 | if self.scheduler is None:
96 | raise SchedulerError('Scheduler is not initialized')
97 | return self.scheduler
98 |
99 | async def init_app(
100 | self,
101 | app: fastapi.FastAPI,
102 | config: pydantic_settings.BaseSettings=None
103 | ) -> None:
104 | self.config = config or self.DEFAULT_CONFIG_CLASS()
105 | if self.config is None:
106 | raise SchedulerError('Scheduler configuration is not initialized')
107 | elif not isinstance(self.config, self.DEFAULT_CONFIG_CLASS):
108 | raise SchedulerError('Scheduler configuration is not valid')
109 | app.state.AIOJOBS_SCHEDULER = self
110 |
111 | async def init(self):
112 | if self.scheduler is not None:
113 | raise SchedulerError('Scheduler is already initialized')
114 | self.scheduler = aiojobs.Scheduler(
115 | close_timeout=self.config.aiojobs_close_timeout,
116 | limit=self.config.aiojobs_limit,
117 | pending_limit=self.config.aiojobs_pending_limit
118 | )
119 |
120 | async def terminate(self):
121 | self.config = None
122 | if self.scheduler is not None:
123 | await self.scheduler.close()
124 | self.scheduler = None
125 |
126 | async def health(self) -> typing.Dict:
127 | return dict(
128 | jobs=len(self.scheduler),
129 | active=self.scheduler.active_count,
130 | pending=self.scheduler.pending_count,
131 | limit=self.scheduler.limit,
132 | closed=self.scheduler.closed
133 | )
134 |
135 |
136 | scheduler_plugin = SchedulerPlugin()
137 |
138 |
139 | async def depends_scheduler(
140 | conn: starlette.requests.HTTPConnection
141 | ) -> aiojobs.Scheduler:
142 | return await conn.app.state.AIOJOBS_SCHEDULER()
143 |
144 |
145 | TSchedulerPlugin = Annotated[aiojobs.Scheduler, fastapi.Depends(depends_scheduler)] # noqa E501
146 |
--------------------------------------------------------------------------------
/fastapi_plugins/settings.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.settings
4 |
5 | from __future__ import absolute_import
6 |
7 | import functools
8 | import typing
9 |
10 | import fastapi
11 | import pydantic_settings
12 | import starlette.config
13 |
14 | from .plugin import PluginError
15 | from .plugin import Plugin
16 | from .utils import Annotated
17 | from .version import VERSION
18 |
19 | __all__ = [
20 | 'ConfigError', 'ConfigPlugin', 'depends_config', 'config_plugin',
21 | 'TConfigPlugin',
22 | #
23 | 'register_config',
24 | 'register_config_docker', 'register_config_local', 'register_config_test',
25 | # 'register_config_by_name',
26 | 'reset_config', 'get_config',
27 | #
28 | 'registered_configuration', 'registered_configuration_docker',
29 | 'registered_configuration_local', 'registered_configuration_test',
30 | # 'registered_configuration_by_name',
31 | #
32 | 'DEFAULT_CONFIG_ENVVAR', 'DEFAULT_CONFIG_NAME',
33 | 'CONFIG_NAME_DEFAULT', 'CONFIG_NAME_DOCKER', 'CONFIG_NAME_LOCAL',
34 | 'CONFIG_NAME_TEST',
35 | ]
36 | __author__ = 'madkote '
37 | __version__ = '.'.join(str(x) for x in VERSION)
38 | __copyright__ = 'Copyright 2021, madkote RES'
39 |
40 | DEFAULT_CONFIG_ENVVAR: str = 'CONFIG_NAME'
41 | DEFAULT_CONFIG_NAME: str = 'docker'
42 |
43 | CONFIG_NAME_DEFAULT = DEFAULT_CONFIG_NAME
44 | CONFIG_NAME_DOCKER = DEFAULT_CONFIG_NAME
45 | CONFIG_NAME_LOCAL = 'local'
46 | CONFIG_NAME_TEST = 'test'
47 |
48 |
49 | class ConfigError(PluginError):
50 | pass
51 |
52 |
53 | class ConfigManager(object):
54 | def __init__(self):
55 | self._settings_map = {}
56 |
57 | def register(
58 | self,
59 | name: str,
60 | config: pydantic_settings.BaseSettings
61 | ) -> None:
62 | self._settings_map[name] = config
63 |
64 | def reset(self) -> None:
65 | self._settings_map.clear()
66 |
67 | def get_config(
68 | self,
69 | config_or_name: typing.Union[str, pydantic_settings.BaseSettings]=None, # noqa E501
70 | config_name_default: str=DEFAULT_CONFIG_NAME,
71 | config_name_envvar: str=DEFAULT_CONFIG_ENVVAR
72 | ) -> pydantic_settings.BaseSettings:
73 | if isinstance(config_or_name, pydantic_settings.BaseSettings):
74 | return config_or_name
75 | if not config_or_name:
76 | config_or_name = config_name_default
77 | base_cfg = starlette.config.Config()
78 | config_or_name = base_cfg(
79 | config_name_envvar,
80 | cast=str,
81 | default=config_or_name
82 | )
83 | if config_or_name not in self._settings_map:
84 | raise ConfigError('Unknown configuration "%s"' % config_or_name)
85 | return self._settings_map[config_or_name]()
86 |
87 |
88 | _manager = ConfigManager()
89 |
90 |
91 | def register_config(
92 | config: pydantic_settings.BaseSettings,
93 | name: str=None
94 | ) -> None:
95 | if not name:
96 | name = CONFIG_NAME_DEFAULT
97 | _manager.register(name, config)
98 |
99 |
100 | def register_config_docker(config: pydantic_settings.BaseSettings) -> None:
101 | _manager.register(CONFIG_NAME_DOCKER, config)
102 |
103 |
104 | def register_config_local(config: pydantic_settings.BaseSettings) -> None:
105 | _manager.register(CONFIG_NAME_LOCAL, config)
106 |
107 |
108 | def register_config_test(config: pydantic_settings.BaseSettings) -> None:
109 | _manager.register(CONFIG_NAME_TEST, config)
110 |
111 |
112 | # def registered_configuration(cls=None, /, *, name: str=None):
113 | def registered_configuration(cls=None, *, name: str=None):
114 | if not name:
115 | name = CONFIG_NAME_DEFAULT
116 |
117 | def wrap(kls):
118 | _manager.register(name, kls)
119 | return kls
120 |
121 | if cls is None:
122 | return wrap
123 | return wrap(cls)
124 |
125 |
126 | def registered_configuration_docker(cls=None):
127 | def wrap(kls):
128 | _manager.register(CONFIG_NAME_DOCKER, kls)
129 | return kls
130 | if cls is None:
131 | return wrap
132 | return wrap(cls)
133 |
134 |
135 | def registered_configuration_local(cls=None):
136 | def wrap(kls):
137 | _manager.register(CONFIG_NAME_LOCAL, kls)
138 | return kls
139 | if cls is None:
140 | return wrap
141 | return wrap(cls)
142 |
143 |
144 | def registered_configuration_test(cls=None):
145 | def wrap(kls):
146 | _manager.register(CONFIG_NAME_TEST, kls)
147 | return kls
148 | if cls is None:
149 | return wrap
150 | return wrap(cls)
151 |
152 |
153 | def reset_config() -> None:
154 | _manager.reset()
155 |
156 |
157 | @functools.lru_cache()
158 | def get_config(
159 | config_or_name: typing.Union[str, pydantic_settings.BaseSettings]=None,
160 | config_name_default: str=DEFAULT_CONFIG_NAME,
161 | config_name_envvar: str=DEFAULT_CONFIG_ENVVAR
162 | ) -> pydantic_settings.BaseSettings:
163 | return _manager.get_config(
164 | config_or_name=config_or_name,
165 | config_name_default=config_name_default,
166 | config_name_envvar=config_name_envvar
167 | )
168 |
169 |
170 | class ConfigPlugin(Plugin):
171 | DEFAULT_CONFIG_CLASS = pydantic_settings.BaseSettings
172 |
173 | async def _on_call(self) -> pydantic_settings.BaseSettings:
174 | return self.config
175 |
176 | async def init_app(
177 | self,
178 | app: fastapi.FastAPI,
179 | config: pydantic_settings.BaseSettings=None,
180 | ) -> None:
181 | self.config = config or self.DEFAULT_CONFIG_CLASS()
182 | app.state.PLUGIN_CONFIG = self
183 |
184 |
185 | config_plugin = ConfigPlugin()
186 |
187 |
188 | async def depends_config(
189 | conn: starlette.requests.HTTPConnection
190 | ) -> pydantic_settings.BaseSettings:
191 | return await conn.app.state.PLUGIN_CONFIG()
192 |
193 |
194 | TConfigPlugin = Annotated[pydantic_settings.BaseSettings, fastapi.Depends(depends_config)] # noqa E501
195 |
--------------------------------------------------------------------------------
/fastapi_plugins/utils.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.utils
4 |
5 | from __future__ import absolute_import
6 |
7 | import sys
8 |
9 | if sys.version_info >= (3, 9):
10 | from typing import Annotated # noqa
11 | else:
12 | from typing_extensions import Annotated # noqa
13 |
--------------------------------------------------------------------------------
/fastapi_plugins/version.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # fastapi_plugins.version
4 |
5 | VERSION = (0, 13, 2)
6 |
7 | __version__ = '.'.join(str(x) for x in VERSION)
8 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | .[dev]
--------------------------------------------------------------------------------
/scripts/demo_app.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # scripts.demo_app
4 | '''
5 | uvicorn demo_app:app
6 |
7 | make docker-up-dev
8 | uvicorn --host 0.0.0.0 scripts.demo_app:app
9 | CONTROL_ROUTER_PREFIX= uvicorn --host 0.0.0.0 scripts.demo_app:app
10 | '''
11 |
12 | from __future__ import absolute_import
13 |
14 | import asyncio
15 | import contextlib
16 | import logging
17 | import typing
18 | import uuid
19 |
20 | import fastapi
21 | import pydantic_settings
22 | import starlette.status
23 |
24 | try:
25 | import fastapi_plugins
26 | except ImportError:
27 | import os
28 | import sys
29 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # noqa E501
30 | import fastapi_plugins
31 |
32 | from fastapi_plugins.memcached import (
33 | MemcachedSettings, memcached_plugin, TMemcachedPlugin
34 | )
35 |
36 |
37 | class OtherSettings(pydantic_settings.BaseSettings):
38 | other: str = 'other'
39 |
40 |
41 | @fastapi_plugins.registered_configuration
42 | class AppSettings(
43 | OtherSettings,
44 | fastapi_plugins.ControlSettings,
45 | fastapi_plugins.RedisSettings,
46 | fastapi_plugins.SchedulerSettings,
47 | fastapi_plugins.LoggingSettings,
48 | MemcachedSettings,
49 | ):
50 | api_name: str = str(__name__)
51 | logging_level: int = logging.DEBUG
52 |
53 |
54 | @fastapi_plugins.registered_configuration(name='sentinel')
55 | class AppSettingsSentinel(AppSettings):
56 | redis_type: fastapi_plugins.RedisType = fastapi_plugins.RedisType.sentinel
57 | redis_sentinels: str = 'localhost:26379'
58 | memcached_host: str = 'localhost'
59 |
60 |
61 | # @fastapi_plugins.registered_configuration_local
62 | # class AppSettingsLocal(AppSettings):
63 | # memcached_host: str = 'memcached'
64 | # redis_sentinels = 'redis-sentinel:26379'
65 |
66 |
67 | @contextlib.asynccontextmanager
68 | async def lifespan(app: fastapi.FastAPI):
69 | config = fastapi_plugins.get_config()
70 |
71 | await fastapi_plugins.config_plugin.init_app(app, config)
72 | await fastapi_plugins.config_plugin.init()
73 | await fastapi_plugins.log_plugin.init_app(app, config, name=__name__)
74 | await fastapi_plugins.log_plugin.init()
75 | await memcached_plugin.init_app(app, config)
76 | await memcached_plugin.init()
77 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
78 | await fastapi_plugins.redis_plugin.init()
79 | await fastapi_plugins.scheduler_plugin.init_app(app=app, config=config)
80 | await fastapi_plugins.scheduler_plugin.init()
81 | await fastapi_plugins.control_plugin.init_app(
82 | app,
83 | config=config,
84 | version=fastapi_plugins.__version__,
85 | environ=config.model_dump()
86 | )
87 | await fastapi_plugins.control_plugin.init()
88 | yield
89 | await fastapi_plugins.control_plugin.terminate()
90 | await fastapi_plugins.scheduler_plugin.terminate()
91 | await fastapi_plugins.redis_plugin.terminate()
92 | await memcached_plugin.terminate()
93 | await fastapi_plugins.log_plugin.terminate()
94 | await fastapi_plugins.config_plugin.terminate()
95 |
96 |
97 | app = fastapi_plugins.register_middleware(fastapi.FastAPI(lifespan=lifespan))
98 |
99 |
100 | @app.get("/")
101 | async def root_get(
102 | cache: fastapi_plugins.TRedisPlugin,
103 | conf: fastapi_plugins.TConfigPlugin,
104 | logger: fastapi_plugins.TLoggerPlugin
105 | ) -> typing.Dict:
106 | ping = await cache.ping()
107 | logger.debug('root_get', extra=dict(ping=ping, api_name=conf.api_name))
108 | return dict(ping=ping, api_name=conf.api_name)
109 |
110 |
111 | @app.post("/jobs/schedule/")
112 | async def job_post(
113 | cache: fastapi_plugins.TRedisPlugin,
114 | scheduler: fastapi_plugins.TSchedulerPlugin,
115 | logger: fastapi_plugins.TLoggerPlugin,
116 | timeout: int=fastapi.Query(..., title='the job sleep time')
117 | ) -> str:
118 | async def coro(job_id, timeout, cache):
119 | await cache.set(job_id, 'processing')
120 | try:
121 | await asyncio.sleep(timeout)
122 | if timeout == 8:
123 | logger.critical('Ugly erred job %s' % job_id)
124 | raise Exception('ugly error')
125 | except asyncio.CancelledError:
126 | await cache.set(job_id, 'canceled')
127 | logger.warning('Cancel job %s' % job_id)
128 | except Exception:
129 | await cache.set(job_id, 'erred')
130 | logger.error('Erred job %s' % job_id)
131 | else:
132 | await cache.set(job_id, 'success')
133 | logger.info('Done job %s' % job_id)
134 |
135 | job_id = str(uuid.uuid4()).replace('-', '')
136 | logger = await fastapi_plugins.log_adapter(logger, extra=dict(job_id=job_id, timeout=timeout)) # noqa E501
137 | logger.info('New job %s' % job_id)
138 | await cache.set(job_id, 'pending')
139 | logger.debug('Pending job %s' % job_id)
140 | await scheduler.spawn(coro(job_id, timeout, cache))
141 | return job_id
142 |
143 |
144 | @app.get("/jobs/status/")
145 | async def job_get(
146 | cache: fastapi_plugins.TRedisPlugin,
147 | job_id: str=fastapi.Query(..., title='the job id')
148 | ) -> typing.Dict:
149 | status = await cache.get(job_id)
150 | if status is None:
151 | raise fastapi.HTTPException(
152 | status_code=starlette.status.HTTP_404_NOT_FOUND,
153 | detail='Job %s not found' % job_id
154 | )
155 | return dict(job_id=job_id, status=status)
156 |
157 |
158 | @app.post("/memcached/demo/")
159 | async def memcached_demo_post(
160 | cache: TMemcachedPlugin,
161 | key: str=fastapi.Query(..., title='the job id')
162 | ) -> typing.Dict:
163 | await cache.set(key.encode(), str(key + '_value').encode())
164 | value = await cache.get(key.encode())
165 | return dict(ping=(await cache.ping()).decode(), key=key, value=value)
166 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # setup
4 |
5 | import codecs
6 | import importlib.util
7 | import os
8 | import sys
9 |
10 | from setuptools import setup
11 | from setuptools import find_packages
12 |
13 | __author__ = 'madkote '
14 | __copyright__ = 'Copyright 2023, madkote'
15 |
16 |
17 | if sys.version_info < (3, 8, 0):
18 | raise RuntimeError("Python 3.8+ required")
19 |
20 | with open("README.md", "r") as fh:
21 | long_description = fh.read()
22 |
23 | with open("CHANGES.md", "r") as fh:
24 | changes_description = fh.read()
25 |
26 |
27 | def get_version(package_name):
28 | try:
29 | version_file = os.path.join(
30 | os.path.dirname(__file__),
31 | package_name,
32 | 'version.py'
33 | )
34 | spec = importlib.util.spec_from_file_location(
35 | '%s.version' % package_name,
36 | version_file
37 | )
38 | version_module = importlib.util.module_from_spec(spec)
39 | spec.loader.exec_module(version_module)
40 | package_version = version_module.__version__
41 | except Exception as e:
42 | raise ValueError(
43 | 'can not determine "%s" version: %s :: %s' % (
44 | package_name, type(e), e
45 | )
46 | )
47 | else:
48 | return package_version
49 |
50 |
51 | def load_requirements(filename):
52 | def _is_req(s):
53 | return s and not s.startswith('-r ') and not s.startswith('git+http') and not s.startswith('#') # noqa E501
54 |
55 | def _read():
56 | with codecs.open(filename, 'r', encoding='utf-8-sig') as fh:
57 | for line in fh:
58 | line = line.strip()
59 | if _is_req(line):
60 | yield line
61 | return sorted(list(_read()))
62 |
63 |
64 | def package_files(directory=None):
65 | paths = []
66 | if directory:
67 | for (path, _, filenames) in os.walk(directory):
68 | for filename in filenames:
69 | paths.append(os.path.join('..', path, filename))
70 | return paths
71 |
72 |
73 | NAME = 'fastapi-plugins'
74 | NAME_PACKAGE = NAME.replace('-', '_')
75 | VERSION = get_version(NAME_PACKAGE)
76 | DESCRIPTION = 'Plugins for FastAPI framework'
77 | URL = 'https://github.com/madkote/%s' % NAME
78 |
79 | REQUIRES_INSTALL = [
80 | 'fastapi>=0.100.0',
81 | 'pydantic>=2.0.0',
82 | 'pydantic-settings>=2.0.0',
83 | 'tenacity>=8.0.0',
84 | #
85 | 'python-json-logger>=2.0.0',
86 | 'redis[hiredis]>=4.3.0,<5',
87 | 'aiojobs>=1.0.0'
88 | ]
89 | REQUIRES_FAKEREDIS = ['fakeredis[lua]>=1.8.0']
90 | REQUIRES_MEMCACHED = ['aiomcache>=0.7.0']
91 | REQUIRES_TESTS = [
92 | 'bandit',
93 | 'docker-compose',
94 | 'flake8',
95 | 'pytest',
96 | 'pytest-asyncio',
97 | 'pytest-cov',
98 | 'tox',
99 | 'twine',
100 | #
101 | 'fastapi[all]',
102 | 'PyYAML>=5.3.1,!=5.4.0,!=5.4.1,<6'
103 | ]
104 |
105 | REQUIRES_EXTRA = {
106 | 'all': REQUIRES_MEMCACHED,
107 | 'fakeredis': REQUIRES_FAKEREDIS,
108 | 'memcached': REQUIRES_MEMCACHED,
109 | 'dev': REQUIRES_MEMCACHED + REQUIRES_FAKEREDIS + REQUIRES_TESTS,
110 | }
111 |
112 | PACKAGES = find_packages(exclude=('scripts', 'tests'))
113 | PACKAGE_DATA = {'': []}
114 |
115 |
116 | # =============================================================================
117 | # SETUP
118 | # =============================================================================
119 | setup(
120 | name=NAME,
121 | version=VERSION,
122 | description=DESCRIPTION,
123 | author='madkote',
124 | author_email=__author__.replace('(at)', '@'),
125 | url=URL,
126 | download_url=URL + '/archive/{}.tar.gz'.format(VERSION),
127 | license='MIT License',
128 | keywords=[
129 | 'async', 'redis', 'aioredis', 'json', 'asyncio', 'plugin', 'fastapi',
130 | 'aiojobs', 'scheduler', 'starlette', 'memcached', 'aiomcache'
131 | ],
132 | install_requires=REQUIRES_INSTALL,
133 | tests_require=REQUIRES_TESTS,
134 | extras_require=REQUIRES_EXTRA,
135 | packages=PACKAGES,
136 | package_data=PACKAGE_DATA,
137 | python_requires='>=3.6.0',
138 | include_package_data=True,
139 | long_description='\n\n'.join((long_description, changes_description)),
140 | long_description_content_type='text/markdown',
141 | platforms=['any'],
142 | classifiers=[
143 | 'Programming Language :: Python',
144 | 'Programming Language :: Python :: 3',
145 | 'Programming Language :: Python :: 3.6',
146 | 'Programming Language :: Python :: 3.7',
147 | 'Programming Language :: Python :: 3.8',
148 | 'Topic :: Utilities',
149 | 'Topic :: Software Development :: Libraries',
150 | 'Topic :: Software Development :: Libraries :: Python Modules',
151 | 'Topic :: System :: Logging',
152 | 'Framework :: AsyncIO',
153 | 'Operating System :: OS Independent',
154 | 'Intended Audience :: Developers',
155 | ]
156 | )
157 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # tests.__init__
4 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # tests.conftest
4 |
5 | from __future__ import absolute_import
6 |
7 | import pytest
8 |
9 |
10 | def pytest_configure(config):
11 | config.addinivalue_line("markers", "control: tests for Control")
12 | config.addinivalue_line("markers", "scheduler: tests for Scheduler")
13 | config.addinivalue_line("markers", "memcached: tests for Memcached")
14 | config.addinivalue_line("markers", "redis: tests for Redis")
15 | config.addinivalue_line("markers", "fakeredis: tests for Fake Redis")
16 | config.addinivalue_line("markers", "sentinel: tests for Redis Sentinel")
17 | config.addinivalue_line("markers", "settings: tests for Settings and Configuration") # noqa E501
18 | config.addinivalue_line("markers", "logger: tests for Logger")
19 |
20 |
21 | @pytest.fixture(scope='session')
22 | def anyio_backend():
23 | return 'asyncio'
24 |
--------------------------------------------------------------------------------
/tests/test_control.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # tests.test_control
4 |
5 | from __future__ import absolute_import
6 |
7 | import contextlib
8 | import typing
9 |
10 | import fastapi
11 | import pydantic_settings
12 | import pytest
13 | import starlette.testclient
14 |
15 | import fastapi_plugins
16 |
17 | from fastapi_plugins.control import DEFAULT_CONTROL_VERSION
18 | from fastapi_plugins.memcached import memcached_plugin
19 | from fastapi_plugins.memcached import MemcachedSettings
20 |
21 |
22 | pytestmark = [pytest.mark.anyio, pytest.mark.control]
23 |
24 |
25 | class DummyPluginHealthOK(
26 | fastapi_plugins.Plugin,
27 | fastapi_plugins.ControlHealthMixin
28 | ):
29 | async def init_app(
30 | self,
31 | app: fastapi.FastAPI,
32 | config: pydantic_settings.BaseSettings=None, # @UnusedVariable
33 | *args, # @UnusedVariable
34 | **kwargs # @UnusedVariable
35 | ) -> None:
36 | app.state.DUMMY_PLUGIN_HEALTH_OK = self
37 |
38 | async def health(self) -> typing.Dict:
39 | return dict(dummy='OK')
40 |
41 |
42 | class DummyPluginHealthOKOnce(
43 | fastapi_plugins.Plugin,
44 | fastapi_plugins.ControlHealthMixin
45 | ):
46 | async def init_app(
47 | self,
48 | app: fastapi.FastAPI,
49 | config: pydantic_settings.BaseSettings=None, # @UnusedVariable
50 | *args, # @UnusedVariable
51 | **kwargs # @UnusedVariable
52 | ) -> None:
53 | self.counter = 0
54 | app.state.DUMMY_PLUGIN_HEALTH_OK_ONCE = self
55 |
56 | async def health(self) -> typing.Dict:
57 | if self.counter > 0:
58 | raise Exception('Health check failed')
59 | else:
60 | self.counter += 1
61 | return dict(dummy='OK')
62 |
63 |
64 | class DummyPluginHealthFail(
65 | fastapi_plugins.Plugin,
66 | fastapi_plugins.ControlHealthMixin
67 | ):
68 | async def init_app(
69 | self,
70 | app: fastapi.FastAPI,
71 | config: pydantic_settings.BaseSettings=None, # @UnusedVariable
72 | *args, # @UnusedVariable
73 | **kwargs # @UnusedVariable
74 | ) -> None:
75 | app.state.DUMMY_PLUGIN_HEALTH_FAIL = self
76 |
77 | async def health(self) -> typing.Dict:
78 | raise Exception('Health check failed')
79 |
80 |
81 | class DummyPluginHealthNotDefined(
82 | fastapi_plugins.Plugin,
83 | fastapi_plugins.ControlHealthMixin
84 | ):
85 | async def init_app(
86 | self,
87 | app: fastapi.FastAPI,
88 | config: pydantic_settings.BaseSettings=None, # @UnusedVariable
89 | *args, # @UnusedVariable
90 | **kwargs # @UnusedVariable
91 | ) -> None:
92 | app.state.DUMMY_PLUGIN_NOT_DEFINED = self
93 |
94 |
95 | class MyControlConfig(
96 | fastapi_plugins.RedisSettings,
97 | fastapi_plugins.SchedulerSettings,
98 | fastapi_plugins.ControlSettings,
99 | MemcachedSettings
100 | ):
101 | pass
102 |
103 |
104 | def make_app(config=None, version=None, environ=None, plugins=None):
105 | if plugins is None:
106 | plugins = []
107 | if config is None:
108 | config = fastapi_plugins.ControlSettings()
109 |
110 | @contextlib.asynccontextmanager
111 | async def lifespan(app: fastapi.FastAPI):
112 | for p in plugins:
113 | await p.init_app(app, config)
114 | await p.init()
115 | kwargs = {}
116 | if version:
117 | kwargs.update(**dict(version=version))
118 | if environ:
119 | kwargs.update(**dict(environ=environ))
120 | await fastapi_plugins.control_plugin.init_app(app, config, **kwargs)
121 | await fastapi_plugins.control_plugin.init()
122 | yield
123 | await fastapi_plugins.control_plugin.terminate()
124 | for p in plugins:
125 | await p.terminate()
126 |
127 | return fastapi_plugins.register_middleware(
128 | fastapi.FastAPI(lifespan=lifespan)
129 | )
130 |
131 |
132 | @pytest.fixture(params=[{}])
133 | def client(request):
134 | with starlette.testclient.TestClient(make_app(**request.param)) as c:
135 | yield c
136 |
137 |
138 | @pytest.mark.parametrize(
139 | 'kwargs, result',
140 | [
141 | pytest.param({}, {}),
142 | pytest.param(dict(environ=dict(ping='pong')), dict(ping='pong')),
143 | ]
144 | )
145 | async def test_controller_environ(kwargs, result):
146 | assert result == await fastapi_plugins.Controller(**kwargs).get_environ()
147 |
148 |
149 | async def test_controller_health():
150 | assert dict(status=True, checks=[]) == (await fastapi_plugins.Controller().get_health()).model_dump() # noqa E501
151 |
152 |
153 | @pytest.mark.parametrize(
154 | 'klass, name, status, details',
155 | [
156 | pytest.param(
157 | DummyPluginHealthOK,
158 | 'DUMMY_PLUGIN_OK',
159 | True,
160 | dict(dummy='OK')
161 | ),
162 | pytest.param(
163 | DummyPluginHealthNotDefined,
164 | 'DUMMY_PLUGIN_NOT_DEFINED',
165 | True,
166 | {}
167 | ),
168 | pytest.param(
169 | DummyPluginHealthFail,
170 | 'DUMMY_PLUGIN_HEALTH_FAIL',
171 | False,
172 | dict(error='Health check failed')
173 | ),
174 | ]
175 | )
176 | async def test_controller_health_plugin(
177 | klass, name, status, details
178 | ):
179 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
180 | config = fastapi_plugins.ControlSettings()
181 | dummy = klass()
182 | await dummy.init_app(app, config)
183 | await dummy.init()
184 | try:
185 | c = fastapi_plugins.Controller()
186 | c.plugins.append((name, dummy))
187 | assert (await c.get_health()).model_dump() == dict(
188 | status=status,
189 | checks=[
190 | dict(
191 | name=name,
192 | status=status,
193 | details=details
194 | )
195 | ]
196 | )
197 | finally:
198 | await dummy.terminate()
199 |
200 |
201 | async def test_controller_heartbeat():
202 | assert await fastapi_plugins.Controller().get_heart_beat() is True
203 |
204 |
205 | @pytest.mark.parametrize(
206 | 'kwargs, version',
207 | [
208 | pytest.param({}, DEFAULT_CONTROL_VERSION),
209 | pytest.param(dict(version='1.2.3'), '1.2.3'),
210 | ]
211 | )
212 | async def test_controller_version(kwargs, version):
213 | assert version == await fastapi_plugins.Controller(**kwargs).get_version()
214 |
215 |
216 | @pytest.mark.parametrize(
217 | 'client, result, status, endpoint',
218 | [
219 | pytest.param(
220 | {},
221 | dict(environ={}),
222 | 200,
223 | '/control/environ'
224 | ),
225 | pytest.param(
226 | dict(environ=dict(ping='pong')),
227 | dict(environ=dict(ping='pong')),
228 | 200,
229 | '/control/environ'
230 | ),
231 | pytest.param(
232 | {},
233 | {'version': DEFAULT_CONTROL_VERSION},
234 | 200,
235 | '/control/version'
236 | ),
237 | pytest.param(
238 | dict(version='1.2.3'),
239 | {'version': '1.2.3'},
240 | 200,
241 | '/control/version'
242 | ),
243 | pytest.param(
244 | {},
245 | dict(is_alive=True),
246 | 200,
247 | '/control/heartbeat'
248 | ),
249 | pytest.param(
250 | {},
251 | dict(status=True, checks=[]),
252 | 200,
253 | '/control/health'
254 | ),
255 | pytest.param(
256 | dict(
257 | plugins=[
258 | DummyPluginHealthNotDefined(),
259 | DummyPluginHealthOK()
260 | ]
261 | ),
262 | dict(
263 | status=True,
264 | checks=[
265 | dict(
266 | name='DUMMY_PLUGIN_NOT_DEFINED',
267 | status=True,
268 | details={}
269 | ),
270 | dict(
271 | name='DUMMY_PLUGIN_HEALTH_OK',
272 | status=True,
273 | details=dict(dummy='OK')
274 | )
275 | ]
276 | ),
277 | 200,
278 | '/control/health'
279 | ),
280 | pytest.param(
281 | dict(
282 | config=MyControlConfig(),
283 | plugins=[
284 | fastapi_plugins.redis_plugin,
285 | fastapi_plugins.scheduler_plugin,
286 | memcached_plugin
287 | ]
288 | ),
289 | dict(
290 | status=True,
291 | checks=[
292 | {
293 | 'name': 'REDIS',
294 | 'status': True,
295 | 'details': {
296 | 'redis_type': 'redis',
297 | 'redis_address': 'redis://localhost:6379',
298 | 'redis_pong': True
299 | }
300 | },
301 | {
302 | 'name': 'AIOJOBS_SCHEDULER',
303 | 'status': True,
304 | 'details': {
305 | 'jobs': 0,
306 | 'active': 0,
307 | 'pending': 0,
308 | 'limit': 100,
309 | 'closed': False
310 | }
311 | },
312 | {
313 | 'name': 'MEMCACHED',
314 | 'status': True,
315 | 'details': {
316 | 'host': 'localhost',
317 | 'port': 11211,
318 | 'version': '1.6.18'
319 | }
320 | }
321 | ]
322 | ),
323 | 200,
324 | '/control/health',
325 | ),
326 | pytest.param(
327 | dict(
328 | plugins=[
329 | DummyPluginHealthNotDefined(),
330 | DummyPluginHealthOK(),
331 | DummyPluginHealthOKOnce()
332 | ]
333 | ),
334 | {
335 | 'detail': {
336 | 'status': False,
337 | 'checks': [
338 | {
339 | 'name': 'DUMMY_PLUGIN_NOT_DEFINED',
340 | 'status': True,
341 | 'details': {}
342 | },
343 | {
344 | 'name': 'DUMMY_PLUGIN_HEALTH_OK',
345 | 'status': True,
346 | 'details': {'dummy': 'OK'}
347 | },
348 | {
349 | 'name': 'DUMMY_PLUGIN_HEALTH_OK_ONCE',
350 | 'status': False,
351 | 'details': {'error': 'Health check failed'}
352 | }
353 | ]
354 | }
355 | },
356 | 417,
357 | '/control/health'
358 | ),
359 | ],
360 | indirect=['client']
361 | )
362 | async def test_router(client, result, status, endpoint):
363 | response = client.get(endpoint)
364 | assert status == response.status_code
365 | assert result == response.json()
366 |
367 |
368 | async def test_router_health_with_plugins_broken_init():
369 | with pytest.raises(fastapi_plugins.ControlError):
370 | with starlette.testclient.TestClient(
371 | make_app(
372 | plugins=[
373 | DummyPluginHealthFail(),
374 | DummyPluginHealthOK()
375 | ]
376 | )
377 | ) as c:
378 | c.get('/control/version')
379 |
380 |
381 | @pytest.mark.parametrize(
382 | 'client, result, status, endpoint',
383 | [
384 | pytest.param(
385 | dict(
386 | config=fastapi_plugins.ControlSettings(
387 | control_router_prefix='outofcontrol'
388 | )
389 | ),
390 | dict(status=True, checks=[]),
391 | 200,
392 | '/outofcontrol/health'
393 | ),
394 | pytest.param(
395 | dict(
396 | config=fastapi_plugins.ControlSettings(
397 | control_router_prefix='outofcontrol'
398 | )
399 | ),
400 | {'version': DEFAULT_CONTROL_VERSION},
401 | 200,
402 | '/outofcontrol/version'
403 | ),
404 | pytest.param(
405 | dict(
406 | config=fastapi_plugins.ControlSettings(
407 | control_router_prefix='outofcontrol'
408 | ),
409 | version='1.2.3'
410 | ),
411 | {'version': '1.2.3'},
412 | 200,
413 | '/outofcontrol/version'
414 | ),
415 | ],
416 | indirect=['client']
417 | )
418 | async def test_router_prefix_custom(client, result, status, endpoint):
419 | response = client.get(endpoint)
420 | assert status == response.status_code
421 | assert result == response.json()
422 |
423 |
424 | @pytest.mark.parametrize(
425 | 'client, endpoints',
426 | [
427 | pytest.param(
428 | dict(
429 | config=fastapi_plugins.ControlSettings(
430 | control_enable_environ=False,
431 | control_enable_health=False,
432 | control_enable_heartbeat=False,
433 | control_enable_version=False
434 | )
435 | ),
436 | [
437 | (404, '/control/environ'),
438 | (404, '/control/version'),
439 | (404, '/control/heartbeat'),
440 | (404, '/control/health')
441 | ]
442 | ),
443 | pytest.param(
444 | dict(
445 | config=fastapi_plugins.ControlSettings(
446 | control_enable_environ=False,
447 | control_enable_health=True,
448 | control_enable_heartbeat=True,
449 | control_enable_version=True
450 | )
451 | ),
452 | [
453 | (404, '/control/environ'),
454 | (200, '/control/version'),
455 | (200, '/control/heartbeat'),
456 | (200, '/control/health')
457 | ]
458 | ),
459 | pytest.param(
460 | dict(
461 | config=fastapi_plugins.ControlSettings(
462 | control_enable_environ=True,
463 | control_enable_health=True,
464 | control_enable_heartbeat=True,
465 | control_enable_version=False
466 | )
467 | ),
468 | [
469 | (200, '/control/environ'),
470 | (404, '/control/version'),
471 | (200, '/control/heartbeat'),
472 | (200, '/control/health')
473 | ]
474 | ),
475 | pytest.param(
476 | dict(
477 | config=fastapi_plugins.ControlSettings(
478 | control_enable_environ=True,
479 | control_enable_health=False,
480 | control_enable_heartbeat=True,
481 | control_enable_version=True
482 | )
483 | ),
484 | [
485 | (200, '/control/environ'),
486 | (200, '/control/version'),
487 | (200, '/control/heartbeat'),
488 | (404, '/control/health')
489 | ]
490 | ),
491 | pytest.param(
492 | dict(
493 | config=fastapi_plugins.ControlSettings(
494 | control_enable_environ=True,
495 | control_enable_health=True,
496 | control_enable_heartbeat=False,
497 | control_enable_version=True
498 | )
499 | ),
500 | [
501 | (200, '/control/environ'),
502 | (200, '/control/version'),
503 | (404, '/control/heartbeat'),
504 | (200, '/control/health')
505 | ]
506 | ),
507 | ],
508 | indirect=['client']
509 | )
510 | async def test_router_disable(client, endpoints):
511 | for status, endpoint in endpoints:
512 | response = client.get(endpoint)
513 | assert status == response.status_code
514 |
--------------------------------------------------------------------------------
/tests/test_logger.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # tests.test_logger
4 |
5 | from __future__ import absolute_import
6 |
7 | import json
8 | import logging
9 |
10 | import fastapi
11 | import pytest
12 |
13 | import fastapi_plugins
14 |
15 |
16 | pytestmark = [pytest.mark.anyio, pytest.mark.logger]
17 |
18 |
19 | @pytest.fixture(params=None)
20 | async def logapp(request):
21 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
22 | await fastapi_plugins.log_plugin.init_app(
23 | app=app,
24 | config=request.param or fastapi_plugins.LoggingSettings(),
25 | name=request.node.name
26 | )
27 | await fastapi_plugins.log_plugin.init()
28 | yield app
29 | await fastapi_plugins.log_plugin.terminate()
30 |
31 |
32 | @pytest.fixture
33 | async def logapp_name(request):
34 | return request.node.name
35 |
36 |
37 | @pytest.mark.parametrize(
38 | 'logapp, level',
39 | [
40 | pytest.param(None, logging.WARNING),
41 | pytest.param(
42 | fastapi_plugins.LoggingSettings(
43 | logging_level=logging.CRITICAL
44 | ),
45 | logging.CRITICAL
46 | ),
47 | ],
48 | indirect=['logapp']
49 | )
50 | async def test_basic(logapp, level, logapp_name):
51 | logger = await fastapi_plugins.log_plugin()
52 | assert logapp_name == logger.name
53 | assert level == logger.level
54 |
55 |
56 | @pytest.mark.parametrize(
57 | 'logapp, level, result',
58 | [
59 | pytest.param(
60 | fastapi_plugins.LoggingSettings(
61 | logging_level=logging.DEBUG,
62 | logging_style=fastapi_plugins.LoggingStyle.logtxt,
63 | logging_handler=fastapi_plugins.LoggingHandlerType.loglist
64 | ),
65 | logging.DEBUG,
66 | ['Hello', 'World', 'Echo']
67 | ),
68 | pytest.param(
69 | fastapi_plugins.LoggingSettings(
70 | logging_level=logging.DEBUG,
71 | logging_style=fastapi_plugins.LoggingStyle.logjson,
72 | logging_handler=fastapi_plugins.LoggingHandlerType.loglist
73 | ),
74 | logging.DEBUG,
75 | [
76 | {"message": "Hello", "level": "DEBUG"},
77 | {"message": "World", "planet": "earth", "level": "INFO"},
78 | {"message": "Echo", "planet": "earth", "satellite": ["moon"], "level": "WARNING"} # noqa E501
79 | ]
80 | ),
81 | pytest.param(
82 | fastapi_plugins.LoggingSettings(
83 | logging_level=logging.DEBUG,
84 | logging_style=fastapi_plugins.LoggingStyle.logfmt,
85 | logging_handler=fastapi_plugins.LoggingHandlerType.loglist
86 | ),
87 | logging.DEBUG,
88 | [
89 | 'at=DEBUG msg="Hello" process=MainProcess',
90 | 'at=INFO msg="World" process=MainProcess planet="earth"',
91 | 'at=WARNING msg="Echo" process=MainProcess planet="earth" satellite="[\'moon\']"' # noqa E501
92 | ]
93 | ),
94 | ],
95 | indirect=['logapp']
96 | )
97 | async def test_format(logapp, level, result, logapp_name):
98 | def _preproc(_results):
99 | def _inner(__results):
100 | for r in __results:
101 | try:
102 | rr = json.loads(r)
103 | for k in ['timestamp', 'name', 'taskName']:
104 | try:
105 | rr.pop(k)
106 | except KeyError:
107 | pass
108 | yield rr
109 | except json.decoder.JSONDecodeError:
110 | yield r
111 | return list(_inner(_results))
112 |
113 | logger = await fastapi_plugins.log_plugin()
114 | assert logapp_name == logger.name
115 | assert level == logger.level
116 | #
117 | logger.debug('Hello')
118 | logger.info('World', extra=dict(planet='earth'))
119 | logger.warning('Echo', extra=dict(planet='earth', satellite=['moon']))
120 | h = logger.handlers[0]
121 | assert result == _preproc([r for r in h.mqueue.queue][1:])
122 |
123 |
124 | @pytest.mark.parametrize(
125 | 'logapp, level, result',
126 | [
127 | pytest.param(
128 | fastapi_plugins.LoggingSettings(
129 | logging_level=logging.DEBUG,
130 | logging_style=fastapi_plugins.LoggingStyle.logtxt,
131 | logging_handler=fastapi_plugins.LoggingHandlerType.loglist
132 | ),
133 | logging.DEBUG,
134 | ['Hello', 'World', 'Echo']
135 | ),
136 | pytest.param(
137 | fastapi_plugins.LoggingSettings(
138 | logging_level=logging.DEBUG,
139 | logging_style=fastapi_plugins.LoggingStyle.logjson,
140 | logging_handler=fastapi_plugins.LoggingHandlerType.loglist
141 | ),
142 | logging.DEBUG,
143 | [
144 | {"message": "Hello", "level": "DEBUG"},
145 | {"message": "World", "planet": "earth", "level": "INFO"},
146 | {"message": "Echo", "planet": "earth", "satellite": ["moon"], "level": "WARNING"} # noqa E501
147 | ]
148 | ),
149 | pytest.param(
150 | fastapi_plugins.LoggingSettings(
151 | logging_level=logging.DEBUG,
152 | logging_style=fastapi_plugins.LoggingStyle.logfmt,
153 | logging_handler=fastapi_plugins.LoggingHandlerType.loglist
154 | ),
155 | logging.DEBUG,
156 | [
157 | 'at=DEBUG msg="Hello" process=MainProcess',
158 | 'at=INFO msg="World" process=MainProcess planet="earth"',
159 | 'at=WARNING msg="Echo" process=MainProcess planet="earth" satellite="[\'moon\']"' # noqa E501
160 | ]
161 | ),
162 | ],
163 | indirect=['logapp']
164 | )
165 | async def test_format_adapter(logapp, level, result, logapp_name):
166 | def _preproc(_results):
167 | def _inner(__results):
168 | for r in __results:
169 | try:
170 | rr = json.loads(r)
171 | for k in ['timestamp', 'name', 'taskName']:
172 | try:
173 | rr.pop(k)
174 | except KeyError:
175 | pass
176 | yield rr
177 | except json.decoder.JSONDecodeError:
178 | yield r
179 | return list(_inner(_results))
180 |
181 | logger = await fastapi_plugins.log_plugin()
182 | assert logapp_name == logger.name
183 | assert level == logger.level
184 | #
185 | logger.debug('Hello')
186 | logger = await fastapi_plugins.log_adapter(logger, extra=dict(planet='earth')) # noqa E501
187 | logger.info('World')
188 | logger = await fastapi_plugins.log_adapter(logger, extra=dict(satellite=['moon'])) # noqa E501
189 | logger.warning('Echo')
190 | h = logger.logger.handlers[0]
191 | assert result == _preproc([r for r in h.mqueue.queue][1:])
192 |
--------------------------------------------------------------------------------
/tests/test_memcached.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # tests.test_memcached
4 |
5 | from __future__ import absolute_import
6 |
7 | import uuid
8 |
9 | import fastapi
10 | import fastapi_plugins
11 | import pytest
12 |
13 | from fastapi_plugins.memcached import memcached_plugin, MemcachedSettings
14 |
15 |
16 | pytestmark = [pytest.mark.anyio, pytest.mark.memcached]
17 |
18 |
19 | @pytest.fixture
20 | def memcache_config():
21 | return MemcachedSettings(
22 | memcached_prestart_tries=10,
23 | memcached_prestart_wait=1
24 | )
25 |
26 |
27 | @pytest.fixture
28 | async def memcache_app(memcache_config):
29 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
30 | await memcached_plugin.init_app(app=app, config=memcache_config)
31 | await memcached_plugin.init()
32 | yield app
33 | await memcached_plugin.terminate()
34 |
35 |
36 | async def test_connect(memcache_app):
37 | pass
38 |
39 |
40 | async def test_ping(memcache_app):
41 | assert (await (await memcached_plugin()).ping()) is not None
42 |
43 |
44 | async def test_health(memcache_app):
45 | assert await memcached_plugin.health() == dict(
46 | host=memcached_plugin.config.memcached_host,
47 | port=memcached_plugin.config.memcached_port,
48 | version='1.6.18'
49 | )
50 |
51 |
52 | async def test_version(memcache_app):
53 | assert await (await memcached_plugin()).version() is not None
54 |
55 |
56 | async def test_get_set(memcache_app):
57 | c = await memcached_plugin()
58 | value = str(uuid.uuid4()).encode()
59 | assert await c.set(b'x', value) is not None
60 | assert await c.get(b'x') == value
61 |
--------------------------------------------------------------------------------
/tests/test_redis.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # tests.test_redis
4 |
5 | from __future__ import absolute_import
6 |
7 | import uuid
8 |
9 | import fastapi
10 | import pytest
11 |
12 | import fastapi_plugins
13 | from fastapi_plugins._redis import RedisType
14 |
15 |
16 | pytestmark = [pytest.mark.anyio, pytest.mark.redis]
17 |
18 |
19 | @pytest.fixture(params=None)
20 | async def redisapp(request):
21 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
22 | await fastapi_plugins.redis_plugin.init_app(
23 | app=app,
24 | config=request.param or fastapi_plugins.RedisSettings(),
25 | )
26 | await fastapi_plugins.redis_plugin.init()
27 | yield app
28 | await fastapi_plugins.redis_plugin.terminate()
29 |
30 |
31 | @pytest.mark.parametrize(
32 | 'redisapp',
33 | [
34 | pytest.param(
35 | fastapi_plugins.RedisSettings(redis_url='redis://localhost:6379/1')
36 | ),
37 | pytest.param(
38 | fastapi_plugins.RedisSettings(
39 | redis_type='fakeredis',
40 | redis_url='redis://localhost:6379/1'
41 | ),
42 | marks=pytest.mark.fakeredis
43 | ),
44 | ],
45 | indirect=['redisapp']
46 | )
47 | async def test_connect_redis_url(redisapp):
48 | pass
49 |
50 |
51 | @pytest.mark.parametrize(
52 | 'redisapp',
53 | [
54 | pytest.param(fastapi_plugins.RedisSettings()),
55 | pytest.param(
56 | fastapi_plugins.RedisSettings(redis_type='fakeredis'),
57 | marks=pytest.mark.fakeredis
58 | ),
59 | pytest.param(
60 | fastapi_plugins.RedisSettings(
61 | redis_type='sentinel',
62 | redis_sentinels='localhost:26379'
63 | ),
64 | marks=pytest.mark.sentinel
65 | ),
66 | ],
67 | indirect=['redisapp']
68 | )
69 | async def test_connect(redisapp):
70 | pass
71 |
72 |
73 | @pytest.mark.parametrize(
74 | 'redisapp',
75 | [
76 | pytest.param(fastapi_plugins.RedisSettings()),
77 | pytest.param(
78 | fastapi_plugins.RedisSettings(redis_type='fakeredis'),
79 | marks=pytest.mark.fakeredis
80 | ),
81 | pytest.param(
82 | fastapi_plugins.RedisSettings(
83 | redis_type='sentinel',
84 | redis_sentinels='localhost:26379'
85 | ),
86 | marks=pytest.mark.sentinel
87 | ),
88 | ],
89 | indirect=['redisapp']
90 | )
91 | async def test_ping(redisapp):
92 | assert (await (await fastapi_plugins.redis_plugin()).ping()) is True
93 |
94 |
95 | @pytest.mark.parametrize(
96 | 'redisapp',
97 | [
98 | pytest.param(fastapi_plugins.RedisSettings()),
99 | pytest.param(
100 | fastapi_plugins.RedisSettings(redis_type='fakeredis'),
101 | marks=pytest.mark.fakeredis
102 | ),
103 | pytest.param(
104 | fastapi_plugins.RedisSettings(
105 | redis_type='sentinel',
106 | redis_sentinels='localhost:26379'
107 | ),
108 | marks=pytest.mark.sentinel
109 | ),
110 | ],
111 | indirect=['redisapp']
112 | )
113 | async def test_health(redisapp):
114 | assert await fastapi_plugins.redis_plugin.health() == dict(
115 | redis_type=fastapi_plugins.redis_plugin.config.redis_type,
116 | redis_address=fastapi_plugins.redis_plugin.config.get_sentinels() if fastapi_plugins.redis_plugin.config.redis_type == RedisType.sentinel else fastapi_plugins.redis_plugin.config.get_redis_address(), # noqa E501
117 | redis_pong=True
118 | )
119 |
120 |
121 | @pytest.mark.parametrize(
122 | 'redisapp',
123 | [
124 | pytest.param(fastapi_plugins.RedisSettings()),
125 | pytest.param(
126 | fastapi_plugins.RedisSettings(redis_type='fakeredis'),
127 | marks=pytest.mark.fakeredis
128 | ),
129 | pytest.param(
130 | fastapi_plugins.RedisSettings(
131 | redis_type='sentinel',
132 | redis_sentinels='localhost:26379'
133 | ),
134 | marks=pytest.mark.sentinel
135 | ),
136 | ],
137 | indirect=['redisapp']
138 | )
139 | async def test_get_set(redisapp):
140 | c = await fastapi_plugins.redis_plugin()
141 | value = str(uuid.uuid4())
142 | assert await c.set('x', value) is not None
143 | assert await c.get('x') == value
144 |
145 |
146 | @pytest.mark.parametrize(
147 | 'redisapp',
148 | [
149 | pytest.param(fastapi_plugins.RedisSettings(redis_ttl=61)),
150 | pytest.param(
151 | fastapi_plugins.RedisSettings(redis_type='fakeredis'),
152 | marks=pytest.mark.fakeredis
153 | ),
154 | pytest.param(
155 | fastapi_plugins.RedisSettings(
156 | redis_type='sentinel',
157 | redis_sentinels='localhost:26379'
158 | ),
159 | marks=pytest.mark.sentinel
160 | ),
161 | ],
162 | indirect=['redisapp']
163 | )
164 | async def test_get_set_ttl(redisapp):
165 | c = await fastapi_plugins.redis_plugin()
166 | value = str(uuid.uuid4())
167 | assert await c.setex('x', c.TTL, value) is not None
168 | assert await c.get('x') == value
169 | ttl = fastapi_plugins.redis_plugin.config.redis_ttl
170 | assert (ttl - 5) <= await c.ttl('x') <= ttl
171 |
172 |
173 | # def redis_must_be_running(cls):
174 | # # TODO: This SHOULD be improved
175 | # try:
176 | # r = redis.StrictRedis('localhost', port=6379)
177 | # r.ping()
178 | # except redis.ConnectionError:
179 | # redis_running = False
180 | # else:
181 | # redis_running = True
182 | # if not redis_running:
183 | # for name, attribute in inspect.getmembers(cls):
184 | # if name.startswith('test_'):
185 | # @wraps(attribute)
186 | # def skip_test(*args, **kwargs):
187 | # pytest.skip("Redis is not running.")
188 | # setattr(cls, name, skip_test)
189 | # cls.setUp = lambda x: None
190 | # cls.tearDown = lambda x: None
191 | # return cls
192 |
--------------------------------------------------------------------------------
/tests/test_scheduler.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # tests.test_scheduler
4 |
5 | from __future__ import absolute_import
6 |
7 | import asyncio
8 | import contextlib
9 | import typing
10 | import uuid
11 |
12 | import fastapi
13 | import pytest
14 | import starlette.testclient
15 |
16 | import fastapi_plugins
17 |
18 |
19 | pytestmark = [pytest.mark.anyio, pytest.mark.scheduler]
20 |
21 |
22 | def make_app(config=None):
23 | if config is None:
24 | class AppSettings(
25 | fastapi_plugins.RedisSettings,
26 | fastapi_plugins.SchedulerSettings
27 | ):
28 | api_name: str = str(__name__)
29 | config = AppSettings()
30 |
31 | @contextlib.asynccontextmanager
32 | async def lifespan(app: fastapi.FastAPI):
33 | await fastapi_plugins.redis_plugin.init_app(app, config=config)
34 | await fastapi_plugins.redis_plugin.init()
35 | await fastapi_plugins.scheduler_plugin.init_app(app, config)
36 | await fastapi_plugins.scheduler_plugin.init()
37 | yield
38 | await fastapi_plugins.scheduler_plugin.terminate()
39 | await fastapi_plugins.redis_plugin.terminate()
40 |
41 | app = fastapi_plugins.register_middleware(
42 | fastapi.FastAPI(lifespan=lifespan)
43 | )
44 |
45 | @app.post('/jobs/schedule')
46 | async def job_post(
47 | cache: fastapi_plugins.TRedisPlugin,
48 | scheduler: fastapi_plugins.TSchedulerPlugin,
49 | timeout: int=fastapi.Query(..., title='the job sleep time')
50 | ) -> str:
51 | async def coro(job_id, timeout, cache):
52 | await cache.set(job_id, 'processing')
53 | try:
54 | await asyncio.sleep(timeout)
55 | if timeout >= 3:
56 | raise Exception('ugly error')
57 | except asyncio.CancelledError:
58 | await cache.set(job_id, 'canceled')
59 | except Exception:
60 | await cache.set(job_id, 'erred')
61 | else:
62 | await cache.set(job_id, 'success')
63 |
64 | job_id = str(uuid.uuid4()).replace('-', '')
65 | await cache.set(job_id, 'pending')
66 | await scheduler.spawn(coro(job_id, timeout, cache))
67 | return job_id
68 |
69 | @app.get('/jobs/status')
70 | async def job_get(
71 | cache: fastapi_plugins.TRedisPlugin,
72 | job_id: str=fastapi.Query(..., title='the job id')
73 | ) -> typing.Dict:
74 | status = await cache.get(job_id)
75 | if status is None:
76 | raise fastapi.HTTPException(
77 | status_code=starlette.status.HTTP_404_NOT_FOUND,
78 | detail='Job %s not found' % job_id
79 | )
80 | return dict(job_id=job_id, status=status)
81 |
82 | @app.get('/demo')
83 | async def demo_get(
84 | ) -> typing.Dict:
85 | return dict(demo=123)
86 |
87 | return app
88 |
89 |
90 | @pytest.fixture(params=[{}])
91 | def client(request):
92 | with starlette.testclient.TestClient(make_app(**request.param)) as c:
93 | yield c
94 |
95 |
96 | @pytest.fixture
97 | async def schedulerapp():
98 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
99 | config = fastapi_plugins.SchedulerSettings()
100 | await fastapi_plugins.scheduler_plugin.init_app(app=app, config=config)
101 | await fastapi_plugins.scheduler_plugin.init()
102 | yield app
103 | await fastapi_plugins.scheduler_plugin.terminate()
104 |
105 |
106 | async def test_basic(schedulerapp):
107 | count = 3
108 | res = {}
109 |
110 | async def coro(name, timeout):
111 | try:
112 | await asyncio.sleep(timeout)
113 | res[name] = name
114 | except asyncio.CancelledError as e:
115 | res[name] = 'cancel'
116 | raise e
117 |
118 | s = await fastapi_plugins.scheduler_plugin()
119 | for i in range(count):
120 | await s.spawn(coro(str(i), i / 10))
121 | await asyncio.sleep(1)
122 | assert res == dict([(str(i), str(i)) for i in range(count)])
123 |
124 |
125 | async def test_health(schedulerapp):
126 | count = 3
127 | res = {}
128 |
129 | async def coro(name, timeout):
130 | try:
131 | await asyncio.sleep(timeout)
132 | res[name] = name
133 | except asyncio.CancelledError as e:
134 | res[name] = 'cancel'
135 | raise e
136 |
137 | s = await fastapi_plugins.scheduler_plugin()
138 | for i in range(count):
139 | await s.spawn(coro(str(i), i / 10))
140 | await asyncio.sleep(1)
141 | assert res == dict([(str(i), str(i)) for i in range(count)])
142 | assert await fastapi_plugins.scheduler_plugin.health() == dict(
143 | jobs=0,
144 | active=0,
145 | pending=0,
146 | limit=100,
147 | closed=False
148 | )
149 |
150 |
151 | async def test_endpoints(client):
152 | endpoint = '/demo'
153 | response = client.get(endpoint)
154 | assert 200 == response.status_code
155 | #
156 | job_timeout = 1
157 | print()
158 | print('> submit a job with timeout=%s' % job_timeout)
159 | endpoint = '/jobs/schedule?timeout=%s' % job_timeout
160 | response = client.post(endpoint)
161 | assert 200 == response.status_code
162 | job_id = response.json()
163 | assert len(job_id) > 0
164 | #
165 | print('> check the job %s' % job_id)
166 | endpoint = '/jobs/status?job_id=%s' % job_id
167 | response = client.get(endpoint)
168 | assert 200 == response.status_code
169 | assert response.json() == {'job_id': job_id, 'status': 'processing'}
170 | #
171 | tries = 10000
172 | attempt = 0
173 | endpoint = '/jobs/status?job_id=%s' % job_id
174 | while attempt < tries:
175 | response = client.get(endpoint)
176 | assert 200 == response.status_code
177 | if response.json() == {'job_id': job_id, 'status': 'success'}:
178 | break
179 | attempt += 1
180 | else:
181 | pytest.fail(f'job {job_id} with timeout {job_timeout} not finished')
182 |
--------------------------------------------------------------------------------
/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | # tests.test_settings
4 |
5 | from __future__ import absolute_import
6 |
7 | import os
8 |
9 | import fastapi
10 | import pytest
11 |
12 | import fastapi_plugins
13 |
14 | from fastapi_plugins.settings import ConfigManager
15 |
16 |
17 | pytestmark = [pytest.mark.anyio, pytest.mark.settings]
18 |
19 |
20 | @pytest.fixture
21 | def myconfig():
22 | class MyConfig(fastapi_plugins.PluginSettings):
23 | api_name: str = 'API name'
24 |
25 | return MyConfig
26 |
27 |
28 | @pytest.fixture
29 | def myconfig_name():
30 | return 'myconfig'
31 |
32 |
33 | @pytest.fixture
34 | def myconfig_name_default():
35 | return fastapi_plugins.CONFIG_NAME_DEFAULT
36 |
37 |
38 | @pytest.fixture
39 | def plugin():
40 | fastapi_plugins.reset_config()
41 | fastapi_plugins.get_config.cache_clear()
42 | yield fastapi_plugins
43 | fastapi_plugins.reset_config()
44 | fastapi_plugins.get_config.cache_clear()
45 |
46 |
47 | def test_manager_register(plugin, myconfig, myconfig_name):
48 | m = ConfigManager()
49 | m.register(myconfig_name, myconfig)
50 | assert m._settings_map == {myconfig_name: myconfig}
51 |
52 |
53 | def test_manager_reset(plugin, myconfig, myconfig_name):
54 | m = ConfigManager()
55 | m.register(myconfig_name, myconfig)
56 | assert m._settings_map == {myconfig_name: myconfig}
57 | m.reset()
58 | assert m._settings_map == {}
59 |
60 |
61 | @pytest.mark.parametrize(
62 | 'name',
63 | [
64 | pytest.param('myconfig_name'),
65 | pytest.param('myconfig_name_default'),
66 | ]
67 | )
68 | def test_manager_get_config(name, plugin, myconfig, request):
69 | name = request.getfixturevalue(name)
70 | m = ConfigManager()
71 | m.register(name, myconfig)
72 | assert m._settings_map == {name: myconfig}
73 | assert myconfig().model_dump() == m.get_config(name).model_dump()
74 |
75 |
76 | def test_manager_get_config_not_existing(plugin, myconfig, myconfig_name):
77 | m = ConfigManager()
78 | m.register(myconfig_name, myconfig)
79 | assert m._settings_map == {myconfig_name: myconfig}
80 | with pytest.raises(fastapi_plugins.ConfigError):
81 | m.get_config()
82 |
83 |
84 | def test_wrap_register_config(plugin, myconfig):
85 | fastapi_plugins.register_config(myconfig)
86 | assert myconfig().model_dump() == fastapi_plugins.get_config().model_dump()
87 |
88 |
89 | def test_wrap_register_config_docker(plugin, myconfig):
90 | # docker is default
91 | fastapi_plugins.register_config_docker(myconfig)
92 | assert myconfig().model_dump() == fastapi_plugins.get_config().model_dump()
93 | assert myconfig().model_dump() == fastapi_plugins.get_config(fastapi_plugins.CONFIG_NAME_DOCKER).model_dump() # noqa E501
94 |
95 |
96 | def test_wrap_register_config_local(plugin, myconfig):
97 | fastapi_plugins.register_config_local(myconfig)
98 | assert myconfig().model_dump() == fastapi_plugins.get_config(fastapi_plugins.CONFIG_NAME_LOCAL).model_dump() # noqa E501
99 | os.environ[fastapi_plugins.DEFAULT_CONFIG_ENVVAR] = fastapi_plugins.CONFIG_NAME_LOCAL # noqa E501
100 | try:
101 | assert myconfig().model_dump() == fastapi_plugins.get_config().model_dump() # noqa E501
102 | finally:
103 | os.environ.pop(fastapi_plugins.DEFAULT_CONFIG_ENVVAR)
104 |
105 |
106 | def test_wrap_register_config_test(plugin, myconfig):
107 | fastapi_plugins.register_config_test(myconfig)
108 | assert myconfig().model_dump() == fastapi_plugins.get_config(fastapi_plugins.CONFIG_NAME_TEST).model_dump() # noqa E501
109 | os.environ[fastapi_plugins.DEFAULT_CONFIG_ENVVAR] = fastapi_plugins.CONFIG_NAME_TEST # noqa E501
110 | try:
111 | assert myconfig().model_dump() == fastapi_plugins.get_config().model_dump() # noqa E501
112 | finally:
113 | os.environ.pop(fastapi_plugins.DEFAULT_CONFIG_ENVVAR)
114 |
115 |
116 | def test_wrap_register_config_by_name(plugin, myconfig, myconfig_name):
117 | fastapi_plugins.register_config(myconfig, name=myconfig_name)
118 | assert myconfig().model_dump() == fastapi_plugins.get_config(myconfig_name).model_dump() # noqa E501
119 |
120 |
121 | def test_wrap_get_config(plugin, myconfig):
122 | fastapi_plugins.register_config(myconfig)
123 | assert myconfig().model_dump() == fastapi_plugins.get_config().model_dump()
124 |
125 |
126 | def test_wrap_get_config_by_name(plugin, myconfig, myconfig_name):
127 | fastapi_plugins.register_config(myconfig, name=myconfig_name)
128 | assert myconfig().model_dump() == fastapi_plugins.get_config(myconfig_name).model_dump() # noqa E501
129 |
130 |
131 | def test_wrap_reset_config(plugin, myconfig):
132 | from fastapi_plugins.settings import _manager
133 | assert _manager._settings_map == {}
134 | fastapi_plugins.register_config(myconfig)
135 | assert _manager._settings_map == {fastapi_plugins.CONFIG_NAME_DOCKER: myconfig} # noqa E501
136 | fastapi_plugins.reset_config()
137 | assert _manager._settings_map == {}
138 |
139 |
140 | def test_decorator_register_config(plugin):
141 | @fastapi_plugins.registered_configuration
142 | class MyConfig(fastapi_plugins.PluginSettings):
143 | api_name: str = 'API name'
144 |
145 | assert MyConfig().model_dump() == fastapi_plugins.get_config().model_dump()
146 |
147 |
148 | def test_decorator_register_config_docker(plugin):
149 | @fastapi_plugins.registered_configuration_docker
150 | class MyConfig(fastapi_plugins.PluginSettings):
151 | api_name: str = 'API name'
152 |
153 | # docker is default
154 | assert MyConfig().model_dump() == fastapi_plugins.get_config().model_dump()
155 | assert MyConfig().model_dump() == fastapi_plugins.get_config(fastapi_plugins.CONFIG_NAME_DOCKER).model_dump() # noqa E501
156 |
157 |
158 | def test_decorator_register_config_local(plugin):
159 | @fastapi_plugins.registered_configuration_local
160 | class MyConfig(fastapi_plugins.PluginSettings):
161 | api_name: str = 'API name'
162 |
163 | assert MyConfig().model_dump() == fastapi_plugins.get_config(fastapi_plugins.CONFIG_NAME_LOCAL).model_dump() # noqa E501
164 | os.environ[fastapi_plugins.DEFAULT_CONFIG_ENVVAR] = fastapi_plugins.CONFIG_NAME_LOCAL # noqa E501
165 | try:
166 | assert MyConfig().model_dump() == fastapi_plugins.get_config().model_dump() # noqa E501
167 | finally:
168 | os.environ.pop(fastapi_plugins.DEFAULT_CONFIG_ENVVAR)
169 |
170 |
171 | def test_decorator_register_config_test(plugin):
172 | @fastapi_plugins.registered_configuration_test
173 | class MyConfig(fastapi_plugins.PluginSettings):
174 | api_name: str = 'API name'
175 |
176 | assert MyConfig().model_dump() == fastapi_plugins.get_config(fastapi_plugins.CONFIG_NAME_TEST).model_dump() # noqa E501
177 | os.environ[fastapi_plugins.DEFAULT_CONFIG_ENVVAR] = fastapi_plugins.CONFIG_NAME_TEST # noqa E501
178 | try:
179 | assert MyConfig().model_dump() == fastapi_plugins.get_config().model_dump() # noqa E501
180 | finally:
181 | os.environ.pop(fastapi_plugins.DEFAULT_CONFIG_ENVVAR)
182 |
183 |
184 | def test_decorator_register_config_by_name(plugin, myconfig_name):
185 | @fastapi_plugins.registered_configuration(name=myconfig_name)
186 | class MyConfig(fastapi_plugins.PluginSettings):
187 | api_name: str = 'API name'
188 |
189 | assert MyConfig().model_dump() == fastapi_plugins.get_config(myconfig_name).model_dump() # noqa E501
190 |
191 |
192 | async def test_app_config(plugin):
193 | @fastapi_plugins.registered_configuration
194 | class MyConfigDocker(fastapi_plugins.PluginSettings):
195 | api_name: str = 'docker'
196 |
197 | @fastapi_plugins.registered_configuration_local
198 | class MyConfigLocal(fastapi_plugins.PluginSettings):
199 | api_name: str = 'local'
200 |
201 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
202 | config = fastapi_plugins.get_config()
203 | await fastapi_plugins.config_plugin.init_app(app=app, config=config)
204 | await fastapi_plugins.config_plugin.init()
205 | try:
206 | assert MyConfigDocker().model_dump() == (await fastapi_plugins.config_plugin()).model_dump() # noqa E501
207 | finally:
208 | await fastapi_plugins.config_plugin.terminate()
209 | fastapi_plugins.reset_config()
210 |
211 |
212 | async def test_app_config_environ(plugin):
213 | os.environ[fastapi_plugins.DEFAULT_CONFIG_ENVVAR] = fastapi_plugins.CONFIG_NAME_LOCAL # noqa E501
214 | try:
215 | @fastapi_plugins.registered_configuration
216 | class MyConfigDocker(fastapi_plugins.PluginSettings):
217 | api_name: str = 'docker'
218 |
219 | @fastapi_plugins.registered_configuration_local
220 | class MyConfigLocal(fastapi_plugins.PluginSettings):
221 | api_name: str = 'local'
222 |
223 | app = fastapi_plugins.register_middleware(fastapi.FastAPI())
224 | config = fastapi_plugins.get_config()
225 | await fastapi_plugins.config_plugin.init_app(app=app, config=config)
226 | await fastapi_plugins.config_plugin.init()
227 | try:
228 | assert MyConfigLocal().model_dump() == (await fastapi_plugins.config_plugin()).model_dump() # noqa E501
229 | finally:
230 | await fastapi_plugins.config_plugin.terminate()
231 | finally:
232 | os.environ.pop(fastapi_plugins.DEFAULT_CONFIG_ENVVAR)
233 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py38, py39, py310, py311, py312
3 |
4 | [testenv]
5 | deps=-r{toxinidir}/requirements.txt
6 |
7 | commands=python -m pytest -v -x tests/
8 |
--------------------------------------------------------------------------------