posthoganalytics 7.5.0__tar.gz → 7.6.0__tar.gz

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.
Files changed (65) hide show
  1. {posthoganalytics-7.5.0/posthoganalytics.egg-info → posthoganalytics-7.6.0}/PKG-INFO +9 -1
  2. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/README.md +8 -0
  3. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/__init__.py +35 -0
  4. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/client.py +38 -0
  5. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/consumer.py +6 -2
  6. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/contexts.py +44 -0
  7. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_client.py +115 -0
  8. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_consumer.py +86 -83
  9. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/version.py +1 -1
  10. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0/posthoganalytics.egg-info}/PKG-INFO +9 -1
  11. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/LICENSE +0 -0
  12. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/MANIFEST.in +0 -0
  13. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/__init__.py +0 -0
  14. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/anthropic/__init__.py +0 -0
  15. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/anthropic/anthropic.py +0 -0
  16. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/anthropic/anthropic_async.py +0 -0
  17. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/anthropic/anthropic_converter.py +0 -0
  18. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/anthropic/anthropic_providers.py +0 -0
  19. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/gemini/__init__.py +0 -0
  20. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/gemini/gemini.py +0 -0
  21. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/gemini/gemini_async.py +0 -0
  22. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/gemini/gemini_converter.py +0 -0
  23. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/langchain/__init__.py +0 -0
  24. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/langchain/callbacks.py +0 -0
  25. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/openai/__init__.py +0 -0
  26. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/openai/openai.py +0 -0
  27. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/openai/openai_async.py +0 -0
  28. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/openai/openai_converter.py +0 -0
  29. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/openai/openai_providers.py +0 -0
  30. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/sanitization.py +0 -0
  31. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/types.py +0 -0
  32. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/ai/utils.py +0 -0
  33. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/args.py +0 -0
  34. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/exception_capture.py +0 -0
  35. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/exception_utils.py +0 -0
  36. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/feature_flags.py +0 -0
  37. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/flag_definition_cache.py +0 -0
  38. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/integrations/__init__.py +0 -0
  39. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/integrations/django.py +0 -0
  40. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/poller.py +0 -0
  41. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/py.typed +0 -0
  42. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/request.py +0 -0
  43. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/__init__.py +0 -0
  44. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_before_send.py +0 -0
  45. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_contexts.py +0 -0
  46. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_exception_capture.py +0 -0
  47. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_feature_flag.py +0 -0
  48. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_feature_flag_result.py +0 -0
  49. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_feature_flags.py +0 -0
  50. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_flag_definition_cache.py +0 -0
  51. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_module.py +0 -0
  52. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_request.py +0 -0
  53. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_size_limited_dict.py +0 -0
  54. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_types.py +0 -0
  55. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/test/test_utils.py +0 -0
  56. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/types.py +0 -0
  57. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics/utils.py +0 -0
  58. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics.egg-info/SOURCES.txt +0 -0
  59. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics.egg-info/dependency_links.txt +0 -0
  60. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics.egg-info/requires.txt +0 -0
  61. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/posthoganalytics.egg-info/top_level.txt +0 -0
  62. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/pyproject.toml +0 -0
  63. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/setup.cfg +0 -0
  64. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/setup.py +0 -0
  65. {posthoganalytics-7.5.0 → posthoganalytics-7.6.0}/setup_analytics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthoganalytics
3
- Version: 7.5.0
3
+ Version: 7.6.0
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
@@ -87,6 +87,14 @@ Dynamic: maintainer
87
87
 
88
88
  Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
89
89
 
90
+ ## Python Version Support
91
+
92
+ | SDK Version | Python Versions Supported | Notes |
93
+ |-------------|---------------------------|-------|
94
+ | 7.3.1+ | 3.10, 3.11, 3.12, 3.13, 3.14 | Added Python 3.14 support |
95
+ | 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 | Dropped Python 3.9 support |
96
+ | 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 | Python 3.9+ required |
97
+
90
98
  ## Development
91
99
 
92
100
  ### Testing Locally
@@ -12,6 +12,14 @@
12
12
 
13
13
  Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
14
14
 
15
+ ## Python Version Support
16
+
17
+ | SDK Version | Python Versions Supported | Notes |
18
+ |-------------|---------------------------|-------|
19
+ | 7.3.1+ | 3.10, 3.11, 3.12, 3.13, 3.14 | Added Python 3.14 support |
20
+ | 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 | Dropped Python 3.9 support |
21
+ | 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 | Python 3.9+ required |
22
+
15
23
  ## Development
16
24
 
17
25
  ### Testing Locally
