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.
@@ -0,0 +1,350 @@
1
+ """부팅 판정 순수 함수 (DESIGN §4-3, §4-4).
2
+
3
+ 입력만 보고 상태와 조치를 정한다. 파일·subprocess·시간·환경변수를 건드리지 않는다.
4
+ Django 를 import 하지 않는다(DESIGN §4-1).
5
+
6
+ 공개 입력은 정확한 타입(``type(x) is ...``)만 받는다. 판정이 로컬 DB 를 지킬지 덮을지를
7
+ 정하므로 사용자 객체의 연산(``__eq__``·``__hash__``·``__bool__``·``__lt__``·``__repr__`` …)을
8
+ 신뢰하지 않는다.
9
+ 검증은 ``_normalize()`` 한 곳에서 하고, 그 뒤의 분류·조치는 정규화된 내장 값만 본다.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from enum import Enum, StrEnum, auto
14
+ from typing import Any
15
+
16
+ __all__ = [
17
+ "Action",
18
+ "Decision",
19
+ "OnUnknown",
20
+ "ReasonCode",
21
+ "Remote",
22
+ "RemoteEmpty",
23
+ "RemoteError",
24
+ "RemoteTxid",
25
+ "State",
26
+ "decide",
27
+ ]
28
+
29
+
30
+ class State(StrEnum):
31
+ FRESH = "fresh"
32
+ MATCH = "match"
33
+ ADOPT = "adopt"
34
+ UNKNOWN = "unknown"
35
+
36
+
37
+ class Action(StrEnum):
38
+ PROCEED = "proceed"
39
+ RESTORE = "restore"
40
+ QUARANTINE_AND_RESTORE = "quarantine_and_restore"
41
+ KEEP_LOCAL = "keep_local"
42
+ REFUSE = "refuse"
43
+
44
+
45
+ class ReasonCode(StrEnum):
46
+ # fresh
47
+ NEW_DB = "new_db"
48
+ RESTORE_FROM_REMOTE = "restore_from_remote"
49
+ # match
50
+ LOCAL_CURRENT = "local_current"
51
+ # adopt (D-11)
52
+ ADOPT_EXISTING = "adopt_existing"
53
+ # unknown
54
+ REMOTE_ERROR = "remote_error"
55
+ STALE_META = "stale_meta"
56
+ # DB 경로가 0바이트 정규 파일인데 로컬 메타 TXID 가 있음(#38, D-16).
57
+ # 메타가 DB 내용을 대변하지 않는다.
58
+ EMPTY_DB = "empty_db"
59
+ NO_LOCAL_META = "no_local_meta"
60
+ REMOTE_EMPTY = "remote_empty"
61
+ REMOTE_AHEAD = "remote_ahead"
62
+ # 로컬 DB·메타 없음 + 원격 빈 목록, init_new 없음(D-13). 복제본 경로 오타와 구분되지 않는다.
63
+ NO_REPLICA_NO_LOCAL = "no_replica_no_local"
64
+
65
+
66
+ class OnUnknown(StrEnum):
67
+ REFUSE = "refuse"
68
+ RESTORE = "restore"
69
+ KEEP_LOCAL = "keep-local"
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class RemoteError:
74
+ """원격 조회 실패(타임아웃·인증·파싱 실패 모두)."""
75
+
76
+ message: str
77
+
78
+ def __post_init__(self) -> None:
79
+ _check_message(self.message)
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class RemoteEmpty:
84
+ """원격 조회 성공, 복제본 없음."""
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class RemoteTxid:
89
+ """원격 조회 성공, 복제본의 최대 TXID."""
90
+
91
+ txid: int
92
+
93
+ def __post_init__(self) -> None:
94
+ _check_txid("remote txid", self.txid)
95
+
96
+
97
+ Remote = RemoteError | RemoteEmpty | RemoteTxid
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class Decision:
102
+ state: State
103
+ action: Action
104
+ reason_code: ReasonCode
105
+ reason: str
106
+
107
+
108
+ _SAFE_REPR = (type(None), bool, int, str)
109
+
110
+
111
+ def _show(value: Any) -> str:
112
+ # 거부할 값의 __repr__ 도 사용자 코드다. 내장 값만 repr 하고 나머지는 타입 이름만 쓴다.
113
+ kind = type(value)
114
+ if any(kind is safe for safe in _SAFE_REPR):
115
+ return repr(value)
116
+ return f"<{kind.__qualname__} object>"
117
+
118
+
119
+ def _check_txid(label: str, value: Any) -> int:
120
+ # 내장 int 만 받는다. 서브클래스는 비교 연산을 바꿔 검증과 판정을 우회할 수 있다.
121
+ if type(value) is not int or value < 0:
122
+ raise ValueError(f"invalid {label} {_show(value)}; must be a non-negative int")
123
+ return value
124
+
125
+
126
+ def _check_message(value: Any) -> str:
127
+ if type(value) is not str:
128
+ raise ValueError(f"invalid remote error message {_show(value)}; must be a str")
129
+ return value
130
+
131
+
132
+ def _check_bool(label: str, value: Any) -> bool:
133
+ if type(value) is not bool:
134
+ raise ValueError(f"invalid {label} {_show(value)}; must be a bool")
135
+ return value
136
+
137
+
138
+ _POLICIES = {p.value: p for p in OnUnknown}
139
+
140
+
141
+ def _check_policy(value: Any) -> OnUnknown:
142
+ # OnUnknown(value) 는 값의 __hash__·__eq__ 로 찾으므로 쓰지 않는다.
143
+ if type(value) is OnUnknown:
144
+ return value
145
+ if type(value) is str and value in _POLICIES:
146
+ return _POLICIES[value]
147
+ choices = ", ".join(_POLICIES)
148
+ raise ValueError(f"invalid on_unknown {_show(value)}; expected one of {choices}")
149
+
150
+
151
+ class _Kind(Enum):
152
+ ERROR = auto()
153
+ EMPTY = auto()
154
+ TXID = auto()
155
+
156
+
157
+ @dataclass(frozen=True, slots=True)
158
+ class _Remote:
159
+ """API 경계에서 한 번 정규화한 원격 결과. 분류와 조치는 이 태그만 본다."""
160
+
161
+ kind: _Kind
162
+ txid: int | None = None
163
+ message: str = ""
164
+
165
+ @property
166
+ def text(self) -> str:
167
+ if self.kind is _Kind.TXID:
168
+ return f"txid {self.txid}"
169
+ if self.kind is _Kind.EMPTY:
170
+ return "empty"
171
+ return f"error: {self.message}"
172
+
173
+
174
+ def _normalize_remote(remote: Any) -> _Remote:
175
+ # isinstance 는 __class__ 를 속인 객체에 둘 이상 참이 될 수 있어 정확한 타입으로 가른다.
176
+ # 필드는 frozen 이라도 object.__setattr__ 로 바뀔 수 있으므로 생성자 검증과 별도로 다시 본다.
177
+ kind = type(remote)
178
+ if kind is RemoteError:
179
+ message = _check_message(remote.message)
180
+ # reason 은 한 줄이어야 한다. str.splitlines 의 줄 구분자를 모두 공백으로 바꾼다.
181
+ return _Remote(_Kind.ERROR, message=" ".join(message.splitlines()))
182
+ if kind is RemoteEmpty:
183
+ return _Remote(_Kind.EMPTY)
184
+ if kind is RemoteTxid:
185
+ return _Remote(_Kind.TXID, txid=_check_txid("remote txid", remote.txid))
186
+ raise ValueError(
187
+ f"invalid remote {_show(remote)}; expected exactly RemoteError, RemoteEmpty or RemoteTxid"
188
+ )
189
+
190
+
191
+ @dataclass(frozen=True, slots=True)
192
+ class _Inputs:
193
+ """정규화된 입력. 모든 필드가 내장 값이거나 이 모듈의 타입이다."""
194
+
195
+ local_exists: bool
196
+ local_empty: bool
197
+ local_txid: int | None
198
+ remote: _Remote
199
+ policy: OnUnknown
200
+ adopt_existing: bool
201
+ init_new: bool
202
+
203
+
204
+ def _normalize(
205
+ local_exists: Any,
206
+ local_empty: Any,
207
+ local_txid: Any,
208
+ remote: Any,
209
+ on_unknown: Any,
210
+ adopt_existing: Any,
211
+ init_new: Any,
212
+ ) -> _Inputs:
213
+ """공개 입력을 검증하는 유일한 지점. 정확한 타입이 아니면 dunder 를 부르기 전에 거부한다."""
214
+ exists = _check_bool("local_exists", local_exists)
215
+ empty = _check_bool("local_empty", local_empty)
216
+ if empty and not exists:
217
+ raise ValueError("invalid local_empty True with local_exists False; an empty db must exist")
218
+ return _Inputs(
219
+ local_exists=exists,
220
+ local_empty=empty,
221
+ local_txid=None if local_txid is None else _check_txid("local txid", local_txid),
222
+ remote=_normalize_remote(remote),
223
+ policy=_check_policy(on_unknown),
224
+ adopt_existing=_check_bool("adopt_existing", adopt_existing),
225
+ init_new=_check_bool("init_new", init_new),
226
+ )
227
+
228
+
229
+ def _classify(inp: _Inputs) -> tuple[State, ReasonCode, str]:
230
+ local_exists, local_txid, remote = inp.local_exists, inp.local_txid, inp.remote
231
+ # 표의 순서대로 본다(DESIGN §4-3).
232
+ # DB 없이 메타만 남은 경우는 원격 결과와 무관하게 stale_meta 다.
233
+ if not local_exists and local_txid is not None:
234
+ return (
235
+ State.UNKNOWN,
236
+ ReasonCode.STALE_META,
237
+ f"no local db but local meta has txid {local_txid}; remote {remote.text}",
238
+ )
239
+ # 0바이트 DB 에 메타 TXID 가 남았으면 TXID 비교는 의미가 없다(#38, D-16).
240
+ # 원격 실패보다 먼저 본다(조치 표가 원격 실패면 어차피 거부한다, D-12).
241
+ # 메타 없는 0바이트는 아래 no_local_meta 로 간다. adopt 하지 않는다(#44, D-18).
242
+ if inp.local_empty and local_txid is not None:
243
+ return (
244
+ State.UNKNOWN,
245
+ ReasonCode.EMPTY_DB,
246
+ f"local db is 0 bytes but local meta has txid {local_txid}; remote {remote.text}",
247
+ )
248
+ if remote.kind is _Kind.ERROR:
249
+ where = "local db exists" if local_exists else "no local db"
250
+ return (
251
+ State.UNKNOWN,
252
+ ReasonCode.REMOTE_ERROR,
253
+ f"{where}, local txid {local_txid}; remote lookup failed: {remote.message}",
254
+ )
255
+ if not local_exists:
256
+ if remote.kind is _Kind.EMPTY:
257
+ # D-13: 빈 목록은 '복제본 없음'과 경로·prefix 오타를 구분하지 못한다(#3 실측).
258
+ if inp.init_new:
259
+ return State.FRESH, ReasonCode.NEW_DB, "no local db and remote empty; start new db"
260
+ return (
261
+ State.UNKNOWN,
262
+ ReasonCode.NO_REPLICA_NO_LOCAL,
263
+ "no local db and remote empty (replica path may be wrong); "
264
+ "use --init-new for the first deploy",
265
+ )
266
+ return (
267
+ State.FRESH,
268
+ ReasonCode.RESTORE_FROM_REMOTE,
269
+ f"no local db; restore remote txid {remote.txid}",
270
+ )
271
+
272
+ if local_txid is None:
273
+ # D-18: 0바이트 파일은 도입할 데이터가 없다. 도입하면 빈 DB 가 첫 세대가 된다.
274
+ if inp.adopt_existing and remote.kind is _Kind.EMPTY and not inp.local_empty:
275
+ return (
276
+ State.ADOPT,
277
+ ReasonCode.ADOPT_EXISTING,
278
+ "local db exists without meta and remote is empty; adopting existing db",
279
+ )
280
+ if inp.local_empty:
281
+ return (
282
+ State.UNKNOWN,
283
+ ReasonCode.NO_LOCAL_META,
284
+ f"local db is 0 bytes and local meta is missing; remote {remote.text}",
285
+ )
286
+ return (
287
+ State.UNKNOWN,
288
+ ReasonCode.NO_LOCAL_META,
289
+ f"local db exists but local meta is missing; remote {remote.text}",
290
+ )
291
+ if remote.kind is _Kind.EMPTY:
292
+ return (
293
+ State.UNKNOWN,
294
+ ReasonCode.REMOTE_EMPTY,
295
+ f"local db exists with txid {local_txid} but remote is empty",
296
+ )
297
+ assert remote.txid is not None
298
+ if local_txid >= remote.txid:
299
+ return (
300
+ State.MATCH,
301
+ ReasonCode.LOCAL_CURRENT,
302
+ f"local txid {local_txid} >= remote txid {remote.txid}",
303
+ )
304
+ return (
305
+ State.UNKNOWN,
306
+ ReasonCode.REMOTE_AHEAD,
307
+ f"local txid {local_txid} < remote txid {remote.txid}",
308
+ )
309
+
310
+
311
+ def decide(
312
+ *,
313
+ local_exists: bool,
314
+ local_txid: int | None,
315
+ remote: Remote,
316
+ local_empty: bool = False,
317
+ on_unknown: OnUnknown | str = OnUnknown.REFUSE,
318
+ adopt_existing: bool = False,
319
+ init_new: bool = False,
320
+ ) -> Decision:
321
+ """부팅 상태(fresh/match/adopt/unknown)와 조치를 정한다. 규칙표는 DESIGN §4-3.
322
+
323
+ ``local_empty`` 는 DB 경로가 0바이트 정규 파일인지다. ``local_exists`` 가 거짓이면 참일 수 없다.
324
+ """
325
+ inp = _normalize(
326
+ local_exists, local_empty, local_txid, remote, on_unknown, adopt_existing, init_new
327
+ )
328
+ state, code, reason = _classify(inp)
329
+
330
+ if state is State.FRESH:
331
+ action = Action.PROCEED if code is ReasonCode.NEW_DB else Action.RESTORE
332
+ elif state is State.MATCH or state is State.ADOPT:
333
+ action = Action.PROCEED
334
+ elif inp.remote.kind is _Kind.ERROR:
335
+ # D-12: 원격 조회 실패면 정책과 무관하게 거부한다(S3 장애 중 옛 볼륨 재부팅 방지).
336
+ action = Action.REFUSE
337
+ elif inp.local_empty and inp.policy is OnUnknown.KEEP_LOCAL:
338
+ # D-17·D-18: 0바이트 DB 는 메타 유무와 무관하게 keep-local 로도 넘기지 않는다.
339
+ # 지킬 로컬 데이터가 없다.
340
+ action = Action.REFUSE
341
+ elif inp.policy is OnUnknown.RESTORE:
342
+ # 복원할 복제본이 있을 때만 격리 후 복원한다. 빈 목록이면 거부한다.
343
+ action = Action.QUARANTINE_AND_RESTORE if inp.remote.kind is _Kind.TXID else Action.REFUSE
344
+ elif inp.policy is OnUnknown.KEEP_LOCAL:
345
+ # 로컬 DB 가 없으면 지킬 것이 없고, 새 DB 로 시작하면 원격을 덮을 수 있다.
346
+ action = Action.KEEP_LOCAL if inp.local_exists else Action.REFUSE
347
+ else:
348
+ action = Action.REFUSE
349
+
350
+ return Decision(state=state, action=action, reason_code=code, reason=reason)