Skip to content
20 changes: 16 additions & 4 deletions devcycle_python_sdk/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,42 @@ def create_user_from_context(
) -> "DevCycleUser":
"""
Builds a DevCycleUser instance from the evaluation context. Will raise a TargetingKeyMissingError if
the context does not contain a valid targeting key or user_id attribute
the context does not contain a valid targeting key, user_id, or userId attribute

:param context: The evaluation context to build the user from
:return: A DevCycleUser instance
"""
user_id = None
user_id_source = None

if context:
if context.targeting_key:
user_id = context.targeting_key
user_id_source = "targeting_key"
elif context.attributes and "user_id" in context.attributes.keys():
user_id = context.attributes["user_id"]
user_id_source = "user_id"
elif context.attributes and "userId" in context.attributes.keys():
user_id = context.attributes["userId"]
user_id_source = "userId"

if not user_id or not isinstance(user_id, str):
if not user_id:
raise TargetingKeyMissingError(
"DevCycle: Evaluation context does not contain a valid targeting key or user_id attribute"
"DevCycle: Evaluation context does not contain a valid targeting key, user_id, or userId attribute"
)

Comment on lines 109 to +127
Copy link

Copilot AI Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The user_id_source variable is only used for error messages when type validation fails. Consider initializing it closer to where it's used or using a more descriptive approach like extracting the source determination into a separate method to improve code clarity.

Copilot uses AI. Check for mistakes.

if not isinstance(user_id, str):
raise TargetingKeyMissingError(
f"DevCycle: {user_id_source} must be a string, got {type(user_id).__name__}"
)

user = DevCycleUser(user_id=user_id)
custom_data: Dict[str, Any] = {}
private_custom_data: Dict[str, Any] = {}
if context and context.attributes:
for key, value in context.attributes.items():
if key == "user_id":
# Skip user_id, userId, and targeting_key - these are reserved for user ID mapping
if key in ("user_id", "userId", "targeting_key"):
continue

if value is not None:
Expand Down
90 changes: 90 additions & 0 deletions test/models/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,96 @@ def test_create_user_from_context_only_user_id(self):
self.assertIsNotNone(user)
self.assertEqual(user.user_id, test_user_id)

def test_create_user_from_context_with_user_id_attribute(self):
"""Comprehensive test for userId attribute support including priority, functionality, and error cases"""
targeting_key_id = "targeting-12345"
user_id = "user_id-12345"
user_id_attr = "userId-12345"

# Test 1: userId as the only user ID source
context = EvaluationContext(
targeting_key=None, attributes={"userId": user_id_attr}
)
user = DevCycleUser.create_user_from_context(context)
self.assertIsNotNone(user)
self.assertEqual(user.user_id, user_id_attr)

# Test 2: Priority order - targeting_key > user_id > userId
context = EvaluationContext(
targeting_key=targeting_key_id,
attributes={"user_id": user_id, "userId": user_id_attr},
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, targeting_key_id)

context = EvaluationContext(
targeting_key=None, attributes={"user_id": user_id, "userId": user_id_attr}
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, user_id)

# Test 3: Error cases - invalid userId types
with self.assertRaises(TargetingKeyMissingError):
DevCycleUser.create_user_from_context(
EvaluationContext(targeting_key=None, attributes={"userId": 12345})
)

with self.assertRaises(TargetingKeyMissingError):
DevCycleUser.create_user_from_context(
EvaluationContext(targeting_key=None, attributes={"userId": None})
)

def test_create_user_from_context_user_id_exclusion(self):
"""Test that all user ID fields (targeting_key, user_id, userId) are excluded from custom data"""
targeting_key_id = "targeting-12345"
user_id = "user_id-12345"
user_id_attr = "userId-12345"

# Test exclusion when targeting_key is used
context = EvaluationContext(
targeting_key=targeting_key_id,
attributes={
"user_id": user_id,
"userId": user_id_attr,
"targeting_key": "should-be-excluded",
"other_field": "value",
},
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, targeting_key_id)
self.assertIsNotNone(user.customData)
self.assertNotIn("user_id", user.customData)
self.assertNotIn("userId", user.customData)
self.assertNotIn("targeting_key", user.customData)
self.assertEqual(user.customData["other_field"], "value")

# Test exclusion when user_id is used
context = EvaluationContext(
targeting_key=None,
attributes={
"user_id": user_id,
"userId": user_id_attr,
"other_field": "value",
},
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, user_id)
self.assertIsNotNone(user.customData)
self.assertNotIn("user_id", user.customData)
self.assertNotIn("userId", user.customData)
self.assertEqual(user.customData["other_field"], "value")

# Test exclusion when userId is used
context = EvaluationContext(
targeting_key=None,
attributes={"userId": user_id_attr, "other_field": "value"},
)
user = DevCycleUser.create_user_from_context(context)
self.assertEqual(user.user_id, user_id_attr)
self.assertIsNotNone(user.customData)
self.assertNotIn("userId", user.customData)
self.assertEqual(user.customData["other_field"], "value")

def test_create_user_from_context_with_attributes(self):
test_user_id = "12345"
context = EvaluationContext(
Expand Down