@@ -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"""
637636def 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
0 commit comments