pygeai 0.7.0b1__py3-none-any.whl → 0.7.0b4__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.
@@ -124,9 +124,9 @@ class ProjectMigrationStrategy(MigrationStrategy):
124
124
  logger.error(" - Operation: Create project")
125
125
  logger.error(f" - Base URL: {self.to_instance}")
126
126
  logger.error(f" - API Key used (first 20 chars): {self.to_api_key[:20] if self.to_api_key else 'None'}...")
127
- Console.write_stderr("\nDEBUG: Operation failed: Create project")
128
- Console.write_stderr(f"DEBUG: Base URL: {self.to_instance}")
129
- Console.write_stderr(f"DEBUG: API Key used (first 20 chars): {self.to_api_key[:20] if self.to_api_key else 'None'}...")
127
+ logger.error("\nDEBUG: Operation failed: Create project")
128
+ logger.error(f"DEBUG: Base URL: {self.to_instance}")
129
+ logger.error(f"DEBUG: API Key used (first 20 chars): {self.to_api_key[:20] if self.to_api_key else 'None'}...")
130
130
  raise ValueError(error_msg) from e
131
131
 
132
132
  if isinstance(response, ErrorListResponse):
@@ -135,9 +135,9 @@ class ProjectMigrationStrategy(MigrationStrategy):
135
135
  logger.error(" - Operation: Create project")
136
136
  logger.error(f" - Base URL: {self.to_instance}")
137
137
  logger.error(f" - API Key used (first 20 chars): {self.to_api_key[:20] if self.to_api_key else 'None'}...")
138
- Console.write_stderr("\nDEBUG: Operation failed: Create project")
139
- Console.write_stderr(f"DEBUG: Base URL: {self.to_instance}")
140
- Console.write_stderr(f"DEBUG: API Key used (first 20 chars): {self.to_api_key[:20] if self.to_api_key else 'None'}...")
138
+ logger.error("\nDEBUG: Operation failed: Create project")
139
+ logger.error(f"DEBUG: Base URL: {self.to_instance}")
140
+ logger.error(f"DEBUG: API Key used (first 20 chars): {self.to_api_key[:20] if self.to_api_key else 'None'}...")
141
141
  raise ValueError(f"Failed to create project: {error_detail}")
142
142
 
143
143
  if not response or not hasattr(response, "project"):
@@ -145,14 +145,6 @@ class ProjectMigrationStrategy(MigrationStrategy):
145
145
 
146
146
  return response.project
147
147
 
148
- def _migrate_assistants(self, new_project: Project):
149
- """
150
- Migrate assistants associated with the project.
151
-
152
- :param new_project: The newly created project to migrate assistants to
153
- """
154
- pass
155
-
156
148
 
157
149
  class _LabResourceMigrationStrategy(MigrationStrategy):
