Skip to content

Commit 4e101d1

Browse files
add replay middleware and testing with nox (#12)
* add replay middleware * add editorconfig * add nox * remove os matrix
1 parent 98bb0e7 commit 4e101d1

File tree

5 files changed

+174
-37
lines changed

5 files changed

+174
-37
lines changed

.editorconfig

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# http://editorconfig.org
2+
3+
root = true
4+
5+
[*]
6+
charset = utf-8
7+
end_of_line = lf
8+
insert_final_newline = true
9+
trim_trailing_whitespace = true
10+
11+
[{{Justfile,.justfile},*.{py,rst,ini,md}}]
12+
indent_size = 4
13+
indent_style = space
14+
15+
[*.py]
16+
line_length = 120
17+
multi_line_output = 3
18+
19+
[*.{css,html,js,json,sass,scss,yml,yaml}]
20+
indent_size = 2
21+
indent_style = space
22+
23+
[*.md]
24+
trim_trailing_whitespace = false
25+
26+
[{Makefile,*.bat}]
27+
indent_style = tab

.github/workflows/test.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ env:
1515

1616
jobs:
1717
test:
18-
name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }}
19-
runs-on: ${{ matrix.os }}
18+
name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}, psycopg ${{ matrix.psycopg-version }}
19+
runs-on: 'ubuntu-latest'
2020
strategy:
2121
fail-fast: false
2222
matrix:
23-
os: [ubuntu-latest, windows-latest, macos-latest]
2423
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12-dev']
24+
django-version: ['3.2', '4.0', '4.1', '4.2', 'main']
25+
psycopg-version: ['2', "3"]
2526
steps:
2627
- uses: actions/checkout@v3
2728

@@ -32,12 +33,11 @@ jobs:
3233

3334
- name: Install dependencies
3435
run: |
35-
python -m pip install --upgrade pip
36-
python -m pip install '.[test]'
36+
python -m pip install --upgrade pip nox
3737
3838
- name: Run tests
3939
run: |
40-
pytest
40+
nox --session "tests-${{ matrix.python-version }}(psycopg='${{ matrix.psycopg-version }}', django='${{ matrix.django-version }}')"
4141
4242
tests:
4343
runs-on: ubuntu-latest

noxfile.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
import nox
4+
5+
PY38 = "3.8"
6+
PY39 = "3.9"
7+
PY310 = "3.10"
8+
PY311 = "3.11"
9+
PY312 = "3.12"
10+
PY_VERSIONS = [PY38, PY39, PY310, PY311, PY312]
11+
PY_DEFAULT = PY38
12+
13+
DJ32 = "3.2"
14+
DJ40 = "4.0"
15+
DJ41 = "4.1"
16+
DJ42 = "4.2"
17+
DJMAIN = "main"
18+
DJMAIN_MIN_PY = PY310
19+
DJ_VERSIONS = [DJ32, DJ40, DJ41, DJ42, DJMAIN]
20+
DJ_DEFAULT = DJ32
21+
22+
PSYCOPG2 = "2"
23+
PSYCOPG3 = "3"
24+
PSYCOPG_VERSIONS = [PSYCOPG2, PSYCOPG3]
25+
PSYCOPG_DEFAULT = PSYCOPG3
26+
27+
28+
def version(ver: str) -> tuple[int, ...]:
29+
"""Convert a string version to a tuple of ints, e.g. "3.10" -> (3, 10)"""
30+
return tuple(map(int, ver.split(".")))
31+
32+
33+
def should_skip(python: str, django: str, psycopg: str) -> tuple(bool, str | None):
34+
"""Return True if the test should be skipped"""
35+
if django == DJMAIN and version(python) < version(DJMAIN_MIN_PY):
36+
return True, f"Django {DJMAIN} requires Python {DJMAIN_MIN_PY}+"
37+
38+
if django == DJ32 and version(python) >= version(PY312):
39+
return True, f"Django {DJ32} requires Python < {PY312}"
40+
41+
if psycopg == PSYCOPG3 and version(python) >= version(PY312):
42+
return True, f"psycopg3 requires Python < {PY312}"
43+
44+
return False, None
45+
46+
47+
@nox.session(python=PY_VERSIONS)
48+
@nox.parametrize("django", DJ_VERSIONS)
49+
@nox.parametrize("psycopg", PSYCOPG_VERSIONS)
50+
def tests(session, django, psycopg):
51+
skip = should_skip(session.python, django, psycopg)
52+
if skip[0]:
53+
session.skip(skip[1])
54+
55+
session.install(".[test]")
56+
57+
if django == DJMAIN:
58+
session.install("https://github.com/django/django/archive/refs/heads/main.zip")
59+
else:
60+
session.install(f"django=={django}")
61+
62+
if psycopg == PSYCOPG2:
63+
session.install("psycopg2-binary")
64+
elif psycopg == PSYCOPG3:
65+
session.install("psycopg[binary]")
66+
67+
session.run("pytest")