@@ -23,6 +23,9 @@ from posthoganalytics.contexts import (
23
23
  from posthoganalytics.contexts import (
24
24
  set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context,
25
25
  )
26
+ from posthoganalytics.contexts import (
27
+ set_context_device_id as inner_set_context_device_id,
28
+ )
26
29
  from posthoganalytics.contexts import (
27
30
  set_context_session as inner_set_context_session,
28
31
  )
@@ -133,6 +136,26 @@ def set_context_session(session_id: str):
133
136
  return inner_set_context_session(session_id)
134
137
 
135
138
 
139
+ def set_context_device_id(device_id: str):
140
+ """
141
+ Set the device ID for the current context, associating all feature flag requests
142
+ in this or child contexts with the given device ID.
143
+
144
+ Args:
145
+ device_id: The device ID to associate with the current context and its children
146
+
147
+ Examples:
148
+ ```python
149
+ from posthoganalytics import set_context_device_id
150
+ set_context_device_id("device_123")
151
+ ```
152
+
153
+ Category:
154
+ Contexts
155
+ """
156
+ return inner_set_context_device_id(device_id)
157
+
158
+
136
159
  def identify_context(distinct_id: str):
137
160
  """
138
161
  Identify the current context with a distinct ID.
@@ -483,6 +506,7 @@ def feature_enabled(
483
506
  only_evaluate_locally=False, # type: bool
484
507
  send_feature_flag_events=True, # type: bool
485
508
  disable_geoip=None, # type: Optional[bool]
509
+ device_id=None, # type: Optional[str]
486
510
  ):
487
511
  # type: (...) -> bool
488
512
  """
@@ -522,6 +546,7 @@ def feature_enabled(
522
546
  only_evaluate_locally=only_evaluate_locally,
523
547
  send_feature_flag_events=send_feature_flag_events,
524
548
  disable_geoip=disable_geoip,
549
+ device_id=device_id,
525
550
  )
526
551
 
527
552
 
@@ -534,6 +559,7 @@ def get_feature_flag(
534
559
  only_evaluate_locally=False, # type: bool
535
560
  send_feature_flag_events=True, # type: bool
536
561
  disable_geoip=None, # type: Optional[bool]
562
+ device_id=None, # type: Optional[str]
537
563
  ) -> Optional[FeatureFlag]:
538
564
  """
539
565
  Get feature flag variant for users. Used with experiments.
@@ -572,6 +598,7 @@ def get_feature_flag(
572
598
  only_evaluate_locally=only_evaluate_locally,
573
599
  send_feature_flag_events=send_feature_flag_events,
574
600
  disable_geoip=disable_geoip,
601
+ device_id=device_id,
575
602
  )
576
603
 
577
604
 
@@ -582,6 +609,7 @@ def get_all_flags(
582
609
  group_properties=None, # type: Optional[dict]
583
610
  only_evaluate_locally=False, # type: bool
584
611
  disable_geoip=None, # type: Optional[bool]
612
+ device_id=None, # type: Optional[str]
585
613
  ) -> Optional[dict[str, FeatureFlag]]:
586
614
  """
587
615
  Get all flags for a given user.
@@ -614,6 +642,7 @@ def get_all_flags(
614
642
  group_properties=group_properties or {},
615
643
  only_evaluate_locally=only_evaluate_locally,
616
644
  disable_geoip=disable_geoip,
645
+ device_id=device_id,
617
646
  )
618
647
 
619
648
 
@@ -626,6 +655,7 @@ def get_feature_flag_result(
626
655
  only_evaluate_locally=False,
627
656
  send_feature_flag_events=True,
628
657
  disable_geoip=None, # type: Optional[bool]
658
+ device_id=None, # type: Optional[str]
629
659
  ):
630
660
  # type: (...) -> Optional[FeatureFlagResult]
631
661
  """
@@ -657,6 +687,7 @@ def get_feature_flag_result(
657
687
  only_evaluate_locally=only_evaluate_locally,
658
688
  send_feature_flag_events=send_feature_flag_events,
659
689
  disable_geoip=disable_geoip,
690
+ device_id=device_id,
660
691
  )
661
692
 
662
693
 
@@ -670,6 +701,7 @@ def get_feature_flag_payload(
670
701
  only_evaluate_locally=False,
671
702
  send_feature_flag_events=True,
672
703
  disable_geoip=None, # type: Optional[bool]
704
+ device_id=None, # type: Optional[str]
673
705
  ) -> Optional[str]:
674
706
  return _proxy(
675
707
  "get_feature_flag_payload",
@@ -682,6 +714,7 @@ def get_feature_flag_payload(
682
714
  only_evaluate_locally=only_evaluate_locally,
683
715
  send_feature_flag_events=send_feature_flag_events,
684
716
  disable_geoip=disable_geoip,
717
+ device_id=device_id,
685
718
  )
686
719
 
687
720
 
@@ -712,6 +745,7 @@ def get_all_flags_and_payloads(
712
745
  group_properties=None, # type: Optional[dict]
713
746
  only_evaluate_locally=False,
714
747
  disable_geoip=None, # type: Optional[bool]
748
+ device_id=None, # type: Optional[str]
715
749
  ) -> FlagsAndPayloads:
716
750
  return _proxy(
717
751
  "get_all_flags_and_payloads",
@@ -721,6 +755,7 @@ def get_all_flags_and_payloads(
721
755
  group_properties=group_properties or {},
722
756
  only_evaluate_locally=only_evaluate_locally,
723
757
  disable_geoip=disable_geoip,
758
+ device_id=device_id,
724
759
  )
725
760
 
726
761
 
@@ -18,6 +18,7 @@ from posthoganalytics.contexts import (
18
18
  get_capture_exception_code_variables_context,
19
19
  get_code_variables_ignore_patterns_context,
20
20
  get_code_variables_mask_patterns_context,
21
+ get_context_device_id,
21
22
  get_context_distinct_id,
22
23
  get_context_session_id,
23
24
  new_context,
@@ -382,6 +383,7 @@ class Client(object):
382
383
  group_properties=None,
383
384
  disable_geoip=None,
384
385
  flag_keys_to_evaluate: Optional[list[str]] = None,
386
+ device_id: Optional[str] = None,
385
387
  ) -> dict[str, Union[bool, str]]:
386
388
  """
387
389
  Get feature flag variants for a user by calling decide.
@@ -394,6 +396,7 @@ class Client(object):
394
396
  disable_geoip: Whether to disable GeoIP for this request.
395
397
  flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
396
398
  only these flags will be evaluated, improving performance.
399
+ device_id: The device ID for this request.
397
400
 
398
401
  Category:
399
402
  Feature flags
@@ -405,6 +408,7 @@ class Client(object):
405
408
  group_properties,
406
409
  disable_geoip,
407
410
  flag_keys_to_evaluate,
411
+ device_id=device_id,
408
412
  )
