├── tests ├── __init__.py └── test_dj_database_url.py ├── dj_database_url ├── py.typed └── __init__.py ├── .isort.cfg ├── .flake8 ├── CONTRIBUTING.md ├── .pre-commit-config.yaml ├── .gitignore ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── LICENSE ├── pyproject.toml ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dj_database_url/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | per-file-ignores= 5 | tests/test_dj_database_url.py: E501, E265 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 4 | 5 | Please see the 6 | [full contributing documentation](https://django-debug-toolbar.readthedocs.io/en/stable/contributing.html) 7 | for more help. 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | 8 | - repo: https://github.com/pycqa/isort 9 | rev: "7.0.0" 10 | hooks: 11 | - id: isort 12 | args: ["--profile", "black"] 13 | 14 | - repo: https://github.com/psf/black-pre-commit-mirror 15 | rev: 25.12.0 16 | hooks: 17 | - id: black 18 | args: [--target-version=py38] 19 | 20 | - repo: https://github.com/pycqa/flake8 21 | rev: '7.3.0' 22 | hooks: 23 | - id: flake8 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | .tox/ 31 | .coverage 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | 36 | # Translations 37 | *.mo 38 | 39 | # Mr Developer 40 | .mr.developer.cfg 41 | .project 42 | .pydevproject 43 | 44 | # Rope 45 | .ropeproject 46 | 47 | # Django stuff: 48 | *.log 49 | *.pot 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # Virtualenv 55 | env/ 56 | .vscode/ 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created, published] 6 | 7 | jobs: 8 | build: 9 | if: github.repository == 'jazzband/dj-database-url' 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v6 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set version from release/tag 18 | id: version 19 | run: | 20 | VERSION=${GITHUB_REF#refs/*/} 21 | VERSION=${VERSION#v} 22 | echo "VERSION=$VERSION" >> $GITHUB_ENV 23 | echo "version=$VERSION" >> $GITHUB_OUTPUT 24 | 25 | - uses: astral-sh/setup-uv@v7 26 | with: 27 | python-version: 3.12 28 | 29 | - name: Build package 30 | run: | 31 | uv version ${{ env.VERSION }} 32 | uv build 33 | uvx twine check dist/* 34 | 35 | - name: Upload packages to Jazzband 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/dj-database-url/upload 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Kenneth Reitz & individual contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | typecheck: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v6 8 | 9 | - name: Install uv 10 | uses: astral-sh/setup-uv@v7 11 | with: 12 | python-version: "3.12" 13 | 14 | - name: Run mypy 15 | run: uvx mypy dj_database_url 16 | 17 | - name: Run pyright 18 | run: uvx pyright dj_database_url 19 | 20 | test: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 26 | django-version: ["4.2", "5.2", "6.0"] 27 | exclude: 28 | # django 4.x is not compatible with python 3.13 or higher 29 | - python-version: "3.13" 30 | django-version: "4.2" 31 | - python-version: "3.14" 32 | django-version: "4.2" 33 | # django 6.x is not compatible with python 3.11 or lower 34 | - python-version: "3.10" 35 | django-version: "6.0" 36 | - python-version: "3.11" 37 | django-version: "6.0" 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - name: Install uv and set the Python version 43 | uses: astral-sh/setup-uv@v7 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | activate-environment: 'true' 47 | 48 | - name: Install dependencies 49 | run: | 50 | uv pip install "Django~=${{ matrix.django-version }}.0" 51 | 52 | - name: Run Tests 53 | run: | 54 | echo "$(python --version) / Django $(django-admin --version)" 55 | uvx coverage run --source=dj_database_url --branch -m unittest discover -v 56 | uvx coverage report 57 | uvx coverage xml 58 | 59 | - uses: codecov/codecov-action@v4 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dj-database-url" 3 | version = "0.0.0" 4 | description = "Use Database URLs in your Django Application." 5 | authors = [ 6 | { name = "Jazzband community" } 7 | ] 8 | readme = "README.rst" 9 | requires-python = ">=3.10" 10 | license = "BSD-3-Clause" 11 | license-files = ["LICENSE"] 12 | dependencies = [ 13 | "django>=4.2", 14 | ] 15 | classifiers = [ 16 | "Environment :: Web Environment", 17 | "Framework :: Django", 18 | "Framework :: Django :: 4.2", 19 | "Framework :: Django :: 5.2", 20 | "Framework :: Django :: 6", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: BSD License", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | "Programming Language :: Python :: 3.14", 34 | ] 35 | 36 | 37 | [build-system] 38 | requires = ["uv_build>=0.9.17,<0.10.0"] 39 | build-backend = "uv_build" 40 | 41 | [tool.uv.build-backend] 42 | module-name = "dj_database_url" 43 | module-root = "" 44 | source-include = ["dj_database_url/py.typed"] 45 | 46 | [tool.black] 47 | skip-string-normalization = 1 48 | 49 | [tool.mypy] 50 | show_error_codes=true 51 | disallow_untyped_defs=true 52 | disallow_untyped_calls=true 53 | warn_redundant_casts=true 54 | 55 | [tool.pyright] 56 | typeCheckingMode = "strict" 57 | 58 | [dependency-groups] 59 | dev = [ 60 | "coverage>=7.13.0", 61 | "mypy>=1.19.1", 62 | "pyright>=1.1.407", 63 | "ruff>=0.14.9", 64 | "setuptools>=80.9.0", 65 | "twine>=6.2.0", 66 | "wheel>=0.45.1", 67 | ] 68 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v3.0.1 (2025-07-01) 4 | * Drop dependency on `typing_extensions`. 5 | 6 | ## v3.0.0 (2025-05-18) 7 | > Bumping to version 3; changes to code do break some API compatability. 8 | * Implement a new decorator registry pattern to impement checks on database connection string. 9 | * You can now support and implement your own database strings by extending the @register functionality. 10 | * Update supported python versions and django versions. 11 | 12 | ## v2.3.0 (2024-10-23) 13 | * Remove Python 3.8 support. 14 | * Remove Django 3 support. 15 | * Add python 3.13 support. 16 | * Add Django 5.1 to the testing library. 17 | 18 | ## v2.2.0 (2024-05-28) 19 | * Add disable_server_side_cursors parameter 20 | * Enhance Query String Parsing for Server-Side Binding in Django 4.2 with psycopg 3.1.8+ 21 | * Update django 5.0 python compatability by @mattseymour in #239 22 | * Improved internals 23 | * Improved documentation 24 | 25 | ## v2.1.0 (2023-08-15) 26 | 27 | * Add value to int parsing when deconstructing url string. 28 | 29 | ## v2.0.0 (2023-04-27) 30 | 31 | * Update project setup such that we now install as a package. 32 | 33 | _Notes_: while this does not alter the underlying application code, we are bumping to 34 | 2.0 incase there are unforeseen knock on use-case issues. 35 | 36 | ## v1.3.0 (2023-03-27) 37 | 38 | * Cosmetic changes to the generation of schemes. 39 | * Bump isort version - 5.11.5. 40 | * raise warning message if database_url is not set. 41 | * CONN_MAX_AGE fix type - Optional[int]. 42 | 43 | ## v1.2.0 (2022-12-13) 44 | 45 | * Add the ability to add test databases. 46 | * Improve url parsing and encoding. 47 | * Fix missing parameter conn_health_check in check function. 48 | 49 | ## v1.1.0 (2022-12-12) 50 | 51 | * Option for connection health checks parameter. 52 | * Update supported version python 3.11. 53 | * Code changes, various improvments. 54 | * Add project links to setup.py 55 | 56 | ## v1.0.0 (2022-06-18) 57 | 58 | Initial release of code now dj-database-urls is part of jazzband. 59 | 60 | * Add support for cockroachdb. 61 | * Add support for the offical MSSQL connector. 62 | * Update License to be compatible with Jazzband. 63 | * Remove support for Python < 3.5 including Python 2.7 64 | * Update source code to Black format. 65 | * Update CI using pre-commit 66 | 67 | ## v0.5.0 (2018-03-01) 68 | 69 | - Use str port for mssql 70 | - Added license 71 | - Add mssql to readme 72 | - Add mssql support using pyodbc 73 | - Fix RST schemas 74 | - Django expects Oracle Ports as strings 75 | - Fix IPv6 address parsing 76 | - Add testing for Python 3.6 77 | - Revert "Add setup.cfg for wheel support" 78 | - added option of postgis backend to also add path parsing. (test added also) 79 | - Support schema definition for redshift 80 | - add redshift support 81 | - Add testing for Python 3.5 82 | - Drop testing for Python 2.6 83 | - Fixes issue with unix file paths being turned to lower case 84 | - add Redis support 85 | - Added SpatiaLite in README.rst 86 | 87 | ## v0.4.1 (2016-04-06) 88 | 89 | - Enable CA providing for MySQL URIs 90 | - Update Readme 91 | - Update trove classifiers 92 | - Updated setup.py description 93 | 94 | ## v0.4.0 (2016-02-04) 95 | 96 | - Update readme 97 | - Fix for python3 98 | - Handle search path config in connect url for postgres 99 | - Add tox config to ease testing against multiple Python versions 100 | - Simplified the querystring parse logic 101 | - Cleaned up querystring parsing 102 | - supports database options 103 | - Added tests for CONN_MAX_AGE 104 | - Added documentation for conn_max_age 105 | - Add in optional support for CONN_MAX_AGE 106 | - Support special characters in user, password and name fields 107 | - Add oracle support 108 | - Added support for percent-encoded postgres paths 109 | - Fixed test_cleardb_parsing test 110 | - Enable automated testing with Python 3.4 111 | - Add URL schema examples to README 112 | - Added support for python mysql-connector 113 | 114 | ## v0.3.0 (2014-03-10) 115 | 116 | - Add .gitignore file 117 | - Remove .pyc file 118 | - Remove travis-ci unsupported python version Per docs http://docs.travis-ci.com/user/languages/python/ "Travis CI support Python versions 2.6, 2.7, 3.2 and 3.3" 119 | - Fix cleardb test 120 | - Add setup.cfg for wheel support 121 | - Add trove classifiers for python versions 122 | - Replace Python 3.1 with Python 3.3 123 | - Add MySQL (GIS) support 124 | - Ability to set different engine 125 | 126 | ## v0.2.2 (2013-07-17) 127 | 128 | - Added spatialite to uses_netloc too 129 | - Added spatialite backend 130 | - Replacing tab with spaces 131 | - Handling special case of sqlite://:memory: 132 | - Empty sqlite path will now use a :memory: database 133 | - Fixing test to actually use the result of the parse 134 | - Adding in tests to ensure sqlite in-memory databases work 135 | - Fixed too-short title underline 136 | - Added :target: attribute to Travis status image in README 137 | - Added docs for default argument to config 138 | - Add "pgsql" as a PostgreSQL URL scheme. 139 | - Add support for blank fields (Django expects '' not None) 140 | - fixed url 141 | 142 | ## v0.2.1 (2012-06-19) 143 | 144 | - Add python3 support 145 | - Adding travis status and tests 146 | - Adding test environment variables 147 | - Adding test for cleardb 148 | - Remove query strings from name 149 | - Adding postgres tests 150 | - Adding tests 151 | - refactor scheme lookup 152 | - RedHat's OpenShift platform uses the 'postgresql' scheme 153 | - Registered postgis URL scheme 154 | - Added `postgis://` url scheme 155 | - Use get() on os.environ instead of an if 156 | 157 | ## v0.2.0 (2012-05-30) 158 | 159 | - Fix parse(s) 160 | 161 | ## v0.1.4 (2012-05-30) 162 | 163 | - Add defaults for env 164 | - Set the DATABASES dict rather than assigning to it 165 | 166 | ## v0.1.3 (2012-05-01) 167 | 168 | - Add note to README on supported databases 169 | - Add support for SQLite 170 | - Clean dependencies 171 | 172 | ## v0.1.2 (2012-04-30) 173 | 174 | - Update readme 175 | - Refactor config and use new parse function 176 | 177 | ## v0.1.1 (2012-04-30) First release 178 | 179 | 🐍 ✨ 180 | -------------------------------------------------------------------------------- /dj_database_url/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import urllib.parse as urlparse 4 | from typing import Any, Callable, Optional, TypedDict 5 | 6 | DEFAULT_ENV = "DATABASE_URL" 7 | ENGINE_SCHEMES: dict[str, "Engine"] = {} 8 | 9 | 10 | # From https://docs.djangoproject.com/en/stable/ref/settings/#databases 11 | class DBConfig(TypedDict, total=False): 12 | ATOMIC_REQUESTS: bool 13 | AUTOCOMMIT: bool 14 | CONN_MAX_AGE: Optional[int] 15 | CONN_HEALTH_CHECKS: bool 16 | DISABLE_SERVER_SIDE_CURSORS: bool 17 | ENGINE: str 18 | HOST: str 19 | NAME: str 20 | OPTIONS: dict[str, Any] 21 | PASSWORD: str 22 | PORT: str | int 23 | TEST: dict[str, Any] 24 | TIME_ZONE: str 25 | USER: str 26 | 27 | 28 | PostprocessCallable = Callable[[DBConfig], None] 29 | OptionType = int | str | bool 30 | 31 | 32 | class ParseError(ValueError): 33 | def __str__(self) -> str: 34 | return ( 35 | "This string is not a valid url, possibly because some of its parts" 36 | " is not properly urllib.parse.quote()'ed." 37 | ) 38 | 39 | 40 | class UnknownSchemeError(ValueError): 41 | def __init__(self, scheme: str): 42 | self.scheme = scheme 43 | 44 | def __str__(self) -> str: 45 | schemes = ", ".join(sorted(ENGINE_SCHEMES.keys())) 46 | return ( 47 | f"Scheme '{self.scheme}://' is unknown." 48 | " Did you forget to register custom backend?" 49 | f" Following schemes have registered backends: {schemes}." 50 | ) 51 | 52 | 53 | def default_postprocess(parsed_config: DBConfig) -> None: 54 | pass 55 | 56 | 57 | class Engine: 58 | def __init__( 59 | self, 60 | backend: str, 61 | postprocess: PostprocessCallable = default_postprocess, 62 | ): 63 | self.backend = backend 64 | self.postprocess = postprocess 65 | 66 | 67 | def register( 68 | scheme: str, backend: str 69 | ) -> Callable[[PostprocessCallable], PostprocessCallable]: 70 | engine = Engine(backend) 71 | if scheme not in ENGINE_SCHEMES: 72 | urlparse.uses_netloc.append(scheme) 73 | ENGINE_SCHEMES[scheme] = engine 74 | 75 | def inner(func: PostprocessCallable) -> PostprocessCallable: 76 | engine.postprocess = func 77 | return func 78 | 79 | return inner 80 | 81 | 82 | register("spatialite", "django.contrib.gis.db.backends.spatialite") 83 | register("mysql-connector", "mysql.connector.django") 84 | register("mysqlgis", "django.contrib.gis.db.backends.mysql") 85 | register("oraclegis", "django.contrib.gis.db.backends.oracle") 86 | register("cockroach", "django_cockroachdb") 87 | 88 | 89 | @register("sqlite", "django.db.backends.sqlite3") 90 | def default_to_in_memory_db(parsed_config: DBConfig) -> None: 91 | # mimic sqlalchemy behaviour 92 | if not parsed_config.get("NAME"): 93 | parsed_config["NAME"] = ":memory:" 94 | 95 | 96 | @register("oracle", "django.db.backends.oracle") 97 | @register("mssqlms", "mssql") 98 | @register("mssql", "sql_server.pyodbc") 99 | def stringify_port(parsed_config: DBConfig) -> None: 100 | parsed_config["PORT"] = str(parsed_config.get("PORT", "")) 101 | 102 | 103 | @register("mysql", "django.db.backends.mysql") 104 | @register("mysql2", "django.db.backends.mysql") 105 | def apply_ssl_ca(parsed_config: DBConfig) -> None: 106 | options = parsed_config.get("OPTIONS", {}) 107 | ca = options.pop("ssl-ca", None) 108 | if ca: 109 | options["ssl"] = {"ca": ca} 110 | 111 | 112 | @register("postgres", "django.db.backends.postgresql") 113 | @register("postgresql", "django.db.backends.postgresql") 114 | @register("pgsql", "django.db.backends.postgresql") 115 | @register("postgis", "django.contrib.gis.db.backends.postgis") 116 | @register("redshift", "django_redshift_backend") 117 | @register("timescale", "timescale.db.backends.postgresql") 118 | @register("timescalegis", "timescale.db.backends.postgis") 119 | def apply_current_schema(parsed_config: DBConfig) -> None: 120 | options = parsed_config.get("OPTIONS", {}) 121 | schema = options.pop("currentSchema", None) 122 | if schema: 123 | options["options"] = f"-c search_path={schema}" 124 | 125 | 126 | def config( 127 | env: str = DEFAULT_ENV, 128 | default: Optional[str] = None, 129 | engine: Optional[str] = None, 130 | conn_max_age: Optional[int] = 0, 131 | conn_health_checks: bool = False, 132 | disable_server_side_cursors: bool = False, 133 | ssl_require: bool = False, 134 | test_options: Optional[dict[str, Any]] = None, 135 | ) -> DBConfig: 136 | """Returns configured DATABASE dictionary from DATABASE_URL.""" 137 | s = os.environ.get(env, default) 138 | 139 | if s is None: 140 | logging.warning( 141 | "No %s environment variable set, and so no databases setup", env 142 | ) 143 | 144 | if s: 145 | return parse( 146 | s, 147 | engine, 148 | conn_max_age, 149 | conn_health_checks, 150 | disable_server_side_cursors, 151 | ssl_require, 152 | test_options, 153 | ) 154 | 155 | return {} 156 | 157 | 158 | def parse( 159 | url: str, 160 | engine: Optional[str] = None, 161 | conn_max_age: Optional[int] = 0, 162 | conn_health_checks: bool = False, 163 | disable_server_side_cursors: bool = False, 164 | ssl_require: bool = False, 165 | test_options: Optional[dict[str, Any]] = None, 166 | ) -> DBConfig: 167 | """Parses a database URL and returns configured DATABASE dictionary.""" 168 | settings = _convert_to_settings( 169 | engine, 170 | conn_max_age, 171 | conn_health_checks, 172 | disable_server_side_cursors, 173 | ssl_require, 174 | test_options, 175 | ) 176 | 177 | if url == "sqlite://:memory:": 178 | # this is a special case, because if we pass this URL into 179 | # urlparse, urlparse will choke trying to interpret "memory" 180 | # as a port number 181 | return {"ENGINE": ENGINE_SCHEMES["sqlite"].backend, "NAME": ":memory:"} 182 | # note: no other settings are required for sqlite 183 | 184 | try: 185 | split_result = urlparse.urlsplit(url) 186 | engine_obj = ENGINE_SCHEMES.get(split_result.scheme) 187 | if engine_obj is None: 188 | raise UnknownSchemeError(split_result.scheme) 189 | path = split_result.path[1:] 190 | query = urlparse.parse_qs(split_result.query) 191 | options = {k: _parse_option_values(v) for k, v in query.items()} 192 | parsed_config: DBConfig = { 193 | "ENGINE": engine_obj.backend, 194 | "USER": urlparse.unquote(split_result.username or ""), 195 | "PASSWORD": urlparse.unquote(split_result.password or ""), 196 | "HOST": urlparse.unquote(split_result.hostname or ""), 197 | "PORT": split_result.port or "", 198 | "NAME": urlparse.unquote(path), 199 | "OPTIONS": options, 200 | } 201 | except UnknownSchemeError: 202 | raise 203 | except ValueError: 204 | raise ParseError() from None 205 | 206 | # Guarantee that config has options, possibly empty, when postprocess() is called 207 | assert isinstance(parsed_config["OPTIONS"], dict) 208 | engine_obj.postprocess(parsed_config) 209 | 210 | # Update the final config with any settings passed in explicitly. 211 | parsed_config["OPTIONS"].update(settings.pop("OPTIONS", {})) 212 | parsed_config.update(settings) 213 | 214 | if not parsed_config["OPTIONS"]: 215 | parsed_config.pop("OPTIONS") 216 | return parsed_config 217 | 218 | 219 | def _parse_option_values(values: list[str]) -> OptionType | list[OptionType]: 220 | parsed_values = [_parse_value(v) for v in values] 221 | return parsed_values[0] if len(parsed_values) == 1 else parsed_values 222 | 223 | 224 | def _parse_value(value: str) -> OptionType: 225 | if value.isdigit(): 226 | return int(value) 227 | if value.lower() in ("true", "false"): 228 | return value.lower() == "true" 229 | return value 230 | 231 | 232 | def _convert_to_settings( 233 | engine: Optional[str], 234 | conn_max_age: Optional[int], 235 | conn_health_checks: bool, 236 | disable_server_side_cursors: bool, 237 | ssl_require: bool, 238 | test_options: Optional[dict[str, Any]], 239 | ) -> DBConfig: 240 | settings: DBConfig = { 241 | "CONN_MAX_AGE": conn_max_age, 242 | "CONN_HEALTH_CHECKS": conn_health_checks, 243 | "DISABLE_SERVER_SIDE_CURSORS": disable_server_side_cursors, 244 | } 245 | if engine: 246 | settings["ENGINE"] = engine 247 | if ssl_require: 248 | settings["OPTIONS"] = {} 249 | settings["OPTIONS"]["sslmode"] = "require" 250 | if test_options: 251 | settings["TEST"] = test_options 252 | return settings 253 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | DJ-Database-URL 2 | ~~~~~~~~~~~~~~~ 3 | 4 | .. image:: https://jazzband.co/static/img/badge.png 5 | :target: https://jazzband.co/ 6 | :alt: Jazzband 7 | 8 | .. image:: https://github.com/jazzband/dj-database-url/actions/workflows/test.yml/badge.svg 9 | :target: https://github.com/jazzband/dj-database-url/actions/workflows/test.yml 10 | 11 | .. image:: https://codecov.io/gh/jazzband/dj-database-url/branch/master/graph/badge.svg?token=7srBUpszOa 12 | :target: https://codecov.io/gh/jazzband/dj-database-url 13 | 14 | This simple Django utility allows you to utilize the 15 | `12factor `_ inspired 16 | ``DATABASE_URL`` environment variable to configure your Django application. 17 | 18 | The ``dj_database_url.config`` method returns a Django database connection 19 | dictionary, populated with all the data specified in your URL. There is 20 | also a `conn_max_age` argument to easily enable Django's connection pool. 21 | 22 | If you'd rather not use an environment variable, you can pass a URL in directly 23 | instead to ``dj_database_url.parse``. 24 | 25 | Installation 26 | ------------ 27 | 28 | Installation is simple: 29 | 30 | .. code-block:: console 31 | 32 | $ pip install dj-database-url 33 | 34 | Usage 35 | ----- 36 | 37 | 1. If ``DATABASES`` is already defined: 38 | 39 | - Configure your database in ``settings.py`` from ``DATABASE_URL``: 40 | 41 | .. code-block:: python 42 | 43 | import dj_database_url 44 | 45 | DATABASES['default'] = dj_database_url.config( 46 | conn_max_age=600, 47 | conn_health_checks=True, 48 | ) 49 | 50 | - Provide a default: 51 | 52 | .. code-block:: python 53 | 54 | DATABASES['default'] = dj_database_url.config( 55 | default='postgres://...', 56 | conn_max_age=600, 57 | conn_health_checks=True, 58 | ) 59 | 60 | - Parse an arbitrary Database URL: 61 | 62 | .. code-block:: python 63 | 64 | DATABASES['default'] = dj_database_url.parse( 65 | 'postgres://...', 66 | conn_max_age=600, 67 | conn_health_checks=True, 68 | ) 69 | 70 | 2. If ``DATABASES`` is not defined: 71 | 72 | - Configure your database in ``settings.py`` from ``DATABASE_URL``: 73 | 74 | .. code-block:: python 75 | 76 | import dj_database_url 77 | 78 | DATABASES = { 79 | 'default': dj_database_url.config( 80 | conn_max_age=600, 81 | conn_health_checks=True, 82 | ), 83 | } 84 | 85 | - You can provide a default, used if the ``DATABASE_URL`` setting is not defined: 86 | 87 | .. code-block:: python 88 | 89 | DATABASES = { 90 | 'default': dj_database_url.config( 91 | default='postgres://...', 92 | conn_max_age=600, 93 | conn_health_checks=True, 94 | ) 95 | } 96 | 97 | - Parse an arbitrary Database URL: 98 | 99 | .. code-block:: python 100 | 101 | DATABASES = { 102 | 'default': dj_database_url.parse( 103 | 'postgres://...', 104 | conn_max_age=600, 105 | conn_health_checks=True, 106 | ) 107 | } 108 | 109 | ``conn_max_age`` sets the |CONN_MAX_AGE setting|__, which tells Django to 110 | persist database connections between requests, up to the given lifetime in 111 | seconds. If you do not provide a value, it will follow Django’s default of 112 | ``0``. Setting it is recommended for performance. 113 | 114 | .. |CONN_MAX_AGE setting| replace:: ``CONN_MAX_AGE`` setting 115 | __ https://docs.djangoproject.com/en/stable/ref/settings/#conn-max-age 116 | 117 | ``conn_health_checks`` sets the |CONN_HEALTH_CHECKS setting|__ (new in Django 118 | 4.1), which tells Django to check a persisted connection still works at the 119 | start of each request. If you do not provide a value, it will follow Django’s 120 | default of ``False``. Enabling it is recommended if you set a non-zero 121 | ``conn_max_age``. 122 | 123 | .. |CONN_HEALTH_CHECKS setting| replace:: ``CONN_HEALTH_CHECKS`` setting 124 | __ https://docs.djangoproject.com/en/stable/ref/settings/#conn-health-checks 125 | 126 | Strings passed to `dj_database_url` must be valid URLs; in 127 | particular, special characters must be url-encoded. The following url will raise 128 | a `ValueError`: 129 | 130 | .. code-block:: plaintext 131 | 132 | postgres://user:p#ssword!@localhost/foobar 133 | 134 | and should instead be passed as: 135 | 136 | .. code-block:: plaintext 137 | 138 | postgres://user:p%23ssword!@localhost/foobar 139 | 140 | `TEST `_ settings can be configured using the ``test_options`` attribute:: 141 | 142 | DATABASES['default'] = dj_database_url.config(default='postgres://...', test_options={'NAME': 'mytestdatabase'}) 143 | 144 | 145 | Supported Databases 146 | ------------------- 147 | 148 | Support currently exists for PostgreSQL, PostGIS, MySQL, MySQL (GIS), 149 | Oracle, Oracle (GIS), Redshift, CockroachDB, Timescale, Timescale (GIS) and SQLite. 150 | 151 | If you want to use 152 | some non-default backends, you need to register them first: 153 | 154 | .. code-block:: python 155 | 156 | import dj_database_url 157 | 158 | # registration should be performed only once 159 | dj_database_url.register("mysql-connector", "mysql.connector.django") 160 | 161 | assert dj_database_url.parse("mysql-connector://user:password@host:port/db-name") == { 162 | "ENGINE": "mysql.connector.django", 163 | # ...other connection params 164 | } 165 | 166 | Some backends need further config adjustments (e.g. oracle and mssql 167 | expect ``PORT`` to be a string). For such cases you can provide a 168 | post-processing function to ``register()`` (note that ``register()`` is 169 | used as a **decorator(!)** in this case): 170 | 171 | .. code-block:: python 172 | 173 | import dj_database_url 174 | 175 | @dj_database_url.register("mssql", "sql_server.pyodbc") 176 | def stringify_port(config): 177 | config["PORT"] = str(config["PORT"]) 178 | 179 | @dj_database_url.register("redshift", "django_redshift_backend") 180 | def apply_current_schema(config): 181 | options = config["OPTIONS"] 182 | schema = options.pop("currentSchema", None) 183 | if schema: 184 | options["options"] = f"-c search_path={schema}" 185 | 186 | @dj_database_url.register("snowflake", "django_snowflake") 187 | def adjust_snowflake_config(config): 188 | config.pop("PORT", None) 189 | config["ACCOUNT"] = config.pop("HOST") 190 | name, _, schema = config["NAME"].partition("/") 191 | if schema: 192 | config["SCHEMA"] = schema 193 | config["NAME"] = name 194 | options = config.get("OPTIONS", {}) 195 | warehouse = options.pop("warehouse", None) 196 | if warehouse: 197 | config["WAREHOUSE"] = warehouse 198 | role = options.pop("role", None) 199 | if role: 200 | config["ROLE"] = role 201 | 202 | URL schema 203 | ---------- 204 | 205 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 206 | | Engine | Django Backend | URL | 207 | +======================+===============================================+==================================================+ 208 | | PostgreSQL | ``django.db.backends.postgresql`` [1]_ | ``postgres://USER:PASSWORD@HOST:PORT/NAME`` [2]_ | 209 | | | | ``postgresql://USER:PASSWORD@HOST:PORT/NAME`` | 210 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 211 | | PostGIS | ``django.contrib.gis.db.backends.postgis`` | ``postgis://USER:PASSWORD@HOST:PORT/NAME`` | 212 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 213 | | MSSQL | ``sql_server.pyodbc`` | ``mssql://USER:PASSWORD@HOST:PORT/NAME`` | 214 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 215 | | MSSQL [5]_ | ``mssql`` | ``mssqlms://USER:PASSWORD@HOST:PORT/NAME`` | 216 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 217 | | MySQL | ``django.db.backends.mysql`` | ``mysql://USER:PASSWORD@HOST:PORT/NAME`` [2]_ | 218 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 219 | | MySQL (GIS) | ``django.contrib.gis.db.backends.mysql`` | ``mysqlgis://USER:PASSWORD@HOST:PORT/NAME`` | 220 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 221 | | SQLite | ``django.db.backends.sqlite3`` | ``sqlite:///PATH`` [3]_ | 222 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 223 | | SpatiaLite | ``django.contrib.gis.db.backends.spatialite`` | ``spatialite:///PATH`` [3]_ | 224 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 225 | | Oracle | ``django.db.backends.oracle`` | ``oracle://USER:PASSWORD@HOST:PORT/NAME`` [4]_ | 226 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 227 | | Oracle (GIS) | ``django.contrib.gis.db.backends.oracle`` | ``oraclegis://USER:PASSWORD@HOST:PORT/NAME`` | 228 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 229 | | Redshift | ``django_redshift_backend`` | ``redshift://USER:PASSWORD@HOST:PORT/NAME`` | 230 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 231 | | CockroachDB | ``django_cockroachdb`` | ``cockroach://USER:PASSWORD@HOST:PORT/NAME`` | 232 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 233 | | Timescale [6]_ | ``timescale.db.backends.postgresql`` | ``timescale://USER:PASSWORD@HOST:PORT/NAME`` | 234 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 235 | | Timescale (GIS) [6]_ | ``timescale.db.backend.postgis`` | ``timescalegis://USER:PASSWORD@HOST:PORT/NAME`` | 236 | +----------------------+-----------------------------------------------+--------------------------------------------------+ 237 | 238 | .. [1] The django.db.backends.postgresql backend is named django.db.backends.postgresql_psycopg2 in older releases. For 239 | backwards compatibility, the old name still works in newer versions. (The new name does not work in older versions). 240 | .. [2] With PostgreSQL or CloudSQL, you can also use unix domain socket paths with 241 | `percent encoding `_: 242 | ``postgres://%2Fvar%2Flib%2Fpostgresql/dbname`` 243 | ``mysql://uf07k1i6d8ia0v@%2fcloudsql%2fproject%3alocation%3ainstance/dbname`` 244 | .. [3] SQLite connects to file based databases. The same URL format is used, omitting 245 | the hostname, and using the "file" portion as the filename of the database. 246 | This has the effect of four slashes being present for an absolute file path: 247 | ``sqlite:////full/path/to/your/database/file.sqlite``. 248 | .. [4] Note that when connecting to Oracle the URL isn't in the form you may know 249 | from using other Oracle tools (like SQLPlus) i.e. user and password are separated 250 | by ``:`` not by ``/``. Also you can omit ``HOST`` and ``PORT`` 251 | and provide a full DSN string or TNS name in ``NAME`` part. 252 | .. [5] Microsoft official `mssql-django `_ adapter. 253 | .. [6] Using the django-timescaledb Package which must be installed. 254 | 255 | 256 | Contributing 257 | ------------ 258 | 259 | We welcome contributions to this project. Projects can take two forms: 260 | 261 | 1. Raising issues or helping others through the github issue tracker. 262 | 2. Contributing code. 263 | 264 | Raising Issues or helping others: 265 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 266 | 267 | When submitting an issue or helping other remember you are talking to humans who have feelings, jobs and lives of their 268 | own. Be nice, be kind, be polite. Remember english may not be someone first language, if you do not understand or 269 | something is not clear be polite and re-ask/ re-word. 270 | 271 | Contributing code: 272 | ^^^^^^^^^^^^^^^^^^ 273 | 274 | * Before writing code be sure to check existing PR's and issues in the tracker. 275 | * Write code to the pylint spec. 276 | * Large or wide sweeping changes will take longer, and may face more scrutiny than smaller confined changes. 277 | * Code should be pass `black` and `flake8` validation. 278 | -------------------------------------------------------------------------------- /tests/test_dj_database_url.py: -------------------------------------------------------------------------------- 1 | # pyright: reportTypedDictNotRequiredAccess=false 2 | 3 | import os 4 | import re 5 | import unittest 6 | from unittest import mock 7 | from urllib.parse import uses_netloc 8 | 9 | import dj_database_url 10 | 11 | POSTGIS_URL = "postgis://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn" 12 | 13 | 14 | class DatabaseTestSuite(unittest.TestCase): 15 | def test_postgres_parsing(self) -> None: 16 | url = dj_database_url.parse( 17 | "postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn" 18 | ) 19 | 20 | assert url["ENGINE"] == "django.db.backends.postgresql" 21 | assert url["NAME"] == "d8r82722r2kuvn" 22 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 23 | assert url["USER"] == "uf07k1i6d8ia0v" 24 | assert url["PASSWORD"] == "wegauwhgeuioweg" 25 | assert url["PORT"] == 5431 26 | 27 | def test_postgres_unix_socket_parsing(self) -> None: 28 | url = dj_database_url.parse( 29 | "postgres://%2Fvar%2Frun%2Fpostgresql/d8r82722r2kuvn" 30 | ) 31 | 32 | assert url["ENGINE"] == "django.db.backends.postgresql" 33 | assert url["NAME"] == "d8r82722r2kuvn" 34 | assert url["HOST"] == "/var/run/postgresql" 35 | assert url["USER"] == "" 36 | assert url["PASSWORD"] == "" 37 | assert url["PORT"] == "" 38 | 39 | url = dj_database_url.parse( 40 | "postgres://%2FUsers%2Fpostgres%2FRuN/d8r82722r2kuvn" 41 | ) 42 | 43 | assert url["ENGINE"] == "django.db.backends.postgresql" 44 | assert url["HOST"] == "/Users/postgres/RuN" 45 | assert url["USER"] == "" 46 | assert url["PASSWORD"] == "" 47 | assert url["PORT"] == "" 48 | 49 | def test_postgres_google_cloud_parsing(self) -> None: 50 | url = dj_database_url.parse( 51 | "postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@%2Fcloudsql%2Fproject_id%3Aregion%3Ainstance_id/d8r82722r2kuvn" 52 | ) 53 | 54 | assert url["ENGINE"] == "django.db.backends.postgresql" 55 | assert url["NAME"] == "d8r82722r2kuvn" 56 | assert url["HOST"] == "/cloudsql/project_id:region:instance_id" 57 | assert url["USER"] == "uf07k1i6d8ia0v" 58 | assert url["PASSWORD"] == "wegauwhgeuioweg" 59 | assert url["PORT"] == "" 60 | 61 | def test_ipv6_parsing(self) -> None: 62 | url = dj_database_url.parse( 63 | "postgres://ieRaekei9wilaim7:wegauwhgeuioweg@[2001:db8:1234::1234:5678:90af]:5431/d8r82722r2kuvn" 64 | ) 65 | 66 | assert url["ENGINE"] == "django.db.backends.postgresql" 67 | assert url["NAME"] == "d8r82722r2kuvn" 68 | assert url["HOST"] == "2001:db8:1234::1234:5678:90af" 69 | assert url["USER"] == "ieRaekei9wilaim7" 70 | assert url["PASSWORD"] == "wegauwhgeuioweg" 71 | assert url["PORT"] == 5431 72 | 73 | def test_postgres_search_path_parsing(self) -> None: 74 | url = dj_database_url.parse( 75 | "postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn?currentSchema=otherschema" 76 | ) 77 | assert url["ENGINE"] == "django.db.backends.postgresql" 78 | assert url["NAME"] == "d8r82722r2kuvn" 79 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 80 | assert url["USER"] == "uf07k1i6d8ia0v" 81 | assert url["PASSWORD"] == "wegauwhgeuioweg" 82 | assert url["PORT"] == 5431 83 | assert url["OPTIONS"]["options"] == "-c search_path=otherschema" 84 | assert "currentSchema" not in url["OPTIONS"] 85 | 86 | def test_postgres_parsing_with_special_characters(self) -> None: 87 | url = dj_database_url.parse( 88 | "postgres://%23user:%23password@ec2-107-21-253-135.compute-1.amazonaws.com:5431/%23database" 89 | ) 90 | 91 | assert url["ENGINE"] == "django.db.backends.postgresql" 92 | assert url["NAME"] == "#database" 93 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 94 | assert url["USER"] == "#user" 95 | assert url["PASSWORD"] == "#password" 96 | assert url["PORT"] == 5431 97 | 98 | def test_postgres_parsing_with_int_bool_str_query_string(self) -> None: 99 | url = dj_database_url.parse( 100 | "postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn?server_side_binding=true&timeout=20&service=my_service&passfile=.my_pgpass" 101 | ) 102 | 103 | assert url["ENGINE"] == "django.db.backends.postgresql" 104 | assert url["NAME"] == "d8r82722r2kuvn" 105 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 106 | assert url["USER"] == "uf07k1i6d8ia0v" 107 | assert url["PASSWORD"] == "wegauwhgeuioweg" 108 | assert url["PORT"] == 5431 109 | assert url["OPTIONS"]["server_side_binding"] is True 110 | assert url["OPTIONS"]["timeout"] == 20 111 | assert url["OPTIONS"]["service"] == "my_service" 112 | assert url["OPTIONS"]["passfile"] == ".my_pgpass" 113 | 114 | def test_postgis_parsing(self) -> None: 115 | url = dj_database_url.parse( 116 | "postgis://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn" 117 | ) 118 | 119 | assert url["ENGINE"] == "django.contrib.gis.db.backends.postgis" 120 | assert url["NAME"] == "d8r82722r2kuvn" 121 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 122 | assert url["USER"] == "uf07k1i6d8ia0v" 123 | assert url["PASSWORD"] == "wegauwhgeuioweg" 124 | assert url["PORT"] == 5431 125 | 126 | def test_postgis_search_path_parsing(self) -> None: 127 | url = dj_database_url.parse( 128 | "postgis://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn?currentSchema=otherschema" 129 | ) 130 | assert url["ENGINE"] == "django.contrib.gis.db.backends.postgis" 131 | assert url["NAME"] == "d8r82722r2kuvn" 132 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 133 | assert url["USER"] == "uf07k1i6d8ia0v" 134 | assert url["PASSWORD"] == "wegauwhgeuioweg" 135 | assert url["PORT"] == 5431 136 | assert url["OPTIONS"]["options"] == "-c search_path=otherschema" 137 | assert "currentSchema" not in url["OPTIONS"] 138 | 139 | def test_mysql_gis_parsing(self) -> None: 140 | url = dj_database_url.parse( 141 | "mysqlgis://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn" 142 | ) 143 | 144 | assert url["ENGINE"] == "django.contrib.gis.db.backends.mysql" 145 | assert url["NAME"] == "d8r82722r2kuvn" 146 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 147 | assert url["USER"] == "uf07k1i6d8ia0v" 148 | assert url["PASSWORD"] == "wegauwhgeuioweg" 149 | assert url["PORT"] == 5431 150 | 151 | def test_mysql_connector_parsing(self) -> None: 152 | url = dj_database_url.parse( 153 | "mysql-connector://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn" 154 | ) 155 | 156 | assert url["ENGINE"] == "mysql.connector.django" 157 | assert url["NAME"] == "d8r82722r2kuvn" 158 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 159 | assert url["USER"] == "uf07k1i6d8ia0v" 160 | assert url["PASSWORD"] == "wegauwhgeuioweg" 161 | assert url["PORT"] == 5431 162 | 163 | def test_config_test_options(self) -> None: 164 | with mock.patch.dict( 165 | os.environ, 166 | { 167 | "DATABASE_URL": "postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn?" 168 | }, 169 | ): 170 | test_db_config = { 171 | 'NAME': 'mytestdatabase', 172 | } 173 | url = dj_database_url.config(test_options=test_db_config) 174 | 175 | assert url['TEST']['NAME'] == 'mytestdatabase' 176 | 177 | def test_cleardb_parsing(self) -> None: 178 | url = dj_database_url.parse( 179 | "mysql://bea6eb025ca0d8:69772142@us-cdbr-east.cleardb.com/heroku_97681db3eff7580?reconnect=true" 180 | ) 181 | 182 | assert url["ENGINE"] == "django.db.backends.mysql" 183 | assert url["NAME"] == "heroku_97681db3eff7580" 184 | assert url["HOST"] == "us-cdbr-east.cleardb.com" 185 | assert url["USER"] == "bea6eb025ca0d8" 186 | assert url["PASSWORD"] == "69772142" 187 | assert url["PORT"] == "" 188 | 189 | def test_database_url(self) -> None: 190 | with mock.patch.dict(os.environ, clear=True): 191 | a = dj_database_url.config() 192 | assert not a 193 | 194 | with mock.patch.dict( 195 | os.environ, 196 | { 197 | "DATABASE_URL": "postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn" 198 | }, 199 | ): 200 | url = dj_database_url.config() 201 | 202 | assert url["ENGINE"] == "django.db.backends.postgresql" 203 | assert url["NAME"] == "d8r82722r2kuvn" 204 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 205 | assert url["USER"] == "uf07k1i6d8ia0v" 206 | assert url["PASSWORD"] == "wegauwhgeuioweg" 207 | assert url["PORT"] == 5431 208 | 209 | def test_empty_sqlite_url(self) -> None: 210 | url = dj_database_url.parse("sqlite://") 211 | 212 | assert url["ENGINE"] == "django.db.backends.sqlite3" 213 | assert url["NAME"] == ":memory:" 214 | 215 | def test_memory_sqlite_url(self) -> None: 216 | url = dj_database_url.parse("sqlite://:memory:") 217 | 218 | assert url["ENGINE"] == "django.db.backends.sqlite3" 219 | assert url["NAME"] == ":memory:" 220 | 221 | def test_sqlite_relative_url(self) -> None: 222 | url = "sqlite:///db.sqlite3" 223 | config = dj_database_url.parse(url) 224 | 225 | assert config["ENGINE"] == "django.db.backends.sqlite3" 226 | assert config["NAME"] == "db.sqlite3" 227 | 228 | def test_sqlite_absolute_url(self) -> None: 229 | # 4 slashes are needed: 230 | # two are part of scheme 231 | # one separates host:port from path 232 | # and the fourth goes to "NAME" value 233 | url = "sqlite:////db.sqlite3" 234 | config = dj_database_url.parse(url) 235 | 236 | assert config["ENGINE"] == "django.db.backends.sqlite3" 237 | assert config["NAME"] == "/db.sqlite3" 238 | 239 | def test_parse_engine_setting(self) -> None: 240 | engine = "django_mysqlpool.backends.mysqlpool" 241 | url = dj_database_url.parse( 242 | "mysql://bea6eb025ca0d8:69772142@us-cdbr-east.cleardb.com/heroku_97681db3eff7580?reconnect=true", 243 | engine, 244 | ) 245 | 246 | assert url["ENGINE"] == engine 247 | 248 | def test_config_engine_setting(self) -> None: 249 | engine = "django_mysqlpool.backends.mysqlpool" 250 | with mock.patch.dict( 251 | os.environ, 252 | { 253 | "DATABASE_URL": "mysql://bea6eb025ca0d8:69772142@us-cdbr-east.cleardb.com/heroku_97681db3eff7580?reconnect=true" 254 | }, 255 | ): 256 | url = dj_database_url.config(engine=engine) 257 | 258 | assert url["ENGINE"] == engine 259 | 260 | def test_parse_conn_max_age_setting(self) -> None: 261 | conn_max_age = 600 262 | url = dj_database_url.parse( 263 | "mysql://bea6eb025ca0d8:69772142@us-cdbr-east.cleardb.com/heroku_97681db3eff7580?reconnect=true", 264 | conn_max_age=conn_max_age, 265 | ) 266 | 267 | assert url["CONN_MAX_AGE"] == conn_max_age 268 | 269 | def test_config_conn_max_age_setting_none(self) -> None: 270 | conn_max_age = None 271 | with mock.patch.dict( 272 | os.environ, 273 | { 274 | "DATABASE_URL": "mysql://bea6eb025ca0d8:69772142@us-cdbr-east.cleardb.com/heroku_97681db3eff7580?reconnect=true" 275 | }, 276 | ): 277 | url = dj_database_url.config(conn_max_age=conn_max_age) 278 | 279 | assert url["CONN_MAX_AGE"] == conn_max_age 280 | 281 | def test_database_url_with_options(self) -> None: 282 | # Test full options 283 | with mock.patch.dict( 284 | os.environ, 285 | { 286 | "DATABASE_URL": "postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn?sslrootcert=rds-combined-ca-bundle.pem&sslmode=verify-full" 287 | }, 288 | ): 289 | url = dj_database_url.config() 290 | 291 | assert url["ENGINE"] == "django.db.backends.postgresql" 292 | assert url["NAME"] == "d8r82722r2kuvn" 293 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 294 | assert url["USER"] == "uf07k1i6d8ia0v" 295 | assert url["PASSWORD"] == "wegauwhgeuioweg" 296 | assert url["PORT"] == 5431 297 | assert url["OPTIONS"] == { 298 | "sslrootcert": "rds-combined-ca-bundle.pem", 299 | "sslmode": "verify-full", 300 | } 301 | 302 | # Test empty options 303 | with mock.patch.dict( 304 | os.environ, 305 | { 306 | "DATABASE_URL": "postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn?" 307 | }, 308 | ): 309 | url = dj_database_url.config() 310 | assert "OPTIONS" not in url 311 | 312 | def test_mysql_database_url_with_sslca_options(self) -> None: 313 | with mock.patch.dict( 314 | os.environ, 315 | { 316 | "DATABASE_URL": "mysql://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:3306/d8r82722r2kuvn?ssl-ca=rds-combined-ca-bundle.pem" 317 | }, 318 | ): 319 | url = dj_database_url.config() 320 | 321 | assert url["ENGINE"] == "django.db.backends.mysql" 322 | assert url["NAME"] == "d8r82722r2kuvn" 323 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 324 | assert url["USER"] == "uf07k1i6d8ia0v" 325 | assert url["PASSWORD"] == "wegauwhgeuioweg" 326 | assert url["PORT"] == 3306 327 | assert url["OPTIONS"] == {"ssl": {"ca": "rds-combined-ca-bundle.pem"}} 328 | 329 | # Test empty options 330 | with mock.patch.dict( 331 | os.environ, 332 | { 333 | "DATABASE_URL": "mysql://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:3306/d8r82722r2kuvn?" 334 | }, 335 | ): 336 | url = dj_database_url.config() 337 | assert "OPTIONS" not in url 338 | 339 | def test_oracle_parsing(self) -> None: 340 | url = dj_database_url.parse("oracle://scott:tiger@oraclehost:1521/hr") 341 | 342 | assert url["ENGINE"] == "django.db.backends.oracle" 343 | assert url["NAME"] == "hr" 344 | assert url["HOST"] == "oraclehost" 345 | assert url["USER"] == "scott" 346 | assert url["PASSWORD"] == "tiger" 347 | assert url["PORT"] == "1521" 348 | 349 | def test_oracle_gis_parsing(self) -> None: 350 | url = dj_database_url.parse("oraclegis://scott:tiger@oraclehost:1521/hr") 351 | 352 | assert url["ENGINE"] == "django.contrib.gis.db.backends.oracle" 353 | assert url["NAME"] == "hr" 354 | assert url["HOST"] == "oraclehost" 355 | assert url["USER"] == "scott" 356 | assert url["PASSWORD"] == "tiger" 357 | assert url["PORT"] == 1521 358 | 359 | def test_oracle_dsn_parsing(self) -> None: 360 | url = dj_database_url.parse( 361 | "oracle://scott:tiger@/" 362 | "(DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)" 363 | "(HOST=oraclehost)(PORT=1521)))" 364 | "(CONNECT_DATA=(SID=hr)))" 365 | ) 366 | 367 | assert url["ENGINE"] == "django.db.backends.oracle" 368 | assert url["USER"] == "scott" 369 | assert url["PASSWORD"] == "tiger" 370 | assert url["HOST"] == "" 371 | assert url["PORT"] == "" 372 | 373 | dsn = ( 374 | "(DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)" 375 | "(HOST=oraclehost)(PORT=1521)))" 376 | "(CONNECT_DATA=(SID=hr)))" 377 | ) 378 | 379 | assert url["NAME"] == dsn 380 | 381 | def test_oracle_tns_parsing(self) -> None: 382 | url = dj_database_url.parse("oracle://scott:tiger@/tnsname") 383 | 384 | assert url["ENGINE"] == "django.db.backends.oracle" 385 | assert url["USER"] == "scott" 386 | assert url["PASSWORD"] == "tiger" 387 | assert url["NAME"] == "tnsname" 388 | assert url["HOST"] == "" 389 | assert url["PORT"] == "" 390 | 391 | def test_redshift_parsing(self) -> None: 392 | url = dj_database_url.parse( 393 | "redshift://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5439/d8r82722r2kuvn?currentSchema=otherschema" 394 | ) 395 | 396 | assert url["ENGINE"] == "django_redshift_backend" 397 | assert url["NAME"] == "d8r82722r2kuvn" 398 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 399 | assert url["USER"] == "uf07k1i6d8ia0v" 400 | assert url["PASSWORD"] == "wegauwhgeuioweg" 401 | assert url["PORT"] == 5439 402 | assert url["OPTIONS"]["options"] == "-c search_path=otherschema" 403 | assert "currentSchema" not in url["OPTIONS"] 404 | 405 | def test_mssql_parsing(self) -> None: 406 | url = dj_database_url.parse( 407 | "mssql://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com/d8r82722r2kuvn?driver=ODBC Driver 13 for SQL Server" 408 | ) 409 | 410 | assert url["ENGINE"] == "sql_server.pyodbc" 411 | assert url["NAME"] == "d8r82722r2kuvn" 412 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 413 | assert url["USER"] == "uf07k1i6d8ia0v" 414 | assert url["PASSWORD"] == "wegauwhgeuioweg" 415 | assert url["PORT"] == "" 416 | assert url["OPTIONS"]["driver"] == "ODBC Driver 13 for SQL Server" 417 | assert "currentSchema" not in url["OPTIONS"] 418 | 419 | def test_mssql_instance_port_parsing(self) -> None: 420 | url = dj_database_url.parse( 421 | "mssql://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com\\insnsnss:12345/d8r82722r2kuvn?driver=ODBC Driver 13 for SQL Server" 422 | ) 423 | 424 | assert url["ENGINE"] == "sql_server.pyodbc" 425 | assert url["NAME"] == "d8r82722r2kuvn" 426 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com\\insnsnss" 427 | assert url["USER"] == "uf07k1i6d8ia0v" 428 | assert url["PASSWORD"] == "wegauwhgeuioweg" 429 | assert url["PORT"] == "12345" 430 | assert url["OPTIONS"]["driver"] == "ODBC Driver 13 for SQL Server" 431 | assert "currentSchema" not in url["OPTIONS"] 432 | 433 | def test_cockroach(self) -> None: 434 | url = dj_database_url.parse( 435 | "cockroach://testuser:testpass@testhost:26257/cockroach?sslmode=verify-full&sslrootcert=/certs/ca.crt&sslcert=/certs/client.myprojectuser.crt&sslkey=/certs/client.myprojectuser.key" 436 | ) 437 | assert url['ENGINE'] == 'django_cockroachdb' 438 | assert url['NAME'] == 'cockroach' 439 | assert url['HOST'] == 'testhost' 440 | assert url['USER'] == 'testuser' 441 | assert url['PASSWORD'] == 'testpass' 442 | assert url['PORT'] == 26257 443 | assert url['OPTIONS']['sslmode'] == 'verify-full' 444 | assert url['OPTIONS']['sslrootcert'] == '/certs/ca.crt' 445 | assert url['OPTIONS']['sslcert'] == '/certs/client.myprojectuser.crt' 446 | assert url['OPTIONS']['sslkey'] == '/certs/client.myprojectuser.key' 447 | 448 | def test_mssqlms_parsing(self) -> None: 449 | url = dj_database_url.parse( 450 | "mssqlms://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com/d8r82722r2kuvn?driver=ODBC Driver 13 for SQL Server" 451 | ) 452 | 453 | assert url["ENGINE"] == "mssql" 454 | assert url["NAME"] == "d8r82722r2kuvn" 455 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 456 | assert url["USER"] == "uf07k1i6d8ia0v" 457 | assert url["PASSWORD"] == "wegauwhgeuioweg" 458 | assert url["PORT"] == "" 459 | assert url["OPTIONS"]["driver"] == "ODBC Driver 13 for SQL Server" 460 | assert "currentSchema" not in url["OPTIONS"] 461 | 462 | def test_timescale_parsing(self) -> None: 463 | url = dj_database_url.parse( 464 | "timescale://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn" 465 | ) 466 | 467 | assert url["ENGINE"] == "timescale.db.backends.postgresql" 468 | assert url["NAME"] == "d8r82722r2kuvn" 469 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 470 | assert url["USER"] == "uf07k1i6d8ia0v" 471 | assert url["PASSWORD"] == "wegauwhgeuioweg" 472 | assert url["PORT"] == 5431 473 | 474 | def test_timescale_unix_socket_parsing(self) -> None: 475 | url = dj_database_url.parse( 476 | "timescale://%2Fvar%2Frun%2Fpostgresql/d8r82722r2kuvn" 477 | ) 478 | 479 | assert url["ENGINE"] == "timescale.db.backends.postgresql" 480 | assert url["NAME"] == "d8r82722r2kuvn" 481 | assert url["HOST"] == "/var/run/postgresql" 482 | assert url["USER"] == "" 483 | assert url["PASSWORD"] == "" 484 | assert url["PORT"] == "" 485 | 486 | url = dj_database_url.parse( 487 | "timescale://%2FUsers%2Fpostgres%2FRuN/d8r82722r2kuvn" 488 | ) 489 | 490 | assert url["ENGINE"] == "timescale.db.backends.postgresql" 491 | assert url["HOST"] == "/Users/postgres/RuN" 492 | assert url["USER"] == "" 493 | assert url["PASSWORD"] == "" 494 | assert url["PORT"] == "" 495 | 496 | def test_timescale_ipv6_parsing(self) -> None: 497 | url = dj_database_url.parse( 498 | "timescale://ieRaekei9wilaim7:wegauwhgeuioweg@[2001:db8:1234::1234:5678:90af]:5431/d8r82722r2kuvn" 499 | ) 500 | 501 | assert url["ENGINE"] == "timescale.db.backends.postgresql" 502 | assert url["NAME"] == "d8r82722r2kuvn" 503 | assert url["HOST"] == "2001:db8:1234::1234:5678:90af" 504 | assert url["USER"] == "ieRaekei9wilaim7" 505 | assert url["PASSWORD"] == "wegauwhgeuioweg" 506 | assert url["PORT"] == 5431 507 | 508 | def test_timescale_search_path_parsing(self) -> None: 509 | url = dj_database_url.parse( 510 | "timescale://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn?currentSchema=otherschema" 511 | ) 512 | assert url["ENGINE"] == "timescale.db.backends.postgresql" 513 | assert url["NAME"] == "d8r82722r2kuvn" 514 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 515 | assert url["USER"] == "uf07k1i6d8ia0v" 516 | assert url["PASSWORD"] == "wegauwhgeuioweg" 517 | assert url["PORT"] == 5431 518 | assert url["OPTIONS"]["options"] == "-c search_path=otherschema" 519 | assert "currentSchema" not in url["OPTIONS"] 520 | 521 | def test_timescale_parsing_with_special_characters(self) -> None: 522 | url = dj_database_url.parse( 523 | "timescale://%23user:%23password@ec2-107-21-253-135.compute-1.amazonaws.com:5431/%23database" 524 | ) 525 | 526 | assert url["ENGINE"] == "timescale.db.backends.postgresql" 527 | assert url["NAME"] == "#database" 528 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 529 | assert url["USER"] == "#user" 530 | assert url["PASSWORD"] == "#password" 531 | assert url["PORT"] == 5431 532 | 533 | def test_timescalegis_parsing(self) -> None: 534 | url = dj_database_url.parse( 535 | "timescalegis://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn" 536 | ) 537 | 538 | assert url["ENGINE"] == "timescale.db.backends.postgis" 539 | assert url["NAME"] == "d8r82722r2kuvn" 540 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 541 | assert url["USER"] == "uf07k1i6d8ia0v" 542 | assert url["PASSWORD"] == "wegauwhgeuioweg" 543 | assert url["PORT"] == 5431 544 | 545 | def test_timescalegis_unix_socket_parsing(self) -> None: 546 | url = dj_database_url.parse( 547 | "timescalegis://%2Fvar%2Frun%2Fpostgresql/d8r82722r2kuvn" 548 | ) 549 | 550 | assert url["ENGINE"] == "timescale.db.backends.postgis" 551 | assert url["NAME"] == "d8r82722r2kuvn" 552 | assert url["HOST"] == "/var/run/postgresql" 553 | assert url["USER"] == "" 554 | assert url["PASSWORD"] == "" 555 | assert url["PORT"] == "" 556 | 557 | url = dj_database_url.parse( 558 | "timescalegis://%2FUsers%2Fpostgres%2FRuN/d8r82722r2kuvn" 559 | ) 560 | 561 | assert url["ENGINE"] == "timescale.db.backends.postgis" 562 | assert url["HOST"] == "/Users/postgres/RuN" 563 | assert url["USER"] == "" 564 | assert url["PASSWORD"] == "" 565 | assert url["PORT"] == "" 566 | 567 | def test_timescalegis_ipv6_parsing(self) -> None: 568 | url = dj_database_url.parse( 569 | "timescalegis://ieRaekei9wilaim7:wegauwhgeuioweg@[2001:db8:1234::1234:5678:90af]:5431/d8r82722r2kuvn" 570 | ) 571 | 572 | assert url["ENGINE"] == "timescale.db.backends.postgis" 573 | assert url["NAME"] == "d8r82722r2kuvn" 574 | assert url["HOST"] == "2001:db8:1234::1234:5678:90af" 575 | assert url["USER"] == "ieRaekei9wilaim7" 576 | assert url["PASSWORD"] == "wegauwhgeuioweg" 577 | assert url["PORT"] == 5431 578 | 579 | def test_timescalegis_search_path_parsing(self) -> None: 580 | url = dj_database_url.parse( 581 | "timescalegis://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722r2kuvn?currentSchema=otherschema" 582 | ) 583 | assert url["ENGINE"] == "timescale.db.backends.postgis" 584 | assert url["NAME"] == "d8r82722r2kuvn" 585 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 586 | assert url["USER"] == "uf07k1i6d8ia0v" 587 | assert url["PASSWORD"] == "wegauwhgeuioweg" 588 | assert url["PORT"] == 5431 589 | assert url["OPTIONS"]["options"] == "-c search_path=otherschema" 590 | assert "currentSchema" not in url["OPTIONS"] 591 | 592 | def test_timescalegis_parsing_with_special_characters(self) -> None: 593 | url = dj_database_url.parse( 594 | "timescalegis://%23user:%23password@ec2-107-21-253-135.compute-1.amazonaws.com:5431/%23database" 595 | ) 596 | 597 | assert url["ENGINE"] == "timescale.db.backends.postgis" 598 | assert url["NAME"] == "#database" 599 | assert url["HOST"] == "ec2-107-21-253-135.compute-1.amazonaws.com" 600 | assert url["USER"] == "#user" 601 | assert url["PASSWORD"] == "#password" 602 | assert url["PORT"] == 5431 603 | 604 | def test_persistent_connection_variables(self) -> None: 605 | url = dj_database_url.parse( 606 | "sqlite://myfile.db", conn_max_age=600, conn_health_checks=True 607 | ) 608 | 609 | assert url["CONN_MAX_AGE"] == 600 610 | assert url["CONN_HEALTH_CHECKS"] is True 611 | 612 | def test_sqlite_memory_persistent_connection_variables(self) -> None: 613 | # memory sqlite ignores connection.close(), so persistent connection 614 | # variables aren’t required 615 | url = dj_database_url.parse( 616 | "sqlite://:memory:", conn_max_age=600, conn_health_checks=True 617 | ) 618 | 619 | assert "CONN_MAX_AGE" not in url 620 | assert "CONN_HEALTH_CHECKS" not in url 621 | 622 | @mock.patch.dict( 623 | os.environ, 624 | {"DATABASE_URL": "postgres://user:password@instance.amazonaws.com:5431/d8r8?"}, 625 | ) 626 | def test_persistent_connection_variables_config(self) -> None: 627 | url = dj_database_url.config(conn_max_age=600, conn_health_checks=True) 628 | 629 | assert url["CONN_MAX_AGE"] == 600 630 | assert url["CONN_HEALTH_CHECKS"] is True 631 | 632 | def test_no_env_variable(self) -> None: 633 | with self.assertLogs() as cm: 634 | with mock.patch.dict(os.environ, clear=True): 635 | url = dj_database_url.config() 636 | assert url == {}, url 637 | assert cm.output == [ 638 | 'WARNING:root:No DATABASE_URL environment variable set, and so no databases setup' 639 | ], cm.output 640 | 641 | def test_credentials_unquoted__raise_value_error(self) -> None: 642 | expected_message = ( 643 | "This string is not a valid url, possibly because some of its parts " 644 | r"is not properly urllib.parse.quote()'ed." 645 | ) 646 | with self.assertRaisesRegex(ValueError, re.escape(expected_message)): 647 | dj_database_url.parse("postgres://user:passw#ord!@localhost/foobar") 648 | 649 | def test_credentials_quoted__ok(self) -> None: 650 | url = "postgres://user%40domain:p%23ssword!@localhost/foobar" 651 | config = dj_database_url.parse(url) 652 | assert config["USER"] == "user@domain" 653 | assert config["PASSWORD"] == "p#ssword!" 654 | 655 | def test_unknown_scheme__raise_value_error(self) -> None: 656 | expected_message = ( 657 | "Scheme 'unknown-scheme://' is unknown. " 658 | "Did you forget to register custom backend? Following schemes have registered backends:" 659 | ) 660 | with self.assertRaisesRegex(ValueError, re.escape(expected_message)): 661 | dj_database_url.parse("unknown-scheme://user:password@localhost/foobar") 662 | 663 | def test_register_multiple_times__no_duplicates_in_uses_netloc(self) -> None: 664 | # make sure that when register() function is misused, 665 | # it won't pollute urllib.parse.uses_netloc list with duplicates. 666 | # Otherwise, it might cause performance issue if some code assumes that 667 | # that list is short and performs linear search on it. 668 | dj_database_url.register("django.contrib.db.backends.bag_end", "bag-end") 669 | dj_database_url.register("django.contrib.db.backends.bag_end", "bag-end") 670 | assert len(uses_netloc) == len(set(uses_netloc)) 671 | 672 | @mock.patch.dict( 673 | os.environ, 674 | {"DATABASE_URL": "postgres://user:password@instance.amazonaws.com:5431/d8r8?"}, 675 | ) 676 | def test_ssl_require(self) -> None: 677 | url = dj_database_url.config(ssl_require=True) 678 | assert url["OPTIONS"] == {'sslmode': 'require'} 679 | 680 | def test_options_int_values(self) -> None: 681 | """Ensure that options with integer values are parsed correctly.""" 682 | url = dj_database_url.parse( 683 | "mysql://user:pw@127.0.0.1:15036/db?connect_timout=3" 684 | ) 685 | assert url["OPTIONS"] == {'connect_timout': 3} 686 | 687 | @mock.patch.dict( 688 | os.environ, 689 | {"DATABASE_URL": "postgres://user:password@instance.amazonaws.com:5431/d8r8?"}, 690 | ) 691 | def test_server_side_cursors__config(self) -> None: 692 | url = dj_database_url.config(disable_server_side_cursors=True) 693 | 694 | assert url["DISABLE_SERVER_SIDE_CURSORS"] is True 695 | 696 | 697 | if __name__ == "__main__": 698 | unittest.main() 699 | --------------------------------------------------------------------------------