├── .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 | Build Status 7 | 8 | 9 | Coverage 10 | 11 | 12 | Package version 13 | 14 | 15 | Join the chat at https://gitter.im/tiangolo/fastapi 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 | --------------------------------------------------------------------------------