409
413
  return to_values(resp_data) or {}
410
414
 
@@ -416,6 +420,7 @@ class Client(object):
416
420
  group_properties=None,
417
421
  disable_geoip=None,
418
422
  flag_keys_to_evaluate: Optional[list[str]] = None,
423
+ device_id: Optional[str] = None,
419
424
  ) -> dict[str, str]:
420
425
  """
421
426
  Get feature flag payloads for a user by calling decide.
@@ -428,6 +433,7 @@ class Client(object):
428
433
  disable_geoip: Whether to disable GeoIP for this request.
429
434
  flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
430
435
  only these flags will be evaluated, improving performance.
436
+ device_id: The device ID for this request.
431
437
 
432
438
  Examples:
433
439
  ```python
@@ -444,6 +450,7 @@ class Client(object):
444
450
  group_properties,
445
451
  disable_geoip,
446
452
  flag_keys_to_evaluate,
453
+ device_id=device_id,
447
454
  )
448
455
  return to_payloads(resp_data) or {}
449
456
 
@@ -455,6 +462,7 @@ class Client(object):
455
462
  group_properties=None,
456
463
  disable_geoip=None,
457
464
  flag_keys_to_evaluate: Optional[list[str]] = None,
465
+ device_id: Optional[str] = None,
458
466
  ) -> FlagsAndPayloads:
459
467
  """
460
468
  Get feature flags and payloads for a user by calling decide.
@@ -467,6 +475,7 @@ class Client(object):
467
475
  disable_geoip: Whether to disable GeoIP for this request.
468
476
  flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
469
477
  only these flags will be evaluated, improving performance.
478
+ device_id: The device ID for this request.
470
479
 
471
480
  Examples:
472
481
  ```python
@@ -483,6 +492,7 @@ class Client(object):
483
492
  group_properties,
484
493
  disable_geoip,
485
494
  flag_keys_to_evaluate,
495
+ device_id=device_id,
486
496
  )
487
497
  return to_flags_and_payloads(resp)
488
498
 
@@ -494,6 +504,7 @@ class Client(object):
494
504
  group_properties=None,
495
505
  disable_geoip=None,
496
506
  flag_keys_to_evaluate: Optional[list[str]] = None,
507
+ device_id: Optional[str] = None,
497
508
  ) -> FlagsResponse:
498
509
  """
499
510
  Get feature flags decision.
@@ -506,6 +517,7 @@ class Client(object):
506
517
  disable_geoip: Whether to disable GeoIP for this request.
507
518
  flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
508
519
  only these flags will be evaluated, improving performance.
520
+ device_id: The device ID for this request.
509
521
 
510
522
  Examples:
511
523
  ```python
@@ -522,6 +534,9 @@ class Client(object):
522
534
  if distinct_id is None:
523
535
  distinct_id = get_context_distinct_id()
524
536
 
537
+ if device_id is None:
538
+ device_id = get_context_device_id()
539
+
525
540
  if disable_geoip is None:
526
541
  disable_geoip = self.disable_geoip
527
542
 
@@ -534,6 +549,7 @@ class Client(object):
534
549
  "person_properties": person_properties,
535
550
  "group_properties": group_properties,
536
551
  "geoip_disable": disable_geoip,
552
+ "device_id": device_id,
537
553
  }
538
554
 
539
555
  if flag_keys_to_evaluate:
@@ -1464,6 +1480,7 @@ class Client(object):
1464
1480
  only_evaluate_locally=False,
1465
1481
  send_feature_flag_events=True,
1466
1482
  disable_geoip=None,
1483
+ device_id: Optional[str] = None,
1467
1484
  ):
1468
1485
  """
1469
1486
  Check if a feature flag is enabled for a user.
@@ -1477,6 +1494,7 @@ class Client(object):
1477
1494
  only_evaluate_locally: Whether to only evaluate locally.
1478
1495
  send_feature_flag_events: Whether to send feature flag events.
1479
1496
  disable_geoip: Whether to disable GeoIP for this request.
1497
+ device_id: The device ID for this request.
1480
1498
 
1481
1499
  Examples:
1482
1500
  ```python
@@ -1499,6 +1517,7 @@ class Client(object):
1499
1517
  only_evaluate_locally=only_evaluate_locally,
1500
1518
  send_feature_flag_events=send_feature_flag_events,
1501
1519
  disable_geoip=disable_geoip,
1520
+ device_id=device_id,
1502
1521
  )
1503
1522
 
1504
1523
  if response is None:
@@ -1530,6 +1549,7 @@ class Client(object):
1530
1549
  only_evaluate_locally=False,
1531
1550
  send_feature_flag_events=True,
1532
1551
  disable_geoip=None,
1552
+ device_id: Optional[str] = None,
1533
1553
  ) -> Optional[FeatureFlagResult]:
1534
1554
  if self.disabled:
1535
1555
  return None
@@ -1584,6 +1604,7 @@ class Client(object):
1584
1604
  person_properties,
1585
1605
  group_properties,
1586
1606
  disable_geoip,
1607
+ device_id=device_id,
1587
1608
  )
1588
1609
  )
1589
1610
  errors = []
@@ -1656,6 +1677,7 @@ class Client(object):
1656
1677
  only_evaluate_locally=False,
1657
1678
  send_feature_flag_events=True,
1658
1679
  disable_geoip=None,
1680
+ device_id: Optional[str] = None,
1659
1681
  ) -> Optional[FeatureFlagResult]:
1660
1682
  """
