unique_toolkit 1.8.1__py3-none-any.whl → 1.23.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.

Potentially problematic release.


This version of unique_toolkit might be problematic. Click here for more details.

Files changed (105) hide show
  1. unique_toolkit/__init__.py +20 -0
  2. unique_toolkit/_common/api_calling/human_verification_manager.py +121 -28
  3. unique_toolkit/_common/chunk_relevancy_sorter/config.py +3 -3
  4. unique_toolkit/_common/chunk_relevancy_sorter/tests/test_service.py +2 -5
  5. unique_toolkit/_common/default_language_model.py +9 -3
  6. unique_toolkit/_common/docx_generator/__init__.py +7 -0
  7. unique_toolkit/_common/docx_generator/config.py +12 -0
  8. unique_toolkit/_common/docx_generator/schemas.py +80 -0
  9. unique_toolkit/_common/docx_generator/service.py +252 -0
  10. unique_toolkit/_common/docx_generator/template/Doc Template.docx +0 -0
  11. unique_toolkit/_common/endpoint_builder.py +138 -117
  12. unique_toolkit/_common/endpoint_requestor.py +240 -14
  13. unique_toolkit/_common/exception.py +20 -0
  14. unique_toolkit/_common/feature_flags/schema.py +1 -5
  15. unique_toolkit/_common/referencing.py +53 -0
  16. unique_toolkit/_common/string_utilities.py +52 -1
  17. unique_toolkit/_common/tests/test_referencing.py +521 -0
  18. unique_toolkit/_common/tests/test_string_utilities.py +506 -0
  19. unique_toolkit/_common/utils/files.py +43 -0
  20. unique_toolkit/agentic/debug_info_manager/debug_info_manager.py +16 -6
  21. unique_toolkit/agentic/debug_info_manager/test/test_debug_info_manager.py +278 -0
  22. unique_toolkit/agentic/evaluation/config.py +3 -2
  23. unique_toolkit/agentic/evaluation/context_relevancy/service.py +2 -2
  24. unique_toolkit/agentic/evaluation/evaluation_manager.py +9 -5
  25. unique_toolkit/agentic/evaluation/hallucination/constants.py +1 -1
  26. unique_toolkit/agentic/evaluation/hallucination/hallucination_evaluation.py +26 -3
  27. unique_toolkit/agentic/history_manager/history_manager.py +14 -11
  28. unique_toolkit/agentic/history_manager/loop_token_reducer.py +3 -4
  29. unique_toolkit/agentic/history_manager/utils.py +10 -87
  30. unique_toolkit/agentic/postprocessor/postprocessor_manager.py +107 -16
  31. unique_toolkit/agentic/reference_manager/reference_manager.py +1 -1
  32. unique_toolkit/agentic/responses_api/__init__.py +19 -0
  33. unique_toolkit/agentic/responses_api/postprocessors/code_display.py +63 -0
  34. unique_toolkit/agentic/responses_api/postprocessors/generated_files.py +145 -0
  35. unique_toolkit/agentic/responses_api/stream_handler.py +15 -0
  36. unique_toolkit/agentic/tools/a2a/__init__.py +18 -2
  37. unique_toolkit/agentic/tools/a2a/evaluation/__init__.py +2 -0
  38. unique_toolkit/agentic/tools/a2a/evaluation/_utils.py +3 -3
  39. unique_toolkit/agentic/tools/a2a/evaluation/config.py +1 -1
  40. unique_toolkit/agentic/tools/a2a/evaluation/evaluator.py +143 -91
  41. unique_toolkit/agentic/tools/a2a/manager.py +7 -1
  42. unique_toolkit/agentic/tools/a2a/postprocessing/__init__.py +11 -3
  43. unique_toolkit/agentic/tools/a2a/postprocessing/_display_utils.py +185 -0
  44. unique_toolkit/agentic/tools/a2a/postprocessing/_ref_utils.py +73 -0
  45. unique_toolkit/agentic/tools/a2a/postprocessing/config.py +21 -0
  46. unique_toolkit/agentic/tools/a2a/postprocessing/display.py +180 -0
  47. unique_toolkit/agentic/tools/a2a/postprocessing/references.py +101 -0
  48. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display_utils.py +1335 -0
  49. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_ref_utils.py +603 -0
  50. unique_toolkit/agentic/tools/a2a/prompts.py +46 -0
  51. unique_toolkit/agentic/tools/a2a/response_watcher/__init__.py +6 -0
  52. unique_toolkit/agentic/tools/a2a/response_watcher/service.py +91 -0
  53. unique_toolkit/agentic/tools/a2a/tool/config.py +15 -5
  54. unique_toolkit/agentic/tools/a2a/tool/service.py +69 -36
  55. unique_toolkit/agentic/tools/config.py +16 -2
  56. unique_toolkit/agentic/tools/factory.py +4 -0
  57. unique_toolkit/agentic/tools/mcp/tool_wrapper.py +7 -35
  58. unique_toolkit/agentic/tools/openai_builtin/__init__.py +11 -0
  59. unique_toolkit/agentic/tools/openai_builtin/base.py +30 -0
  60. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/__init__.py +8 -0
  61. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/config.py +57 -0
  62. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/service.py +230 -0
  63. unique_toolkit/agentic/tools/openai_builtin/manager.py +62 -0
  64. unique_toolkit/agentic/tools/test/test_mcp_manager.py +95 -7
  65. unique_toolkit/agentic/tools/test/test_tool_progress_reporter.py +240 -0
  66. unique_toolkit/agentic/tools/tool.py +0 -11
  67. unique_toolkit/agentic/tools/tool_manager.py +337 -122
  68. unique_toolkit/agentic/tools/tool_progress_reporter.py +81 -15
  69. unique_toolkit/agentic/tools/utils/__init__.py +18 -0
  70. unique_toolkit/agentic/tools/utils/execution/execution.py +8 -4
  71. unique_toolkit/agentic/tools/utils/source_handling/schema.py +1 -1
  72. unique_toolkit/chat/__init__.py +8 -1
  73. unique_toolkit/chat/deprecated/service.py +232 -0
  74. unique_toolkit/chat/functions.py +54 -40
  75. unique_toolkit/chat/rendering.py +34 -0
  76. unique_toolkit/chat/responses_api.py +461 -0
  77. unique_toolkit/chat/schemas.py +1 -1
  78. unique_toolkit/chat/service.py +96 -1569
  79. unique_toolkit/content/functions.py +116 -1
  80. unique_toolkit/content/schemas.py +59 -0
  81. unique_toolkit/content/service.py +5 -37
  82. unique_toolkit/content/smart_rules.py +301 -0
  83. unique_toolkit/framework_utilities/langchain/client.py +27 -3
  84. unique_toolkit/framework_utilities/openai/client.py +12 -1
  85. unique_toolkit/framework_utilities/openai/message_builder.py +85 -1
  86. unique_toolkit/language_model/default_language_model.py +3 -0
  87. unique_toolkit/language_model/functions.py +25 -9
  88. unique_toolkit/language_model/infos.py +72 -4
  89. unique_toolkit/language_model/schemas.py +246 -40
  90. unique_toolkit/protocols/support.py +91 -9
  91. unique_toolkit/services/__init__.py +7 -0
  92. unique_toolkit/services/chat_service.py +1630 -0
  93. unique_toolkit/services/knowledge_base.py +861 -0
  94. unique_toolkit/smart_rules/compile.py +56 -301
  95. unique_toolkit/test_utilities/events.py +197 -0
  96. {unique_toolkit-1.8.1.dist-info → unique_toolkit-1.23.0.dist-info}/METADATA +173 -3
  97. {unique_toolkit-1.8.1.dist-info → unique_toolkit-1.23.0.dist-info}/RECORD +99 -67
  98. unique_toolkit/agentic/tools/a2a/postprocessing/_display.py +0 -122
  99. unique_toolkit/agentic/tools/a2a/postprocessing/_utils.py +0 -19
  100. unique_toolkit/agentic/tools/a2a/postprocessing/postprocessor.py +0 -230
  101. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_consolidate_references.py +0 -665
  102. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display.py +0 -391
  103. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_postprocessor_reference_functions.py +0 -256
  104. {unique_toolkit-1.8.1.dist-info → unique_toolkit-1.23.0.dist-info}/LICENSE +0 -0
  105. {unique_toolkit-1.8.1.dist-info → unique_toolkit-1.23.0.dist-info}/WHEEL +0 -0
