kedis-python 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (91) hide show
  1. {kedis_python-0.2.0 → kedis_python-0.3.0}/PKG-INFO +7 -2
  2. {kedis_python-0.2.0 → kedis_python-0.3.0}/README.md +6 -1
  3. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/commands.py +66 -41
  4. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/main.py +1 -1
  5. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/parser.py +147 -17
  6. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/server.py +387 -85
  7. kedis_python-0.3.0/kedis_python/settings.py +238 -0
  8. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/ui.py +1 -1
  9. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/PKG-INFO +7 -2
  10. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/SOURCES.txt +17 -1
  11. {kedis_python-0.2.0 → kedis_python-0.3.0}/pyproject.toml +1 -1
  12. kedis_python-0.3.0/tests/test_chunked_reply.py +409 -0
  13. kedis_python-0.3.0/tests/test_doc_claims.py +72 -0
  14. kedis_python-0.3.0/tests/test_error_line_cap.py +132 -0
  15. kedis_python-0.3.0/tests/test_follower_stream.py +495 -0
  16. kedis_python-0.3.0/tests/test_fuzz.py +286 -0
  17. kedis_python-0.3.0/tests/test_fuzz_properties.py +63 -0
  18. kedis_python-0.3.0/tests/test_multi_limits.py +301 -0
  19. kedis_python-0.3.0/tests/test_multi_readonly.py +157 -0
  20. kedis_python-0.3.0/tests/test_multi_replication.py +475 -0
  21. kedis_python-0.3.0/tests/test_replica_snapshot.py +141 -0
  22. kedis_python-0.3.0/tests/test_replica_write_deadline.py +279 -0
  23. kedis_python-0.3.0/tests/test_replication_task.py +217 -0
  24. kedis_python-0.3.0/tests/test_reply_budgets.py +226 -0
  25. kedis_python-0.3.0/tests/test_reply_snapshots.py +198 -0
  26. kedis_python-0.3.0/tests/test_server_flags.py +427 -0
  27. kedis_python-0.3.0/tests/test_write_deadline.py +242 -0
  28. kedis_python-0.2.0/kedis_python/replica.py +0 -454
  29. {kedis_python-0.2.0 → kedis_python-0.3.0}/LICENSE +0 -0
  30. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/__init__.py +0 -0
  31. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/aof.py +0 -0
  32. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/benchmark.py +0 -0
  33. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/benchmark_asyncio.py +0 -0
  34. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/help_data.py +0 -0
  35. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/hyperloglog.py +0 -0
  36. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/kesp_cli.py +0 -0
  37. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/network.py +0 -0
  38. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/simulator.py +0 -0
  39. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/skiplist.py +0 -0
  40. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/store.py +0 -0
  41. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/stream.py +0 -0
  42. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python/textcodec.py +0 -0
  43. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/dependency_links.txt +0 -0
  44. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/entry_points.txt +0 -0
  45. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/requires.txt +0 -0
  46. {kedis_python-0.2.0 → kedis_python-0.3.0}/kedis_python.egg-info/top_level.txt +0 -0
  47. {kedis_python-0.2.0 → kedis_python-0.3.0}/setup.cfg +0 -0
  48. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_aof_persistence.py +0 -0
  49. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_aof_records.py +0 -0
  50. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_bench.py +0 -0
  51. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_binary_safety.py +0 -0
  52. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_bitmap_persistence.py +0 -0
  53. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_bitmaps.py +0 -0
  54. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_cli_housekeep.py +0 -0
  55. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_cli_stream_view.py +0 -0
  56. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_data_replies.py +0 -0
  57. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_del_exists.py +0 -0
  58. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_docs_data_types.py +0 -0
  59. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_docs_streams.py +0 -0
  60. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_help_data.py +0 -0
  61. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_history_setup.py +0 -0
  62. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_housekeeping.py +0 -0
  63. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_hyperloglog.py +0 -0
  64. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_inline_block.py +0 -0
  65. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_kesp.py +0 -0
  66. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_kesp2.py +0 -0
  67. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_kesp_decoder.py +0 -0
  68. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_kesp_encoder.py +0 -0
  69. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_lists.py +0 -0
  70. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_lrange.py +0 -0
  71. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_lru.py +0 -0
  72. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_parser_casing.py +0 -0
  73. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_parser_framing.py +0 -0
  74. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_parser_harden.py +0 -0
  75. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_parser_limits.py +0 -0
  76. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_pubsub.py +0 -0
  77. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_replica.py +0 -0
  78. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_reply_limits.py +0 -0
  79. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_server_limits.py +0 -0
  80. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_server_protocol_errors.py +0 -0
  81. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_commands.py +0 -0
  82. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_core.py +0 -0
  83. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_groups_commands.py +0 -0
  84. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_groups_core.py +0 -0
  85. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_groups_server.py +0 -0
  86. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_groups_store.py +0 -0
  87. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_server.py +0 -0
  88. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_stream_store.py +0 -0
  89. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_type.py +0 -0
  90. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_version.py +0 -0
  91. {kedis_python-0.2.0 → kedis_python-0.3.0}/tests/test_zadd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kedis-python
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A Redis-inspired in-memory store in Python.
5
5
  Author: V SS Karthik