1661
1683
  Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely
@@ -1680,6 +1702,7 @@ class Client(object):
1680
1702
  only_evaluate_locally: Whether to only evaluate locally.
1681
1703
  send_feature_flag_events: Whether to send feature flag events.
1682
1704
  disable_geoip: Whether to disable GeoIP for this request.
1705
+ device_id: The device ID for this request.
1683
1706
 
1684
1707
  Returns:
1685
1708
  Optional[FeatureFlagResult]: The feature flag result or None if disabled/not found.
@@ -1693,6 +1716,7 @@ class Client(object):
1693
1716
  only_evaluate_locally=only_evaluate_locally,
1694
1717
  send_feature_flag_events=send_feature_flag_events,
1695
1718
  disable_geoip=disable_geoip,
1719
+ device_id=device_id,
1696
1720
  )
1697
1721
 
1698
1722
  def get_feature_flag(
@@ -1706,6 +1730,7 @@ class Client(object):
1706
1730
  only_evaluate_locally=False,
1707
1731
  send_feature_flag_events=True,
1708
1732
  disable_geoip=None,
1733
+ device_id: Optional[str] = None,
1709
1734
  ) -> Optional[FlagValue]:
1710
1735
  """
1711
1736
  Get multivariate feature flag value for a user.
@@ -1719,6 +1744,7 @@ class Client(object):
1719
1744
  only_evaluate_locally: Whether to only evaluate locally.
1720
1745
  send_feature_flag_events: Whether to send feature flag events.
1721
1746
  disable_geoip: Whether to disable GeoIP for this request.
1747
+ device_id: The device ID for this request.
1722
1748
 
1723
1749
  Examples:
1724
1750
  ```python
@@ -1741,6 +1767,7 @@ class Client(object):
1741
1767
  only_evaluate_locally=only_evaluate_locally,
1742
1768
  send_feature_flag_events=send_feature_flag_events,
1743
1769
  disable_geoip=disable_geoip,
1770
+ device_id=device_id,
1744
1771
  )
1745
1772
  return feature_flag_result.get_value() if feature_flag_result else None
1746
1773
 
@@ -1794,6 +1821,7 @@ class Client(object):
1794
1821
  only_evaluate_locally=False,
1795
1822
  send_feature_flag_events=False,
1796
1823
  disable_geoip=None,
1824
+ device_id: Optional[str] = None,
1797
1825
  ):
1798
1826
  """
1799
1827
  Get the payload for a feature flag.
@@ -1808,6 +1836,7 @@ class Client(object):
1808
1836
  only_evaluate_locally: Whether to only evaluate locally.
1809
1837
  send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events.
1810
1838
  disable_geoip: Whether to disable GeoIP for this request.
1839
+ device_id: The device ID for this request.
1811
1840
 
1812
1841
  Examples:
1813
1842
  ```python
@@ -1840,6 +1869,7 @@ class Client(object):
1840
1869
  only_evaluate_locally=only_evaluate_locally,
1841
1870
  send_feature_flag_events=send_feature_flag_events,
1842
1871
  disable_geoip=disable_geoip,
1872
+ device_id=device_id,
1843
1873
  )
1844
1874
  return feature_flag_result.payload if feature_flag_result else None
1845
1875
 
@@ -1851,6 +1881,7 @@ class Client(object):
1851
1881
  person_properties: dict[str, str],
1852
1882
  group_properties: dict[str, str],
1853
1883
  disable_geoip: Optional[bool],
1884
+ device_id: Optional[str] = None,
1854
1885
  ) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
1855
1886
  """
1856
1887
  Calls /flags and returns the flag details, request id, evaluated at timestamp,
@@ -1863,6 +1894,7 @@ class Client(object):
1863
1894
  group_properties,
1864
1895
  disable_geoip,
1865
1896
  flag_keys_to_evaluate=[key],
1897
+ device_id=device_id,
1866
1898
  )
1867
1899
  request_id = resp_data.get("requestId")
1868
1900
  evaluated_at = resp_data.get("evaluatedAt")
@@ -1987,6 +2019,7 @@ class Client(object):
1987
2019
  only_evaluate_locally=False,
1988
2020
  disable_geoip=None,
1989
2021
  flag_keys_to_evaluate: Optional[list[str]] = None,
2022
+ device_id: Optional[str] = None,
1990
2023
  ) -> Optional[dict[str, Union[bool, str]]]:
1991
2024
  """
1992
2025
  Get all feature flags for a user.
@@ -2000,6 +2033,7 @@ class Client(object):
2000
2033
  disable_geoip: Whether to disable GeoIP for this request.
2001
2034
  flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
2002
2035
  only these flags will be evaluated, improving performance.
2036
+ device_id: The device ID for this request.
2003
2037
 
2004
2038
  Examples:
2005
2039
  ```python
@@ -2017,6 +2051,7 @@ class Client(object):
2017
2051
  only_evaluate_locally=only_evaluate_locally,
2018
2052
  disable_geoip=disable_geoip,
2019
2053
  flag_keys_to_evaluate=flag_keys_to_evaluate,
2054
+ device_id=device_id,
2020
2055
  )
2021
2056
 
2022
2057
  return response["featureFlags"]
@@ -2031,6 +2066,7 @@ class Client(object):
2031
2066
  only_evaluate_locally=False,
2032
2067
  disable_geoip=None,
2033
2068
  flag_keys_to_evaluate: Optional[list[str]] = None,
2069
+ device_id: Optional[str] = None,
2034
2070
  ) -> FlagsAndPayloads:
