Skip to content

Commit fcae678

Browse files
committed
Add decorators and tests
1 parent 5386849 commit fcae678

File tree

7 files changed

+180
-10
lines changed

7 files changed

+180
-10
lines changed

.travis.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
language: python
2+
python:
3+
- 3.8
4+
- 3.6
5+
- 2.7
6+
install: pip install tox-travis
7+
script: tox

readonly/cursor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ def _last_executed(self):
105105

106106

107107
class PatchedCursorWrapper(utils.CursorWrapper):
108-
def __init__(self, cursor, db):
109-
self.cursor = ReadOnlyCursorWrapper(cursor, db)
108+
def __init__(self, cursor, db,read_only=None):
109+
self.cursor = ReadOnlyCursorWrapper(cursor, db, read_only=read_only)
110110
self.db = db
111111

112112

readonly/decorators.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from contextlib import contextmanager
2+
3+
from django.db.backends import utils
4+
5+
from readonly.cursor import (
6+
PatchedCursorWrapper,
7+
PatchedCursorDebugWrapper,
8+
)
9+
10+
_orig_CursorWrapper = utils.CursorWrapper
11+
_orig_CursorDebugWrapper = utils.CursorDebugWrapper
12+
13+
14+
class ForcedPatchedCursorWrapper(PatchedCursorWrapper):
15+
def __init__(self, cursor, db):
16+
super(ForcedPatchedCursorWrapper, self).__init__(cursor, db, read_only=True)
17+
18+
19+
class ForcedPatchedCursorDebugWrapper(PatchedCursorDebugWrapper):
20+
def __init__(self, cursor, db):
21+
super(ForcedPatchedCursorDebugWrapper, self).__init__(
22+
cursor, db, read_only=True
23+
)
24+
25+
26+
@contextmanager
27+
def readonly():
28+
old_CursorWrapper = utils.CursorWrapper
29+
old_CursorDebugWrapper = utils.CursorDebugWrapper
30+
utils.CursorWrapper = ForcedPatchedCursorWrapper
31+
utils.CursorDebugWrapper = ForcedPatchedCursorDebugWrapper
32+
try:
33+
yield
34+
finally:
35+
utils.CursorWrapper = old_CursorWrapper
36+
utils.CursorDebugWrapper = old_CursorDebugWrapper
37+
38+
39+
@contextmanager
40+
def dangerously_enabled():
41+
old_CursorWrapper = utils.CursorWrapper
42+
old_CursorDebugWrapper = utils.CursorDebugWrapper
43+
utils.CursorWrapper = _orig_CursorWrapper
44+
utils.CursorDebugWrapper = _orig_CursorDebugWrapper
45+
try:
46+
yield
47+
finally:
48+
utils.CursorWrapper = old_CursorWrapper
49+
utils.CursorDebugWrapper = old_CursorDebugWrapper

tests/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.db import models
2+
3+
4+
class Widget(models.Model):
5+
name = models.CharField(max_length=100)

tests/settings.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22

33
DB_READ_ONLY_MIDDLEWARE_MESSAGE = False
44
SITE_READ_ONLY = False
5-
DB_READ_ONLY_DATABASES = False
5+
DB_READ_ONLY_DATABASES = []
66

7-
DATABASE_ENGINE = "sqlite3"
8-
9-
# Uncomment below to run tests with mysql
10-
# DATABASE_ENGINE = "django.db.backends.mysql"
11-
# DATABASE_NAME = "readonly_test"
12-
# DATABASE_USER = "readonly_test"
13-
# DATABASE_HOST = "/var/mysql/mysql.sock"
7+
DATABASES = {
8+
"default": {
9+
"ENGINE": "django.db.backends.sqlite3",
10+
"NAME": ":memory:",
11+
},
12+
}
1413

1514
INSTALLED_APPS = [
1615
"readonly",
16+
"tests",
1717
]
1818

1919
MIDDLEWARE = [

tests/test_context_manager.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from django.db import transaction
2+
from django.db.transaction import TransactionManagementError
3+
4+
from django.test import TestCase
5+
6+
from readonly.decorators import (
7+
readonly,
8+
dangerously_enabled,
9+
)
10+
from readonly.exceptions import DatabaseWriteDenied
11+
12+
from tests.models import Widget
13+
14+
15+
class ContextManagerTestCase(TestCase):
16+
def _create_obj(self):
17+
with transaction.atomic():
18+
obj = Widget.objects.create()
19+
obj.save()
20+
21+
def test_normal(self):
22+
Widget.objects.count()
23+
obj = Widget.objects.create()
24+
obj.save()
25+
26+
def test_readonly_transaction(self):
27+
before = Widget.objects.count()
28+
29+
with readonly():
30+
with self.assertRaises(DatabaseWriteDenied):
31+
with transaction.atomic():
32+
obj = Widget.objects.create()
33+
obj.save()
34+
35+
after = Widget.objects.count()
36+
assert after == before
37+
38+
obj = Widget.objects.create()
39+
obj.save()
40+
41+
after = Widget.objects.count()
42+
assert after == before + 1
43+
44+
def test_readonly(self):
45+
Widget.objects.count()
46+
47+
with readonly():
48+
with self.assertRaises(DatabaseWriteDenied):
49+
obj = Widget.objects.create()
50+
obj.save()
51+
52+
# TODO: Automatic cancellation of the transaction would simplify
53+
# developer use of readonly & DatabaseWriteDenied with foreign code
54+
with self.assertRaises(TransactionManagementError):
55+
Widget.objects.count()
56+
57+
def test_nested_readonly_disabled(self):
58+
with readonly():
59+
with self.assertRaises(DatabaseWriteDenied):
60+
self._create_obj()
61+
with readonly():
62+
with self.assertRaises(DatabaseWriteDenied):
63+
self._create_obj()
64+
with readonly():
65+
with self.assertRaises(DatabaseWriteDenied):
66+
self._create_obj()
67+
68+
Widget.objects.create()
69+
70+
def test_readonly_enabled(self):
71+
with readonly():
72+
with dangerously_enabled():
73+
self._create_obj()
74+
75+
def test_nested_readonly_enabled(self):
76+
with readonly():
77+
with readonly():
78+
with dangerously_enabled():
79+
with readonly():
80+
with dangerously_enabled():
81+
with readonly():
82+
with self.assertRaises(DatabaseWriteDenied):
83+
self._create_obj()
84+
85+
with self.assertRaises(DatabaseWriteDenied):
86+
self._create_obj()
87+
88+
with self.assertRaises(DatabaseWriteDenied):
89+
self._create_obj()
90+
91+
self._create_obj()

tox.ini

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[tox]
2+
envlist = py27-django{18,19,110,111}, py{36,37,38}-django{111,20,21,22,30,31}
3+
skip_missing_interpreters = True
4+
5+
[testenv]
6+
commands = python -m django test {posargs}
7+
setenv =
8+
DJANGO_SETTINGS_MODULE = tests.settings
9+
deps =
10+
django18: Django>=1.8,<1.9
11+
django19: Django>=1.9,<1.10
12+
django110: Django>=1.10,<1.11
13+
django111: Django>=1.11,<1.12
14+
django20: Django>=2.0,<2.1
15+
django21: Django>=2.1,<2.2
16+
django22: Django>=2.2,<2.3
17+
django30: Django>=3.0,<3.1
18+
django31: Django>=3.1,<3.2

0 commit comments

Comments
 (0)