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 +138 -0
- kedis_python/commands.py +724 -34
- kedis_python/help_data.py +711 -0
- kedis_python/hyperloglog.py +78 -0
- kedis_python/main.py +348 -158
- kedis_python/network.py +61 -7
- kedis_python/parser.py +435 -76
- kedis_python/server.py +292 -65
- kedis_python/store.py +996 -31
- kedis_python/stream.py +416 -0
- kedis_python/textcodec.py +32 -0
- kedis_python/ui.py +186 -9
- {kedis_python-0.1.3.dist-info → kedis_python-0.2.0.dist-info}/METADATA +34 -10
- kedis_python-0.2.0.dist-info/RECORD +25 -0
- kedis_python-0.1.3.dist-info/RECORD +0 -20
- {kedis_python-0.1.3.dist-info → kedis_python-0.2.0.dist-info}/WHEEL +0 -0
- {kedis_python-0.1.3.dist-info → kedis_python-0.2.0.dist-info}/entry_points.txt +0 -0
- {kedis_python-0.1.3.dist-info → kedis_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {kedis_python-0.1.3.dist-info → kedis_python-0.2.0.dist-info}/top_level.txt +0 -0
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
|