158
150
  """
@@ -0,0 +1,436 @@
1
+ import unittest
2
+ import json
3
+ from json import JSONDecodeError
4
+ from unittest.mock import MagicMock
5
+ from pygeai.chat.clients import ChatClient
6
+ from pygeai.core.common.exceptions import InvalidAPIResponseException
7
+
8
+
9
+ class TestStreamingJsonParsing(unittest.TestCase):
10
+ """
11
+ Tests for JSON parsing in streaming responses.
12
+
13
+ These tests validate the current behavior of json.loads() in streaming
14
+ generators to ensure optimizations don't break functionality.
15
+
16
+ Run with:
17
+ python -m unittest pygeai.tests.chat.test_streaming_json.TestStreamingJsonParsing
18
+ """
19
+
20
+ def setUp(self):
21
+ """Set up test client"""
22
+ self.client = ChatClient(api_key="test_key", base_url="test_url")
23
+ self.client.api_service = MagicMock()
24
+
25
+ def test_parse_streaming_chunk_valid(self):
26
+ """Test parsing valid streaming JSON chunk"""
27
+ chunk = '{"choices": [{"delta": {"content": "Hello"}}]}'
28
+
29
+ result = json.loads(chunk)
30
+
31
+ self.assertIn("choices", result)
32
+ self.assertEqual(result["choices"][0]["delta"]["content"], "Hello")
33
+
34
+ def test_parse_streaming_chunk_invalid(self):
35
+ """Test that invalid streaming chunk raises JSONDecodeError"""
36
+ chunk = 'invalid json'
37
+
38
+ with self.assertRaises(JSONDecodeError):
39
+ json.loads(chunk)
40
+
41
+ def test_parse_streaming_chunk_empty_content(self):
42
+ """Test parsing chunk with empty content field"""
43
+ chunk = '{"choices": [{"delta": {"content": ""}}]}'
44
+
45
+ result = json.loads(chunk)
46
+
47
+ self.assertEqual(result["choices"][0]["delta"]["content"], "")
48
+
49
+ def test_parse_streaming_chunk_no_content(self):
50
+ """Test parsing chunk without content field"""
51
+ chunk = '{"choices": [{"delta": {}}]}'
52
+
53
+ result = json.loads(chunk)
54
+
55
+ self.assertNotIn("content", result["choices"][0]["delta"])
56
+
57
+ def test_parse_streaming_chunk_multiple_choices(self):
58
+ """Test parsing chunk with multiple choices"""
59
+ chunk = '{"choices": [{"delta": {"content": "A"}}, {"delta": {"content": "B"}}]}'
60
+
61
+ result = json.loads(chunk)
62
+
63
+ self.assertEqual(len(result["choices"]), 2)
64
+ self.assertEqual(result["choices"][0]["delta"]["content"], "A")
65
+
66
+ def test_stream_chat_generator_success(self):
67
+ """Test stream_chat_generator with valid data"""
68
+ streaming_data = [
69
+ 'data: {"choices": [{"delta": {"content": "Hello"}}]}',
70
+ 'data: {"choices": [{"delta": {"content": " world"}}]}',
71
+ 'data: {"choices": [{"delta": {"content": "!"}}]}',
72
+ 'data: [DONE]'
73
+ ]
74
+
75
+ mock_response = iter(streaming_data)
76
+
77
+ result = list(self.client.stream_chat_generator(mock_response))
78
+
79
+ self.assertEqual(result, ["Hello", " world", "!"])
80
+
81
+ def test_stream_chat_generator_skips_invalid_json(self):
82
+ """Test that generator skips chunks with invalid JSON"""
83
+ streaming_data = [
84
+ 'data: {"choices": [{"delta": {"content": "Valid"}}]}',
85
+ 'data: invalid json here', # Should be skipped
86
+ 'data: {"choices": [{"delta": {"content": " content"}}]}',
87
+ 'data: [DONE]'
88
+ ]
89
+
90
+ mock_response = iter(streaming_data)
91
+
92
+ result = list(self.client.stream_chat_generator(mock_response))
93
+
94
+ # Should only get valid chunks
95
+ self.assertEqual(result, ["Valid", " content"])
96
+
97
+ def test_stream_chat_generator_skips_malformed_structure(self):
98
+ """Test that generator skips chunks with malformed structure"""
99
+ streaming_data = [
100
+ 'data: {"choices": [{"delta": {"content": "Good"}}]}',
101
+ 'data: {"no_choices": "here"}', # Malformed - no choices
102
+ 'data: {"choices": []}', # Empty choices
103
+ 'data: {"choices": [{"no_delta": "here"}]}', # No delta
104
+ 'data: {"choices": [{"delta": {"content": " data"}}]}',
105
+ 'data: [DONE]'
106
+ ]
107
+
108
+ mock_response = iter(streaming_data)
109
+
110
+ result = list(self.client.stream_chat_generator(mock_response))
111
+
112
+ self.assertEqual(result, ["Good", " data"])
113
+
114
+ def test_stream_chat_generator_done_signal(self):
115
+ """Test that [DONE] signal stops iteration"""
116
+ streaming_data = [
117
+ 'data: {"choices": [{"delta": {"content": "Before"}}]}',
118
+ 'data: [DONE]',
119
+ 'data: {"choices": [{"delta": {"content": "After"}}]}' # Should not be processed
120
+ ]
121
+
122
+ mock_response = iter(streaming_data)
123
+
124
+ result = list(self.client.stream_chat_generator(mock_response))
125
+
126
+ # Should only get content before [DONE]
127
+ self.assertEqual(result, ["Before"])
128
+
129
+ def test_stream_chat_generator_non_data_lines(self):
130
+ """Test that non-data lines are ignored"""
131
+ streaming_data = [
132
+ 'event: start',
133
+ 'data: {"choices": [{"delta": {"content": "Content"}}]}',
134
+ ': comment line',
135
+ 'data: {"choices": [{"delta": {"content": " here"}}]}',
136
+ 'data: [DONE]'
137
+ ]
138
+
139
+ mock_response = iter(streaming_data)
140
+
141
+ result = list(self.client.stream_chat_generator(mock_response))
142
+
143
+ self.assertEqual(result, ["Content", " here"])
144
+
145
+ def test_stream_response_generator_success(self):
146
+ """Test stream_response_generator with valid data"""
147
+ streaming_data = [
148
+ 'data: {"choices": [{"delta": {"content": "Test"}}]}',
149
+ 'data: {"choices": [{"delta": {"content": " response"}}]}',
150
+ 'data: [DONE]'
151
+ ]
152
+
153
+ mock_response = iter(streaming_data)
154
+
155
+ result = list(self.client.stream_response_generator(mock_response))
156
+
157
+ self.assertEqual(result, ["Test", " response"])
158
+
159
+ def test_stream_generator_with_whitespace(self):
160
+ """Test streaming with various whitespace in data"""
161
+ streaming_data = [
162
+ 'data: {"choices": [{"delta": {"content": "A"}}]} ', # Extra spaces
163
+ 'data:\t{"choices": [{"delta": {"content": "B"}}]}\t', # Tabs
164
+ 'data:{"choices": [{"delta": {"content": "C"}}]}', # No space after colon
165
+ 'data: [DONE]'
166
+ ]
167
+
168
+ mock_response = iter(streaming_data)
169
+
170
+ result = list(self.client.stream_chat_generator(mock_response))
171
+
172
+ self.assertEqual(result, ["A", "B", "C"])
173
+
174
+ def test_stream_generator_error_handling(self):
175
+ """Test that streaming errors are caught and raised appropriately"""
176
+ def error_response():
177
+ yield 'data: {"choices": [{"delta": {"content": "Start"}}]}'
178
+ raise Exception("Streaming error")
179
+
180
+ mock_response = error_response()
181
+
182
+ with self.assertRaises(InvalidAPIResponseException) as context:
183
+ list(self.client.stream_chat_generator(mock_response))
184
+
185
+ self.assertIn("Unable to process streaming chat response", str(context.exception))
186
+
187
+ def test_streaming_chunk_with_special_characters(self):
188
+ """Test streaming chunks with special characters"""
189
+ chunk = '{"choices": [{"delta": {"content": "Hello \\"world\\" 🌍"}}]}'
190
+
191
+ result = json.loads(chunk)
192
+
193
+ self.assertEqual(result["choices"][0]["delta"]["content"], 'Hello "world" 🌍')
194
+
195
+ def test_streaming_chunk_with_unicode(self):
196
+ """Test streaming chunks with unicode characters"""
197
+ chunk = '{"choices": [{"delta": {"content": "こんにちは世界"}}]}'
198
+
199
+ result = json.loads(chunk)
200
+
201
+ self.assertEqual(result["choices"][0]["delta"]["content"], "こんにちは世界")
202
+
203
+ def test_streaming_chunk_with_newlines(self):
204
+ """Test streaming chunks with newline characters"""
205
+ chunk = '{"choices": [{"delta": {"content": "Line 1\\nLine 2\\nLine 3"}}]}'
206
+
207
+ result = json.loads(chunk)
208
+
209
+ self.assertEqual(result["choices"][0]["delta"]["content"], "Line 1\nLine 2\nLine 3")
210
+
211
+
212
+ class TestStreamingJsonParsingEdgeCases(unittest.TestCase):
213
+ """
214
+ Edge case tests for streaming JSON parsing.
215
+
216
+ Run with:
217
+ python -m unittest pygeai.tests.chat.test_streaming_json.TestStreamingJsonParsingEdgeCases
218
+ """
219
+
220
+ def setUp(self):
221
+ """Set up test client"""
222
+ self.client = ChatClient(api_key="test_key", base_url="test_url")
223
+ self.client.api_service = MagicMock()
224
+
225
+ def test_empty_streaming_response(self):
226
+ """Test handling of empty streaming response"""
227
+ streaming_data = []
228
+ mock_response = iter(streaming_data)
229
+
230
+ result = list(self.client.stream_chat_generator(mock_response))
231
+
232
+ self.assertEqual(result, [])
233
+
234
+ def test_only_done_signal(self):
235
+ """Test streaming with only DONE signal"""
236
+ streaming_data = ['data: [DONE]']
237
+ mock_response = iter(streaming_data)
238
+
239
+ result = list(self.client.stream_chat_generator(mock_response))
240
+
241
+ self.assertEqual(result, [])
242
+
243
+ def test_streaming_with_empty_chunks(self):
244
+ """Test streaming with empty content chunks"""
245
+ streaming_data = [
246
+ 'data: {"choices": [{"delta": {"content": ""}}]}',
247
+ 'data: {"choices": [{"delta": {"content": ""}}]}',
248
+ 'data: [DONE]'
249
+ ]
250
+
251
+ mock_response = iter(streaming_data)
252
+
253
+ result = list(self.client.stream_chat_generator(mock_response))
254
+
255
+ # Empty strings are still yielded
256
+ self.assertEqual(result, ["", ""])
257
+
258
+ def test_streaming_with_very_long_content(self):
259
+ """Test streaming with very long content chunk"""
260
+ long_content = "A" * 10000
261
+ chunk = f'{{"choices": [{{"delta": {{"content": "{long_content}"}}}}]}}'
262
+ streaming_data = [
263
+ f'data: {chunk}',
264
+ 'data: [DONE]'
265
+ ]
266
+
267
+ mock_response = iter(streaming_data)
268
+
269
+ result = list(self.client.stream_chat_generator(mock_response))
270
+
271
+ self.assertEqual(len(result[0]), 10000)
272
+
273
+ def test_streaming_with_nested_json_in_content(self):
274
+ """Test streaming where content itself contains JSON string"""
275
+ content = '{\\"nested\\": \\"value\\"}'
276
+ chunk = f'{{"choices": [{{"delta": {{"content": "{content}"}}}}]}}'
277
+ streaming_data = [
278
+ f'data: {chunk}',
279
+ 'data: [DONE]'
280
+ ]
281
+
282
+ mock_response = iter(streaming_data)
283
+
284
+ result = list(self.client.stream_chat_generator(mock_response))
285
+
286
+ self.assertEqual(result[0], '{"nested": "value"}')
287
+
288
+ def test_streaming_choices_out_of_bounds_protection(self):
289
+ """Test that accessing choices[0] is safe"""
290
+ # Chunk with empty choices array
291
+ streaming_data = [
292
+ 'data: {"choices": []}',
293
+ 'data: [DONE]'
294
+ ]
295
+
296
+ mock_response = iter(streaming_data)
297
+
298
+ # Should not raise IndexError - chunk should be skipped
299
+ result = list(self.client.stream_chat_generator(mock_response))
300
+
301
+ self.assertEqual(result, [])
302
+
303
+ def test_streaming_delta_missing_content_key(self):
304
+ """Test chunks where delta exists but no content key"""
305
+ streaming_data = [
306
+ 'data: {"choices": [{"delta": {"role": "assistant"}}]}', # No content
307
+ 'data: {"choices": [{"delta": {"content": "Valid"}}]}',
308
+ 'data: [DONE]'
309
+ ]
310
+
311
+ mock_response = iter(streaming_data)
312
+
313
+ result = list(self.client.stream_chat_generator(mock_response))
314
+
315
+ # Only chunk with content should be yielded
316
+ self.assertEqual(result, ["Valid"])
317
+
318
+ def test_streaming_multiple_rapid_chunks(self):
319
+ """Test rapid succession of chunks (simulates fast streaming)"""
320
+ streaming_data = [f'data: {{"choices": [{{"delta": {{"content": "{i}"}}}}]}}' for i in range(100)]
321
+ streaming_data.append('data: [DONE]')
322
+
323
+ mock_response = iter(streaming_data)
324
+
325
+ result = list(self.client.stream_chat_generator(mock_response))
326
+
327
+ self.assertEqual(len(result), 100)
328
+ self.assertEqual(result[0], "0")
329
+ self.assertEqual(result[99], "99")
330
+
331
+ def test_streaming_chunk_access_pattern(self):
332
+ """Test the nested dictionary access pattern used in streaming"""
333
+ chunk = '{"choices": [{"delta": {"content": "test"}}]}'
334
+ json_data = json.loads(chunk)
335
+
336
+ # This is the exact pattern used in the code
337
+ if (
338
+ json_data.get("choices")
339
+ and len(json_data["choices"]) > 0
340
+ and "delta" in json_data["choices"][0]
341
+ and "content" in json_data["choices"][0]["delta"]
342
+ ):
343
+ content = json_data["choices"][0]["delta"]["content"]
344
+ self.assertEqual(content, "test")
345
+ else:
346
+ self.fail("Access pattern failed")
347
+
348
+ def test_streaming_chunk_safe_get_pattern(self):
349
+ """Test safe dictionary access with .get()"""
350
+ # Test with valid structure
351
+ chunk1 = '{"choices": [{"delta": {"content": "test"}}]}'
352
+ data1 = json.loads(chunk1)
353
+ choices = data1.get("choices")
354
+ self.assertIsNotNone(choices)
355
+
356
+ # Test with missing key
357
+ chunk2 = '{"no_choices": []}'
358
+ data2 = json.loads(chunk2)
359
+ choices = data2.get("choices")
360
+ self.assertIsNone(choices)
361
+
362
+
363
+ class TestStreamingJsonParsingPerformance(unittest.TestCase):
364
+ """
365
+ Performance-related tests for streaming JSON parsing.
366
+
367
+ These tests ensure the current implementation can handle realistic scenarios.
368
+
369
+ Run with:
370
+ python -m unittest pygeai.tests.chat.test_streaming_json.TestStreamingJsonParsingPerformance
371
+ """
372
+
373
+ def test_parse_many_chunks_sequentially(self):
374
+ """Test parsing many chunks in sequence (baseline performance test)"""
375
+ chunks = [
376
+ f'{{"choices": [{{"delta": {{"content": "chunk{i}"}}}}]}}'
377
+ for i in range(1000)
378
+ ]
379
+
380
+ # Measure baseline performance
381
+ for chunk in chunks:
382
+ result = json.loads(chunk)
383
+ self.assertIn("choices", result)
384
+
385
+ def test_parse_complex_streaming_chunks(self):
386
+ """Test parsing complex streaming chunks with nested data"""
387
+ chunk = '''
388
+ {
389
+ "id": "chatcmpl-123",
390
+ "object": "chat.completion.chunk",
391
+ "created": 1694268190,
392
+ "model": "gpt-4",
393
+ "choices": [{
394
+ "index": 0,
395
+ "delta": {
396
+ "role": "assistant",
397
+ "content": "Hello"
398
+ },
399
+ "finish_reason": null
400
+ }]
401
+ }
402
+ '''
403
+
404
+ result = json.loads(chunk)
405
+
406
+ self.assertEqual(result["choices"][0]["delta"]["content"], "Hello")
407
+ self.assertEqual(result["model"], "gpt-4")
408
+
409
+ def test_parse_minimal_vs_verbose_chunks(self):
410
+ """Test parsing both minimal and verbose chunk formats"""
411
+ # Minimal
412
+ minimal = '{"choices": [{"delta": {"content": "A"}}]}'
413
+ result1 = json.loads(minimal)
414
+ self.assertEqual(result1["choices"][0]["delta"]["content"], "A")
415
+
416
+ # Verbose (more realistic from API)
417
+ verbose = '''
418
+ {
419
+ "id": "123",
420
+ "object": "chat.completion.chunk",
421
+ "created": 1234567890,
422
+ "model": "model-name",
423
+ "choices": [{
424
+ "index": 0,
425
+ "delta": {"content": "A"},
426
+ "finish_reason": null
427
+ }],
428
+ "usage": null
429
+ }
430
+ '''
431
+ result2 = json.loads(verbose)
432
+ self.assertEqual(result2["choices"][0]["delta"]["content"], "A")
433
+
434
+
435
+ if __name__ == '__main__':
436
+ unittest.main()