django-sqlite-ops 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_sqlite_ops/__init__.py +1 -0
- django_sqlite_ops/apps.py +19 -0
- django_sqlite_ops/boot/__init__.py +4 -0
- django_sqlite_ops/boot/__main__.py +6 -0
- django_sqlite_ops/boot/cli.py +1066 -0
- django_sqlite_ops/boot/decide.py +350 -0
- django_sqlite_ops/boot/litestream.py +542 -0
- django_sqlite_ops/checks.py +400 -0
- django_sqlite_ops/compat/__init__.py +1 -0
- django_sqlite_ops/compat/_channels_lite_aio.py +49 -0
- django_sqlite_ops/compat/channels_lite.py +132 -0
- django_sqlite_ops/database.py +118 -0
- django_sqlite_ops/doctor.py +1051 -0
- django_sqlite_ops/health.py +1129 -0
- django_sqlite_ops/management/__init__.py +0 -0
- django_sqlite_ops/management/commands/__init__.py +0 -0
- django_sqlite_ops/management/commands/sqlite_doctor.py +161 -0
- django_sqlite_ops/restore_check.py +709 -0
- django_sqlite_ops/wal.py +287 -0
- django_sqlite_ops-0.1.0a1.dist-info/METADATA +987 -0
- django_sqlite_ops-0.1.0a1.dist-info/RECORD +23 -0
- django_sqlite_ops-0.1.0a1.dist-info/WHEEL +4 -0
- django_sqlite_ops-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""정적 시스템 체크 (DESIGN §6-1). 설정만 읽고 DB 를 열지 않는다.
|
|
2
|
+
|
|
3
|
+
기준값은 ``database.recommended()`` 에서 읽는다. ``sqlite_database()`` 를 쓰지 않은
|
|
4
|
+
설정(직접 쓴 dict, dj-lite 결과)도 같은 기준으로 검사한다.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import unquote
|
|
12
|
+
|
|
13
|
+
from django.conf import settings
|
|
14
|
+
from django.core import checks
|
|
15
|
+
|
|
16
|
+
from .database import DEFAULT_PROFILE, ENGINE, PROFILES, recommended
|
|
17
|
+
|
|
18
|
+
__all__ = ["TAG", "check_deploy_settings", "check_settings", "register"]
|
|
19
|
+
|
|
20
|
+
# Tags.database 를 쓰지 않는다. Django 6.1 은 --database 없이 돌면 database 태그 체크를 빼고
|
|
21
|
+
# (6.1.2 django/core/checks/registry.py:89-93), 5.2 는 빼지 않는다(5.2.18 같은 파일의
|
|
22
|
+
# run_checks :72-96 에 그 분기가 없다). 두 지원 버전에서 기본으로 돌도록 자체 태그를 쓴다.
|
|
23
|
+
TAG = "sqlite_ops"
|
|
24
|
+
|
|
25
|
+
# 식별자: "..." · `...` · [...] 인용, 또는 맨 이름. 값: 인용 문자열, 이름, 정수.
|
|
26
|
+
_IDENT = r"""(?:"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]|[A-Za-z_][A-Za-z0-9_$]*)"""
|
|
27
|
+
_VALUE = r"""(?:'(?:[^']|'')*'|"(?:[^"]|"")*"|[A-Za-z_][A-Za-z0-9_$]*|[-+]?\d+)"""
|
|
28
|
+
_PRAGMA = re.compile(
|
|
29
|
+
rf"""\s*PRAGMA\s+(?:(?P<schema>{_IDENT})\s*\.\s*)?(?P<name>{_IDENT})\s*
|
|
30
|
+
(?:=\s*(?P<eq>{_VALUE})|\(\s*(?P<call>{_VALUE})\s*\))?\s*""",
|
|
31
|
+
re.IGNORECASE | re.VERBOSE,
|
|
32
|
+
)
|
|
33
|
+
# SQLite 가 받는 journal_mode 값. 그 밖의 값은 SQLite 가 무시하므로 결과를 단정할 수 없다.
|
|
34
|
+
_JOURNAL_MODES = frozenset({"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"})
|
|
35
|
+
_QUOTES = {"'": "'", '"': '"', "`": "`", "[": "]"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register() -> None:
|
|
39
|
+
checks.register(check_settings, TAG)
|
|
40
|
+
checks.register(check_deploy_settings, TAG, deploy=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _profile() -> tuple[str | None, checks.Error | None]:
|
|
44
|
+
config = getattr(settings, "SQLITE_OPS", {})
|
|
45
|
+
if not isinstance(config, Mapping):
|
|
46
|
+
return None, checks.Error(
|
|
47
|
+
f"SQLITE_OPS must be a dict, got {type(config).__name__}.",
|
|
48
|
+
hint=f'Use SQLITE_OPS = {{"PROFILE": "{DEFAULT_PROFILE}"}} (DESIGN §6-0).',
|
|
49
|
+
id="sqlite_ops.E001",
|
|
50
|
+
)
|
|
51
|
+
profile = config.get("PROFILE", DEFAULT_PROFILE)
|
|
52
|
+
if not isinstance(profile, str) or profile not in PROFILES:
|
|
53
|
+
return None, checks.Error(
|
|
54
|
+
f"Unknown SQLITE_OPS['PROFILE'] {profile!r}.",
|
|
55
|
+
hint=f"Use one of: {', '.join(PROFILES)} (DESIGN §6-0).",
|
|
56
|
+
id="sqlite_ops.E001",
|
|
57
|
+
)
|
|
58
|
+
return profile, None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sqlite_aliases():
|
|
62
|
+
for alias, config in settings.DATABASES.items():
|
|
63
|
+
if isinstance(config, Mapping) and config.get("ENGINE") == ENGINE:
|
|
64
|
+
yield alias, config
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _options(config: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
68
|
+
options = config.get("OPTIONS") or {}
|
|
69
|
+
return options if isinstance(options, Mapping) else {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _uri(name: Any) -> tuple[str, dict[str, list[str]]] | None:
|
|
73
|
+
"""``file:`` URI 면 (파일명, 쿼리)를, 아니면 ``None`` 을 돌려준다.
|
|
74
|
+
|
|
75
|
+
SQLite 처럼 파일명은 첫 ``?``·``#`` 앞, 쿼리는 ``?`` 뒤 ``#`` 앞이다. 구분자로 먼저 나눈 뒤
|
|
76
|
+
파일명과 쿼리 키·값을 퍼센트 디코딩한다(인코딩된 ``%3F`` 를 구분자로 보지 않는다).
|
|
77
|
+
"""
|
|
78
|
+
if isinstance(name, os.PathLike):
|
|
79
|
+
name = os.fspath(name)
|
|
80
|
+
if not isinstance(name, str) or not name.startswith("file:"):
|
|
81
|
+
return None
|
|
82
|
+
rest = name[len("file:") :]
|
|
83
|
+
if rest.startswith("//"):
|
|
84
|
+
# authority 는 "//" 뒤 다음 "/" 까지다(쿼리·프래그먼트 구분자보다 먼저 본다). 로컬
|
|
85
|
+
# authority 는 떼어 낸다. 그 밖의 authority 는 _uri_authority() 가 판정한다.
|
|
86
|
+
end = rest.find("/", 2)
|
|
87
|
+
rest = rest[end:] if end >= 0 else ""
|
|
88
|
+
rest = rest.split("#", 1)[0]
|
|
89
|
+
path, _, raw_query = rest.partition("?")
|
|
90
|
+
query: dict[str, list[str]] = {}
|
|
91
|
+
for part in raw_query.split("&"):
|
|
92
|
+
if part:
|
|
93
|
+
key, _, value = part.partition("=")
|
|
94
|
+
query.setdefault(unquote(key), []).append(unquote(value))
|
|
95
|
+
return unquote(path), query
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _uri_authority(name: Any) -> str | None:
|
|
99
|
+
"""``file://`` URI 의 authority 가 SQLite 가 받지 않는 값이면 그 값을, 아니면 ``None``.
|
|
100
|
+
|
|
101
|
+
SQLite 는 authority 로 빈 문자열과 ``localhost`` 만 받는다(https://www.sqlite.org/uri.html).
|
|
102
|
+
authority 는 ``//`` 뒤 다음 ``/`` 까지이고, 대소문자를 가리며 퍼센트 디코딩하지 않는다
|
|
103
|
+
(``LOCALHOST``·``%6cocalhost``·``localhost?mode=ro`` 는 연결 오류, 재현함). Windows 의
|
|
104
|
+
``SQLITE_ALLOW_URI_AUTHORITY`` UNC 경로는 흉내 내지 않는다.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(name, os.PathLike):
|
|
107
|
+
name = os.fspath(name)
|
|
108
|
+
if not isinstance(name, str) or not name.startswith("file://"):
|
|
109
|
+
return None
|
|
110
|
+
rest = name[len("file://") :]
|
|
111
|
+
end = rest.find("/")
|
|
112
|
+
authority = rest[:end] if end >= 0 else rest
|
|
113
|
+
return None if authority in ("", "localhost") else authority
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# SQLite 가 받는 값 (https://www.sqlite.org/uri.html, sqlite3_uri_boolean). 그 밖은 판정하지 않는다.
|
|
117
|
+
_MODES = frozenset({"ro", "rw", "rwc", "memory"})
|
|
118
|
+
_TRUE = frozenset({"1", "yes", "true", "on"})
|
|
119
|
+
_FALSE = frozenset({"0", "no", "false", "off"})
|
|
120
|
+
_ROLE_KEYS = ("mode", "immutable", "vfs")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _role(name: Any) -> tuple[str, str | None]:
|
|
124
|
+
"""별칭의 역할과, 판정할 수 없을 때 그 이유를 돌려준다.
|
|
125
|
+
|
|
126
|
+
역할: ``"write"`` · ``"memory"`` · ``"read-only"`` · ``"vfs"`` · ``"unknown"``.
|
|
127
|
+
역할을 정하는 쿼리 키(``mode``·``immutable``·``vfs``)가 겹치거나 SQLite 표준 표기가 아니면,
|
|
128
|
+
또는 URI authority 가 빈 값·``localhost`` 가 아니면 ``"unknown"`` 이다. SQLite 의
|
|
129
|
+
중복·비표준 해석을 흉내 내지 않는다.
|
|
130
|
+
"""
|
|
131
|
+
if isinstance(name, os.PathLike):
|
|
132
|
+
name = os.fspath(name)
|
|
133
|
+
if name == ":memory:":
|
|
134
|
+
return "memory", None
|
|
135
|
+
uri = _uri(name)
|
|
136
|
+
if uri is None:
|
|
137
|
+
return "write", None
|
|
138
|
+
authority = _uri_authority(name)
|
|
139
|
+
if authority is not None:
|
|
140
|
+
return "unknown", f"URI authority {authority!r} is not empty or 'localhost'"
|
|
141
|
+
path, query = uri
|
|
142
|
+
# SQLite 는 디코딩된 NUL 앞까지만 읽는다. 역할 키가 숨을 수 있으니 어느 키든 판정하지 않는다
|
|
143
|
+
if "\0" in path:
|
|
144
|
+
return "unknown", "the decoded file name contains NUL (%00)"
|
|
145
|
+
for key, values in query.items():
|
|
146
|
+
if "\0" in key or any("\0" in value for value in values):
|
|
147
|
+
return "unknown", f"query key {key!r} or its value contains NUL (%00)"
|
|
148
|
+
for key in _ROLE_KEYS:
|
|
149
|
+
if len(query.get(key, [])) > 1:
|
|
150
|
+
return "unknown", f"query key {key!r} is given more than once"
|
|
151
|
+
mode = query.get("mode", [None])[0]
|
|
152
|
+
if mode is not None and mode not in _MODES:
|
|
153
|
+
return "unknown", f"mode={mode!r} is not one of {', '.join(sorted(_MODES))}"
|
|
154
|
+
immutable = query.get("immutable", [None])[0]
|
|
155
|
+
if immutable is not None and immutable.lower() not in _TRUE | _FALSE:
|
|
156
|
+
return (
|
|
157
|
+
"unknown",
|
|
158
|
+
f"immutable={immutable!r} is not a boolean (1/0, yes/no, true/false, on/off)",
|
|
159
|
+
)
|
|
160
|
+
if path == ":memory:" or mode == "memory":
|
|
161
|
+
return "memory", None
|
|
162
|
+
if mode == "ro" or (immutable is not None and immutable.lower() in _TRUE):
|
|
163
|
+
return "read-only", None
|
|
164
|
+
if query.get("vfs") == ["litestream"]:
|
|
165
|
+
return "vfs", None
|
|
166
|
+
return "write", None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_litestream_vfs(name: Any) -> bool:
|
|
170
|
+
uri = _uri(name)
|
|
171
|
+
return uri is not None and uri[1].get("vfs") == ["litestream"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _strip_comments(statement: str) -> str:
|
|
175
|
+
"""SQL 주석(``--``, ``/* */``)을 공백으로 바꾼다. 인용 문자열·식별자 안은 그대로 둔다."""
|
|
176
|
+
out = []
|
|
177
|
+
i, n = 0, len(statement)
|
|
178
|
+
while i < n:
|
|
179
|
+
ch = statement[i]
|
|
180
|
+
if ch in _QUOTES:
|
|
181
|
+
close = _QUOTES[ch]
|
|
182
|
+
j = i + 1
|
|
183
|
+
while j < n:
|
|
184
|
+
if statement[j] == close:
|
|
185
|
+
if close != "]" and statement[j + 1 : j + 2] == close:
|
|
186
|
+
j += 2 # 겹친 인용 부호
|
|
187
|
+
continue
|
|
188
|
+
break
|
|
189
|
+
j += 1
|
|
190
|
+
out.append(statement[i : j + 1])
|
|
191
|
+
i = j + 1
|
|
192
|
+
elif statement.startswith("--", i):
|
|
193
|
+
j = statement.find("\n", i)
|
|
194
|
+
i = n if j < 0 else j
|
|
195
|
+
out.append(" ")
|
|
196
|
+
elif statement.startswith("/*", i):
|
|
197
|
+
j = statement.find("*/", i + 2)
|
|
198
|
+
i = n if j < 0 else j + 2
|
|
199
|
+
out.append(" ")
|
|
200
|
+
else:
|
|
201
|
+
out.append(ch)
|
|
202
|
+
i += 1
|
|
203
|
+
return "".join(out)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _unquote(token: str) -> str:
|
|
207
|
+
if token and token[0] in _QUOTES:
|
|
208
|
+
close = _QUOTES[token[0]]
|
|
209
|
+
inner = token[1:-1]
|
|
210
|
+
return inner if close == "]" else inner.replace(close * 2, close)
|
|
211
|
+
return token
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _journal_mode(init_command: Any) -> tuple[str | None, str | None]:
|
|
215
|
+
"""``init_command`` 에서 main 의 ``journal_mode`` 를 읽는다.
|
|
216
|
+
|
|
217
|
+
``(마지막으로 확정된 값(대문자) 또는 None, 형식을 확정할 수 없는 첫 문장 또는 None)``.
|
|
218
|
+
Django 처럼 ``;`` 로 나눈 문장마다 본다. SQL 파서가 아니므로 ``journal_mode`` 를 언급하는데
|
|
219
|
+
아는 형식이 아니면 판정할 수 없다고 본다.
|
|
220
|
+
"""
|
|
221
|
+
if not isinstance(init_command, str):
|
|
222
|
+
return None, None
|
|
223
|
+
mode = undetermined = None
|
|
224
|
+
for statement in init_command.split(";"):
|
|
225
|
+
cleaned = _strip_comments(statement)
|
|
226
|
+
if "journal_mode" not in cleaned.lower():
|
|
227
|
+
continue
|
|
228
|
+
match = _PRAGMA.fullmatch(cleaned)
|
|
229
|
+
if match is None:
|
|
230
|
+
undetermined = undetermined or statement.strip()
|
|
231
|
+
continue
|
|
232
|
+
if _unquote(match["name"]).lower() != "journal_mode":
|
|
233
|
+
continue
|
|
234
|
+
if match["schema"] and _unquote(match["schema"]).lower() != "main":
|
|
235
|
+
continue # 다른 스키마(temp 등)는 이 DB 의 모드를 바꾸지 않는다
|
|
236
|
+
value = match["eq"] or match["call"]
|
|
237
|
+
if value is None:
|
|
238
|
+
continue # 조회만 한다
|
|
239
|
+
value = _unquote(value).upper()
|
|
240
|
+
if value not in _JOURNAL_MODES:
|
|
241
|
+
undetermined = undetermined or statement.strip()
|
|
242
|
+
continue
|
|
243
|
+
mode = value
|
|
244
|
+
return mode, undetermined
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _health_errors() -> list[checks.Error]:
|
|
248
|
+
"""E002: ``SQLITE_OPS["HEALTH"]`` 가 잘못됨. ``HEALTH`` 가 없으면 검사하지 않는다(DESIGN §7)."""
|
|
249
|
+
from .health import load_config # health 는 doctor → checks 를 import 한다
|
|
250
|
+
|
|
251
|
+
_, errors = load_config()
|
|
252
|
+
return [
|
|
253
|
+
checks.Error(
|
|
254
|
+
message,
|
|
255
|
+
hint="See README '복제 헬스' and DESIGN §7 for the HEALTH settings.",
|
|
256
|
+
obj=alias,
|
|
257
|
+
id="sqlite_ops.E002",
|
|
258
|
+
)
|
|
259
|
+
for message, alias in errors
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _asgi_evidence() -> str | None:
|
|
264
|
+
"""ASGI 로 도는 근거를 돌려준다. 없으면 ``None``(WSGI 로 본다). W003·W005 가 함께 쓴다.
|
|
265
|
+
|
|
266
|
+
근거는 ``ASGI_APPLICATION`` 설정, 그리고 명시한 ``SQLITE_OPS["PROFILE"]`` 이다(두 배포
|
|
267
|
+
프로필은 ASGI 서버로 띄운다, DESIGN §8). 기본값으로 정해진 프로필은 근거가 아니다.
|
|
268
|
+
``WSGI_APPLICATION`` 은 ``startproject`` 가 늘 넣으므로 WSGI 근거로 쓰지 않는다. 판단 표와
|
|
269
|
+
미탐은 DESIGN §6-1.
|
|
270
|
+
"""
|
|
271
|
+
if getattr(settings, "ASGI_APPLICATION", None):
|
|
272
|
+
return "ASGI_APPLICATION is set"
|
|
273
|
+
config = getattr(settings, "SQLITE_OPS", {})
|
|
274
|
+
if isinstance(config, Mapping) and "PROFILE" in config:
|
|
275
|
+
return f"SQLITE_OPS['PROFILE'] = {config['PROFILE']!r} is an ASGI deployment profile"
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_settings(app_configs=None, **kwargs):
|
|
280
|
+
"""항상 도는 체크: E001(프로필), E002(헬스 설정), W003·W005(별칭의 ``CONN_MAX_AGE``)."""
|
|
281
|
+
_, error = _profile()
|
|
282
|
+
if error:
|
|
283
|
+
return [error]
|
|
284
|
+
messages: list = _health_errors()
|
|
285
|
+
asgi = _asgi_evidence()
|
|
286
|
+
for alias, config in _sqlite_aliases():
|
|
287
|
+
vfs = _is_litestream_vfs(config.get("NAME"))
|
|
288
|
+
conn_max_age = config.get("CONN_MAX_AGE", 0)
|
|
289
|
+
if asgi is None and vfs and conn_max_age is not None:
|
|
290
|
+
# ASGI 의 VFS 별칭은 미검증이다. WSGI 로 판단될 때만 낸다 (DESIGN §6-1)
|
|
291
|
+
messages.append(
|
|
292
|
+
checks.Warning(
|
|
293
|
+
f"Litestream VFS database {alias!r} reconnects per request "
|
|
294
|
+
"(CONN_MAX_AGE is not None).",
|
|
295
|
+
hint=(
|
|
296
|
+
f'Set DATABASES["{alias}"]["CONN_MAX_AGE"] = None; measured under WSGI: '
|
|
297
|
+
"1,008ms per request with 0, 1.7ms with None (DESIGN §6-0)."
|
|
298
|
+
),
|
|
299
|
+
obj=alias,
|
|
300
|
+
id="sqlite_ops.W003",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
elif asgi is not None and not vfs and conn_max_age is None:
|
|
304
|
+
messages.append(
|
|
305
|
+
checks.Warning(
|
|
306
|
+
f"Database {alias!r} has CONN_MAX_AGE = None under ASGI ({asgi}).",
|
|
307
|
+
hint=(
|
|
308
|
+
"Under ASGI, sync views run in a new thread per request, so the "
|
|
309
|
+
"connection is not reused and is not closed at request end; a benchmark "
|
|
310
|
+
"saw 810-982 open database file descriptors (limit 1024) and 'unable to "
|
|
311
|
+
"open database file' errors (fd exhaustion is the suspected cause). "
|
|
312
|
+
f'Set DATABASES["{alias}"]["CONN_MAX_AGE"] = 0 or remove the key. '
|
|
313
|
+
"See docs/research/bench-2026-10-08.md and DESIGN §6-1; if this "
|
|
314
|
+
"project runs under WSGI, silence sqlite_ops.W005."
|
|
315
|
+
),
|
|
316
|
+
obj=alias,
|
|
317
|
+
id="sqlite_ops.W005",
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
return messages
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def check_deploy_settings(app_configs=None, **kwargs):
|
|
324
|
+
"""``check --deploy`` 에서만 도는 체크: W001(transaction_mode), W002(journal_mode),
|
|
325
|
+
W004(별칭 역할을 판정할 수 없음)."""
|
|
326
|
+
profile, error = _profile()
|
|
327
|
+
if error:
|
|
328
|
+
return [] # E001 은 check_settings 가 낸다
|
|
329
|
+
rec = recommended(profile)
|
|
330
|
+
want_mode = rec["transaction_mode"].upper()
|
|
331
|
+
want_journal = str(rec["pragmas"]["journal_mode"]).upper()
|
|
332
|
+
messages = []
|
|
333
|
+
for alias, config in _sqlite_aliases():
|
|
334
|
+
role, reason = _role(config.get("NAME"))
|
|
335
|
+
if role == "unknown":
|
|
336
|
+
# 역할을 모르면 쓰기 권고를 내지 않는다. 따르면 연결이 깨질 수 있다(mode=ro 에 WAL)
|
|
337
|
+
messages.append(
|
|
338
|
+
checks.Warning(
|
|
339
|
+
f"Cannot determine the role of database {alias!r} (write, read-only, "
|
|
340
|
+
f"memory or Litestream VFS) from its NAME: {reason}.",
|
|
341
|
+
hint=(
|
|
342
|
+
"Rewrite NAME as a standard URI: no authority other than "
|
|
343
|
+
"'localhost' (file:///path or file:/path), no %00, each query key "
|
|
344
|
+
"(mode, immutable, vfs) once, mode=ro|rw|rwc|memory, immutable=1|0, "
|
|
345
|
+
"vfs=<name> (DESIGN §6-1)."
|
|
346
|
+
),
|
|
347
|
+
obj=alias,
|
|
348
|
+
id="sqlite_ops.W004",
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
continue
|
|
352
|
+
if role in ("read-only", "vfs"):
|
|
353
|
+
# 쓰기 권고가 의미 없거나, 따르면 연결이 깨진다(mode=ro 에 journal_mode=WAL)
|
|
354
|
+
continue
|
|
355
|
+
options = _options(config)
|
|
356
|
+
mode = options.get("transaction_mode")
|
|
357
|
+
if not isinstance(mode, str) or mode.upper() != want_mode:
|
|
358
|
+
messages.append(
|
|
359
|
+
checks.Warning(
|
|
360
|
+
f"Database {alias!r} has OPTIONS['transaction_mode'] = {mode!r}, "
|
|
361
|
+
f"not {want_mode!r}.",
|
|
362
|
+
hint=(
|
|
363
|
+
"Use sqlite_database() or set "
|
|
364
|
+
f'OPTIONS["transaction_mode"] = "{want_mode}" (DESIGN §6-0).'
|
|
365
|
+
),
|
|
366
|
+
obj=alias,
|
|
367
|
+
id="sqlite_ops.W001",
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
if role == "memory":
|
|
371
|
+
continue
|
|
372
|
+
journal, undetermined = _journal_mode(options.get("init_command"))
|
|
373
|
+
if undetermined is not None:
|
|
374
|
+
messages.append(
|
|
375
|
+
checks.Warning(
|
|
376
|
+
f"Database {alias!r}: cannot determine journal_mode from "
|
|
377
|
+
f"OPTIONS['init_command'] statement {undetermined!r}.",
|
|
378
|
+
hint=(
|
|
379
|
+
"Use sqlite_database() or write it as "
|
|
380
|
+
f'"PRAGMA journal_mode={want_journal}" (DESIGN §6-1).'
|
|
381
|
+
),
|
|
382
|
+
obj=alias,
|
|
383
|
+
id="sqlite_ops.W002",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
elif journal != want_journal:
|
|
387
|
+
messages.append(
|
|
388
|
+
checks.Warning(
|
|
389
|
+
f"Database {alias!r} does not set journal_mode={want_journal} in "
|
|
390
|
+
"OPTIONS['init_command'].",
|
|
391
|
+
hint=(
|
|
392
|
+
"Use sqlite_database() or add "
|
|
393
|
+
f'"PRAGMA journal_mode={want_journal}" to OPTIONS["init_command"] '
|
|
394
|
+
"(DESIGN §6-0)."
|
|
395
|
+
),
|
|
396
|
+
obj=alias,
|
|
397
|
+
id="sqlite_ops.W002",
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
return messages
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""외부 패키지 결함에 대한 우리 쪽 호환 패치. 모두 버전 게이트 뒤에 있고 자동 적용하지 않는다."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``AIOSQLiteChannelLayer._receive_single_from_db`` 의 교체본 (DESIGN §9).
|
|
2
|
+
|
|
3
|
+
channels-lite 0.4.0 원본(``channels_lite/layers/aio.py`` 143–177)을 그대로 옮기고 선점 판정만
|
|
4
|
+
바꿨다. 원본은 ``conn.total_changes > 0`` 으로 판정하는데, 이 값은 연결이 열린 뒤 누적된 변경 수라서
|
|
5
|
+
풀에서 재사용된 연결이면 UPDATE 가 0행이어도 참이 된다. 교체본은 UPDATE 커서의 ``rowcount`` 를 본다.
|
|
6
|
+
|
|
7
|
+
원본과의 차이가 이 두 줄(UPDATE 결과를 ``cursor`` 에 받는 것과 판정)뿐인지는
|
|
8
|
+
``tests/test_compat_channels_lite.py`` 가 원본 소스와 대조해 확인한다. 이 파일은 channels-lite 를
|
|
9
|
+
import 하므로 ``channels_lite.apply()`` 가 게이트를 통과한 뒤에만 import 한다.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from channels_lite.layers import ChannelEmpty
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _receive_single_from_db(self, channel):
|
|
16
|
+
"""Pull a single message from the database for the given channel."""
|
|
17
|
+
async with self.connection() as conn:
|
|
18
|
+
now = self._to_django_datetime()
|
|
19
|
+
|
|
20
|
+
# Find first non-delivered, non-expired message
|
|
21
|
+
cursor = await conn.execute(
|
|
22
|
+
"""
|
|
23
|
+
SELECT id, data FROM channels_lite_event
|
|
24
|
+
WHERE channel_name = ? AND delivered = 0 AND expires_at >= ?
|
|
25
|
+
ORDER BY expires_at ASC
|
|
26
|
+
LIMIT 1
|
|
27
|
+
""",
|
|
28
|
+
(channel, now),
|
|
29
|
+
)
|
|
30
|
+
row = await cursor.fetchone()
|
|
31
|
+
|
|
32
|
+
if row:
|
|
33
|
+
event_id = row[0]
|
|
34
|
+
data_json = row[1]
|
|
35
|
+
|
|
36
|
+
# Mark as delivered
|
|
37
|
+
cursor = await conn.execute(
|
|
38
|
+
"UPDATE channels_lite_event SET delivered = 1 WHERE id = ? AND delivered = 0",
|
|
39
|
+
(event_id,),
|
|
40
|
+
)
|
|
41
|
+
await conn.commit()
|
|
42
|
+
|
|
43
|
+
# Check if update was successful
|
|
44
|
+
if cursor.rowcount == 1:
|
|
45
|
+
message = self.deserialize(data_json)
|
|
46
|
+
full_channel = self._extract_message_channel(message, channel)
|
|
47
|
+
return full_channel, message
|
|
48
|
+
|
|
49
|
+
raise ChannelEmpty()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""channels-lite aio 레이어의 중복 배달 호환 패치 (DESIGN §9).
|
|
2
|
+
|
|
3
|
+
``AIOSQLiteChannelLayer._receive_single_from_db`` 는 선점 성공을 ``conn.total_changes > 0`` 으로
|
|
4
|
+
판정한다. 풀에서 재사용된 연결이 앞서 쓰기를 했으면 경쟁에서 진 수신자도 같은 메시지를 받는다.
|
|
5
|
+
이 모듈은 그 메서드 하나를 ``cursor.rowcount == 1`` 로 판정하는 교체본으로 바꾼다.
|
|
6
|
+
|
|
7
|
+
- **버전 게이트**: 설치된 channels-lite 가 검증한 판(:data:`VERIFIED_VERSION`)이고 원본 메서드의
|
|
8
|
+
소스가 검증한 소스(:data:`VERIFIED_SOURCE_SHA256`)와 같을 때만 바꾼다. 아니면 바꾸지 않고 사유를
|
|
9
|
+
:func:`status` 로 알려 준다(``sqlite_doctor`` 의 채널 섹션).
|
|
10
|
+
- **명시 적용**: 자동으로 적용하지 않는다. ``SQLITE_OPS = {"PATCH_CHANNELS_LITE_AIO": True}`` 이면
|
|
11
|
+
앱 ``ready()`` 에서, 아니면 :func:`apply` 를 직접 부를 때만 적용한다. :func:`apply` 는 멱등이다.
|
|
12
|
+
- channels-lite 를 import 할 뿐 DB 는 열지 않는다.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import inspect
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from importlib import metadata
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"SETTING",
|
|
24
|
+
"VERIFIED_SOURCE_SHA256",
|
|
25
|
+
"VERIFIED_VERSION",
|
|
26
|
+
"Status",
|
|
27
|
+
"apply",
|
|
28
|
+
"enabled",
|
|
29
|
+
"status",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
SETTING = "PATCH_CHANNELS_LITE_AIO"
|
|
33
|
+
VERIFIED_VERSION = "0.4.0"
|
|
34
|
+
# channels-lite 0.4.0 의 ``inspect.getsource(AIOSQLiteChannelLayer._receive_single_from_db)``.
|
|
35
|
+
VERIFIED_SOURCE_SHA256 = "bc15cbf6a5a737eb4b9f4877440857d603dd1fbdb6ec32487aee68374fc6170d"
|
|
36
|
+
|
|
37
|
+
_METHOD = "_receive_single_from_db"
|
|
38
|
+
_MARK = "__sqlite_ops_patch__"
|
|
39
|
+
_lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Status:
|
|
44
|
+
"""패치 상태. ``applicable`` 은 게이트 통과 여부, ``applied`` 는 이 프로세스에서 적용 여부다."""
|
|
45
|
+
|
|
46
|
+
installed: bool
|
|
47
|
+
version: str | None
|
|
48
|
+
applicable: bool
|
|
49
|
+
applied: bool
|
|
50
|
+
reason: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _version() -> str | None:
|
|
54
|
+
try:
|
|
55
|
+
return metadata.version("channels-lite")
|
|
56
|
+
except metadata.PackageNotFoundError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _layer_class():
|
|
61
|
+
"""``(클래스, None)`` 또는 ``(None, 사유)``."""
|
|
62
|
+
try:
|
|
63
|
+
from channels_lite.layers.aio import AIOSQLiteChannelLayer
|
|
64
|
+
except ImportError as exc:
|
|
65
|
+
return None, f"aio layer is not importable ({exc}); install channels-lite[aio]"
|
|
66
|
+
return AIOSQLiteChannelLayer, None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _gate(cls) -> str | None:
|
|
70
|
+
"""게이트를 통과하면 ``None``, 아니면 사유. 이미 바꾼 클래스는 원본 소스로 판정한다."""
|
|
71
|
+
method = getattr(cls, _METHOD)
|
|
72
|
+
original = getattr(method, "__wrapped_original__", method)
|
|
73
|
+
try:
|
|
74
|
+
source = inspect.getsource(original)
|
|
75
|
+
except (OSError, TypeError) as exc:
|
|
76
|
+
return f"source of {_METHOD} is not available ({exc}); cannot verify"
|
|
77
|
+
if hashlib.sha256(source.encode()).hexdigest() != VERIFIED_SOURCE_SHA256:
|
|
78
|
+
return f"{_METHOD} differs from the verified channels-lite {VERIFIED_VERSION} source"
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def status() -> Status:
|
|
83
|
+
"""현재 상태를 돌려준다. 아무것도 바꾸지 않는다."""
|
|
84
|
+
version = _version()
|
|
85
|
+
if version is None:
|
|
86
|
+
return Status(False, None, False, False, "channels-lite is not installed")
|
|
87
|
+
if version != VERIFIED_VERSION:
|
|
88
|
+
return Status(
|
|
89
|
+
True,
|
|
90
|
+
version,
|
|
91
|
+
False,
|
|
92
|
+
False,
|
|
93
|
+
f"channels-lite {version} is not verified (verified: =={VERIFIED_VERSION}); "
|
|
94
|
+
"patch not applied",
|
|
95
|
+
)
|
|
96
|
+
cls, reason = _layer_class()
|
|
97
|
+
if cls is None:
|
|
98
|
+
return Status(True, version, False, False, reason)
|
|
99
|
+
reason = _gate(cls)
|
|
100
|
+
if reason is not None:
|
|
101
|
+
return Status(True, version, False, False, reason + "; patch not applied")
|
|
102
|
+
if getattr(getattr(cls, _METHOD), _MARK, False):
|
|
103
|
+
return Status(True, version, True, True, "applied")
|
|
104
|
+
return Status(True, version, True, False, "not applied in this process")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def apply() -> bool:
|
|
108
|
+
"""게이트를 통과하면 패치를 적용하고 ``True``, 아니면 아무것도 바꾸지 않고 ``False``.
|
|
109
|
+
|
|
110
|
+
두 번 불러도 한 번만 바꾼다. channels-lite 가 없으면 조용히 ``False`` 다.
|
|
111
|
+
"""
|
|
112
|
+
with _lock:
|
|
113
|
+
current = status()
|
|
114
|
+
if current.applied:
|
|
115
|
+
return True
|
|
116
|
+
if not current.applicable:
|
|
117
|
+
return False
|
|
118
|
+
from channels_lite.layers.aio import AIOSQLiteChannelLayer
|
|
119
|
+
|
|
120
|
+
from ._channels_lite_aio import _receive_single_from_db as patched
|
|
121
|
+
|
|
122
|
+
# 원본은 게이트 판정(_gate)이 다시 볼 수 있도록 교체본에 붙여 둔다
|
|
123
|
+
patched.__wrapped_original__ = getattr(AIOSQLiteChannelLayer, _METHOD)
|
|
124
|
+
patched.__qualname__ = f"AIOSQLiteChannelLayer.{_METHOD}"
|
|
125
|
+
setattr(patched, _MARK, True)
|
|
126
|
+
setattr(AIOSQLiteChannelLayer, _METHOD, patched)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def enabled(config: object) -> bool:
|
|
131
|
+
"""``SQLITE_OPS`` 에서 패치가 켜져 있는지. 정확히 ``True`` 일 때만 켠다."""
|
|
132
|
+
return isinstance(config, Mapping) and config.get(SETTING) is True
|