66import time
77import re
88import json
9+ import importlib .resources as ir
910
1011from .http import HttpClient
1112
@@ -21,7 +22,13 @@ def _escape_odata_quotes(value: str) -> str:
2122 """Escape single quotes for OData queries (by doubling them)."""
2223 return value .replace ("'" , "''" )
2324
24- def __init__ (self , auth , base_url : str , config = None ) -> None :
25+ def __init__ (
26+ self ,
27+ auth ,
28+ base_url : str ,
29+ config = None ,
30+ feature_flags : Optional [Dict [str , bool ]] = None ,
31+ ) -> None :
2532 self .auth = auth
2633 self .base_url = (base_url or "" ).rstrip ("/" )
2734 if not self .base_url :
@@ -41,8 +48,75 @@ def __init__(self, auth, base_url: str, config=None) -> None:
4148 self ._entityset_primaryid_cache : dict [str , str ] = {}
4249 # Cache: logical name -> primary id attribute
4350 self ._logical_primaryid_cache : dict [str , str ] = {}
44- # Cache : (logical_name, attribute_logical) -> {normalized_label: option_value }
51+ # Picklist label cache : (logical_name, attribute_logical) -> {'map': {...}, 'ts': epoch_seconds }
4552 self ._picklist_label_cache = {}
53+ self ._picklist_cache_ttl_seconds = 3600 # 1 hour TTL
54+ # Load required feature flags from bundled JSON resource; fail hard if missing or invalid.
55+ try :
56+ data = ir .files ("dataverse_sdk" ).joinpath ("feature_flags.json" ).read_text (encoding = "utf-8" )
57+ except Exception as e :
58+ raise RuntimeError (f"Failed to load feature_flags.json resource: { e } " ) from e
59+ try :
60+ loaded = json .loads (data ) if data else {}
61+ except Exception as e :
62+ raise RuntimeError (f"feature_flags.json is not valid JSON: { e } " ) from e
63+ if not isinstance (loaded , dict ):
64+ raise RuntimeError ("feature_flags.json root must be a JSON object mapping feature -> (bool | object)" )
65+
66+ self ._features : Dict [str , bool ] = {}
67+ self ._feature_metadata : Dict [str , Dict [str , Any ]] = {}
68+
69+ for raw_key , raw_val in loaded .items ():
70+ # Enforce key type and non-empty constraint
71+ if not isinstance (raw_key , str ):
72+ raise RuntimeError (f"feature_flags.json key '{ raw_key } ' is not a string" )
73+ key = raw_key .strip ().lower ()
74+ if not key :
75+ raise RuntimeError ("feature_flags.json contains an empty feature name" )
76+ # Object form with strict schema: { "default": bool, "description": str }
77+ if isinstance (raw_val , dict ):
78+ required_keys = {"default" , "description" }
79+ unknown = set (raw_val .keys ()) - required_keys
80+ if unknown :
81+ raise RuntimeError (
82+ f"Feature '{ raw_key } ' has unknown metadata keys: { ', ' .join (sorted (unknown ))} . Allowed: default, description"
83+ )
84+ missing = required_keys - set (raw_val .keys ())
85+ if missing :
86+ raise RuntimeError (
87+ f"Feature '{ raw_key } ' object missing required key(s): { ', ' .join (sorted (missing ))} (requires: default, description)"
88+ )
89+ if not isinstance (raw_val ["default" ], bool ):
90+ raise RuntimeError (f"Feature '{ raw_key } ' field 'default' must be boolean" )
91+ desc = raw_val ["description" ]
92+ if not isinstance (desc , str ) or not desc .strip ():
93+ raise RuntimeError (f"Feature '{ raw_key } ' field 'description' must be a non-empty string" )
94+ self ._features [key ] = raw_val ["default" ]
95+ self ._feature_metadata [key ] = {"description" : desc .strip ()}
96+ continue
97+ # Any other type is invalid
98+ raise RuntimeError (
99+ f"Feature '{ raw_key } ' must be an object with 'default' (bool) and required 'description' (non-empty str)"
100+ )
101+
102+ # Overlay user overrides (if supplied). Overrides must:
103+ # - use existing feature names declared in feature_flags.json
104+ # - provide boolean values only (no coercion of truthy/falsy non-bools)
105+ # - use non-empty string keys
106+ if isinstance (feature_flags , dict ):
107+ for k , v in feature_flags .items ():
108+ if not isinstance (k , str ) or not k .strip ():
109+ raise ValueError ("feature_flags override keys must be non-empty strings" )
110+ norm = k .strip ().lower ()
111+ if norm not in self ._features :
112+ raise ValueError (
113+ f"Unknown feature flag override '{ k } ' (not declared in feature_flags.json)"
114+ )
115+ if not isinstance (v , bool ):
116+ raise ValueError (
117+ f"Override value for feature '{ k } ' must be boolean (got { type (v ).__name__ } )"
118+ )
119+ self ._features [norm ] = v
46120
47121 def _headers (self ) -> Dict [str , str ]:
48122 """Build standard OData headers with bearer auth."""
@@ -769,15 +843,18 @@ def _normalize_picklist_label(self, label: str) -> str:
769843 def _optionset_map (self , entity_set : str , attr_logical : str ) -> Optional [Dict [str , int ]]:
770844 """Build or return cached mapping of normalized label -> value for a picklist attribute.
771845
772- Returns None if attribute is not a picklist or no options available.
846+ Returns empty dict if attribute is not a picklist or has no options. Returns None only
847+ for invalid inputs or unexpected metadata parse failures.
773848 """
774849 if not entity_set or not attr_logical :
775850 return None
776851 logical = self ._logical_from_entity_set (entity_set )
777852 cache_key = (logical , attr_logical .lower ())
778- if cache_key in self ._picklist_label_cache :
779- # Empty dict cached => known non-picklist (negative cache sentinel)
780- return self ._picklist_label_cache [cache_key ]
853+ now = time .time ()
854+ entry = self ._picklist_label_cache .get (cache_key )
855+ if isinstance (entry , dict ) and 'map' in entry and (now - entry .get ('ts' , 0 )) < self ._picklist_cache_ttl_seconds :
856+ return entry ['map' ]
857+
781858 attr_esc = self ._escape_odata_quotes (attr_logical )
782859 logical_esc = self ._escape_odata_quotes (logical )
783860
@@ -808,9 +885,8 @@ def _optionset_map(self, entity_set: str, attr_logical: str) -> Optional[Dict[st
808885 return None
809886 attr_md = items [0 ]
810887 if attr_md .get ("AttributeType" ) not in ("Picklist" , "PickList" ):
811- # Negative cache sentinel: attribute confirmed not a picklist; avoid future metadata calls
812- self ._picklist_label_cache [cache_key ] = {}
813- return self ._picklist_label_cache [cache_key ]
888+ self ._picklist_label_cache [cache_key ] = {'map' : {}, 'ts' : now }
889+ return {}
814890
815891 # Step 2: fetch with expand only now that we know it's a picklist
816892 # Need to cast to the derived PicklistAttributeMetadata type; OptionSet is not a nav on base AttributeMetadata.
@@ -856,16 +932,21 @@ def _optionset_map(self, entity_set: str, attr_logical: str) -> Optional[Dict[st
856932 normalized = self ._normalize_picklist_label (lab )
857933 mapping .setdefault (normalized , val )
858934 if mapping :
859- self ._picklist_label_cache [cache_key ] = mapping
935+ self ._picklist_label_cache [cache_key ] = { 'map' : mapping , 'ts' : now }
860936 return mapping
861- return None
937+ # No options available
938+ self ._picklist_label_cache [cache_key ] = {'map' : {}, 'ts' : now }
939+ return {}
862940
863941 def _convert_labels_to_ints (self , entity_set : str , record : Dict [str , Any ]) -> Dict [str , Any ]:
864942 """Return a copy of record with any labels converted to option ints.
865943
866944 Heuristic: For each string value, attempt to resolve against picklist metadata.
867945 If attribute isn't a picklist or label not found, value left unchanged.
868946 """
947+ # Fast-path: feature disabled (default). Return original record without copy to avoid overhead.
948+ if not self .is_feature_enabled ("option_set_label_conversion" ):
949+ return record
869950 out = record .copy ()
870951 for k , v in list (out .items ()):
871952 if not isinstance (v , str ) or not v .strip ():
@@ -1040,3 +1121,50 @@ def _create_table(self, tablename: str, schema: Dict[str, Any]) -> Dict[str, Any
10401121 "metadata_id" : metadata_id ,
10411122 "columns_created" : created_cols ,
10421123 }
1124+
1125+ # ---------------------- Cache maintenance -------------------------
1126+ def _flush_cache (
1127+ self ,
1128+ kind ,
1129+ ) -> int :
1130+ """Flush cached client metadata/state.
1131+
1132+ Currently supported kinds:
1133+ - 'picklist': clears entries from the picklist label cache used by label -> int conversion.
1134+
1135+ Parameters
1136+ ----------
1137+ kind : str
1138+ Cache kind to flush. Only 'picklist' is implemented today. Future kinds
1139+ (e.g. 'entityset', 'primaryid') can be added without breaking the signature.
1140+
1141+ Returns
1142+ -------
1143+ int
1144+ Number of cache entries removed.
1145+
1146+ """
1147+ k = (kind or "" ).strip ().lower ()
1148+ if k != "picklist" :
1149+ raise ValueError (f"Unsupported cache kind '{ kind } ' (only 'picklist' is implemented)" )
1150+
1151+ removed = len (self ._picklist_label_cache )
1152+ self ._picklist_label_cache .clear ()
1153+ return removed
1154+
1155+ # ---------------------- Feature flags / toggles --------------------
1156+ def set_feature (self , name : str , enabled : bool ) -> None :
1157+ if not isinstance (name , str ) or not name .strip ():
1158+ raise ValueError ("Feature name must be a non-empty string" )
1159+ self ._features [name .strip ().lower ()] = bool (enabled )
1160+
1161+ def enable_feature (self , name : str ) -> None :
1162+ self .set_feature (name , True )
1163+
1164+ def disable_feature (self , name : str ) -> None :
1165+ self .set_feature (name , False )
1166+
1167+ def is_feature_enabled (self , name : str ) -> bool :
1168+ if not isinstance (name , str ):
1169+ return False
1170+ return bool (self ._features .get (name .strip ().lower ()))
0 commit comments