@@ -1,7 +1,8 @@
1
1
  from enum import StrEnum
2
2
  from typing import Any, Callable, Generic, Protocol, TypeVar
3
+ from urllib.parse import urljoin, urlparse
3
4
 
4
- from pydantic import BaseModel
5
+ from pydantic import BaseModel, Field
5
6
  from typing_extensions import ParamSpec
6
7
 
7
8
  from unique_toolkit._common.endpoint_builder import (
@@ -24,11 +25,30 @@ CombinedParamsType = TypeVar("CombinedParamsType", bound=BaseModel)
24
25
  ResponseT_co = TypeVar("ResponseT_co", bound=BaseModel, covariant=True)
25
26
 
26
27
 
28
+ class RequestContext(BaseModel):
29
+ base_url: str
30
+ headers: dict[str, str] = Field(default_factory=dict)
31
+
32
+
33
+ def _verify_url(url: str) -> None:
34
+ parse_result = urlparse(url)
35
+ if not (parse_result.netloc and parse_result.scheme):
36
+ raise ValueError("Scheme and netloc are required for url")
37
+
38
+
27
39
  class EndpointRequestorProtocol(Protocol, Generic[CombinedParamsSpec, ResponseT_co]):
28
40
  @classmethod
29
41
  def request(
30
42
  cls,
31
- headers: dict[str, str],
43
+ context: RequestContext,
44
+ *args: CombinedParamsSpec.args,
45
+ **kwargs: CombinedParamsSpec.kwargs,
46
+ ) -> ResponseT_co: ...
47
+
48
+ @classmethod
49
+ async def request_async(
50
+ cls,
51
+ context: RequestContext,
32
52
  *args: CombinedParamsSpec.args,
33
53
  **kwargs: CombinedParamsSpec.kwargs,
34
54
  ) -> ResponseT_co: ...
@@ -53,7 +73,7 @@ def build_fake_requestor(
53
73
  @classmethod
54
74
  def request(
55
75
  cls,
56
- headers: dict[str, str],
76
+ context: RequestContext,
57
77
  *args: CombinedParamsSpec.args,
58
78
  **kwargs: CombinedParamsSpec.kwargs,
59
79
  ) -> ResponseType:
@@ -66,7 +86,22 @@ def build_fake_requestor(
66
86
  f"Invalid parameters passed to combined model {combined_model.__name__}: {e}"
67
87
  )
68
88
 
69
- return cls._operation.handle_response(return_value)
89
+ return cls._operation.handle_response(
90
+ return_value,
91
+ model_validate_options=cls._operation.response_validate_options(),
92
+ )
93
+
94
+ @classmethod
95
+ async def request_async(
96
+ cls,
97
+ context: RequestContext,
98
+ headers: dict[str, str] | None = None,
99
+ *args: CombinedParamsSpec.args,
100
+ **kwargs: CombinedParamsSpec.kwargs,
101
+ ) -> ResponseType:
102
+ raise NotImplementedError(
103
+ "Async request not implemented for fake requestor"
104
+ )
70
105
 
71
106
  return FakeRequestor
72
107
 
@@ -91,7 +126,7 @@ def build_request_requestor(
91
126
  @classmethod
92
127
  def request(
93
128
  cls,
94
- headers: dict[str, str],
129
+ context: RequestContext,
95
130
  *args: CombinedParamsSpec.args,
96
131
  **kwargs: CombinedParamsSpec.kwargs,
97
132
  ) -> ResponseType:
@@ -100,23 +135,204 @@ def build_request_requestor(
100
135
  combined=kwargs
101
136
  )
102
137
 
103
- url = cls._operation.create_url_from_model(path_params)
104
- payload = cls._operation.create_payload_from_model(payload_model)
138
+ path = cls._operation.create_path_from_model(
139
+ path_params, model_dump_options=cls._operation.path_dump_options()
140
+ )
141
+ url = urljoin(context.base_url, path)
142
+ _verify_url(url)
143
+
144
+ payload = cls._operation.create_payload_from_model(
145
+ payload_model, model_dump_options=cls._operation.payload_dump_options()
146
+ )
105
147
 
106
148
  response = requests.request(
107
149
  method=cls._operation.request_method(),
108
150
  url=url,
109
- headers=headers,
151
+ headers=context.headers,
110
152
  json=payload,
111
153
  )
112
- return cls._operation.handle_response(response.json())
154
+
155
+ response_json = response.json()
156
+
157
+ return cls._operation.handle_response(
158
+ response_json,
159
+ model_validate_options=cls._operation.response_validate_options(),
160
+ )
161
+
162
+ @classmethod
163
+ async def request_async(
164
+ cls,
165
+ base_url: str = "",
166
+ headers: dict[str, str] | None = None,
167
+ *args: CombinedParamsSpec.args,
168
+ **kwargs: CombinedParamsSpec.kwargs,
169
+ ) -> ResponseType:
170
+ raise NotImplementedError(
171
+ "Async request not implemented for request requestor"
172
+ )
113
173
 
114
174
  return RequestRequestor
115
175
 
116
176
 
177
+ def build_httpx_requestor(
178
+ operation_type: type[
179
+ ApiOperationProtocol[
180
+ PathParamsSpec,
181
+ PathParamsType,
182
+ PayloadParamSpec,
183
+ PayloadType,
184
+ ResponseType,
185
+ ]
186
+ ],
187
+ combined_model: Callable[CombinedParamsSpec, CombinedParamsType],
188
+ ) -> type[EndpointRequestorProtocol[CombinedParamsSpec, ResponseType]]:
189
+ import httpx
190
+
191
+ class HttpxRequestor(EndpointRequestorProtocol):
192
+ _operation = operation_type
193
+
194
+ @classmethod
195
+ def request(
196
+ cls,
197
+ context: RequestContext,
198
+ *args: CombinedParamsSpec.args,
199
+ **kwargs: CombinedParamsSpec.kwargs,
200
+ ) -> ResponseType:
201
+ headers = context.headers or {}
202
+
203
+ path_params, payload_model = cls._operation.models_from_combined(
204
+ combined=kwargs
205
+ )
206
+
207
+ path = cls._operation.create_path_from_model(
208
+ path_params, model_dump_options=cls._operation.path_dump_options()
209
+ )
210
+ url = urljoin(context.base_url, path)
211
+ _verify_url(url)
212
+ with httpx.Client() as client:
213
+ response = client.request(
214
+ method=cls._operation.request_method(),
215
+ url=url,
216
+ headers=headers,
217
+ json=cls._operation.create_payload_from_model(
218
+ payload_model,
219
+ model_dump_options=cls._operation.payload_dump_options(),
220
+ ),
221
+ )
222
+ response_json = response.json()
223
+ return cls._operation.handle_response(
224
+ response_json,
225
+ model_validate_options=cls._operation.response_validate_options(),
226
+ )
227
+
228
+ @classmethod
229
+ async def request_async(
230
+ cls,
231
+ context: RequestContext,
232
+ *args: CombinedParamsSpec.args,
233
+ **kwargs: CombinedParamsSpec.kwargs,
234
+ ) -> ResponseType:
235
+ headers = context.headers or {}
236
+
237
+ path_params, payload_model = cls._operation.models_from_combined(
238
+ combined=kwargs
239
+ )
240
+
241
+ path = cls._operation.create_path_from_model(
242
+ path_params, model_dump_options=cls._operation.path_dump_options()
243
+ )
244
+ url = urljoin(context.base_url, path)
245
+ _verify_url(url)
246
+ async with httpx.AsyncClient() as client:
247
+ response = await client.request(
248
+ method=cls._operation.request_method(),
249
+ url=url,
250
+ headers=headers,
251
+ json=cls._operation.create_payload_from_model(
252
+ payload_model,
253
+ model_dump_options=cls._operation.payload_dump_options(),
254
+ ),
255
+ )
256
+ response_json = response.json()
257
+ return cls._operation.handle_response(
258
+ response_json,
259
+ model_validate_options=cls._operation.response_validate_options(),
260
+ )
261
+
262
+ return HttpxRequestor
263
+
264
+
265
+ def build_aiohttp_requestor(
266
+ operation_type: type[
267
+ ApiOperationProtocol[
268
+ PathParamsSpec,
269
+ PathParamsType,
270
+ PayloadParamSpec,
271
+ PayloadType,
272
+ ResponseType,
273
+ ]
274
+ ],
275
+ combined_model: Callable[CombinedParamsSpec, CombinedParamsType],
276
+ ) -> type[EndpointRequestorProtocol[CombinedParamsSpec, ResponseType]]:
277
+ import aiohttp
278
+
279
+ class AiohttpRequestor(EndpointRequestorProtocol):
280
+ _operation = operation_type
281
+
282
+ @classmethod
283
+ def request(
284
+ cls,
285
+ context: RequestContext,
286
+ *args: CombinedParamsSpec.args,
287
+ **kwargs: CombinedParamsSpec.kwargs,
288
+ ) -> ResponseType:
289
+ raise NotImplementedError(
290
+ "Sync request not implemented for aiohttp requestor"
291
+ )
292
+
293
+ @classmethod
294
+ async def request_async(
295
+ cls,
296
+ context: RequestContext,
297
+ headers: dict[str, str] | None = None,
298
+ *args: CombinedParamsSpec.args,
299
+ **kwargs: CombinedParamsSpec.kwargs,
300
+ ) -> ResponseType:
301
+ headers = context.headers or {}
302
+
303
+ path_params, payload_model = cls._operation.models_from_combined(
304
+ combined=kwargs
305
+ )
306
+ path = cls._operation.create_path_from_model(
307
+ path_params, model_dump_options=cls._operation.path_dump_options()
308
+ )
309
+ url = urljoin(context.base_url, path)
310
+ _verify_url(url)
311
+
312
+ async with aiohttp.ClientSession() as session:
313
+ response = await session.request(
314
+ method=cls._operation.request_method(),
315
+ url=url,
316
+ headers=headers,
317
+ json=cls._operation.create_payload_from_model(
318
+ payload=payload_model,
319
+ model_dump_options=cls._operation.payload_dump_options(),
320
+ ),
321
+ )
322
+ response_json = await response.json()
323
+ return cls._operation.handle_response(
324
+ response=response_json,
325
+ model_validate_options=cls._operation.response_validate_options(),
326
+ )
327
+
328
+ return AiohttpRequestor
329
+
330
+
117
331
  class RequestorType(StrEnum):
118
332
  REQUESTS = "requests"
119
333
  FAKE = "fake"
334
+ HTTPIX = "httpx"
335
+ AIOHTTP = "aiohttp"
120
336
 
121
337
 
122
338
  def build_requestor(
@@ -147,6 +363,14 @@ def build_requestor(
147
363
  combined_model=combined_model,
148
364
  return_value=return_value,
149
365
  )
366
+ case RequestorType.HTTPIX:
367
+ return build_httpx_requestor(
368
+ operation_type=operation_type, combined_model=combined_model
369
+ )
370
+ case RequestorType.AIOHTTP:
371
+ return build_aiohttp_requestor(
372
+ operation_type=operation_type, combined_model=combined_model
373
+ )
150
374
 
151
375
 
152
376
  if __name__ == "__main__":
@@ -169,7 +393,7 @@ if __name__ == "__main__":
169
393
 
170
394
  UserEndpoint = build_api_operation(
171
395
  method=HttpMethods.GET,
172
- url_template=Template("https://api.example.com/users/{user_id}"),
396
+ path_template=Template("/users/{user_id}"),
173
397
  path_params_constructor=GetUserPathParams,
174
398
  payload_constructor=GetUserRequestBody,
175
399
  response_model_type=UserResponse,
@@ -183,19 +407,21 @@ if __name__ == "__main__":
183
407
 
184
408
  # Note that the return value is a pydantic UserResponse object
185
409
  response = FakeUserRequestor().request(
186
- headers={"a": "b"},
410
+ context=RequestContext(base_url="https://example.com", headers={"a": "b"}),
187
411
  user_id=123,
188
412
  include_profile=True,
189
413
  )
190
414
 
191
- RequestRequstor = build_request_requestor(
415
+ RequestRequestor = build_request_requestor(
192
416
  operation_type=UserEndpoint,
193
417
  combined_model=CombinedParams,
194
418
  )
195
419
 
196
420
  # Check type hints
197
- response = RequestRequstor().request(
198
- headers={"a": "b"}, user_id=123, include_profile=True
421
+ response = RequestRequestor().request(
422
+ context=RequestContext(base_url="https://example.com", headers={"a": "b"}),
423
+ user_id=123,
424
+ include_profile=True,
199
425
  )
200
426
 
201
427
  print(response.model_dump())
@@ -35,3 +35,23 @@ class CommonException(Exception):
35
35
 
36
36
  class ConfigurationException(Exception):
37
37
  pass
38
+
39
+
40
+ class InfoExceptionForAi(Exception):
41
+ """
42
+ This exception is raised as information to the AI.
43
+ Such that it can be used to inform the user about the error.
44
+ In a meaningful way.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ error_message: str,
50
+ message_for_ai: str,
51
+ ):
52
+ super().__init__(error_message)
53
+ self._message_for_ai = message_for_ai
54
+
55
+ @property
56
+ def message_for_ai(self):
57
+ return self._message_for_ai
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel, Field
1
+ from pydantic import BaseModel
2
2
 
3
3
  from unique_toolkit.agentic.tools.config import get_configuration_dict
4
4
 
@@ -7,7 +7,3 @@ class FeatureExtendedSourceSerialization(BaseModel):
7
7
  """Mixin for experimental feature in Source serialization"""
8
8
 
9
9
  model_config = get_configuration_dict()
10
- full_sources_serialize_dump: bool = Field(
11
- default=False,
12
- description="Whether to include the full source object in the tool response. If True, includes the full Source object. If False, uses the old format with only source_number and content.",
13
- )
@@ -0,0 +1,53 @@
1
+ """
2
+ Utilities for handling references in the "postprocessed" format, i.e <sup>X</sup>
3
+ """
4
+
5
+ import functools
6
+ import re
7
+ from typing import Generator
8
+
9
+ _REF_DETECTION_PATTERN = re.compile(r"<sup>\s*(?P<reference_number>\d+)\s*</sup>")
10
+
11
+
12
+ def _iter_ref_numbers(text: str) -> Generator[int, None, None]:
13
+ for match in _REF_DETECTION_PATTERN.finditer(text):
14
+ yield int(match.group("reference_number"))
15
+
16
+
17
+ @functools.cache
18
+ def _get_detection_pattern_for_ref(ref_number: int) -> re.Pattern[str]:
19
+ return re.compile(rf"<sup>\s*{ref_number}\s*</sup>")
20
+
21
+
22
+ def get_reference_pattern(ref_number: int) -> str:
23
+ return f"<sup>{ref_number}</sup>"
24
+
25
+
26
+ def get_all_ref_numbers(text: str) -> list[int]:
27
+ return sorted(set(_iter_ref_numbers(text)))
28
+
29
+
30
+ def get_max_ref_number(text: str) -> int | None:
31
+ return max(_iter_ref_numbers(text), default=None)
32
+
33
+
34
+ def replace_ref_number(text: str, ref_number: int, replacement: int | str) -> str:
35
+ if isinstance(replacement, int):
36
+ replacement = get_reference_pattern(replacement)
37
+
38
+ return _get_detection_pattern_for_ref(ref_number).sub(replacement, text)
39
+
40
+
41
+ def remove_ref_number(text: str, ref_number: int) -> str:
42
+ return _get_detection_pattern_for_ref(ref_number).sub("", text)
43
+
44
+
45
+ def remove_all_refs(text: str) -> str:
46
+ return _REF_DETECTION_PATTERN.sub("", text)
47
+
48
+
49
+ def remove_consecutive_ref_space(text: str) -> str:
50
+ """
51
+ Remove spaces between consecutive references.
52
+ """
53
+ return re.sub(r"</sup>\s*<sup>", "</sup><sup>", text)
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import re
3
- from typing import Any
3
+ from typing import Any, Iterable, Sequence
4
+ from uuid import uuid4
4
5
 
5
6
 
6
7
  def _is_elementary_type(value: Any) -> bool:
@@ -87,3 +88,53 @@ def extract_dicts_from_string(text: str) -> list[dict[str, Any]]:
87
88
  continue
88
89
 
89
90
  return dictionaries
91
+
92
+
93
+ def _replace_in_text_non_overlapping(
94
+ text: str, repls: Iterable[tuple[str | re.Pattern[str], str]]
95
+ ) -> str:
96
+ for pattern, replacement in repls:
97
+ text = re.sub(pattern, replacement, text)
98
+ return text
99
+
100
+
101
+ def replace_in_text(
102
+ text: str, repls: Sequence[tuple[str | re.Pattern[str], str]]
103
+ ) -> str:
104
+ """
105
+ Replace multiple patterns in text without replacement interference.
106
+
107
+ This function performs all replacements independently, preventing cases where
108
+ a replacement value matches another pattern, which would cause unintended
109
+ cascading replacements.
110
+
111
+ Why this is needed:
112
+ - Naive sequential replacements can interfere with each other
113
+ - Example: replacing "foo" -> "bar" and "bar" -> "baz" would incorrectly
114
+ turn "foo" into "baz" if done sequentially
115
+ - This function uses a two-phase approach with UUID placeholders to ensure
116
+ each pattern is replaced exactly once with its intended value
117
+
118
+ Args:
119
+ text: The input text to perform replacements on
120
+ repls: Sequence of (pattern, replacement) tuples where pattern can be
121
+ a string or compiled regex pattern
122
+
123
+ Returns:
124
+ Text with all patterns replaced by their corresponding replacements
125
+
126
+ Example:
127
+ >>> text = "foo and bar"
128
+ >>> repls = [("foo", "bar"), ("bar", "baz")]
129
+ >>> replace_in_text(text, repls)
130
+ "bar and baz" # Both replacements applied independently
131
+ """
132
+ if len(repls) == 0:
133
+ return text
134
+
135
+ placeholders = [uuid4().hex for _ in range(len(repls))]
136
+ orig, repls = zip(*repls)
137
+
138
+ # 2 phase replacement, since the map keys and values can overlap
139
+ text = _replace_in_text_non_overlapping(text, zip(orig, placeholders))
140
+ return _replace_in_text_non_overlapping(text, zip(placeholders, repls))