2035
2071
  """
2036
2072
  Get all feature flags and their payloads for a user.
@@ -2044,6 +2080,7 @@ class Client(object):
2044
2080
  disable_geoip: Whether to disable GeoIP for this request.
2045
2081
  flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
2046
2082
  only these flags will be evaluated, improving performance.
2083
+ device_id: The device ID for this request.
2047
2084
 
2048
2085
  Examples:
2049
2086
  ```python
@@ -2079,6 +2116,7 @@ class Client(object):
2079
2116
  group_properties=group_properties,
2080
2117
  disable_geoip=disable_geoip,
2081
2118
  flag_keys_to_evaluate=flag_keys_to_evaluate,
2119
+ device_id=device_id,
2082
2120
  )
2083
2121
  return to_flags_and_payloads(decide_response)
2084
2122
  except Exception as e:
@@ -84,12 +84,16 @@ class Consumer(Thread):
84
84
  self.log.error("error uploading: %s", e)
85
85
  success = False
86
86
  if self.on_error:
87
- self.on_error(e, batch)
87
+ try:
88
+ self.on_error(e, batch)
89
+ except Exception as e:
90
+ self.log.error("on_error handler failed: %s", e)
88
91
  finally:
89
92
  # mark items as acknowledged from queue
90
93
  for item in batch:
91
94
  self.queue.task_done()
92
- return success
95
+
96
+ return success
93
97
 
94
98
  def next(self):
95
99
  """Return the next batch of items to upload."""
@@ -21,6 +21,7 @@ class ContextScope:
21
21
  self.capture_exceptions = capture_exceptions
22
22
  self.session_id: Optional[str] = None
23
23
  self.distinct_id: Optional[str] = None
24
+ self.device_id: Optional[str] = None
24
25
  self.tags: Dict[str, Any] = {}
25
26
  self.capture_exception_code_variables: Optional[bool] = None
26
27
  self.code_variables_mask_patterns: Optional[list] = None
@@ -32,6 +33,9 @@ class ContextScope:
32
33
  def set_distinct_id(self, distinct_id: str):
33
34
  self.distinct_id = distinct_id
34
35
 
36
+ def set_device_id(self, device_id: str):
37
+ self.device_id = device_id
38
+
35
39
  def add_tag(self, key: str, value: Any):
36
40
  self.tags[key] = value
37
41
 
@@ -61,6 +65,13 @@ class ContextScope:
61
65
  return self.parent.get_distinct_id()
62
66
  return None
63
67
 
68
+ def get_device_id(self) -> Optional[str]:
69
+ if self.device_id is not None:
70
+ return self.device_id
71
+ if self.parent is not None and not self.fresh:
72
+ return self.parent.get_device_id()
73
+ return None
74
+
64
75
  def collect_tags(self) -> Dict[str, Any]:
65
76
  if self.parent and not self.fresh:
66
77
  # We want child tags to take precedence over parent tags,
@@ -275,6 +286,39 @@ def get_context_distinct_id() -> Optional[str]:
275
286
  return None
276
287
 
277
288
 
289
+ def set_context_device_id(device_id: str) -> None:
290
+ """
291
+ Set the device ID for the current context, associating all feature flag requests in this or
292
+ child contexts with the given device ID (unless set_context_device_id is called again).
293
+ Entering a fresh context will clear the context-level device ID.
294
+
295
+ Args:
296
+ device_id: The device ID to associate with the current context and its children.
297
+
298
+ Category:
299
+ Contexts
300
+ """
301
+ current_context = _get_current_context()
302
+ if current_context:
303
+ current_context.set_device_id(device_id)
304
+
305
+
306
+ def get_context_device_id() -> Optional[str]:
307
+ """
308
+ Get the device ID for the current context.
309
+
310
+ Returns:
311
+ The device ID if set, None otherwise
312
+
313
+ Category:
314
+ Contexts
315
+ """
316
+ current_context = _get_current_context()
317
+ if current_context:
318
+ return current_context.get_device_id()
319
+ return None
320
+
321
+
278
322
  def set_capture_exception_code_variables_context(enabled: bool) -> None:
279
323
  """
280
324
  Set whether code variables are captured for the current context.
@@ -648,6 +648,7 @@ class TestClient(unittest.TestCase):
648
648
  person_properties={},
649
649
  group_properties={},
650
650
  geoip_disable=True,
651
+ device_id=None,
651
652
  )
652
653
 
653
654
  @mock.patch("posthog.client.flags")
@@ -712,6 +713,7 @@ class TestClient(unittest.TestCase):
712
713
  person_properties={},
713
714
  group_properties={},
714
715
  geoip_disable=False,
716
+ device_id=None,
715
717
  )
716
718
 
717
719
  @mock.patch("posthog.client.flags")
@@ -1911,6 +1913,7 @@ class TestClient(unittest.TestCase):
1911
1913
  person_properties={"distinct_id": "some_id"},
1912
1914
  group_properties={},
1913
1915
  geoip_disable=True,
1916
+ device_id=None,
1914
1917
  flag_keys_to_evaluate=["random_key"],
1915
1918
  )
1916
1919
  patch_flags.reset_mock()
@@ -1926,6 +1929,7 @@ class TestClient(unittest.TestCase):
1926
1929
  person_properties={"distinct_id": "feature_enabled_distinct_id"},
1927
1930
  group_properties={},
1928
1931
  geoip_disable=True,
1932
+ device_id=None,
1929
1933
  flag_keys_to_evaluate=["random_key"],
1930
1934
  )
1931
1935
  patch_flags.reset_mock()
