Skip to content

Commit 948adcd

Browse files
aepfliclaude
andauthored
feat: rust based improvements (#52)
## Description <!-- Provide a brief description of your changes --> ## Related Issue <!-- Link to the related issue(s) --> Closes # ## Type of Change <!-- Mark the relevant option with an "x" --> - [ ] `feat`: New feature (minor version bump) - [ ] `fix`: Bug fix (patch version bump) - [ ] `docs`: Documentation only changes - [ ] `chore`: Maintenance tasks, dependency updates - [ ] `refactor`: Code refactoring without functional changes - [ ] `test`: Adding or updating tests - [ ] `ci`: CI/CD changes - [ ] `perf`: Performance improvements - [ ] `build`: Build system changes - [ ] `style`: Code style/formatting changes ## PR Title Format **IMPORTANT**: Since we use squash and merge, your PR title will become the commit message. Please ensure your PR title follows the [Conventional Commits](https://www.conventionalcommits.org/) format: ``` <type>(<optional-scope>): <description> ``` ### Examples: - `feat(operators): add new string comparison operator` - `fix(wasm): correct memory allocation bug` - `docs: update API examples in README` - `chore(deps): update rust dependencies` For breaking changes, use `!` after the type/scope or include `BREAKING CHANGE:` in the PR description: - `feat(api)!: redesign evaluation API` ## Testing <!-- Describe the testing you've performed --> - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] All tests pass (`cargo test`) - [ ] Code is formatted (`cargo fmt`) - [ ] Clippy checks pass (`cargo clippy -- -D warnings`) - [ ] WASM builds successfully (if applicable) ## Breaking Changes <!-- If this introduces breaking changes, describe them here --> - [ ] This PR includes breaking changes - [ ] Documentation has been updated to reflect breaking changes - [ ] Migration guide included (if needed) ## Additional Notes <!-- Any additional information, context, or screenshots --> --------- Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 004db1e commit 948adcd

11 files changed

Lines changed: 1049 additions & 2363 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Generated files
22
/target/
33
/dist/
4+
/python/tests/__pycache__
45

56
# Cargo lock file (library)
67
Cargo.lock

examples/evaluators_demo.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
//!
33
//! Run with: cargo run --example evaluators_demo
44
5-
use flagd_evaluator::evaluation::evaluate_flag;
6-
use flagd_evaluator::storage::{get_flag_state, update_flag_state};
5+
use flagd_evaluator::{FlagEvaluator, ValidationMode};
76
use serde_json::json;
87

98
fn main() {
@@ -46,9 +45,14 @@ fn main() {
4645
println!("╚═══════════════════════════════════════════════════════════╝\n");
4746

4847
println!("Loading flag configuration with $evaluators...");
49-
update_flag_state(config).expect("Failed to update state");
5048

51-
let state = get_flag_state().expect("Failed to get state");
49+
// Create an evaluator instance
50+
let mut evaluator = FlagEvaluator::new(ValidationMode::Strict);
51+
evaluator
52+
.update_state(config)
53+
.expect("Failed to update state");
54+
55+
let state = evaluator.get_state().expect("Failed to get state");
5256
let flag = state.flags.get("vipFeatures").expect("Flag not found");
5357

5458
println!("✓ Configuration loaded successfully");
@@ -63,7 +67,7 @@ fn main() {
6367

6468
// Test 1: Admin user
6569
let context = json!({"email": "admin@company.com", "tier": "basic"});
66-
let result = evaluate_flag(flag, &context, &state.flag_set_metadata);
70+
let result = evaluator.evaluate_flag("vipFeatures", &context);
6771
println!("1️⃣ Admin user (admin@company.com, tier=basic):");
6872
println!(
6973
" → Result: {}, Variant: {}",
@@ -73,7 +77,7 @@ fn main() {
7377

7478
// Test 2: Premium user
7579
let context = json!({"email": "user@company.com", "tier": "premium"});
76-
let result = evaluate_flag(flag, &context, &state.flag_set_metadata);
80+
let result = evaluator.evaluate_flag("vipFeatures", &context);
7781
println!("\n2️⃣ Premium user (user@company.com, tier=premium):");
7882
println!(
7983
" → Result: {}, Variant: {}",
@@ -83,7 +87,7 @@ fn main() {
8387

8488
// Test 3: Regular user
8589
let context = json!({"email": "user@company.com", "tier": "basic"});
86-
let result = evaluate_flag(flag, &context, &state.flag_set_metadata);
90+
let result = evaluator.evaluate_flag("vipFeatures", &context);
8791
println!("\n3️⃣ Regular user (user@company.com, tier=basic):");
8892
println!(
8993
" → Result: {}, Variant: {}",

python/src/lib.rs

Lines changed: 44 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
use ::flagd_evaluator::evaluation::{
2-
evaluate_bool_flag, evaluate_flag, evaluate_float_flag, evaluate_int_flag, evaluate_string_flag,
3-
};
4-
use ::flagd_evaluator::model::ParsingResult;
1+
use ::flagd_evaluator::ValidationMode;
52
use pyo3::prelude::*;
63
use pyo3::types::PyDict;
74
use serde_json::Value;
@@ -27,7 +24,8 @@ use serde_json::Value;
2724
/// True
2825
#[pyclass]
2926
struct FlagEvaluator {
30-
state: Option<ParsingResult>,
27+
/// Wrap the Rust FlagEvaluator directly - no duplication!
28+
inner: ::flagd_evaluator::FlagEvaluator,
3129
}
3230

3331
#[pymethods]
@@ -41,17 +39,15 @@ impl FlagEvaluator {
4139
#[new]
4240
#[pyo3(signature = (permissive=false))]
4341
fn new(permissive: bool) -> Self {
44-
use ::flagd_evaluator::storage::{set_validation_mode, ValidationMode};
45-
4642
let mode = if permissive {
4743
ValidationMode::Permissive
4844
} else {
4945
ValidationMode::Strict
5046
};
5147

52-
set_validation_mode(mode);
53-
54-
FlagEvaluator { state: None }
48+
FlagEvaluator {
49+
inner: ::flagd_evaluator::FlagEvaluator::new(mode),
50+
}
5551
}
5652

5753
/// Update the flag configuration state
@@ -73,21 +69,23 @@ impl FlagEvaluator {
7369
))
7470
})?;
7571

76-
// Parse the configuration
77-
let parsing_result = ParsingResult::parse(&config_str).map_err(|e| {
72+
// Delegate to the Rust FlagEvaluator
73+
let response = self.inner.update_state(&config_str).map_err(|e| {
7874
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
79-
"Failed to parse config: {}",
75+
"Failed to update state: {}",
8076
e
8177
))
8278
})?;
8379

84-
// Store the state
85-
self.state = Some(parsing_result.clone());
86-
87-
// Return update response (simplified - just success)
88-
let result_dict = PyDict::new_bound(py);
89-
result_dict.set_item("success", true)?;
90-
Ok(result_dict.into())
80+
// Convert response to Python dict
81+
pythonize::pythonize(py, &response)
82+
.map(|bound| bound.unbind())
83+
.map_err(|e| {
84+
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
85+
"Failed to convert response: {}",
86+
e
87+
))
88+
})
9189
}
9290

9391
/// Evaluate a feature flag
@@ -104,22 +102,11 @@ impl FlagEvaluator {
104102
flag_key: String,
105103
context: &Bound<'_, PyDict>,
106104
) -> PyResult<PyObject> {
107-
let state = self.state.as_ref().ok_or_else(|| {
108-
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
109-
"No state loaded. Call update_state() first.",
110-
)
111-
})?;
112-
113-
// Look up the flag
114-
let flag = state.flags.get(&flag_key).ok_or_else(|| {
115-
PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!("Flag not found: {}", flag_key))
116-
})?;
117-
118105
// Convert context to JSON Value
119106
let context_value: Value = pythonize::depythonize(context.as_any())?;
120107

121-
// Evaluate the flag
122-
let result = evaluate_flag(flag, &context_value, &state.flag_set_metadata);
108+
// Delegate to the Rust FlagEvaluator
109+
let result = self.inner.evaluate_flag(&flag_key, &context_value);
123110

124111
// Convert result to Python dict
125112
pythonize::pythonize(py, &result)
@@ -147,18 +134,13 @@ impl FlagEvaluator {
147134
context: &Bound<'_, PyDict>,
148135
default_value: bool,
149136
) -> PyResult<bool> {
150-
let state = self.state.as_ref().ok_or_else(|| {
151-
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
152-
"No state loaded. Call update_state() first.",
153-
)
154-
})?;
155-
156-
let flag = state.flags.get(&flag_key).ok_or_else(|| {
157-
PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!("Flag not found: {}", flag_key))
158-
})?;
159-
160137
let context_value: Value = pythonize::depythonize(context.as_any())?;
161-
let result = evaluate_bool_flag(flag, &context_value, &state.flag_set_metadata);
138+
let result = self.inner.evaluate_bool(&flag_key, &context_value);
139+
140+
// If there's an error, return the default value
141+
if result.error_code.is_some() {
142+
return Ok(default_value);
143+
}
162144

163145
match result.value {
164146
Value::Bool(b) => Ok(b),
@@ -181,18 +163,13 @@ impl FlagEvaluator {
181163
context: &Bound<'_, PyDict>,
182164
default_value: String,
183165
) -> PyResult<String> {
184-
let state = self.state.as_ref().ok_or_else(|| {
185-
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
186-
"No state loaded. Call update_state() first.",
187-
)
188-
})?;
189-
190-
let flag = state.flags.get(&flag_key).ok_or_else(|| {
191-
PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!("Flag not found: {}", flag_key))
192-
})?;
193-
194166
let context_value: Value = pythonize::depythonize(context.as_any())?;
195-
let result = evaluate_string_flag(flag, &context_value, &state.flag_set_metadata);
167+
let result = self.inner.evaluate_string(&flag_key, &context_value);
168+
169+
// If there's an error, return the default value
170+
if result.error_code.is_some() {
171+
return Ok(default_value);
172+
}
196173

197174
match result.value {
198175
Value::String(s) => Ok(s),
@@ -215,18 +192,13 @@ impl FlagEvaluator {
215192
context: &Bound<'_, PyDict>,
216193
default_value: i64,
217194
) -> PyResult<i64> {
218-
let state = self.state.as_ref().ok_or_else(|| {
219-
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
220-
"No state loaded. Call update_state() first.",
221-
)
222-
})?;
223-
224-
let flag = state.flags.get(&flag_key).ok_or_else(|| {
225-
PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!("Flag not found: {}", flag_key))
226-
})?;
227-
228195
let context_value: Value = pythonize::depythonize(context.as_any())?;
229-
let result = evaluate_int_flag(flag, &context_value, &state.flag_set_metadata);
196+
let result = self.inner.evaluate_int(&flag_key, &context_value);
197+
198+
// If there's an error, return the default value
199+
if result.error_code.is_some() {
200+
return Ok(default_value);
201+
}
230202

231203
match result.value {
232204
Value::Number(n) => Ok(n.as_i64().unwrap_or(default_value)),
@@ -249,18 +221,13 @@ impl FlagEvaluator {
249221
context: &Bound<'_, PyDict>,
250222
default_value: f64,
251223
) -> PyResult<f64> {
252-
let state = self.state.as_ref().ok_or_else(|| {
253-
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
254-
"No state loaded. Call update_state() first.",
255-
)
256-
})?;
257-
258-
let flag = state.flags.get(&flag_key).ok_or_else(|| {
259-
PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!("Flag not found: {}", flag_key))
260-
})?;
261-
262224
let context_value: Value = pythonize::depythonize(context.as_any())?;
263-
let result = evaluate_float_flag(flag, &context_value, &state.flag_set_metadata);
225+
let result = self.inner.evaluate_float(&flag_key, &context_value);
226+
227+
// If there's an error, return the default value
228+
if result.error_code.is_some() {
229+
return Ok(default_value);
230+
}
264231

265232
match result.value {
266233
Value::Number(n) => Ok(n.as_f64().unwrap_or(default_value)),

python/tests/test_basic.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,21 @@ def test_flag_evaluator_float():
105105

106106

107107
def test_flag_evaluator_no_state():
108-
"""Test that evaluating without state raises an error."""
108+
"""Test that evaluating without state returns the default value."""
109109
from flagd_evaluator import FlagEvaluator
110110

111111
evaluator = FlagEvaluator()
112112

113-
with pytest.raises(RuntimeError, match="No state loaded"):
114-
evaluator.evaluate_bool("myFlag", {}, False)
113+
# Should return default value when no state is loaded
114+
result = evaluator.evaluate_bool("myFlag", {}, False)
115+
assert result == False
116+
117+
result2 = evaluator.evaluate_bool("myFlag", {}, True)
118+
assert result2 == True
115119

116120

117121
def test_flag_evaluator_flag_not_found():
118-
"""Test that evaluating non-existent flag raises an error."""
122+
"""Test that evaluating non-existent flag returns the default value."""
119123
from flagd_evaluator import FlagEvaluator
120124

121125
evaluator = FlagEvaluator()
@@ -129,5 +133,9 @@ def test_flag_evaluator_flag_not_found():
129133
}
130134
})
131135

