codex-agent-framework 0.1.1__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.
- codex_agent/__init__.py +38 -0
- codex_agent/__main__.py +18 -0
- codex_agent/agent.py +1179 -0
- codex_agent/ai.py +494 -0
- codex_agent/builtin_commands.py +136 -0
- codex_agent/builtin_providers.py +42 -0
- codex_agent/builtin_tools.py +418 -0
- codex_agent/chat.py +139 -0
- codex_agent/command.py +19 -0
- codex_agent/event.py +136 -0
- codex_agent/get_text/__init__.py +20 -0
- codex_agent/get_text/default_gitignore +110 -0
- codex_agent/get_text/get_text.py +1240 -0
- codex_agent/get_text/simpler_get_text.py +184 -0
- codex_agent/get_webdriver.py +44 -0
- codex_agent/image.py +304 -0
- codex_agent/latex.py +165 -0
- codex_agent/memory.py +318 -0
- codex_agent/message.py +423 -0
- codex_agent/prompts/image_generation_system_prompt.txt +13 -0
- codex_agent/prompts/system_prompt.txt +88 -0
- codex_agent/provider.py +19 -0
- codex_agent/stream_utils.py +645 -0
- codex_agent/tool.py +235 -0
- codex_agent/utils.py +374 -0
- codex_agent/voice.py +353 -0
- codex_agent/worker.py +190 -0
- codex_agent_framework-0.1.1.dist-info/METADATA +361 -0
- codex_agent_framework-0.1.1.dist-info/RECORD +33 -0
- codex_agent_framework-0.1.1.dist-info/WHEEL +5 -0
- codex_agent_framework-0.1.1.dist-info/entry_points.txt +2 -0
- codex_agent_framework-0.1.1.dist-info/licenses/LICENSE +21 -0
- codex_agent_framework-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
from queue import Queue
|
|
2
|
+
from inspect import isgenerator
|
|
3
|
+
from threading import ThreadError
|
|
4
|
+
import time
|
|
5
|
+
import regex # Third-party regex module
|
|
6
|
+
import re
|
|
7
|
+
from typing import Generator, List, Iterable, Callable, Tuple, Union
|
|
8
|
+
from .utils import tokenize, Thread
|
|
9
|
+
from textwrap import dedent
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
def log(*data):
|
|
13
|
+
if False:
|
|
14
|
+
print(*data)
|
|
15
|
+
input()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class END:
|
|
19
|
+
#sentinel value for end of stream
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
class Task:
|
|
23
|
+
|
|
24
|
+
def __init__(self,target=None,args=None,kwargs=None):
|
|
25
|
+
self.target_task=target or self.target
|
|
26
|
+
self.args=args if args is not None else ()
|
|
27
|
+
self.kwargs=kwargs if kwargs is not None else {}
|
|
28
|
+
self.thread=None
|
|
29
|
+
|
|
30
|
+
def target(self,*args,**kwargs):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def start(self):
|
|
34
|
+
self.thread=Thread(target=self.target_task,args=self.args,kwargs=self.kwargs)
|
|
35
|
+
self.thread.start()
|
|
36
|
+
|
|
37
|
+
def join(self):
|
|
38
|
+
self.thread.join()
|
|
39
|
+
|
|
40
|
+
class Tee:
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.queues={}
|
|
44
|
+
self.thread=None
|
|
45
|
+
|
|
46
|
+
def target(self,stream):
|
|
47
|
+
for chunk in stream:
|
|
48
|
+
for k in self.queues:
|
|
49
|
+
self.queues[k].put(chunk)
|
|
50
|
+
time.sleep(0.0005)
|
|
51
|
+
for k in self.queues:
|
|
52
|
+
self.queues[k].put(END)
|
|
53
|
+
|
|
54
|
+
def tee(self,stream, n=2):
|
|
55
|
+
self.queues={k:Queue() for k in range(n)}
|
|
56
|
+
self.thread=Thread(target=self.target, args=(stream,),kwargs=dict(n=n))
|
|
57
|
+
self.thread.start()
|
|
58
|
+
readers={}
|
|
59
|
+
for k in self.queues:
|
|
60
|
+
def reader():
|
|
61
|
+
while not (processed_chunk:=self.queues[k].get()) is END:
|
|
62
|
+
yield processed_chunk
|
|
63
|
+
readers[k]=reader()
|
|
64
|
+
return tuple(readers.values())
|
|
65
|
+
|
|
66
|
+
class Splitter:
|
|
67
|
+
|
|
68
|
+
def __init__(self,condition=None):
|
|
69
|
+
self.queue_true=None
|
|
70
|
+
self.queue_false=None
|
|
71
|
+
self.condition=condition or self.split_condition
|
|
72
|
+
self.thread=None
|
|
73
|
+
|
|
74
|
+
def split_condition(self,chunk):
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
def target(self,stream):
|
|
78
|
+
for chunk in stream:
|
|
79
|
+
if self.condition(chunk):
|
|
80
|
+
self.queue_true.put(chunk)
|
|
81
|
+
else:
|
|
82
|
+
self.queue_false.put(chunk)
|
|
83
|
+
self.queue_true.put(END)
|
|
84
|
+
self.queue_false.put(END)
|
|
85
|
+
|
|
86
|
+
def split(self, stream):
|
|
87
|
+
self.queue_true=Queue()
|
|
88
|
+
self.queue_false=Queue()
|
|
89
|
+
self.thread=Thread(target=self.target, args=(stream,))
|
|
90
|
+
self.thread.start()
|
|
91
|
+
def reader_true():
|
|
92
|
+
while not (chunk:=self.queue_true.get()) is END:
|
|
93
|
+
yield chunk
|
|
94
|
+
def reader_false():
|
|
95
|
+
while not (chunk:=self.queue_false.get()) is END:
|
|
96
|
+
yield chunk
|
|
97
|
+
return reader_true(),reader_false()
|
|
98
|
+
|
|
99
|
+
class Streamer:
|
|
100
|
+
|
|
101
|
+
def __init__(self,stream_processor=None,threaded=True):
|
|
102
|
+
self.queue=None
|
|
103
|
+
self.threaded=threaded
|
|
104
|
+
self.process_stream=stream_processor or self.stream_processor
|
|
105
|
+
|
|
106
|
+
def stream_processor(self,stream):
|
|
107
|
+
"""
|
|
108
|
+
Default stream processor : Just yields the stream, does nothing else
|
|
109
|
+
Can be overriden in subclasses or bypassed by providing a stream_processor to the instance
|
|
110
|
+
"""
|
|
111
|
+
return stream
|
|
112
|
+
|
|
113
|
+
def target(self,stream):
|
|
114
|
+
for processed_chunk in self.process_stream(stream):
|
|
115
|
+
time.sleep(0.0005)
|
|
116
|
+
self.queue.put(processed_chunk)
|
|
117
|
+
self.queue.put(END)
|
|
118
|
+
|
|
119
|
+
def process(self,stream):
|
|
120
|
+
if self.threaded:
|
|
121
|
+
self.queue=Queue()
|
|
122
|
+
self.thread=Thread(target=self.target, args=(stream,))
|
|
123
|
+
self.thread.daemon=True
|
|
124
|
+
self.thread.start()
|
|
125
|
+
def reader():
|
|
126
|
+
while not (processed_chunk:=self.queue.get()) is END:
|
|
127
|
+
yield processed_chunk
|
|
128
|
+
return reader()
|
|
129
|
+
else:
|
|
130
|
+
return self.process_stream(stream)
|
|
131
|
+
|
|
132
|
+
def __call__(self, stream):
|
|
133
|
+
return self.process(stream)
|
|
134
|
+
|
|
135
|
+
class StreamLogger(Streamer):
|
|
136
|
+
|
|
137
|
+
def __init__(self,threaded=True,file=None):
|
|
138
|
+
super().__init__(threaded=threaded)
|
|
139
|
+
self.file=file
|
|
140
|
+
|
|
141
|
+
def stream_processor(self, stream):
|
|
142
|
+
output=""
|
|
143
|
+
for token in stream:
|
|
144
|
+
if token:
|
|
145
|
+
output += token
|
|
146
|
+
yield token
|
|
147
|
+
if self.file is not None:
|
|
148
|
+
import os
|
|
149
|
+
if not os.path.isfile(self.file):
|
|
150
|
+
open(self.file,'w', encoding='utf-8').close()
|
|
151
|
+
with open(self.file,'a', encoding='utf-8') as f:
|
|
152
|
+
f.write(output)
|
|
153
|
+
|
|
154
|
+
def fenced(start: Union[str,regex.Pattern,re.Pattern], end: Union[str,regex.Pattern,re.Pattern], flags=0) -> regex.Pattern:
|
|
155
|
+
r"""
|
|
156
|
+
Generates a regex pattern that matches text enclosed between a start and an end delimiter,
|
|
157
|
+
The inner part is captured as the 'content' group (default).
|
|
158
|
+
There can be other captured groups defined in the start and end patterns.
|
|
159
|
+
The end delimiter can include backreferences to captured groups from the opening fence into placeholders in the closing fence.
|
|
160
|
+
|
|
161
|
+
The closing fence pattern may include placeholders in the form of "#1#" for the first captured group,
|
|
162
|
+
"#2#" for the second, or "#name#" for a named capturing group; these will be replaced by the appropriate
|
|
163
|
+
backreference. This mechanism is completely generic and does not enforce any specific delimiter shapes.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
# Using a numeric placeholder:
|
|
167
|
+
pattern = fenced(r"START (\w+)", r"END #1#", flags=regex.DOTALL)
|
|
168
|
+
text = "START content END content"
|
|
169
|
+
match = pattern.match(text)
|
|
170
|
+
if match:
|
|
171
|
+
print(match.group())
|
|
172
|
+
|
|
173
|
+
# Using a named capturing group:
|
|
174
|
+
pattern = fenced(r"BEGIN (?P<tag>\w+)", r"FIN #tag#", flags=regex.DOTALL)
|
|
175
|
+
text = "BEGIN example FIN example"
|
|
176
|
+
match = pattern.match(text)
|
|
177
|
+
if match:
|
|
178
|
+
print(match.group())
|
|
179
|
+
|
|
180
|
+
:param start: Opening delimiter (str or regex.Pattern). May contain capturing groups.
|
|
181
|
+
:param end: Closing delimiter (str or regex.Pattern) which may include placeholders (e.g., "#1#", "#name#").
|
|
182
|
+
:param flags: Regex flags to modify behavior (e.g., regex.DOTALL, regex.MULTILINE).
|
|
183
|
+
:return: A compiled regex pattern.
|
|
184
|
+
"""
|
|
185
|
+
def prepare_pattern(pattern):
|
|
186
|
+
if isinstance(pattern, (regex.Pattern,re.Pattern)):
|
|
187
|
+
return pattern.pattern
|
|
188
|
+
if isinstance(pattern, str):
|
|
189
|
+
return pattern
|
|
190
|
+
raise ValueError("`start` and `end` must be either a string or a regex Pattern object")
|
|
191
|
+
|
|
192
|
+
start_regex = prepare_pattern(start)
|
|
193
|
+
end_regex = prepare_pattern(end)
|
|
194
|
+
|
|
195
|
+
# Replace placeholders in end pattern with regex backrefs
|
|
196
|
+
def replace_placeholder(match):
|
|
197
|
+
key = match.group(1)
|
|
198
|
+
if key.isdigit():
|
|
199
|
+
return rf"\{key}"
|
|
200
|
+
else:
|
|
201
|
+
return rf"(?P={key})"
|
|
202
|
+
end_regex = regex.sub(r"#(\w+)#", replace_placeholder, end_regex)
|
|
203
|
+
|
|
204
|
+
# Named content group
|
|
205
|
+
inner = rf'(?P<content>(?:(?!{end_regex})[\s\S])*)'
|
|
206
|
+
|
|
207
|
+
full_pattern = rf'{start_regex}{inner}(?:{end_regex}|$)'
|
|
208
|
+
return regex.compile(full_pattern, flags)
|
|
209
|
+
|
|
210
|
+
def is_compiled_pattern(pattern):
|
|
211
|
+
if isinstance(pattern, (re.Pattern,regex.Pattern)):
|
|
212
|
+
return True
|
|
213
|
+
elif isinstance(pattern,str):
|
|
214
|
+
return False
|
|
215
|
+
else:
|
|
216
|
+
raise ValueError(f"Expected either a string, re.Pattern or regex.Pattern object. got {type(pattern)}")
|
|
217
|
+
|
|
218
|
+
def is_re_pattern(pattern):
|
|
219
|
+
return isinstance(pattern,re.Pattern)
|
|
220
|
+
|
|
221
|
+
class TokenStreamer(Streamer):
|
|
222
|
+
|
|
223
|
+
def __init__(self, patterns, threaded=True):
|
|
224
|
+
"""
|
|
225
|
+
:param patterns: List of tuples (pattern, processing_func).
|
|
226
|
+
`pattern` peut être une string, un re.Pattern ou un regex.Pattern.
|
|
227
|
+
`processing_func` doit être de la forme:
|
|
228
|
+
lambda match: output_str
|
|
229
|
+
"""
|
|
230
|
+
super().__init__(threaded=threaded)
|
|
231
|
+
self.compiled_patterns = []
|
|
232
|
+
for pattern, func in patterns:
|
|
233
|
+
if is_compiled_pattern(pattern):
|
|
234
|
+
if is_re_pattern(pattern):
|
|
235
|
+
# recompile avec regex pour autoriser les partial matches
|
|
236
|
+
self.compiled_patterns.append(
|
|
237
|
+
(regex.compile(pattern.pattern, flags=pattern.flags), func)
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
self.compiled_patterns.append((pattern, func))
|
|
241
|
+
else:
|
|
242
|
+
self.compiled_patterns.append((regex.compile(pattern), func))
|
|
243
|
+
|
|
244
|
+
def update_candidates(self, buffer, candidates):
|
|
245
|
+
"""
|
|
246
|
+
Pour chaque pattern, on tente de matcher le buffer en mode partial.
|
|
247
|
+
Si le match est trouvé, on le stocke dans candidates.
|
|
248
|
+
|
|
249
|
+
candidates: dict[index_pattern] = (match_obj, is_complete: bool)
|
|
250
|
+
"""
|
|
251
|
+
if buffer:
|
|
252
|
+
for i, (pattern, _) in enumerate(self.compiled_patterns):
|
|
253
|
+
match = regex.match(pattern, buffer, partial=True)
|
|
254
|
+
if match:
|
|
255
|
+
# match_obj = match
|
|
256
|
+
# Si le match s'étend jusqu'à la fin du buffer, on ne peut pas conclure.
|
|
257
|
+
if match.span()[1] == len(buffer):
|
|
258
|
+
candidates[i] = (match, False)
|
|
259
|
+
else:
|
|
260
|
+
# Des caractères en plus après le match → pattern terminé.
|
|
261
|
+
candidates[i] = (match, True)
|
|
262
|
+
else:
|
|
263
|
+
candidates.pop(i, None)
|
|
264
|
+
return candidates
|
|
265
|
+
|
|
266
|
+
def compute_output(self, buffer, candidates):
|
|
267
|
+
"""
|
|
268
|
+
Si un unique candidat est présent et marqué comme complet, on le traite et on le transforme.
|
|
269
|
+
On ne valide le candidat que s'il ne couvre PAS exactement tout le buffer
|
|
270
|
+
(évite de traiter un match susceptible d'être prolongé).
|
|
271
|
+
"""
|
|
272
|
+
if len(candidates) == 1:
|
|
273
|
+
key = list(candidates.keys())[0]
|
|
274
|
+
match, is_complete = candidates[key]
|
|
275
|
+
full = match.group(0)
|
|
276
|
+
|
|
277
|
+
# Ne traiter que si le match est complet ET que le buffer contient plus que le candidat
|
|
278
|
+
if is_complete and len(full) < len(buffer):
|
|
279
|
+
_, func = self.compiled_patterns[key]
|
|
280
|
+
new_buffer = buffer[len(full):]
|
|
281
|
+
del candidates[key]
|
|
282
|
+
# On passe directement le match à la fonction de traitement
|
|
283
|
+
return func(match), new_buffer, candidates
|
|
284
|
+
else:
|
|
285
|
+
return None, buffer, candidates
|
|
286
|
+
|
|
287
|
+
if len(candidates) == 0:
|
|
288
|
+
if buffer:
|
|
289
|
+
# Pas de pattern détecté, on avance d'un caractère.
|
|
290
|
+
char = buffer[0]
|
|
291
|
+
new_buffer = buffer[1:]
|
|
292
|
+
return char, new_buffer, candidates
|
|
293
|
+
else:
|
|
294
|
+
return None, buffer, candidates
|
|
295
|
+
else:
|
|
296
|
+
return None, buffer, candidates
|
|
297
|
+
|
|
298
|
+
def yield_greediest(self, buffer):
|
|
299
|
+
"""
|
|
300
|
+
Lors du vidage du buffer, on tente de trouver parmi tous les patterns celui qui correspond le mieux
|
|
301
|
+
(le plus long match commençant au début) et on le traite.
|
|
302
|
+
"""
|
|
303
|
+
matching = []
|
|
304
|
+
for pattern, func in self.compiled_patterns:
|
|
305
|
+
match = regex.match(pattern, buffer)
|
|
306
|
+
if match and match.span()[0] == 0:
|
|
307
|
+
matching.append((func, match))
|
|
308
|
+
|
|
309
|
+
if matching:
|
|
310
|
+
# On choisit le match le plus long
|
|
311
|
+
func, match = sorted(matching, key=lambda x: len(x[1].group(0)), reverse=True)[0]
|
|
312
|
+
full = match.group(0)
|
|
313
|
+
processed = func(match)
|
|
314
|
+
new_buffer = buffer[len(full):]
|
|
315
|
+
return processed, new_buffer
|
|
316
|
+
else:
|
|
317
|
+
return None, buffer
|
|
318
|
+
|
|
319
|
+
def stream_processor(self, stream):
|
|
320
|
+
"""
|
|
321
|
+
Accumule les tokens dans un buffer et tente de détecter, dès que possible, des patterns à transformer.
|
|
322
|
+
"""
|
|
323
|
+
buffer = ''
|
|
324
|
+
candidates = {}
|
|
325
|
+
|
|
326
|
+
for token in stream:
|
|
327
|
+
if not token:
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
buffer += token
|
|
331
|
+
keep_on_checking = True
|
|
332
|
+
|
|
333
|
+
while keep_on_checking:
|
|
334
|
+
candidates = self.update_candidates(buffer, candidates)
|
|
335
|
+
output, buffer, candidates = self.compute_output(buffer, candidates)
|
|
336
|
+
if output is not None:
|
|
337
|
+
yield output
|
|
338
|
+
else:
|
|
339
|
+
keep_on_checking = False
|
|
340
|
+
|
|
341
|
+
# Vidage du buffer une fois le flux terminé
|
|
342
|
+
while buffer:
|
|
343
|
+
processed, buffer = self.yield_greediest(buffer)
|
|
344
|
+
if processed:
|
|
345
|
+
yield processed
|
|
346
|
+
else:
|
|
347
|
+
yield buffer[0]
|
|
348
|
+
buffer = buffer[1:]
|
|
349
|
+
|
|
350
|
+
class Extractor(Streamer):
|
|
351
|
+
|
|
352
|
+
def __init__(self, pattern, ignore=None, threaded=True):
|
|
353
|
+
super().__init__(threaded=threaded)
|
|
354
|
+
ignore=ignore or []
|
|
355
|
+
self.token_streamer = TokenStreamer([
|
|
356
|
+
# ignore processing for patterns in ignore, we just keep them in the output
|
|
357
|
+
*[(ignored,(lambda m:m.group(0))) for ignored in ignore],
|
|
358
|
+
(pattern, self.process_match)
|
|
359
|
+
], threaded=False)
|
|
360
|
+
self.matchs=[]
|
|
361
|
+
|
|
362
|
+
def stream_processor(self, stream):
|
|
363
|
+
self.matchs=[]
|
|
364
|
+
return self.token_streamer(stream)
|
|
365
|
+
|
|
366
|
+
def process_match(self,match):
|
|
367
|
+
self.matchs.append(match)
|
|
368
|
+
return ''
|
|
369
|
+
|
|
370
|
+
class XMLExtractor(Extractor):
|
|
371
|
+
# Attributs XML : string, int, float, bool, null, json object/list
|
|
372
|
+
ATTR_RE = regex.compile(
|
|
373
|
+
r"""
|
|
374
|
+
(?P<name>[A-Za-z_][\w:.-]*) # nom
|
|
375
|
+
\s*=\s*
|
|
376
|
+
(
|
|
377
|
+
(?P<quote>["'])(?P<qvalue>.*?)(?P=quote) # valeur "quoted"
|
|
378
|
+
|
|
|
379
|
+
(?P<uvalue>[^\s>]+) # valeur non-quotée
|
|
380
|
+
)
|
|
381
|
+
""",
|
|
382
|
+
regex.VERBOSE | regex.DOTALL,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def __init__(self,threaded=True):
|
|
386
|
+
pattern = fenced(
|
|
387
|
+
r"<(?P<tool>[A-Za-z_][\w\-.]*)"
|
|
388
|
+
r"(?P<attrs>[^>]*)>",
|
|
389
|
+
r"</#tool#>",
|
|
390
|
+
flags=regex.DOTALL,
|
|
391
|
+
)
|
|
392
|
+
ignore=[fenced('```','```',flags=re.DOTALL|re.MULTILINE)]
|
|
393
|
+
super().__init__(pattern, ignore=ignore, threaded=threaded)
|
|
394
|
+
|
|
395
|
+
@classmethod
|
|
396
|
+
def parse_attrs(cls, attrs_src: str) -> dict:
|
|
397
|
+
attrs_src = attrs_src or ""
|
|
398
|
+
result = {}
|
|
399
|
+
|
|
400
|
+
for m in cls.ATTR_RE.finditer(attrs_src):
|
|
401
|
+
name = m.group("name")
|
|
402
|
+
raw = m.group("qvalue") or m.group("uvalue") or ""
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
# JSON-compatible values: numbers, booleans, null, dicts, lists…
|
|
406
|
+
value = json.loads(raw)
|
|
407
|
+
except Exception:
|
|
408
|
+
# else keep raw string
|
|
409
|
+
value = raw
|
|
410
|
+
|
|
411
|
+
result[name] = value
|
|
412
|
+
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
def process_match(self, match):
|
|
416
|
+
tool = match.group("tool")
|
|
417
|
+
attrs_src = match.group("attrs") or ""
|
|
418
|
+
content = match.group("content") or ""
|
|
419
|
+
|
|
420
|
+
args = self.parse_attrs(attrs_src)
|
|
421
|
+
args["content"] = content.strip('\n') # 🔥 INTÉGRATION DIRECTE ICI 🔥
|
|
422
|
+
|
|
423
|
+
self.matchs.append({
|
|
424
|
+
"name": tool,
|
|
425
|
+
"args": args, # dict final prêt pour un tool call
|
|
426
|
+
"match": match,
|
|
427
|
+
})
|
|
428
|
+
return f'```Called {tool!r} tool via XML (tool call details accessible in message.tool_calls)```' # on retire le bloc XML du texte visible
|
|
429
|
+
|
|
430
|
+
class MarkdownBlockExtractor(Extractor):
|
|
431
|
+
"""
|
|
432
|
+
En fait maintenant : extracteur de blocs markdown tool.
|
|
433
|
+
|
|
434
|
+
Forme reconnue :
|
|
435
|
+
|
|
436
|
+
```tool_name(foo=1, bar="x", debug=true)
|
|
437
|
+
...content...
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
-> match :
|
|
441
|
+
- tool : "tool_name"
|
|
442
|
+
- args : dict typé (int, float, bool, None, objets/lists JSON...)
|
|
443
|
+
- content: texte intérieur (multi-ligne)
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
KWARG_RE = regex.compile(
|
|
447
|
+
r"""
|
|
448
|
+
(?P<name>[A-Za-z_]\w*)
|
|
449
|
+
\s*=\s*
|
|
450
|
+
(
|
|
451
|
+
(?P<quote>["'])(?P<qvalue>.*?)(?P=quote)
|
|
452
|
+
|
|
|
453
|
+
(?P<uvalue>[^,\s)]+)
|
|
454
|
+
)
|
|
455
|
+
(?:\s*,\s*|$)
|
|
456
|
+
""",
|
|
457
|
+
regex.VERBOSE | regex.DOTALL,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def __init__(self, ignore=None, threaded=True):
|
|
461
|
+
# Exactly 3 backticks: (?<!`)```(?!`)
|
|
462
|
+
start = (
|
|
463
|
+
r"(?<!`)```(?!`)" # EXACTLY 3 backticks
|
|
464
|
+
r"(?P<tool>[A-Za-z_][\w\-.]*)" # tool name
|
|
465
|
+
r"(?:\((?P<raw_args>[^\n)]*)\))?" # optional (...args...)
|
|
466
|
+
r"[^\n]*\n" # rest of the line + newline
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
end = r"(?<!`)```(?!`)[ \t]*" # closing fence (exactly 3)
|
|
470
|
+
|
|
471
|
+
pattern = fenced(start, end, flags=regex.DOTALL)
|
|
472
|
+
super().__init__(pattern, ignore=ignore, threaded=threaded)
|
|
473
|
+
|
|
474
|
+
@classmethod
|
|
475
|
+
def parse_kwargs(cls, raw_args: str) -> dict:
|
|
476
|
+
raw_args = (raw_args or "").strip()
|
|
477
|
+
if not raw_args:
|
|
478
|
+
return {}
|
|
479
|
+
|
|
480
|
+
result = {}
|
|
481
|
+
for m in cls.KWARG_RE.finditer(raw_args):
|
|
482
|
+
name = m.group("name")
|
|
483
|
+
raw_val = m.group("qvalue") or m.group("uvalue") or ""
|
|
484
|
+
try:
|
|
485
|
+
value = json.loads(raw_val)
|
|
486
|
+
except Exception:
|
|
487
|
+
value = raw_val
|
|
488
|
+
result[name] = value
|
|
489
|
+
|
|
490
|
+
return result
|
|
491
|
+
|
|
492
|
+
def process_match(self, match):
|
|
493
|
+
tool = match.group("tool")
|
|
494
|
+
raw_args = match.group("raw_args") or "" # <= si pas de (), ça devient ""
|
|
495
|
+
content = match.group("content") or ""
|
|
496
|
+
|
|
497
|
+
args = self.parse_kwargs(raw_args)
|
|
498
|
+
args["content"] = content
|
|
499
|
+
|
|
500
|
+
self.matchs.append({
|
|
501
|
+
"name": tool,
|
|
502
|
+
"args": args,
|
|
503
|
+
"match": match,
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
# On renvoie le bloc tel quel
|
|
507
|
+
return match.group()
|
|
508
|
+
|
|
509
|
+
class MappingStreamSplitter:
|
|
510
|
+
|
|
511
|
+
"""converts a stream of Mappings into a dict of streams, one for each key of the Mapping data"""
|
|
512
|
+
|
|
513
|
+
def __init__(self, defaults=None, threaded=False):
|
|
514
|
+
self.threaded=threaded
|
|
515
|
+
self.readers=dict()
|
|
516
|
+
self.queues=dict()
|
|
517
|
+
self.defaults=defaults or {}
|
|
518
|
+
self.done=False
|
|
519
|
+
|
|
520
|
+
def maybe_init_readers(self, data):
|
|
521
|
+
for key in data.keys():
|
|
522
|
+
if key not in self.readers:
|
|
523
|
+
self.queues[key]=Queue()
|
|
524
|
+
def reader(key=key):
|
|
525
|
+
while (value:=self.queues[key].get()) is not END:
|
|
526
|
+
yield value
|
|
527
|
+
self.readers[key]=reader()
|
|
528
|
+
|
|
529
|
+
def process(self,stream):
|
|
530
|
+
self.queues=dict()
|
|
531
|
+
self.done=False
|
|
532
|
+
if self.defaults:
|
|
533
|
+
self.maybe_init_readers(self.defaults)
|
|
534
|
+
for data in stream:
|
|
535
|
+
self.maybe_init_readers(data)
|
|
536
|
+
for key, value in data.items():
|
|
537
|
+
self.queues[key].put(value)
|
|
538
|
+
for key in set(self.defaults.keys())-set(data.keys()):
|
|
539
|
+
self.queues[key].put(self.defaults[key])
|
|
540
|
+
if self.threaded:
|
|
541
|
+
time.sleep(0.0005)
|
|
542
|
+
for key in self.queues:
|
|
543
|
+
self.queues[key].put(END)
|
|
544
|
+
self.done=True
|
|
545
|
+
|
|
546
|
+
def split(self, stream):
|
|
547
|
+
if self.threaded:
|
|
548
|
+
thread=Thread(target=self.process, args=(stream,))
|
|
549
|
+
thread.start()
|
|
550
|
+
time.sleep(0.01) # let (at least) the default readers be initialized
|
|
551
|
+
else:
|
|
552
|
+
self.process(stream)
|
|
553
|
+
return self.readers
|
|
554
|
+
#warning! in threaded mode, readers will be added dynamically as new keys are encountered
|
|
555
|
+
#it's better suited for streams of Mappings with a predictable set of keys
|
|
556
|
+
|
|
557
|
+
def __call__(self, stream):
|
|
558
|
+
return self.split(stream)
|
|
559
|
+
|
|
560
|
+
class MappingStreamGatherer:
|
|
561
|
+
|
|
562
|
+
"""
|
|
563
|
+
Does it backwards, it takes a dict of streams and returns a stream of Mappings of the chosen type (default, dicts)
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
def __init__(self, defaults=None, type=None, threaded=False):
|
|
567
|
+
self.type=type or dict
|
|
568
|
+
self.defaults=defaults or {}
|
|
569
|
+
self.threaded=threaded
|
|
570
|
+
self.queue=None
|
|
571
|
+
|
|
572
|
+
def process(self, streams:dict, processor_for_key=None, splitter=None):
|
|
573
|
+
active_streams={}
|
|
574
|
+
while True:
|
|
575
|
+
data={}
|
|
576
|
+
for key, stream in list(streams.items()): #list because new readers may appear or be deleted as we loop
|
|
577
|
+
stream = active_streams.get(key) or stream
|
|
578
|
+
if processor_for_key and key not in active_streams:
|
|
579
|
+
stream = active_streams[key] = processor_for_key(key, stream)
|
|
580
|
+
try:
|
|
581
|
+
data[key]=next(stream)
|
|
582
|
+
except StopIteration:
|
|
583
|
+
# the stream is consumed
|
|
584
|
+
del streams[key]
|
|
585
|
+
active_streams.pop(key, None)
|
|
586
|
+
if data:
|
|
587
|
+
for key, default in self.defaults.items():
|
|
588
|
+
data.setdefault(key, default)
|
|
589
|
+
self.queue.put(self.type(data))
|
|
590
|
+
if not streams:
|
|
591
|
+
if splitter is not None and not splitter.done:
|
|
592
|
+
time.sleep(0.0005)
|
|
593
|
+
continue
|
|
594
|
+
break
|
|
595
|
+
if splitter is not None and not splitter.done:
|
|
596
|
+
time.sleep(0.0005)
|
|
597
|
+
self.queue.put(END)
|
|
598
|
+
|
|
599
|
+
def gather(self, streams:dict, processor_for_key=None, splitter=None):
|
|
600
|
+
self.queue=Queue()
|
|
601
|
+
if self.threaded:
|
|
602
|
+
Thread(target=self.process, args=(streams, processor_for_key, splitter)).start()
|
|
603
|
+
else:
|
|
604
|
+
self.process(streams, processor_for_key=processor_for_key, splitter=splitter)
|
|
605
|
+
|
|
606
|
+
def stream():
|
|
607
|
+
while (mapping:=self.queue.get()) is not END:
|
|
608
|
+
yield mapping
|
|
609
|
+
|
|
610
|
+
return stream()
|
|
611
|
+
|
|
612
|
+
def __call__(self, streams:dict, processor_for_key=None, splitter=None):
|
|
613
|
+
return self.gather(streams, processor_for_key=processor_for_key, splitter=splitter)
|
|
614
|
+
|
|
615
|
+
class MappingStreamProcessor(Streamer):
|
|
616
|
+
|
|
617
|
+
"""
|
|
618
|
+
Allows to plug specific processors (Streamers) to specific keys of a stream of mappings
|
|
619
|
+
"""
|
|
620
|
+
|
|
621
|
+
def __init__(self, defaults=None, processors=None, type=None, threaded=False):
|
|
622
|
+
super().__init__(threaded=threaded)
|
|
623
|
+
self.type=type if type is not None else dict
|
|
624
|
+
self.defaults=defaults if defaults is not None else {}
|
|
625
|
+
self.processors=processors if processors is not None else {}
|
|
626
|
+
self.splitter=MappingStreamSplitter(defaults=self.defaults, threaded=True)
|
|
627
|
+
self.gatherer=MappingStreamGatherer(defaults=self.defaults, type=self.type, threaded=True)
|
|
628
|
+
|
|
629
|
+
def stream_processor(self, stream):
|
|
630
|
+
if not self.processors:
|
|
631
|
+
return stream
|
|
632
|
+
streams=self.splitter(stream)
|
|
633
|
+
def processor_for_key(key, stream):
|
|
634
|
+
processor=self.processors.get(key)
|
|
635
|
+
if isinstance(processor,list):
|
|
636
|
+
for proc in reversed(processor):
|
|
637
|
+
stream=proc(stream)
|
|
638
|
+
elif callable(processor):
|
|
639
|
+
stream=processor(stream)
|
|
640
|
+
return stream
|
|
641
|
+
return self.gatherer(streams, processor_for_key=processor_for_key, splitter=self.splitter)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
|