json-repair 0.29.2__py3-none-any.whl → 0.29.3__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,69 @@
1
+ from enum import Enum, auto
2
+ from typing import List
3
+
4
+
5
+ class ContextValues(Enum):
6
+ OBJECT_KEY = auto()
7
+ OBJECT_VALUE = auto()
8
+ ARRAY = auto()
9
+
10
+
11
+ class JsonContext:
12
+ def __init__(self) -> None:
13
+ self.context: List[ContextValues] = []
14
+
15
+ def set(self, value: ContextValues) -> None:
16
+ """
17
+ Set a new context value.
18
+
19
+ Args:
20
+ value (ContextValues): The context value to be added.
21
+
22
+ Returns:
23
+ None
24
+ """
25
+ # If a value is provided update the context variable and save in stack
26
+ if value:
27
+ self.context.append(value)
28
+
29
+ def reset(self) -> None:
30
+ """
31
+ Remove the most recent context value.
32
+
33
+ Returns:
34
+ None
35
+ """
36
+ self.context.pop()
37
+
38
+ def is_current(self, context: ContextValues) -> bool:
39
+ """
40
+ Check if the given context is the current (most recent) context.
41
+
42
+ Args:
43
+ context (ContextValues): The context value to check.
44
+
45
+ Returns:
46
+ bool: True if the given context is the same as the most recent context in the stack, False otherwise.
47
+ """
48
+ return self.context[-1] == context
49
+
50
+ def is_any(self, context: ContextValues) -> bool:
51
+ """
52
+ Check if the given context exists anywhere in the context stack.
53
+
54
+ Args:
55
+ context (ContextValues): The context value to check.
56
+
57
+ Returns:
58
+ bool: True if the given context exists in the stack, False otherwise.
59
+ """
60
+ return context in self.context
61
+
62
+ def is_empty(self) -> bool:
63
+ """
64
+ Check if the context stack is empty.
65
+
66
+ Returns:
67
+ bool: True if the context stack is empty, False otherwise.
68
+ """
69
+ return len(self.context) == 0
@@ -0,0 +1,598 @@
1
+ from typing import Any, Dict, List, Optional, Union, TextIO, Tuple, Literal
2
+
3
+ from .string_file_wrapper import StringFileWrapper
4
+ from .json_context import JsonContext, ContextValues
5
+
6
+ JSONReturnType = Union[Dict[str, Any], List[Any], str, float, int, bool, None]
7
+
8
+
9
+ class JSONParser:
10
+ def __init__(
11
+ self,
12
+ json_str: Union[str, StringFileWrapper],
13
+ json_fd: Optional[TextIO],
14
+ logging: Optional[bool],
15
+ json_fd_chunk_length: int = 0,
16
+ ) -> None:
17
+ # The string to parse
18
+ self.json_str: Union[str, StringFileWrapper] = json_str
19
+ # Alternatively, the file description with a json file in it
20
+ if json_fd:
21
+ # This is a trick we do to treat the file wrapper as an array
22
+ self.json_str = StringFileWrapper(json_fd, json_fd_chunk_length)
23
+ # Index is our iterator that will keep track of which character we are looking at right now
24
+ self.index: int = 0
25
+ # This is used in the object member parsing to manage the special cases of missing quotes in key or value
26
+ self.context = JsonContext()
27
+ # Use this to log the activity, but only if logging is active
28
+
29
+ # This is a trick but a beatiful one. We call self.log in the code over and over even if it's not needed.
30
+ # We could add a guard in the code for each call but that would make this code unreadable, so here's this neat trick
31
+ # Replace self.log with a noop
32
+ self.logging = logging
33
+ if logging:
34
+ self.logger: List[Dict[str, str]] = []
35
+ self.log = self._log
36
+ else:
37
+ self.log = self.noop
38
+
39
+ def parse(
40
+ self,
41
+ ) -> Union[JSONReturnType, Tuple[JSONReturnType, List[Dict[str, str]]]]:
42
+ json = self.parse_json()
43
+ if self.index < len(self.json_str):
44
+ self.log(
45
+ "The parser returned early, checking if there's more json elements",
46
+ )
47
+ json = [json]
48
+ last_index = self.index
49
+ while self.index < len(self.json_str):
50
+ j = self.parse_json()
51
+ if j != "":
52
+ json.append(j)
53
+ if self.index == last_index:
54
+ self.index += 1
55
+ last_index = self.index
56
+ # If nothing extra was found, don't return an array
57
+ if len(json) == 1:
58
+ self.log(
59
+ "There were no more elements, returning the element without the array",
60
+ )
61
+ json = json[0]
62
+ if self.logging:
63
+ return json, self.logger
64
+ else:
65
+ return json
66
+
67
+ def parse_json(
68
+ self,
69
+ ) -> JSONReturnType:
70
+ while True:
71
+ char = self.get_char_at()
72
+ # False means that we are at the end of the string provided
73
+ if char is False:
74
+ return ""
75
+ # <object> starts with '{'
76
+ elif char == "{":
77
+ self.index += 1
78
+ return self.parse_object()
79
+ # <array> starts with '['
80
+ elif char == "[":
81
+ self.index += 1
82
+ return self.parse_array()
83
+ # there can be an edge case in which a key is empty and at the end of an object
84
+ # like "key": }. We return an empty string here to close the object properly
85
+ elif char == "}":
86
+ self.log(
87
+ "At the end of an object we found a key with missing value, skipping",
88
+ )
89
+ return ""
90
+ # <string> starts with a quote
91
+ elif not self.context.is_empty() and (
92
+ char in ['"', "'", "“"] or char.isalpha()
93
+ ):
94
+ return self.parse_string()
95
+ # <number> starts with [0-9] or minus
96
+ elif not self.context.is_empty() and (
97
+ char.isdigit() or char == "-" or char == "."
98
+ ):
99
+ return self.parse_number()
100
+ # If everything else fails, we just ignore and move on
101
+ else:
102
+ self.index += 1
103
+
104
+ def parse_object(self) -> Dict[str, JSONReturnType]:
105
+ # <object> ::= '{' [ <member> *(', ' <member>) ] '}' ; A sequence of 'members'
106
+ obj = {}
107
+ # Stop when you either find the closing parentheses or you have iterated over the entire string
108
+ while (self.get_char_at() or "}") != "}":
109
+ # This is what we expect to find:
110
+ # <member> ::= <string> ': ' <json>
111
+
112
+ # Skip filler whitespaces
113
+ self.skip_whitespaces_at()
114
+
115
+ # Sometimes LLMs do weird things, if we find a ":" so early, we'll change it to "," and move on
116
+ if (self.get_char_at() or "") == ":":
117
+ self.log(
118
+ "While parsing an object we found a : before a key, ignoring",
119
+ )
120
+ self.index += 1
121
+
122
+ # We are now searching for they string key
123
+ # Context is used in the string parser to manage the lack of quotes
124
+ self.context.set(ContextValues.OBJECT_KEY)
125
+
126
+ self.skip_whitespaces_at()
127
+
128
+ # <member> starts with a <string>
129
+ key = ""
130
+ while self.get_char_at():
131
+ key = str(self.parse_string())
132
+
133
+ if key != "" or (key == "" and self.get_char_at() == ":"):
134
+ # If the string is empty but there is a object divider, we are done here
135
+ break
136
+
137
+ self.skip_whitespaces_at()
138
+
139
+ # We reached the end here
140
+ if (self.get_char_at() or "}") == "}":
141
+ continue
142
+
143
+ self.skip_whitespaces_at()
144
+
145
+ # An extreme case of missing ":" after a key
146
+ if (self.get_char_at() or "") != ":":
147
+ self.log(
148
+ "While parsing an object we missed a : after a key",
149
+ )
150
+
151
+ self.index += 1
152
+ self.context.reset()
153
+ self.context.set(ContextValues.OBJECT_VALUE)
154
+ # The value can be any valid json
155
+ value = self.parse_json()
156
+
157
+ # Reset context since our job is done
158
+ self.context.reset()
159
+ obj[key] = value
160
+
161
+ if (self.get_char_at() or "") in [",", "'", '"']:
162
+ self.index += 1
163
+
164
+ # Remove trailing spaces
165
+ self.skip_whitespaces_at()
166
+
167
+ self.index += 1
168
+ return obj
169
+
170
+ def parse_array(self) -> List[JSONReturnType]:
171
+ # <array> ::= '[' [ <json> *(', ' <json>) ] ']' ; A sequence of JSON values separated by commas
172
+ arr = []
173
+ self.context.set(ContextValues.ARRAY)
174
+ # Stop when you either find the closing parentheses or you have iterated over the entire string
175
+ while (self.get_char_at() or "]") != "]":
176
+ self.skip_whitespaces_at()
177
+ value = self.parse_json()
178
+
179
+ # It is possible that parse_json() returns nothing valid, so we stop
180
+ if value == "":
181
+ break
182
+
183
+ if value == "..." and self.get_char_at(-1) == ".":
184
+ self.log(
185
+ "While parsing an array, found a stray '...'; ignoring it",
186
+ )
187
+ else:
188
+ arr.append(value)
189
+
190
+ # skip over whitespace after a value but before closing ]
191
+ char = self.get_char_at()
192
+ while char and (char.isspace() or char == ","):
193
+ self.index += 1
194
+ char = self.get_char_at()
195
+
196
+ # Especially at the end of an LLM generated json you might miss the last "]"
197
+ char = self.get_char_at()
198
+ if char and char != "]":
199
+ self.log(
200
+ "While parsing an array we missed the closing ], adding it back",
201
+ )
202
+ self.index -= 1
203
+
204
+ self.index += 1
205
+ self.context.reset()
206
+ return arr
207
+
208
+ def parse_string(self) -> Union[str, bool, None]:
209
+ # <string> is a string of valid characters enclosed in quotes
210
+ # i.e. { name: "John" }
211
+ # Somehow all weird cases in an invalid JSON happen to be resolved in this function, so be careful here
212
+
213
+ # Flag to manage corner cases related to missing starting quote
214
+ missing_quotes = False
215
+ doubled_quotes = False
216
+ lstring_delimiter = rstring_delimiter = '"'
217
+
218
+ char = self.get_char_at()
219
+ # A valid string can only start with a valid quote or, in our case, with a literal
220
+ while char and char not in ['"', "'", "“"] and not char.isalnum():
221
+ self.index += 1
222
+ char = self.get_char_at()
223
+
224
+ if not char:
225
+ # This is an empty string
226
+ return ""
227
+
228
+ # Ensuring we use the right delimiter
229
+ if char == "'":
230
+ lstring_delimiter = rstring_delimiter = "'"
231
+ elif char == "“":
232
+ lstring_delimiter = "“"
233
+ rstring_delimiter = "”"
234
+ elif char.isalnum():
235
+ # This could be a <boolean> and not a string. Because (T)rue or (F)alse or (N)ull are valid
236
+ # But remember, object keys are only of type string
237
+ if char.lower() in ["t", "f", "n"] and not self.context.is_current(
238
+ ContextValues.OBJECT_KEY
239
+ ):
240
+ value = self.parse_boolean_or_null()
241
+ if value != "":
242
+ return value
243
+ self.log(
244
+ "While parsing a string, we found a literal instead of a quote",
245
+ )
246
+ self.log(
247
+ "While parsing a string, we found no starting quote. Will add the quote back",
248
+ )
249
+ missing_quotes = True
250
+
251
+ if not missing_quotes:
252
+ self.index += 1
253
+
254
+ # There is sometimes a weird case of doubled quotes, we manage this also later in the while loop
255
+ if self.get_char_at() == lstring_delimiter:
256
+ # If it's an empty key, this was easy
257
+ if (
258
+ self.context.is_current(ContextValues.OBJECT_KEY)
259
+ and self.get_char_at(1) == ":"
260
+ ):
261
+ self.index += 1
262
+ return ""
263
+ # Find the next delimiter
264
+ i = self.skip_to_character(
265
+ character=rstring_delimiter, idx=1, move_main_index=False
266
+ )
267
+ next_c = self.get_char_at(i)
268
+ # Now check that the next character is also a delimiter to ensure that we have "".....""
269
+ # In that case we ignore this rstring delimiter
270
+ if next_c and (self.get_char_at(i + 1) or "") == rstring_delimiter:
271
+ self.log(
272
+ "While parsing a string, we found a valid starting doubled quote, ignoring it",
273
+ )
274
+ doubled_quotes = True
275
+ self.index += 1
276
+ else:
277
+ # Ok this is not a doubled quote, check if this is an empty string or not
278
+ i = self.skip_whitespaces_at(idx=1, move_main_index=False)
279
+ next_c = self.get_char_at(i)
280
+ if next_c not in [",", "]", "}"]:
281
+ self.log(
282
+ "While parsing a string, we found a doubled quote but it was a mistake, removing one quote",
283
+ )
284
+ self.index += 1
285
+
286
+ # Initialize our return value
287
+ string_acc = ""
288
+
289
+ # Here things get a bit hairy because a string missing the final quote can also be a key or a value in an object
290
+ # In that case we need to use the ":|,|}" characters as terminators of the string
291
+ # So this will stop if:
292
+ # * It finds a closing quote
293
+ # * It iterated over the entire sequence
294
+ # * If we are fixing missing quotes in an object, when it finds the special terminators
295
+ char = self.get_char_at()
296
+ while char and char != rstring_delimiter:
297
+ if (
298
+ missing_quotes
299
+ and self.context.is_current(ContextValues.OBJECT_KEY)
300
+ and (char == ":" or char.isspace())
301
+ ):
302
+ self.log(
303
+ "While parsing a string missing the left delimiter in object key context, we found a :, stopping here",
304
+ )
305
+ break
306
+ if self.context.is_current(ContextValues.OBJECT_VALUE) and char in [
307
+ ",",
308
+ "}",
309
+ ]:
310
+ rstring_delimiter_missing = True
311
+ # check if this is a case in which the closing comma is NOT missing instead
312
+ i = self.skip_to_character(
313
+ character=rstring_delimiter, idx=1, move_main_index=False
314
+ )
315
+ next_c = self.get_char_at(i)
316
+ if next_c:
317
+ i += 1
318
+ # found a delimiter, now we need to check that is followed strictly by a comma or brace
319
+ i = self.skip_whitespaces_at(idx=i, move_main_index=False)
320
+ next_c = self.get_char_at(i)
321
+ if next_c and next_c in [",", "}"]:
322
+ rstring_delimiter_missing = False
323
+ if rstring_delimiter_missing:
324
+ self.log(
325
+ "While parsing a string missing the left delimiter in object value context, we found a , or } and we couldn't determine that a right delimiter was present. Stopping here",
326
+ )
327
+ break
328
+ string_acc += char
329
+ self.index += 1
330
+ char = self.get_char_at()
331
+ if char and len(string_acc) > 0 and string_acc[-1] == "\\":
332
+ # This is a special case, if people use real strings this might happen
333
+ self.log("Found a stray escape sequence, normalizing it")
334
+ string_acc = string_acc[:-1]
335
+ if char in [rstring_delimiter, "t", "n", "r", "b", "\\"]:
336
+ escape_seqs = {"t": "\t", "n": "\n", "r": "\r", "b": "\b"}
337
+ string_acc += escape_seqs.get(char, char) or char
338
+ self.index += 1
339
+ char = self.get_char_at()
340
+ # ChatGPT sometimes forget to quote stuff in html tags or markdown, so we do this whole thing here
341
+ if char == rstring_delimiter:
342
+ # Special case here, in case of double quotes one after another
343
+ if doubled_quotes and self.get_char_at(1) == rstring_delimiter:
344
+ self.log(
345
+ "While parsing a string, we found a doubled quote, ignoring it"
346
+ )
347
+ self.index += 1
348
+ elif missing_quotes and self.context.is_current(
349
+ ContextValues.OBJECT_VALUE
350
+ ):
351
+ # In case of missing starting quote I need to check if the delimeter is the end or the beginning of a key
352
+ i = 1
353
+ next_c = self.get_char_at(i)
354
+ while next_c and next_c not in [
355
+ rstring_delimiter,
356
+ lstring_delimiter,
357
+ ]:
358
+ i += 1
359
+ next_c = self.get_char_at(i)
360
+ if next_c:
361
+ # We found a quote, now let's make sure there's a ":" following
362
+ i += 1
363
+ # found a delimiter, now we need to check that is followed strictly by a comma or brace
364
+ i = self.skip_whitespaces_at(idx=i, move_main_index=False)
365
+ next_c = self.get_char_at(i)
366
+ if next_c and next_c == ":":
367
+ # Reset the cursor
368
+ self.index -= 1
369
+ char = self.get_char_at()
370
+ self.log(
371
+ "In a string with missing quotes and object value context, I found a delimeter but it turns out it was the beginning on the next key. Stopping here.",
372
+ )
373
+ break
374
+ else:
375
+ # Check if eventually there is a rstring delimiter, otherwise we bail
376
+ i = 1
377
+ next_c = self.get_char_at(i)
378
+ check_comma_in_object_value = True
379
+ while next_c and next_c not in [
380
+ rstring_delimiter,
381
+ lstring_delimiter,
382
+ ]:
383
+ # This is a bit of a weird workaround, essentially in object_value context we don't always break on commas
384
+ # This is because the routine after will make sure to correct any bad guess and this solves a corner case
385
+ if check_comma_in_object_value and next_c.isalpha():
386
+ check_comma_in_object_value = False
387
+ # If we are in an object context, let's check for the right delimiters
388
+ if (
389
+ (
390
+ self.context.is_any(ContextValues.OBJECT_KEY)
391
+ and next_c in [":", "}"]
392
+ )
393
+ or (
394
+ self.context.is_any(ContextValues.OBJECT_VALUE)
395
+ and next_c == "}"
396
+ )
397
+ or (
398
+ self.context.is_any(ContextValues.ARRAY)
399
+ and next_c in ["]", ","]
400
+ )
401
+ or (
402
+ check_comma_in_object_value
403
+ and self.context.is_current(ContextValues.OBJECT_VALUE)
404
+ and next_c == ","
405
+ )
406
+ ):
407
+ break
408
+ i += 1
409
+ next_c = self.get_char_at(i)
410
+ # If we stopped for a comma in object_value context, let's check if find a "} at the end of the string
411
+ if next_c == "," and self.context.is_current(
412
+ ContextValues.OBJECT_VALUE
413
+ ):
414
+ i += 1
415
+ i = self.skip_to_character(
416
+ character=rstring_delimiter, idx=i, move_main_index=False
417
+ )
418
+ next_c = self.get_char_at(i)
419
+ # Ok now I found a delimiter, let's skip whitespaces and see if next we find a }
420
+ i += 1
421
+ i = self.skip_whitespaces_at(idx=i, move_main_index=False)
422
+ next_c = self.get_char_at(i)
423
+ if next_c == "}":
424
+ # OK this is valid then
425
+ self.log(
426
+ "While parsing a string, we misplaced a quote that would have closed the string but has a different meaning here since this is the last element of the object, ignoring it",
427
+ )
428
+ string_acc += str(char)
429
+ self.index += 1
430
+ char = self.get_char_at()
431
+ elif next_c == rstring_delimiter:
432
+ if self.context.is_current(ContextValues.OBJECT_VALUE):
433
+ # But this might not be it! This could be just a missing comma
434
+ # We found a delimiter and we need to check if this is a key
435
+ # so find a rstring_delimiter and a colon after
436
+ i += 1
437
+ i = self.skip_to_character(
438
+ character=rstring_delimiter,
439
+ idx=i,
440
+ move_main_index=False,
441
+ )
442
+ i += 1
443
+ next_c = self.get_char_at(i)
444
+ while next_c and next_c != ":":
445
+ if next_c in [
446
+ lstring_delimiter,
447
+ rstring_delimiter,
448
+ ",",
449
+ ]:
450
+ break
451
+ i += 1
452
+ next_c = self.get_char_at(i)
453
+ # Only if we fail to find a ':' then we know this is misplaced quote
454
+ if next_c != ":":
455
+ self.log(
456
+ "While parsing a string, we a misplaced quote that would have closed the string but has a different meaning here, ignoring it",
457
+ )
458
+ string_acc += str(char)
459
+ self.index += 1
460
+ char = self.get_char_at()
461
+
462
+ if (
463
+ char
464
+ and missing_quotes
465
+ and self.context.is_current(ContextValues.OBJECT_KEY)
466
+ and char.isspace()
467
+ ):
468
+ self.log(
469
+ "While parsing a string, handling an extreme corner case in which the LLM added a comment instead of valid string, invalidate the string and return an empty value",
470
+ )
471
+ self.skip_whitespaces_at()
472
+ if self.get_char_at() not in [":", ","]:
473
+ return ""
474
+
475
+ # A fallout of the previous special case in the while loop,
476
+ # we need to update the index only if we had a closing quote
477
+ if char != rstring_delimiter:
478
+ self.log(
479
+ "While parsing a string, we missed the closing quote, ignoring",
480
+ )
481
+ else:
482
+ self.index += 1
483
+
484
+ return string_acc.rstrip()
485
+
486
+ def parse_number(self) -> Union[float, int, str, JSONReturnType]:
487
+ # <number> is a valid real number expressed in one of a number of given formats
488
+ number_str = ""
489
+ number_chars = set("0123456789-.eE/,")
490
+ char = self.get_char_at()
491
+ is_array = self.context.is_current(ContextValues.ARRAY)
492
+ while char and char in number_chars and (char != "," or not is_array):
493
+ number_str += char
494
+ self.index += 1
495
+ char = self.get_char_at()
496
+ if len(number_str) > 1 and number_str[-1] in "-eE/,":
497
+ # The number ends with a non valid character for a number/currency, rolling back one
498
+ number_str = number_str[:-1]
499
+ self.index -= 1
500
+ try:
501
+ if "," in number_str:
502
+ return str(number_str)
503
+ if "." in number_str or "e" in number_str or "E" in number_str:
504
+ return float(number_str)
505
+ elif number_str == "-":
506
+ # If there is a stray "-" this will throw an exception, throw away this character
507
+ return self.parse_json()
508
+ else:
509
+ return int(number_str)
510
+ except ValueError:
511
+ return number_str
512
+
513
+ def parse_boolean_or_null(self) -> Union[bool, str, None]:
514
+ # <boolean> is one of the literal strings 'true', 'false', or 'null' (unquoted)
515
+ starting_index = self.index
516
+ char = (self.get_char_at() or "").lower()
517
+ value: Optional[Tuple[str, Optional[bool]]]
518
+ if char == "t":
519
+ value = ("true", True)
520
+ elif char == "f":
521
+ value = ("false", False)
522
+ elif char == "n":
523
+ value = ("null", None)
524
+
525
+ if value:
526
+ i = 0
527
+ while char and i < len(value[0]) and char == value[0][i]:
528
+ i += 1
529
+ self.index += 1
530
+ char = (self.get_char_at() or "").lower()
531
+ if i == len(value[0]):
532
+ return value[1]
533
+
534
+ # If nothing works reset the index before returning
535
+ self.index = starting_index
536
+ return ""
537
+
538
+ def get_char_at(self, count: int = 0) -> Union[str, Literal[False]]:
539
+ # Why not use something simpler? Because try/except in python is a faster alternative to an "if" statement that is often True
540
+ try:
541
+ return self.json_str[self.index + count]
542
+ except IndexError:
543
+ return False
544
+
545
+ def skip_whitespaces_at(self, idx: int = 0, move_main_index=True) -> int:
546
+ """
547
+ This function quickly iterates on whitespaces, syntactic sugar to make the code more concise
548
+ """
549
+ try:
550
+ char = self.json_str[self.index + idx]
551
+ except IndexError:
552
+ return idx
553
+ while char.isspace():
554
+ if move_main_index:
555
+ self.index += 1
556
+ else:
557
+ idx += 1
558
+ try:
559
+ char = self.json_str[self.index + idx]
560
+ except IndexError:
561
+ return idx
562
+ return idx
563
+
564
+ def skip_to_character(
565
+ self, character: str, idx: int = 0, move_main_index=True
566
+ ) -> int:
567
+ """
568
+ This function quickly iterates to find a character, syntactic sugar to make the code more concise
569
+ """
570
+ try:
571
+ char = self.json_str[self.index + idx]
572
+ except IndexError:
573
+ return idx
574
+ while char != character:
575
+ if move_main_index: # pragma: no cover
576
+ self.index += 1
577
+ else:
578
+ idx += 1
579
+ try:
580
+ char = self.json_str[self.index + idx]
581
+ except IndexError:
582
+ return idx
583
+ return idx
584
+
585
+ def _log(self, text: str) -> None:
586
+ window: int = 10
587
+ start: int = max(self.index - window, 0)
588
+ end: int = min(self.index + window, len(self.json_str))
589
+ context: str = self.json_str[start:end]
590
+ self.logger.append(
591
+ {
592
+ "text": text,
593
+ "context": context,
594
+ }
595
+ )
596
+
597
+ def noop(*args: Any, **kwargs: Any) -> None:
598
+ pass