132-
with pytest.raises(KeyError, match="Flag not found"):
133-
evaluator.evaluate_bool("nonExistentFlag", {}, False)
136+
# Should return default value for non-existent flag
137+
result = evaluator.evaluate_bool("nonExistentFlag", {}, False)
138+
assert result == False
139+
140+
result2 = evaluator.evaluate_string("nonExistentFlag", {}, "fallback")
141+
assert result2 == "fallback"

src/evaluation.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,18 @@ pub fn evaluate_flag(
313313
// Merge metadata (flag metadata takes priority over flag-set metadata)
314314
let merged_metadata = merge_metadata(flag_set_metadata, &flag.metadata);
315315

316+
// Check if flag was not found
317+
if flag.state == "FLAG_NOT_FOUND" {
318+
return EvaluationResult {
319+
value: Value::Null,
320+
variant: None,
321+
reason: ResolutionReason::FlagNotFound,
322+
error_code: Some(ErrorCode::FlagNotFound),
323+
error_message: Some(format!("Flag '{}' not found in configuration", flag_key)),
324+
flag_metadata: merged_metadata,
325+
};
326+
}
327+
316328
// Check if flag is disabled
317329
// Return Disabled reason with FLAG_NOT_FOUND error code to signal the client
318330
// to use its code-defined default value. The Disabled reason provides better

0 commit comments

Comments
 (0)