posthoganalytics 7.6.0__py3-none-any.whl → 7.8.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.
@@ -0,0 +1,577 @@
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from posthoganalytics.ai.prompts import Prompts
5
+
6
+
7
+ class MockResponse:
8
+ """Mock HTTP response for testing."""
9
+
10
+ def __init__(self, json_data=None, status_code=200, ok=True):
11
+ self._json_data = json_data
12
+ self.status_code = status_code
13
+ self.ok = ok
14
+
15
+ def json(self):
16
+ if self._json_data is None:
17
+ raise ValueError("No JSON data")
18
+ return self._json_data
19
+
20
+
21
+ class TestPrompts(unittest.TestCase):
22
+ """Tests for the Prompts class."""
23
+
24
+ mock_prompt_response = {
25
+ "id": 1,
26
+ "name": "test-prompt",
27
+ "prompt": "Hello, {{name}}! You are a helpful assistant for {{company}}.",
28
+ "version": 1,
29
+ "created_by": "user@example.com",
30
+ "created_at": "2024-01-01T00:00:00Z",
31
+ "updated_at": "2024-01-01T00:00:00Z",
32
+ "deleted": False,
33
+ }
34
+
35
+ def create_mock_posthog(
36
+ self, personal_api_key="phx_test_key", host="https://us.i.posthog.com"
37
+ ):
38
+ """Create a mock PostHog client."""
39
+ mock = MagicMock()
40
+ mock.personal_api_key = personal_api_key
41
+ mock.raw_host = host
42
+ return mock
43
+
44
+
45
+ class TestPromptsGet(TestPrompts):
46
+ """Tests for the Prompts.get() method."""
47
+
48
+ @patch("posthog.ai.prompts._get_session")
49
+ def test_successfully_fetch_a_prompt(self, mock_get_session):
50
+ """Should successfully fetch a prompt."""
51
+ mock_get = mock_get_session.return_value.get
52
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
53
+
54
+ posthog = self.create_mock_posthog()
55
+ prompts = Prompts(posthog)
56
+
57
+ result = prompts.get("test-prompt")
58
+
59
+ self.assertEqual(result, self.mock_prompt_response["prompt"])
60
+ mock_get.assert_called_once()
61
+ call_args = mock_get.call_args
62
+ self.assertEqual(
63
+ call_args[0][0],
64
+ "https://us.i.posthog.com/api/projects/@current/llm_prompts/name/test-prompt/",
65
+ )
66
+ self.assertIn("Authorization", call_args[1]["headers"])
67
+ self.assertEqual(
68
+ call_args[1]["headers"]["Authorization"], "Bearer phx_test_key"
69
+ )
70
+
71
+ @patch("posthog.ai.prompts._get_session")
72
+ @patch("posthog.ai.prompts.time.time")
73
+ def test_return_cached_prompt_when_fresh(self, mock_time, mock_get_session):
74
+ """Should return cached prompt when fresh (no API call)."""
75
+ mock_get = mock_get_session.return_value.get
76
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
77
+ mock_time.return_value = 1000.0
78
+
79
+ posthog = self.create_mock_posthog()
80
+ prompts = Prompts(posthog)
81
+
82
+ # First call - fetches from API
83
+ result1 = prompts.get("test-prompt", cache_ttl_seconds=300)
84
+ self.assertEqual(result1, self.mock_prompt_response["prompt"])
85
+ self.assertEqual(mock_get.call_count, 1)
86
+
87
+ # Advance time by 60 seconds (still within TTL)
88
+ mock_time.return_value = 1060.0
89
+
90
+ # Second call - should use cache
91
+ result2 = prompts.get("test-prompt", cache_ttl_seconds=300)
92
+ self.assertEqual(result2, self.mock_prompt_response["prompt"])
93
+ self.assertEqual(mock_get.call_count, 1) # No additional fetch
94
+
95
+ @patch("posthog.ai.prompts._get_session")
96
+ @patch("posthog.ai.prompts.time.time")
97
+ def test_refetch_when_cache_is_stale(self, mock_time, mock_get_session):
98
+ """Should refetch when cache is stale."""
99
+ mock_get = mock_get_session.return_value.get
100
+ updated_prompt_response = {
101
+ **self.mock_prompt_response,
102
+ "prompt": "Updated prompt: Hello, {{name}}!",
103
+ }
104
+
105
+ mock_get.side_effect = [
106
+ MockResponse(json_data=self.mock_prompt_response),
107
+ MockResponse(json_data=updated_prompt_response),
108
+ ]
109
+ mock_time.return_value = 1000.0
110
+
111
+ posthog = self.create_mock_posthog()
112
+ prompts = Prompts(posthog)
113
+
114
+ # First call - fetches from API
115
+ result1 = prompts.get("test-prompt", cache_ttl_seconds=60)
116
+ self.assertEqual(result1, self.mock_prompt_response["prompt"])
117
+ self.assertEqual(mock_get.call_count, 1)
118
+
119
+ # Advance time past TTL
120
+ mock_time.return_value = 1061.0
121
+
122
+ # Second call - should refetch
123
+ result2 = prompts.get("test-prompt", cache_ttl_seconds=60)
124
+ self.assertEqual(result2, updated_prompt_response["prompt"])
125
+ self.assertEqual(mock_get.call_count, 2)
126
+
127
+ @patch("posthog.ai.prompts._get_session")
128
+ @patch("posthog.ai.prompts.time.time")
129
+ @patch("posthog.ai.prompts.log")
130
+ def test_use_stale_cache_on_fetch_failure_with_warning(
131
+ self, mock_log, mock_time, mock_get_session
132
+ ):
133
+ """Should use stale cache on fetch failure with warning."""
134
+ mock_get = mock_get_session.return_value.get
135
+ mock_get.side_effect = [
136
+ MockResponse(json_data=self.mock_prompt_response),
137
+ Exception("Network error"),
138
+ ]
139
+ mock_time.return_value = 1000.0
140
+
141
+ posthog = self.create_mock_posthog()
142
+ prompts = Prompts(posthog)
143
+
144
+ # First call - populates cache
145
+ result1 = prompts.get("test-prompt", cache_ttl_seconds=60)
146
+ self.assertEqual(result1, self.mock_prompt_response["prompt"])
147
+
148
+ # Advance time past TTL
149
+ mock_time.return_value = 1061.0
150
+
151
+ # Second call - should use stale cache
152
+ result2 = prompts.get("test-prompt", cache_ttl_seconds=60)
153
+ self.assertEqual(result2, self.mock_prompt_response["prompt"])
154
+
155
+ # Check warning was logged
156
+ mock_log.warning.assert_called()
157
+ warning_call = mock_log.warning.call_args
158
+ self.assertIn("using stale cache", warning_call[0][0])
159
+
160
+ @patch("posthog.ai.prompts._get_session")
161
+ @patch("posthog.ai.prompts.log")
162
+ def test_use_fallback_when_no_cache_and_fetch_fails_with_warning(
163
+ self, mock_log, mock_get_session
164
+ ):
165
+ """Should use fallback when no cache and fetch fails with warning."""
166
+ mock_get = mock_get_session.return_value.get
167
+ mock_get.side_effect = Exception("Network error")
168
+
169
+ posthog = self.create_mock_posthog()
170
+ prompts = Prompts(posthog)
171
+
172
+ fallback = "Default system prompt."
173
+ result = prompts.get("test-prompt", fallback=fallback)
174
+
175
+ self.assertEqual(result, fallback)
176
+
177
+ # Check warning was logged
178
+ mock_log.warning.assert_called()
179
+ warning_call = mock_log.warning.call_args
180
+ self.assertIn("using fallback", warning_call[0][0])
181
+
182
+ @patch("posthog.ai.prompts._get_session")
183
+ def test_throw_when_no_cache_no_fallback_and_fetch_fails(self, mock_get_session):
184
+ """Should throw when no cache, no fallback, and fetch fails."""
185
+ mock_get = mock_get_session.return_value.get
186
+ mock_get.side_effect = Exception("Network error")
187
+
188
+ posthog = self.create_mock_posthog()
189
+ prompts = Prompts(posthog)
190
+
191
+ with self.assertRaises(Exception) as context:
192
+ prompts.get("test-prompt")
193
+
194
+ self.assertIn("Network error", str(context.exception))
195
+
196
+ @patch("posthog.ai.prompts._get_session")
197
+ def test_handle_404_response(self, mock_get_session):
198
+ """Should handle 404 response."""
199
+ mock_get = mock_get_session.return_value.get
200
+ mock_get.return_value = MockResponse(status_code=404, ok=False)
201
+
202
+ posthog = self.create_mock_posthog()
203
+ prompts = Prompts(posthog)
204
+
205
+ with self.assertRaises(Exception) as context:
206
+ prompts.get("nonexistent-prompt")
207
+
208
+ self.assertIn('Prompt "nonexistent-prompt" not found', str(context.exception))
209
+
210
+ @patch("posthog.ai.prompts._get_session")
211
+ def test_handle_403_response(self, mock_get_session):
212
+ """Should handle 403 response."""
213
+ mock_get = mock_get_session.return_value.get
214
+ mock_get.return_value = MockResponse(status_code=403, ok=False)
215
+
216
+ posthog = self.create_mock_posthog()
217
+ prompts = Prompts(posthog)
218
+
219
+ with self.assertRaises(Exception) as context:
220
+ prompts.get("restricted-prompt")
221
+
222
+ self.assertIn(
223
+ 'Access denied for prompt "restricted-prompt"', str(context.exception)
224
+ )
225
+
226
+ def test_throw_when_no_personal_api_key_configured(self):
227
+ """Should throw when no personal_api_key is configured."""
228
+ posthog = self.create_mock_posthog(personal_api_key=None)
229
+ prompts = Prompts(posthog)
230
+
231
+ with self.assertRaises(Exception) as context:
232
+ prompts.get("test-prompt")
233
+
234
+ self.assertIn(
235
+ "personal_api_key is required to fetch prompts", str(context.exception)
236
+ )
237
+
238
+ @patch("posthog.ai.prompts._get_session")
239
+ def test_throw_when_api_returns_invalid_response_format(self, mock_get_session):
240
+ """Should throw when API returns invalid response format."""
241
+ mock_get = mock_get_session.return_value.get
242
+ mock_get.return_value = MockResponse(json_data={"invalid": "response"})
243
+
244
+ posthog = self.create_mock_posthog()
245
+ prompts = Prompts(posthog)
246
+
247
+ with self.assertRaises(Exception) as context:
248
+ prompts.get("test-prompt")
249
+
250
+ self.assertIn("Invalid response format", str(context.exception))
251
+
252
+ @patch("posthog.ai.prompts._get_session")
253
+ def test_use_custom_host_from_posthog_options(self, mock_get_session):
254
+ """Should use custom host from PostHog options."""
255
+ mock_get = mock_get_session.return_value.get
256
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
257
+
258
+ posthog = self.create_mock_posthog(host="https://eu.i.posthog.com")
259
+ prompts = Prompts(posthog)
260
+
261
+ prompts.get("test-prompt")
262
+
263
+ call_args = mock_get.call_args
264
+ self.assertTrue(
265
+ call_args[0][0].startswith("https://eu.i.posthog.com/"),
266
+ f"Expected URL to start with 'https://eu.i.posthog.com/', got {call_args[0][0]}",
267
+ )
268
+
269
+ @patch("posthog.ai.prompts._get_session")
270
+ @patch("posthog.ai.prompts.time.time")
271
+ def test_use_default_cache_ttl_5_minutes(self, mock_time, mock_get_session):
272
+ """Should use default cache TTL (5 minutes) when not specified."""
273
+ mock_get = mock_get_session.return_value.get
274
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
275
+ mock_time.return_value = 1000.0
276
+
277
+ posthog = self.create_mock_posthog()
278
+ prompts = Prompts(posthog)
279
+
280
+ # First call
281
+ prompts.get("test-prompt")
282
+ self.assertEqual(mock_get.call_count, 1)
283
+
284
+ # Advance time by 4 minutes (within default 5-minute TTL)
285
+ mock_time.return_value = 1000.0 + (4 * 60)
286
+
287
+ # Second call - should use cache
288
+ prompts.get("test-prompt")
289
+ self.assertEqual(mock_get.call_count, 1)
290
+
291
+ # Advance time past 5-minute TTL
292
+ mock_time.return_value = 1000.0 + (6 * 60)
293
+
294
+ # Third call - should refetch
295
+ prompts.get("test-prompt")
296
+ self.assertEqual(mock_get.call_count, 2)
297
+
298
+ @patch("posthog.ai.prompts._get_session")
299
+ @patch("posthog.ai.prompts.time.time")
300
+ def test_use_custom_default_cache_ttl_from_constructor(
301
+ self, mock_time, mock_get_session
302
+ ):
303
+ """Should use custom default cache TTL from constructor."""
304
+ mock_get = mock_get_session.return_value.get
305
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
306
+ mock_time.return_value = 1000.0
307
+
308
+ posthog = self.create_mock_posthog()
309
+ prompts = Prompts(posthog, default_cache_ttl_seconds=60)
310
+
311
+ # First call
312
+ prompts.get("test-prompt")
313
+ self.assertEqual(mock_get.call_count, 1)
314
+
315
+ # Advance time past custom TTL
316
+ mock_time.return_value = 1061.0
317
+
318
+ # Second call - should refetch
319
+ prompts.get("test-prompt")
320
+ self.assertEqual(mock_get.call_count, 2)
321
+
322
+ @patch("posthog.ai.prompts._get_session")
323
+ def test_url_encode_prompt_names_with_special_characters(self, mock_get_session):
324
+ """Should URL-encode prompt names with special characters."""
325
+ mock_get = mock_get_session.return_value.get
326
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
327
+
328
+ posthog = self.create_mock_posthog()
329
+ prompts = Prompts(posthog)
330
+
331
+ prompts.get("prompt with spaces/and/slashes")
332
+
333
+ call_args = mock_get.call_args
334
+ self.assertEqual(
335
+ call_args[0][0],
336
+ "https://us.i.posthog.com/api/projects/@current/llm_prompts/name/prompt%20with%20spaces%2Fand%2Fslashes/",
337
+ )
338
+
339
+ @patch("posthog.ai.prompts._get_session")
340
+ def test_work_with_direct_options_no_posthog_client(self, mock_get_session):
341
+ """Should work with direct options (no PostHog client)."""
342
+ mock_get = mock_get_session.return_value.get
343
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
344
+
345
+ prompts = Prompts(personal_api_key="phx_direct_key")
346
+
347
+ result = prompts.get("test-prompt")
348
+
349
+ self.assertEqual(result, self.mock_prompt_response["prompt"])
350
+ call_args = mock_get.call_args
351
+ self.assertEqual(
352
+ call_args[0][0],
353
+ "https://us.i.posthog.com/api/projects/@current/llm_prompts/name/test-prompt/",
354
+ )
355
+ self.assertEqual(
356
+ call_args[1]["headers"]["Authorization"], "Bearer phx_direct_key"
357
+ )
358
+
359
+ @patch("posthog.ai.prompts._get_session")
360
+ def test_use_custom_host_from_direct_options(self, mock_get_session):
361
+ """Should use custom host from direct options."""
362
+ mock_get = mock_get_session.return_value.get
363
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
364
+
365
+ prompts = Prompts(
366
+ personal_api_key="phx_direct_key", host="https://eu.i.posthog.com"
367
+ )
368
+
369
+ prompts.get("test-prompt")
370
+
371
+ call_args = mock_get.call_args
372
+ self.assertEqual(
373
+ call_args[0][0],
374
+ "https://eu.i.posthog.com/api/projects/@current/llm_prompts/name/test-prompt/",
375
+ )
376
+
377
+ @patch("posthog.ai.prompts._get_session")
378
+ @patch("posthog.ai.prompts.time.time")
379
+ def test_use_custom_default_cache_ttl_from_direct_options(
380
+ self, mock_time, mock_get_session
381
+ ):
382
+ """Should use custom default cache TTL from direct options."""
383
+ mock_get = mock_get_session.return_value.get
384
+ mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
385
+ mock_time.return_value = 1000.0
386
+
387
+ prompts = Prompts(
388
+ personal_api_key="phx_direct_key", default_cache_ttl_seconds=60
389
+ )
390
+
391
+ # First call
392
+ prompts.get("test-prompt")
393
+ self.assertEqual(mock_get.call_count, 1)
394
+
395
+ # Advance time past custom TTL
396
+ mock_time.return_value = 1061.0
397
+
398
+ # Second call - should refetch
399
+ prompts.get("test-prompt")
400
+ self.assertEqual(mock_get.call_count, 2)
401
+
402
+
403
+ class TestPromptsCompile(TestPrompts):
404
+ """Tests for the Prompts.compile() method."""
405
+
406
+ def test_replace_a_single_variable(self):
407
+ """Should replace a single variable."""
408
+ posthog = self.create_mock_posthog()
409
+ prompts = Prompts(posthog)
410
+
411
+ result = prompts.compile("Hello, {{name}}!", {"name": "World"})
412
+
413
+ self.assertEqual(result, "Hello, World!")
414
+
415
+ def test_replace_multiple_variables(self):
416
+ """Should replace multiple variables."""
417
+ posthog = self.create_mock_posthog()
418
+ prompts = Prompts(posthog)
419
+
420
+ result = prompts.compile(
421
+ "Hello, {{name}}! Welcome to {{company}}. Your tier is {{tier}}.",
422
+ {"name": "John", "company": "Acme Corp", "tier": "premium"},
423
+ )
424
+
425
+ self.assertEqual(
426
+ result, "Hello, John! Welcome to Acme Corp. Your tier is premium."
427
+ )
428
+
429
+ def test_handle_numbers(self):
430
+ """Should handle numbers."""
431
+ posthog = self.create_mock_posthog()
432
+ prompts = Prompts(posthog)
433
+
434
+ result = prompts.compile("You have {{count}} items.", {"count": 42})
435
+
436
+ self.assertEqual(result, "You have 42 items.")
437
+
438
+ def test_handle_booleans(self):
439
+ """Should handle booleans."""
440
+ posthog = self.create_mock_posthog()
441
+ prompts = Prompts(posthog)
442
+
443
+ result = prompts.compile("Feature enabled: {{enabled}}", {"enabled": True})
444
+
445
+ self.assertEqual(result, "Feature enabled: True")
446
+
447
+ def test_leave_unmatched_variables_unchanged(self):
448
+ """Should leave unmatched variables unchanged."""
449
+ posthog = self.create_mock_posthog()
450
+ prompts = Prompts(posthog)
451
+
452
+ result = prompts.compile(
453
+ "Hello, {{name}}! Your {{unknown}} is ready.", {"name": "World"}
454
+ )
455
+
456
+ self.assertEqual(result, "Hello, World! Your {{unknown}} is ready.")
457
+
458
+ def test_handle_prompts_with_no_variables(self):
459
+ """Should handle prompts with no variables."""
460
+ posthog = self.create_mock_posthog()
461
+ prompts = Prompts(posthog)
462
+
463
+ result = prompts.compile("You are a helpful assistant.", {})
464
+
465
+ self.assertEqual(result, "You are a helpful assistant.")
466
+
467
+ def test_handle_empty_variables_dict(self):
468
+ """Should handle empty variables dict."""
469
+ posthog = self.create_mock_posthog()
470
+ prompts = Prompts(posthog)
471
+
472
+ result = prompts.compile("Hello, {{name}}!", {})
473
+
474
+ self.assertEqual(result, "Hello, {{name}}!")
475
+
476
+ def test_handle_multiple_occurrences_of_same_variable(self):
477
+ """Should handle multiple occurrences of the same variable."""
478
+ posthog = self.create_mock_posthog()
479
+ prompts = Prompts(posthog)
480
+
481
+ result = prompts.compile(
482
+ "Hello, {{name}}! Goodbye, {{name}}!", {"name": "World"}
483
+ )
484
+
485
+ self.assertEqual(result, "Hello, World! Goodbye, World!")
486
+
487
+ def test_work_with_direct_options_initialization(self):
488
+ """Should work with direct options initialization."""
489
+ prompts = Prompts(personal_api_key="phx_test_key")
490
+
491
+ result = prompts.compile("Hello, {{name}}!", {"name": "World"})
492
+
493
+ self.assertEqual(result, "Hello, World!")
494
+
495
+ def test_handle_variables_with_hyphens(self):
496
+ """Should handle variables with hyphens."""
497
+ prompts = Prompts(personal_api_key="phx_test_key")
498
+
499
+ result = prompts.compile("User ID: {{user-id}}", {"user-id": "12345"})
500
+
501
+ self.assertEqual(result, "User ID: 12345")
502
+
503
+ def test_handle_variables_with_dots(self):
504
+ """Should handle variables with dots."""
505
+ prompts = Prompts(personal_api_key="phx_test_key")
506
+
507
+ result = prompts.compile("Company: {{company.name}}", {"company.name": "Acme"})
508
+
509
+ self.assertEqual(result, "Company: Acme")
510
+
511
+
512
+ class TestPromptsClearCache(TestPrompts):
513
+ """Tests for the Prompts.clear_cache() method."""
514
+
515
+ @patch("posthog.ai.prompts._get_session")
516
+ def test_clear_a_specific_prompt_from_cache(self, mock_get_session):
517
+ """Should clear a specific prompt from cache."""
518
+ mock_get = mock_get_session.return_value.get
519
+ other_prompt_response = {**self.mock_prompt_response, "name": "other-prompt"}
520
+
521
+ mock_get.side_effect = [
522
+ MockResponse(json_data=self.mock_prompt_response),
523
+ MockResponse(json_data=other_prompt_response),
524
+ MockResponse(json_data=self.mock_prompt_response),
525
+ ]
526
+
527
+ posthog = self.create_mock_posthog()
528
+ prompts = Prompts(posthog)
529
+
530
+ # Populate cache with two prompts
531
+ prompts.get("test-prompt")
532
+ prompts.get("other-prompt")
533
+ self.assertEqual(mock_get.call_count, 2)
534
+
535
+ # Clear only test-prompt
536
+ prompts.clear_cache("test-prompt")
537
+
538
+ # test-prompt should be refetched
539
+ prompts.get("test-prompt")
540
+ self.assertEqual(mock_get.call_count, 3)
541
+
542
+ # other-prompt should still be cached
543
+ prompts.get("other-prompt")
544
+ self.assertEqual(mock_get.call_count, 3)
545
+
546
+ @patch("posthog.ai.prompts._get_session")
547
+ def test_clear_all_prompts_from_cache(self, mock_get_session):
548
+ """Should clear all prompts from cache when no name is provided."""
549
+ mock_get = mock_get_session.return_value.get
550
+ other_prompt_response = {**self.mock_prompt_response, "name": "other-prompt"}
551
+
552
+ mock_get.side_effect = [
553
+ MockResponse(json_data=self.mock_prompt_response),
554
+ MockResponse(json_data=other_prompt_response),
555
+ MockResponse(json_data=self.mock_prompt_response),
556
+ MockResponse(json_data=other_prompt_response),
557
+ ]
558
+
559
+ posthog = self.create_mock_posthog()
560
+ prompts = Prompts(posthog)
561
+
562
+ # Populate cache with two prompts
563
+ prompts.get("test-prompt")
564
+ prompts.get("other-prompt")
565
+ self.assertEqual(mock_get.call_count, 2)
566
+
567
+ # Clear all cache
568
+ prompts.clear_cache()
569
+
570
+ # Both prompts should be refetched
571
+ prompts.get("test-prompt")
572
+ prompts.get("other-prompt")
573
+ self.assertEqual(mock_get.call_count, 4)
574
+
575
+
576
+ if __name__ == "__main__":
577
+ unittest.main()