6
6
  License-Expression: MIT
@@ -53,6 +53,7 @@ Dive into the internal grimoire to understand how Kedis achieves microsecond lat
53
53
  * [KESP Wire Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/protocol.md)
54
54
  * [Networking & Surge Tank](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/networking.md)
55
55
  * [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md)
56
+ * [Fuzzing the Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/fuzzing.md)
56
57
  * [Persistence & I/O Drivetrain](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/persistence.md)
57
58
  * [Command Spellbook](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/commands.md)
58
59
  * [TTL & Lifespan Mechanics](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/ttl.md)
@@ -200,7 +201,8 @@ Kedis supports master-replica replication:
200
201
 
201
202
  * Replicas connect to a master and perform a `SYNC` handshake to receive the initial dataset
202
203
  * The master streams subsequent writes to connected replicas in real time
203
- * Replicas are read-only — writes issued directly to a replica are rejected
204
+ * Replicas are read-only — writes issued directly to a replica are rejected, and so are writes inside a `MULTI` block: the write gets `-EREADONLY` when it is queued and `EXEC` aborts with `-EXECABORT`
205
+ * A `MULTI`/`EXEC` block runs as one unit on the master and reaches followers as one transaction, which a follower applies as a unit
204
206
 
205
207
  ---
206
208
 
@@ -211,6 +213,7 @@ Asyncio-based server implementation supporting:
211
213
  * many concurrent client connections on a single event loop
212
214
  * command execution over sockets via the KESP protocol
213
215
  * standalone local mode fallback
216
+ * every limit settable at startup, e.g. `kedis-server --port 7001 --max-clients 500` (see [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md))
214
217
 
215
218
  ---
216
219
 
@@ -313,6 +316,7 @@ The project includes automated tests covering:
313
316
  * persistence recovery
314
317
  * expiration behavior
315
318
  * streams and consumer groups, from the data structure up to live TCP sessions
319
+ * protocol fuzzing: a mutation fuzzer, reference readers and property tests (see [Fuzzing the Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/fuzzing.md))
316
320
 
317
321
  Additional coverage is actively being expanded as the project evolves.
318
322
 
@@ -384,6 +388,7 @@ Current limitations include:
384
388
  * limited fault tolerance compared to production databases
