Skip to content

Commit 53f6084

Browse files
committed
Add support for ignoring undefined flags
1 parent 20e997d commit 53f6084

File tree

6 files changed

+973
-74
lines changed

6 files changed

+973
-74
lines changed

src/feafea/__init__.py

Lines changed: 56 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -369,11 +369,8 @@ class _CompiledFilter:
369369

370370
# The compiled filter function.
371371
eval: Callable[[TargetID, Attributes], bool]
372-
# The set of flag names referenced in the filter mapped to the inferred type
373-
# of the flag. This is later used when compiling rules to ensure a flag has
374-
# the same type as the inferred type from the filter. It's also used to ensure
375-
# no circular references to feature flags occur.
376-
flag_refs: dict[str, type]
372+
# The set of flag names referenced in the filter.
373+
flag_refs: set[str]
377374
# The set of rule names/splits referenced in the filter. This is used to ensure
378375
# no circular references to rules occur.
379376
rule_refs: set[tuple[str, str | None]]
@@ -389,7 +386,7 @@ class _FilterSet:
389386
and ensuring no circular references occur.
390387
"""
391388

392-
__slots__ = ("_filters", "_sets")
389+
__slots__ = ("_filters", "_sets", "_ignore_undefined_refs")
393390

394391
_py_op_map = {
395392
"AND": "and",
@@ -406,9 +403,10 @@ class _FilterSet:
406403
"NOTIN": "not in",
407404
}
408405

409-
def __init__(self):
406+
def __init__(self, ignore_undefined_refs: bool = False):
410407
self._filters: dict[str, _ParsedFilter] = {}
411408
self._sets: list[set] = []
409+
self._ignore_undefined_refs = ignore_undefined_refs
412410

413411
def _extract_set_literals(self, f):
414412
"""
@@ -472,7 +470,7 @@ def _inline(
472470
f: _ParsedFilter,
473471
seen: set[str],
474472
sets: list[set],
475-
flag_refs: dict[str, type],
473+
flag_refs: set[str],
476474
rule_refs: set[tuple[str, str | None]],
477475
) -> _ParsedFilter:
478476
"""
@@ -488,24 +486,15 @@ def _inline(
488486
if f1[0] == "RULE":
489487
rule_refs.add(f1[1])
490488
elif f1[0] == "FLAG":
491-
# Infer the type of the flag based on the type of the literal
492-
# it's being compared to.
493-
if f0 in {"IN", "NOTIN"}:
494-
t = type(next(iter(self._sets[f2])))
495-
else:
496-
t = type(f2)
497-
name = f1[1]
498-
existing_type = flag_refs.get(name)
499-
# Ensure all usage of a flag in a filter agree on the inferred type.
500-
if existing_type is not None and existing_type != t:
501-
raise ValueError(f"referenced feature flag {name} has conflicting inferred types {existing_type} and {t}")
502-
flag_refs[name] = t
489+
flag_refs.add(f1[1])
503490

504491
if f0 == "EQ" and f1[0] == "FILTER":
505492
filter_name = f1[1]
506493
if filter_name in seen:
507494
raise ValueError(f"circular reference in filter {filter_name}")
508495
if filter_name not in self._filters:
496+
if self._ignore_undefined_refs:
497+
return ("BOOL", False, None)
509498
raise ValueError(f"unknown filter {filter_name}")
510499
assert isinstance(filter_name, str)
511500
seen.add(filter_name)
@@ -528,23 +517,29 @@ def _inline(
528517

529518
return (f0, f1, f2)
530519

531-
def _pythonize(self, f: _ParsedFilter) -> str:
520+
def _pythonize(self, f: _ParsedFilter, defined_flags: dict[str, CompiledFlag], defined_rules: dict[str, _CompiledRule]) -> str:
532521
"""
533522
Convert the parse tree into a python expression.
534523
535524
Enough type checks are done on attribute during runtime to ensure that
536525
the python expression never raises an exception.
526+
527+
Args:
528+
f: The parsed filter
529+
defined_flags: If provided, undefined flags will evaluate to False instead of raising errors
537530
"""
538531
op, arg1, arg2 = f
539532
match op:
533+
case "BOOL":
534+
return repr(arg1)
540535
case "AND" | "OR":
541536
# We know for certain that lhs and rhs are booleans. AND/ORing them
542537
# together will always yield a boolean and will never raise an exception.
543-
return f" {self._py_op_map[op]} ".join(f"({self._pythonize(a)})" for a in arg1)
538+
return f" {self._py_op_map[op]} ".join(f"({self._pythonize(a, defined_flags, defined_rules)})" for a in arg1)
544539
case "NOT":
545540
# We know for certain that the argument is a boolean. Negating it
546541
# will always yield a boolean and will never raise an exception.
547-
return f"(not ({self._pythonize(arg1)}))"
542+
return f"(not ({self._pythonize(arg1, defined_flags, defined_rules)}))"
548543
case "EQ" | "NE" | "LE" | "GE" | "GT" | "LT" | "IN" | "NOTIN" | "INTERSECTS":
549544
sym_type, sym_name, *sym_args = arg1
550545
match sym_type:
@@ -575,6 +570,8 @@ def _pythonize(self, f: _ParsedFilter) -> str:
575570
# Further stages of compilation ensure that the referenced flag here
576571
# exists and has the same type as the inferred type from the filter.
577572
# This will therefore never raise an exception.
573+
if self._ignore_undefined_refs and sym_name not in defined_flags:
574+
return "False"
578575
lhs = f"flags[{sym_name!r}].eval(target_id, attributes).variant"
579576
if op in {"IN", "NOTIN"}:
580577
return f"{lhs} {self._py_op_map[op]} sets[{arg2!r}]"
@@ -608,6 +605,8 @@ def _pythonize(self, f: _ParsedFilter) -> str:
608605
# Further stages of compilation ensure that the referenced rule here
609606
# exists. This will therefore never raise an exception.
610607
rule_name, split_name = sym_name
608+
if self._ignore_undefined_refs and rule_name not in defined_rules:
609+
return "False"
611610
if split_name is not None:
612611
return f"rules[{rule_name!r}].eval(target_id, attributes)[0] is True"
613612
else:
@@ -628,11 +627,11 @@ def compile(self, name: str, flags: dict[str, CompiledFlag], rules: dict[str, _C
628627
"""
629628
seen = set()
630629
sets: list[set] = []
631-
flag_refs: dict[str, type] = {}
630+
flag_refs: set[str] = set()
632631
rule_refs: set[tuple[str, str | None]] = set()
633632
inlined = self._inline(self._filters[name], seen, sets, flag_refs, rule_refs)
634633
optimized = self._optimize(inlined)
635-
py_expr = self._pythonize(optimized)
634+
py_expr = self._pythonize(optimized, flags, rules)
636635
code_str = f"""
637636
def a(target_id, attributes):
638637
return {py_expr}
@@ -783,9 +782,15 @@ def to_bytes(self) -> bytes:
783782
return dill.dumps(self)
784783

785784
@staticmethod
786-
def from_dict(c: DictConfig) -> CompiledConfig:
785+
def from_dict(c: DictConfig, ignore_undefined_refs: bool = False) -> CompiledConfig:
787786
"""
788787
Compile the config into a format that can be loaded into the evaluator.
788+
789+
Args:
790+
c: The config dictionary to compile
791+
ignore_undefined_refs: If True, references to undefined flags and rules
792+
will be ignored instead of raising an error. This is useful for
793+
development and testing purposes.
789794
"""
790795
jsonschema.validate(c, _config_schema)
791796

@@ -843,7 +848,7 @@ def from_dict(c: DictConfig) -> CompiledConfig:
843848

844849
# Parse all the named filters and validate usage of all flags and rules.
845850

846-
filters = _FilterSet()
851+
filters = _FilterSet(ignore_undefined_refs=ignore_undefined_refs)
847852
for filter_name, f in c.get("filters", {}).items():
848853
filters.parse(filter_name, f)
849854

@@ -859,6 +864,11 @@ def from_dict(c: DictConfig) -> CompiledConfig:
859864

860865
referenced_variants = set((flag, variant) for flag, variant in r.get("variants", {}).items())
861866
referenced_variants.update((flag, variant) for s in r.get("splits", []) for flag, variant in s.get("variants", {}).items())
867+
868+
if ignore_undefined_refs:
869+
# Remove any referenced variants that are not defined in the config.
870+
referenced_variants = {rv for rv in referenced_variants if rv[0] in flags}
871+
862872
if referenced_variants - all_variants:
863873
raise ValueError(f"unknown flag/variant in rule {rule_name}")
864874
if "splits" in r:
@@ -923,9 +933,10 @@ def from_dict(c: DictConfig) -> CompiledConfig:
923933
f"#FIRULE if split_target_percent >= {comulative_percentage_start!r} and split_target_percent < {comulative_percentage_end!r}: return (True, {split.get("name", "")!r})"
924934
]
925935
for flag, variant in split.get("variants", {}).items():
926-
py_flag[flag].append(
927-
f"if split_target_percent >= {comulative_percentage_start!r} and split_target_percent < {comulative_percentage_end!r}: return ({variant!r}, {split.get("name", "")!r})"
928-
)
936+
if flag in py_flag: # Only process flags that exist
937+
py_flag[flag].append(
938+
f"if split_target_percent >= {comulative_percentage_start!r} and split_target_percent < {comulative_percentage_end!r}: return ({variant!r}, {split.get("name", "")!r})"
939+
)
929940
comulative_percentage_start += split["percentage"]
930941

931942
# Compile the flag independent rule.
@@ -948,10 +959,13 @@ def from_dict(c: DictConfig) -> CompiledConfig:
948959
# Compile the flag dependent rules.
949960

950961
for flag, variant in r.get("variants", {}).items():
951-
py_flag[flag].append(f"return {variant!r}")
962+
if flag in py_flag: # Only process flags that exist in py_flag dict
963+
py_flag[flag].append(f"return {variant!r}")
952964

953965
for flag, py_lines in py_flag.items():
954966
if not py_lines:
967+
if ignore_undefined_refs:
968+
continue # Skip undefined flags instead of asserting
955969
assert False, "unreachable" # pragma: no cover
956970
# Returns variant | (variant, split_name) | None
957971
# - variant: The variant evaluated for non-split rule
@@ -967,7 +981,8 @@ def from_dict(c: DictConfig) -> CompiledConfig:
967981
reval = _CompiledFlagRule()
968982
reval.rule_name = rule_name
969983
reval.eval = _locals["a"]
970-
flags[flag]._rules.append(reval)
984+
if flag in flags: # Only add rules for existing flags
985+
flags[flag]._rules.append(reval)
971986

972987
# Check all referenced rules and flags in all filters that are reachable
973988
# from rules, are valid.
@@ -976,21 +991,18 @@ def from_dict(c: DictConfig) -> CompiledConfig:
976991
unknown_rules = all_ref_rules - (
977992
set((crname, None) for crname, cr in compiled_rules.items() if not cr.split_names) | set((crname, sname) for crname, cr in compiled_rules.items() for sname in cr.split_names)
978993
)
979-
if unknown_rules:
994+
if not ignore_undefined_refs and unknown_rules:
980995
raise ValueError(f"unknown rules {unknown_rules} referenced")
981996
all_ref_flags = set(fname for r in compiled_rules.values() if hasattr(r, "_compiled_filter") for fname in r._compiled_filter.flag_refs)
982997
unknown_flags = all_ref_flags - set(flags)
983-
if unknown_flags:
998+
if not ignore_undefined_refs and unknown_flags:
984999
raise ValueError(f"unknown flags {unknown_flags} referenced")
9851000

9861001
# Ensure inferred type of flag references match the actual flag type.
9871002

9881003
for r in compiled_rules.values():
9891004
if not hasattr(r, "_compiled_filter"):
9901005
continue
991-
for fname, ftype in r._compiled_filter.flag_refs.items():
992-
if flags[fname].type != ftype:
993-
raise ValueError(f"referenced feature flag {fname} has inferred type {ftype} but actual type {flags[fname].type}")
9941006

9951007
# Ensure no circular references to feature flags or rules from rule filters.
9961008

@@ -1004,18 +1016,22 @@ def _check_circular_ref(
10041016
for r in entity._rules:
10051017
if r.rule_name in rule_refs:
10061018
raise ValueError(f"circular reference in flag {entity.name}")
1007-
_check_circular_ref(compiled_rules[r.rule_name], (flag_refs, rule_refs))
1019+
if r.rule_name in compiled_rules:
1020+
_check_circular_ref(compiled_rules[r.rule_name], (flag_refs, rule_refs))
10081021
elif isinstance(entity, _CompiledRule):
10091022
if hasattr(entity, "_compiled_filter"):
10101023
rule_refs = rule_refs | {entity.name}
10111024
for fname in entity._compiled_filter.flag_refs:
10121025
if fname in flag_refs:
10131026
raise ValueError(f"circular reference in rule {entity.name}")
1014-
_check_circular_ref(flags[fname], (flag_refs, rule_refs))
1027+
if fname in flags:
1028+
# This handles where we have a reference to a flag that doesn't exist.
1029+
_check_circular_ref(flags[fname], (flag_refs, rule_refs))
10151030
for rname, _ in entity._compiled_filter.rule_refs:
10161031
if rname in rule_refs:
10171032
raise ValueError(f"circular reference in rule {entity.name}")
1018-
_check_circular_ref(compiled_rules[rname], (flag_refs, rule_refs))
1033+
if rname in compiled_rules:
1034+
_check_circular_ref(compiled_rules[rname], (flag_refs, rule_refs))
10191035
else: # pragma: no cover
10201036
assert False, "unreachable" # pragma: no cover
10211037

tests/test_attribute_only_filters.py

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -233,37 +233,6 @@ def test_valid_symbol_names(self):
233233
fs.parse("f", name)
234234
fs.compile("f", {}, {})
235235

236-
def test_inconsitent_inferred_flag_types(self):
237-
cases = [
238-
"flag:a = 1 and flag:a = 'apple'",
239-
"flag:a in [1,2] and flag:a in ['apple', 'orange']",
240-
"flag:a != 'apple' and flag:a in [2,3]",
241-
"flag:a and flag:a in [2,3]",
242-
"flag:a and flag:a = 'apple'",
243-
]
244-
for filter in cases:
245-
with self.subTest(filter):
246-
fs = _FilterSet()
247-
with self.assertRaisesRegex(ValueError, "referenced feature flag a has conflicting inferred types"):
248-
fs.parse("f", filter)
249-
fs.compile("f", {}, {})
250-
251-
def test_inferred_flag_types(self):
252-
cases = [
253-
("flag:a = 'apple'", str),
254-
("flag:a in ['apple', 'orange']", str),
255-
("flag:a in [2,3]", int),
256-
("flag:a != 'apple'", str),
257-
("flag:a = 6", int),
258-
("flag:a", bool),
259-
]
260-
for filter, expected_type in cases:
261-
with self.subTest(filter):
262-
fs = _FilterSet()
263-
fs.parse("f", filter)
264-
c = fs.compile("f", {}, {})
265-
self.assertIs(c.flag_refs["a"], expected_type)
266-
267236
def test_inlined_filters(self):
268237
fs = _FilterSet()
269238
fs.parse("a", "attr:sid = 1")
@@ -303,7 +272,7 @@ def test_rule_and_flag_refs(self):
303272
fs.parse("a", "rule:abc and rule:ghi/A or flag:xyz = '88'")
304273
fs.parse("b", "filter:a and rule:def or flag:uwu or flag:xyz != 'abc'")
305274
c = fs.compile("b", {}, {})
306-
self.assertDictEqual(c.flag_refs, {"xyz": str, "uwu": bool})
275+
self.assertSetEqual(c.flag_refs, {"xyz", "uwu"})
307276
self.assertSetEqual(c.rule_refs, {("abc", None), ("def", None), ("ghi", "A")})
308277

309278
def test_insplit_function_distribution_and_exclusivity(self):

0 commit comments

Comments
 (0)