@@ -1939,6 +1943,7 @@ class TestClient(unittest.TestCase):
1939
1943
  person_properties={"distinct_id": "all_flags_payloads_id"},
1940
1944
  group_properties={},
1941
1945
  geoip_disable=False,
1946
+ device_id=None,
1942
1947
  )
1943
1948
 
1944
1949
  @mock.patch("posthog.client.Poller")
@@ -1987,6 +1992,7 @@ class TestClient(unittest.TestCase):
1987
1992
  "instance": {"$group_key": "app.posthog.com"},
1988
1993
  },
1989
1994
  geoip_disable=False,
1995
+ device_id=None,
1990
1996
  flag_keys_to_evaluate=["random_key"],
1991
1997
  )
1992
1998
 
@@ -2014,6 +2020,7 @@ class TestClient(unittest.TestCase):
2014
2020
  "instance": {"$group_key": "app.posthog.com"},
2015
2021
  },
2016
2022
  geoip_disable=False,
2023
+ device_id=None,
2017
2024
  flag_keys_to_evaluate=["random_key"],
2018
2025
  )
2019
2026
 
@@ -2031,8 +2038,116 @@ class TestClient(unittest.TestCase):
2031
2038
  person_properties={"distinct_id": "some_id"},
2032
2039
  group_properties={},
2033
2040
  geoip_disable=False,
2041
+ device_id=None,
2034
2042
  )
2035
2043
 
