feat(python-sdk): add in-memory config evaluation managers - #2
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request establishes a foundational in-memory evaluation layer for the Python SDK. It enables the SDK to efficiently resolve experiences, features, and segments directly from loaded configuration data, mirroring the capabilities of its JavaScript counterpart. This enhancement is crucial for building out the SDK's core functionality, providing a robust mechanism for deterministic evaluation without immediate reliance on external network calls or public SDK entrypoints. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a significant refactoring and expansion of the SDK's core logic, adding new managers for data, experiences, features, and segments, along with utility functions for object merging and type casting. It also includes comprehensive unit tests and a new fixture configuration. However, several critical issues and improvement opportunities have been identified: the test_config.json fixture contains invalid JSON, which will cause parsing errors; the DataManager.is_valid_config_data method has a logical bug, incorrectly validating configurations with an "error" key; in DataManager.match_rules_by_field, segments are incorrectly retrieved using the audiences field; FeatureManager.run_features silently drops bucketing errors; FeatureManager.run_feature and run_feature_by_id have inconsistent return types; the cast_type function in type_utils.py has ambiguous behavior when casting invalid JSON; the config parameter is passed to several manager constructors but is immediately deleted or only partially used, indicating a potential API design inconsistency; and the _retrieve_bucketing and _get_bucketing_by_field methods in DataManager also have inconsistent return types.
| @pytest.fixture | ||
| def config(): | ||
| fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "test_config.json" | ||
| return json.loads(fixture_path.read_text()) |
There was a problem hiding this comment.
| "type": "float" | ||
| }, | ||
| { | ||
| "key": "link", |
There was a problem hiding this comment.
The test_config.json file contains invalid JSON. Specifically, there is a missing opening curly brace { for a dictionary within the features array, making the entry for the "link" variable malformed. This will cause parsing errors when the configuration is loaded.
{
"key": "link",
"type": "string"
},| data.get("error") | ||
| or (data.get("account_id") and (data.get("project") or {}).get("id")) |
There was a problem hiding this comment.
The is_valid_config_data method incorrectly considers a configuration valid if it contains an "error" key with a truthy value. A configuration with an "error" key typically indicates an invalid state, not a valid one. The logic should likely check for the absence of an error.
not data.get("error")
and data.get("account_id")
and (data.get("project") or {}).get("id")| else: | ||
| audiences_matched = not audiences | ||
|
|
||
| segments = self.get_items_by_ids(experience.get("audiences") or [], "segments") |
There was a problem hiding this comment.
In match_rules_by_field, the line segments = self.get_items_by_ids(experience.get("audiences") or [], "segments") appears to be a logical error. It attempts to retrieve segments using the audiences field of the experience object. Audiences and segments are distinct entities, and experience objects typically have an audiences field, but not a segments field that would contain segment IDs. This will likely result in incorrect segment matching.
| if isinstance(variation, dict): | ||
| bucketed_variations.append(variation) |
There was a problem hiding this comment.
The run_features method silently drops potential errors returned by _data_manager.get_bucketing. If get_bucketing returns a BucketingError or RuleError enum member (as it's designed to do), these errors are not propagated or handled, leading to features not being evaluated without any clear indication of why. This can make debugging difficult.
| if isinstance(variation, dict): | |
| bucketed_variations.append(variation) | |
| variation = self._data_manager.get_bucketing(visitor_id, experience.get("key"), attributes) | |
| if isinstance(variation, dict): | |
| bucketed_variations.append(variation) | |
| elif isinstance(variation, (BucketingError, RuleError)): | |
| # Log the error or handle it appropriately | |
| # For now, we'll just skip this experience if it results in an error | |
| pass |
| data_manager: DataManager, | ||
| rule_manager: RuleManager, | ||
| ) -> None: | ||
| del config |
There was a problem hiding this comment.
The config parameter is passed to the __init__ method but is immediately deleted using del config. This indicates that the parameter is not used within the method. It's better practice to prefix unused parameters with an underscore (e.g., _config) to clearly signal intent and avoid unnecessary operations like del.
| del config | |
| _config: Mapping[str, Any] | None = None, |
| try: | ||
| return json.loads(str(value)) | ||
| except Exception: | ||
| return str(value) |
There was a problem hiding this comment.
In the cast_type function, when kind is "json", if json.loads(str(value)) fails due to an invalid JSON string, the function falls back to returning str(value). This can lead to ambiguous behavior where a caller might expect a JSON object (dict/list) or an error, but instead receives an unparsed string. It would be clearer to either raise an exception or return None to explicitly indicate a failure to cast to JSON.
| return str(value) | |
| try: | |
| return json.loads(str(value)) | |
| except json.JSONDecodeError: | |
| return None | |
| except Exception: | |
| return str(value) |
| *, | ||
| data_store: Any = None, | ||
| ) -> None: | ||
| del config |
There was a problem hiding this comment.
The config parameter is passed to the __init__ method but is immediately deleted using del config. This indicates that the parameter is not used within the method. It's better practice to prefix unused parameters with an underscore (e.g., _config) to clearly signal intent and avoid unnecessary operations like del.
| del config | |
| _config: Mapping[str, Any] | None = None, |
| else {"experienceId": str(experience.get("id"))}, | ||
| ) | ||
| if not bucketing: | ||
| return BucketingError.VARIAION_NOT_DECIDED |
There was a problem hiding this comment.
The methods _retrieve_bucketing and _get_bucketing_by_field (line 355) can return either a dictionary (representing a bucketing result) or a BucketingError/RuleError enum member. This inconsistency in return types can make error handling more complex for callers. Consider raising exceptions for error conditions or returning None consistently and letting the caller interpret the absence of a result as an error.
| config, | ||
| bucketing_manager=bucketing_manager, | ||
| rule_manager=rule_manager, | ||
| ) | ||
| segments_manager = SegmentsManager( | ||
| config, | ||
| data_manager=data_manager, | ||
| rule_manager=rule_manager, | ||
| ) | ||
| experience_manager = ExperienceManager(config, data_manager=data_manager) | ||
| feature_manager = FeatureManager(config, data_manager=data_manager) |
There was a problem hiding this comment.
The config object is passed to the constructors of DataManager, SegmentsManager, ExperienceManager, and FeatureManager. However, these managers then immediately discard or ignore the config parameter (e.g., using del config or by only extracting sub-parts of it and not using the full object). This creates an inconsistent API where config is required in the signature but not fully utilized, which can be misleading. Consider refactoring the constructors to only accept the specific configuration parts they need, or to use a common base class that handles the config extraction if it's a shared pattern.
All eight open Dependabot alerts on this repo sit in `yarn.lock` — the dev-only semantic-release tooling. None of them reach the published wheel/sdist (httpx is still the only runtime dependency). Three of the four highs were already reachable inside the existing semver ranges; the fourth (sigstore GHSA-52v5-jr5w-gjxr, `certificateOIDs` verification constraints silently dropped) needed sigstore >= 4.1.1, which only arrives through a major bump: semantic-release 24.2.9 -> 25.0.9 @semantic-release/npm 12.0.2 -> 13.1.5 npm 10.9.8 -> 11.19.0 libnpmpublish 10 -> 11.2.0 }-> sigstore ^3 -> ^4 (4.1.1) pacote 19 -> 21.5.1 } @sigstore/core 2.0.0 -> 3.2.1 tar 7.5.16 -> 7.5.22 `@semantic-release/github` moves to ^12 to match what semantic-release 25 depends on — leaving it at ^11 would hoist the older copy to the project root and shadow the one core resolves. Resolved (4 high, 4 medium — no criticals were open): #14 high ip-address 10.2.0 -> 10.4.0 (needs >= 10.3.1) #9 high brace-expansion 2.1.1 -> 5.0.9 (needs >= 2.1.2) #7 high js-yaml 4.2.0 -> 4.3.1 (needs >= 4.3.0) #2 high sigstore 3.1.0 -> 4.1.1 (needs >= 4.1.1) #13 medium ip-address (same bump as #14) #12 medium ip-address (same bump as #14) #4 medium tar 7.5.16 -> 7.5.22 (needs >= 7.5.18) #1 medium @sigstore/core 2.0.0 -> 3.2.1 (needs >= 3.2.1) Node: semantic-release 25 requires ^22.14.0 || >= 24.10.0. release.yml installs `lts/*`, currently Node 24.19.0 — satisfied, and every future LTS line stays above the floor. Verified: `yarn install --immutable` (what release.yml runs) passes against the regenerated lockfile with the lockfile format unchanged (__metadata version 10), and `yarn npm audit --all --recursive` reports no suggestions. A `semantic-release --dry-run` against this branch loads all four configured plugins, passes verifyConditions for both exec and github (GitHub authentication + push permission), and analyzes commits to "no release" — correct, since `chore` is a non-releasing type in release.config.mjs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
This PR adds the in-memory evaluation layer for the Python SDK on top of the deterministic core from PR1. It ports the JS SDK’s static-config evaluation flow so Python code can resolve experiences, features, and segments from loaded config data without introducing the public SDK entrypoints or HTTP layer yet.
What’s Included
Added
DataManagerfor:Added
DataStoreManageras a thin datastore adapter abstractionAdded
SegmentsManagerfor:Added
ExperienceManagerfor:Added
FeatureManagerfor:Added type-casting utilities for feature variables
Added shared config fixtures and manager-level unit tests for:
Verification
.venv/bin/pytest -q56 passedOut of Scope
This PR intentionally does not include:
ConvertSDKContextThose will come in the next PRs once the in-memory evaluation layer is locked down.