diff --git a/README.md b/README.md index 029d5ca..38d6360 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,43 @@ def my_method(request): pass ``` +Custom context fields can be returned by `request_field_extractor` or supplied in +`DeciderContext(extracted_fields={...})`. Values may be strings, booleans, integers, +floats, or `None`. Fields without a native Rust type are passed through +`other_fields` for targeting, overrides, and bucketing. + +The identifier APIs also accept custom field names without a library update: + +```python +variant = request.decider.get_variant_for_identifier( + "merchant_experiment", identifier="merchant_123", identifier_type="merchant_id" +) +``` + +The experiment must use `merchant_id` as its bucket field to bucket by this value. +The override applies only to this call; the stored request context is unchanged. +This also works with `get_variant_for_identifier_without_expose` and +`get_all_variants_for_identifier_without_expose`. Empty names and the reserved +`other_fields` name are rejected. + +`get_variant` and `get_variant_without_expose` also accept fields for one call: + +```python +variant = request.decider.get_variant( + "merchant_experiment", context_fields={"merchant_tier": "gold"} +) +``` + +These fields take precedence over stored fields for that call only. Empty names and +`other_fields` are rejected here too. Exposure events use the stored fields plus the +experiment's bucketing value. + +Other names are accepted, including `experiment`, `variant`, `span`, `event_type`, +and `inputs`. In exposure events, those top-level logger arguments keep their +metadata values, and a custom field with the same name is kept in `inputs`. +A custom `self` field is also accepted, but exposure events log it only in `inputs` +because the event logger's `log` method already has a `self` parameter. + See [the documentation] for more information (documentation builds can be found [here](https://readthedocs.org/projects/reddit-experiments/builds/)) . diff --git a/reddit_decider/__init__.py b/reddit_decider/__init__.py index 00a6f86..1e1684f 100644 --- a/reddit_decider/__init__.py +++ b/reddit_decider/__init__.py @@ -10,8 +10,10 @@ from typing import cast from typing import Dict from typing import IO +from typing import Iterable from typing import List from typing import Optional +from typing import Tuple from typing import Type from typing import TypeVar from typing import Union @@ -30,7 +32,6 @@ from rust_decider import Decision from rust_decider import FeatureNotFoundException from rust_decider import ValueTypeMismatchException -from typing_extensions import Literal from .prometheus_metrics import experiments_client_counter @@ -50,6 +51,7 @@ logger = logging.getLogger(__name__) EMPLOYEE_ROLES = ["employee", "contractor"] +# Retained for callers that import this list; custom identifier names are also supported. IDENTIFIERS = [ "user_id", "device_id", @@ -132,7 +134,7 @@ def to_dict(self) -> Dict: if self._conversion_pixel_id is not None: ef["conversion_pixel_id"] = self._conversion_pixel_id - return { + fields = { "user_id": self._user_id, "device_id": self._device_id, "country_code": self._country_code, @@ -143,9 +145,10 @@ def to_dict(self) -> Dict: "origin_service": self._origin_service, "cookie_created_timestamp": self._cookie_created_timestamp, "loid_created_timestamp": self._loid_created_timestamp, - "other_fields": ef, **ef, } + # Native typed fields use top-level values; all other fields use this fallback. + return {**fields, "other_fields": fields} def to_event_dict(self) -> Dict: user_fields = { @@ -265,13 +268,17 @@ def _send_expose(self, event: str, exposure_fields: dict) -> None: event_fields = {**event_fields, **{bucket_val: bucketing_value}} + # Custom fields may share these names; logger metadata wins at the top level. + # `self` would collide with the bound log method's parameter, so it stays in inputs. self._event_logger.log( - experiment=experiment, - variant=event_variant, - span=self._span, - event_type=EventType.EXPOSE, - inputs=event_fields, - **event_fields, + **{ + **{k: v for k, v in event_fields.items() if k != "self"}, + "experiment": experiment, + "variant": event_variant, + "span": self._span, + "event_type": EventType.EXPOSE, + "inputs": event_fields, + } ) return @@ -311,13 +318,17 @@ def _send_expose_if_holdout(self, event: str, exposure_fields: dict) -> None: event_fields = {**event_fields, **{bucket_val: bucketing_value}} + # Custom fields may share these names; logger metadata wins at the top level. + # `self` would collide with the bound log method's parameter, so it stays in inputs. self._event_logger.log( - experiment=experiment, - variant=event_variant, - span=self._span, - event_type=EventType.EXPOSE, - inputs=event_fields, - **event_fields, + **{ + **{k: v for k, v in event_fields.items() if k != "self"}, + "experiment": experiment, + "variant": event_variant, + "span": self._span, + "event_type": EventType.EXPOSE, + "inputs": event_fields, + } ) return @@ -331,7 +342,11 @@ def _cast_to_int(input: str) -> int: return out def get_variant( - self, experiment_name: str, **exposure_kwargs: Optional[Dict[str, Any]] + self, + experiment_name: str, + *, + context_fields: Optional[Dict[str, Any]] = None, + **exposure_kwargs: Optional[Dict[str, Any]], ) -> Optional[str]: """Return a bucketing variant, if any, with auto-exposure. @@ -345,13 +360,20 @@ def get_variant( :param experiment_name: Name of the experiment you want a variant for. + :param context_fields: Optional fields used for targeting, overrides, and bucketing + in this call only. They take precedence over the stored :code:`DeciderContext`. + Exposure events use the stored fields plus the experiment's bucketing value. + :param exposure_kwargs: Additional arguments that will be passed to :code:`events_logger` (keys must be part of v2 event schema, use dicts for nested fields) under :code:`inputs` and as :code:`kwargs` :return: Variant name if a variant is assigned, :code:`None` otherwise. """ - ctx = self._decider_context.to_dict() + ctx = self._context_with_fields((context_fields or {}).items()) + if ctx is None: + return None + decision = self._get_decision(experiment_name, ctx) if decision is None: @@ -365,7 +387,9 @@ def get_variant( return decision.variant - def get_variant_without_expose(self, experiment_name: str) -> Optional[str]: + def get_variant_without_expose( + self, experiment_name: str, *, context_fields: Optional[Dict[str, Any]] = None + ) -> Optional[str]: """Return a bucketing variant, if any, without emitting exposure event. The :code:`expose()` function is available to be manually called afterward. @@ -378,9 +402,16 @@ def get_variant_without_expose(self, experiment_name: str) -> Optional[str]: :param experiment_name: Name of the experiment you want a variant for. + :param context_fields: Optional fields used for targeting, overrides, and bucketing + in this call only. They take precedence over the stored :code:`DeciderContext`. + Exposure events use the stored fields plus the experiment's bucketing value. + :return: Variant name if a variant is assigned, None otherwise. """ - ctx = self._decider_context.to_dict() + ctx = self._context_with_fields((context_fields or {}).items()) + if ctx is None: + return None + decision = self._get_decision(experiment_name, ctx) if decision is None: @@ -441,28 +472,36 @@ def expose( stop_ts=feature.stop_ts, ) + # Custom fields may share these names; logger metadata wins at the top level. + # `self` would collide with the bound log method's parameter, so it stays in inputs. self._event_logger.log( - experiment=experiment, - variant=variant_name, - span=self._span, - event_type=EventType.EXPOSE, - inputs=event_fields, - **event_fields, + **{ + **{k: v for k, v in event_fields.items() if k != "self"}, + "experiment": experiment, + "variant": variant_name, + "span": self._span, + "event_type": EventType.EXPOSE, + "inputs": event_fields, + } ) + def _context_with_fields(self, fields: Iterable[Tuple[str, Any]]) -> Optional[Dict]: + ctx = self._decider_context.to_dict() + for name, value in fields: + if not isinstance(name, str) or not name or name == "other_fields": + logger.warning( + 'Context field names must be non-empty strings other than "other_fields".' + ) + return None + ctx[name] = value + ctx["other_fields"][name] = value + return ctx + def get_variant_for_identifier( self, experiment_name: str, identifier: str, - identifier_type: Literal[ - "user_id", - "device_id", - "canonical_url", - "subreddit_id", - "ad_account_id", - "business_id", - "conversion_pixel_id", - ], + identifier_type: str, **exposure_kwargs: Optional[Dict[str, Any]], ) -> Optional[str]: """Return a bucketing variant, if any, with auto-exposure for a given :code:`identifier`. @@ -497,17 +536,10 @@ def get_variant_for_identifier( :return: Variant name if a variant is assigned, None otherwise. """ - if identifier_type not in IDENTIFIERS: - logger.warning( - f'"{identifier_type}" is not one of supported "identifier_type": {IDENTIFIERS}.' - ) + ctx = self._context_with_fields([(identifier_type, identifier)]) + if ctx is None: return None - ctx = self._decider_context.to_dict() - ctx[identifier_type] = identifier - if identifier_type == "conversion_pixel_id": - ctx["other_fields"][identifier_type] = identifier - decision = self._get_decision(experiment_name, ctx) if decision is None: @@ -525,15 +557,7 @@ def get_variant_for_identifier_without_expose( self, experiment_name: str, identifier: str, - identifier_type: Literal[ - "user_id", - "device_id", - "canonical_url", - "subreddit_id", - "ad_account_id", - "business_id", - "conversion_pixel_id", - ], + identifier_type: str, ) -> Optional[str]: """Return a bucketing variant, if any, without emitting exposure event for a given :code:`identifier`. @@ -568,17 +592,10 @@ def get_variant_for_identifier_without_expose( :return: Variant name if a variant is assigned, None otherwise. """ - if identifier_type not in IDENTIFIERS: - logger.warning( - f'"{identifier_type}" is not one of supported "identifier_type": {IDENTIFIERS}.' - ) + ctx = self._context_with_fields([(identifier_type, identifier)]) + if ctx is None: return None - ctx = self._decider_context.to_dict() - ctx[identifier_type] = identifier - if identifier_type == "conversion_pixel_id": - ctx["other_fields"][identifier_type] = identifier - decision = self._get_decision(experiment_name, ctx) if decision is None: @@ -654,15 +671,7 @@ def _decision_to_dict(self, decision: Decision) -> Dict[str, Any]: def get_all_variants_for_identifier_without_expose( self, identifier: str, - identifier_type: Literal[ - "user_id", - "device_id", - "canonical_url", - "subreddit_id", - "ad_account_id", - "business_id", - "conversion_pixel_id", - ], + identifier_type: str, ) -> List[Dict[str, Union[str, int]]]: """Return a list of experiment dicts for experiments having :code:`bucket_val` match :code:`identifier_type`, for a given :code:`identifier`, in this format: @@ -696,17 +705,10 @@ def get_all_variants_for_identifier_without_expose( :return: list of experiment dicts with non-:code:`None` variants. """ - if identifier_type not in IDENTIFIERS: - logger.warning( - f'"{identifier_type}" is not one of supported "identifier_type": {IDENTIFIERS}.' - ) + ctx = self._context_with_fields([(identifier_type, identifier)]) + if ctx is None: return [] - ctx = self._decider_context.to_dict() - ctx[identifier_type] = identifier - if identifier_type == "conversion_pixel_id": - ctx["other_fields"][identifier_type] = identifier - all_decisions = self._get_all_decisions(ctx=ctx, bucketing_field_filter=identifier_type) if all_decisions is None: diff --git a/tests/decider_tests.py b/tests/decider_tests.py index 1c2d783..c3d77ad 100644 --- a/tests/decider_tests.py +++ b/tests/decider_tests.py @@ -107,6 +107,15 @@ def setup_decider(file_name, decider_context, mock_span, event_logger): ) +# Mock.log accepts a `self` kwarg, but baseplate's bound log(self, **kwargs) rejects it. +class BoundLogger: + def __init__(self, recorder): + self.recorder = recorder + + def log(self, **kwargs): + self.recorder.log(**kwargs) + + def first_occurrence_of_key_in(array, dict_key, name): return next((v for v in array if v[dict_key] == name), None) @@ -575,6 +584,22 @@ def assert_minimal_exposure_event_fields( self.assertEqual(getattr(event_fields["experiment"], "version"), cfg["version"]) self.assertEqual(getattr(event_fields["experiment"], "bucket_val"), bucket_val) + def assert_custom_field_exposure( + self, experiment_name: str, variant: str, field: str, value: str + ): + self.event_logger.log.assert_called_once() + event_fields = self.event_logger.log.call_args.kwargs + self.assertEqual(event_fields["experiment"].name, experiment_name) + self.assertEqual(event_fields["experiment"].bucket_val, field) + self.assertEqual(event_fields["variant"], variant) + self.assertIs(event_fields["span"], self.mock_span) + self.assertEqual(event_fields["event_type"], EventType.EXPOSE) + self.assertEqual(event_fields["inputs"][field], value) + self.assertNotIn("self", event_fields) + for logged_fields in (event_fields, event_fields["inputs"]): + self.assertEqual(logged_fields["user_id"], USER_ID) + self.assertEqual(logged_fields["custom_field"], "kept") + def test_get_variant(self): with create_temp_config_file(self.exp_base_config) as f: decider = setup_decider(f, self.dc, self.mock_span, self.event_logger) @@ -673,7 +698,9 @@ def test_cohort_targeting_uses_typed_context_without_mutating_input(self): self.assertEqual(decider.get_variant_without_expose("exp_1"), expected) self.assertEqual(fields, {"$cohort_power_users": membership}) - self.assertEqual(ctx.to_dict()["other_fields"], fields) + self.assertEqual( + ctx.to_dict()["other_fields"]["$cohort_power_users"], membership + ) @unittest.skipUnless( DECIDER_SUPPORTS_TARGETING_GATES, @@ -1219,6 +1246,141 @@ def test_get_variant_for_identifier_business_id(self): # `identifier` passed to correct event field of experiment's `bucket_val` config self.assertEqual(event_fields["business_id"], identifier) + def test_identifier_apis_reject_malformed_field_names(self): + with create_temp_config_file(self.exp_base_config) as f: + decider = setup_decider(f, self.dc, self.mock_span, self.event_logger) + for method_name in ( + "get_variant_for_identifier", + "get_variant_for_identifier_without_expose", + "get_all_variants_for_identifier_without_expose", + ): + for identifier_type in (None, 123, "", "other_fields"): + with self.subTest(method=method_name, identifier_type=identifier_type): + kwargs = {"identifier": "anything", "identifier_type": identifier_type} + expected = None + if method_name == "get_all_variants_for_identifier_without_expose": + expected = [] + else: + kwargs["experiment_name"] = "exp_1" + with self.assertLogs("reddit_decider", level="WARNING"): + self.assertEqual(getattr(decider, method_name)(**kwargs), expected) + self.event_logger.log.assert_not_called() + + def test_context_fields_reject_malformed_field_names(self): + with create_temp_config_file(self.exp_base_config) as f: + decider = setup_decider(f, self.dc, self.mock_span, self.event_logger) + for method_name in ("get_variant", "get_variant_without_expose"): + for name in (None, 123, "", "other_fields"): + with self.subTest(method=method_name, name=name): + method = getattr(decider, method_name) + with self.assertLogs("reddit_decider", level="WARNING"): + self.assertIsNone(method("exp_1", context_fields={name: "anything"})) + self.event_logger.log.assert_not_called() + + def test_custom_identifier_override_preserves_context(self): + experiment = self.exp_base_config["exp_1"]["experiment"] + experiment["bucket_val"] = "custom_identifier" + experiment["variants"] = [ + {"range_start": 0.0, "range_end": 1.0, "name": "control"}, + {"range_start": 1.0, "range_end": 1.0, "name": "enabled"}, + ] + experiment["overrides"] = [ + {"enabled": {"EQ": {"field": "custom_identifier", "values": ["matching"]}}} + ] + # All-variant evaluation must still filter out experiments with another bucket field. + self.exp_base_config.update(self.additional_two_exp) + fields = {"custom_identifier": "original"} + context = DeciderContext(user_id=USER_ID, extracted_fields=fields) + original_context = context.to_dict() + + with create_temp_config_file(self.exp_base_config) as f: + decider = setup_decider(f, context, self.mock_span, self.event_logger) + for method_name in ( + "get_variant_for_identifier", + "get_variant_for_identifier_without_expose", + "get_all_variants_for_identifier_without_expose", + ): + for identifier, expected in (("matching", "enabled"), ("different", "control")): + with self.subTest(method=method_name, identifier=identifier): + self.event_logger.reset_mock() + method = getattr(decider, method_name) + kwargs = {"identifier": identifier, "identifier_type": "custom_identifier"} + if method_name == "get_all_variants_for_identifier_without_expose": + variants = method(**kwargs) + self.assertEqual(len(variants), 1) + self.assertEqual(variants[0]["experimentName"], "exp_1") + variant = variants[0]["name"] + else: + variant = method(experiment_name="exp_1", **kwargs) + self.assertEqual(variant, expected) + if method_name == "get_variant_for_identifier": + self.event_logger.log.assert_called_once() + self.assertEqual( + self.event_logger.log.call_args.kwargs["custom_identifier"], + identifier, + ) + else: + self.event_logger.log.assert_not_called() + self.assertEqual(context.to_dict(), original_context) + self.assertEqual(fields, {"custom_identifier": "original"}) + self.assertEqual(decider.get_variant_without_expose("exp_1"), "control") + + def test_context_fields_override_preserves_context(self): + experiment = self.exp_base_config["exp_1"]["experiment"] + experiment["bucket_val"] = "custom_identifier" + experiment["variants"] = [ + {"range_start": 0.0, "range_end": 1.0, "name": "control"}, + {"range_start": 1.0, "range_end": 1.0, "name": "enabled"}, + ] + experiment["overrides"] = [{"enabled": {"EQ": {"field": "tier", "values": ["gold"]}}}] + fields = {"custom_identifier": "original"} + context = DeciderContext(user_id=USER_ID, extracted_fields=fields) + original_context = context.to_dict() + + with create_temp_config_file(self.exp_base_config) as f: + decider = setup_decider(f, context, self.mock_span, self.event_logger) + for method_name in ("get_variant", "get_variant_without_expose"): + for tier, expected in (("gold", "enabled"), ("silver", "control")): + with self.subTest(method=method_name, tier=tier): + self.event_logger.reset_mock() + context_fields = {"custom_identifier": "per_call", "tier": tier} + method = getattr(decider, method_name) + self.assertEqual(method("exp_1", context_fields=context_fields), expected) + if method_name == "get_variant": + self.event_logger.log.assert_called_once() + inputs = self.event_logger.log.call_args.kwargs["inputs"] + self.assertEqual(inputs["custom_identifier"], "per_call") + self.assertNotIn("tier", inputs) + else: + self.event_logger.log.assert_not_called() + self.assertEqual( + context_fields, {"custom_identifier": "per_call", "tier": tier} + ) + self.assertEqual(context.to_dict(), original_context) + self.assertEqual(fields, {"custom_identifier": "original"}) + self.assertEqual(decider.get_variant_without_expose("exp_1"), "control") + + def test_untyped_constructor_field_reaches_targeting(self): + self.exp_base_config["exp_1"]["experiment"]["targeting"] = { + "GE": {"field": "loid_created_timestamp", "value": 1500} + } + with create_temp_config_file(self.exp_base_config) as f: + for timestamp, extracted_fields, expected in ( + (2000, {}, "variant_4"), + (1000, {}, None), + (None, {}, None), + (2000, {"loid_created_timestamp": 1000}, None), + (1000, {"loid_created_timestamp": 2000}, "variant_4"), + ): + with self.subTest(timestamp=timestamp, extracted_fields=extracted_fields): + context = DeciderContext( + user_id=USER_ID, + loid_created_timestamp=timestamp, + extracted_fields=extracted_fields, + ) + decider = setup_decider(f, context, self.mock_span, self.event_logger) + self.assertEqual(decider.get_variant_without_expose("exp_1"), expected) + def test_conversion_pixel_id_override(self): experiment = self.exp_base_config["exp_1"]["experiment"] experiment["bucket_val"] = "conversion_pixel_id" @@ -1261,31 +1423,59 @@ def test_conversion_pixel_id_override(self): else: self.event_logger.log.assert_not_called() - def test_get_variant_for_identifier_bogus_identifier_type(self): - identifier = "anything" - identifier_type = "blah" - - with create_temp_config_file(self.exp_base_config) as f: - decider = setup_decider( - f, self.minimal_decider_context, self.mock_span, self.event_logger - ) - - self.assertEqual(self.event_logger.log.call_count, 0) - with self.assertLogs() as captured: - variant = decider.get_variant_for_identifier( - experiment_name="exp_1", identifier=identifier, identifier_type=identifier_type - ) - - self.assertEqual(variant, None) - - assert any( - "\"blah\" is not one of supported \"identifier_type\": ['user_id', 'device_id', 'canonical_url', 'subreddit_id', 'ad_account_id', 'business_id', 'conversion_pixel_id']." - in x.getMessage() - for x in captured.records - ) - - # exposure isn't emitted either - self.assertEqual(self.event_logger.log.call_count, 0) + def test_custom_fields_named_like_logger_metadata_are_exposed(self): + for field in ("experiment", "variant", "span", "event_type", "inputs", "self"): + config = deepcopy(self.exp_base_config) + config["exp_1"]["experiment"]["variants"] = [ + {"range_start": 0.0, "range_end": 1.0, "name": "enabled"} + ] + config["child"] = deepcopy(config["exp_1"]) + config["child"].update({"id": 3, "name": "child", "parent_hg_name": "hg"}) + config.update(deepcopy(self.parent_hg_config)) + for feature in config.values(): + feature["experiment"]["bucket_val"] = field + fields = {field: "stored", "custom_field": "kept"} + context = DeciderContext(user_id=USER_ID, extracted_fields=fields) + original_context = context.to_dict() + override = {"identifier": "override", "identifier_type": field} + + with create_temp_config_file(config) as f: + decider = setup_decider(f, context, self.mock_span, BoundLogger(self.event_logger)) + with self.subTest(field=field, method="get_variant"): + self.event_logger.reset_mock() + self.assertEqual(decider.get_variant("exp_1"), "enabled") + self.assert_custom_field_exposure("exp_1", "enabled", field, "stored") + with self.subTest(field=field, method="get_variant_for_identifier"): + self.event_logger.reset_mock() + variant = decider.get_variant_for_identifier("exp_1", **override) + self.assertEqual(variant, "enabled") + self.assert_custom_field_exposure("exp_1", "enabled", field, "override") + with self.subTest(field=field, method="expose"): + self.event_logger.reset_mock() + decider.expose("exp_1", "enabled") + self.assert_custom_field_exposure("exp_1", "enabled", field, "stored") + with self.subTest(field=field, method="get_variant_without_expose"): + self.event_logger.reset_mock() + self.assertIsNone(decider.get_variant_without_expose("child")) + self.assert_custom_field_exposure("hg", "holdout", field, "stored") + with self.subTest(field=field, method="get_variant_for_identifier_without_expose"): + self.event_logger.reset_mock() + self.assertIsNone( + decider.get_variant_for_identifier_without_expose("child", **override) + ) + self.assert_custom_field_exposure("hg", "holdout", field, "override") + with self.subTest( + field=field, method="get_all_variants_for_identifier_without_expose" + ): + self.event_logger.reset_mock() + variants = decider.get_all_variants_for_identifier_without_expose(**override) + self.assertEqual( + {v["experimentName"]: v["name"] for v in variants}, + {"exp_1": "enabled", "hg": "holdout"}, + ) + self.assert_custom_field_exposure("hg", "holdout", field, "override") + self.assertEqual(context.to_dict(), original_context) + self.assertEqual(fields, {field: "stored", "custom_field": "kept"}) def test_expose(self): with create_temp_config_file(self.exp_base_config) as f: @@ -1453,30 +1643,6 @@ def test_get_variant_for_identifier_without_expose_business_id(self): # no exposures should be triggered self.assertEqual(self.event_logger.log.call_count, 0) - def test_get_variant_for_identifier_without_expose_bogus_identifier_type(self): - identifier = "anything" - identifier_type = "blah" - - with create_temp_config_file(self.exp_base_config) as f: - decider = setup_decider(f, self.dc, self.mock_span, self.event_logger) - - self.assertEqual(self.event_logger.log.call_count, 0) - with self.assertLogs() as captured: - variant = decider.get_variant_for_identifier_without_expose( - experiment_name="exp_1", identifier=identifier, identifier_type=identifier_type - ) - - self.assertEqual(variant, None) - - assert any( - "\"blah\" is not one of supported \"identifier_type\": ['user_id', 'device_id', 'canonical_url', 'subreddit_id', 'ad_account_id', 'business_id', 'conversion_pixel_id']." - in x.getMessage() - for x in captured.records - ) - - # no exposures should be triggered - self.assertEqual(self.event_logger.log.call_count, 0) - def test_get_all_variants_without_expose(self): # add 2 more experiments self.exp_base_config.update(self.additional_two_exp) @@ -1865,32 +2031,6 @@ def test_get_all_variants_for_identifier_without_expose_canonical_url_with_hg(se identifier=identifier, ) - def test_get_all_variants_for_identifier_without_expose_bogus_identifier_type(self): - identifier = "anything" - # use non-supported `identifier_type` - identifier_type = "blah" - - with create_temp_config_file(self.exp_base_config) as f: - decider = setup_decider(f, self.dc, self.mock_span, self.event_logger) - - self.assertEqual(self.event_logger.log.call_count, 0) - - with self.assertLogs() as captured: - variant_arr = decider.get_all_variants_for_identifier_without_expose( - identifier=identifier, identifier_type=identifier_type - ) - - self.assertEqual(len(variant_arr), 0) - - assert any( - "\"blah\" is not one of supported \"identifier_type\": ['user_id', 'device_id', 'canonical_url', 'subreddit_id', 'ad_account_id', 'business_id', 'conversion_pixel_id']." - in x.getMessage() - for x in captured.records - ) - - # no exposures should be triggered - self.assertEqual(self.event_logger.log.call_count, 0) - def test_get_variant_with_exposure_kwargs(self): with create_temp_config_file(self.exp_base_config) as f: decider = setup_decider(f, self.dc, self.mock_span, self.event_logger)