2044
+ @parameterized.expand(
2045
+ [
2046
+ # method, method_args, expected_person_props, expected_flag_keys
2047
+ (
2048
+ "get_feature_flag",
2049
+ ["random_key", "some_id"],
2050
+ {"distinct_id": "some_id"},
2051
+ ["random_key"],
2052
+ ),
2053
+ (
2054
+ "feature_enabled",
2055
+ ["random_key", "some_id"],
2056
+ {"distinct_id": "some_id"},
2057
+ ["random_key"],
2058
+ ),
2059
+ (
2060
+ "get_all_flags_and_payloads",
2061
+ ["some_id"],
2062
+ {"distinct_id": "some_id"},
2063
+ None,
2064
+ ),
2065
+ ("get_all_flags", ["some_id"], {"distinct_id": "some_id"}, None),
2066
+ ("get_flags_decision", ["some_id"], {}, None),
2067
+ ]
2068
+ )
2069
+ @mock.patch("posthog.client.flags")
2070
+ def test_device_id_is_passed_to_flags_request(
2071
+ self,
2072
+ method,
2073
+ method_args,
2074
+ expected_person_props,
2075
+ expected_flag_keys,
2076
+ patch_flags,
2077
+ ):
2078
+ """Test that device_id is properly passed to the flags request when provided."""
2079
+ patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
2080
+ client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
2081
+
2082
+ getattr(client, method)(*method_args, device_id="test-device-123")
2083
+
2084
+ expected_call = {
2085
+ "distinct_id": "some_id",
2086
+ "groups": {},
2087
+ "person_properties": expected_person_props,
2088
+ "group_properties": {},
2089
+ "geoip_disable": True,
2090
+ "device_id": "test-device-123",
2091
+ }
2092
+ if expected_flag_keys:
2093
+ expected_call["flag_keys_to_evaluate"] = expected_flag_keys
2094
+
2095
+ patch_flags.assert_called_with(
2096
+ "random_key", "https://us.i.posthog.com", timeout=3, **expected_call
2097
+ )
2098
+
2099
+ @mock.patch("posthog.client.flags")
2100
+ def test_device_id_from_context_is_used_in_flags_request(self, patch_flags):
2101
+ """Test that device_id from context is used in flags request when not explicitly provided."""
2102
+ from posthoganalytics.contexts import new_context, set_context_device_id
2103
+
2104
+ patch_flags.return_value = {
2105
+ "featureFlags": {
2106
+ "beta-feature": "random-variant",
2107
+ }
2108
+ }
2109
+ client = Client(
2110
+ FAKE_TEST_API_KEY,
2111
+ on_error=self.set_fail,
2112
+ )
2113
+
2114
+ # Test that device_id from context is used
2115
+ with new_context():
2116
+ set_context_device_id("context-device-id")
2117
+ client.get_feature_flag("random_key", "some_id")
2118
+ patch_flags.assert_called_with(
2119
+ "random_key",
2120
+ "https://us.i.posthog.com",
2121
+ timeout=3,
2122
+ distinct_id="some_id",
2123
+ groups={},
2124
+ person_properties={"distinct_id": "some_id"},
2125
+ group_properties={},
2126
+ geoip_disable=True,
2127
+ device_id="context-device-id",
2128
+ flag_keys_to_evaluate=["random_key"],
2129
+ )
2130
+
2131
+ # Test that explicit device_id overrides context
2132
+ patch_flags.reset_mock()
2133
+ with new_context():
2134
+ set_context_device_id("context-device-id")
2135
+ client.get_feature_flag(
2136
+ "random_key", "some_id", device_id="explicit-device-id"
2137
+ )
2138
+ patch_flags.assert_called_with(
2139
+ "random_key",
2140
+ "https://us.i.posthog.com",
2141
+ timeout=3,
2142
+ distinct_id="some_id",
2143
+ groups={},
2144
+ person_properties={"distinct_id": "some_id"},
2145
+ group_properties={},
2146
+ geoip_disable=True,
2147
+ device_id="explicit-device-id",
2148
+ flag_keys_to_evaluate=["random_key"],
2149
+ )
2150
+
2036
2151
  @parameterized.expand(
2037
2152
  [
2038
2153
  # name, sys_platform, version_info, expected_runtime, expected_version, expected_os, expected_os_version, platform_method, platform_return, distro_info
@@ -1,8 +1,10 @@
1
1
  import json
2
2
  import time
3
3
  import unittest
4
+ from typing import Any
4
5
 
5
6
  import mock
7
+ from parameterized import parameterized
6
8
 
7
9
  try:
8
10
  from queue import Queue
@@ -14,15 +16,19 @@ from posthoganalytics.request import APIError
14
16
  from posthoganalytics.test.test_utils import TEST_API_KEY
15
17
 
16
18
 
19
+ def _track_event(event_name: str = "python event") -> dict[str, str]:
20
+ return {"type": "track", "event": event_name, "distinct_id": "distinct_id"}
21
+
22
+
17
23
  class TestConsumer(unittest.TestCase):
18
- def test_next(self):
24
+ def test_next(self) -> None:
19
25
  q = Queue()
20
26
  consumer = Consumer(q, "")
21
27
  q.put(1)
22
28
  next = consumer.next()
23
29
  self.assertEqual(next, [1])
24
30
 
25
- def test_next_limit(self):
31
+ def test_next_limit(self) -> None:
26
32
  q = Queue()
27
33
  flush_at = 50
28
34
  consumer = Consumer(q, "", flush_at)
@@ -31,7 +37,7 @@ class TestConsumer(unittest.TestCase):
31
37
  next = consumer.next()
32
38
  self.assertEqual(next, list(range(flush_at)))
33
39
 
34
- def test_dropping_oversize_msg(self):
40
+ def test_dropping_oversize_msg(self) -> None:
35
41
  q = Queue()
36
42
  consumer = Consumer(q, "")
37
43
  oversize_msg = {"m": "x" * MAX_MSG_SIZE}
@@ -40,15 +46,14 @@ class TestConsumer(unittest.TestCase):
40
46
  self.assertEqual(next, [])
41
47
  self.assertTrue(q.empty())
42
48
 
43
- def test_upload(self):
49
+ def test_upload(self) -> None:
44
50
  q = Queue()
45
51
  consumer = Consumer(q, TEST_API_KEY)
46
- track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
47
- q.put(track)
52
+ q.put(_track_event())
48
53
  success = consumer.upload()
49
54
  self.assertTrue(success)
50
55
 
51
- def test_flush_interval(self):
56
+ def test_flush_interval(self) -> None:
52
57
  # Put _n_ items in the queue, pausing a little bit more than
53
58
  # _flush_interval_ after each one.
54
59
  # The consumer should upload _n_ times.
@@ -57,17 +62,12 @@ class TestConsumer(unittest.TestCase):
57
62
  consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
58
63
  with mock.patch("posthog.consumer.batch_post") as mock_post:
59
64
  consumer.start()
60
- for i in range(0, 3):
61
- track = {
62
- "type": "track",
63
- "event": "python event %d" % i,
64
- "distinct_id": "distinct_id",
65
- }
66
- q.put(track)
65
+ for i in range(3):
66
+ q.put(_track_event("python event %d" % i))
67
67
  time.sleep(flush_interval * 1.1)
68
68
  self.assertEqual(mock_post.call_count, 3)
69
69
 
70
- def test_multiple_uploads_per_interval(self):
70
+ def test_multiple_uploads_per_interval(self) -> None:
71
71
  # Put _flush_at*2_ items in the queue at once, then pause for
72
72
  # _flush_interval_. The consumer should upload 2 times.
73
73
  q = Queue()
@@ -78,88 +78,60 @@ class TestConsumer(unittest.TestCase):
78
78
  )
79
79
  with mock.patch("posthog.consumer.batch_post") as mock_post:
80
80
  consumer.start()
81
- for i in range(0, flush_at * 2):
82
- track = {
83
- "type": "track",
84
- "event": "python event %d" % i,
85
- "distinct_id": "distinct_id",
86
- }
87
- q.put(track)
81
+ for i in range(flush_at * 2):
82
+ q.put(_track_event("python event %d" % i))
88
83
  time.sleep(flush_interval * 1.1)
89
84
  self.assertEqual(mock_post.call_count, 2)
90
85
 
91
- def test_request(self):
86
+ def test_request(self) -> None:
92
87
  consumer = Consumer(None, TEST_API_KEY)
93
- track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
94
- consumer.request([track])
88
+ consumer.request([_track_event()])
95
89
 
96
- def _test_request_retry(self, consumer, expected_exception, exception_count):
97
- def mock_post(*args, **kwargs):
98
- mock_post.call_count += 1
99
- if mock_post.call_count <= exception_count:
100
- raise expected_exception
90
+ def _run_retry_test(
91
+ self, exception: Exception, exception_count: int, retries: int = 10
92
+ ) -> None:
93
+ call_count = [0]
101
94
 
102
- mock_post.call_count = 0
95
+ def mock_post(*args: Any, **kwargs: Any) -> None:
96
+ call_count[0] += 1
97
+ if call_count[0] <= exception_count:
98
+ raise exception
103
99
 
100
+ consumer = Consumer(None, TEST_API_KEY, retries=retries)
104
101
  with mock.patch(
105
102
  "posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)
106
103
  ):
107
- track = {
108
- "type": "track",
109
- "event": "python event",
110
- "distinct_id": "distinct_id",
111
- }
112
- # request() should succeed if the number of exceptions raised is
113
- # less than the retries paramater.
114
- if exception_count <= consumer.retries:
115
- consumer.request([track])
104
+ if exception_count <= retries:
105
+ consumer.request([_track_event()])
116
106
  else:
117
- # if exceptions are raised more times than the retries
118
- # parameter, we expect the exception to be returned to
119
- # the caller.
120
- try:
121
- consumer.request([track])
122
- except type(expected_exception) as exc:
123
- self.assertEqual(exc, expected_exception)
124
- else:
125
- self.fail(
126
- "request() should raise an exception if still failing after %d retries"
127
- % consumer.retries
128
- )
129
-
130
- def test_request_retry(self):
131
- # we should retry on general errors
132
- consumer = Consumer(None, TEST_API_KEY)
133
- self._test_request_retry(consumer, Exception("generic exception"), 2)
134
-
135
- # we should retry on server errors
136
- consumer = Consumer(None, TEST_API_KEY)
137
- self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 2)
138
-
139
- # we should retry on HTTP 429 errors
140
- consumer = Consumer(None, TEST_API_KEY)
141
- self._test_request_retry(consumer, APIError(429, "Too Many Requests"), 2)
142
-
143
- # we should NOT retry on other client errors
144
- consumer = Consumer(None, TEST_API_KEY)
145
- api_error = APIError(400, "Client Errors")
146
- try:
147
- self._test_request_retry(consumer, api_error, 1)
148
- except APIError:
149
- pass
150
- else:
151
- self.fail("request() should not retry on client errors")
152
-
153
- # test for number of exceptions raise > retries value
154
- consumer = Consumer(None, TEST_API_KEY, retries=3)
155
- self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 3)
156
-
157
- def test_pause(self):
107
+ with self.assertRaises(type(exception)):
108
+ consumer.request([_track_event()])
109
+
110
+ @parameterized.expand(
111
+ [
112
+ ("general_errors", Exception("generic exception"), 2),
113
+ ("server_errors", APIError(500, "Internal Server Error"), 2),
114
+ ("rate_limit_errors", APIError(429, "Too Many Requests"), 2),
115
+ ]
116
+ )
117
+ def test_request_retries_on_retriable_errors(
118
+ self, _name: str, exception: Exception, exception_count: int
119
+ ) -> None:
120
+ self._run_retry_test(exception, exception_count)
121
+
122
+ def test_request_does_not_retry_client_errors(self) -> None:
123
+ with self.assertRaises(APIError):
124
+ self._run_retry_test(APIError(400, "Client Errors"), 1)
125
+
126
+ def test_request_fails_when_exceptions_exceed_retries(self) -> None:
127
+ self._run_retry_test(APIError(500, "Internal Server Error"), 4, retries=3)
128
+
129
+ def test_pause(self) -> None:
158
130
  consumer = Consumer(None, TEST_API_KEY)
