kedis-python 0.1.3__py3-none-any.whl → 0.2.0__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.
kedis_python/aof.py ADDED
@@ -0,0 +1,138 @@
1
+ """On-disk record format for the append-only file (AOF).
2
+
3
+ Every logged command is one KESP-framed record, the same shape the wire
4
+ protocol uses for commands:
5
+
6
+ A<count>\\n then, for each token: S<length>\\n<payload>\\n
7
+
8
+ Lengths count bytes of the UTF-8 payload, so a token can hold anything: spaces,
9
+ newlines, tabs, an empty string, text that looks like another command. The old
10
+ format joined tokens with spaces on one line, so a value could not survive a
11
+ restart and a value containing a newline could plant commands in the log.
12
+
13
+ Strings are encoded with "surrogatepass", so every Python str round-trips
14
+ exactly, lone surrogates included.
15
+
16
+ Files written by older versions hold one whitespace-separated command per line.
17
+ scan() still reads those, line by line, and a file may mix both: old lines
18
+ first, framed records after.
19
+ """
20
+
21
+ import re
22
+
23
+ _COUNT = re.compile(rb"(?:0|[1-9][0-9]*)")
24
+ MAX_HEADER_LINE = 32 # "A<n>" / "S<n>" lines are never longer than this
25
+
26
+
27
+ def encode_record(tokens) -> bytes:
28
+ """One command as a framed record."""
29
+ parts = [b"A%d\n" % len(tokens)]
30
+ for token in tokens:
31
+ raw = str(token).encode("utf-8", "surrogatepass")
32
+ parts.append(b"S%d\n" % len(raw))
33
+ parts.append(raw)
34
+ parts.append(b"\n")
35
+ return b"".join(parts)
36
+
37
+
38
+ class ScanResult:
39
+ """How a scan ended.
40
+
41
+ status: "ok" every byte was consumed
42
+ "torn" the file ends in the middle of a record (a crash while
43
+ writing); everything before good_end is intact
44
+ "corrupt" a record is malformed; see reason
45
+ good_end: offset just past the last complete record or line
46
+ """
47
+
48
+ __slots__ = ("good_end", "reason", "status")
49
+
50
+ def __init__(self):
51
+ self.status = "ok"
52
+ self.good_end = 0
53
+ self.reason = ""
54
+
55
+
56
+ def _count(buf, start: int, end: int):
57
+ field = bytes(buf[start:end])
58
+ return int(field) if _COUNT.fullmatch(field) else None
59
+
60
+
61
+ def scan(buf, result: ScanResult):
62
+ """Yields (tokens, end_offset) for each command in buf, a bytes-like object
63
+ that supports find() and slicing (bytes or mmap). Stops at the first
64
+ incomplete or malformed record and says why in result."""
65
+ total = len(buf)
66
+ pos = 0
67
+
68
+ def stop(status: str, reason: str):
69
+ result.status = status
70
+ result.reason = reason
71
+
72
+ while pos < total:
73
+ if buf[pos : pos + 1] == b"A":
74
+ following = buf[pos + 1 : pos + 2]
75
+ if following == b"":
76
+ return stop("torn", "file ends inside a record header")
77
+ # A legacy command word starts "A" + a letter (AUTH, ...). Anything
78
+ # else after the "A" is a record header and must validate as one.
79
+ framed = not following.isalpha()
80
+ else:
81
+ framed = False
82
+
83
+ if not framed:
84
+ # Legacy line: whitespace-separated tokens, as older versions wrote.
85
+ nl = buf.find(b"\n", pos)
86
+ line_end = total if nl == -1 else nl
87
+ next_pos = total if nl == -1 else nl + 1
88
+ tokens = bytes(buf[pos:line_end]).decode("utf-8", "replace").split()
89
+ result.good_end = next_pos
90
+ if tokens:
91
+ yield tokens, next_pos
92
+ pos = next_pos
93
+ continue
94
+
95
+ nl = buf.find(b"\n", pos)
96
+ if nl == -1:
97
+ if total - pos > MAX_HEADER_LINE:
98
+ return stop("corrupt", "array header too long")
99
+ return stop("torn", "file ends inside a record header")
100
+ if nl - pos > MAX_HEADER_LINE:
101
+ return stop("corrupt", "array header too long")
102
+ count = _count(buf, pos + 1, nl)
103
+ if count is None:
104
+ return stop("corrupt", "invalid array header")
105
+
106
+ cursor = nl + 1
107
+ tokens = []
108
+ for _ in range(count):
109
+ if cursor >= total:
110
+ return stop("torn", "file ends between tokens")
111
+ if buf[cursor : cursor + 1] != b"S":
112
+ return stop("corrupt", "expected a string header")
113
+ nl = buf.find(b"\n", cursor)
114
+ if nl == -1:
115
+ if total - cursor > MAX_HEADER_LINE:
116
+ return stop("corrupt", "string header too long")
117
+ return stop("torn", "file ends inside a string header")
118
+ if nl - cursor > MAX_HEADER_LINE:
119
+ return stop("corrupt", "string header too long")
120
+ length = _count(buf, cursor + 1, nl)
121
+ if length is None:
122
+ return stop("corrupt", "invalid string length")
123
+ start = nl + 1
124
+ end = start + length
125
+ if end + 1 > total:
126
+ return stop("torn", "file ends inside a string")
127
+ if buf[end : end + 1] != b"\n":
128
+ return stop("corrupt", "string is not newline-terminated")
129
+ try:
130
+ tokens.append(bytes(buf[start:end]).decode("utf-8", "surrogatepass"))
131
+ except UnicodeDecodeError:
132
+ return stop("corrupt", "string is not valid UTF-8")
133
+ cursor = end + 1
134
+
135
+ result.good_end = cursor
136
+ if tokens:
137
+ yield tokens, cursor
138
+ pos = cursor