Skip to content

Commit b4aed08

Browse files
author
Max Wang
committed
fixed cache issues and added feature flags
1 parent 5e0282a commit b4aed08

5 files changed

Lines changed: 324 additions & 12 deletions

File tree

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,53 @@ VS Code Tasks
335335
- Install deps: `Install deps (pip)`
336336
- Run example: `Run Quickstart (Dataverse SDK)`
337337

338+
339+
## Feature Flags (extensible) & Picklist Label Coercion
340+
341+
The SDK uses a lightweight feature flag system.
342+
343+
Current flags:
344+
- `option_set_label_conversion` – Translate picklist (option set) string labels in outgoing create/update payloads into their numeric values using cached metadata (TTL 1h). Disabled by default.
345+
346+
Source & schema:
347+
`feature_flags.json` (packaged) provides defaults. Each feature MUST use the strict object form only:
348+
349+
```json
350+
{
351+
"flag_name": {
352+
"default": false,
353+
"description": "Human readable explanation of the flag purpose"
354+
}
355+
}
356+
```
357+
358+
Rules:
359+
- Both `default` (bool) and `description` (non-empty string) are required.
360+
- No extra keys are allowed (startup fails fast on unknown keys).
361+
- The JSON file does NOT accept boolean shorthand; that form is permitted only for runtime overrides passed to the client.
362+
363+
Override precedence (highest wins):
364+
1. Explicit `feature_flags={...}` passed to `DataverseClient` (boolean overrides only at present).
365+
2. Bundled `feature_flags.json` defaults (boolean or object form).
366+
367+
Provide a feature flag mapping at construction:
368+
```python
369+
client = DataverseClient(
370+
base_url="https://yourorg.crm.dynamics.com",
371+
feature_flags={
372+
"option_set_label_conversion": True,
373+
# future flags here
374+
}
375+
)
376+
```
377+
378+
Toggle/check at runtime:
379+
```python
380+
client.enable_feature("option_set_label_conversion")
381+
client.is_feature_enabled("option_set_label_conversion")
382+
client.disable_feature("option_set_label_conversion")
383+
```
384+
338385
## Limitations / Future Work
339386
- No general-purpose OData batching, upsert, or association operations yet.
340387
- `DeleteMultiple` not yet exposed.

src/dataverse_sdk/client.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ def __init__(
4242
base_url: str,
4343
credential: Optional[TokenCredential] = None,
4444
config: Optional[DataverseConfig] = None,
45+
feature_flags: Optional[Dict[str, bool]] = None,
4546
) -> None:
4647
self.auth = AuthManager(credential)
4748
self._base_url = (base_url or "").rstrip("/")
4849
if not self._base_url:
4950
raise ValueError("base_url is required.")
5051
self._config = config or DataverseConfig.from_env()
5152
self._odata: Optional[ODataClient] = None
53+
self._feature_flags = dict(feature_flags) if isinstance(feature_flags, dict) else None
5254

5355
def _get_odata(self) -> ODataClient:
5456
"""Get or create the internal OData client instance.
@@ -59,7 +61,12 @@ def _get_odata(self) -> ODataClient:
5961
The lazily-initialized low-level client used to perform requests.
6062
"""
6163
if self._odata is None:
62-
self._odata = ODataClient(self.auth, self._base_url, self._config)
64+
self._odata = ODataClient(
65+
self.auth,
66+
self._base_url,
67+
self._config,
68+
feature_flags=self._feature_flags,
69+
)
6370
return self._odata
6471

6572
# ---------------- Unified CRUD: create/update/delete ----------------
@@ -248,6 +255,39 @@ def list_tables(self) -> list[str]:
248255
"""
249256
return self._get_odata()._list_tables()
250257

258+
# ---------------------- Cache utilities ----------------------
259+
def flush_cache(self, kind) -> int:
260+
"""Flush cached client metadata/state.
261+
262+
Currently supported kinds:
263+
- 'picklist': clears entries from the picklist label cache used by label -> int conversion.
264+
265+
Parameters
266+
----------
267+
kind : str
268+
Cache kind to flush. Only 'picklist' is implemented today. Future kinds
269+
(e.g. 'entityset', 'primaryid') can be added without breaking the signature.
270+
271+
Returns
272+
-------
273+
int
274+
Number of cache entries removed.
275+
276+
"""
277+
return self._get_odata()._flush_cache(kind)
278+
279+
# ---------------------- Feature flags / toggles ----------------------
280+
def set_feature(self, name: str, enabled: bool) -> None:
281+
self._get_odata().set_feature(name, enabled)
282+
283+
def enable_feature(self, name: str) -> None:
284+
self.set_feature(name, True)
285+
286+
def disable_feature(self, name: str) -> None:
287+
self.set_feature(name, False)
288+
289+
def is_feature_enabled(self, name: str) -> bool:
290+
return self._get_odata().is_feature_enabled(name)
251291

252292
__all__ = ["DataverseClient"]
253293

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"option_set_label_conversion": {
3+
"default": false,
4+
"description": "Convert option set label strings to their integer values using metadata. Enable when data includes option set."
5+
}
6+
}

src/dataverse_sdk/odata.py

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import time
77
import re
88
import json
9+
import importlib.resources as ir
910

1011
from .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

Comments
 (0)