├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── devops.py ├── docker-compose.yml ├── docs ├── contributing.md ├── css │ ├── mkdocs-material.css │ └── mkdocstrings.css ├── index.md ├── installation.md ├── module.md ├── release_notes.md ├── requirements.txt └── static │ ├── dark_logo.png │ └── light_logo.png ├── environment.yml ├── footing.yaml ├── manage.py ├── mkdocs.yml ├── pgtransaction ├── __init__.py ├── config.py ├── py.typed ├── tests │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── test_config.py │ └── test_transaction.py ├── transaction.py └── version.py ├── poetry.lock ├── pyproject.toml ├── settings.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | ambitioneng: 5 | executors: 6 | python: 7 | working_directory: /code 8 | docker: 9 | - image: opus10/circleci-python-library:2024-10-26 10 | environment: 11 | # Ensure makefile commands are not wrapped in "docker compose run" 12 | EXEC_WRAPPER: '' 13 | DATABASE_URL: postgres://root@localhost/circle_test?sslmode=disable 14 | - image: cimg/postgres:<> 15 | environment: 16 | POSTGRES_USER: root 17 | POSTGRES_DB: circle_test 18 | POSTGRES_PASSWORD: password 19 | parameters: 20 | pg_version: 21 | type: "string" 22 | default: "14.4" 23 | commands: 24 | test: 25 | steps: 26 | - checkout 27 | - restore_cache: 28 | key: v5-{{ checksum "poetry.lock" }} 29 | - run: make dependencies 30 | - run: make full-test-suite 31 | - save_cache: 32 | key: v5-{{ checksum "poetry.lock" }} 33 | paths: 34 | - /home/circleci/.cache/pypoetry/ 35 | - /code/.venv 36 | - /code/.tox 37 | 38 | jobs: 39 | test_pg_min: 40 | executor: 41 | name: ambitioneng/python 42 | pg_version: "13.16" 43 | steps: 44 | - ambitioneng/test 45 | 46 | test_pg_max: 47 | executor: 48 | name: ambitioneng/python 49 | pg_version: "17.0" 50 | steps: 51 | - ambitioneng/test 52 | 53 | lint: 54 | executor: ambitioneng/python 55 | steps: 56 | - checkout 57 | - restore_cache: 58 | key: v5-{{ checksum "poetry.lock" }} 59 | - run: make dependencies 60 | - run: make lint 61 | 62 | type_check: 63 | executor: ambitioneng/python 64 | steps: 65 | - checkout 66 | - restore_cache: 67 | key: v5-{{ checksum "poetry.lock" }} 68 | - run: make dependencies 69 | - run: make type-check || true 70 | 71 | deploy: 72 | executor: ambitioneng/python 73 | steps: 74 | - checkout 75 | - run: ssh-add -D 76 | - restore_cache: 77 | key: v5-{{ checksum "poetry.lock" }} 78 | - run: make dependencies 79 | - run: poetry run python devops.py deploy 80 | 81 | workflows: 82 | version: 2 83 | on_commit: 84 | jobs: 85 | - test_pg_min: 86 | filters: 87 | tags: 88 | only: /.*/ 89 | - test_pg_max: 90 | filters: 91 | tags: 92 | only: /.*/ 93 | - lint: 94 | filters: 95 | tags: 96 | only: /.*/ 97 | - type_check: 98 | filters: 99 | tags: 100 | only: /.*/ 101 | - deploy: 102 | context: python-library 103 | requires: 104 | - test_pg_min 105 | - test_pg_max 106 | - lint 107 | - type_check 108 | filters: 109 | branches: 110 | ignore: /.*/ 111 | tags: 112 | only: /.*/ 113 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{yaml,yml}] 12 | indent_size = 2 13 | 14 | [makefile] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/vim,osx,python,django,pycharm,komodoedit,elasticbeanstalk,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=vim,osx,python,django,pycharm,komodoedit,elasticbeanstalk,visualstudiocode 4 | 5 | ### Django ### 6 | *.log 7 | *.pot 8 | *.pyc 9 | __pycache__/ 10 | local_settings.py 11 | db.sqlite3 12 | media 13 | 14 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 15 | # in your Git repository. Update and uncomment the following line accordingly. 16 | # /staticfiles/ 17 | 18 | ### Django.Python Stack ### 19 | # Byte-compiled / optimized / DLL files 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | pip-wheel-metadata/ 41 | share/python-wheels/ 42 | *.egg-info/ 43 | .installed.cfg 44 | *.egg 45 | MANIFEST 46 | 47 | # PyInstaller 48 | # Usually these files are written by a python script from a template 49 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 50 | *.manifest 51 | *.spec 52 | 53 | # Installer logs 54 | pip-log.txt 55 | pip-delete-this-directory.txt 56 | 57 | # Unit test / coverage reports 58 | htmlcov/ 59 | .tox/ 60 | .nox/ 61 | .coverage 62 | .coverage.* 63 | .cache 64 | nosetests.xml 65 | coverage.xml 66 | *.cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | 70 | # Translations 71 | *.mo 72 | 73 | # Django stuff: 74 | db.sqlite3-journal 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # celery beat schedule file 104 | celerybeat-schedule 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | ### ElasticBeanstalk ### 137 | .elasticbeanstalk/ 138 | 139 | ### KomodoEdit ### 140 | *.komodoproject 141 | .komodotools 142 | 143 | ### OSX ### 144 | # General 145 | .DS_Store 146 | .AppleDouble 147 | .LSOverride 148 | 149 | # Icon must end with two \r 150 | Icon 151 | 152 | # Thumbnails 153 | ._* 154 | 155 | # Files that might appear in the root of a volume 156 | .DocumentRevisions-V100 157 | .fseventsd 158 | .Spotlight-V100 159 | .TemporaryItems 160 | .Trashes 161 | .VolumeIcon.icns 162 | .com.apple.timemachine.donotpresent 163 | 164 | # Directories potentially created on remote AFP share 165 | .AppleDB 166 | .AppleDesktop 167 | Network Trash Folder 168 | Temporary Items 169 | .apdisk 170 | 171 | ### PyCharm ### 172 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 173 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 174 | 175 | # User-specific stuff 176 | .idea/**/workspace.xml 177 | .idea/**/tasks.xml 178 | .idea/**/usage.statistics.xml 179 | .idea/**/dictionaries 180 | .idea/**/shelf 181 | 182 | # Generated files 183 | .idea/**/contentModel.xml 184 | 185 | # Sensitive or high-churn files 186 | .idea/**/dataSources/ 187 | .idea/**/dataSources.ids 188 | .idea/**/dataSources.local.xml 189 | .idea/**/sqlDataSources.xml 190 | .idea/**/dynamic.xml 191 | .idea/**/uiDesigner.xml 192 | .idea/**/dbnavigator.xml 193 | 194 | # Gradle 195 | .idea/**/gradle.xml 196 | .idea/**/libraries 197 | 198 | # Gradle and Maven with auto-import 199 | # When using Gradle or Maven with auto-import, you should exclude module files, 200 | # since they will be recreated, and may cause churn. Uncomment if using 201 | # auto-import. 202 | # .idea/modules.xml 203 | # .idea/*.iml 204 | # .idea/modules 205 | # *.iml 206 | # *.ipr 207 | 208 | # CMake 209 | cmake-build-*/ 210 | 211 | # Mongo Explorer plugin 212 | .idea/**/mongoSettings.xml 213 | 214 | # File-based project format 215 | *.iws 216 | 217 | # IntelliJ 218 | out/ 219 | 220 | # mpeltonen/sbt-idea plugin 221 | .idea_modules/ 222 | 223 | # JIRA plugin 224 | atlassian-ide-plugin.xml 225 | 226 | # Cursive Clojure plugin 227 | .idea/replstate.xml 228 | 229 | # Crashlytics plugin (for Android Studio and IntelliJ) 230 | com_crashlytics_export_strings.xml 231 | crashlytics.properties 232 | crashlytics-build.properties 233 | fabric.properties 234 | 235 | # Editor-based Rest Client 236 | .idea/httpRequests 237 | 238 | # Android studio 3.1+ serialized cache file 239 | .idea/caches/build_file_checksums.ser 240 | 241 | ### PyCharm Patch ### 242 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 243 | 244 | # *.iml 245 | # modules.xml 246 | # .idea/misc.xml 247 | # *.ipr 248 | 249 | # Sonarlint plugin 250 | .idea/sonarlint 251 | 252 | ### Python ### 253 | # Byte-compiled / optimized / DLL files 254 | 255 | # C extensions 256 | 257 | # Distribution / packaging 258 | 259 | # PyInstaller 260 | # Usually these files are written by a python script from a template 261 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 262 | 263 | # Installer logs 264 | 265 | # Unit test / coverage reports 266 | 267 | # Translations 268 | 269 | # Django stuff: 270 | 271 | # Flask stuff: 272 | 273 | # Scrapy stuff: 274 | 275 | # Sphinx documentation 276 | 277 | # PyBuilder 278 | 279 | # Jupyter Notebook 280 | 281 | # IPython 282 | 283 | # pyenv 284 | 285 | # pipenv 286 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 287 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 288 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 289 | # install all needed dependencies. 290 | 291 | # celery beat schedule file 292 | 293 | # SageMath parsed files 294 | 295 | # Environments 296 | 297 | # Spyder project settings 298 | 299 | # Rope project settings 300 | 301 | # mkdocs documentation 302 | 303 | # mypy 304 | 305 | # Pyre type checker 306 | 307 | ### Vim ### 308 | # Swap 309 | [._]*.s[a-v][a-z] 310 | [._]*.sw[a-p] 311 | [._]s[a-rt-v][a-z] 312 | [._]ss[a-gi-z] 313 | [._]sw[a-p] 314 | 315 | # Session 316 | Session.vim 317 | Sessionx.vim 318 | 319 | # Temporary 320 | .netrwhist 321 | *~ 322 | # Auto-generated tag files 323 | tags 324 | # Persistent undo 325 | [._]*.un~ 326 | 327 | ### VisualStudioCode ### 328 | .vscode/* 329 | !.vscode/settings.json 330 | !.vscode/tasks.json 331 | !.vscode/launch.json 332 | !.vscode/extensions.json 333 | 334 | ### VisualStudioCode Patch ### 335 | # Ignore all local history of files 336 | .history 337 | 338 | # End of https://www.gitignore.io/api/vim,osx,python,django,pycharm,komodoedit,elasticbeanstalk,visualstudiocode 339 | 340 | # Ignore custom Docker compose DB data 341 | .db 342 | 343 | # Ignore local poetry settings 344 | poetry.toml 345 | 346 | # Ignore PyCharm idea folder 347 | .idea 348 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.9" 6 | mkdocs: 7 | configuration: mkdocs.yml 8 | fail_on_warning: false 9 | formats: all 10 | python: 11 | install: 12 | - requirements: docs/requirements.txt 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0.0 (2025-05-24) 4 | 5 | #### Api-Break 6 | 7 | * Support deferrable and read-only transactions by [@max-muoto](https://github.com/max-muoto) in [#21](https://github.com/AmbitionEng/django-pgtransaction/pull/21). 8 | * Breaking changes: 9 | * `execute_set_isolation_level` was removed in favor of `execute_set_transaction_modes`. 10 | * All arguments that aren't present in the Django implementation of `transaction.atomic` are now keyword-only. 11 | 12 | ## 1.5.3 (2025-05-18) 13 | 14 | #### Changes 15 | 16 | - Improve and correct typing by [@max-muoto](https://github.com/max-muoto) in [#19](https://github.com/AmbitionEng/django-pgtransaction/pull/19). 17 | 18 | ## 1.5.2 (2025-01-10) 19 | 20 | #### Fixes 21 | 22 | - Lazily load Django settings when using `pgtransaction` drop-in decorator by [@muscovite](https://github.com/muscovite) in [#17](https://github.com/AmbitionEng/django-pgtransaction/pull/17). 23 | 24 | ## 1.5.1 (2024-12-15) 25 | 26 | #### Changes 27 | 28 | - Changed project ownership to `AmbitionEng` by [@wesleykendall](https://github.com/wesleykendall) in [#16](https://github.com/AmbitionEng/django-pgtransaction/pull/16). 29 | 30 | ## 1.5.0 (2024-11-01) 31 | 32 | #### Changes 33 | 34 | - Added Python 3.13 support, dropped Python 3.8. Added Postgres17 support by [@wesleykendall](https://github.com/wesleykendall) in [#15](https://github.com/Opus10/django-pgtransaction/pull/15). 35 | 36 | ## 1.4.0 (2024-08-24) 37 | 38 | #### Changes 39 | 40 | - Django 5.1 compatibilty, and Dropped Django 3.2 / Postgres 12 support by [@wesleykendall](https://github.com/wesleykendall) in [#14](https://github.com/Opus10/django-pgtransaction/pull/14). 41 | 42 | ## 1.3.2 (2024-04-23) 43 | 44 | #### Trivial 45 | 46 | - Updated with latest Python template. [Wesley Kendall, c7a010c] 47 | 48 | ## 1.3.1 (2024-04-06) 49 | 50 | #### Trivial 51 | 52 | - Fix ReadTheDocs builds. [Wesley Kendall, 7d60e2a] 53 | 54 | ## 1.3.0 (2023-11-26) 55 | 56 | #### Feature 57 | 58 | - Django 5.0 compatibility [Wesley Kendall, 129331b] 59 | 60 | Support and test against Django 5 with psycopg2 and psycopg3. 61 | 62 | ## 1.2.1 (2023-10-09) 63 | 64 | #### Trivial 65 | 66 | - Added Opus10 branding to docs [Wesley Kendall, 4a0b78c] 67 | 68 | ## 1.2.0 (2023-10-08) 69 | 70 | #### Feature 71 | 72 | - Add Python 3.12 support and use Mkdocs for documentation [Wesley Kendall, ed0d18e] 73 | 74 | Python 3.12 and Postgres 16 are supported now, along with having revamped docs using Mkdocs and the Material theme. 75 | 76 | Python 3.7 support was dropped. 77 | 78 | ## 1.1.0 (2023-06-09) 79 | 80 | #### Feature 81 | 82 | - Added Python 3.11, Django 4.2, and Psycopg 3 support [Wesley Kendall, 6c032bb] 83 | 84 | Adds Python 3.11, Django 4.2, and Psycopg 3 support along with tests for multiple Postgres versions. Drops support for Django 2.2. 85 | 86 | ## 1.0.0 (2022-09-20) 87 | 88 | #### Api-Break 89 | 90 | - Initial release of django-pgtransaction [Paul Gilmartin, 09bca27] 91 | 92 | django-pgtransaction offers a drop-in replacement for the 93 | default ``django.db.transaction`` module which, when used on top of a PostgreSQL 94 | database, extends the functionality of that module with Postgres-specific features. 95 | 96 | V1 of django-pgtransaction provides the ``atomic`` decorator/context manager, which 97 | provides the following additional arguments to Django's ``atomic``: 98 | 99 | 1. ``isolation_level``: For setting the isolation level of the transaction. 100 | 2. ``retry``: For retrying when deadlock or serialization errors happen. 101 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | This project was created using footing. For more information about footing, go to the [footing docs](https://github.com/AmbitionEng/footing). 4 | 5 | ## Setup 6 | 7 | Set up your development environment with: 8 | 9 | git clone git@github.com:AmbitionEng/django-pgtransaction.git 10 | cd django-pgtransaction 11 | make docker-setup 12 | 13 | `make docker-setup` will set up a development environment managed by Docker. Install docker [here](https://www.docker.com/get-started) and be sure it is running when executing any of the commands below. 14 | 15 | If you prefer a native development environment, `make conda-setup` will set up a development environment managed by [Conda](https://conda.io). Dependent services, such as databases, must be ran manually. 16 | 17 | ## Testing and Validation 18 | 19 | Run the tests on one Python version with: 20 | 21 | make test 22 | 23 | Run the full test suite against all supported Python versions with: 24 | 25 | make full-test-suite 26 | 27 | Validate the code with: 28 | 29 | make lint 30 | 31 | If your code fails the linter checks, fix common errors with: 32 | 33 | make lint-fix 34 | 35 | ## Documentation 36 | 37 | [Mkdocs Material](https://squidfunk.github.io/mkdocs-material/) documentation can be built with: 38 | 39 | make docs 40 | 41 | A shortcut for serving them is: 42 | 43 | make docs-serve 44 | 45 | ## Releases and Versioning 46 | 47 | The version number and release notes are manually updated by the maintainer during the release process. Do not edit these. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025, Ambition 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | * 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 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without 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 AMBITION BE LIABLE FOR ANY 22 | 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 25 | ON 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for packaging and testing django-pgtransaction 2 | # 3 | # This Makefile has the following targets: 4 | # 5 | # setup - Sets up the development environment 6 | # dependencies - Installs dependencies 7 | # docs - Build documentation 8 | # docs-serve - Serve documentation 9 | # lint - Run code linting and static checks 10 | # lint-fix - Fix common linting errors 11 | # type-check - Run Pyright type-checking 12 | # test - Run tests using pytest 13 | # full-test-suite - Run full test suite using tox 14 | # shell - Run a shell in a virtualenv 15 | # docker-teardown - Spin down docker resources 16 | 17 | OS = $(shell uname -s) 18 | 19 | PACKAGE_NAME=django-pgtransaction 20 | MODULE_NAME=pgtransaction 21 | SHELL=bash 22 | 23 | ifeq (${OS}, Linux) 24 | DOCKER_CMD?=sudo docker 25 | DOCKER_RUN_ARGS?=-v /home:/home -v $(shell pwd):/code -e EXEC_WRAPPER="" -u "$(shell id -u):$(shell id -g)" -v /etc/passwd:/etc/passwd 26 | # The user can be passed to docker exec commands in Linux. 27 | # For example, "make shell user=root" for access to apt-get commands 28 | user?=$(shell id -u) 29 | group?=$(shell id ${user} -u) 30 | EXEC_WRAPPER?=$(DOCKER_CMD) exec --user="$(user):$(group)" -it $(PACKAGE_NAME) 31 | else ifeq (${OS}, Darwin) 32 | DOCKER_CMD?=docker 33 | DOCKER_RUN_ARGS?=-v ~/:/home/circleci -v $(shell pwd):/code -e EXEC_WRAPPER="" 34 | EXEC_WRAPPER?=$(DOCKER_CMD) exec -it $(PACKAGE_NAME) 35 | endif 36 | 37 | # Docker run mounts the local code directory, SSH (for git), and global git config information 38 | DOCKER_RUN_CMD?=$(DOCKER_CMD) compose run --name $(PACKAGE_NAME) $(DOCKER_RUN_ARGS) -d app 39 | 40 | # Print usage of main targets when user types "make" or "make help" 41 | .PHONY: help 42 | help: 43 | ifndef run 44 | @echo "Please choose one of the following targets: \n"\ 45 | " docker-setup: Setup Docker development environment\n"\ 46 | " conda-setup: Setup Conda development environment\n"\ 47 | " lock: Lock dependencies\n"\ 48 | " dependencies: Install dependencies\n"\ 49 | " shell: Start a shell\n"\ 50 | " test: Run tests\n"\ 51 | " tox: Run tests against all versions of Python\n"\ 52 | " lint: Run code linting and static checks\n"\ 53 | " lint-fix: Fix common linting errors\n"\ 54 | " type-check: Run Pyright type-checking\n"\ 55 | " docs: Build documentation\n"\ 56 | " docs-serve: Serve documentation\n"\ 57 | " docker-teardown: Spin down docker resources\n"\ 58 | "\n"\ 59 | "View the Makefile for more documentation" 60 | @exit 2 61 | else 62 | $(EXEC_WRAPPER) $(run) 63 | endif 64 | 65 | 66 | # Pull the latest container and start a detached run 67 | .PHONY: docker-start 68 | docker-start: 69 | $(DOCKER_CMD) compose pull 70 | $(DOCKER_RUN_CMD) 71 | 72 | 73 | # Lock dependencies 74 | .PHONY: lock 75 | lock: 76 | $(EXEC_WRAPPER) poetry lock --no-update 77 | $(EXEC_WRAPPER) poetry export --with dev --without-hashes -f requirements.txt > docs/requirements.txt 78 | 79 | 80 | # Install dependencies 81 | .PHONY: dependencies 82 | dependencies: 83 | $(EXEC_WRAPPER) poetry install --no-ansi 84 | 85 | 86 | # Sets up the local database 87 | .PHONY: db-setup 88 | db-setup: 89 | -psql postgres -c "CREATE USER postgres;" 90 | -psql postgres -c "ALTER USER postgres SUPERUSER;" 91 | -psql postgres -c "CREATE DATABASE ${MODULE_NAME}_local OWNER postgres;" 92 | -psql postgres -c "GRANT ALL PRIVILEGES ON DATABASE ${MODULE_NAME}_local to postgres;" 93 | $(EXEC_WRAPPER) python manage.py migrate 94 | 95 | 96 | # Sets up a conda development environment 97 | .PHONY: conda-create 98 | conda-create: 99 | -conda env create -f environment.yml -y 100 | $(EXEC_WRAPPER) poetry config virtualenvs.create false --local 101 | 102 | 103 | # Sets up a Conda development environment 104 | .PHONY: conda-setup 105 | conda-setup: EXEC_WRAPPER=conda run -n ${PACKAGE_NAME} --no-capture-output 106 | conda-setup: conda-create lock dependencies db-setup 107 | 108 | 109 | # Sets up a Docker development environment 110 | .PHONY: docker-setup 111 | docker-setup: docker-teardown docker-start lock dependencies 112 | 113 | 114 | # Spin down docker resources 115 | .PHONY: docker-teardown 116 | docker-teardown: 117 | $(DOCKER_CMD) compose down --remove-orphans 118 | 119 | 120 | # Run a shell 121 | .PHONY: shell 122 | shell: 123 | $(EXEC_WRAPPER) /bin/bash 124 | 125 | 126 | # Run pytest 127 | .PHONY: test 128 | test: 129 | $(EXEC_WRAPPER) pytest 130 | 131 | 132 | # Run full test suite 133 | .PHONY: full-test-suite 134 | full-test-suite: 135 | $(EXEC_WRAPPER) tox 136 | 137 | 138 | # Build documentation 139 | .PHONY: docs 140 | docs: 141 | $(EXEC_WRAPPER) mkdocs build -s 142 | 143 | 144 | # Serve documentation 145 | .PHONY: docs-serve 146 | docs-serve: 147 | $(EXEC_WRAPPER) mkdocs serve 148 | 149 | 150 | # Run code linting and static analysis. Ensure docs can be built 151 | .PHONY: lint 152 | lint: 153 | $(EXEC_WRAPPER) ruff format . --check 154 | $(EXEC_WRAPPER) ruff check ${MODULE_NAME} 155 | $(EXEC_WRAPPER) bash -c 'make docs' 156 | $(EXEC_WRAPPER) diff <(poetry export --with dev --without-hashes -f requirements.txt) docs/requirements.txt >/dev/null 2>&1 || exit 1 157 | 158 | 159 | # Fix common linting errors 160 | .PHONY: lint-fix 161 | lint-fix: 162 | $(EXEC_WRAPPER) ruff format . 163 | $(EXEC_WRAPPER) ruff check ${MODULE_NAME} --fix 164 | 165 | 166 | # Run Pyright type-checking 167 | .PHONY: type-check 168 | type-check: 169 | $(EXEC_WRAPPER) pyright $(MODULE_NAME) 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-pgtransaction 2 | 3 | django-pgtransaction offers a drop-in replacement for the default `django.db.transaction` module which, when used on top of a PostgreSQL database, extends the functionality of that module with Postgres-specific features. 4 | 5 | At present, django-pgtransaction offers an extension of the `django.db.transaction.atomic` context manager/decorator which allows one to dynamically set [transaction characteristics](https://www.postgresql.org/docs/current/sql-set-transaction.html) including: 6 | - [Isolation level](https://www.postgresql.org/docs/current/transaction-iso.html) 7 | - Read mode (READ WRITE/READ ONLY) 8 | - Deferrability (DEFERRABLE/NOT DEFERRABLE) 9 | - Retry policy for Postgres locking exceptions 10 | 11 | See the quickstart below or [the docs](https://django-pgtransaction.readthedocs.io/) for examples. 12 | 13 | ## Quickstart 14 | 15 | After installation, set transaction characteristics using `pgtransaction.atomic`: 16 | 17 | ### Isolation Levels 18 | 19 | Set the isolation level for specific consistency guarantees: 20 | 21 | ```python 22 | import pgtransaction 23 | 24 | with pgtransaction.atomic(isolation_level=pgtransaction.SERIALIZABLE): 25 | # Do queries with SERIALIZABLE isolation... 26 | ``` 27 | 28 | There are three isolation levels: `pgtransaction.READ_COMMITTED`, `pgtransaction.REPEATABLE_READ`, and `pgtransaction.SERIALIZABLE`. By default it inherits the parent isolation level, which is Django's default of "READ COMMITTED". 29 | 30 | ### Read-Only Transactions 31 | 32 | Read-only mode can be used queries that don't modify data: 33 | 34 | ```python 35 | with pgtransaction.atomic(read_mode=pgtransaction.READ_ONLY): 36 | # Can only read, not write 37 | results = MyModel.objects.all() 38 | ``` 39 | 40 | ### Deferrable Transactions 41 | 42 | Prevent serialization failures for long-running queries by blocking: 43 | 44 | ```python 45 | with pgtransaction.atomic( 46 | isolation_level=pgtransaction.SERIALIZABLE, 47 | read_mode=pgtransaction.READ_ONLY, 48 | deferrable=pgtransaction.DEFERRABLE 49 | ): 50 | # Long-running read-only query that won't cause serialization conflicts 51 | analytics_data = expensive_query() 52 | ``` 53 | 54 | Note: `DEFERRABLE` only works with `SERIALIZABLE` isolation level and `READ_ONLY` mode. 55 | 56 | ### Retries for Concurrent Updates 57 | 58 | When using stricter isolation levels like `pgtransaction.SERIALIZABLE`, Postgres will throw serialization errors upon concurrent updates to rows. Use the `retry` argument with the decorator to retry these failures: 59 | 60 | ```python 61 | @pgtransaction.atomic(isolation_level=pgtransaction.SERIALIZABLE, retry=3) 62 | def do_queries(): 63 | # Do queries... 64 | ``` 65 | 66 | Note that the `retry` argument will not work when used as a context manager. A `RuntimeError` will be thrown. 67 | 68 | By default, retries are only performed when `psycopg.errors.SerializationError` or `psycopg.errors.DeadlockDetected` errors are raised. Configure retried psycopg errors with `settings.PGTRANSACTION_RETRY_EXCEPTIONS`. You can set a default retry amount with `settings.PGTRANSACTION_RETRY`. 69 | 70 | ### Nested Usage 71 | 72 | `pgtransaction.atomic` can be nested, but keep the following in mind: 73 | 74 | 1. Isolation mode cannot be changed once a query has been performed. 75 | 2. Read-write mode can not be changed to from within a read only block. 76 | 3. The retry argument only works on the outermost invocation as a decorator, otherwise `RuntimeError` is raised. 77 | 78 | ## Compatibility 79 | 80 | `django-pgtransaction` is compatible with Python 3.9 - 3.13, Django 4.2 - 5.1, Psycopg 2 - 3, and Postgres 13 - 17. 81 | 82 | ## Documentation 83 | 84 | Check out the [Postgres docs](https://www.postgresql.org/docs/current/transaction-iso.html) to learn about transaction isolation in Postgres. 85 | 86 | [View the django-pgtransaction docs here](https://django-pgtransaction.readthedocs.io/) 87 | 88 | ## Installation 89 | 90 | Install `django-pgtransaction` with: 91 | 92 | pip3 install django-pgtransaction 93 | After this, add `pgtransaction` to the `INSTALLED_APPS` setting of your Django project. 94 | 95 | ## Contributing Guide 96 | 97 | For information on setting up django-pgtransaction for development and contributing changes, view [CONTRIBUTING.md](CONTRIBUTING.md). 98 | 99 | ## Creators 100 | 101 | - [Paul Gilmartin](https://github.com/PaulGilmartin) 102 | - [Wes Kendall](https://github.com/wesleykendall) 103 | 104 | -------------------------------------------------------------------------------- /devops.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Devops functions for this package. Includes functions for automated 5 | package deployment, changelog generation, and changelog checking. 6 | 7 | This script is generated by the template at 8 | https://github.com/AmbitionEng/python-library-template 9 | 10 | Do not change this script! Any fixes or updates to this script should be made 11 | to https://github.com/AmbitionEng/python-library-template 12 | """ 13 | 14 | import os 15 | import subprocess 16 | import sys 17 | from typing import IO, Any, TypeAlias, Union 18 | 19 | 20 | File: TypeAlias = Union[IO[Any], int, None] 21 | 22 | 23 | def _shell( 24 | cmd: str, 25 | check: bool = True, 26 | stdin: File = None, 27 | stdout: File = None, 28 | stderr: File = None, 29 | ): # pragma: no cover 30 | """Runs a subprocess shell with check=True by default""" 31 | return subprocess.run(cmd, shell=True, check=check, stdin=stdin, stdout=stdout, stderr=stderr) 32 | 33 | 34 | def _publish_to_pypi() -> None: 35 | """ 36 | Uses poetry to publish to pypi 37 | """ 38 | if "PYPI_USERNAME" not in os.environ or "PYPI_PASSWORD" not in os.environ: 39 | raise RuntimeError("Must set PYPI_USERNAME and PYPI_PASSWORD env vars") 40 | 41 | _shell("poetry config http-basic.pypi ${PYPI_USERNAME} ${PYPI_PASSWORD}") 42 | _shell("poetry build") 43 | _shell("poetry publish -vvv -n", stdout=subprocess.PIPE) 44 | 45 | 46 | def deploy() -> None: 47 | """Deploys the package and uploads documentation.""" 48 | # Ensure proper environment 49 | if not os.environ.get("CIRCLECI"): # pragma: no cover 50 | raise RuntimeError("Must be on CircleCI to run this script") 51 | 52 | _publish_to_pypi() 53 | 54 | print("Deployment complete.") 55 | 56 | 57 | if __name__ == "__main__": 58 | if sys.argv[-1] == "deploy": 59 | deploy() 60 | else: 61 | raise RuntimeError(f'Invalid subcommand "{sys.argv[-1]}"') 62 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | db: 5 | image: cimg/postgres:14.4 6 | volumes: 7 | - ./.db:/var/lib/postgresql/data 8 | environment: 9 | - POSTGRES_NAME=postgres 10 | - POSTGRES_USER=postgres 11 | - POSTGRES_PASSWORD=postgres 12 | app: 13 | image: opus10/circleci-python-library 14 | environment: 15 | - DATABASE_URL=postgres://postgres:postgres@db:5432/postgres 16 | depends_on: 17 | - db 18 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | --8<-- "CONTRIBUTING.md" -------------------------------------------------------------------------------- /docs/css/mkdocs-material.css: -------------------------------------------------------------------------------- 1 | .md-typeset__table { 2 | min-width: 100%; 3 | } 4 | 5 | .md-typeset table:not([class]) { 6 | display: table; 7 | } 8 | 9 | :root { 10 | --md-primary-fg-color: #1d1f29; 11 | --md-primary-fg-color--light: #1d1f29; 12 | --md-primary-fg-color--dark: #1d1f29; 13 | } 14 | 15 | .md-content { 16 | --md-typeset-a-color: #00bc70; 17 | } 18 | 19 | .md-footer { 20 | background-color: #1d1f29; 21 | } 22 | .md-footer-meta { 23 | background-color: #1d1f29; 24 | } 25 | 26 | readthedocs-flyout { 27 | display: none; 28 | } -------------------------------------------------------------------------------- /docs/css/mkdocstrings.css: -------------------------------------------------------------------------------- 1 | div.doc-contents:not(.first) { 2 | padding-left: 25px; 3 | border-left: .05rem solid var(--md-typeset-table-color); 4 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # django-pgtransaction 2 | 3 | django-pgtransaction offers a drop-in replacement for the default `django.db.transaction` module which, when used on top of a PostgreSQL database, extends the functionality of that module with Postgres-specific features. 4 | 5 | At present, django-pgtransaction offers an extension of the `django.db.transaction.atomic` context manager/decorator which allows one to dynamically set [transaction characteristics](https://www.postgresql.org/docs/current/sql-set-transaction.html) including: 6 | - [Isolation level](https://www.postgresql.org/docs/current/transaction-iso.html) 7 | - Read mode (READ WRITE/READ ONLY) 8 | - Deferrability (DEFERRABLE/NOT DEFERRABLE) 9 | - Retry policy for Postgres locking exceptions 10 | 11 | See [module docs](module.md) and the quickstart below for examples. 12 | 13 | ## Quickstart 14 | 15 | After [installation](installation.md), set transaction characteristics using [pgtransaction.atomic][]: 16 | 17 | ### Isolation Levels 18 | 19 | Set the isolation level for specific consistency guarantees: 20 | 21 | ```python 22 | import pgtransaction 23 | 24 | with pgtransaction.atomic(isolation_level=pgtransaction.SERIALIZABLE): 25 | # Do queries with SERIALIZABLE isolation... 26 | ``` 27 | 28 | There are three isolation levels: `pgtransaction.READ_COMMITTED`, `pgtransaction.REPEATABLE_READ`, and `pgtransaction.SERIALIZABLE`. By default it inherits the parent isolation level, which is Django's default of "READ COMMITTED". 29 | 30 | ### Read-Only Transactions 31 | 32 | Read-only mode can be used queries that don't modify data: 33 | 34 | ```python 35 | with pgtransaction.atomic(read_mode=pgtransaction.READ_ONLY): 36 | # Can only read, not write 37 | results = MyModel.objects.all() 38 | ``` 39 | 40 | ### Deferrable Transactions 41 | 42 | Prevent serialization failures for long-running queries by blocking: 43 | 44 | ```python 45 | with pgtransaction.atomic( 46 | isolation_level=pgtransaction.SERIALIZABLE, 47 | read_mode=pgtransaction.READ_ONLY, 48 | deferrable=pgtransaction.DEFERRABLE 49 | ): 50 | # Long-running read-only query that won't cause serialization conflicts 51 | analytics_data = expensive_query() 52 | ``` 53 | 54 | Note: `DEFERRABLE` only works with `SERIALIZABLE` isolation level and `READ_ONLY` mode. 55 | 56 | ### Retries for Concurrent Updates 57 | 58 | When using stricter isolation levels like `pgtransaction.SERIALIZABLE`, Postgres will throw serialization errors upon concurrent updates to rows. Use the `retry` argument with the decorator to retry these failures: 59 | 60 | ```python 61 | @pgtransaction.atomic(isolation_level=pgtransaction.SERIALIZABLE, retry=3) 62 | def do_queries(): 63 | # Do queries... 64 | ``` 65 | 66 | !!! note 67 | 68 | The `retry` argument will not work when used as a context manager. A `RuntimeError` will be thrown. 69 | 70 | By default, retries are only performed when `psycopg.errors.SerializationError` or `psycopg.errors.DeadlockDetected` errors are raised. Configure retried psycopg errors with `settings.PGTRANSACTION_RETRY_EXCEPTIONS`. You can set a default retry amount with `settings.PGTRANSACTION_RETRY`. 71 | 72 | ### Nested Usage 73 | 74 | [pgtransaction.atomic][] can be nested, but keep the following in mind: 75 | 76 | 1. Isolation mode cannot be changed once a query has been performed. 77 | 2. Read-write mode can not be changed to from within a read only block. 78 | 3. The retry argument only works on the outermost invocation as a decorator, otherwise `RuntimeError` is raised. 79 | 80 | ## Compatibility 81 | 82 | `django-pgtransaction` is compatible with Python 3.9 - 3.13, Django 4.2 - 5.1, Psycopg 2 - 3, and Postgres 13 - 17. 83 | 84 | ## Other Reading 85 | 86 | Check out the [Postgres docs](https://www.postgresql.org/docs/current/transaction-iso.html) to learn about transaction isolation in Postgres. 87 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install `django-pgtransaction` with: 4 | 5 | pip3 install django-pgtransaction 6 | 7 | After this, add `pgtransaction` to the `INSTALLED_APPS` setting of your Django project. -------------------------------------------------------------------------------- /docs/module.md: -------------------------------------------------------------------------------- 1 | # Module 2 | 3 | ::: pgtransaction.atomic 4 | -------------------------------------------------------------------------------- /docs/release_notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | --8<-- "CHANGELOG.md:2" 4 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | arrow==1.3.0 ; python_full_version >= "3.9.0" and python_version < "4" 2 | asgiref==3.7.2 ; python_full_version >= "3.9.0" and python_version < "4" 3 | babel==2.13.0 ; python_full_version >= "3.9.0" and python_version < "4" 4 | binaryornot==0.4.4 ; python_full_version >= "3.9.0" and python_version < "4" 5 | black==24.10.0 ; python_version >= "3.9" and python_version < "4" 6 | build==1.2.2.post1 ; python_full_version >= "3.9.0" and python_version < "4.0" 7 | cachecontrol[filecache]==0.14.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 8 | cachetools==5.5.0 ; python_full_version >= "3.9.0" and python_version < "4" 9 | certifi==2023.7.22 ; python_full_version >= "3.9.0" and python_version < "4" 10 | cffi==1.17.1 ; python_full_version >= "3.9.0" and python_version < "4.0" and (sys_platform == "darwin" or sys_platform == "linux") and (sys_platform == "darwin" or platform_python_implementation != "PyPy") 11 | chardet==5.2.0 ; python_full_version >= "3.9.0" and python_version < "4" 12 | charset-normalizer==3.3.0 ; python_full_version >= "3.9.0" and python_version < "4" 13 | cleo==2.1.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 14 | click==8.1.7 ; python_version >= "3.9" and python_version < "4" 15 | colorama==0.4.6 ; python_version >= "3.9" and python_version < "4" 16 | cookiecutter==1.7.3 ; python_full_version >= "3.9.0" and python_version < "4" 17 | coverage[toml]==7.3.2 ; python_full_version >= "3.9.0" and python_version < "4" 18 | crashtest==0.4.1 ; python_full_version >= "3.9.0" and python_version < "4.0" 19 | cryptography==43.0.3 ; python_full_version >= "3.9.0" and python_version < "4.0" and sys_platform == "linux" 20 | distlib==0.3.7 ; python_full_version >= "3.9.0" and python_version < "4" 21 | dj-database-url==2.3.0 ; python_full_version >= "3.9.0" and python_version < "4" 22 | django-dynamic-fixture==4.0.1 ; python_full_version >= "3.9.0" and python_version < "4" 23 | django-stubs-ext==5.1.1 ; python_full_version >= "3.9.0" and python_version < "4" 24 | django-stubs==5.1.1 ; python_full_version >= "3.9.0" and python_version < "4" 25 | django==4.2.6 ; python_full_version >= "3.9.0" and python_version < "4" 26 | dulwich==0.21.7 ; python_full_version >= "3.9.0" and python_version < "4.0" 27 | exceptiongroup==1.1.3 ; python_full_version >= "3.9.0" and python_version < "3.11" 28 | fastjsonschema==2.20.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 29 | filelock==3.16.1 ; python_full_version >= "3.9.0" and python_version < "4" 30 | footing==0.1.4 ; python_full_version >= "3.9.0" and python_version < "4" 31 | ghp-import==2.1.0 ; python_version >= "3.9" and python_version < "4" 32 | griffe==1.2.0 ; python_version >= "3.9" and python_version < "4" 33 | idna==3.4 ; python_full_version >= "3.9.0" and python_version < "4" 34 | importlib-metadata==6.8.0 ; python_version >= "3.9" and python_version < "3.12" 35 | iniconfig==2.0.0 ; python_full_version >= "3.9.0" and python_version < "4" 36 | installer==0.7.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 37 | jaraco-classes==3.4.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 38 | jeepney==0.8.0 ; python_full_version >= "3.9.0" and python_version < "4.0" and sys_platform == "linux" 39 | jinja2-time==0.2.0 ; python_full_version >= "3.9.0" and python_version < "4" 40 | jinja2==3.1.2 ; python_version >= "3.9" and python_version < "4" 41 | keyring==24.3.1 ; python_full_version >= "3.9.0" and python_version < "4.0" 42 | markdown==3.7 ; python_version >= "3.9" and python_version < "4" 43 | markupsafe==2.1.3 ; python_version >= "3.9" and python_version < "4" 44 | mergedeep==1.3.4 ; python_version >= "3.9" and python_version < "4" 45 | mkdocs-autorefs==1.2.0 ; python_version >= "3.9" and python_version < "4" 46 | mkdocs-get-deps==0.2.0 ; python_version >= "3.9" and python_version < "4" 47 | mkdocs-material-extensions==1.3.1 ; python_full_version >= "3.9.0" and python_version < "4" 48 | mkdocs-material==9.5.42 ; python_full_version >= "3.9.0" and python_version < "4" 49 | mkdocs==1.6.1 ; python_version >= "3.9" and python_version < "4" 50 | mkdocstrings-python==1.12.2 ; python_version >= "3.9" and python_version < "4" 51 | mkdocstrings==0.26.2 ; python_version >= "3.9" and python_version < "4" 52 | more-itertools==10.5.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 53 | msgpack==1.1.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 54 | mypy-extensions==1.0.0 ; python_version >= "3.9" and python_version < "4" 55 | nodeenv==1.8.0 ; python_full_version >= "3.9.0" and python_version < "4" 56 | packaging==24.1 ; python_version >= "3.9" and python_version < "4" 57 | paginate==0.5.6 ; python_full_version >= "3.9.0" and python_version < "4" 58 | pathspec==0.11.2 ; python_version >= "3.9" and python_version < "4" 59 | pexpect==4.9.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 60 | pkginfo==1.11.2 ; python_full_version >= "3.9.0" and python_version < "4.0" 61 | platformdirs==4.3.6 ; python_version >= "3.9" and python_version < "4" 62 | pluggy==1.5.0 ; python_full_version >= "3.9.0" and python_version < "4" 63 | poetry-core==1.9.1 ; python_full_version >= "3.9.0" and python_version < "4.0" 64 | poetry-plugin-export==1.8.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 65 | poetry==1.8.4 ; python_full_version >= "3.9.0" and python_version < "4.0" 66 | poyo==0.5.0 ; python_full_version >= "3.9.0" and python_version < "4" 67 | psycopg2-binary==2.9.10 ; python_full_version >= "3.9.0" and python_version < "4" 68 | ptyprocess==0.7.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 69 | pycparser==2.22 ; python_full_version >= "3.9.0" and python_version < "4.0" and (sys_platform == "darwin" or sys_platform == "linux") and (sys_platform == "darwin" or platform_python_implementation != "PyPy") 70 | pygments==2.16.1 ; python_full_version >= "3.9.0" and python_version < "4" 71 | pymdown-extensions==10.3 ; python_version >= "3.9" and python_version < "4" 72 | pyproject-api==1.8.0 ; python_full_version >= "3.9.0" and python_version < "4" 73 | pyproject-hooks==1.2.0 ; python_full_version >= "3.9.0" and python_version < "4.0" 74 | pyright==1.1.386 ; python_full_version >= "3.9.0" and python_version < "4" 75 | pytest-cov==5.0.0 ; python_full_version >= "3.9.0" and python_version < "4" 76 | pytest-django==4.9.0 ; python_full_version >= "3.9.0" and python_version < "4" 77 | pytest-dotenv==0.5.2 ; python_full_version >= "3.9.0" and python_version < "4" 78 | pytest==8.3.3 ; python_full_version >= "3.9.0" and python_version < "4" 79 | python-dateutil==2.8.2 ; python_version >= "3.9" and python_version < "4" 80 | python-dotenv==1.0.0 ; python_full_version >= "3.9.0" and python_version < "4" 81 | python-gitlab==3.15.0 ; python_full_version >= "3.9.0" and python_version < "4" 82 | python-slugify==8.0.1 ; python_full_version >= "3.9.0" and python_version < "4" 83 | pywin32-ctypes==0.2.3 ; python_full_version >= "3.9.0" and python_version < "4.0" and sys_platform == "win32" 84 | pyyaml-env-tag==0.1 ; python_version >= "3.9" and python_version < "4" 85 | pyyaml==6.0.1 ; python_version >= "3.9" and python_version < "4" 86 | rapidfuzz==3.10.1 ; python_version >= "3.9" and python_version < "4.0" 87 | regex==2023.10.3 ; python_full_version >= "3.9.0" and python_version < "4" 88 | requests-file==1.5.1 ; python_full_version >= "3.9.0" and python_version < "4" 89 | requests-toolbelt==1.0.0 ; python_full_version >= "3.9.0" and python_version < "4" 90 | requests==2.31.0 ; python_full_version >= "3.9.0" and python_version < "4" 91 | ruff==0.7.1 ; python_full_version >= "3.9.0" and python_version < "4" 92 | secretstorage==3.3.3 ; python_full_version >= "3.9.0" and python_version < "4.0" and sys_platform == "linux" 93 | setuptools==68.2.2 ; python_full_version >= "3.9.0" and python_version < "4" 94 | shellingham==1.5.4 ; python_full_version >= "3.9.0" and python_version < "4.0" 95 | six==1.16.0 ; python_version >= "3.9" and python_version < "4" 96 | sqlparse==0.4.4 ; python_full_version >= "3.9.0" and python_version < "4" 97 | text-unidecode==1.3 ; python_full_version >= "3.9.0" and python_version < "4" 98 | tldextract==3.6.0 ; python_full_version >= "3.9.0" and python_version < "4" 99 | tomli==2.0.1 ; python_version >= "3.9" and python_full_version <= "3.11.0a6" 100 | tomlkit==0.13.2 ; python_full_version >= "3.9.0" and python_version < "4.0" 101 | tox==4.23.2 ; python_full_version >= "3.9.0" and python_version < "4" 102 | trove-classifiers==2024.10.21.16 ; python_full_version >= "3.9.0" and python_version < "4.0" 103 | types-python-dateutil==2.8.19.14 ; python_full_version >= "3.9.0" and python_version < "4" 104 | types-pyyaml==6.0.12.20240311 ; python_full_version >= "3.9.0" and python_version < "4" 105 | typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "4" 106 | tzdata==2023.3 ; python_full_version >= "3.9.0" and python_version < "4" and sys_platform == "win32" 107 | urllib3==2.0.6 ; python_full_version >= "3.9.0" and python_version < "4" 108 | virtualenv==20.27.1 ; python_full_version >= "3.9.0" and python_version < "4" 109 | watchdog==3.0.0 ; python_version >= "3.9" and python_version < "4" 110 | xattr==1.1.0 ; python_full_version >= "3.9.0" and python_version < "4.0" and sys_platform == "darwin" 111 | zipp==3.17.0 ; python_version >= "3.9" and python_version < "3.12" 112 | -------------------------------------------------------------------------------- /docs/static/dark_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmbitionEng/django-pgtransaction/edb3a706bb2452ecf94ab424c6dca4221c3e1744/docs/static/dark_logo.png -------------------------------------------------------------------------------- /docs/static/light_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmbitionEng/django-pgtransaction/edb3a706bb2452ecf94ab424c6dca4221c3e1744/docs/static/light_logo.png -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: django-pgtransaction 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python==3.13.0 6 | - poetry==1.8.4 7 | - pip==24.2 8 | - postgresql==17.0 9 | variables: 10 | DATABASE_URL: "postgres://postgres@localhost:5432/pgtransaction_local" 11 | EXEC_WRAPPER: "" 12 | -------------------------------------------------------------------------------- /footing.yaml: -------------------------------------------------------------------------------- 1 | _extensions: 2 | - jinja2_time.TimeExtension 3 | _template: git@github.com:Opus10/public-django-app-template.git 4 | _version: b7d2321846eeeb120ea9455597ddcf9bd8b5e7a4 5 | check_types_in_ci: 'False' 6 | is_django: 'True' 7 | module_name: pgtransaction 8 | repo_name: django-pgtransaction 9 | short_description: A context manager/decorator which extends Django's atomic function 10 | with the ability to set isolation level and retries for a given transaction. 11 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | from django.core.management import execute_from_command_line 8 | 9 | execute_from_command_line(sys.argv) 10 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: django-pgtransaction 2 | docs_dir: docs 3 | 4 | repo_name: AmbitionEng/django-pgtransaction 5 | repo_url: https://github.com/AmbitionEng/django-pgtransaction 6 | 7 | plugins: 8 | - search 9 | - mkdocstrings: 10 | handlers: 11 | python: 12 | import: 13 | - https://docs.python.org/3/objects.inv 14 | - https://installer.readthedocs.io/en/stable/objects.inv 15 | - https://mkdocstrings.github.io/autorefs/objects.inv 16 | options: 17 | docstring_options: 18 | ignore_init_summary: true 19 | line_length: 80 20 | heading_level: 2 21 | merge_init_into_class: true 22 | separate_signature: true 23 | show_root_heading: true 24 | show_root_full_path: true 25 | show_root_members_full_path: true 26 | show_signature_annotations: true 27 | show_symbol_type_heading: true 28 | show_symbol_type_toc: true 29 | signature_crossrefs: true 30 | 31 | markdown_extensions: 32 | # For admonitions 33 | - admonition 34 | - pymdownx.details 35 | - pymdownx.superfences 36 | - pymdownx.highlight: 37 | anchor_linenums: true 38 | line_spans: __span 39 | pygments_lang_class: true 40 | - pymdownx.inlinehilite 41 | - pymdownx.snippets 42 | - pymdownx.superfences 43 | - tables 44 | - pymdownx.superfences: 45 | custom_fences: 46 | - name: mermaid 47 | class: mermaid 48 | format: !!python/name:pymdownx.superfences.fence_code_format 49 | - toc: 50 | permalink: true 51 | 52 | theme: 53 | name: material 54 | logo: static/dark_logo.png 55 | favicon: static/light_logo.png 56 | features: 57 | - content.code.copy 58 | - navigation.footer 59 | - navigation.path 60 | - navigation.sections 61 | - navigation.tracking 62 | - search.suggest 63 | - search.highlight 64 | - toc.follow 65 | palette: 66 | - media: "(prefers-color-scheme: light)" 67 | scheme: default 68 | primary: custom 69 | toggle: 70 | icon: material/brightness-7 71 | name: Switch to dark mode 72 | - media: "(prefers-color-scheme: dark)" 73 | scheme: slate 74 | primary: custom 75 | toggle: 76 | icon: material/brightness-4 77 | name: Switch to light mode 78 | 79 | extra_css: 80 | - css/mkdocstrings.css 81 | - css/mkdocs-material.css 82 | 83 | nav: 84 | - Overview: index.md 85 | - Installation: installation.md 86 | - Module: module.md 87 | - Release Notes: release_notes.md 88 | - Contributing Guide: contributing.md 89 | -------------------------------------------------------------------------------- /pgtransaction/__init__.py: -------------------------------------------------------------------------------- 1 | from pgtransaction.transaction import ( 2 | DEFERRABLE, 3 | NOT_DEFERRABLE, 4 | READ_COMMITTED, 5 | READ_ONLY, 6 | READ_WRITE, 7 | REPEATABLE_READ, 8 | SERIALIZABLE, 9 | Atomic, 10 | atomic, 11 | ) 12 | from pgtransaction.version import __version__ 13 | 14 | __all__ = [ 15 | "Atomic", 16 | "atomic", 17 | "READ_COMMITTED", 18 | "REPEATABLE_READ", 19 | "SERIALIZABLE", 20 | "READ_WRITE", 21 | "READ_ONLY", 22 | "DEFERRABLE", 23 | "NOT_DEFERRABLE", 24 | "__version__", 25 | ] 26 | -------------------------------------------------------------------------------- /pgtransaction/config.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | try: 5 | import psycopg.errors as psycopg_errors 6 | except ImportError: 7 | import psycopg2.errors as psycopg_errors 8 | except Exception as exc: # pragma: no cover 9 | raise ImproperlyConfigured("Error loading psycopg2 or psycopg module") from exc 10 | 11 | 12 | def retry_exceptions(): 13 | """The default errors caught when retrying. 14 | 15 | Note that these must be psycopg errors. 16 | """ 17 | return getattr( 18 | settings, 19 | "PGTRANSACTION_RETRY_EXCEPTIONS", 20 | (psycopg_errors.SerializationFailure, psycopg_errors.DeadlockDetected), 21 | ) 22 | 23 | 24 | def retry(): 25 | """The default retry amount""" 26 | return getattr(settings, "PGTRANSACTION_RETRY", 0) 27 | -------------------------------------------------------------------------------- /pgtransaction/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmbitionEng/django-pgtransaction/edb3a706bb2452ecf94ab424c6dca4221c3e1744/pgtransaction/py.typed -------------------------------------------------------------------------------- /pgtransaction/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmbitionEng/django-pgtransaction/edb3a706bb2452ecf94ab424c6dca4221c3e1744/pgtransaction/tests/__init__.py -------------------------------------------------------------------------------- /pgtransaction/tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-14 13:26 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Trade", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 23 | ), 24 | ), 25 | ("company", models.CharField(max_length=36, unique=True)), 26 | ("price", models.FloatField()), 27 | ( 28 | "owner", 29 | models.ForeignKey( 30 | null=True, 31 | on_delete=django.db.models.deletion.SET_NULL, 32 | to=settings.AUTH_USER_MODEL, 33 | ), 34 | ), 35 | ], 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /pgtransaction/tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmbitionEng/django-pgtransaction/edb3a706bb2452ecf94ab424c6dca4221c3e1744/pgtransaction/tests/migrations/__init__.py -------------------------------------------------------------------------------- /pgtransaction/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | 4 | 5 | class Trade(models.Model): 6 | company = models.CharField(max_length=36, unique=True) 7 | price = models.FloatField() 8 | owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) 9 | -------------------------------------------------------------------------------- /pgtransaction/tests/test_config.py: -------------------------------------------------------------------------------- 1 | try: 2 | import psycopg.errors as psycopg_errors 3 | except ImportError: 4 | import psycopg2.errors as psycopg_errors 5 | 6 | from pgtransaction import config 7 | 8 | 9 | def test_retry_exceptions(settings): 10 | assert config.retry_exceptions() == ( 11 | psycopg_errors.SerializationFailure, 12 | psycopg_errors.DeadlockDetected, 13 | ) 14 | 15 | settings.PGTRANSACTION_RETRY_EXCEPTIONS = [psycopg_errors.DeadlockDetected] 16 | assert config.retry_exceptions() == [psycopg_errors.DeadlockDetected] 17 | 18 | 19 | def test_retry(settings): 20 | assert config.retry() == 0 21 | 22 | settings.PGTRANSACTION_RETRY = 1 23 | assert config.retry() == 1 24 | -------------------------------------------------------------------------------- /pgtransaction/tests/test_transaction.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | import ddf 5 | import pytest 6 | from django.db import transaction 7 | from django.db.utils import InternalError, OperationalError 8 | 9 | import pgtransaction 10 | from pgtransaction.tests.models import Trade 11 | from pgtransaction.transaction import atomic 12 | 13 | try: 14 | import psycopg.errors as psycopg_errors 15 | except ImportError: 16 | import psycopg2.errors as psycopg_errors 17 | 18 | 19 | @pytest.mark.django_db() 20 | def test_atomic_read_committed(): 21 | with atomic(isolation_level=pgtransaction.READ_COMMITTED): 22 | ddf.G(Trade) 23 | assert 1 == Trade.objects.count() 24 | 25 | 26 | @pytest.mark.django_db() 27 | def test_atomic_repeatable_read(): 28 | with atomic(isolation_level=pgtransaction.REPEATABLE_READ): 29 | ddf.G(Trade) 30 | assert 1 == Trade.objects.count() 31 | 32 | 33 | @pytest.mark.django_db(transaction=True) 34 | def test_atomic_repeatable_read_with_select(): 35 | ddf.G(Trade, price=1) 36 | with atomic(isolation_level="REPEATABLE READ"): 37 | trade = Trade.objects.last() 38 | trade.price = 2 39 | trade.save() 40 | assert 1 == Trade.objects.count() 41 | 42 | 43 | @pytest.mark.django_db() 44 | def test_atomic_serializable(): 45 | with atomic(isolation_level=pgtransaction.SERIALIZABLE): 46 | ddf.G(Trade) 47 | assert 1 == Trade.objects.count() 48 | 49 | 50 | @pytest.mark.django_db() 51 | def test_atomic_decorator(): 52 | @atomic(isolation_level="REPEATABLE READ") 53 | def f(): 54 | ddf.G(Trade) 55 | 56 | f() 57 | assert 1 == Trade.objects.count() 58 | 59 | 60 | @pytest.mark.django_db(transaction=True) 61 | def test_atomic_decorator_with_args(): 62 | @atomic(isolation_level="REPEATABLE READ") 63 | def f(trade_id): 64 | trade = Trade.objects.get(id=trade_id) 65 | trade.price = 2 66 | trade.save() 67 | 68 | trade = ddf.G(Trade, price=1) 69 | f(trade.pk) 70 | assert 1 == Trade.objects.count() 71 | 72 | 73 | @pytest.mark.django_db(transaction=True) 74 | def test_atomic_nested_isolation_levels(): 75 | # This is permitted because no statements have been issued 76 | with transaction.atomic(): 77 | with atomic(isolation_level="SERIALIZABLE"): 78 | pass 79 | 80 | # You can't change the isolation levels after issuing 81 | # a statement 82 | with pytest.raises(InternalError): 83 | with atomic(isolation_level="REPEATABLE READ"): 84 | ddf.G(Trade) 85 | with atomic(isolation_level="SERIALIZABLE"): 86 | pass 87 | 88 | # This is permitted because the isolation levels remain the same 89 | with atomic(isolation_level="REPEATABLE READ"): 90 | ddf.G(Trade) 91 | with atomic(isolation_level="REPEATABLE READ"): 92 | pass 93 | 94 | # Final sanity check 95 | with pytest.raises(InternalError): 96 | with atomic(isolation_level="REPEATABLE READ"): 97 | ddf.G(Trade) 98 | with atomic(isolation_level="REPEATABLE READ"): 99 | with atomic(isolation_level="SERIALIZABLE"): 100 | pass 101 | 102 | 103 | @pytest.mark.django_db(transaction=True) 104 | def test_atomic_nested_read_modes(): 105 | # This is permitted because no statements have been issued 106 | with transaction.atomic(): 107 | with atomic(read_mode=pgtransaction.READ_ONLY): 108 | pass 109 | 110 | # You can nest READ_ONLY inside READ_WRITE after issuing statements 111 | with atomic(read_mode=pgtransaction.READ_WRITE): 112 | ddf.G(Trade) 113 | with atomic(read_mode=pgtransaction.READ_ONLY): 114 | # Can read in nested read-only transaction 115 | Trade.objects.count() 116 | 117 | # This is permitted - same read modes 118 | with atomic(read_mode=pgtransaction.READ_WRITE): 119 | ddf.G(Trade) 120 | with atomic(read_mode=pgtransaction.READ_WRITE): 121 | ddf.G(Trade) 122 | 123 | # You cannot set READ WRITE mode after any query has been issued 124 | with pytest.raises(InternalError): 125 | with atomic(read_mode=pgtransaction.READ_ONLY): 126 | Trade.objects.count() 127 | with atomic(read_mode=pgtransaction.READ_WRITE): 128 | pass 129 | 130 | 131 | @pytest.mark.django_db() 132 | def test_atomic_with_nested_atomic(): 133 | with atomic(isolation_level="REPEATABLE READ"): 134 | ddf.G(Trade) 135 | with atomic(): 136 | ddf.G(Trade) 137 | assert 2 == Trade.objects.count() 138 | 139 | 140 | @pytest.mark.django_db() 141 | def test_atomic_rollback(): 142 | with pytest.raises(Exception, match="Exception thrown"): 143 | with atomic(isolation_level="REPEATABLE READ"): 144 | ddf.G(Trade) 145 | raise Exception("Exception thrown") 146 | 147 | assert not Trade.objects.exists() 148 | 149 | 150 | @pytest.mark.django_db() 151 | def test_pg_atomic_nested_atomic_rollback(): 152 | with atomic(isolation_level="REPEATABLE READ"): 153 | ddf.G(Trade) 154 | try: 155 | with atomic(): 156 | ddf.G(Trade) 157 | raise RuntimeError 158 | except RuntimeError: 159 | pass 160 | assert 1 == Trade.objects.count() 161 | 162 | 163 | @pytest.mark.django_db(transaction=True) 164 | def test_atomic_retries_context_manager_not_allowed(): 165 | with pytest.raises(RuntimeError, match="as a context manager"): 166 | with atomic(isolation_level="REPEATABLE READ", retry=1): 167 | pass 168 | 169 | 170 | @pytest.mark.django_db() 171 | def test_atomic_nested_retries_not_permitted(): 172 | with pytest.raises(RuntimeError, match="Retries are not permitted"): 173 | with transaction.atomic(): 174 | with atomic(isolation_level="REPEATABLE READ", retry=1): 175 | pass 176 | 177 | @atomic(isolation_level="REPEATABLE READ", retry=1) 178 | def decorated(): 179 | pass 180 | 181 | with pytest.raises(RuntimeError, match="Retries are not permitted"): 182 | with transaction.atomic(): 183 | decorated() 184 | 185 | 186 | @pytest.mark.django_db(transaction=True) 187 | def test_atomic_retries_all_retries_fail(): 188 | assert not Trade.objects.exists() 189 | attempts = [] 190 | 191 | @atomic(isolation_level="REPEATABLE READ", retry=2) 192 | def func(retries): 193 | attempts.append(True) 194 | ddf.G(Trade) 195 | raise OperationalError from psycopg_errors.SerializationFailure 196 | 197 | with pytest.raises(OperationalError): 198 | func(attempts) 199 | 200 | assert not Trade.objects.exists() 201 | assert len(attempts) == 3 202 | 203 | # Ensure the decorator tries again 204 | with pytest.raises(OperationalError): 205 | func(attempts) 206 | 207 | assert not Trade.objects.exists() 208 | assert len(attempts) == 6 209 | 210 | 211 | @pytest.mark.django_db(transaction=True) 212 | def test_atomic_retries_decorator_first_retry_passes(): 213 | assert not Trade.objects.exists() 214 | attempts = [] 215 | 216 | @atomic(isolation_level="REPEATABLE READ", retry=1) 217 | def func(attempts): 218 | attempts.append(True) 219 | ddf.G(Trade) 220 | if len(attempts) == 1: 221 | raise OperationalError from psycopg_errors.SerializationFailure 222 | 223 | func(attempts) 224 | assert 1 == Trade.objects.all().count() 225 | assert len(attempts) == 2 226 | 227 | 228 | @pytest.mark.django_db(transaction=True) 229 | def test_pg_atomic_retries_with_nested_atomic_failure(): 230 | assert not Trade.objects.exists() 231 | attempts = [] 232 | 233 | @atomic(isolation_level="REPEATABLE READ", retry=2) 234 | def outer(attempts): 235 | ddf.G(Trade) 236 | 237 | @atomic 238 | def inner(attempts): 239 | attempts.append(True) 240 | ddf.G(Trade) 241 | raise psycopg_errors.SerializationFailure 242 | 243 | try: 244 | inner(attempts) 245 | except psycopg_errors.SerializationFailure: 246 | pass 247 | 248 | outer(attempts) 249 | assert 1 == Trade.objects.all().count() 250 | assert len(attempts) == 1 251 | 252 | 253 | @pytest.mark.django_db(transaction=True) 254 | def test_atomic_retries_with_run_time_failure(): 255 | assert not Trade.objects.exists() 256 | attempts = [] 257 | 258 | @atomic(isolation_level="REPEATABLE READ", retry=2) 259 | def outer(attempts): 260 | attempts.append(True) 261 | ddf.G(Trade) 262 | raise RuntimeError 263 | 264 | with pytest.raises(RuntimeError): 265 | outer(attempts) 266 | 267 | assert not Trade.objects.all().exists() 268 | assert len(attempts) == 1 269 | 270 | 271 | @pytest.mark.django_db(transaction=True) 272 | def test_atomic_retries_with_nested_atomic_and_outer_retry(): 273 | assert not Trade.objects.exists() 274 | attempts = [] 275 | 276 | @atomic(isolation_level="REPEATABLE READ", retry=1) 277 | def outer(attempts): 278 | ddf.G(Trade) 279 | 280 | @atomic 281 | def inner(attempts): 282 | attempts.append(True) 283 | ddf.G(Trade) 284 | 285 | inner(attempts) 286 | 287 | if len(attempts) == 1: 288 | raise OperationalError from psycopg_errors.SerializationFailure 289 | 290 | outer(attempts) 291 | assert 2 == Trade.objects.all().count() 292 | assert len(attempts) == 2 293 | 294 | 295 | @pytest.mark.django_db(transaction=True) 296 | def test_concurrent_serialization_error(): 297 | """ 298 | Simulate a concurrency issue that will throw a serialization error. 299 | Ensure that a retry is successful 300 | """ 301 | 302 | def concurrent_update(barrier, trade, calls): 303 | # We have to instantiate the decorator inside the function, otherwise 304 | # it is shared among threads and causes the test to hang. It's uncertain 305 | # what causes it to hang. 306 | @pgtransaction.atomic(isolation_level="SERIALIZABLE", retry=3) 307 | def inner_update(trade, calls): 308 | calls.append(True) 309 | trade = Trade.objects.get(id=trade.id) 310 | trade.price = 2 311 | trade.save() 312 | time.sleep(1) 313 | 314 | barrier.wait() 315 | inner_update(trade, calls) 316 | 317 | barrier = threading.Barrier(2) 318 | trade = ddf.G(Trade, price=1) 319 | calls = [] 320 | t1 = threading.Thread(target=concurrent_update, args=[barrier, trade, calls]) 321 | t2 = threading.Thread(target=concurrent_update, args=[barrier, trade, calls]) 322 | 323 | t1.start() 324 | t2.start() 325 | 326 | t1.join() 327 | t2.join() 328 | 329 | # We should have at least had three attempts. It's highly unlikely we would have four, 330 | # but the possibility exists. 331 | assert 3 <= len(calls) <= 4 332 | 333 | 334 | @pytest.mark.django_db(transaction=True) 335 | def test_atomic_read_only(): 336 | """Test that a read only transaction cannot write.""" 337 | with atomic(read_mode=pgtransaction.READ_ONLY): 338 | trade = ddf.N(Trade) 339 | # Should not be able to write. 340 | with pytest.raises(InternalError): 341 | if trade is not None: # pragma: no branch - we always hit this branch 342 | trade.price = 2 343 | trade.save() 344 | 345 | 346 | @pytest.mark.django_db(transaction=True) 347 | def test_atomic_deferrable_validation(): 348 | """Test validation of deferrable mode.""" 349 | 350 | # Should raise error if not used with SERIALIZABLE and READ ONLY 351 | with pytest.raises(ValueError, match="DEFFERABLE transactions have no effect"): 352 | with atomic(deferrable=pgtransaction.DEFERRABLE): # type: ignore - also yields a type error. 353 | pass 354 | 355 | # Allowed with SERIALIZABLE and READ ONLY 356 | with atomic( 357 | isolation_level=pgtransaction.SERIALIZABLE, 358 | read_mode=pgtransaction.READ_ONLY, 359 | deferrable=pgtransaction.DEFERRABLE, 360 | ): 361 | pass 362 | 363 | 364 | @pytest.mark.django_db(transaction=True) 365 | def test_deferrable_read_only_behavior(): 366 | """Test behavior of deferrable read only transactions.""" 367 | import threading 368 | 369 | trade = ddf.G(Trade, company="Company 1") 370 | 371 | def modify_data() -> None: 372 | with atomic(): 373 | trade_obj = Trade.objects.get(id=trade.id) 374 | trade_obj.company = "Company 2" 375 | trade_obj.save() 376 | 377 | thread = threading.Thread(target=modify_data) 378 | 379 | with atomic( 380 | isolation_level=pgtransaction.SERIALIZABLE, 381 | read_mode=pgtransaction.READ_ONLY, 382 | deferrable=pgtransaction.DEFERRABLE, 383 | ): 384 | initial_read = Trade.objects.get(id=trade.id) 385 | assert initial_read.company == "Company 1" 386 | 387 | thread.start() 388 | thread.join() 389 | 390 | # Data should stay the same. 391 | final_read = Trade.objects.get(id=trade.id) 392 | assert final_read.company == "Company 1" 393 | -------------------------------------------------------------------------------- /pgtransaction/transaction.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from functools import cached_property, wraps 5 | from typing import Any, Callable, Final, Literal, TypeVar, overload 6 | 7 | import django 8 | from django.db import DEFAULT_DB_ALIAS, Error, transaction 9 | from django.db.utils import NotSupportedError 10 | 11 | from pgtransaction import config 12 | 13 | _C = TypeVar("_C", bound=Callable[..., Any]) 14 | 15 | READ_COMMITTED: Final = "READ COMMITTED" 16 | REPEATABLE_READ: Final = "REPEATABLE READ" 17 | SERIALIZABLE: Final = "SERIALIZABLE" 18 | READ_WRITE: Final = "READ WRITE" 19 | READ_ONLY: Final = "READ ONLY" 20 | DEFERRABLE: Final = "DEFERRABLE" 21 | NOT_DEFERRABLE: Final = "NOT DEFERRABLE" 22 | 23 | 24 | _LOGGER = logging.getLogger("pgtransaction") 25 | 26 | 27 | class Atomic(transaction.Atomic): 28 | def __init__( 29 | self, 30 | using: str | None, 31 | savepoint: bool, 32 | durable: bool, 33 | *, 34 | isolation_level: Literal["READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"] | None, 35 | retry: int | None, 36 | read_mode: Literal["READ WRITE", "READ ONLY"] | None, 37 | deferrable: Literal["DEFERRABLE", "NOT DEFERRABLE"] | None, 38 | ): 39 | if django.VERSION >= (3, 2): 40 | super().__init__(using, savepoint, durable) 41 | else: # pragma: no cover 42 | super().__init__(using, savepoint) 43 | 44 | self.isolation_level = isolation_level 45 | self.read_mode = read_mode 46 | self.deferrable = deferrable 47 | self._retry = retry 48 | self._used_as_context_manager = True 49 | 50 | if self.isolation_level or self.read_mode or self.deferrable: # pragma: no cover 51 | if self.connection.vendor != "postgresql": 52 | raise NotSupportedError( 53 | f"pgtransaction.atomic cannot be used with {self.connection.vendor}" 54 | ) 55 | 56 | if self.isolation_level and self.isolation_level.upper() not in ( 57 | READ_COMMITTED, 58 | REPEATABLE_READ, 59 | SERIALIZABLE, 60 | ): 61 | raise ValueError(f'Invalid isolation level "{self.isolation_level}"') 62 | 63 | if self.read_mode and self.read_mode.upper() not in ( 64 | READ_WRITE, 65 | READ_ONLY, 66 | ): 67 | raise ValueError(f'Invalid read mode "{self.read_mode}"') 68 | 69 | if self.deferrable and self.deferrable.upper() not in ( 70 | DEFERRABLE, 71 | NOT_DEFERRABLE, 72 | ): 73 | raise ValueError(f'Invalid deferrable mode "{self.deferrable}"') 74 | 75 | if self.deferrable == DEFERRABLE and not ( 76 | self.isolation_level == SERIALIZABLE and self.read_mode == READ_ONLY 77 | ): 78 | raise ValueError( 79 | "DEFFERABLE transactions have no effect unless " 80 | "SERIALIZABLE isolation level and " 81 | "READ ONLY mode are used." 82 | ) 83 | 84 | @cached_property 85 | def retry(self) -> int: 86 | """ 87 | Lazily load the configured retry value 88 | 89 | We do this so that atomic decorators can be instantiated without an 90 | implicit dependency on Django settings being configured. 91 | 92 | Note that this is not fully thread safe as the cached_property decorator 93 | can be redundantly called by multiple threads, but there should be no 94 | adverse effect in this case. 95 | """ 96 | return self._retry if self._retry is not None else config.retry() 97 | 98 | @property 99 | def connection(self) -> Any: 100 | # Don't set this property on the class, otherwise it won't be thread safe 101 | return transaction.get_connection(self.using) 102 | 103 | def __call__(self, func: _C) -> _C: 104 | self._used_as_context_manager = False 105 | 106 | @wraps(func) 107 | def inner(*args: Any, **kwds: Any) -> Any: 108 | num_retries = 0 109 | 110 | while True: # pragma: no branch 111 | try: 112 | with self._recreate_cm(): 113 | return func(*args, **kwds) 114 | except Error as error: 115 | if ( 116 | error.__cause__.__class__ not in config.retry_exceptions() 117 | or num_retries >= self.retry 118 | ): 119 | raise 120 | 121 | num_retries += 1 122 | 123 | return inner # type: ignore - we only care about accuracy for the outer method 124 | 125 | def execute_set_transaction_modes(self) -> None: 126 | with self.connection.cursor() as cursor: 127 | transaction_modes: list[str] = [] 128 | 129 | if self.isolation_level: 130 | transaction_modes.append(f"ISOLATION LEVEL {self.isolation_level.upper()}") 131 | 132 | # Only set non-default values. 133 | if self.read_mode: # pragma: no branch 134 | transaction_modes.append(self.read_mode.upper()) 135 | 136 | if self.deferrable: # pragma: no branch 137 | transaction_modes.append(self.deferrable.upper()) 138 | 139 | if transaction_modes: # pragma: no branch 140 | cursor.execute(f"SET TRANSACTION {' '.join(transaction_modes)}") 141 | 142 | def __enter__(self) -> None: 143 | in_nested_atomic_block = self.connection.in_atomic_block 144 | 145 | if in_nested_atomic_block and self.retry: 146 | raise RuntimeError("Retries are not permitted within a nested atomic transaction") 147 | 148 | if self.retry and self._used_as_context_manager: 149 | raise RuntimeError( 150 | "Cannot use pgtransaction.atomic as a context manager " 151 | "when retry is non-zero. Use as a decorator instead." 152 | ) 153 | 154 | # If we're already in a nested atomic block, try setting the transaction modes 155 | # before any check points are made when entering the atomic decorator. 156 | # This helps avoid errors and allow people to still nest transaction modes 157 | # when applicable 158 | if in_nested_atomic_block and (self.isolation_level or self.read_mode or self.deferrable): 159 | self.execute_set_transaction_modes() 160 | 161 | super().__enter__() 162 | 163 | # If we weren't in a nested atomic block, set the transaction modes for the first 164 | # time after the transaction has been started 165 | if not in_nested_atomic_block and ( 166 | self.isolation_level or self.read_mode or self.deferrable 167 | ): 168 | self.execute_set_transaction_modes() 169 | 170 | 171 | @overload 172 | def atomic(using: _C) -> _C: ... 173 | 174 | 175 | # Deferrable only has effect when used with SERIALIZABLE isolation level 176 | # and READ ONLY mode. 177 | @overload 178 | def atomic( 179 | using: str | None = None, 180 | savepoint: bool = True, 181 | durable: bool = False, 182 | *, 183 | isolation_level: Literal["SERIALIZABLE"] = ..., 184 | retry: int | None = None, 185 | read_mode: Literal["READ ONLY"] | None = None, 186 | deferrable: Literal["DEFERRABLE"] | None = None, 187 | ) -> Atomic: ... 188 | 189 | 190 | @overload 191 | def atomic( 192 | using: str | None = None, 193 | savepoint: bool = True, 194 | durable: bool = False, 195 | *, 196 | isolation_level: Literal["READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"] | None = None, 197 | retry: int | None = None, 198 | read_mode: Literal["READ WRITE", "READ ONLY"] | None = None, 199 | deferrable: Literal["DEFERRABLE", "NOT DEFERRABLE"] | None = None, 200 | ) -> Atomic: ... 201 | 202 | 203 | def atomic( 204 | using: str | None | _C = None, 205 | savepoint: bool = True, 206 | durable: bool = False, 207 | *, 208 | isolation_level: Literal["READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"] | None = None, 209 | retry: int | None = None, 210 | read_mode: Literal["READ WRITE", "READ ONLY"] | None = None, 211 | deferrable: Literal["DEFERRABLE", "NOT DEFERRABLE"] | None = None, 212 | ) -> Atomic | _C: 213 | """ 214 | Extends `django.db.transaction.atomic` with PostgreSQL functionality. 215 | 216 | Allows one to dynamically set transaction characteristics when opening a transaction, 217 | including isolation level, read mode, and deferrability. Also supports specifying 218 | a retry policy for when an operation in that transaction results in a Postgres 219 | locking exception. 220 | 221 | Args: 222 | using: The database to use. 223 | savepoint: If `True`, create a savepoint to roll back. 224 | durable: If `True`, raise a `RuntimeError` if nested within another atomic block. 225 | isolation_level: The isolation level we wish to be 226 | used for the duration of the transaction. If passed in 227 | as None, the current isolation level is used. Otherwise, 228 | we must choose from `pgtransaction.READ_COMMITTED`, 229 | `pgtransaction.REPEATABLE_READ` or `pgtransaction.SERIALIZABLE`. 230 | Note that the default isolation for a Django project is 231 | "READ COMMITTED". It is not permitted to pass this value 232 | as anything but None when using [pgtransaction.atomic][] 233 | is used as a nested atomic block - in that scenario, 234 | the isolation level is inherited from the parent transaction. 235 | retry: An integer specifying the number of attempts 236 | we want to retry the entire transaction upon encountering 237 | the settings-specified psycogp2 exceptions. If passed in as 238 | None, we default to using the settings-specified retry 239 | policy defined by `settings.PGTRANSACTION_RETRY_EXCEPTIONS` and 240 | `settings.PGTRANSACTION_RETRY`. Note that it is not possible 241 | to specify a non-zero value of retry when [pgtransaction.atomic][] 242 | is used in a nested atomic block or when used as a context manager. 243 | read_mode: The read mode for the transaction. Must be one of 244 | `pgtransaction.READ_WRITE` or `pgtransaction.READ_ONLY`. 245 | deferrable: Whether the transaction is deferrable. Must be one of 246 | `pgtransaction.DEFERRABLE` or `pgtransaction.NOT_DEFERRABLE`. 247 | DEFERRABLE only has effect when used with SERIALIZABLE isolation level 248 | and READ ONLY mode. In this case, it allows the transaction to be 249 | deferred until it can be executed without causing serialization 250 | anomalies. 251 | 252 | Example: 253 | Since [pgtransaction.atomic][] inherits from `django.db.transaction.atomic`, it 254 | can be used in exactly the same manner. Additionally, when used as a 255 | context manager or a decorator, one can use it to specify transaction 256 | characteristics. For example: 257 | 258 | import pgtransaction 259 | 260 | with pgtransaction.atomic(isolation_level=pgtransaction.REPEATABLE_READ): 261 | # Transaction is now REPEATABLE READ for the duration of the block 262 | ... 263 | 264 | # Use READ ONLY mode with SERIALIZABLE isolation 265 | with pgtransaction.atomic( 266 | isolation_level=pgtransaction.SERIALIZABLE, 267 | read_mode=pgtransaction.READ_ONLY 268 | ): 269 | # Transaction is now SERIALIZABLE and READ ONLY 270 | ... 271 | 272 | # Use DEFERRABLE with SERIALIZABLE and READ ONLY 273 | with pgtransaction.atomic( 274 | isolation_level=pgtransaction.SERIALIZABLE, 275 | read_mode=pgtransaction.READ_ONLY, 276 | deferrable=pgtransaction.DEFERRABLE 277 | ): 278 | # Transaction is now SERIALIZABLE, READ ONLY, and DEFERRABLE 279 | ... 280 | 281 | Note that setting transaction modes in a nested atomic block is permitted as long 282 | as no queries have been made. 283 | 284 | Example: 285 | When used as a decorator, one can also specify a `retry` argument. This 286 | defines the number of times the transaction will be retried upon encountering 287 | the exceptions referenced by `settings.PGTRANSACTION_RETRY_EXCEPTIONS`, 288 | which defaults to 289 | `(psycopg.errors.SerializationFailure, psycopg.errors.DeadlockDetected)`. 290 | For example: 291 | 292 | @pgtransaction.atomic(retry=3) 293 | def update(): 294 | # will retry update function up to 3 times 295 | # whenever any exception in settings.PGTRANSACTION_RETRY_EXCEPTIONS 296 | # is encountered. Each retry will open a new transaction (after 297 | # rollback the previous one). 298 | 299 | Attempting to set a non-zero value for `retry` when using [pgtransaction.atomic][] 300 | as a context manager will result in a `RuntimeError`. 301 | """ 302 | # Copies structure of django.db.transaction.atomic 303 | if callable(using): 304 | return Atomic( 305 | using=DEFAULT_DB_ALIAS, 306 | savepoint=savepoint, 307 | durable=durable, 308 | isolation_level=isolation_level, 309 | retry=retry, 310 | read_mode=read_mode, 311 | deferrable=deferrable, 312 | )(using) 313 | else: 314 | return Atomic( 315 | using=using, 316 | savepoint=savepoint, 317 | durable=durable, 318 | isolation_level=isolation_level, 319 | retry=retry, 320 | read_mode=read_mode, 321 | deferrable=deferrable, 322 | ) 323 | -------------------------------------------------------------------------------- /pgtransaction/version.py: -------------------------------------------------------------------------------- 1 | from importlib import metadata 2 | 3 | __version__ = metadata.version("django-pgtransaction") 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry_core>=1.9.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.coverage.run] 6 | branch = true 7 | source = ["pgtransaction"] 8 | 9 | [tool.coverage.report] 10 | exclude_lines = [ 11 | "pragma: no cover", 12 | "raise AssertionError", 13 | "raise NotImplementedError", 14 | "pass", 15 | "pytest.mark.skip", 16 | "@(typing\\.)?overload", 17 | "if TYPE_CHECKING:", 18 | ] 19 | show_missing = true 20 | fail_under = 100 21 | 22 | [tool.poetry] 23 | name = "django-pgtransaction" 24 | packages = [ 25 | { include = "pgtransaction" } 26 | ] 27 | exclude = [ 28 | "*/tests/" 29 | ] 30 | version = "2.0.0" 31 | description = "A context manager/decorator which extends Django's atomic function with the ability to set isolation level and retries for a given transaction." 32 | authors = ["Paul Gilmartin", "Wes Kendall"] 33 | classifiers = [ 34 | "Intended Audience :: Developers", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | "Programming Language :: Python :: 3.13", 42 | "Programming Language :: Python :: 3 :: Only", 43 | "Framework :: Django", 44 | "Framework :: Django :: 4.2", 45 | "Framework :: Django :: 5.0", 46 | "Framework :: Django :: 5.1", 47 | ] 48 | license = "BSD-3-Clause" 49 | readme = "README.md" 50 | homepage = "https://github.com/AmbitionEng/django-pgtransaction" 51 | repository = "https://github.com/AmbitionEng/django-pgtransaction" 52 | documentation = "https://django-pgtransaction.readthedocs.io" 53 | 54 | [tool.poetry.dependencies] 55 | python = ">=3.9.0,<4" 56 | django = ">=4" 57 | 58 | [tool.poetry.dev-dependencies] 59 | pytest = "8.3.3" 60 | pytest-cov = "5.0.0" 61 | pytest-dotenv = "0.5.2" 62 | tox = "4.23.2" 63 | ruff = "0.7.1" 64 | pyright = "1.1.386" 65 | mkdocs = "1.6.1" 66 | black = "24.10.0" 67 | mkdocs-material = "9.5.42" 68 | mkdocstrings-python = "1.12.2" 69 | footing = "*" 70 | setuptools = "*" 71 | poetry-core = "1.9.1" 72 | cleo = "2.1.0" 73 | poetry-plugin-export = "1.8.0" 74 | typing-extensions = "4.12.2" 75 | django-stubs = "5.1.1" 76 | dj-database-url = "2.3.0" 77 | psycopg2-binary = "2.9.10" 78 | pytest-django = "4.9.0" 79 | django-dynamic-fixture = "4.0.1" 80 | 81 | [tool.pytest.ini_options] 82 | xfail_strict = true 83 | testpaths = "pgtransaction/tests" 84 | norecursedirs = ".venv" 85 | addopts = "--reuse-db" 86 | DJANGO_SETTINGS_MODULE = "settings" 87 | 88 | [tool.ruff] 89 | lint.select = ["E", "F", "B", "I", "G", "C4"] 90 | line-length = 99 91 | target-version = "py39" 92 | 93 | [tool.pyright] 94 | exclude = [ 95 | "**/node_modules", 96 | "**/__pycache__", 97 | "src/experimental", 98 | "src/typestubs", 99 | "**/migrations/**", 100 | "**/tests/**", 101 | ] 102 | pythonVersion = "3.9" 103 | typeCheckingMode = "standard" 104 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import dj_database_url 4 | 5 | 6 | SECRET_KEY = "django-pgtransaction" 7 | # Install the tests as an app so that we can make test models 8 | INSTALLED_APPS = [ 9 | "django.contrib.auth", 10 | "django.contrib.contenttypes", 11 | "pgtransaction", 12 | "pgtransaction.tests", 13 | ] 14 | 15 | # Database url comes from the DATABASE_URL env var 16 | DATABASES = {"default": dj_database_url.config()} 17 | 18 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 19 | 20 | USE_TZ = False 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = 4 | py{39,310,311,312,313}-django42-psycopg2 5 | py313-django42-psycopg3 6 | py{310,311,312,313}-django50-psycopg2 7 | py313-django50-psycopg3 8 | py{310,311,312,313}-django51-psycopg2 9 | py313-django51-psycopg3 10 | report 11 | 12 | [testenv] 13 | allowlist_externals = 14 | poetry 15 | bash 16 | grep 17 | skip_install = true 18 | passenv = 19 | DATABASE_URL 20 | PYTHONDONTWRITEBYTECODE 21 | install_command = pip install {opts} --no-compile {packages} 22 | deps = 23 | django42: Django>=4.2,<4.3 24 | django50: Django>=5.0,<5.1 25 | django51: Django>=5.1,<5.2 26 | psycopg2: psycopg2-binary 27 | psycopg3: psycopg[binary] 28 | commands = 29 | bash -c 'poetry export --with dev --without-hashes -f requirements.txt | grep -v "^[dD]jango==" | grep -v "^psycopg2-binary==" | pip install --no-compile -q --no-deps -r /dev/stdin' 30 | pip install --no-compile -q --no-deps --no-build-isolation -e . 31 | pytest --create-db --cov --cov-fail-under=0 --cov-append --cov-config pyproject.toml {posargs} 32 | 33 | [testenv:report] 34 | allowlist_externals = 35 | coverage 36 | skip_install = true 37 | depends = py{39,310,311,312,313}-django42-psycopg2, py313-django42-psycopg3, py{310,311,312,313}-django50-psycopg2, py313-django50-psycopg3, py{310,311,312,313}-django51-psycopg2, py313-django51-psycopg3 38 | parallel_show_output = true 39 | commands = 40 | coverage report --fail-under 100 41 | coverage erase 42 | --------------------------------------------------------------------------------