159
131
  consumer.pause()
160
132
  self.assertFalse(consumer.running)
161
133
 
162
- def test_max_batch_size(self):
134
+ def test_max_batch_size(self) -> None:
163
135
  q = Queue()
164
136
  consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
165
137
  properties = {}
@@ -175,7 +147,7 @@ class TestConsumer(unittest.TestCase):
175
147
  # Let's capture 8MB of data to trigger two batches
176
148
  n_msgs = int(8_000_000 / msg_size)
177
149
 
178
- def mock_post_fn(_, data, **kwargs):
150
+ def mock_post_fn(_: str, data: str, **kwargs: Any) -> mock.Mock:
179
151
  res = mock.Mock()
180
152
  res.status_code = 200
181
153
  request_size = len(data.encode())
@@ -194,3 +166,34 @@ class TestConsumer(unittest.TestCase):
194
166
  q.put(track)
195
167
  q.join()
196
168
  self.assertEqual(mock_post.call_count, 2)
169
+
170
+ @parameterized.expand(
171
+ [
172
+ ("on_error_succeeds", False),
173
+ ("on_error_raises", True),
174
+ ]
175
+ )
176
+ def test_upload_exception_calls_on_error_and_does_not_raise(
177
+ self, _name: str, on_error_raises: bool
178
+ ) -> None:
179
+ on_error_called: list[tuple[Exception, list[dict[str, str]]]] = []
180
+
181
+ def on_error(e: Exception, batch: list[dict[str, str]]) -> None:
182
+ on_error_called.append((e, batch))
183
+ if on_error_raises:
184
+ raise Exception("on_error failed")
185
+
186
+ q = Queue()
187
+ consumer = Consumer(q, TEST_API_KEY, on_error=on_error)
188
+ track = _track_event()
189
+ q.put(track)
190
+
191
+ with mock.patch.object(
192
+ consumer, "request", side_effect=Exception("request failed")
193
+ ):
194
+ result = consumer.upload()
195
+
196
+ self.assertFalse(result)
197
+ self.assertEqual(len(on_error_called), 1)
198
+ self.assertEqual(str(on_error_called[0][0]), "request failed")
199
+ self.assertEqual(on_error_called[0][1], [track])
@@ -1,4 +1,4 @@
1
- VERSION = "7.5.0"
1
+ VERSION = "7.6.0"
2
2
 
3
3
  if __name__ == "__main__":
4
4
  print(VERSION, end="") # noqa: T201
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthoganalytics
3
- Version: 7.5.0
3
+ Version: 7.6.0
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
@@ -87,6 +87,14 @@ Dynamic: maintainer
87
87
 
88
88
  Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
89
89
 
90
+ ## Python Version Support
91
+
92
+ | SDK Version | Python Versions Supported | Notes |
93
+ |-------------|---------------------------|-------|
94
+ | 7.3.1+ | 3.10, 3.11, 3.12, 3.13, 3.14 | Added Python 3.14 support |
95
+ | 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 | Dropped Python 3.9 support |
96
+ | 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 | Python 3.9+ required |
97
+
90
98
  ## Development
91
99
 
92
100
  ### Testing Locally