pyproject.toml

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
[build-system]
2-
requires = ["hatchling"]
32
build-backend = "hatchling.build"
3+
requires = ["hatchling"]
44

55
[project]
6-
name = "django-flyio"
7-
description = 'A set of simple utilities for Django apps running on Fly.io'
8-
readme = "README.md"
9-
requires-python = ">=3.8"
10-
license = "MIT"
11-
keywords = []
12-
authors = [{ name = "Josh", email = "[email protected]" }]
6+
authors = [{name = "Josh", email = "[email protected]"}]
137
classifiers = [
148
"Development Status :: 4 - Beta",
159
"License :: OSI Approved :: MIT License",
@@ -24,11 +18,19 @@ classifiers = [
2418
"Programming Language :: Python :: Implementation :: CPython",
2519
]
2620
dependencies = ["django>=3.2", "dj_database_url"]
21+
description = 'A set of simple utilities for Django apps running on Fly.io'
2722
dynamic = ["version"]
23+
keywords = []
24+
license = "MIT"
25+
name = "django-flyio"
26+
readme = "README.md"
27+
requires-python = ">=3.8"
2828

2929
[project.optional-dependencies]
3030
dev = ["black", "hatch", "ruff"]
31-
test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-django"]
31+
psycopg = ["psycopg[binary]"]
32+
psycopg2 = ["psycopg2-binary"]
33+
test = ["coverage[toml]", "nox", "pytest", "pytest-asyncio", "pytest-django"]
3234
types = ["django-stubs", "mypy"]
3335

3436
[project.urls]
@@ -40,32 +42,44 @@ Source = "https://github.com/joshuadavidthomas/django-flyio"
4042
path = "src/django_flyio/__init__.py"
4143

4244
[tool.black]
43-
target-version = ["py38"]
4445
line-length = 120
4546
skip-string-normalization = true
47+
target-version = ["py38"]
4648

4749
[tool.coverage.report]
4850
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
4951

5052
[tool.mypy]
53+
ignore_missing_imports = true
5154
mypy_path = "src/"
5255
namespace_packages = false
5356
show_error_codes = true
5457
strict = true
5558
warn_unreachable = true
56-
ignore_missing_imports = true
5759

5860
[[tool.mypy.overrides]]
59-
module = "tests.*"
6061
allow_untyped_defs = true
62+
module = "tests.*"
6163

6264
[tool.pytest.ini_options]
63-
django_find_project = false
6465
DJANGO_SETTINGS_MODULE = "tests.settings"
66+
django_find_project = false
6567
pythonpath = ". src"
6668

6769
[tool.ruff]
68-
target-version = "py38"
70+
ignore = [
71+
# Allow non-abstract empty methods in abstract base classes
72+
"B027", # Allow boolean positional values in function calls, like `dict.get(... True)`
73+
"FBT003", # Ignore checks for possible passwords
74+
"S105",
75+
"S106",
76+
"S107", # Ignore complexity
77+
"C901",
78+
"PLR0911",
79+
"PLR0912",
80+
"PLR0913",
81+
"PLR0915",
82+
]
6983
line-length = 120
7084
select = [
7185
"A",
@@ -94,30 +108,15 @@ select = [
94108
"W",
95109
"YTT",
96110
]
97-
ignore = [
98-
# Allow non-abstract empty methods in abstract base classes
99-
"B027",
100-
# Allow boolean positional values in function calls, like `dict.get(... True)`
101-
"FBT003",
102-
# Ignore checks for possible passwords
103-
"S105",
104-
"S106",
105-
"S107",
106-
# Ignore complexity
107-
"C901",
108-
"PLR0911",
109-
"PLR0912",
110-
"PLR0913",
111-
"PLR0915",
112-
]
111+
target-version = "py38"
113112
unfixable = [
114113
# Don't touch unused imports
115114
"F401",
116115
]
117116

118117
[tool.ruff.isort]
119-
known-first-party = ["django_flyio"]
120118
force-single-line = true
119+
known-first-party = ["django_flyio"]
121120
required-imports = ["from __future__ import annotations"]
122121

123122
[tool.ruff.flake8-tidy-imports]

src/django_flyio/middleware.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@
88

99
import asyncio
1010
import os
11+
from http import HTTPStatus
1112
from typing import Awaitable
1213
from typing import Callable
1314

1415
from django.http import HttpRequest
16+
from django.http.response import HttpResponse
1517
from django.http.response import HttpResponseBase
1618

19+
try:
20+
from psycopg.errors import ReadOnlySqlTransaction
21+
except ImportError:
22+
from psycopg2.errors import ReadOnlySqlTransaction
23+
1724
FLY_SERVER = "fly-server"
25+
FLY_REPLAY = "fly-replay"
1826

1927

2028
class FlyResponseMiddleware:
@@ -58,3 +66,39 @@ def add_response_headers(self, response: HttpResponseBase) -> None:
5866
response[FLY_SERVER] = f"{machine_id}-{region}"
5967
else:
6068
response[FLY_SERVER] = "unknown"
69+
70+
71+
class FlyReplayMiddleware:
72+
sync_capable = True
73+
async_capable = True
74+
75+
def __init__(
76+
self,
77+
get_response: (
78+
Callable[[HttpRequest], HttpResponseBase] | Callable[[HttpRequest], Awaitable[HttpResponseBase]]
79+
),
80+
) -> None:
81+
self.get_response = get_response
82+
if asyncio.iscoroutinefunction(self.get_response):
83+
# Mark the class as async-capable, but do the actual switch
84+
# inside __call__ to avoid swapping out dunder methods
85+
self._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore [attr-defined]
86+
else:
87+
self._is_coroutine = None
88+
89+
def __call__(self, request: HttpRequest) -> HttpResponseBase | Awaitable[HttpResponseBase]:
90+
return self.__acall__(request) if self._is_coroutine else self.get_response(request)
91+
92+
async def __acall__(self, request: HttpRequest) -> HttpResponseBase:
93+
aresponse = self.get_response(request)
94+
assert not isinstance(aresponse, HttpResponseBase) # noqa: S101
95+
return await aresponse
96+
97+
def process_exception(self, request: HttpRequest, exception: Exception) -> HttpResponseBase: # noqa: ARG002
98+
if isinstance(exception, ReadOnlySqlTransaction):
99+
primary_region = os.getenv("PRIMARY_REGION", None)
100+
response = HttpResponse()
101+
response.content = response.make_bytes(f"retry in region {primary_region}")
102+
response.status_code = HTTPStatus.CONFLICT
103+
response[FLY_REPLAY] = f"region={primary_region}"
104+
return response

0 commit comments

Comments
 (0)