├── .flake8 ├── .github ├── dependabot.yml └── workflows │ └── tox.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── LICENSE ├── README.md ├── poetry.lock ├── pydantic_settings ├── __init__.py ├── cache.py ├── database.py ├── default.py ├── models.py ├── py.typed ├── sentry.py └── settings.py ├── pyproject.toml ├── tests ├── manage.py ├── settings_confdir_proj │ ├── __init__.py │ └── conf │ │ └── __init__.py ├── settings_proj │ ├── __init__.py │ ├── asgi.py │ ├── conf.py │ ├── urls.py │ └── wsgi.py ├── test_cache.py └── test_env.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'pip' # See documentation for possible values 9 | directory: '/' # Location of package manifests 10 | schedule: 11 | interval: 'daily' 12 | - package-ecosystem: 'github-actions' # See documentation for possible values 13 | directory: '/' # Location of package manifests 14 | schedule: 15 | interval: 'daily' 16 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Tox 2 | 3 | on: [push, workflow_call, workflow_dispatch, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ['3.8', '3.9', '3.10'] 11 | name: Python ${{ matrix.python-version }} Tests 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install poetry 21 | poetry install 22 | - name: Run tests with tox 23 | run: | 24 | poetry run tox 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .vscode 3 | __pycache__ 4 | .DS_Store 5 | .devcontainer 6 | .tox 7 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | # Install poetry 4 | RUN brew install poetry black -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | github: 2 | prebuilds: 3 | branches: true 4 | pullRequestsFromForks: true 5 | gitConfig: 6 | alias.st: status 7 | image: 8 | file: .gitpod.Dockerfile 9 | tasks: 10 | - init: | 11 | poetry config virtualenvs.in-project true 12 | poetry install 13 | command: | 14 | poetry shell 15 | vscode: 16 | extensions: 17 | - ms-python.python 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joshua Ourisman 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-pydantic-settings 2 | 3 | ## Use pydantic settings management to simplify configuration of Django settings. 4 | 5 | Very much a work in progress, but reads the standard DJANGO_SETTINGS_MODULE environment variable (defaulting to pydantic_settings.settings.PydanticSettings) to load a sub-class of pydantic_settings.Settings. All settings (that have been defined in pydantic_settings.Settings) can be overridden with environment variables. A special DatabaseSettings class is used to allow multiple databases to be configured simply with DSNs. 6 | 7 | As of django-pydantic-settings 0.6.0, Django 4.0 is now supported, but, as a result, support for Python 3.6 and 3.7 has been dropped. Python <3.8 can still be used with versions 0.5.0 and lower, and Django 3.2.x and lower; Python 3.6.0 requires django-pydantic-settings <0.4.0. Currently, django-pydantic-settings is tested on Python 3.8, 3.9, and 3.10, and Django 2.2, 3.0, 3.1, 3.2, and 4.0. 8 | 9 | ## Installation & Setup 10 | 11 | Install django-pydantic-settings: 12 | 13 | ``` 14 | pip install django-pydantic-settings 15 | ``` 16 | 17 | Modify your Django project's `manage.py` file to use django-pydantic-settings, it should look something like this: 18 | 19 | ```python 20 | #!/usr/bin/env python 21 | """Django's command-line utility for administrative tasks.""" 22 | import sys 23 | 24 | from pydantic_settings import SetUp 25 | 26 | 27 | def main(): 28 | """Run administrative tasks.""" 29 | SetUp().configure() 30 | 31 | try: 32 | from django.core.management import execute_from_command_line 33 | except ImportError as exc: 34 | raise ImportError( 35 | "Couldn't import Django. Are you sure it's installed and " 36 | "available on your PYTHONPATH environment variable? Did you " 37 | "forget to activate a virtual environment?" 38 | ) from exc 39 | execute_from_command_line(sys.argv) 40 | 41 | 42 | if __name__ == "__main__": 43 | main() 44 | ``` 45 | 46 | Your `wsgi.py` and/or `asgi.py` files will need to be modified similarly, and look something like this: 47 | 48 | ```python 49 | from django.core.wsgi import get_wsgi_application 50 | 51 | from pydantic_settings import SetUp 52 | 53 | SetUp().configure() 54 | application = get_wsgi_application() 55 | ``` 56 | 57 | The `SetUp` class will automatically look for the standard `DJANGO_SETTINGS_MODULE` environment variable, read it, confirm that it points to an existing Python module, and load that module. Your `DJANGO_SETTINGS_MODULE` variable should point to a `pydantic_settings.settings.PydanticSettings` sub-class (though technically any Python class that defines a `dict()` method which returns a Python dictionary of key/value pairs matching the required Django settings will work). Calling the `configure()` method will then use the specified module to configure your project's Django settings. 58 | 59 | If your project uses a package to specify multiple different settings classes, simply set `DJANGO_SETTINGS_MODULE` to be the full path to the desired settings class. For example, given the following directory structure: 60 | 61 | ``` 62 | my_project/ 63 | ├─ settings/ 64 | │ ├─ __init__.py 65 | │ ├─ base.py 66 | │ ├─ local.py 67 | │ ├─ production.py 68 | ├─ my_app/ 69 | 70 | ``` 71 | 72 | To use a settings class called `MyLocal` in `local.py` you would set your `DJANGO_SETTINGS_MODULE` to `my_project.settings.local.MyLocal`. 73 | 74 | ## Required settings 75 | 76 | There are no settings that must be configured in order to use Django with django-pydantic-settings. All of the possible settings defined by Django ([Settings Reference](https://docs.djangoproject.com/en/3.1/ref/settings/)) are configured in the `pydantic_settings.settings.PydanticSettings` class, using their normal default values provided by Django, or a reasonable calculated value. 77 | 78 | If you define and use a settings custom subclass, `BASE_DIR`, `ROOT_URLCONF` and `WSGI_APPLICATION` have calculated defaults relative to its base module. For example, if your `DJANGO_SETTINGS_MODULE` is set to `my_awesome_project.settings.PydanticSettingsSubclass`, then `ROOT_URLCONF` and `WSGI_APPLICATION` will be default to `my_awesome_project.urls` and `my_awesome_project.wsgi.application` respectively, and `BASE_DIR` will default to the directory that contains `my_awesome_project`. This default behavior can be overridden by simply manually specifying `ROOT_URLCONF: str = 'the_actual_urlconf'` and `WSGI_APPLICATION: str = 'the_actual_wsgi_file.application'` in your `PydanticSettings` sub-class. 79 | 80 | The other setting worth thinking about is `SECRET_KEY`. By default, `SECRET_KEY` is automatically generated using Django's own `get_random_secret_key()` function. This will work just fine, though as it will be re-calculated every time your `PydanticSettings` sub-class is instantiated, you should set this to something static if you're using Django's authentication and don't want to lose your session every time the server is restarted. 81 | 82 | ## Database configuration 83 | 84 | The default database configuration can be configured by an environment variable named `DATABASE_URL`, containing a DSN (Data Source Name) string. 85 | 86 | Google Cloud SQL database connections from within Google Cloud Run are supported; the `DatabaseDsn` type will detect and automatically escape DSN strings of the form `postgres://username:password@/cloudsql/project:region:instance/database` so that they can be properly handled. 87 | 88 | Alternatively you can set all your databases at once, by using the `DATABASES` setting (either in a `PydanticSettings` sub-class or via the `DJANGO_DATABASES` environment variable: 89 | 90 | ```python 91 | def MySettings(PydanticSettings): 92 | DATABASES = {"default": "sqlite:///db.sqlite3"} # type: ignore 93 | ``` 94 | 95 | It is also possible to configure additional database connections with environment variables in the same way as the default `DATABASE_URL` configuration by using a `Field` that has a `configure_database` argument that points to the database alias in the `DATABASES` dictionary. 96 | 97 | ```python 98 | from pydantic_settings import PydanticSettings 99 | from pydantic_settings.database import DatabaseDsn 100 | 101 | 102 | def MySettings(PydanticSettings): 103 | secondary_database_dsn: Optional[DatabaseDsn] = Field( 104 | env="SECONDARY_DATABASE_URL", configure_database="secondary" 105 | ) 106 | ``` 107 | 108 | For example, the `tests/settings_proj/conf.py` file is has a settings subclass configured like this and outputs the changes to the `DATABASES` setting when run directly: 109 | 110 | ``` 111 | ❯ DATABASE_URL=postgres://username:password@/cloudsql/project:region:instance/database SECONDARY_DATABASE_URL=sqlite:///foo python settings_test/database_settings.py 112 | {'default': {'ENGINE': 'django.db.backends.postgresql', 113 | 'HOST': '/cloudsql/project:region:instance', 114 | 'NAME': 'database', 115 | 'PASSWORD': 'password', 116 | 'USER': 'username'}, 117 | 'secondary': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'foo'}} 118 | ``` 119 | 120 | ## Sentry configuration 121 | 122 | django-pydantic-settings provides built-in functionality for configuring your Django project to use [Sentry](https://sentry.io/). The simplest way to use this is to inherit from `pydantic_settings.sentry.SentrySettings` rather than `pydantic_settings.settings.PydanticSettings`. This adds the setting `SENTRY_DSN`, which uses the `pydantic_settings.sentry.SentryDsn` type. This will automatically be set according to the `DJANGO_SENTRY_DSN` environment variable, and expects a Sentry DSN (obviously). It validates that the provided DSN is a valid URL, and then automatically initializes the Sentry SDK using the built-in DjangoIntegration. Using this functionality required `sentry-sdk` to be installed, which will be included automatically if you install `django-pydantic-settings[sentry]`. 123 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.7.2" 6 | description = "ASGI specs, helper code, and adapters" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, 11 | {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, 12 | ] 13 | 14 | [package.dependencies] 15 | typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} 16 | 17 | [package.extras] 18 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 19 | 20 | [[package]] 21 | name = "backports.zoneinfo" 22 | version = "0.2.1" 23 | description = "Backport of the standard library zoneinfo module" 24 | optional = false 25 | python-versions = ">=3.6" 26 | files = [ 27 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, 28 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, 29 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, 30 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, 31 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, 32 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, 33 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, 34 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, 35 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, 36 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, 37 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, 38 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, 39 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, 40 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, 41 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, 42 | {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, 43 | ] 44 | 45 | [package.extras] 46 | tzdata = ["tzdata"] 47 | 48 | [[package]] 49 | name = "cachetools" 50 | version = "5.3.1" 51 | description = "Extensible memoizing collections and decorators" 52 | optional = false 53 | python-versions = ">=3.7" 54 | files = [ 55 | {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, 56 | {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, 57 | ] 58 | 59 | [[package]] 60 | name = "certifi" 61 | version = "2022.12.7" 62 | description = "Python package for providing Mozilla's CA Bundle." 63 | optional = true 64 | python-versions = ">=3.6" 65 | files = [ 66 | {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, 67 | {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, 68 | ] 69 | 70 | [[package]] 71 | name = "chardet" 72 | version = "5.2.0" 73 | description = "Universal encoding detector for Python 3" 74 | optional = false 75 | python-versions = ">=3.7" 76 | files = [ 77 | {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, 78 | {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, 79 | ] 80 | 81 | [[package]] 82 | name = "colorama" 83 | version = "0.4.6" 84 | description = "Cross-platform colored terminal text." 85 | optional = false 86 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 87 | files = [ 88 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 89 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 90 | ] 91 | 92 | [[package]] 93 | name = "distlib" 94 | version = "0.3.7" 95 | description = "Distribution utilities" 96 | optional = false 97 | python-versions = "*" 98 | files = [ 99 | {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, 100 | {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, 101 | ] 102 | 103 | [[package]] 104 | name = "django" 105 | version = "4.2.5" 106 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 107 | optional = false 108 | python-versions = ">=3.8" 109 | files = [ 110 | {file = "Django-4.2.5-py3-none-any.whl", hash = "sha256:b6b2b5cae821077f137dc4dade696a1c2aa292f892eca28fa8d7bfdf2608ddd4"}, 111 | {file = "Django-4.2.5.tar.gz", hash = "sha256:5e5c1c9548ffb7796b4a8a4782e9a2e5a3df3615259fc1bfd3ebc73b646146c1"}, 112 | ] 113 | 114 | [package.dependencies] 115 | asgiref = ">=3.6.0,<4" 116 | "backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} 117 | sqlparse = ">=0.3.1" 118 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 119 | 120 | [package.extras] 121 | argon2 = ["argon2-cffi (>=19.1.0)"] 122 | bcrypt = ["bcrypt"] 123 | 124 | [[package]] 125 | name = "dnspython" 126 | version = "2.2.1" 127 | description = "DNS toolkit" 128 | optional = false 129 | python-versions = ">=3.6,<4.0" 130 | files = [ 131 | {file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"}, 132 | {file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"}, 133 | ] 134 | 135 | [package.extras] 136 | curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] 137 | dnssec = ["cryptography (>=2.6,<37.0)"] 138 | doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"] 139 | idna = ["idna (>=2.1,<4.0)"] 140 | trio = ["trio (>=0.14,<0.20)"] 141 | wmi = ["wmi (>=1.5.1,<2.0.0)"] 142 | 143 | [[package]] 144 | name = "email-validator" 145 | version = "1.1.3" 146 | description = "A robust email syntax and deliverability validation library for Python 2.x/3.x." 147 | optional = false 148 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 149 | files = [ 150 | {file = "email_validator-1.1.3-py2.py3-none-any.whl", hash = "sha256:5675c8ceb7106a37e40e2698a57c056756bf3f272cfa8682a4f87ebd95d8440b"}, 151 | {file = "email_validator-1.1.3.tar.gz", hash = "sha256:aa237a65f6f4da067119b7df3f13e89c25c051327b2b5b66dc075f33d62480d7"}, 152 | ] 153 | 154 | [package.dependencies] 155 | dnspython = ">=1.15.0" 156 | idna = ">=2.0.0" 157 | 158 | [[package]] 159 | name = "exceptiongroup" 160 | version = "1.0.0rc9" 161 | description = "Backport of PEP 654 (exception groups)" 162 | optional = false 163 | python-versions = ">=3.7" 164 | files = [ 165 | {file = "exceptiongroup-1.0.0rc9-py3-none-any.whl", hash = "sha256:2e3c3fc1538a094aab74fad52d6c33fc94de3dfee3ee01f187c0e0c72aec5337"}, 166 | {file = "exceptiongroup-1.0.0rc9.tar.gz", hash = "sha256:9086a4a21ef9b31c72181c77c040a074ba0889ee56a7b289ff0afb0d97655f96"}, 167 | ] 168 | 169 | [package.extras] 170 | test = ["pytest (>=6)"] 171 | 172 | [[package]] 173 | name = "filelock" 174 | version = "3.12.3" 175 | description = "A platform independent file lock." 176 | optional = false 177 | python-versions = ">=3.8" 178 | files = [ 179 | {file = "filelock-3.12.3-py3-none-any.whl", hash = "sha256:f067e40ccc40f2b48395a80fcbd4728262fab54e232e090a4063ab804179efeb"}, 180 | {file = "filelock-3.12.3.tar.gz", hash = "sha256:0ecc1dd2ec4672a10c8550a8182f1bd0c0a5088470ecd5a125e45f49472fac3d"}, 181 | ] 182 | 183 | [package.dependencies] 184 | typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.11\""} 185 | 186 | [package.extras] 187 | docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] 188 | testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] 189 | 190 | [[package]] 191 | name = "idna" 192 | version = "3.3" 193 | description = "Internationalized Domain Names in Applications (IDNA)" 194 | optional = false 195 | python-versions = ">=3.5" 196 | files = [ 197 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 198 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 199 | ] 200 | 201 | [[package]] 202 | name = "iniconfig" 203 | version = "1.1.1" 204 | description = "iniconfig: brain-dead simple config-ini parsing" 205 | optional = false 206 | python-versions = "*" 207 | files = [ 208 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 209 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 210 | ] 211 | 212 | [[package]] 213 | name = "packaging" 214 | version = "23.1" 215 | description = "Core utilities for Python packages" 216 | optional = false 217 | python-versions = ">=3.7" 218 | files = [ 219 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 220 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 221 | ] 222 | 223 | [[package]] 224 | name = "platformdirs" 225 | version = "3.10.0" 226 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 227 | optional = false 228 | python-versions = ">=3.7" 229 | files = [ 230 | {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, 231 | {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, 232 | ] 233 | 234 | [package.extras] 235 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 236 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 237 | 238 | [[package]] 239 | name = "pluggy" 240 | version = "1.3.0" 241 | description = "plugin and hook calling mechanisms for python" 242 | optional = false 243 | python-versions = ">=3.8" 244 | files = [ 245 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 246 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 247 | ] 248 | 249 | [package.extras] 250 | dev = ["pre-commit", "tox"] 251 | testing = ["pytest", "pytest-benchmark"] 252 | 253 | [[package]] 254 | name = "pydantic" 255 | version = "1.10.8" 256 | description = "Data validation and settings management using python type hints" 257 | optional = false 258 | python-versions = ">=3.7" 259 | files = [ 260 | {file = "pydantic-1.10.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1243d28e9b05003a89d72e7915fdb26ffd1d39bdd39b00b7dbe4afae4b557f9d"}, 261 | {file = "pydantic-1.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0ab53b609c11dfc0c060d94335993cc2b95b2150e25583bec37a49b2d6c6c3f"}, 262 | {file = "pydantic-1.10.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9613fadad06b4f3bc5db2653ce2f22e0de84a7c6c293909b48f6ed37b83c61f"}, 263 | {file = "pydantic-1.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df7800cb1984d8f6e249351139667a8c50a379009271ee6236138a22a0c0f319"}, 264 | {file = "pydantic-1.10.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0c6fafa0965b539d7aab0a673a046466d23b86e4b0e8019d25fd53f4df62c277"}, 265 | {file = "pydantic-1.10.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e82d4566fcd527eae8b244fa952d99f2ca3172b7e97add0b43e2d97ee77f81ab"}, 266 | {file = "pydantic-1.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:ab523c31e22943713d80d8d342d23b6f6ac4b792a1e54064a8d0cf78fd64e800"}, 267 | {file = "pydantic-1.10.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:666bdf6066bf6dbc107b30d034615d2627e2121506c555f73f90b54a463d1f33"}, 268 | {file = "pydantic-1.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:35db5301b82e8661fa9c505c800d0990bc14e9f36f98932bb1d248c0ac5cada5"}, 269 | {file = "pydantic-1.10.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90c1e29f447557e9e26afb1c4dbf8768a10cc676e3781b6a577841ade126b85"}, 270 | {file = "pydantic-1.10.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93e766b4a8226e0708ef243e843105bf124e21331694367f95f4e3b4a92bbb3f"}, 271 | {file = "pydantic-1.10.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:88f195f582851e8db960b4a94c3e3ad25692c1c1539e2552f3df7a9e972ef60e"}, 272 | {file = "pydantic-1.10.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:34d327c81e68a1ecb52fe9c8d50c8a9b3e90d3c8ad991bfc8f953fb477d42fb4"}, 273 | {file = "pydantic-1.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:d532bf00f381bd6bc62cabc7d1372096b75a33bc197a312b03f5838b4fb84edd"}, 274 | {file = "pydantic-1.10.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7d5b8641c24886d764a74ec541d2fc2c7fb19f6da2a4001e6d580ba4a38f7878"}, 275 | {file = "pydantic-1.10.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b1f6cb446470b7ddf86c2e57cd119a24959af2b01e552f60705910663af09a4"}, 276 | {file = "pydantic-1.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c33b60054b2136aef8cf190cd4c52a3daa20b2263917c49adad20eaf381e823b"}, 277 | {file = "pydantic-1.10.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1952526ba40b220b912cdc43c1c32bcf4a58e3f192fa313ee665916b26befb68"}, 278 | {file = "pydantic-1.10.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bb14388ec45a7a0dc429e87def6396f9e73c8c77818c927b6a60706603d5f2ea"}, 279 | {file = "pydantic-1.10.8-cp37-cp37m-win_amd64.whl", hash = "sha256:16f8c3e33af1e9bb16c7a91fc7d5fa9fe27298e9f299cff6cb744d89d573d62c"}, 280 | {file = "pydantic-1.10.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ced8375969673929809d7f36ad322934c35de4af3b5e5b09ec967c21f9f7887"}, 281 | {file = "pydantic-1.10.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93e6bcfccbd831894a6a434b0aeb1947f9e70b7468f274154d03d71fabb1d7c6"}, 282 | {file = "pydantic-1.10.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:191ba419b605f897ede9892f6c56fb182f40a15d309ef0142212200a10af4c18"}, 283 | {file = "pydantic-1.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:052d8654cb65174d6f9490cc9b9a200083a82cf5c3c5d3985db765757eb3b375"}, 284 | {file = "pydantic-1.10.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ceb6a23bf1ba4b837d0cfe378329ad3f351b5897c8d4914ce95b85fba96da5a1"}, 285 | {file = "pydantic-1.10.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f2e754d5566f050954727c77f094e01793bcb5725b663bf628fa6743a5a9108"}, 286 | {file = "pydantic-1.10.8-cp38-cp38-win_amd64.whl", hash = "sha256:6a82d6cda82258efca32b40040228ecf43a548671cb174a1e81477195ed3ed56"}, 287 | {file = "pydantic-1.10.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e59417ba8a17265e632af99cc5f35ec309de5980c440c255ab1ca3ae96a3e0e"}, 288 | {file = "pydantic-1.10.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:84d80219c3f8d4cad44575e18404099c76851bc924ce5ab1c4c8bb5e2a2227d0"}, 289 | {file = "pydantic-1.10.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e4148e635994d57d834be1182a44bdb07dd867fa3c2d1b37002000646cc5459"}, 290 | {file = "pydantic-1.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12f7b0bf8553e310e530e9f3a2f5734c68699f42218bf3568ef49cd9b0e44df4"}, 291 | {file = "pydantic-1.10.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42aa0c4b5c3025483240a25b09f3c09a189481ddda2ea3a831a9d25f444e03c1"}, 292 | {file = "pydantic-1.10.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:17aef11cc1b997f9d574b91909fed40761e13fac438d72b81f902226a69dac01"}, 293 | {file = "pydantic-1.10.8-cp39-cp39-win_amd64.whl", hash = "sha256:66a703d1983c675a6e0fed8953b0971c44dba48a929a2000a493c3772eb61a5a"}, 294 | {file = "pydantic-1.10.8-py3-none-any.whl", hash = "sha256:7456eb22ed9aaa24ff3e7b4757da20d9e5ce2a81018c1b3ebd81a0b88a18f3b2"}, 295 | {file = "pydantic-1.10.8.tar.gz", hash = "sha256:1410275520dfa70effadf4c21811d755e7ef9bb1f1d077a21958153a92c8d9ca"}, 296 | ] 297 | 298 | [package.dependencies] 299 | email-validator = {version = ">=1.0.3", optional = true, markers = "extra == \"email\""} 300 | typing-extensions = ">=4.2.0" 301 | 302 | [package.extras] 303 | dotenv = ["python-dotenv (>=0.10.4)"] 304 | email = ["email-validator (>=1.0.3)"] 305 | 306 | [[package]] 307 | name = "pyproject-api" 308 | version = "1.6.1" 309 | description = "API to interact with the python pyproject.toml based projects" 310 | optional = false 311 | python-versions = ">=3.8" 312 | files = [ 313 | {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, 314 | {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, 315 | ] 316 | 317 | [package.dependencies] 318 | packaging = ">=23.1" 319 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 320 | 321 | [package.extras] 322 | docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] 323 | testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68.1.2)", "wheel (>=0.41.2)"] 324 | 325 | [[package]] 326 | name = "pytest" 327 | version = "7.3.1" 328 | description = "pytest: simple powerful testing with Python" 329 | optional = false 330 | python-versions = ">=3.7" 331 | files = [ 332 | {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, 333 | {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, 334 | ] 335 | 336 | [package.dependencies] 337 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 338 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 339 | iniconfig = "*" 340 | packaging = "*" 341 | pluggy = ">=0.12,<2.0" 342 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 343 | 344 | [package.extras] 345 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 346 | 347 | [[package]] 348 | name = "pytest-dotenv" 349 | version = "0.5.2" 350 | description = "A py.test plugin that parses environment files before running tests" 351 | optional = false 352 | python-versions = "*" 353 | files = [ 354 | {file = "pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732"}, 355 | {file = "pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f"}, 356 | ] 357 | 358 | [package.dependencies] 359 | pytest = ">=5.0.0" 360 | python-dotenv = ">=0.9.1" 361 | 362 | [[package]] 363 | name = "python-dotenv" 364 | version = "0.20.0" 365 | description = "Read key-value pairs from a .env file and set them as environment variables" 366 | optional = false 367 | python-versions = ">=3.5" 368 | files = [ 369 | {file = "python-dotenv-0.20.0.tar.gz", hash = "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f"}, 370 | {file = "python_dotenv-0.20.0-py3-none-any.whl", hash = "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"}, 371 | ] 372 | 373 | [package.extras] 374 | cli = ["click (>=5.0)"] 375 | 376 | [[package]] 377 | name = "sentry-sdk" 378 | version = "1.30.0" 379 | description = "Python client for Sentry (https://sentry.io)" 380 | optional = true 381 | python-versions = "*" 382 | files = [ 383 | {file = "sentry-sdk-1.30.0.tar.gz", hash = "sha256:7dc873b87e1faf4d00614afd1058bfa1522942f33daef8a59f90de8ed75cd10c"}, 384 | {file = "sentry_sdk-1.30.0-py2.py3-none-any.whl", hash = "sha256:2e53ad63f96bb9da6570ba2e755c267e529edcf58580a2c0d2a11ef26e1e678b"}, 385 | ] 386 | 387 | [package.dependencies] 388 | certifi = "*" 389 | urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} 390 | 391 | [package.extras] 392 | aiohttp = ["aiohttp (>=3.5)"] 393 | arq = ["arq (>=0.23)"] 394 | beam = ["apache-beam (>=2.12)"] 395 | bottle = ["bottle (>=0.12.13)"] 396 | celery = ["celery (>=3)"] 397 | chalice = ["chalice (>=1.16.0)"] 398 | django = ["django (>=1.8)"] 399 | falcon = ["falcon (>=1.4)"] 400 | fastapi = ["fastapi (>=0.79.0)"] 401 | flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] 402 | grpcio = ["grpcio (>=1.21.1)"] 403 | httpx = ["httpx (>=0.16.0)"] 404 | huey = ["huey (>=2)"] 405 | loguru = ["loguru (>=0.5)"] 406 | opentelemetry = ["opentelemetry-distro (>=0.35b0)"] 407 | opentelemetry-experimental = ["opentelemetry-distro (>=0.40b0,<1.0)", "opentelemetry-instrumentation-aiohttp-client (>=0.40b0,<1.0)", "opentelemetry-instrumentation-django (>=0.40b0,<1.0)", "opentelemetry-instrumentation-fastapi (>=0.40b0,<1.0)", "opentelemetry-instrumentation-flask (>=0.40b0,<1.0)", "opentelemetry-instrumentation-requests (>=0.40b0,<1.0)", "opentelemetry-instrumentation-sqlite3 (>=0.40b0,<1.0)", "opentelemetry-instrumentation-urllib (>=0.40b0,<1.0)"] 408 | pure-eval = ["asttokens", "executing", "pure-eval"] 409 | pymongo = ["pymongo (>=3.1)"] 410 | pyspark = ["pyspark (>=2.4.4)"] 411 | quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] 412 | rq = ["rq (>=0.6)"] 413 | sanic = ["sanic (>=0.8)"] 414 | sqlalchemy = ["sqlalchemy (>=1.2)"] 415 | starlette = ["starlette (>=0.19.1)"] 416 | starlite = ["starlite (>=1.48)"] 417 | tornado = ["tornado (>=5)"] 418 | 419 | [[package]] 420 | name = "sqlparse" 421 | version = "0.4.4" 422 | description = "A non-validating SQL parser." 423 | optional = false 424 | python-versions = ">=3.5" 425 | files = [ 426 | {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, 427 | {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, 428 | ] 429 | 430 | [package.extras] 431 | dev = ["build", "flake8"] 432 | doc = ["sphinx"] 433 | test = ["pytest", "pytest-cov"] 434 | 435 | [[package]] 436 | name = "tomli" 437 | version = "2.0.1" 438 | description = "A lil' TOML parser" 439 | optional = false 440 | python-versions = ">=3.7" 441 | files = [ 442 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 443 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 444 | ] 445 | 446 | [[package]] 447 | name = "tox" 448 | version = "4.11.1" 449 | description = "tox is a generic virtualenv management and test command line tool" 450 | optional = false 451 | python-versions = ">=3.8" 452 | files = [ 453 | {file = "tox-4.11.1-py3-none-any.whl", hash = "sha256:da761b4a57ee2b92b5ce39f48ff723fc42d185bf2af508effb683214efa662ea"}, 454 | {file = "tox-4.11.1.tar.gz", hash = "sha256:8a8cc94b7269f8e43dfc636eff2da4b33a199a4e575b5b086cc51aae24ac4262"}, 455 | ] 456 | 457 | [package.dependencies] 458 | cachetools = ">=5.3.1" 459 | chardet = ">=5.2" 460 | colorama = ">=0.4.6" 461 | filelock = ">=3.12.3" 462 | packaging = ">=23.1" 463 | platformdirs = ">=3.10" 464 | pluggy = ">=1.3" 465 | pyproject-api = ">=1.6.1" 466 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 467 | virtualenv = ">=20.24.3" 468 | 469 | [package.extras] 470 | docs = ["furo (>=2023.8.19)", "sphinx (>=7.2.4)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.24)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 471 | testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=1)", "diff-cover (>=7.7)", "distlib (>=0.3.7)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.18)", "psutil (>=5.9.5)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.12)", "wheel (>=0.41.2)"] 472 | 473 | [[package]] 474 | name = "typing-extensions" 475 | version = "4.7.1" 476 | description = "Backported and Experimental Type Hints for Python 3.7+" 477 | optional = false 478 | python-versions = ">=3.7" 479 | files = [ 480 | {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, 481 | {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, 482 | ] 483 | 484 | [[package]] 485 | name = "tzdata" 486 | version = "2022.1" 487 | description = "Provider of IANA time zone data" 488 | optional = false 489 | python-versions = ">=2" 490 | files = [ 491 | {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, 492 | {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, 493 | ] 494 | 495 | [[package]] 496 | name = "urllib3" 497 | version = "1.26.17" 498 | description = "HTTP library with thread-safe connection pooling, file post, and more." 499 | optional = false 500 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 501 | files = [ 502 | {file = "urllib3-1.26.17-py2.py3-none-any.whl", hash = "sha256:94a757d178c9be92ef5539b8840d48dc9cf1b2709c9d6b588232a055c524458b"}, 503 | {file = "urllib3-1.26.17.tar.gz", hash = "sha256:24d6a242c28d29af46c3fae832c36db3bbebcc533dd1bb549172cd739c82df21"}, 504 | ] 505 | 506 | [package.extras] 507 | brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] 508 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] 509 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 510 | 511 | [[package]] 512 | name = "virtualenv" 513 | version = "20.24.3" 514 | description = "Virtual Python Environment builder" 515 | optional = false 516 | python-versions = ">=3.7" 517 | files = [ 518 | {file = "virtualenv-20.24.3-py3-none-any.whl", hash = "sha256:95a6e9398b4967fbcb5fef2acec5efaf9aa4972049d9ae41f95e0972a683fd02"}, 519 | {file = "virtualenv-20.24.3.tar.gz", hash = "sha256:e5c3b4ce817b0b328af041506a2a299418c98747c4b1e68cb7527e74ced23efc"}, 520 | ] 521 | 522 | [package.dependencies] 523 | distlib = ">=0.3.7,<1" 524 | filelock = ">=3.12.2,<4" 525 | platformdirs = ">=3.9.1,<4" 526 | 527 | [package.extras] 528 | docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 529 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] 530 | 531 | [extras] 532 | sentry = ["sentry-sdk"] 533 | 534 | [metadata] 535 | lock-version = "2.0" 536 | python-versions = "^3.8" 537 | content-hash = "1abb94b5bae7caea48a9ff79c2e4b6c9112f50bc968e2515b546a77f5a06407b" 538 | -------------------------------------------------------------------------------- /pydantic_settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .settings import PydanticSettings, SetUp 2 | 3 | __ALL__ = [PydanticSettings, SetUp] 4 | -------------------------------------------------------------------------------- /pydantic_settings/cache.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict 3 | from urllib.parse import parse_qs 4 | 5 | from django import VERSION 6 | from pydantic import AnyUrl 7 | 8 | from pydantic_settings.models import CacheModel 9 | 10 | BUILTIN_DJANGO_BACKEND = "django.core.cache.backends.redis.RedisCache" 11 | DJANGO_REDIS_BACKEND = ( 12 | "django_redis.cache.RedisCache" if VERSION[0] < 4 else BUILTIN_DJANGO_BACKEND 13 | ) 14 | 15 | CACHE_ENGINES = { 16 | "db": "django.core.cache.backends.db.DatabaseCache", 17 | "djangopylibmc": "django_pylibmc.memcached.PyLibMCCache", 18 | "dummy": "django.core.cache.backends.dummy.DummyCache", 19 | "elasticache": "django_elasticache.memcached.ElastiCache", 20 | "file": "django.core.cache.backends.filebased.FileBasedCache", 21 | "hiredis": DJANGO_REDIS_BACKEND, 22 | "locmem": "django.core.cache.backends.locmem.LocMemCache", 23 | "memcached": "django.core.cache.backends.memcached.PyLibMCCache", 24 | "pymemcache": "django.core.cache.backends.memcached.PyMemcacheCache", 25 | "pymemcached": "django.core.cache.backends.memcached.MemcachedCache", 26 | "redis-cache": "redis_cache.RedisCache", 27 | "redis": DJANGO_REDIS_BACKEND, 28 | "rediss": DJANGO_REDIS_BACKEND, 29 | "uwsgicache": "uwsgicache.UWSGICache", 30 | } 31 | 32 | REDIS_PARSERS = { 33 | "hiredis": "redis.connection.HiredisParser", 34 | } 35 | 36 | FILE_UNIX_PREFIX = ( 37 | "memcached", 38 | "pymemcached", 39 | "pymemcache", 40 | "djangopylibmc", 41 | "redis", 42 | "hiredis", 43 | ) 44 | 45 | 46 | class CacheDsn(AnyUrl): 47 | __slots__ = AnyUrl.__slots__ + ("query_args",) 48 | host_required = False 49 | 50 | query_args: Dict[str, str] 51 | 52 | def __init__(self, *args, **kwargs): 53 | super().__init__(*args, **kwargs) 54 | if self.query: 55 | self.query_args = { 56 | key.upper(): ";".join(val) for key, val in parse_qs(self.query).items() 57 | } 58 | else: 59 | self.query_args = {} 60 | 61 | allowed_schemes = set(CACHE_ENGINES) 62 | 63 | def to_settings_model(self) -> CacheModel: 64 | return CacheModel(**parse(self)) 65 | 66 | @property 67 | def is_redis_scheme(self) -> bool: 68 | return self.scheme in ("redis", "rediss", "hiredis") 69 | 70 | 71 | def parse(dsn: CacheDsn) -> dict: 72 | """Parses a cache URL.""" 73 | backend = CACHE_ENGINES[dsn.scheme] 74 | config = {"BACKEND": backend} 75 | 76 | options = {} 77 | if dsn.scheme in REDIS_PARSERS: 78 | options["PARSER_CLASS"] = REDIS_PARSERS[dsn.scheme] 79 | 80 | cache_args = dsn.query_args.copy() 81 | 82 | # File based 83 | if dsn.host is None: 84 | path = dsn.path 85 | 86 | if dsn.scheme in FILE_UNIX_PREFIX: 87 | path = "unix:" + path 88 | 89 | if dsn.is_redis_scheme: 90 | match = re.match(r"(.*)/(\d+)$", path) 91 | if match: 92 | path, db = match.groups() 93 | else: 94 | db = "0" 95 | path = f"{path}?db={db}" 96 | 97 | config["LOCATION"] = path 98 | # Redis URL based 99 | elif dsn.is_redis_scheme: 100 | # Specifying the database is optional, use db 0 if not specified. 101 | db = (dsn.path and dsn.path[1:]) or "0" 102 | port = dsn.port if dsn.port else 6379 103 | scheme = "rediss" if dsn.scheme == "rediss" else "redis" 104 | location = f"{scheme}://{dsn.host}:{port}/{db}" 105 | if dsn.password: 106 | if backend == BUILTIN_DJANGO_BACKEND or dsn.scheme == "redis-cache": 107 | location = location.replace("://", f"://{dsn.password}@", 1) 108 | else: 109 | options["PASSWORD"] = dsn.password 110 | config["LOCATION"] = location 111 | 112 | # Pop redis-cache specific arguments. 113 | if dsn.scheme == "redis-cache": 114 | for key in ("PARSER_CLASS", "CONNECTION_POOL_CLASS"): 115 | if val := cache_args.pop(key, None): 116 | options[key] = val 117 | 118 | pool_class_opts = {} 119 | for pool_class_key in ("MAX_CONNECTIONS", "TIMEOUT"): 120 | if val := cache_args.pop(pool_class_key, None): 121 | pool_class_opts[pool_class_key] = val 122 | if pool_class_opts: 123 | options["CONNECTION_POOL_CLASS_KWARGS"] = pool_class_opts 124 | 125 | if dsn.scheme == "uwsgicache": 126 | config["LOCATION"] = config.get("LOCATION") or "default" 127 | 128 | # Pop special options from cache_args 129 | # https://docs.djangoproject.com/en/4.0/topics/cache/#cache-arguments 130 | for key in ["MAX_ENTRIES", "CULL_FREQUENCY"]: 131 | if val := dsn.query_args.pop(key, None): 132 | options[key] = int(val) 133 | 134 | config.update(cache_args) 135 | if options: 136 | config["OPTIONS"] = options 137 | 138 | return config 139 | -------------------------------------------------------------------------------- /pydantic_settings/database.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib.parse 3 | from typing import Dict, Optional, Pattern, Tuple, cast 4 | from urllib.parse import quote_plus 5 | 6 | from pydantic import AnyUrl 7 | from pydantic.validators import constr_length_validator, str_validator 8 | 9 | from pydantic_settings.models import DatabaseModel 10 | 11 | _cloud_sql_regex_cache = None 12 | 13 | 14 | DB_ENGINES = { 15 | "postgres": "django.db.backends.postgresql", 16 | "postgresql": "django.db.backends.postgresql", 17 | "postgis": "django.contrib.gis.db.backends.postgis", 18 | "mssql": "sql_server.pyodbc", 19 | "mysql": "django.db.backends.mysql", 20 | "mysqlgis": "django.contrib.gis.db.backends.mysql", 21 | "sqlite": "django.db.backends.sqlite3", 22 | "spatialite": "django.contrib.gis.db.backends.spatialite", 23 | "oracle": "django.db.backends.oracle", 24 | "oraclegis": "django.contrib.gis.db.backends.oracle", 25 | "redshift": "django_redshift_backend", 26 | } 27 | 28 | 29 | def cloud_sql_regex() -> Pattern[str]: 30 | global _cloud_sql_regex_cache 31 | if _cloud_sql_regex_cache is None: 32 | _cloud_sql_regex_cache = re.compile( 33 | r"(?:(?P[a-z][a-z0-9+\-.]+)://)?" # scheme https://tools.ietf.org/html/rfc3986#appendix-A 34 | r"(?:(?P[^\s:/]*)(?::(?P[^\s/]*))?@)?" # user info 35 | r"(?P/[^\s?#]*)?", # path 36 | re.IGNORECASE, 37 | ) 38 | return _cloud_sql_regex_cache 39 | 40 | 41 | class DatabaseDsn(AnyUrl): 42 | allowed_schemes = set(DB_ENGINES) 43 | 44 | @classmethod 45 | def validate(cls, value, field, config): 46 | if value.__class__ == cls: 47 | return value 48 | 49 | value = str_validator(value) 50 | if cls.strip_whitespace: 51 | value = value.strip() 52 | 53 | url: str = cast(str, constr_length_validator(value, field, config)) 54 | 55 | if "/cloudsql/" in url: 56 | m = cloud_sql_regex().match(url) 57 | if m: 58 | parts = m.groupdict() 59 | socket, path = parts["path"].rsplit("/", 1) 60 | escaped_socket = quote_plus(socket) 61 | escaped_dsn = value.replace(socket, escaped_socket) 62 | return super().validate(escaped_dsn, field, config) 63 | 64 | return super().validate(value, field, config) 65 | 66 | @classmethod 67 | def validate_host( 68 | cls, parts: Dict[str, str] 69 | ) -> Tuple[Optional[str], Optional[str], str, bool]: 70 | host = None 71 | for f in ("domain", "ipv4", "ipv6"): 72 | host = parts[f] 73 | if host: 74 | break 75 | 76 | if host is None: 77 | return None, None, "file", False 78 | 79 | if host.startswith("%2F"): 80 | return host, None, "socket", False 81 | 82 | return super().validate_host(parts) 83 | 84 | def to_settings_model(self) -> DatabaseModel: 85 | name = self.path 86 | if name and name.startswith("/"): 87 | name = name[1:] 88 | return DatabaseModel( 89 | NAME=name or "", 90 | USER=self.user or "", 91 | PASSWORD=self.password or "", 92 | HOST=urllib.parse.unquote(self.host) if self.host else "", 93 | PORT=self.port or "", 94 | ENGINE=DB_ENGINES[self.scheme], 95 | ) 96 | -------------------------------------------------------------------------------- /pydantic_settings/default.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from pydantic import root_validator 4 | 5 | from pydantic_settings.models import DatabaseModel, TemplateBackendModel 6 | from pydantic_settings.settings import DatabaseModel, PydanticSettings 7 | 8 | 9 | class DjangoDefaultProjectSettings(PydanticSettings): 10 | """ 11 | An example of a PydanticSettings subclass that uses the default settings Django 12 | generates for new projects. 13 | """ 14 | 15 | DATABASES: Dict[str, DatabaseModel] = {"default": "sqlite:///db.sqlite3"} # type: ignore 16 | 17 | TEMPLATES: List[TemplateBackendModel] = [ 18 | TemplateBackendModel.parse_obj(data) 19 | for data in [ 20 | { 21 | "BACKEND": "django.template.backends.django.DjangoTemplates", 22 | "DIRS": [], 23 | "APP_DIRS": True, 24 | "OPTIONS": { 25 | "context_processors": [ 26 | "django.template.context_processors.debug", 27 | "django.template.context_processors.request", 28 | "django.contrib.auth.context_processors.auth", 29 | "django.contrib.messages.context_processors.messages", 30 | ], 31 | }, 32 | }, 33 | ] 34 | ] 35 | 36 | INSTALLED_APPS: List[str] = [ 37 | "django.contrib.admin", 38 | "django.contrib.auth", 39 | "django.contrib.contenttypes", 40 | "django.contrib.sessions", 41 | "django.contrib.messages", 42 | "django.contrib.staticfiles", 43 | ] 44 | 45 | MIDDLEWARE: List[str] = [ 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ] 54 | 55 | AUTH_PASSWORD_VALIDATORS: List[dict] = [ 56 | { 57 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 58 | }, 59 | { 60 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 61 | }, 62 | { 63 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 64 | }, 65 | { 66 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 67 | }, 68 | ] 69 | 70 | TIME_ZONE: str = "UTC" 71 | USE_TZ: bool = True 72 | 73 | STATIC_URL: str = "static/" 74 | 75 | DEFAULT_AUTO_FIELD: str = "django.db.models.BigAutoField" 76 | 77 | @root_validator(allow_reuse=True) 78 | def default_database(cls, values): 79 | base_dir = values.get("BASE_DIR") 80 | if base_dir: 81 | values["DATABASES"]["default"] = DatabaseModel( 82 | ENGINE="django.db.backends.sqlite3", NAME=str(base_dir / "db.sqlite3") 83 | ) 84 | return values 85 | -------------------------------------------------------------------------------- /pydantic_settings/models.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import DirectoryPath 4 | from pydantic.main import BaseModel 5 | from typing_extensions import TypedDict 6 | 7 | 8 | class TemplateBackendModel(BaseModel): 9 | BACKEND: str 10 | NAME: Optional[str] 11 | DIRS: Optional[List[DirectoryPath]] 12 | APP_DIRS: Optional[bool] 13 | OPTIONS: Optional[dict] 14 | 15 | 16 | class CacheModel(BaseModel): 17 | BACKEND: str 18 | KEY_FUNCTION: Optional[str] = None 19 | KEY_PREFIX: str = "" 20 | LOCATION: str = "" 21 | OPTIONS: dict = {} 22 | TIMEOUT: Optional[int] = None 23 | VERSION: int = 1 24 | 25 | 26 | class DatabaseTestDict(TypedDict, total=False): 27 | CHARSET: Optional[str] 28 | COLLATION: Optional[str] 29 | DEPENDENCIES: Optional[List[str]] 30 | MIGRATE: bool 31 | MIRROR: Optional[str] 32 | NAME: Optional[str] 33 | SERIALIZE: Optional[bool] # Deprecated since v4.0 34 | TEMPLATE: Optional[str] 35 | CREATE_DB: Optional[bool] 36 | CREATE_USER: Optional[bool] 37 | USER: Optional[str] 38 | PASSWORD: Optional[str] 39 | ORACLE_MANAGED_FILES: Optional[bool] 40 | TBLSPACE: Optional[str] 41 | TBLSPACE_TMP: Optional[str] 42 | DATAFILE: Optional[str] 43 | DATAFILE_TMP: Optional[str] 44 | DATAFILE_MAXSIZE: Optional[str] 45 | DATAFILE_TMP_MAXSIZE: Optional[str] 46 | DATAFILE_SIZE: Optional[str] 47 | DATAFILE_TMP_SIZE: Optional[str] 48 | DATAFILE_EXTSIZE: Optional[str] 49 | DATAFILE_TMP_EXTSIZE: Optional[str] 50 | 51 | 52 | class DatabaseModel(BaseModel): 53 | ATOMIC_REQUESTS: bool = False 54 | AUTOCOMMIT: bool = True 55 | ENGINE: str 56 | HOST: str = "" 57 | NAME: str = "" 58 | CONN_MAX_AGE: int = 0 59 | OPTIONS: dict = {} 60 | PASSWORD: str = "" 61 | PORT: str = "" 62 | TIME_ZONE: Optional[str] = None 63 | DISABLE_SERVER_SIDE_CURSORS: bool = False 64 | USER: str = "" 65 | TEST: DatabaseTestDict = {} 66 | DATA_UPLOAD_MEMORY_MAX_SIZE: Optional[int] = None 67 | -------------------------------------------------------------------------------- /pydantic_settings/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshourisman/django-pydantic-settings/c6d8f701f441f0ed37948f00f575161532fb4c9d/pydantic_settings/py.typed -------------------------------------------------------------------------------- /pydantic_settings/sentry.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import sentry_sdk 4 | from pydantic import AnyUrl 5 | from pydantic.fields import ModelField 6 | from pydantic.main import BaseConfig 7 | from sentry_sdk.integrations.django import DjangoIntegration 8 | 9 | from .settings import PydanticSettings 10 | 11 | 12 | class SentryDsn(AnyUrl): 13 | @classmethod 14 | def validate(cls, value: Any, field: ModelField, config: BaseConfig) -> AnyUrl: 15 | dsn = super().validate(value, field, config) 16 | 17 | sentry_sdk.init( 18 | dsn=dsn, 19 | integrations=[DjangoIntegration()], 20 | traces_sample_rate=1.0, 21 | send_default_pii=True, 22 | ) 23 | 24 | return dsn 25 | 26 | 27 | class SentrySettings(PydanticSettings): 28 | SENTRY_DSN: SentryDsn 29 | -------------------------------------------------------------------------------- /pydantic_settings/settings.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from pathlib import Path 3 | from typing import ( 4 | Any, 5 | Callable, 6 | Dict, 7 | Iterable, 8 | List, 9 | Optional, 10 | Pattern, 11 | Sequence, 12 | Tuple, 13 | Union, 14 | ) 15 | 16 | from django.conf import global_settings, settings 17 | from django.core.management.utils import get_random_secret_key 18 | from pydantic import ( 19 | BaseSettings, 20 | DirectoryPath, 21 | Field, 22 | PyObject, 23 | parse_obj_as, 24 | root_validator, 25 | validator, 26 | ) 27 | from pydantic.fields import ModelField 28 | from pydantic.networks import EmailStr, IPvAnyAddress 29 | from pydantic.types import FilePath 30 | 31 | from pydantic_settings.cache import CacheDsn 32 | from pydantic_settings.database import DatabaseDsn 33 | from pydantic_settings.models import CacheModel, DatabaseModel, TemplateBackendModel 34 | 35 | try: 36 | from typing import Literal 37 | except ImportError: 38 | from typing_extensions import Literal 39 | 40 | 41 | DEFAULT_SETTINGS_MODULE_FIELD = Field( 42 | "pydantic_settings.settings.PydanticSettings", env="DJANGO_SETTINGS_MODULE" 43 | ) 44 | 45 | 46 | class SetUp(BaseSettings): 47 | DJANGO_SETTINGS_MODULE: PyObject = "pydantic_settings.settings.PydanticSettings" 48 | 49 | def configure(self): 50 | if settings.configured: 51 | return False 52 | 53 | settings_obj: PydanticSettings 54 | # The settings module can either be a settings class, or an instance of a 55 | # settings class. 56 | if inspect.isclass(self.DJANGO_SETTINGS_MODULE): 57 | settings_obj = self.DJANGO_SETTINGS_MODULE() 58 | else: 59 | settings_obj = self.DJANGO_SETTINGS_MODULE 60 | 61 | settings_dict = { 62 | key: value 63 | for key, value in settings_obj.dict().items() 64 | if key == key.upper() 65 | and ( 66 | hasattr(global_settings, key) is False 67 | # Running the test suite can modify settings.DATABASES, so always 68 | # override the mutable global_settings.DATABASES. 69 | or key == "DATABASES" 70 | or value != getattr(global_settings, key) 71 | ) 72 | } 73 | settings.configure(**settings_dict) 74 | return True 75 | 76 | 77 | def _get_default_setting(setting: str) -> Any: 78 | return getattr(global_settings, setting, None) 79 | 80 | 81 | class PydanticSettings(BaseSettings): 82 | BASE_DIR: Optional[DirectoryPath] = None 83 | 84 | DEBUG: Optional[bool] = global_settings.DEBUG 85 | DEBUG_PROPAGATE_EXCEPTIONS: Optional[ 86 | bool 87 | ] = global_settings.DEBUG_PROPAGATE_EXCEPTIONS 88 | ADMINS: Optional[List[Tuple[str, EmailStr]]] = _get_default_setting("ADMIN") 89 | INTERNAL_IPS: Optional[List[IPvAnyAddress]] = _get_default_setting("INTERNAL_IPS") 90 | 91 | # Would be nice to do something like Union[Literal["*"], IPvAnyAddress, AnyUrl], but 92 | # there are a lot of different options that need to be valid and don't necessarily 93 | # fit those types. 94 | ALLOWED_HOSTS: Optional[List[str]] = global_settings.ALLOWED_HOSTS 95 | 96 | # Validate against actual list of valid TZs? 97 | # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List 98 | TIME_ZONE: Optional[str] = global_settings.TIME_ZONE 99 | USE_TZ: Optional[bool] 100 | 101 | # Validate LANGUAGE_CODE and LANGUAGES_BIDI against LANGUAGES. 102 | LANGUAGE_CODE: Optional[str] = global_settings.LANGUAGE_CODE 103 | LANGUAGES: Optional[List[Tuple[str, str]]] = global_settings.LANGUAGES 104 | LANGUAGES_BIDI: Optional[List[str]] = global_settings.LANGUAGES_BIDI 105 | 106 | USE_I18N: Optional[bool] = global_settings.USE_I18N 107 | LOCALE_PATHS: Optional[List[DirectoryPath]] = _get_default_setting("LOCALE_PATHS") 108 | LANGUAGE_COOKIE_NAME: Optional[str] = global_settings.LANGUAGE_COOKIE_NAME 109 | LANGUAGE_COOKIE_AGE: Optional[int] = global_settings.LANGUAGE_COOKIE_AGE 110 | LANGUAGE_COOKIE_DOMAIN: Optional[str] = global_settings.LANGUAGE_COOKIE_DOMAIN 111 | LANGUAGE_COOKIE_PATH: Optional[str] = global_settings.LANGUAGE_COOKIE_PATH 112 | LANGUAGE_COOKIE_SECURE: Optional[bool] = _get_default_setting( 113 | "LANGUAGE_COOKIE_SECURE" 114 | ) 115 | LANGUAGE_COOKIE_HTTPONLY: Optional[bool] = _get_default_setting( 116 | "LANGUAGE_COOKIE_HTTPONLY" 117 | ) 118 | LANGUAGE_COOKIE_SAMESITE: Optional[ 119 | Literal["Lax", "Strict", "None"] 120 | ] = _get_default_setting("LANGUAGE_COOKIE_SAMESITE") 121 | USE_L10N: Optional[bool] = global_settings.USE_L10N 122 | MANAGERS: Optional[List[Tuple[str, EmailStr]]] = _get_default_setting("MANAGERS") 123 | DEFAULT_CHARSET: Optional[str] = global_settings.DEFAULT_CHARSET 124 | SERVER_EMAIL: Optional[ 125 | Union[EmailStr, Literal["root@localhost"]] 126 | ] = global_settings.SERVER_EMAIL # type: ignore 127 | 128 | DATABASES: Dict[str, DatabaseModel] = global_settings.DATABASES # type: ignore 129 | DATABASE_ROUTERS: Optional[ 130 | List[str] 131 | ] = global_settings.DATABASE_ROUTERS # type: ignore 132 | EMAIL_BACKEND: Optional[str] = global_settings.EMAIL_BACKEND 133 | EMAIL_HOST: Optional[str] = global_settings.EMAIL_HOST 134 | EMAIL_PORT: Optional[int] = global_settings.EMAIL_PORT 135 | EMAIL_USE_LOCALTIME: Optional[bool] = global_settings.EMAIL_USE_LOCALTIME 136 | EMAIL_HOST_USER: Optional[str] = global_settings.EMAIL_HOST_USER 137 | EMAIL_HOST_PASSWORD: Optional[str] = global_settings.EMAIL_HOST_PASSWORD 138 | EMAIL_USE_TLS: Optional[bool] = global_settings.EMAIL_USE_TLS 139 | EMAIL_USE_SSL: Optional[bool] = global_settings.EMAIL_USE_SSL 140 | EMAIL_SSL_CERTFILE: Optional[ 141 | FilePath 142 | ] = global_settings.EMAIL_SSL_CERTFILE # type: ignore 143 | EMAIL_SSL_KEYFILE: Optional[ 144 | FilePath 145 | ] = global_settings.EMAIL_SSL_KEYFILE # type: ignore 146 | EMAIL_TIMEOUT: Optional[int] = global_settings.EMAIL_TIMEOUT 147 | INSTALLED_APPS: Optional[List[str]] = global_settings.INSTALLED_APPS 148 | TEMPLATES: Optional[ 149 | List[TemplateBackendModel] 150 | ] = global_settings.TEMPLATES # type: ignore 151 | FORM_RENDERER: Optional[str] = global_settings.FORM_RENDERER 152 | DEFAULT_FROM_EMAIL: Optional[str] = global_settings.DEFAULT_FROM_EMAIL 153 | EMAIL_SUBJECT_PREFIX: Optional[str] = global_settings.EMAIL_SUBJECT_PREFIX 154 | APPEND_SLASH: Optional[bool] = global_settings.APPEND_SLASH 155 | PREPEND_WWW: Optional[bool] = global_settings.PREPEND_WWW 156 | FORCE_SCRIPT_NAME: Optional[str] = global_settings.FORCE_SCRIPT_NAME 157 | DISALLOWED_USER_AGENTS: Optional[ 158 | List[Pattern] 159 | ] = global_settings.DISALLOWED_USER_AGENTS 160 | ABSOLUTE_URL_OVERRIDES: Optional[ 161 | Dict[str, Callable] 162 | ] = global_settings.ABSOLUTE_URL_OVERRIDES 163 | IGNORABLE_404_URLS: Optional[List[Pattern]] = global_settings.IGNORABLE_404_URLS 164 | SECRET_KEY: str = Field(default_factory=get_random_secret_key) 165 | DEFAULT_FILE_STORAGE: Optional[str] = global_settings.DEFAULT_FILE_STORAGE 166 | MEDIA_ROOT: Optional[str] = global_settings.MEDIA_ROOT 167 | MEDIA_URL: Optional[str] = global_settings.MEDIA_URL 168 | STATIC_ROOT: Optional[DirectoryPath] = global_settings.STATIC_ROOT # type: ignore 169 | STATIC_URL: Optional[str] = global_settings.STATIC_URL 170 | FILE_UPLOAD_HANDLERS: Optional[List[str]] = global_settings.FILE_UPLOAD_HANDLERS 171 | FILE_UPLOAD_MAX_MEMORY_SIZE: Optional[ 172 | int 173 | ] = global_settings.FILE_UPLOAD_MAX_MEMORY_SIZE 174 | DATA_UPLOAD_MAX_MEMORY_SIZE: Optional[ 175 | int 176 | ] = global_settings.DATA_UPLOAD_MAX_MEMORY_SIZE 177 | DATA_UPLOAD_MAX_NUMBER_FIELDS: Optional[ 178 | int 179 | ] = global_settings.DATA_UPLOAD_MAX_NUMBER_FIELDS 180 | FILE_UPLOAD_TEMP_DIR: Optional[ 181 | DirectoryPath 182 | ] = global_settings.FILE_UPLOAD_TEMP_DIR # type: ignore 183 | FILE_UPLOAD_PERMISSIONS: Optional[int] = global_settings.FILE_UPLOAD_PERMISSIONS 184 | FILE_UPLOAD_DIRECTORY_PERMISSIONS: Optional[ 185 | int 186 | ] = global_settings.FILE_UPLOAD_DIRECTORY_PERMISSIONS 187 | FORMAT_MODULE_PATH: Optional[str] = global_settings.FORMAT_MODULE_PATH 188 | DATE_FORMAT: Optional[str] = global_settings.DATE_FORMAT 189 | DATETIME_FORMAT: Optional[str] = global_settings.DATETIME_FORMAT 190 | TIME_FORMAT: Optional[str] = global_settings.TIME_FORMAT 191 | YEAR_MONTH_FORMAT: Optional[str] = global_settings.YEAR_MONTH_FORMAT 192 | MONTH_DAY_FORMAT: Optional[str] = global_settings.MONTH_DAY_FORMAT 193 | SHORT_DATE_FORMAT: Optional[str] = global_settings.SHORT_DATE_FORMAT 194 | SHORT_DATETIME_FORMAT: Optional[str] = global_settings.SHORT_DATETIME_FORMAT 195 | DATE_INPUT_FORMATS: Optional[List[str]] = global_settings.DATE_INPUT_FORMATS 196 | TIME_INPUT_FORMATS: Optional[List[str]] = global_settings.TIME_INPUT_FORMATS 197 | DATETIME_INPUT_FORMATS: Optional[List[str]] = global_settings.DATETIME_INPUT_FORMATS 198 | FIRST_DAY_OF_WEEK: Optional[int] = global_settings.FIRST_DAY_OF_WEEK 199 | DECIMAL_SEPARATOR: Optional[str] = global_settings.DECIMAL_SEPARATOR 200 | USE_THOUSAND_SEPARATOR: Optional[bool] = global_settings.USE_THOUSAND_SEPARATOR 201 | THOUSAND_SEPARATOR: Optional[str] = global_settings.THOUSAND_SEPARATOR 202 | DEFAULT_TABLESPACE: Optional[str] = global_settings.DEFAULT_TABLESPACE 203 | DEFAULT_INDEX_TABLESPACE: Optional[str] = global_settings.DEFAULT_INDEX_TABLESPACE 204 | X_FRAME_OPTIONS: Optional[str] = global_settings.X_FRAME_OPTIONS 205 | USE_X_FORWARDED_HOST: Optional[bool] = global_settings.USE_X_FORWARDED_HOST 206 | USE_X_FORWARDED_PORT: Optional[bool] = global_settings.USE_X_FORWARDED_PORT 207 | WSGI_APPLICATION: Optional[str] = None 208 | SECURE_PROXY_SSL_HEADER: Optional[ 209 | Tuple[str, str] 210 | ] = global_settings.SECURE_PROXY_SSL_HEADER 211 | DEFAULT_HASHING_ALGORITHM: Optional[ 212 | Literal["sha1", "sha256"] 213 | ] = _get_default_setting("DEFAULT_HASHING_ALGORITHM") 214 | MIDDLEWARE: Optional[List[str]] = global_settings.MIDDLEWARE 215 | SESSION_CACHE_ALIAS: Optional[str] = global_settings.SESSION_CACHE_ALIAS 216 | SESSION_COOKIE_NAME: Optional[str] = global_settings.SESSION_COOKIE_NAME 217 | SESSION_COOKIE_AGE: Optional[int] = global_settings.SESSION_COOKIE_AGE 218 | SESSION_COOKIE_DOMAIN: Optional[str] = global_settings.SESSION_COOKIE_DOMAIN 219 | SESSION_COOKIE_SECURE: Optional[bool] = global_settings.SESSION_COOKIE_SECURE 220 | SESSION_COOKIE_PATH: Optional[str] = global_settings.SESSION_COOKIE_PATH 221 | SESSION_COOKIE_HTTPONLY: Optional[bool] = global_settings.SESSION_COOKIE_HTTPONLY 222 | SESSION_COOKIE_SAMESITE: Optional[ 223 | Literal["Lax", "Strict", "None"] 224 | ] = _get_default_setting( 225 | "SESSION_COOKIE_SAMESITE" 226 | ) # type: ignore 227 | SESSION_SAVE_EVERY_REQUEST: Optional[ 228 | bool 229 | ] = global_settings.SESSION_SAVE_EVERY_REQUEST 230 | SESSION_EXPIRE_AT_BROWSER_CLOSE: Optional[ 231 | bool 232 | ] = global_settings.SESSION_EXPIRE_AT_BROWSER_CLOSE 233 | SESSION_ENGINE: Optional[str] = global_settings.SESSION_ENGINE 234 | SESSION_FILE_PATH: Optional[ 235 | DirectoryPath 236 | ] = global_settings.SESSION_FILE_PATH # type: ignore 237 | SESSION_SERIALIZER: Optional[str] = global_settings.SESSION_SERIALIZER 238 | CACHES: Dict[str, CacheModel] = global_settings.CACHES # type: ignore 239 | CACHE_MIDDLEWARE_KEY_PREFIX: Optional[ 240 | str 241 | ] = global_settings.CACHE_MIDDLEWARE_KEY_PREFIX 242 | CACHE_MIDDLEWARE_SECONDS: Optional[int] = global_settings.CACHE_MIDDLEWARE_SECONDS 243 | CACHE_MIDDLEWARE_ALIAS: Optional[str] = global_settings.CACHE_MIDDLEWARE_ALIAS 244 | AUTH_USER_MODEL: Optional[str] = global_settings.AUTH_USER_MODEL 245 | AUTHENTICATION_BACKENDS: Optional[ 246 | Sequence[str] 247 | ] = global_settings.AUTHENTICATION_BACKENDS 248 | LOGIN_URL: Optional[str] = global_settings.LOGIN_URL 249 | LOGIN_REDIRECT_URL: Optional[str] = global_settings.LOGIN_REDIRECT_URL 250 | PASSWORD_RESET_TIMEOUT_DAYS: Optional[int] = _get_default_setting( 251 | "PASSWORD_RESET_TIMEOUT_DAYS" 252 | ) 253 | PASSWORD_RESET_TIMEOUT: Optional[int] = _get_default_setting( 254 | "PASSWORD_RESET_TIMEOUT" 255 | ) 256 | PASSWORD_HASHERS: Optional[List[str]] = global_settings.PASSWORD_HASHERS 257 | AUTH_PASSWORD_VALIDATORS: Optional[ 258 | List[dict] 259 | ] = global_settings.AUTH_PASSWORD_VALIDATORS 260 | SIGNING_BACKEND: Optional[str] = global_settings.SIGNING_BACKEND 261 | CSRF_FAILURE_VIEW: Optional[str] = global_settings.CSRF_FAILURE_VIEW 262 | CSRF_COOKIE_NAME: Optional[str] = global_settings.CSRF_COOKIE_NAME 263 | CSRF_COOKIE_AGE: Optional[int] = global_settings.CSRF_COOKIE_AGE 264 | CSRF_COOKIE_DOMAIN: Optional[str] = global_settings.CSRF_COOKIE_DOMAIN 265 | CSRF_COOKIE_PATH: Optional[str] = global_settings.CSRF_COOKIE_PATH 266 | CSRF_COOKIE_SECURE: Optional[bool] = global_settings.CSRF_COOKIE_SECURE 267 | CSRF_COOKIE_HTTPONLY: Optional[bool] = global_settings.CSRF_COOKIE_HTTPONLY 268 | CSRF_COOKIE_SAMESITE: Optional[ 269 | Literal["Lax", "Strict", "None"] 270 | ] = _get_default_setting( 271 | "CSRF_COOKIE_SAMESITE" 272 | ) # type: ignore 273 | CSRF_HEADER_NAME: Optional[str] = global_settings.CSRF_HEADER_NAME 274 | CSRF_TRUSTED_ORIGINS: Optional[List[str]] = global_settings.CSRF_TRUSTED_ORIGINS 275 | CSRF_USE_SESSIONS: Optional[bool] = global_settings.CSRF_USE_SESSIONS 276 | MESSAGE_STORAGE: Optional[str] = global_settings.MESSAGE_STORAGE 277 | LOGGING_CONFIG: Optional[str] = global_settings.LOGGING_CONFIG 278 | LOGGING: Optional[dict] = global_settings.LOGGING 279 | DEFAULT_EXCEPTION_REPORTER: Optional[str] = _get_default_setting( 280 | "DEFAULT_EXCEPTION_REPORTER" 281 | ) 282 | DEFAULT_EXCEPTION_REPORTER_FILTER: Optional[ 283 | str 284 | ] = global_settings.DEFAULT_EXCEPTION_REPORTER_FILTER 285 | TEST_RUNNER: Optional[str] = global_settings.TEST_RUNNER 286 | TEST_NON_SERIALIZED_APPS: Optional[ 287 | List[str] 288 | ] = global_settings.TEST_NON_SERIALIZED_APPS 289 | FIXTURE_DIRS: Optional[ 290 | List[DirectoryPath] 291 | ] = global_settings.FIXTURE_DIRS # type: ignore 292 | STATICFILES_DIRS: Optional[ 293 | List[DirectoryPath] 294 | ] = global_settings.STATICFILES_DIRS # type: ignore 295 | STATICFILES_STORAGE: Optional[str] = global_settings.STATICFILES_STORAGE 296 | STATICFILES_FINDERS: Optional[List[str]] = global_settings.STATICFILES_FINDERS 297 | MIGRATION_MODULES: Optional[Dict[str, str]] = global_settings.MIGRATION_MODULES 298 | SILENCED_SYSTEM_CHECKS: Optional[List[str]] = global_settings.SILENCED_SYSTEM_CHECKS 299 | SECURE_BROWSER_XSS_FILTER: Optional[bool] = _get_default_setting( 300 | "SECURE_BROWSER_XSS_FILTER" 301 | ) 302 | SECURE_CONTENT_TYPE_NOSNIFF: Optional[ 303 | bool 304 | ] = global_settings.SECURE_CONTENT_TYPE_NOSNIFF 305 | SECURE_HSTS_INCLUDE_SUBDOMAINS: Optional[ 306 | bool 307 | ] = global_settings.SECURE_HSTS_INCLUDE_SUBDOMAINS 308 | SECURE_HSTS_PRELOAD: Optional[bool] = global_settings.SECURE_HSTS_PRELOAD 309 | SECURE_HSTS_SECONDS: Optional[int] = global_settings.SECURE_HSTS_SECONDS 310 | SECURE_REDIRECT_EXEMPT: Optional[ 311 | List[Pattern] 312 | ] = global_settings.SECURE_REDIRECT_EXEMPT # type: ignore 313 | SECURE_REFERRER_POLICY: Optional[ 314 | Literal[ 315 | "no-referrer", 316 | "no-referrer-when-downgrade", 317 | "origin", 318 | "origin-when-cross-origin", 319 | "same-origin", 320 | "strict-origin", 321 | "strict-origin-when-cross-origin", 322 | "unsafe-url", 323 | ] 324 | ] = _get_default_setting("SECURE_REFERRER_POLICY") 325 | SECURE_SSL_HOST: Optional[str] = global_settings.SECURE_SSL_HOST 326 | SECURE_SSL_REDIRECT: Optional[bool] = global_settings.SECURE_SSL_REDIRECT 327 | 328 | ROOT_URLCONF: Optional[str] = None 329 | 330 | default_database_dsn: Optional[DatabaseDsn] = Field( 331 | env="DATABASE_URL", configure_database="default" 332 | ) 333 | default_cache_dsn: Optional[CacheDsn] = Field( 334 | env="CACHE_URL", configure_cache="default" 335 | ) 336 | 337 | class Config: 338 | env_prefix = "DJANGO_" 339 | 340 | @validator("DATABASES", pre=True) 341 | def parse_databases(cls, databases: dict) -> dict: 342 | """ 343 | Parse any databases specified as DSNs into DatabaseModel objects. 344 | """ 345 | parsed_databases = {} 346 | for key, value in databases.items(): 347 | if value and isinstance(value, str) and not isinstance(value, DatabaseDsn): 348 | value = parse_obj_as(DatabaseDsn, value) 349 | if isinstance(value, DatabaseDsn): 350 | value = value.to_settings_model() 351 | if value: 352 | parsed_databases[key] = value 353 | return parsed_databases 354 | 355 | @root_validator 356 | def set_default_database(cls, values: dict) -> dict: 357 | """ 358 | Set the default database if it is not already set and is provided by 359 | default_database_dsn field. 360 | """ 361 | DATABASES = values["DATABASES"] 362 | for db_key, attr in cls._get_dsn_fields(field_extra="configure_database"): 363 | if not DATABASES.get(db_key): 364 | database_dsn: Optional[DatabaseDsn] = values[attr] 365 | if database_dsn: 366 | DATABASES[db_key] = database_dsn.to_settings_model() 367 | del values[attr] 368 | return values 369 | 370 | @root_validator 371 | def set_default_cache(cls, values: dict) -> dict: 372 | """ 373 | Set the default cache if it is not already set and is provided by 374 | default_cache_dsn field. 375 | """ 376 | CACHES = values.get("CACHES") or {} 377 | for cache_key, attr in cls._get_dsn_fields(field_extra="configure_cache"): 378 | cache_dsn: Optional[CacheDsn] = values[attr] 379 | if cache_dsn: 380 | CACHES = values.setdefault("CACHES", {}) 381 | CACHES[cache_key] = cache_dsn.to_settings_model() 382 | del values[attr] 383 | return values 384 | 385 | @classmethod 386 | def _get_dsn_fields(cls, field_extra: str) -> Iterable[Tuple[str, str]]: 387 | field: ModelField 388 | for field in cls.__fields__.values(): 389 | db_key = field.field_info.extra.get(field_extra) 390 | if db_key: 391 | yield db_key, field.name 392 | 393 | @root_validator 394 | def get_dynamic_defaults(cls, values): 395 | """ 396 | Get dynamic defaults for BASE_DIR, ROOT_URLCONF and WSGI_APPLICATION. 397 | 398 | If any of these settings are not explicitly set, and a custom `PydanticSettings` 399 | class is being used then ROOT_URLCONF and WSGI_APPLICATION will be prepended 400 | with the settings root module and BASE_DIR will be set to the parent directory 401 | containing the root module package. 402 | """ 403 | module = inspect.getmodule(cls) 404 | if not module or module.__name__.startswith("pydantic_settings."): 405 | return values 406 | 407 | project_module_name = module.__name__.split(".", 1)[0] 408 | if project_module_name: 409 | if not values["WSGI_APPLICATION"]: 410 | values["WSGI_APPLICATION"] = f"{project_module_name}.wsgi.application" 411 | if not values["ROOT_URLCONF"]: 412 | values["ROOT_URLCONF"] = f"{project_module_name}.urls" 413 | 414 | base_dir: Optional[Path] = values["BASE_DIR"] 415 | if not base_dir: 416 | ancestor = module.__name__.count(".") 417 | path = Path(inspect.getfile(module)).resolve() 418 | if path.stem == "__init__": 419 | ancestor += 1 420 | values["BASE_DIR"] = path.parents[ancestor] 421 | 422 | return values 423 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-pydantic-settings" 3 | version = "0.6.3" 4 | description = "Manage Django settings with Pydantic." 5 | authors = ["Josh Ourisman "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/joshourisman/django-pydantic-settings" 9 | repository = "https://github.com/joshourisman/django-pydantic-settings" 10 | packages = [{ include = "pydantic_settings" }] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.8" 14 | Django = ">=1.11" 15 | sentry-sdk = { version = "*", optional = true } 16 | pydantic = { version = "^1.8", extras = ["email"] } 17 | typing-extensions = { version = ">=3.7.4,<5.0.0", python = '<2.8' } 18 | 19 | [tool.poetry.extras] 20 | sentry = ["sentry-sdk"] 21 | 22 | [tool.poetry.dev-dependencies] 23 | pytest = "^7.3.1" 24 | pytest-dotenv = "^0.5.2" 25 | tox = "^4.11.1" 26 | 27 | [build-system] 28 | requires = ["poetry-core>=1.0.0"] 29 | build-backend = "poetry.core.masonry.api" 30 | 31 | [tool.isort] 32 | profile = "black" 33 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import sys 4 | 5 | from pydantic_settings import SetUp 6 | 7 | 8 | def main(): 9 | """Run administrative tasks.""" 10 | SetUp().configure() 11 | 12 | try: 13 | from django.core.management import execute_from_command_line 14 | except ImportError as exc: 15 | raise ImportError( 16 | "Couldn't import Django. Are you sure it's installed and " 17 | "available on your PYTHONPATH environment variable? Did you " 18 | "forget to activate a virtual environment?" 19 | ) from exc 20 | execute_from_command_line(sys.argv) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /tests/settings_confdir_proj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshourisman/django-pydantic-settings/c6d8f701f441f0ed37948f00f575161532fb4c9d/tests/settings_confdir_proj/__init__.py -------------------------------------------------------------------------------- /tests/settings_confdir_proj/conf/__init__.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import PydanticSettings 2 | 3 | 4 | class Settings(PydanticSettings): 5 | ... 6 | -------------------------------------------------------------------------------- /tests/settings_proj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshourisman/django-pydantic-settings/c6d8f701f441f0ed37948f00f575161532fb4c9d/tests/settings_proj/__init__.py -------------------------------------------------------------------------------- /tests/settings_proj/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for settings_test project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | from django.conf import settings 11 | from django.core.asgi import get_asgi_application 12 | 13 | from pydantic_settings import SetUp 14 | 15 | SetUp().configure() 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /tests/settings_proj/conf.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import Field 4 | 5 | from pydantic_settings import PydanticSettings 6 | from pydantic_settings.database import DatabaseDsn 7 | from pydantic_settings.default import DjangoDefaultProjectSettings 8 | 9 | 10 | class TestSettings(PydanticSettings): 11 | secondary_database_dsn: Optional[DatabaseDsn] = Field( 12 | env="SECONDARY_DATABASE_URL", configure_database="secondary" 13 | ) 14 | 15 | 16 | class TestDefaultSettings(DjangoDefaultProjectSettings): 17 | ROOT_URLCONF = "tests.settings_test.urls" 18 | 19 | 20 | if __name__ == "__main__": 21 | # Run this file directly with DATABASE_URL and/or SECONDARY_DATABASE_URL environment 22 | # variables to test the database settings. 23 | settings = TestSettings() 24 | import pprint 25 | 26 | pprint.pprint(settings.dict(exclude_defaults=True).get("DATABASES")) 27 | -------------------------------------------------------------------------------- /tests/settings_proj/urls.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | from django.urls import path 3 | 4 | 5 | def test_view(request): 6 | return JsonResponse({"success": True}) 7 | 8 | 9 | urlpatterns = [ 10 | path("", test_view), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/settings_proj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for settings_test project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | from pydantic_settings import SetUp 14 | 15 | SetUp().configure() 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django import VERSION 3 | from pydantic import BaseModel 4 | 5 | from pydantic_settings.cache import CacheDsn 6 | 7 | 8 | # Do tests against different urls 9 | @pytest.mark.parametrize( 10 | "url,expected,expected_old", 11 | [ 12 | ( 13 | "redis://localhost:6379/0", 14 | { 15 | "BACKEND": "django.core.cache.backends.redis.RedisCache", 16 | "LOCATION": "redis://localhost:6379/0", 17 | }, 18 | { 19 | "BACKEND": "django_redis.cache.RedisCache", 20 | "LOCATION": "redis://localhost:6379/0", 21 | }, 22 | ), 23 | ( 24 | "rediss://localhost:6379/0?db=1", 25 | { 26 | "BACKEND": "django.core.cache.backends.redis.RedisCache", 27 | "LOCATION": "rediss://localhost:6379/0", 28 | }, 29 | { 30 | "BACKEND": "django_redis.cache.RedisCache", 31 | "LOCATION": "rediss://localhost:6379/0", 32 | }, 33 | ), 34 | ( 35 | "hiredis://:password1@localhost:6379/0?db=1", 36 | { 37 | "BACKEND": "django.core.cache.backends.redis.RedisCache", 38 | "LOCATION": "redis://password1@localhost:6379/0", 39 | "OPTIONS": {"PARSER_CLASS": "redis.connection.HiredisParser"}, 40 | }, 41 | { 42 | "BACKEND": "django_redis.cache.RedisCache", 43 | "LOCATION": "redis://localhost:6379/0", 44 | "OPTIONS": { 45 | "PARSER_CLASS": "redis.connection.HiredisParser", 46 | "PASSWORD": "password1", 47 | }, 48 | }, 49 | ), 50 | ( 51 | "uwsgicache:///some/cache", 52 | { 53 | "BACKEND": "uwsgicache.UWSGICache", 54 | "LOCATION": "/some/cache", 55 | }, 56 | None, 57 | ), 58 | ( 59 | "memcached://localhost:11211", 60 | {"BACKEND": "django.core.cache.backends.memcached.PyLibMCCache"}, 61 | None, 62 | ), 63 | ], 64 | ) 65 | def test_cache_dsn(url, expected, expected_old): 66 | class Model(BaseModel): 67 | url: CacheDsn 68 | 69 | settings_model = Model(url=url).url.to_settings_model() 70 | assert settings_model.dict(exclude_defaults=True) == ( 71 | expected if VERSION >= (4, 0) or not expected_old else expected_old 72 | ) 73 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | from django.conf import settings 6 | from django.test import Client 7 | from django.utils.functional import empty 8 | 9 | from pydantic_settings import SetUp 10 | 11 | 12 | @pytest.fixture() 13 | def configure_settings(monkeypatch): 14 | settings._wrapped = empty 15 | 16 | def func(env: dict = {}): 17 | for key, value in env.items(): 18 | monkeypatch.setenv(key, value) 19 | SetUp().configure() 20 | 21 | yield func 22 | settings._wrapped = empty 23 | 24 | 25 | def test_no_env(configure_settings): 26 | """Test that Django can run with no settings specified in the environment.""" 27 | configure_settings() 28 | 29 | assert settings.DATABASES == {} 30 | 31 | 32 | def test_env_loaded(configure_settings): 33 | configure_settings({"DATABASE_URL": "sqlite:///foo"}) 34 | 35 | assert settings.DATABASES["default"]["NAME"] == "foo" 36 | 37 | 38 | def test_cache_loaded(configure_settings): 39 | configure_settings({"CACHE_URL": "redis:///1"}) 40 | 41 | assert "redis" in settings.CACHES["default"]["BACKEND"] 42 | 43 | 44 | def test_env_loaded2(configure_settings): 45 | configure_settings({"DATABASE_URL": "sqlite:///bar"}) 46 | assert settings.DATABASES["default"]["NAME"] == "bar" 47 | 48 | 49 | def test_sqlite_path(configure_settings): 50 | """ 51 | Make sure we aren't improperly stripping the leading slash from the path for SQLite databases with an 52 | absolute path 53 | """ 54 | configure_settings({"DATABASE_URL": "sqlite:////db/test.db"}) 55 | 56 | assert settings.DATABASES["default"]["NAME"] == "/db/test.db" 57 | 58 | 59 | def test_database_port(configure_settings): 60 | configure_settings({"DATABASE_URL": "postgres://foo:bar@foo.com:6543/database"}) 61 | 62 | assert settings.DATABASES["default"]["PORT"] == "6543" 63 | 64 | 65 | def test_multiple_databases(configure_settings): 66 | configure_settings( 67 | { 68 | "DJANGO_SETTINGS_MODULE": "settings_proj.conf.TestSettings", 69 | "DATABASE_URL": "postgres://foo:bar@foo.com:6543/database", 70 | "SECONDARY_DATABASE_URL": "sqlite:///secondary.db", 71 | } 72 | ) 73 | 74 | assert "default" in settings.DATABASES 75 | assert "secondary" in settings.DATABASES 76 | 77 | 78 | def test_allowed_hosts(configure_settings): 79 | configure_settings( 80 | {"DJANGO_ALLOWED_HOSTS": '["*", "127.0.0.1", ".localhost", "[::1]"]'} 81 | ) 82 | 83 | assert settings.DEBUG is False 84 | 85 | 86 | def test_language_cookie_path(configure_settings): 87 | configure_settings({"DJANGO_LANGUAGE_COOKIE_PATH": "/foo/bar"}) 88 | 89 | assert settings.LANGUAGE_COOKIE_PATH == "/foo/bar" 90 | 91 | 92 | def test_email_host_password(configure_settings): 93 | configure_settings({"DJANGO_EMAIL_HOST_PASSWORD": "password1"}) 94 | 95 | assert settings.EMAIL_HOST_PASSWORD == "password1" 96 | 97 | 98 | def test_templates_settings(configure_settings): 99 | configure_settings( 100 | { 101 | "DJANGO_TEMPLATES": json.dumps( 102 | [ 103 | { 104 | "BACKEND": "django.template.backends.django.DjangoTemplates", 105 | "APP_DIRS": True, 106 | } 107 | ] 108 | ) 109 | } 110 | ) 111 | 112 | assert ( 113 | settings.TEMPLATES[0]["BACKEND"] 114 | == "django.template.backends.django.DjangoTemplates" 115 | ) 116 | 117 | 118 | def test_base_dir(configure_settings): 119 | tests_dir = Path(__file__).parent 120 | configure_settings({"DJANGO_BASE_DIR": str(tests_dir)}) 121 | assert settings.BASE_DIR == tests_dir 122 | 123 | 124 | def test_base_dir_default(configure_settings): 125 | configure_settings() 126 | assert settings.BASE_DIR == None 127 | 128 | 129 | def test_dynamic_defaults(configure_settings): 130 | configure_settings() 131 | assert settings.ROOT_URLCONF == None 132 | assert settings.WSGI_APPLICATION == None 133 | 134 | 135 | def test_dynamic_defaults_custom_module(configure_settings): 136 | configure_settings({"DJANGO_SETTINGS_MODULE": "settings_proj.conf.TestSettings"}) 137 | 138 | assert settings.BASE_DIR == Path(__file__).parent 139 | assert settings.ROOT_URLCONF == "settings_proj.urls" 140 | assert settings.WSGI_APPLICATION == "settings_proj.wsgi.application" 141 | 142 | 143 | def test_dynamic_defaults_custom_module_confdir(configure_settings): 144 | configure_settings( 145 | {"DJANGO_SETTINGS_MODULE": "settings_confdir_proj.conf.Settings"} 146 | ) 147 | 148 | assert settings.BASE_DIR == Path(__file__).parent 149 | assert settings.ROOT_URLCONF == "settings_confdir_proj.urls" 150 | assert settings.WSGI_APPLICATION == "settings_confdir_proj.wsgi.application" 151 | 152 | 153 | def test_http(configure_settings): 154 | configure_settings( 155 | {"DJANGO_ROOT_URLCONF": "settings_proj.urls", "DJANGO_DEBUG": "True"} 156 | ) 157 | client = Client() 158 | response = client.get("/") 159 | 160 | assert response.json()["success"] is True 161 | 162 | 163 | def test_escaped_gcp_cloudsql_socket(configure_settings): 164 | tests_dir = Path(__file__).parent 165 | configure_settings( 166 | { 167 | "DJANGO_BASE_DIR": str(tests_dir), 168 | "DATABASE_URL": "postgres://username:password@%2Fcloudsql%2Fproject%3Aregion%3Ainstance/database", 169 | } 170 | ) 171 | 172 | # assert "default" in settings.DATABASES 173 | 174 | default = settings.DATABASES["default"] 175 | assert default["NAME"] == "database" 176 | assert default["USER"] == "username" 177 | assert default["PASSWORD"] == "password" 178 | assert default["HOST"] == "/cloudsql/project:region:instance" 179 | assert default["ENGINE"] == "django.db.backends.postgresql" 180 | 181 | 182 | from django.conf import global_settings 183 | 184 | gd = global_settings.DATABASES 185 | 186 | 187 | def test_unescaped_gcp_cloudsql_socket(configure_settings): 188 | tests_dir = Path(__file__).parent 189 | configure_settings( 190 | { 191 | "DJANGO_BASE_DIR": str(tests_dir), 192 | "DATABASE_URL": "postgres://username:password@/cloudsql/project:region:instance/database", 193 | } 194 | ) 195 | 196 | assert "default" in settings.DATABASES 197 | 198 | default = settings.DATABASES["default"] 199 | assert default["NAME"] == "database" 200 | assert default["USER"] == "username" 201 | assert default["PASSWORD"] == "password" 202 | assert default["HOST"] == "/cloudsql/project:region:instance" 203 | assert default["ENGINE"] == "django.db.backends.postgresql" 204 | 205 | 206 | def test_default_db(configure_settings): 207 | base_dir = Path(__file__).parent 208 | configure_settings( 209 | { 210 | "DJANGO_SETTINGS_MODULE": "settings_proj.conf.TestDefaultSettings", 211 | "DJANGO_BASE_DIR": base_dir, 212 | } 213 | ) 214 | 215 | default = settings.DATABASES["default"] 216 | assert default["NAME"] == str(base_dir / "db.sqlite3") 217 | 218 | 219 | def test_default_db_no_basedir(configure_settings): 220 | configure_settings( 221 | { 222 | "DJANGO_SETTINGS_MODULE": "pydantic_settings.default.DjangoDefaultProjectSettings", 223 | } 224 | ) 225 | 226 | default = settings.DATABASES["default"] 227 | assert default["NAME"] == "db.sqlite3" 228 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | isolated_build = true 8 | envlist = django{21,22,30,31,32,40,41} 9 | 10 | [testenv] 11 | whitelist_externals = poetry 12 | deps = 13 | pytest 14 | pydantic[email] 15 | django21: Django >=2.1, < 2.2 16 | django22: Django >=2.2, < 3.0 17 | django30: Django >=3.0, < 3.1 18 | django31: Django >=3.1, < 3.2 19 | django32: Django >=3.2, < 3.3 20 | django40: Django >=4.0, < 4.1 21 | django41: Django >=4.1, < 4.2 22 | commands = 23 | pytest 24 | --------------------------------------------------------------------------------