385
389
  * no memory-based eviction ceiling (LRU tracks usage, but isn't yet tied to a hard memory limit)
386
390
  * AOF persistence is not buffered — writes can trigger a sync depending on config
391
+ * `SAVE` and `COMPACT` hold the engine lock while they dump the store, so other commands wait (about 1 s for 3 million items); a write forwarded to followers is encoded on the event loop in one go (about 0.55 s for a command with 1,000,000 arguments). Both are listed in [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md#not-covered-yet)
387
392
  * consumer groups have no `XCLAIM`/`XAUTOCLAIM` yet; the full list of stream limits is in [docs/streams.md](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md#-known-limits)
388
393
 
389
394
  These limitations are intentional learning opportunities and areas of active development.
@@ -33,6 +33,7 @@ Dive into the internal grimoire to understand how Kedis achieves microsecond lat
33
33
  * [KESP Wire Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/protocol.md)
34
34
  * [Networking & Surge Tank](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/networking.md)
35
35
  * [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md)
36
+ * [Fuzzing the Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/fuzzing.md)
36
37
  * [Persistence & I/O Drivetrain](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/persistence.md)
37
38
  * [Command Spellbook](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/commands.md)
38
39
  * [TTL & Lifespan Mechanics](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/ttl.md)
@@ -180,7 +181,8 @@ Kedis supports master-replica replication:
180
181
 
181
182
  * Replicas connect to a master and perform a `SYNC` handshake to receive the initial dataset
182
183
  * The master streams subsequent writes to connected replicas in real time
183
- * Replicas are read-only — writes issued directly to a replica are rejected
184
+ * Replicas are read-only — writes issued directly to a replica are rejected, and so are writes inside a `MULTI` block: the write gets `-EREADONLY` when it is queued and `EXEC` aborts with `-EXECABORT`
185
+ * A `MULTI`/`EXEC` block runs as one unit on the master and reaches followers as one transaction, which a follower applies as a unit
184
186
 
185
187
  ---
186
188
 
@@ -191,6 +193,7 @@ Asyncio-based server implementation supporting:
191
193
  * many concurrent client connections on a single event loop
192
194
  * command execution over sockets via the KESP protocol
193
195
  * standalone local mode fallback
196
+ * every limit settable at startup, e.g. `kedis-server --port 7001 --max-clients 500` (see [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md))
194
197
 
195
198
  ---
196
199
 
@@ -293,6 +296,7 @@ The project includes automated tests covering:
293
296
  * persistence recovery
294
297
  * expiration behavior
295
298
  * streams and consumer groups, from the data structure up to live TCP sessions
299
+ * protocol fuzzing: a mutation fuzzer, reference readers and property tests (see [Fuzzing the Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/fuzzing.md))
296
300
 
297
301
  Additional coverage is actively being expanded as the project evolves.
298
302
 
@@ -364,6 +368,7 @@ Current limitations include:
364
368
  * limited fault tolerance compared to production databases
365
369
  * no memory-based eviction ceiling (LRU tracks usage, but isn't yet tied to a hard memory limit)
366
370
  * AOF persistence is not buffered — writes can trigger a sync depending on config
371
+ * `SAVE` and `COMPACT` hold the engine lock while they dump the store, so other commands wait (about 1 s for 3 million items); a write forwarded to followers is encoded on the event loop in one go (about 0.55 s for a command with 1,000,000 arguments). Both are listed in [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md#not-covered-yet)
367
372
  * consumer groups have no `XCLAIM`/`XAUTOCLAIM` yet; the full list of stream limits is in [docs/streams.md](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md#-known-limits)
368
373
 
369
374
  These limitations are intentional learning opportunities and areas of active development.
@@ -136,57 +136,82 @@ class CommandHandler:
136
136
  if not tokens:
137
137
  return "-ERR empty command or syntax error", []
138
138
 
139
- cmd = tokens[0].upper()
140
- args = tokens[
141
- 1:
142
- ] # (Though your current code passes tokens, let's keep your routing exact)
143
-
144
139
  # 🚀 TRACK A: Start the high-precision telemetry stopwatch (in nanoseconds)
145
140
  start_time = time.perf_counter_ns()
141
+ try:
142
+ with self._engine_lock:
143
+ return self._run_locked(tokens, client_socket)
144
+ finally:
145
+ self._record_duration(tokens, start_time)
146
+
147
+ def execute_batch(self, batch: list, client_socket=None):
148
+ """Runs several commands as one unit: the engine lock is taken once, so
149
+ no other client's command can run between them, and the AOF records
150
+ them back to back. Returns (results, forwards): one result per command,
151
+ and the commands a master must send to replicas, in order (see
152
+ replication_payloads). Commands are not skipped or reordered; one that
153
+ fails gives an error result and the rest still run, like the one-at-a-
154
+ time path."""
155
+ results: list = []
156
+ forwards: list = []
157
+ write_commands = self.WRITE_COMMANDS
158
+ starts: list = []
159
+ with self._engine_lock:
160
+ for tokens in batch:
161
+ if not tokens:
162
+ results.append("-ERR empty command or syntax error")
163
+ starts.append(None)
164
+ continue
165
+ starts.append(time.perf_counter_ns())
166
+ result, records = self._run_locked(tokens, client_socket)
167
+ results.append(result)
168
+ if tokens[0].upper() in write_commands:
169
+ forwards.extend(self.replication_payloads(tokens, result, records))
170
+ for tokens, started in zip(batch, starts):
171
+ if started is not None:
172
+ self._record_duration(tokens, started)
173
+ return results, forwards
174
+
175
+ def _run_locked(self, tokens: list[str], client_socket=None):
176
+ """Runs one command. The caller holds the engine lock."""
177
+ cmd = tokens[0].upper()
146
178
  result = None
147
179
  records = []
148
180
 
149
181
  try:
150
- with self._engine_lock:
151
- try:
152
- if cmd == "XGAPPLY" and client_socket is not None:
153
- # Internal replication command: a network client
154
- # must not be able to write raw group records.
155
- result = f"-ERR unknown command '{cmd}'"
156
- elif cmd in self._commands:
157
- if cmd in ["SUBSCRIBE", "PUBLISH"]:
158
- handler_function = self._commands[cmd]
159
- result = handler_function(tokens, client_socket)
160
- else:
161
- handler_function = self._commands[cmd]
162
- # Note: Depending on how your handlers are registered,
163
- # they might expect 'tokens' or 'args'. Keeping your existing call:
164
- result = handler_function(tokens)
182
+ try:
183
+ if cmd == "XGAPPLY" and client_socket is not None:
184
+ # Internal replication command: a network client
185
+ # must not be able to write raw group records.
186
+ result = f"-ERR unknown command '{cmd}'"
187
+ elif cmd in self._commands:
188
+ handler_function = self._commands[cmd]
189
+ if cmd in ["SUBSCRIBE", "PUBLISH"]:
190
+ result = handler_function(tokens, client_socket)
165
191
  else:
166
- result = f"-ERR unknown command '{cmd}'"
167
- finally:
168
- records = self.store.drain_group_records()
169
- return result, records
170
-
171
- except TypeError as e:
172
- result = f"-ERR wrong number of arguments for '{cmd}' command"
192
+ # Handlers take the whole token list, command name included.
193
+ result = handler_function(tokens)
194
+ else:
195
+ result = f"-ERR unknown command '{cmd}'"
196
+ finally:
197
+ records = self.store.drain_group_records()
173
198
  return result, records
199
+ except TypeError:
200
+ return f"-ERR wrong number of arguments for '{cmd}' command", records
174
201
  except Exception as e:
175
- result = f"-ERR {str(e)}"
176
- return result, records
202
+ return f"-ERR {str(e)}", records
177
203
 
178
- finally:
179
- # 🚀 TRACK A: Stop the stopwatch, convert nanoseconds to microseconds
180
- end_time = time.perf_counter_ns()
181
- duration_us = (end_time - start_time) // 1000
182
-
183
- # Feed the metrics to the store's slowlog ring buffer if available
184
- if (
185
- hasattr(self, "store")
186
- and self.store
187
- and hasattr(self.store, "_log_slow_command")
188
- ):
189
- self.store._log_slow_command(tokens, duration_us)
204
+ def _record_duration(self, tokens: list[str], start_time: int) -> None:
205
+ # 🚀 TRACK A: Stop the stopwatch, convert nanoseconds to microseconds
206
+ duration_us = (time.perf_counter_ns() - start_time) // 1000
207
+
208
+ # Feed the metrics to the store's slowlog ring buffer if available
209
+ if (
210
+ hasattr(self, "store")
211
+ and self.store
212
+ and hasattr(self.store, "_log_slow_command")
213
+ ):
214
+ self.store._log_slow_command(tokens, duration_us)
190
215
 
191
216
  # ---------------------------------------------------------
192
217
  # COMMAND HANDLERS (The isolated engine components)
@@ -447,7 +447,7 @@ class KedisClient:
447
447
  return True
448
448
 
449
449
  if cmd == "INFO":
450
- version = "0.2.0"
450
+ version = "0.3.0"
451
451
  codename = "Echo"
452
452
 
453
453
  if self.local_mode:
@@ -36,7 +36,7 @@ class CommandParser:
36
36
  _COUNT = re.compile(rb"(?:0|[1-9][0-9]*)")
37
37
 
38
38
  @staticmethod
39
- def parse(raw_data, start: int = 0):
39
+ def parse(raw_data, start: int = 0, normalise: bool = True):
40
40
  """
41
41
  Parses incoming network data. Returns (tokens, bytes_consumed).
42
42
 
@@ -51,12 +51,16 @@ class CommandParser:
51
51
  raw_data may be bytes or a bytearray. Parsing starts at offset start and
52
52
  bytes_consumed counts from there, so a caller can work through a buffer
53
53
  holding many pipelined commands without copying or trimming it each time.
54
+
55
+ The first token is taken to be a command name and is uppercased. Pass
56
+ normalise=False for a frame that is data from its first token, such as the
57
+ baseline snapshot a leader sends: its JSON must reach the caller as sent.
54
58
  """
55
- tokens, consumed, _ = CommandParser.parse_buffer(raw_data, start)
59
+ tokens, consumed, _ = CommandParser.parse_buffer(raw_data, start, normalise)
56
60
  return tokens, consumed
57
61
 
58
62
  @staticmethod
59
- def parse_buffer(raw_data, start: int = 0):
63
+ def parse_buffer(raw_data, start: int = 0, normalise: bool = True):
60
64
  """parse(), plus a hint for incomplete frames: returns (tokens, consumed,
61
65
  need). When the next frame is incomplete, need is the length the bytes
62
66
  from start + consumed must reach before parsing can make any progress
@@ -75,7 +79,8 @@ class CommandParser:
75
79
  # Only the command name is normalised. Every other token is data
76
80
  # (keys, values, field names) until a handler says otherwise, so
77
81
  # handlers match their own options case-insensitively.
78
- tokens[0] = tokens[0].upper()
82
+ if normalise:
83
+ tokens[0] = tokens[0].upper()
79
84
  return tokens, end - start, 0
80
85
  pos = end # blank frame: skip it and keep scanning
81
86
  return [], pos - start, 0
@@ -267,6 +272,11 @@ class KESPDecoder:
267
272
  MAX_DEPTH = 32 # nesting guard, so a hostile reply can't blow the stack
268
273
  MAX_LINE = 64 * 1024 # a status/error line longer than this is garbage
269
274
  MAX_HEADER_LINE = 32 # I<n> / S<n> / A<n> headers are never longer
275
+ # Whole-reply budgets. MAX_DEPTH and the per-array/per-string caps bound one
276
+ # node; these bound the *sum* over a reply, so many small nodes cannot add up
277
+ # to an unbounded amount of memory or work. Looked up at call time.
278
+ MAX_REPLY_ELEMENTS = 4_194_304 # every value in the reply, containers included
279
+ MAX_REPLY_PAYLOAD = 1024 * 1024 * 1024 # string and text-line bytes, summed
270
280
 
271
281
  _COUNT = re.compile(rb"(?:0|[1-9][0-9]*)")
272
282
  _INTEGER = re.compile(rb"(?:0|-?[1-9][0-9]*)")
@@ -275,7 +285,12 @@ class KESPDecoder:
275
285
  _NUMERIC_SIGILS = (b"I", b"S", b"A")
276
286
 
277
287
  @staticmethod
278
- def decode(buf, pos: int = 0, _depth: int = 0, lenient: bool = False):
288
+ def decode(
289
+ buf, pos: int = 0, _depth: int = 0, lenient: bool = False, _budget=None
290
+ ):
291
+ if _budget is None:
292
+ # [elements left, payload bytes left], shared by the whole reply.
293
+ _budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
279
294
  if _depth > KESPDecoder.MAX_DEPTH:
280
295
  raise ReplyProtocolError("reply nested too deeply")
281
296
  if pos >= len(buf):
@@ -309,6 +324,16 @@ class KESPDecoder:
309
324
  after = nl + 1
310
325
  rest = line[1:]
311
326
 
327
+ # Charge this value to the whole-reply budget. A reply that is still
328
+ # incomplete never gets here, so a retry is not charged twice.
329
+ if _budget[0] <= 0:
330
+ raise ReplyProtocolError("reply has too many elements")
331
+ _budget[0] -= 1
332
+ if sigil not in (b"N", b"I", b"S", b"A"): # line-carried text
333
+ if len(line) > _budget[1]:
334
+ raise ReplyProtocolError("reply payload too large")
335
+ _budget[1] -= len(line)
336
+
312
337
  if sigil == b"N":
313
338
  if rest and not lenient:
314
339
  raise ReplyProtocolError("invalid nil reply")
@@ -326,6 +351,10 @@ class KESPDecoder:
326
351
  length = KESPDecoder._count(rest)
327
352
  if length > CommandParser.MAX_BULK_LENGTH:
328
353
  raise ReplyProtocolError("invalid bulk length")
354
+ if length > _budget[1]:
355
+ # Refuse on the header, before waiting for the bytes to arrive.
356
+ raise ReplyProtocolError("reply payload too large")
357
+ _budget[1] -= length
329
358
  end = after + length
330
359
  if len(buf) < end + 1:
331
360
  raise IncompleteReply(end + 1)
@@ -337,9 +366,14 @@ class KESPDecoder:
337
366
  count = KESPDecoder._count(rest)
338
367
  if count > CommandParser.MAX_ARRAY_LENGTH:
339
368
  raise ReplyProtocolError("invalid array length")
369
+ if count > _budget[0]:
370
+ # Every element costs at least one; refuse on the header.
371
+ raise ReplyProtocolError("reply has too many elements")
340
372
  items = []
341
373
  for _ in range(count):
342
- item, after = KESPDecoder.decode(buf, after, _depth + 1, lenient)
374
+ item, after = KESPDecoder.decode(
375
+ buf, after, _depth + 1, lenient, _budget
376
+ )
343
377
  items.append(item)
344
378
  return items, after
345
379
 
@@ -388,10 +422,81 @@ class KESPEncoder:
388
422
  """
389
423
  Translates raw Python objects from the router into strict KESP network bytes.
390
424
  """
425
+ # The same whole-reply budgets the decoder enforces, so a reply that
426
+ # leaves the server is one the client will accept.
427
+ budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
391
428
  try:
392
- return KESPEncoder._encode(data, 0, True)
429
+ return KESPEncoder._encode(data, 0, True, budget)
393
430
  except ReplyEncodeError as exc:
394
- return KESPEncoder._error_line(f"Internal server error: {exc}")
431
+ return KESPEncoder.failure_reply(exc)
432
+
433
+ @staticmethod
434
+ def failure_reply(exc) -> bytes:
435
+ """The one error line that stands in for a reply that could not be encoded."""
436
+ return KESPEncoder._error_line(f"Internal server error: {exc}")
437
+
438
+ @staticmethod
439
+ def encode_chunks(data, chunk: int = 512):
440
+ """encode(), in pieces: b"".join(encode_chunks(x)) == encode(x) for every
441
+ x that encode() can encode.
442
+
443
+ A reply with more than `chunk` elements comes out as its header and then
444
+ one piece per `chunk` elements, so a caller can hand control back to the
445
+ event loop between pieces instead of holding it for the whole reply.
446
+ Smaller replies, and replies that are not a flat top-level array or
447
+ dict, come out as a single piece.
448
+
449
+ Unlike encode() this raises ReplyEncodeError when the reply cannot be
450
+ encoded, possibly after some pieces were produced: the caller must hold
451
+ every piece back until the last one is made, then send either all of
452
+ them or failure_reply(exc), so that a failure never leaves half a frame
453
+ on the wire.
454
+ """
455
+ budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
456
+
457
+ if isinstance(data, (list, set, tuple)) and len(data) > chunk:
458
+ # The same checks _encode makes for a top-level array.
459
+ if budget[0] <= 0:
460
+ raise ReplyEncodeError("reply has too many elements")
461
+ budget[0] -= 1
462
+ if len(data) > CommandParser.MAX_ARRAY_LENGTH:
463
+ raise ReplyEncodeError("array too large")
464
+ if len(data) > budget[0]:
465
+ raise ReplyEncodeError("reply has too many elements")
466
+ yield f"A{len(data)}\n".encode("utf-8")
467
+ items = data if isinstance(data, (list, tuple)) else list(data)
468
+ for i in range(0, len(items), chunk):
469
+ yield b"".join(
470
+ [
471
+ KESPEncoder._encode(item, 1, False, budget)
472
+ for item in items[i : i + chunk]
473
+ ]
474
+ )
475
+
476
+ elif isinstance(data, dict) and len(data) > chunk:
477
+ # The same checks _encode makes for a top-level dict (a flat array of
478
+ # alternating keys and values).
479
+ if budget[0] <= 0:
480
+ raise ReplyEncodeError("reply has too many elements")
481
+ budget[0] -= 1
482
+ if len(data) * 2 > CommandParser.MAX_ARRAY_LENGTH:
483
+ raise ReplyEncodeError("array too large")
484
+ if len(data) * 2 > budget[0]:
485
+ raise ReplyEncodeError("reply has too many elements")
486
+ yield f"A{len(data) * 2}\n".encode("utf-8")
487
+ entries = list(data.items())
488
+ for i in range(0, len(entries), chunk):
489
+ parts = []
490
+ for k, v in entries[i : i + chunk]:
491
+ if budget[0] <= 0:
492
+ raise ReplyEncodeError("reply has too many elements")
493
+ budget[0] -= 1 # the key
494
+ parts.append(KESPEncoder._bulk(str(k), budget))
495
+ parts.append(KESPEncoder._encode(v, 1, False, budget))
496
+ yield b"".join(parts)
497
+
498
+ else:
499
+ yield KESPEncoder._encode(data, 0, True, budget)
395
500
 
396
501
  @staticmethod
397
502
  def encode_command(tokens) -> bytes:
@@ -411,22 +516,39 @@ class KESPEncoder:
411
516
  raw = to_bytes(text)
412
517
  except UnicodeEncodeError:
413
518
  raw = text.encode("utf-8", "replace")
519
+ # Errors often echo client input (an unknown command's name, say). A line
520
+ # longer than the decoder's MAX_LINE would be rejected by our own client,
521
+ # so cut it, at a character boundary, and say so.
522
+ limit = KESPDecoder.MAX_LINE - 1 # the 'E' counts towards the line
523
+ if len(raw) > limit:
524
+ cut = limit - 3
525
+ while cut > 0 and raw[cut] & 0xC0 == 0x80:
526
+ cut -= 1 # raw[cut] would be mid-character: step back to its start
527
+ raw = raw[:cut] + b"..."
414
528
  return b"E" + raw + b"\n"
415
529
 
416
530
  @staticmethod
417
- def _bulk(text: str) -> bytes:
531
+ def _bulk(text: str, budget=None) -> bytes:
418
532
  try:
419
533
  raw = to_bytes(text)
420
534
  except UnicodeEncodeError:
421
535
  raise ReplyEncodeError("string is not valid UTF-8") from None
422
536
  if len(raw) > CommandParser.MAX_BULK_LENGTH:
423
537
  raise ReplyEncodeError("string too large")
538
+ if budget is not None:
539
+ if len(raw) > budget[1]:
540
+ raise ReplyEncodeError("reply payload too large")
541
+ budget[1] -= len(raw)
424
542
  return b"S%d\n%s\n" % (len(raw), raw)
425
543
 
426
544
  @staticmethod
427
- def _encode(data, depth: int, top: bool) -> bytes:
545
+ def _encode(data, depth: int, top: bool, budget) -> bytes:
428
546
  if depth > KESPDecoder.MAX_DEPTH:
429
547
  raise ReplyEncodeError("reply nested too deeply")
548
+ # One element per value, containers included (as the decoder counts).
549
+ if budget[0] <= 0:
550
+ raise ReplyEncodeError("reply has too many elements")
551
+ budget[0] -= 1
430
552
 
431
553
  # 1. Null / Missing Data
432
554
  if data is None:
@@ -450,7 +572,7 @@ class KESPEncoder:
450
572
  # 4. Strings & Status Messages
451
573
  if isinstance(data, str):
452
574
  if isinstance(data, BulkString) or not top:
453
- return KESPEncoder._bulk(data)
575
+ return KESPEncoder._bulk(data, budget)
454
576
 
455
577
  if data in KESPEncoder._STATUSES:
456
578
  val = data if data.startswith("+") else f"+{data}"
@@ -462,16 +584,18 @@ class KESPEncoder:
462
584
  return KESPEncoder._error_line(data[len(prefix) + 1 :])
463
585
 
464
586
  # Otherwise, it's a standard Binary-Safe Bulk String
465
- return KESPEncoder._bulk(data)
587
+ return KESPEncoder._bulk(data, budget)
466
588
 
467
589
  # 5. Arrays (Lists/Sets)
468
590
  if isinstance(data, (list, set, tuple)):
469
591
  if len(data) > CommandParser.MAX_ARRAY_LENGTH:
470
592
  raise ReplyEncodeError("array too large")
593
+ if len(data) > budget[0]:
594
+ raise ReplyEncodeError("reply has too many elements")
471
595
  header = f"A{len(data)}\n".encode("utf-8")
472
596
  # Recursively encode every element inside the array
473
597
  elements = b"".join(
474
- KESPEncoder._encode(item, depth + 1, False) for item in data
598
+ KESPEncoder._encode(item, depth + 1, False, budget) for item in data
475
599
  )
476
600
  return header + elements
477
601
 
@@ -480,11 +604,17 @@ class KESPEncoder:
480
604
  # A dictionary of 3 items becomes a KESP array of 6 items [k1, v1, k2, v2...]
481
605
  if len(data) * 2 > CommandParser.MAX_ARRAY_LENGTH:
482
606
  raise ReplyEncodeError("array too large")
607
+ if len(data) * 2 > budget[0]:
608
+ raise ReplyEncodeError("reply has too many elements")
483
609
  header = f"A{len(data) * 2}\n".encode("utf-8")
484
- elements = b"".join(
485
- KESPEncoder._bulk(str(k)) + KESPEncoder._encode(v, depth + 1, False)
486
- for k, v in data.items()
487
- )
610
+ parts = []
611
+ for k, v in data.items():
612
+ if budget[0] <= 0:
613
+ raise ReplyEncodeError("reply has too many elements")
614
+ budget[0] -= 1 # the key
615
+ parts.append(KESPEncoder._bulk(str(k), budget))
616
+ parts.append(KESPEncoder._encode(v, depth + 1, False, budget))
617
+ elements = b"".join(parts)
488
618
  return header + elements
489
619
 
490
620
  # Fallback