diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65c1e43f..342ad9b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: submodules: true - run: npm ci - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo build --verbose + - run: cargo build --verbose --all-targets - run: cargo test --verbose - run: cargo doc --verbose diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..594c389d --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,280 @@ +# This file is autogenerated by maturin v1.7.0 +# To update, run +# +# maturin generate-ci -o .github/workflows/python.yml --pytest --manifest-path ./python-sdk/Cargo.toml github +# +# Manually modified: +# - tag filter in Release job to only trigger on python-sdk@ tags +# - added checkout with submodules +# - replaced `pytest` with `npm run with-server test:python` +name: Python SDK + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + - runner: ubuntu-latest + target: s390x + - runner: ubuntu-latest + target: ppc64le + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Build wheels + uses: PyO3/maturin-action@v1 + if: ${{ startsWith(matrix.platform.target, 'x86') }} + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./python-sdk/Cargo.toml + sccache: 'true' + manylinux: auto + before-script-linux: | + yum install -y perl-IPC-Cmd devtoolset-10-libatomic-devel + - name: Build wheels + uses: PyO3/maturin-action@v1 + if: ${{ !startsWith(matrix.platform.target, 'x86') }} + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./python-sdk/Cargo.toml + sccache: 'true' + manylinux: auto + before-script-linux: | + apt-get update + apt-get install --no-install-recommends -y libssl-dev + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + - name: pytest + if: ${{ startsWith(matrix.platform.target, 'x86_64') }} + shell: bash + run: | + set -ex + python3 -m venv .venv + source .venv/bin/activate + pip install eppo-server-sdk --find-links dist --force-reinstall + pip install pytest cachetools + npm ci + npm run with-server test:python + - name: pytest + if: ${{ !startsWith(matrix.platform.target, 'x86') && matrix.platform.target != 'ppc64' }} + uses: uraimo/run-on-arch-action@v2 + with: + arch: ${{ matrix.platform.target }} + distro: ubuntu22.04 + githubToken: ${{ github.token }} + install: | + apt-get update + apt-get install -y --no-install-recommends python3 python3-pip nodejs npm + pip3 install -U pip pytest cachetools + run: | + set -ex + pip3 install eppo-server-sdk --find-links dist --force-reinstall + npm ci + npm run with-server test:python + + musllinux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./python-sdk/Cargo.toml + sccache: 'true' + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + - name: pytest + if: ${{ startsWith(matrix.platform.target, 'x86_64') }} + uses: addnab/docker-run-action@v3 + with: + image: alpine:latest + options: -v ${{ github.workspace }}:/io -w /io + run: | + set -ex + apk add py3-pip py3-virtualenv nodejs npm + python3 -m virtualenv .venv + source .venv/bin/activate + pip install eppo-server-sdk --no-index --find-links dist --force-reinstall + pip install pytest cachetools + npm ci + npm run with-server test:python + - name: pytest + # `npm ci` just hangs on Alpine armv7 now. + # Disabling tests until this issue is fixed: + # https://github.com/nodejs/docker-node/issues/1829 + if: ${{ !startsWith(matrix.platform.target, 'x86') && matrix.platform.target != 'armv7' }} + uses: uraimo/run-on-arch-action@v2 + with: + arch: ${{ matrix.platform.target }} + distro: alpine_latest + githubToken: ${{ github.token }} + install: | + apk add py3-virtualenv nodejs npm + run: | + set -ex + python3 -m virtualenv .venv + source .venv/bin/activate + pip install pytest cachetools + pip install eppo-server-sdk --find-links dist --force-reinstall + npm ci + npm run with-server test:python + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./python-sdk/Cargo.toml + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + - name: pytest + if: ${{ !startsWith(matrix.platform.target, 'aarch64') }} + shell: bash + run: | + set -ex + python3 -m venv .venv + source .venv/Scripts/activate + pip install eppo-server-sdk --find-links dist --force-reinstall + pip install pytest cachetools + npm ci + npm run with-server test:python + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-12 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./python-sdk/Cargo.toml + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + - name: pytest + run: | + set -ex + python3 -m venv .venv + source .venv/bin/activate + pip install eppo-server-sdk --find-links dist --force-reinstall + pip install pytest cachetools + npm ci + npm run with-server test:python + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist --manifest-path ./python-sdk/Cargo.toml + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/python-sdk@')" + needs: [linux, musllinux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/Cargo.toml b/Cargo.toml index 200e8fb3..273f6fae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "eppo_core", "rust-sdk", + "python-sdk", "ruby-sdk/ext/eppo_client", ] diff --git a/eppo_core/Cargo.toml b/eppo_core/Cargo.toml index 422143f0..5d1769c1 100644 --- a/eppo_core/Cargo.toml +++ b/eppo_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eppo_core" -version = "2.0.0" +version = "3.0.0" edition = "2021" description = "Eppo SDK core library" repository = "https://github.com/Eppo-exp/rust-sdk" @@ -9,6 +9,10 @@ keywords = ["eppo", "feature-flags"] categories = ["config"] rust-version = "1.71.1" +[features] +# Add implementation of `FromPyObject`/`ToPyObject` for some types. +pyo3 = ["dep:pyo3", "dep:serde-pyobject"] + [dependencies] chrono = { version = "0.4.38", features = ["serde"] } derive_more = "0.99.17" @@ -23,6 +27,10 @@ serde_json = "1.0.116" thiserror = "1.0.60" url = "2.5.0" +# pyo3 dependencies +pyo3 = { version = "0.22.0", optional = true, default-features = false } +serde-pyobject = { version = "0.4.0", optional = true} + [dev-dependencies] criterion = { version = "0.4", features = ["html_reports"] } env_logger = "0.11.3" diff --git a/eppo_core/benches/evaluation_details.rs b/eppo_core/benches/evaluation_details.rs index e4e9d844..471c2ba6 100644 --- a/eppo_core/benches/evaluation_details.rs +++ b/eppo_core/benches/evaluation_details.rs @@ -4,7 +4,7 @@ use std::fs::File; use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; use eppo_core::{ - ufc::{get_assignment, get_assignment_details}, + eval::{get_assignment, get_assignment_details}, Configuration, }; diff --git a/eppo_core/src/attributes.rs b/eppo_core/src/attributes.rs index baee38d0..11008f35 100644 --- a/eppo_core/src/attributes.rs +++ b/eppo_core/src/attributes.rs @@ -46,3 +46,34 @@ impl From<&str> for AttributeValue { Self::String(value.to_owned()) } } + +#[cfg(feature = "pyo3")] +mod pyo3_impl { + use pyo3::{exceptions::PyTypeError, prelude::*, types::*}; + + use super::*; + + impl<'py> FromPyObject<'py> for AttributeValue { + fn extract_bound(value: &Bound<'py, PyAny>) -> PyResult { + if let Ok(s) = value.downcast::() { + return Ok(AttributeValue::String(s.extract()?)); + } + // In Python, Bool inherits from Int, so it must be checked first here. + if let Ok(s) = value.downcast::() { + return Ok(AttributeValue::Boolean(s.extract()?)); + } + if let Ok(s) = value.downcast::() { + return Ok(AttributeValue::Number(s.extract()?)); + } + if let Ok(s) = value.downcast::() { + return Ok(AttributeValue::Number(s.extract()?)); + } + if let Ok(_) = value.downcast::() { + return Ok(AttributeValue::Null); + } + Err(PyTypeError::new_err( + "invalid type for subject attribute value", + )) + } + } +} diff --git a/eppo_core/src/configuration_store.rs b/eppo_core/src/configuration_store.rs index 32567112..01e1904e 100644 --- a/eppo_core/src/configuration_store.rs +++ b/eppo_core/src/configuration_store.rs @@ -34,8 +34,7 @@ impl ConfigurationStore { } /// Set new configuration. - pub fn set_configuration(&self, config: Configuration) { - let config = Arc::new(config); + pub fn set_configuration(&self, config: Arc) { let mut configuration_slot = self .configuration .write() @@ -66,7 +65,7 @@ mod tests { { let store = store.clone(); let _ = std::thread::spawn(move || { - store.set_configuration(Configuration::from_server_response( + store.set_configuration(Arc::new(Configuration::from_server_response( UniversalFlagConfig { created_at: Utc::now(), environment: Environment { @@ -76,7 +75,7 @@ mod tests { bandits: HashMap::new(), }, None, - )) + ))) }) .join(); } diff --git a/eppo_core/src/context_attributes.rs b/eppo_core/src/context_attributes.rs index 6506499e..9898d2c6 100644 --- a/eppo_core/src/context_attributes.rs +++ b/eppo_core/src/context_attributes.rs @@ -7,6 +7,7 @@ use crate::{AttributeValue, Attributes}; /// `ContextAttributes` are subject or action attributes split by their semantics. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "pyo3", pyo3::pyclass(module = "eppo_client"))] pub struct ContextAttributes { /// Numeric attributes are quantitative (e.g., real numbers) and define a scale. /// @@ -42,14 +43,13 @@ where acc.numeric.insert(key.to_owned(), value); } AttributeValue::Boolean(value) => { - // TBD: shall we ignore boolean attributes instead? - // // One argument for including it here is that this basically guarantees that // assignment evaluation inside bandit evaluation works the same way as if // `get_assignment()` was called with generic `Attributes`. // - // We can go a step further and remove `AttributeValue::Boolean` altogether, - // forcing it to be converted to a string before any evaluation. + // We can go a step further and remove `AttributeValue::Boolean` altogether + // (from `eppo_core`), forcing it to be converted to a string before any + // evaluation. acc.categorical.insert(key.to_owned(), value.to_string()); } AttributeValue::Null => { @@ -74,3 +74,66 @@ impl ContextAttributes { result } } + +#[cfg(feature = "pyo3")] +mod pyo3_impl { + use std::collections::HashMap; + + use pyo3::prelude::*; + + use crate::Attributes; + + use super::ContextAttributes; + + #[pymethods] + impl ContextAttributes { + #[new] + fn new( + numeric_attributes: HashMap, + categorical_attributes: HashMap, + ) -> ContextAttributes { + ContextAttributes { + numeric: numeric_attributes, + categorical: categorical_attributes, + } + } + + /// Create an empty Attributes instance with no numeric or categorical attributes. + /// + /// Returns: + /// ContextAttributes: An instance of the ContextAttributes class with empty dictionaries + /// for numeric and categorical attributes. + #[staticmethod] + fn empty() -> ContextAttributes { + ContextAttributes::default() + } + + /// Create an ContextAttributes instance from a dictionary of attributes. + + /// Args: + /// attributes (Dict[str, Union[float, int, bool, str]]): A dictionary where keys are attribute names + /// and values are attribute values which can be of type float, int, bool, or str. + + /// Returns: + /// ContextAttributes: An instance of the ContextAttributes class + /// with numeric and categorical attributes separated. + #[staticmethod] + fn from_dict(attributes: Attributes) -> ContextAttributes { + attributes.into() + } + + /// Note that this copies internal attributes, so changes to returned value won't have + /// effect. This may be mitigated by setting numeric attributes again. + #[getter] + fn get_numeric_attributes(&self, py: Python) -> PyObject { + self.numeric.to_object(py) + } + + /// Note that this copies internal attributes, so changes to returned value won't have + /// effect. This may be mitigated by setting categorical attributes again. + #[getter] + fn get_categorical_attributes(&self, py: Python) -> PyObject { + self.categorical.to_object(py) + } + } +} diff --git a/eppo_core/src/eval/eval_details.rs b/eppo_core/src/eval/eval_details.rs index c843ac84..f2fcb2ba 100644 --- a/eppo_core/src/eval/eval_details.rs +++ b/eppo_core/src/eval/eval_details.rs @@ -243,3 +243,20 @@ impl From for BanditEvaluationCode { } } } + +#[cfg(feature = "pyo3")] +mod pyo3_impl { + use pyo3::prelude::*; + + use crate::pyo3::TryToPyObject; + + use super::EvaluationDetails; + + impl TryToPyObject for EvaluationDetails { + fn try_to_pyobject(&self, py: Python) -> PyResult { + serde_pyobject::to_pyobject(py, self) + .map(|it| it.unbind()) + .map_err(|err| err.0) + } + } +} diff --git a/eppo_core/src/eval/mod.rs b/eppo_core/src/eval/mod.rs index fd9cbef6..5d5a848d 100644 --- a/eppo_core/src/eval/mod.rs +++ b/eppo_core/src/eval/mod.rs @@ -7,4 +7,4 @@ mod eval_visitor; pub mod eval_details; pub use eval_assignment::{get_assignment, get_assignment_details}; -pub use eval_bandits::{get_bandit_action, get_bandit_action_details}; +pub use eval_bandits::{get_bandit_action, get_bandit_action_details, BanditResult}; diff --git a/eppo_core/src/events.rs b/eppo_core/src/events.rs index 154e24d3..799dd4e8 100644 --- a/eppo_core/src/events.rs +++ b/eppo_core/src/events.rs @@ -62,3 +62,42 @@ pub struct BanditEvent { pub action_categorical_attributes: HashMap, pub meta_data: HashMap, } + +impl AssignmentEvent { + pub fn add_sdk_metadata(&mut self, name: String, version: String) { + self.meta_data.insert("sdkName".to_owned(), name); + self.meta_data.insert("sdkVersion".to_owned(), version); + } +} + +impl BanditEvent { + pub fn add_sdk_metadata(&mut self, name: String, version: String) { + self.meta_data.insert("sdkName".to_owned(), name); + self.meta_data.insert("sdkVersion".to_owned(), version); + } +} + +#[cfg(feature = "pyo3")] +mod pyo3_impl { + use pyo3::{PyObject, PyResult, Python}; + + use crate::pyo3::TryToPyObject; + + use super::{AssignmentEvent, BanditEvent}; + + impl TryToPyObject for AssignmentEvent { + fn try_to_pyobject(&self, py: Python) -> PyResult { + serde_pyobject::to_pyobject(py, self) + .map(|it| it.unbind()) + .map_err(|err| err.0) + } + } + + impl TryToPyObject for BanditEvent { + fn try_to_pyobject(&self, py: Python) -> PyResult { + serde_pyobject::to_pyobject(py, self) + .map(|it| it.unbind()) + .map_err(|err| err.0) + } + } +} diff --git a/eppo_core/src/lib.rs b/eppo_core/src/lib.rs index 36c63b95..403ae36c 100644 --- a/eppo_core/src/lib.rs +++ b/eppo_core/src/lib.rs @@ -20,6 +20,8 @@ pub mod configuration_store; pub mod eval; pub mod events; pub mod poller_thread; +#[cfg(feature = "pyo3")] +pub mod pyo3; pub mod sharder; pub mod ufc; diff --git a/eppo_core/src/poller_thread.rs b/eppo_core/src/poller_thread.rs index 5c0b019b..3eef4ed9 100644 --- a/eppo_core/src/poller_thread.rs +++ b/eppo_core/src/poller_thread.rs @@ -28,7 +28,7 @@ pub struct PollerThreadConfig { impl PollerThreadConfig { /// Default value for [`PollerThreadConfig::interval`]. - pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); + pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(30); /// Default value for [`PollerThreadConfig::jitter`]. pub const DEFAULT_POLL_JITTER: Duration = Duration::from_secs(30); @@ -67,7 +67,9 @@ pub struct PollerThread { join_handle: std::thread::JoinHandle<()>, /// Used to send a stop command to the poller thread. - stop_sender: std::sync::mpsc::Sender<()>, + // TODO: take a look at `std::thread::park_timeout()`. It could be used to build simpler + // synchronization. + stop_sender: std::sync::mpsc::SyncSender<()>, /// Holds `None` if configuration hasn't been fetched yet. Holds `Some(Ok(()))` if configuration /// has been fetches successfully. Holds `Some(Err(...))` if there was an error fetching the @@ -110,7 +112,11 @@ impl PollerThread { store: Arc, config: PollerThreadConfig, ) -> std::io::Result { - let (stop_sender, stop_receiver) = std::sync::mpsc::channel::<()>(); + // Using `sync_channel` here as it makes `stop_sender` `Sync` (shareable between + // threads). Buffer size of 1 should be enough for our use case as we're sending a stop + // command and we can simply `try_send()` and ignore if the buffer is full (another thread + // has send a stop command already). + let (stop_sender, stop_receiver) = std::sync::mpsc::sync_channel::<()>(1); let result = Arc::new((Mutex::new(None), Condvar::new())); @@ -130,7 +136,7 @@ impl PollerThread { let result = fetcher.fetch_configuration(); match result { Ok(configuration) => { - store.set_configuration(configuration); + store.set_configuration(Arc::new(configuration)); update_result(Ok(())) } Err(err @ (Error::Unauthorized | Error::InvalidBaseUrl(_))) => { @@ -227,9 +233,11 @@ impl PollerThread { /// /// This function does not wait for the thread to actually stop. pub fn stop(&self) { - // Error means that the receiver was dropped (thread exited). Ignoring it as there's nothing - // useful we can do—thread is already stopped. - let _ = self.stop_sender.send(()); + // Error means that the receiver was dropped (thread exited) or the channel buffer is + // full. First case can be ignored it as there's nothing useful we can do—thread is already + // stopped. Second case can be ignored as it indicates that another thread already sent a + // stop command and the thread will stop anyway. + let _ = self.stop_sender.try_send(()); } /// Stop the poller thread and block waiting for it to exit. diff --git a/eppo_core/src/pyo3.rs b/eppo_core/src/pyo3.rs new file mode 100644 index 00000000..7e07e586 --- /dev/null +++ b/eppo_core/src/pyo3.rs @@ -0,0 +1,31 @@ +//! Helpers for Python SDK implementation. +use pyo3::prelude::*; + +/// Similar to [`pyo3::ToPyObject`] but allows the conversion to fail. +pub trait TryToPyObject { + fn try_to_pyobject(&self, py: Python) -> PyResult; +} + +// Implementing on `&T` to allow dtolnay specialization[1] (e.g., for `Option` below). +// +// [1]: https://github.com/dtolnay/case-studies/blob/master/autoref-specialization/README.md +impl TryToPyObject for &T { + fn try_to_pyobject(&self, py: Python) -> PyResult { + Ok(self.to_object(py)) + } +} + +impl TryToPyObject for Py { + fn try_to_pyobject(&self, py: Python) -> PyResult { + Ok(self.to_object(py)) + } +} + +impl TryToPyObject for Option { + fn try_to_pyobject(&self, py: Python) -> PyResult { + match self { + Some(it) => it.try_to_pyobject(py), + None => Ok(().to_object(py)), + } + } +} diff --git a/eppo_core/src/ufc/assignment.rs b/eppo_core/src/ufc/assignment.rs index 611d225c..0cca9d95 100644 --- a/eppo_core/src/ufc/assignment.rs +++ b/eppo_core/src/ufc/assignment.rs @@ -230,3 +230,28 @@ impl AssignmentValue { } } } + +#[cfg(feature = "pyo3")] +mod pyo3_impl { + use pyo3::prelude::*; + + use crate::pyo3::TryToPyObject; + + use super::*; + + impl TryToPyObject for AssignmentValue { + fn try_to_pyobject(&self, py: Python) -> PyResult { + let obj = match self { + AssignmentValue::String(s) => s.to_object(py), + AssignmentValue::Integer(i) => i.to_object(py), + AssignmentValue::Numeric(n) => n.to_object(py), + AssignmentValue::Boolean(b) => b.to_object(py), + AssignmentValue::Json(j) => match serde_pyobject::to_pyobject(py, j) { + Ok(it) => it.unbind(), + Err(err) => return Err(err.0), + }, + }; + Ok(obj) + } + } +} diff --git a/mock-server/README.md b/mock-server/README.md index 29ef9b12..6ab62d25 100644 --- a/mock-server/README.md +++ b/mock-server/README.md @@ -2,4 +2,17 @@ This is a simple mock server for use in client tests. It just serves a bunch of static files from `sdk-test-data` at the appropriate locations. -See `prepare.sh` for the list of files served. +The mock server may serve multiple "environments" at the same time at different prefixes, so the base URL format is `http://localhost:8378/{env_name}/api`. + +We currently have three: +- `ufc` is the default `flags-v1.json`. +- `obfuscated` is the obfuscated version of `ufc`. +- `bandits` is bandit flags and bandit models. + +See `prepare.js` for the full list of files and environments served. + +# Q&A + +## Why port 8378? + +8378 is "test" in T9. diff --git a/mock-server/package.json b/mock-server/package.json index 21332d2e..3aa3c894 100644 --- a/mock-server/package.json +++ b/mock-server/package.json @@ -1,7 +1,8 @@ { "private": true, + "type": "module", "scripts": { - "prestart": "./prepare.sh", + "prestart": "node ./prepare.js", "start": "http-server -p 8378 -a 127.0.0.1 --cors" }, "dependencies": { diff --git a/mock-server/prepare.js b/mock-server/prepare.js new file mode 100644 index 00000000..0fbe0095 --- /dev/null +++ b/mock-server/prepare.js @@ -0,0 +1,48 @@ +// This is a prepare script that is automatically run before "start" command. +// +// It is repsonsible to copy files from sdk-test-data into the proper +import fs from 'node:fs'; +import path from 'node:path'; +import { exit } from 'node:process'; +import { fileURLToPath } from 'node:url'; + +// The format here is: +// env name -> { api path -> file relative to sdk-test-data } +const envs = { + ufc: { + 'api/flag-config/v1/config': 'ufc/flags-v1.json', + }, + obfuscated: { + 'api/flag-config/v1/config': 'ufc/flags-v1-obfuscated.json', + }, + bandit: { + 'api/flag-config/v1/config': 'ufc/bandit-flags-v1.json', + 'api/flag-config/v1/bandits': 'ufc/bandit-models-v1.json', + } +} + + +function main() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const sdkTestDataPath = path.join(__dirname, '../sdk-test-data/') + const publicPath = path.join(__dirname, './public/') + + try { + fs.rmdirSync(path.join(__dirname, 'public'), {recursive: true}); + } catch { + // ignore if it's not present + } + + for (const [env, links] of Object.entries(envs)) { + for (const [target, source] of Object.entries(links)) { + const sourcePath = path.join(sdkTestDataPath, source); + const targetPath = path.join(publicPath, env, target); + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + console.log(sourcePath, '->', targetPath); + fs.copyFileSync(sourcePath, targetPath); + } + } +} + +main() diff --git a/mock-server/prepare.sh b/mock-server/prepare.sh deleted file mode 100755 index 19cfa38b..00000000 --- a/mock-server/prepare.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -e - -cd "$(dirname "$0")" -rm -rf ./public -mkdir -p ./public/{ufc,obfuscated,bandit}/api/flag-config/v1/ -ln -s ../../../../../../sdk-test-data/ufc/flags-v1.json ./public/ufc/api/flag-config/v1/config -ln -s ../../../../../../sdk-test-data/ufc/flags-v1-obfuscated.json ./public/obfuscated/api/flag-config/v1/config -ln -s ../../../../../../sdk-test-data/ufc/bandit-flags-v1.json ./public/bandit/api/flag-config/v1/config -ln -s ../../../../../../sdk-test-data/ufc/bandit-models-v1.json ./public/bandit/api/flag-config/v1/bandits diff --git a/package.json b/package.json index 8cb131b2..3b88747b 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "scripts": { "test": "npm run with-server 'npm-run-all test:*'", "test:rust": "cargo test", + "test:python": "cd python-sdk && pytest", "test:ruby": "cd ruby-sdk && bundle exec rake test", "with-server": "start-server-and-test start-mock-server http://127.0.0.1:8378", "start-mock-server": "npm start --prefix ./mock-server" diff --git a/python-sdk/.gitignore b/python-sdk/.gitignore new file mode 100644 index 00000000..c8f04429 --- /dev/null +++ b/python-sdk/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/python-sdk/Cargo.toml b/python-sdk/Cargo.toml new file mode 100644 index 00000000..da1a0284 --- /dev/null +++ b/python-sdk/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "eppo_py" +version = "4.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +eppo_core = { version = "3.0.0", path = "../eppo_core", features = ["pyo3"] } +log = "0.4.22" +pyo3 = { version = "0.22.0" } +pyo3-log = "0.11.0" +serde-pyobject = "0.4.0" +serde_json = "1.0.125" + +[target.'cfg(target_os = "linux")'.dependencies] +# We don't use reqwest directly, so the following overrides it to +# enable feature flag. native-tls-vendored is required to vendor +# OpenSSL on linux builds, so we don't depend on shared libraries. +# +# See: https://github.com/PyO3/maturin-action/discussions/78 +reqwest = { version = "*", features = ["native-tls-vendored"] } + +[target.'cfg(all(target_os = "linux", target_arch = "s390x"))'.dependencies] +# OpenSSL 3.3+ includes a patch[1] for s390x architecture that uses a +# specialized instruction (cijne), which is not recognized by GCC in +# ghcr.io/rust-cross/manylinux2014-cross:s390x, and thus fails to +# compile in CI. +# +# Pin "openssl-src" to 300.2.x, which will install openssl-3.2.x, +# which didn't use cijne. +# +# [1]: https://github.com/openssl/openssl/pull/22221 +openssl-src = "~300.2" diff --git a/python-sdk/LICENSE b/python-sdk/LICENSE new file mode 100644 index 00000000..0eaceb43 --- /dev/null +++ b/python-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Eppo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python-sdk/README.md b/python-sdk/README.md new file mode 100644 index 00000000..13471762 --- /dev/null +++ b/python-sdk/README.md @@ -0,0 +1,136 @@ +# Eppo Python SDK + +[![Test and lint SDK](https://github.com/Eppo-exp/rust-sdk/actions/workflows/python.yml/badge.svg)](https://github.com/Eppo-exp/rust-sdk/actions/workflows/python.yml) + +[Eppo](https://www.geteppo.com/) is a modular flagging and experimentation analysis tool. Eppo's Python SDK is built to make assignments in multi-user server side contexts. Before proceeding you'll need an Eppo account. + +## Features + +- Feature gates +- Kill switches +- Progressive rollouts +- A/B/n experiments +- Mutually exclusive experiments (Layers) +- Holdouts +- Contextual multi-armed bandits +- Dynamic configuration + +## Installation + +```shell +pip install eppo-server-sdk +``` + +## Quick start + +Begin by initializing a singleton instance of Eppo's client. Once initialized, the client can be used to make assignments anywhere in your app. + +#### Initialize once + +```python +import eppo_client +from eppo_client import Config, AssignmentLogger + +client_config = Config( + api_key="", assignment_logger=AssignmentLogger() +) +eppo_client.init(client_config) +``` + + +#### Assign anywhere + +```python +import eppo_client + +client = eppo_client.get_instance() +user = get_current_user() + +variation = client.get_boolean_assignment( + 'show-new-feature', + user.id, + { 'country': user.country }, + False +) +``` + +## Assignment functions + +Every Eppo flag has a return type that is set once on creation in the dashboard. Once a flag is created, assignments in code should be made using the corresponding typed function: + +```python +get_boolean_assignment(...) +get_numeric_assignment(...) +get_integer_assignment(...) +get_string_assignment(...) +get_json_assignment(...) +``` + +Each function has the same signature, but returns the type in the function name. For booleans use `get_boolean_assignment`, which has the following signature: + +```python +get_boolean_assignment( + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default_value: bool +) -> bool: + ``` + +## Initialization options + +The `init` function accepts the following optional configuration arguments. + +| Option | Type | Description | Default | +| ------ | ----- | ----- | ----- | +| **`assignment_logger`** | AssignmentLogger | A callback that sends each assignment to your data warehouse. Required only for experiment analysis. See [example](#assignment-logger) below. | `None` | +| **`is_graceful_mode`** | bool | When true, gracefully handles all exceptions within the assignment function and returns the default value. | `True` | +| **`poll_interval_seconds`** | int | The interval in seconds at which the SDK polls for configuration updates. | `30` | +| **`poll_jitter_seconds`** | int | The jitter in seconds to add to the poll interval. | `30` | + +## Assignment logger + +To use the Eppo SDK for experiments that require analysis, pass in a callback logging function to the `init` function on SDK initialization. The SDK invokes the callback to capture assignment data whenever a variation is assigned. The assignment data is needed in the warehouse to perform analysis. + +The code below illustrates an example implementation of a logging callback using [Segment](https://segment.com/), but you can use any system you'd like. The only requirement is that the SDK receives a `log_assignment` callback function. Here we define an implementation of the Eppo `SegmentAssignmentLogger` interface containing a single function named `log_assignment`: + +```python +from eppo_client import AssignmentLogger, Config +import analytics + +# Connect to Segment. +analytics.write_key = "" + +class SegmentAssignmentLogger(AssignmentLogger): + def log_assignment(self, assignment): + analytics.track(assignment["subject"], "Eppo Randomization Assignment", assignment) + +client_config = Config(api_key="", assignment_logger=SegmentAssignmentLogger()) +``` + +## Export configuration + +To support the use-case of needing to bootstrap a front-end client, the Eppo SDK provides a function to export flag configurations to a JSON string. + +Use the `Configuration.get_flags_configuration` function to export flag configurations to a JSON string and then send it to the front-end client. + +```python +from fastapi import JSONResponse + +import eppo_client +import json + +client = eppo_client.get_instance() +flags_configuration = client.get_configuration().get_flags_configuration() + +# Create a JSONResponse object with the stringified JSON +response = JSONResponse(content={"flagsConfiguration": flags_configuration}) +``` + +## Philosophy + +Eppo's SDKs are built for simplicity, speed and reliability. Flag configurations are compressed and distributed over a global CDN (Fastly), typically reaching your servers in under 15ms. Server SDKs continue polling Eppo’s API at 30-second intervals. Configurations are then cached locally, ensuring that each assignment is made instantly. Evaluation logic within each SDK consists of a few lines of simple numeric and string comparisons. The typed functions listed above are all developers need to understand, abstracting away the complexity of the Eppo's underlying (and expanding) feature set. + +## Contributing + +To publish a new version of the SDK, set the version as desired in `eppo_client/version.py`, then create a new Github release. The CI/CD configuration will handle the build and publish to PyPi. diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml new file mode 100644 index 00000000..2014087b --- /dev/null +++ b/python-sdk/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "eppo-server-sdk" +description = "Eppo SDK for Python" +readme = "README.md" +authors = [{ name = "Eppo", email = "eppo-team@geteppo.com" }] +license = { file = "LICENSE" } +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "License :: OSI Approved :: MIT License", +] +dynamic = ["version"] +[project.urls] +"Bug Tracker" = "https://github.com/Eppo-exp/rust-sdk/issues" + +[project.optional-dependencies] +test = [ + "pytest", + "cachetools", + "types-cachetools" +] + +[tool.maturin] +features = ["pyo3/extension-module"] +python-source = "python" +module-name = "eppo_client._eppo_client" diff --git a/python-sdk/pytest.ini b/python-sdk/pytest.ini new file mode 100644 index 00000000..f0aaa372 --- /dev/null +++ b/python-sdk/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + rust_only: mark a test as only passing on Python-on-Rust SDK. diff --git a/python-sdk/python/eppo_client/__init__.py b/python-sdk/python/eppo_client/__init__.py new file mode 100644 index 00000000..141da78a --- /dev/null +++ b/python-sdk/python/eppo_client/__init__.py @@ -0,0 +1,29 @@ +from typing import Any, Dict, Set, Union, Type + +# Rust currently does not define submodules as packages, so Rust +# submodules are not importable from Python.[1] There is a hacky way +# to make submodules re-exportable (by tweaking sys.modules) but it +# has some drawbacks. +# +# It's more straightforward to add a bit of Python to re-export items +# at different locations. +# +# This __init__.py just re-exports everything from the Rust module.[2] +# +# [1]: https://github.com/PyO3/pyo3/issues/759 +# [2]: https://www.maturin.rs/project_layout#pure-rust-project +import eppo_client._eppo_client as _eppo_client +from eppo_client._eppo_client import * +from eppo_client._eppo_client import __version__ + +# re-exports +from eppo_client.assignment_logger import AssignmentCacheLogger +from eppo_client.bandit import BanditResult + +Attribute = Union[str, int, float, bool, None] +Attributes = Dict[str, Attribute] + +__doc__ = _eppo_client.__doc__ +__all__ = ["AssignmentCacheLogger", "BanditResult", "Attribute", "Attributes"] +if hasattr(_eppo_client, "__all__"): + __all__.extend(_eppo_client.__all__) diff --git a/python-sdk/python/eppo_client/_eppo_client.pyi b/python-sdk/python/eppo_client/_eppo_client.pyi new file mode 100644 index 00000000..e19aa9d6 --- /dev/null +++ b/python-sdk/python/eppo_client/_eppo_client.pyi @@ -0,0 +1,171 @@ +from typing import Dict, Any, Set, Union + +__version__: str + +def init(config: ClientConfig) -> EppoClient: ... +def get_instance() -> EppoClient: ... + +class Configuration: + def __init__(self, flags_configuration: bytes) -> None: ... + def get_flags_configuration(self) -> bytes: ... + def get_flag_keys(self) -> Set[str]: ... + def get_bandit_keys(self) -> Set[str]: ... + +class ClientConfig: + api_key: str + base_url: str + assignment_logger: AssignmentLogger + is_graceful_mode: bool + poll_interval_seconds: int | None + poll_jitter_seconds: int + initial_configuration: Configuration | None + + def __init__( + self, + *, + api_key: str, + base_url: str = ..., + assignment_logger: AssignmentLogger, + is_graceful_mode: bool = True, + poll_interval_seconds: int | None = ..., + poll_jitter_seconds: int = ..., + initial_configuration: Configuration | None = None + ): ... + +class AssignmentLogger: + def log_assignment(self, event: Dict) -> None: ... + def log_bandit_action(self, event: Dict) -> None: ... + +class EppoClient: + def get_string_assignment( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: str, + ) -> str: ... + def get_integer_assignment( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: int, + ) -> int: ... + def get_numeric_assignment( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: float, + ) -> float: ... + def get_boolean_assignment( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: bool, + ) -> bool: ... + def get_json_assignment( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: Any, + ) -> Any: ... + def get_string_assignment_details( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: str, + ) -> EvaluationResult: ... + def get_integer_assignment_details( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: int, + ) -> EvaluationResult: ... + def get_numeric_assignment_details( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: float, + ) -> EvaluationResult: ... + def get_boolean_assignment_details( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: bool, + ) -> EvaluationResult: ... + def get_json_assignment_details( + self, + flag_key: str, + subject_key: str, + subject_attributes: Dict[str, Union[str, int, float, bool, None]], + default: Any, + ) -> EvaluationResult: ... + def get_bandit_action( + self, + flag_key: str, + subject_key: str, + subject_context: ( + ContextAttributes | Dict[str, Union[str, int, float, bool, None]] + ), + actions: ( + Dict[str, ContextAttributes] + | Dict[str, Dict[str, Union[str, int, float, bool, None]]] + ), + default: str, + ) -> EvaluationResult: ... + def get_bandit_action_details( + self, + flag_key: str, + subject_key: str, + subject_context: ( + ContextAttributes | Dict[str, Union[str, int, float, bool, None]] + ), + actions: ( + Dict[str, ContextAttributes] + | Dict[str, Dict[str, Union[str, int, float, bool, None]]] + ), + default: str, + ) -> EvaluationResult: ... + def get_configuration(self) -> Configuration | None: ... + def set_configuration(self, configuration: Configuration): ... + def get_flag_keys(self) -> Set[str]: ... + def get_bandit_keys(self) -> Set[str]: ... + def set_is_graceful_mode(self, is_graceful_mode: bool): ... + def is_initialized(self) -> bool: ... + def wait_for_initialization(self) -> None: ... + +class ContextAttributes: + def __new__( + cls, + numeric_attributes: Dict[str, float], + categorical_attributes: Dict[str, str], + ): ... + @staticmethod + def empty() -> ContextAttributes: ... + @staticmethod + def from_dict( + attributes: Dict[str, Union[str, int, float, bool, None]] + ) -> ContextAttributes: ... + @property + def numeric_attributes(self) -> Dict[str, float]: ... + @property + def categorical_attributes(self) -> Dict[str, str]: ... + +class EvaluationResult: + variation: Any + action: str | None + evaluation_details: Any | None + def __new__( + cls, + variation: Any, + action: str | None = None, + evaluation_details: Any | None = None, + ): ... + def to_string(self) -> str: ... diff --git a/python-sdk/python/eppo_client/assignment_logger.py b/python-sdk/python/eppo_client/assignment_logger.py new file mode 100644 index 00000000..64c860ee --- /dev/null +++ b/python-sdk/python/eppo_client/assignment_logger.py @@ -0,0 +1,54 @@ +from typing import Dict, Optional, Tuple, MutableMapping + +from eppo_client import AssignmentLogger + + +class AssignmentCacheLogger(AssignmentLogger): + def __init__( + self, + inner: AssignmentLogger, + *, + assignment_cache: Optional[MutableMapping] = None, + bandit_cache: Optional[MutableMapping] = None, + ): + super().__init__() + self.__inner = inner + self.__assignment_cache = assignment_cache + self.__bandit_cache = bandit_cache + + def log_assignment(self, event: Dict): + _cache_or_call( + self.__assignment_cache, + *AssignmentCacheLogger.__assignment_cache_keyvalue(event), + lambda: self.__inner.log_assignment(event), + ) + + def log_bandit_action(self, event: Dict): + _cache_or_call( + self.__bandit_cache, + *AssignmentCacheLogger.__bandit_cache_keyvalue(event), + lambda: self.__inner.log_bandit_action(event), + ) + + @staticmethod + def __assignment_cache_keyvalue(event: Dict) -> Tuple[Tuple, Tuple]: + key = (event["featureFlag"], event["subject"]) + value = (event["allocation"], event["variation"]) + return key, value + + @staticmethod + def __bandit_cache_keyvalue(event: Dict) -> Tuple[Tuple, Tuple]: + key = (event["flagKey"], event["subject"]) + value = (event["banditKey"], event["action"]) + return key, value + + +def _cache_or_call(cache: Optional[MutableMapping], key, value, fn): + if cache is not None and (previous := cache.get(key)) and previous == value: + # ok, cached + return + + fn() + + if cache is not None: + cache[key] = value diff --git a/python-sdk/python/eppo_client/bandit.py b/python-sdk/python/eppo_client/bandit.py new file mode 100644 index 00000000..c9111719 --- /dev/null +++ b/python-sdk/python/eppo_client/bandit.py @@ -0,0 +1,3 @@ +from eppo_client import ContextAttributes, EvaluationResult + +BanditResult = EvaluationResult diff --git a/python-sdk/python/eppo_client/config.py b/python-sdk/python/eppo_client/config.py new file mode 100644 index 00000000..c1e2d5a2 --- /dev/null +++ b/python-sdk/python/eppo_client/config.py @@ -0,0 +1 @@ +from eppo_client import ClientConfig as Config, AssignmentLogger diff --git a/python-sdk/python/eppo_client/py.typed b/python-sdk/python/eppo_client/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/python-sdk/python/eppo_client/version.py b/python-sdk/python/eppo_client/version.py new file mode 100644 index 00000000..b9e2d2bc --- /dev/null +++ b/python-sdk/python/eppo_client/version.py @@ -0,0 +1 @@ +from eppo_client import __version__ diff --git a/python-sdk/src/assignment_logger.rs b/python-sdk/src/assignment_logger.rs new file mode 100644 index 00000000..6187b809 --- /dev/null +++ b/python-sdk/src/assignment_logger.rs @@ -0,0 +1,22 @@ +use pyo3::prelude::*; +use pyo3::types::PyDict; + +#[derive(Debug, Clone)] +#[pyclass(frozen, subclass, module = "eppo_client")] +pub struct AssignmentLogger {} + +#[pymethods] +impl AssignmentLogger { + #[new] + #[pyo3(signature = (*args, **kwargs))] + #[allow(unused_variables)] + fn new(args: &Bound<'_, PyAny>, kwargs: Option<&Bound<'_, PyAny>>) -> AssignmentLogger { + AssignmentLogger {} + } + + #[allow(unused_variables)] + fn log_assignment(slf: Bound, event: Bound) {} + + #[allow(unused_variables)] + fn log_bandit_action(slf: Bound, event: Bound) {} +} diff --git a/python-sdk/src/client.rs b/python-sdk/src/client.rs new file mode 100644 index 00000000..d95bf3dd --- /dev/null +++ b/python-sdk/src/client.rs @@ -0,0 +1,709 @@ +use std::{ + collections::HashMap, + ops::Deref, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; + +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError}, + intern, + prelude::*, + types::{PyBool, PyFloat, PyInt, PySet, PyString}, + PyTraverseError, PyVisit, +}; + +use eppo_core::{ + configuration_fetcher::ConfigurationFetcher, + configuration_store::ConfigurationStore, + eval::{ + eval_details::{EvaluationDetails, EvaluationResultWithDetails}, + get_assignment, get_assignment_details, get_bandit_action, get_bandit_action_details, + BanditResult, + }, + events::{AssignmentEvent, BanditEvent}, + poller_thread::{PollerThread, PollerThreadConfig}, + pyo3::TryToPyObject, + ufc::VariationType, + Attributes, ContextAttributes, +}; + +use crate::{ + assignment_logger::AssignmentLogger, client_config::ClientConfig, configuration::Configuration, +}; + +#[pyclass(frozen, get_all, module = "eppo_client")] +pub struct EvaluationResult { + variation: Py, + action: Option>, + /// Optional evaluation details. + evaluation_details: Option>, +} +#[pymethods] +impl EvaluationResult { + #[new] + #[pyo3(signature = (variation, action=None, evaluation_details=None))] + fn new( + variation: Py, + action: Option>, + evaluation_details: Option>, + ) -> EvaluationResult { + EvaluationResult { + variation, + action, + evaluation_details, + } + } + + fn to_string(&self, py: Python) -> PyResult> { + // use pyo3::types::PyAnyMethods; + let s = if let Some(action) = &self.action { + action.clone_ref(py) + } else { + self.variation.bind(py).str()?.unbind() + }; + Ok(s) + } + + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + use pyo3::types::PyList; + + let pieces = PyList::new_bound( + py, + [ + intern!(py, "EvaluationResult(variation=").clone(), + self.variation.bind(py).repr()?, + intern!(py, ", action=").clone(), + self.action.to_object(py).into_bound(py).repr()?, + intern!(py, ", evaluation_details=").clone(), + self.evaluation_details + .to_object(py) + .into_bound(py) + .repr()?, + intern!(py, ")").clone(), + ], + ); + intern!(py, "").call_method1(intern!(py, "join"), (pieces,)) + } +} +impl EvaluationResult { + fn from_details( + py: Python, + result: EvaluationResultWithDetails, + default: Py, + ) -> PyResult { + let EvaluationResultWithDetails { + variation, + action, + evaluation_details, + } = result; + + let variation = if let Some(variation) = variation { + variation.try_to_pyobject(py)? + } else { + default + }; + + Ok(EvaluationResult { + variation, + action: action.map(|it| PyString::new_bound(py, &it).unbind()), + evaluation_details: Some(evaluation_details.try_to_pyobject(py)?), + }) + } + + fn from_bandit_result( + py: Python, + result: BanditResult, + details: Option, + ) -> PyResult { + let variation = result.variation.into_py(py); + let action = result + .action + .map(|it| PyString::new_bound(py, &it).unbind()); + + let evaluation_details = if let Some(details) = details { + Some(details.try_to_pyobject(py)?) + } else { + None + }; + + Ok(EvaluationResult { + variation, + action, + evaluation_details, + }) + } +} + +#[pyclass(frozen, module = "eppo_client")] +pub struct EppoClient { + configuration_store: Arc, + poller_thread: Option, + assignment_logger: Py, + is_graceful_mode: AtomicBool, +} + +#[pymethods] +impl EppoClient { + fn get_string_assignment( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::String), + default.into_any(), + ) + } + fn get_integer_assignment( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Integer), + default.into_any(), + ) + } + fn get_numeric_assignment( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Numeric), + default.into_any(), + ) + } + fn get_boolean_assignment( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Boolean), + default.into_any(), + ) + } + fn get_json_assignment( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: PyObject, + ) -> PyResult { + slf.get().get_assignment( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Json), + default.into_any(), + ) + } + + fn get_string_assignment_details( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment_details( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::String), + default.into_any(), + ) + } + fn get_integer_assignment_details( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment_details( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Integer), + default.into_any(), + ) + } + fn get_numeric_assignment_details( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment_details( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Numeric), + default.into_any(), + ) + } + fn get_boolean_assignment_details( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment_details( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Boolean), + default.into_any(), + ) + } + fn get_json_assignment_details( + slf: &Bound, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + default: Py, + ) -> PyResult { + slf.get().get_assignment_details( + slf.py(), + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Json), + default.into_any(), + ) + } + + /// Determines the bandit action for a given subject based on the provided bandit key and subject attributes. + /// + /// This method performs the following steps: + /// 1. Retrieves the experiment assignment for the given bandit key and subject. + /// 2. Checks if the assignment matches the bandit key. If not, it means the subject is not allocated in the bandit, + /// and the method returns a EvaluationResult with the assignment. + /// 3. Evaluates the bandit action using the bandit evaluator. + /// 4. Logs the bandit action event. + /// 5. Returns the EvaluationResult containing the selected action key and the assignment. + /// + /// Args: + /// flag_key (str): The feature flag key that contains the bandit as one of the variations. + /// subject_key (str): The key identifying the subject. + /// subject_context (Union[ContextAttributes, Attributes]): The subject context. + /// If supplying an ActionAttributes, it gets converted to an ActionContexts instance + /// actions (Union[ActionContexts, ActionAttributes]): The dictionary that maps action keys + /// to their context of actions with their contexts. + /// If supplying an ActionAttributes, it gets converted to an ActionContexts instance. + /// default (str): The default variation to use if an error is encountered retrieving the + /// assigned variation. + /// + /// Returns: + /// EvaluationResult: The result containing either the bandit action if the subject is part of the bandit, + /// or the assignment if they are not. The EvaluationResult includes: + /// - variation (str): The assignment key indicating the subject's variation. + /// - action (Optional[str]): The key of the selected action if the subject was assigned one + /// by the bandit. + /// + /// Example: + /// result = client.get_bandit_action( + /// "flag_key", + /// "subject_key", + /// ContextAttributes( + /// numeric_attributes={"age": 25}, + /// categorical_attributes={"country": "USA"}), + /// { + /// "action1": ContextAttributes( + /// numeric_attributes={"price": 10.0}, + /// categorical_attributes={"category": "A"} + /// ), + /// "action2": {"price": 10.0, "category": "B"} + /// "action3": ContextAttributes.empty(), + /// }, + /// "default" + /// ) + /// if result.action: + /// do_action(result.variation) + /// else: + /// do_status_quo() + fn get_bandit_action( + slf: &Bound, + flag_key: &str, + subject_key: &str, + #[pyo3(from_py_with = "context_attributes_from_py")] subject_context: RefOrOwned< + ContextAttributes, + PyRef, + >, + #[pyo3(from_py_with = "actions_from_py")] actions: HashMap, + default: &str, + ) -> PyResult { + let py = slf.py(); + let this = slf.get(); + let configuration = this.configuration_store.get_configuration(); + + let mut result = get_bandit_action( + configuration.as_ref().map(|it| it.as_ref()), + flag_key, + subject_key, + &subject_context, + &actions, + default, + ); + + if let Some(event) = result.assignment_event.take() { + let _ = this.log_assignment_event(py, event); + } + if let Some(event) = result.bandit_event.take() { + let _ = this.log_bandit_event(py, event); + } + + EvaluationResult::from_bandit_result(py, result, None) + } + + /// Same as get_bandit_action() but returns EvaluationResult with evaluation_details. + fn get_bandit_action_details( + slf: &Bound, + flag_key: &str, + subject_key: &str, + #[pyo3(from_py_with = "context_attributes_from_py")] subject_context: RefOrOwned< + ContextAttributes, + PyRef, + >, + #[pyo3(from_py_with = "actions_from_py")] actions: HashMap, + default: &str, + ) -> PyResult { + let py = slf.py(); + let this = slf.get(); + let configuration = this.configuration_store.get_configuration(); + + let (mut result, details) = get_bandit_action_details( + configuration.as_ref().map(|it| it.as_ref()), + flag_key, + subject_key, + &subject_context, + &actions, + default, + ); + + if let Some(event) = result.assignment_event.take() { + let _ = this.log_assignment_event(py, event); + } + if let Some(event) = result.bandit_event.take() { + let _ = this.log_bandit_event(py, event); + } + + EvaluationResult::from_bandit_result(py, result, Some(details)) + } + + fn get_configuration(&self) -> Option { + self.configuration_store + .get_configuration() + .map(Configuration::new) + } + + fn set_configuration(&self, configuration: &Configuration) { + self.configuration_store + .set_configuration(Arc::clone(&configuration.configuration)); + } + + fn set_is_graceful_mode(&self, is_graceful_mode: bool) { + self.is_graceful_mode + .store(is_graceful_mode, Ordering::Release); + } + + // Returns True if the client has successfully initialized the flag configuration and is ready + // to serve requests. + fn is_initialized(&self) -> bool { + let config = self.configuration_store.get_configuration(); + config.is_some() + } + + /// Wait for configuration to get fetches. + /// + /// This method releases GIL, so other Python thread can make progress. + fn wait_for_initialization(&self, py: Python) -> PyResult<()> { + if let Some(poller) = &self.poller_thread { + py.allow_threads(|| poller.wait_for_configuration()) + .map_err(|err| PyRuntimeError::new_err(err.to_string())) + } else { + Err(PyRuntimeError::new_err("poller is disabled")) + } + } + + /// Returns a set of all flag keys that have been initialized. + /// This can be useful to debug the initialization process. + /// + /// Deprecated. Use EppoClient.get_configuration() instead. + fn get_flag_keys<'py>(&'py self, py: Python<'py>) -> PyResult> { + let config = self.configuration_store.get_configuration(); + match config { + Some(config) => PySet::new_bound(py, config.flags.flags.keys()), + None => PySet::empty_bound(py), + } + } + + /// Returns a set of all bandit keys that have been initialized. + /// This can be useful to debug the initialization process. + /// + /// Deprecated. Use EppoClient.get_configuration() instead. + fn get_bandit_keys<'py>(&'py self, py: Python<'py>) -> PyResult> { + let config = self.configuration_store.get_configuration(); + match config { + Some(config) => { + PySet::new_bound(py, config.bandits.iter().flat_map(|it| it.bandits.keys())) + } + None => PySet::empty_bound(py), + } + } + + // Implementing [Garbage Collector integration][1] in case user's `AssignmentLogger` holds a + // reference to `EppoClient`. This will allow the GC to detect this cycle and break it. + // + // [1]: https://pyo3.rs/v0.22.2/class/protocols.html#garbage-collector-integration + fn __traverse__(&self, visit: PyVisit) -> Result<(), PyTraverseError> { + visit.call(&self.assignment_logger) + } + fn __clear__(&self) { + // We're frozen and don't hold mutable Python references, so there's nothing to clear. + } +} + +#[derive(Debug, Clone, Copy)] +enum RefOrOwned +where + Ref: Deref, +{ + Ref(Ref), + Owned(T), +} +impl Deref for RefOrOwned +where + Ref: Deref, +{ + type Target = T; + + fn deref(&self) -> &Self::Target { + match self { + RefOrOwned::Ref(r) => r, + RefOrOwned::Owned(owned) => owned, + } + } +} + +fn context_attributes_from_py<'py>( + obj: &'py Bound<'py, PyAny>, +) -> PyResult>> { + if let Ok(attrs) = obj.downcast::() { + return Ok(RefOrOwned::Ref(attrs.borrow())); + } + if let Ok(attrs) = Attributes::extract_bound(obj) { + return Ok(RefOrOwned::Owned(attrs.into())); + } + Err(PyTypeError::new_err(format!( + "attributes must be either ContextAttributes or Attributes" + ))) +} + +fn actions_from_py(obj: &Bound) -> PyResult> { + if let Ok(result) = FromPyObject::extract_bound(&obj) { + return Ok(result); + } + + if let Ok(result) = HashMap::::extract_bound(&obj) { + let result = result + .into_iter() + .map(|(name, attrs)| (name, ContextAttributes::from(attrs))) + .collect(); + return Ok(result); + } + + Err(PyTypeError::new_err(format!( + "action attributes must be either ContextAttributes or Attributes" + ))) +} + +// Rust-only methods +impl EppoClient { + pub fn new(py: Python, config: &ClientConfig) -> PyResult { + let configuration_store = Arc::new(ConfigurationStore::new()); + if let Some(configuration) = &config.initial_configuration { + let configuration = Arc::clone(&configuration.get().configuration); + configuration_store.set_configuration(configuration); + } + + let poller_thread = config + .poll_interval_seconds + .map(|poll_interval_seconds| { + PollerThread::start_with_config( + ConfigurationFetcher::new( + eppo_core::configuration_fetcher::ConfigurationFetcherConfig { + base_url: config.base_url.clone(), + api_key: config.api_key.clone(), + sdk_name: "python".to_owned(), + sdk_version: env!("CARGO_PKG_VERSION").to_owned(), + }, + ), + configuration_store.clone(), + PollerThreadConfig { + interval: Duration::from_secs(poll_interval_seconds.into()), + jitter: Duration::from_secs(config.poll_jitter_seconds), + }, + ) + }) + .transpose() + .map_err(|err| { + // This should normally never happen. + PyRuntimeError::new_err(format!("unable to start poller thread: {err}")) + })?; + + Ok(EppoClient { + configuration_store, + poller_thread, + assignment_logger: config + .assignment_logger + .as_ref() + .ok_or_else(|| { + // This should never happen as assigment_logger setter requires a valid logger. + PyRuntimeError::new_err(format!("Config.assignment_logger is None")) + })? + .clone_ref(py), + is_graceful_mode: AtomicBool::new(config.is_graceful_mode), + }) + } + + fn get_assignment( + &self, + py: Python, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + expected_type: Option, + default: Py, + ) -> PyResult { + let config = self.configuration_store.get_configuration(); + + let result = get_assignment( + config.as_ref().map(AsRef::as_ref), + &flag_key, + &subject_key, + &subject_attributes, + expected_type, + ); + + let assignment = match result { + Ok(assignment) => assignment, + Err(err) => { + if self.is_graceful_mode.load(Ordering::Acquire) { + None + } else { + return Err(PyErr::new::(err.to_string())); + } + } + }; + + if let Some(assignment) = assignment { + if let Some(event) = assignment.event { + if let Err(err) = self.log_assignment_event(py, event) { + log::warn!(target: "eppo", "error logging assignment event: {err}") + } + } + + Ok(assignment.value.try_to_pyobject(py)?) + } else { + Ok(default) + } + } + + fn get_assignment_details( + &self, + py: Python, + flag_key: &str, + subject_key: &str, + subject_attributes: Attributes, + expected_type: Option, + default: Py, + ) -> PyResult { + let config = self.configuration_store.get_configuration(); + + let (result, event) = get_assignment_details( + config.as_ref().map(AsRef::as_ref), + &flag_key, + &subject_key, + &subject_attributes, + expected_type, + ); + + if let Some(event) = event { + if let Err(err) = self.log_assignment_event(py, event) { + log::warn!(target: "eppo", "error logging assignment event: {err}") + } + } + + EvaluationResult::from_details(py, result, default) + } + + /// Try to log assignment event using `self.assignment_logger`. + pub fn log_assignment_event(&self, py: Python, mut event: AssignmentEvent) -> PyResult<()> { + event.add_sdk_metadata("python".to_owned(), env!("CARGO_PKG_VERSION").to_owned()); + let event = event.try_to_pyobject(py)?; + self.assignment_logger + .call_method1(py, intern!(py, "log_assignment"), (event,))?; + Ok(()) + } + + /// Try to log bandit event using `self.assignment_logger`. + pub fn log_bandit_event(&self, py: Python, mut event: BanditEvent) -> PyResult<()> { + event.add_sdk_metadata("python".to_owned(), env!("CARGO_PKG_VERSION").to_owned()); + let event = event.try_to_pyobject(py)?; + self.assignment_logger + .call_method1(py, intern!(py, "log_bandit_action"), (event,))?; + Ok(()) + } + + pub fn shutdown(&self) { + if let Some(poller) = &self.poller_thread { + // Using `.stop()` instead of `.shutdown()` here because we don't need to wait for the + // poller thread to exit. + poller.stop(); + } + } +} + +impl Drop for EppoClient { + fn drop(&mut self) { + self.shutdown(); + } +} diff --git a/python-sdk/src/client_config.rs b/python-sdk/src/client_config.rs new file mode 100644 index 00000000..ff79373c --- /dev/null +++ b/python-sdk/src/client_config.rs @@ -0,0 +1,78 @@ +use std::num::NonZeroU64; + +use pyo3::{exceptions::PyValueError, prelude::*, PyTraverseError, PyVisit}; + +use eppo_core::{configuration_fetcher::DEFAULT_BASE_URL, poller_thread::PollerThreadConfig}; + +use crate::{assignment_logger::AssignmentLogger, configuration::Configuration}; + +#[pyclass(module = "eppo_client", get_all, set_all)] +pub struct ClientConfig { + pub(crate) api_key: String, + pub(crate) base_url: String, + pub(crate) assignment_logger: Option>, + pub(crate) is_graceful_mode: bool, + pub(crate) poll_interval_seconds: Option, + pub(crate) poll_jitter_seconds: u64, + pub(crate) initial_configuration: Option>, +} + +#[pymethods] +impl ClientConfig { + #[new] + #[pyo3(signature = ( + api_key, + *, + base_url=DEFAULT_BASE_URL.to_owned(), + assignment_logger, + is_graceful_mode=true, + poll_interval_seconds=Some(NonZeroU64::new(PollerThreadConfig::DEFAULT_POLL_INTERVAL.as_secs()).unwrap()), + poll_jitter_seconds=PollerThreadConfig::DEFAULT_POLL_JITTER.as_secs(), + initial_configuration=None + ))] + fn new( + api_key: String, + base_url: String, + assignment_logger: Py, + is_graceful_mode: bool, + poll_interval_seconds: Option, + poll_jitter_seconds: u64, + initial_configuration: Option>, + ) -> PyResult { + if api_key.is_empty() { + return Err(PyValueError::new_err( + "Invalid value for api_key: cannot be blank", + )); + } + + Ok(ClientConfig { + api_key, + base_url, + assignment_logger: Some(assignment_logger), + is_graceful_mode, + poll_interval_seconds, + poll_jitter_seconds, + initial_configuration, + }) + } + + // Overriding the default setter to make `assignment_logger` non-optional. + #[setter] + fn set_assignment_logger(&mut self, assignment_logger: Py) { + self.assignment_logger = Some(assignment_logger); + } + + // Implementing [Garbage Collector integration][1] in case user's `AssignmentLogger` holds a + // reference to `Config`. This will allow the GC to detect this cycle and break it. + // + // [1]: https://pyo3.rs/v0.22.2/class/protocols.html#garbage-collector-integration + fn __traverse__(&self, visit: PyVisit) -> Result<(), PyTraverseError> { + if let Some(assignment_logger) = &self.assignment_logger { + visit.call(assignment_logger)?; + } + Ok(()) + } + fn __clear__(&mut self) { + self.assignment_logger = None; + } +} diff --git a/python-sdk/src/configuration.rs b/python-sdk/src/configuration.rs new file mode 100644 index 00000000..da1ed2e8 --- /dev/null +++ b/python-sdk/src/configuration.rs @@ -0,0 +1,70 @@ +// We export configuration object into Python world with a limited set of operations to allow +// backend to pass on configuration when initializing the frontend. +use std::{borrow::Cow, sync::Arc}; + +use pyo3::{ + exceptions::{PyRuntimeError, PyValueError}, + prelude::*, + types::PySet, +}; + +use eppo_core::Configuration as CoreConfiguration; + +/// Eppo configuration of the client, including flags and bandits configuration. +/// +/// Internally, this is a thin wrapper around Rust-owned configuration object. +#[pyclass(frozen, module = "eppo_client")] +pub struct Configuration { + pub configuration: Arc, +} + +#[pymethods] +impl Configuration { + #[new] + #[pyo3(signature = (*, flags_configuration))] + fn py_new(flags_configuration: &[u8]) -> PyResult { + let flag_config = serde_json::from_slice(flags_configuration).map_err(|err| { + PyValueError::new_err(format!("argument 'flags_configuration': {err:?}")) + })?; + Ok(Configuration { + configuration: Arc::new(CoreConfiguration::from_server_response(flag_config, None)), + }) + } + + // Returns a set of all flag keys that have been initialized. + // This can be useful to debug the initialization process. + fn get_flag_keys<'py>(&'py self, py: Python<'py>) -> PyResult> { + PySet::new_bound(py, self.configuration.flags.flags.keys()) + } + + // Returns a set of all bandit keys that have been initialized. + // This can be useful to debug the initialization process. + fn get_bandit_keys<'py>(&'py self, py: Python<'py>) -> PyResult> { + PySet::new_bound( + py, + self.configuration + .bandits + .iter() + .flat_map(|it| it.bandits.keys()), + ) + } + + /// Return bytes representing flags configuration. + /// + /// It should be treated as opaque and passed on to another Eppo client (e.g., javascript client + /// on frontend) for initialization. + fn get_flags_configuration(&self) -> PyResult> { + serde_json::to_vec(&self.configuration.flags) + .map(Cow::Owned) + .map_err(|err| { + log::warn!(target:"eppo", "{err}"); + PyRuntimeError::new_err(err.to_string()) + }) + } +} + +impl Configuration { + pub fn new(configuration: Arc) -> Configuration { + Configuration { configuration } + } +} diff --git a/python-sdk/src/init.rs b/python-sdk/src/init.rs new file mode 100644 index 00000000..9e674fdc --- /dev/null +++ b/python-sdk/src/init.rs @@ -0,0 +1,98 @@ +use std::sync::{Mutex, RwLock}; + +use pyo3::{exceptions::PyException, prelude::*}; + +use crate::{client::EppoClient, client_config::ClientConfig}; + +// TODO: use `pyo3::sync::GILProtected` instead? +static CLIENT_INSTANCE: RwLock>> = RwLock::new(None); + +/// Initializes a global Eppo client instance. +/// +/// This method should be called once on application startup. +/// If invoked more than once, it will re-initialize the global client instance. +/// Use the :func:`EppoClient.get_instance()` method to access the client instance. +/// +/// :param config: client configuration containing the API Key +/// :type config: Config +#[pyfunction] +pub fn init(config: Bound) -> PyResult> { + initialize_pyo3_log(); + + let py = config.py(); + + let client = Bound::new(py, EppoClient::new(py, &*config.borrow())?)?.unbind(); + + // minimizing the scope of holding the write lock + let existing = { + let client = Py::clone_ref(&client, py); + + let mut instance = CLIENT_INSTANCE.write().map_err(|err| { + // This should normally never happen as it signifies that another thread + // panicked while holding the lock. + PyException::new_err(format!("failed to acquire writer lock: {err}")) + })?; + std::mem::replace(&mut *instance, Some(client)) + }; + if let Some(existing) = existing { + existing.get().shutdown(); + existing.drop_ref(py); + } + + Ok(client) +} + +/// Used to access an initialized client instance. +/// +/// Use this method to get a client instance for assigning variants. +/// This method may only be called after invocation of :func:`eppo_client.init()`, otherwise it +/// throws an exception. +/// +/// :return: a shared client instance +/// :rtype: EppoClient +#[pyfunction] +pub fn get_instance(py: Python) -> PyResult> { + let instance = CLIENT_INSTANCE.read().map_err(|err| { + // This should normally never happen as it signifies that another thread + // panicked while holding the lock. + PyException::new_err(format!("failed to acquire reader lock: {err}")) + })?; + if let Some(existing) = &*instance { + Ok(Py::clone_ref(existing, py)) + } else { + Err(PyException::new_err( + "init() must be called before get_instance()", + )) + } +} + +/// Initialize `pyo3_log` crate connecting Rust's `log` to Python's `logger`. +/// +/// If called multiple times, resets the pyo3_log cache. +fn initialize_pyo3_log() { + static LOG_RESET_HANDLE: Mutex> = Mutex::new(None); + { + if let Ok(mut reset_handle) = LOG_RESET_HANDLE.lock() { + if let Some(previous_handle) = &mut *reset_handle { + // There's a previous handle. Logging is already initialized, but we reset + // caches. + previous_handle.reset(); + } else { + if let Ok(new_handle) = pyo3_log::try_init() { + *reset_handle = Some(new_handle); + } else { + // This should not happen as initialization error signals that we already + // initialized logging. (In which case, `LOG_RESET_HANDLE` should contain + // `Some()`.) + debug_assert!(false, "tried to initialize pyo3_log second time"); + } + } + } else { + // This should normally never happen as it shows that another thread has panicked + // while holding `LOG_RESET_HANDLE`. + // + // That's probably not sever enough to throw an exception into user's code. + debug_assert!(false, "failed to acquire LOG_RESET_HANDLE lock"); + } + } +} diff --git a/python-sdk/src/lib.rs b/python-sdk/src/lib.rs new file mode 100644 index 00000000..5826fbf2 --- /dev/null +++ b/python-sdk/src/lib.rs @@ -0,0 +1,30 @@ +use pyo3::prelude::*; + +mod assignment_logger; +mod client; +mod client_config; +mod configuration; +mod init; + +#[pymodule(module = "eppo_client", name = "_eppo_client")] +mod eppo_client { + use pyo3::prelude::*; + + #[pymodule_export] + use crate::{ + assignment_logger::AssignmentLogger, + client::{EppoClient, EvaluationResult}, + client_config::ClientConfig, + configuration::Configuration, + init::{get_instance, init}, + }; + + #[pymodule_export] + use eppo_core::ContextAttributes; + + #[pymodule_init] + fn module_init(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + Ok(()) + } +} diff --git a/python-sdk/tests/__init__.py b/python-sdk/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python-sdk/tests/test_assignment_logger.py b/python-sdk/tests/test_assignment_logger.py new file mode 100644 index 00000000..eb4bcd0d --- /dev/null +++ b/python-sdk/tests/test_assignment_logger.py @@ -0,0 +1,29 @@ +from typing import Dict + +from eppo_client.assignment_logger import AssignmentLogger + + +def test_can_inherit_assignment_logger(): + class MyAssignmentLogger(AssignmentLogger): + pass + + logger = MyAssignmentLogger() + + +def test_can_override_methods(): + class MyAssignmentLogger(AssignmentLogger): + def log_assignment(self, assignment_event: Dict): + print("log_assignment", assignment_event) + + def log_bandit_action(self, bandit_event: Dict): + print("log_assignment", bandit_event) + + logger = MyAssignmentLogger() + + +def test_has_log_assignment(): + AssignmentLogger().log_assignment({}) + + +def test_has_log_bandit_action(): + AssignmentLogger().log_bandit_action({}) diff --git a/python-sdk/tests/test_bandit_result.py b/python-sdk/tests/test_bandit_result.py new file mode 100644 index 00000000..dbdaef79 --- /dev/null +++ b/python-sdk/tests/test_bandit_result.py @@ -0,0 +1,22 @@ +import pytest + +# In rust implementation, `eppo_client.bandit.BanditResult` is an alias +# to `eppo_client.EvaluationResult`. +from eppo_client.bandit import BanditResult + + +def test_bandit_result_to_string_variation(): + result = BanditResult(variation="variation", action=None) + assert result.to_string() == "variation" + + +def test_bandit_result_to_string_action(): + result = BanditResult(variation="variation", action="action") + assert result.to_string() == "action" + + +# Native python implementation does not convert variation to string. +@pytest.mark.rust_only +def test_bandit_result_to_string_number_variation(): + result = BanditResult(variation=13, action=None) + assert result.to_string() == "13" diff --git a/python-sdk/tests/test_bandits.py b/python-sdk/tests/test_bandits.py new file mode 100644 index 00000000..8fc732f1 --- /dev/null +++ b/python-sdk/tests/test_bandits.py @@ -0,0 +1,88 @@ +import pytest + +import eppo_client +from eppo_client.assignment_logger import AssignmentLogger +from eppo_client.bandit import ContextAttributes, BanditResult + +from .util import load_test_files, init + +test_data = load_test_files("bandit-tests") + + +@pytest.fixture(scope="session", autouse=True) +def init_fixture(): + init("bandit") + yield + + +@pytest.mark.parametrize("test_case", test_data, ids=lambda x: x["file_name"]) +def test_get_bandit_action(test_case): + client = eppo_client.get_instance() + + flag = test_case["flag"] + default_value = test_case["defaultValue"] + + for subject in test_case["subjects"]: + result = client.get_bandit_action( + flag, + subject["subjectKey"], + ContextAttributes( + numeric_attributes=subject["subjectAttributes"]["numericAttributes"], + categorical_attributes=subject["subjectAttributes"][ + "categoricalAttributes" + ], + ), + { + action["actionKey"]: ContextAttributes( + action["numericAttributes"], action["categoricalAttributes"] + ) + for action in subject["actions"] + }, + default_value, + ) + + assert result.variation == subject["assignment"]["variation"], ( + f"Flag {flag} failed for subject {subject['subjectKey']}:" + f"expected assignment {subject['assignment']['variation']}, got {result.variation}" + ) + assert result.action == subject["assignment"]["action"], ( + f"Flag {flag} failed for subject {subject['subjectKey']}:" + f"expected action {subject['assignment']['action']}, got {result.action}" + ) + + +@pytest.mark.rust_only +@pytest.mark.parametrize("test_case", test_data, ids=lambda x: x["file_name"]) +def test_get_bandit_action_details(test_case): + client = eppo_client.get_instance() + + flag = test_case["flag"] + default_value = test_case["defaultValue"] + + for subject in test_case["subjects"]: + result = client.get_bandit_action_details( + flag, + subject["subjectKey"], + ContextAttributes( + numeric_attributes=subject["subjectAttributes"]["numericAttributes"], + categorical_attributes=subject["subjectAttributes"][ + "categoricalAttributes" + ], + ), + { + action["actionKey"]: ContextAttributes( + action["numericAttributes"], action["categoricalAttributes"] + ) + for action in subject["actions"] + }, + default_value, + ) + + assert result.variation == subject["assignment"]["variation"], ( + f"Flag {flag} failed for subject {subject['subjectKey']}:" + f"expected assignment {subject['assignment']['variation']}, got {result.variation}" + ) + assert result.action == subject["assignment"]["action"], ( + f"Flag {flag} failed for subject {subject['subjectKey']}:" + f"expected action {subject['assignment']['action']}, got {result.action}" + ) diff --git a/python-sdk/tests/test_cache_assignment_logger.py b/python-sdk/tests/test_cache_assignment_logger.py new file mode 100644 index 00000000..42d679b5 --- /dev/null +++ b/python-sdk/tests/test_cache_assignment_logger.py @@ -0,0 +1,111 @@ +from unittest.mock import Mock + +from cachetools import LRUCache + +from eppo_client import AssignmentCacheLogger +from eppo_client.version import __version__ + + +def test_non_caching(): + inner = Mock() + logger = AssignmentCacheLogger(inner) + + logger.log_assignment(make_assignment_event()) + logger.log_assignment(make_assignment_event()) + logger.log_bandit_action(make_bandit_event()) + logger.log_bandit_action(make_bandit_event()) + + assert inner.log_assignment.call_count == 2 + assert inner.log_bandit_action.call_count == 2 + + +def test_assignment_cache(): + inner = Mock() + logger = AssignmentCacheLogger(inner, assignment_cache=LRUCache(100)) + + logger.log_assignment(make_assignment_event()) + logger.log_assignment(make_assignment_event()) + + assert inner.log_assignment.call_count == 1 + + +def test_bandit_cache(): + inner = Mock() + logger = AssignmentCacheLogger(inner, bandit_cache=LRUCache(100)) + + logger.log_bandit_action(make_bandit_event()) + logger.log_bandit_action(make_bandit_event()) + + assert inner.log_bandit_action.call_count == 1 + + +def test_bandit_flip_flop(): + inner = Mock() + logger = AssignmentCacheLogger(inner, bandit_cache=LRUCache(100)) + + logger.log_bandit_action(make_bandit_event(action="action1")) + logger.log_bandit_action(make_bandit_event(action="action1")) + assert inner.log_bandit_action.call_count == 1 + + logger.log_bandit_action(make_bandit_event(action="action2")) + assert inner.log_bandit_action.call_count == 2 + + logger.log_bandit_action(make_bandit_event(action="action1")) + assert inner.log_bandit_action.call_count == 3 + + +def make_assignment_event( + *, + allocation="allocation", + experiment="experiment", + featureFlag="featureFlag", + variation="variation", + subject="subject", + timestamp="2024-09-09T11:32:15.484Z", + subjectAttributes={}, + metaData={"sdkLanguage": "python", "sdkVersion": __version__}, + extra_logging={}, +): + return { + **extra_logging, + "allocation": allocation, + "experiment": experiment, + "featureFlag": featureFlag, + "variation": variation, + "subject": subject, + "timestamp": timestamp, + "subjectAttributes": subjectAttributes, + "metaData": metaData, + } + + +def make_bandit_event( + *, + flag_key="flagKey", + bandit_key="banditKey", + subject_key="subjectKey", + action="action", + action_probability=1.0, + optimality_gap=None, + evaluation=None, + bandit_data=None, + subject_context_attributes=None, + timestamp="2024-09-09T11:32:15.484Z", + model_version="model_version", + meta_data={"sdkLanguage": "python", "sdkVersion": __version__}, +): + return { + "flagKey": flag_key, + "banditKey": bandit_key, + "subject": subject_key, + "action": action, + "actionProbability": action_probability, + "optimalityGap": optimality_gap, + "modelVersion": model_version, + "timestamp": timestamp, + "subjectNumericAttributes": {}, + "subjectCategoricalAttributes": {}, + "actionNumericAttributes": {}, + "actionCategoricalAttributes": {}, + "metaData": meta_data, + } diff --git a/python-sdk/tests/test_client.py b/python-sdk/tests/test_client.py new file mode 100644 index 00000000..0508e218 --- /dev/null +++ b/python-sdk/tests/test_client.py @@ -0,0 +1,52 @@ +from time import sleep +import pytest + +import eppo_client +from eppo_client.config import Config, AssignmentLogger + +from .util import init + + +def test_is_initialized_false(): + client = init("ufc", wait_for_init=False) + assert client.is_initialized() == False + + +def test_is_initialized_true(): + client = init("ufc", wait_for_init=True) + assert client.is_initialized() == True + + +@pytest.mark.rust_only +def test_wait_for_initialization(): + client = init("ufc", wait_for_init=False) + client.wait_for_initialization() + assert client.is_initialized() == True + + +def test_get_flag_keys_none(): + client = init("ufc", wait_for_init=False) + assert client.get_flag_keys() == set() + + +def test_get_flag_keys_some(): + client = init("ufc", wait_for_init=True) + + keys = client.get_flag_keys() + assert isinstance(keys, set) + assert len(keys) != 0 + assert "numeric_flag" in keys + + +def test_get_bandit_keys_none(): + client = init("bandit", wait_for_init=False) + assert client.get_bandit_keys() == set() + + +def test_get_bandit_keys_some(): + client = init("bandit", wait_for_init=True) + + keys = client.get_bandit_keys() + assert isinstance(keys, set) + assert len(keys) != 0 + assert "banner_bandit" in keys diff --git a/python-sdk/tests/test_config.py b/python-sdk/tests/test_config.py new file mode 100644 index 00000000..7b3f463c --- /dev/null +++ b/python-sdk/tests/test_config.py @@ -0,0 +1,63 @@ +import pytest + +from eppo_client.config import Config +from eppo_client.assignment_logger import AssignmentLogger + + +class TestConfig: + def test_can_create_with_api_key_and_assignment_logger(self): + Config(api_key="test-key", assignment_logger=AssignmentLogger()) + + @pytest.mark.rust_only + def test_requires_non_empty_key(self): + with pytest.raises(ValueError): + Config(api_key="", assignment_logger=AssignmentLogger()) + + def test_requires_api_key(self): + # Python SDK raises Pydantic's ValidationError. + # Python-on-rust raises TypeError. + with pytest.raises(Exception): + Config(assignment_logger=AssignmentLogger()) + + def test_requires_assignment_logger(self): + # Python SDK raises Pydantic's ValidationError. + # Python-on-rust raises TypeError. + with pytest.raises(Exception): + Config(api_key="test-key") + + def test_assignment_logger_must_be_a_subclass_of_logger(self): + class MyLogger: + pass + + # Python SDK raises Pydantic's ValidationError. + # Python-on-rust raises TypeError. + with pytest.raises(Exception): + Config(api_key="test-key", assignment_logger=MyLogger()) + + def test_assignment_accepts_a_subclass_of_logger(self): + class MyLogger(AssignmentLogger): + pass + + Config(api_key="test-key", assignment_logger=MyLogger()) + + # This one is failing on native python sdk as we don't have + # `validate_assignment` enabled. + @pytest.mark.rust_only + def test_cant_reset_assignment_logger(self): + config = Config(api_key="test-key", assignment_logger=AssignmentLogger()) + with pytest.raises(TypeError): + config.assignment_logger = None + assert config.assignment_logger is not None + + def test_can_set_assignment_logger_to_another_logger(self): + config = Config(api_key="test-key", assignment_logger=AssignmentLogger()) + config.assignment_logger = AssignmentLogger() + + @pytest.mark.rust_only + def test_poll_interval_seconds_cannot_be_0(self): + with pytest.raises(ValueError): + Config( + api_key="test-key", + assignment_logger=AssignmentLogger(), + poll_interval_seconds=0, + ) diff --git a/python-sdk/tests/test_configuration.py b/python-sdk/tests/test_configuration.py new file mode 100644 index 00000000..5d829770 --- /dev/null +++ b/python-sdk/tests/test_configuration.py @@ -0,0 +1,45 @@ +from eppo_client import Configuration + +import pytest +import json + +from .util import init + + +class TestConfiguration: + def test_init_valid(self): + Configuration( + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":{}}' + ) + + def test_init_invalid_json(self): + """Input is not valid JSON string.""" + with pytest.raises(Exception): + Configuration(flags_configuration=b"{") + + def test_init_invalid_format(self): + """flags is specified as array instead of object""" + with pytest.raises(Exception): + Configuration( + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":[]}' + ) + + +@pytest.mark.rust_only +def test_configuration_none(): + client = init("ufc", wait_for_init=False) + configuration = client.get_configuration() + assert configuration == None + + +@pytest.mark.rust_only +def test_configuration_some(): + client = init("ufc", wait_for_init=True) + configuration = client.get_configuration() + assert configuration != None + + flag_config = configuration.get_flags_configuration() + + result = json.loads(flag_config) + assert result["environment"] == {"name": "Test"} + assert "numeric_flag" in result["flags"] diff --git a/python-sdk/tests/test_context_attributes.py b/python-sdk/tests/test_context_attributes.py new file mode 100644 index 00000000..9ef19108 --- /dev/null +++ b/python-sdk/tests/test_context_attributes.py @@ -0,0 +1,75 @@ +import pytest + +from eppo_client.bandit import ContextAttributes + + +def test_init(): + ContextAttributes(numeric_attributes={"a": 12}, categorical_attributes={"b": "s"}) + + +def test_init_unnamed(): + ContextAttributes({"a": 12}, {"b": "s"}) + + +@pytest.mark.rust_only +def test_type_check(): + with pytest.raises(TypeError): + ContextAttributes( + numeric_attributes={"a": "s"}, categorical_attributes={"b": "s"} + ) + + +def test_bool_as_numeric(): + attrs = ContextAttributes( + numeric_attributes={"true": True, "false": False}, categorical_attributes={} + ) + assert attrs.numeric_attributes == {"true": 1.0, "false": 0.0} + + +def test_empty(): + attrs = ContextAttributes.empty() + + +def test_from_dict(): + attrs = ContextAttributes.from_dict( + { + "numeric1": 1, + "numeric2": 42.3, + "categorical1": "string", + } + ) + assert attrs.numeric_attributes == {"numeric1": 1.0, "numeric2": 42.3} + assert attrs.categorical_attributes == { + "categorical1": "string", + } + + +# `bool` is a subclass of `int` in Python, so it was incorrectly +# captured as numeric attribute: +# https://linear.app/eppo/issue/FF-3106/ +@pytest.mark.rust_only +def test_from_dict_bool(): + attrs = ContextAttributes.from_dict( + { + "categorical": True, + } + ) + assert attrs.numeric_attributes == {} + assert attrs.categorical_attributes == { + "categorical": "true", + } + + +@pytest.mark.rust_only +def test_does_not_allow_bad_attributes(): + with pytest.raises(TypeError): + attrs = ContextAttributes.from_dict({"custom": {"tested": True}}) + + +# In Rust, context attributes live in Rust land and getter returns a +# copy of attributes. +@pytest.mark.rust_only +def test_attributes_are_frozen(): + attrs = ContextAttributes.from_dict({"cat": "string"}) + attrs.categorical_attributes["cat"] = "dog" + assert attrs.categorical_attributes == {"cat": "string"} diff --git a/python-sdk/tests/test_import_locations.py b/python-sdk/tests/test_import_locations.py new file mode 100644 index 00000000..243bbc80 --- /dev/null +++ b/python-sdk/tests/test_import_locations.py @@ -0,0 +1,21 @@ +# Tests that imports are available at old location (for +# backward-compatibility) + + +def test_config(): + from eppo_client.config import Config + + # Our docs import logger from eppo_client.config + from eppo_client.config import AssignmentLogger + + +def test_assignment_logger(): + from eppo_client.assignment_logger import AssignmentLogger + + +def test_bandit(): + from eppo_client.bandit import BanditResult + + +def test_version(): + from eppo_client.version import __version__ diff --git a/python-sdk/tests/test_initial_configuration.py b/python-sdk/tests/test_initial_configuration.py new file mode 100644 index 00000000..cd9a2885 --- /dev/null +++ b/python-sdk/tests/test_initial_configuration.py @@ -0,0 +1,47 @@ +import eppo_client +from eppo_client import Configuration +from eppo_client.config import Config +from eppo_client.assignment_logger import AssignmentLogger + + +def test_without_initial_configuration(): + client = eppo_client.init( + Config( + api_key="test", + base_url="http://localhost:8378/api", + assignment_logger=AssignmentLogger(), + ) + ) + assert not client.is_initialized() + + +def test_with_initial_configuration(): + client = eppo_client.init( + Config( + api_key="test", + base_url="http://localhost:8378/api", + assignment_logger=AssignmentLogger(), + initial_configuration=Configuration( + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":{}}' + ), + ) + ) + assert client.is_initialized() + + +def test_update_configuration(): + client = eppo_client.init( + Config( + api_key="test", + poll_interval_seconds=None, + assignment_logger=AssignmentLogger(), + ) + ) + + client.set_configuration( + Configuration( + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":{}}' + ) + ) + + assert client.is_initialized() diff --git a/python-sdk/tests/test_sdk_test_data_eval_assignment.py b/python-sdk/tests/test_sdk_test_data_eval_assignment.py new file mode 100644 index 00000000..1c234592 --- /dev/null +++ b/python-sdk/tests/test_sdk_test_data_eval_assignment.py @@ -0,0 +1,72 @@ +import pytest + +import eppo_client +from eppo_client.assignment_logger import AssignmentLogger + +from .util import load_test_files, init + +test_data = load_test_files("tests") + + +@pytest.fixture(scope="session", autouse=True) +def init_fixture(): + init("ufc") + yield + + +@pytest.mark.parametrize("test_case", test_data, ids=lambda x: x["file_name"]) +def test_assign_subject_in_sample(test_case): + client = eppo_client.get_instance() + print("---- Test case for {} Experiment".format(test_case["flag"])) + + get_typed_assignment = { + "STRING": client.get_string_assignment, + "INTEGER": client.get_integer_assignment, + "NUMERIC": client.get_numeric_assignment, + "BOOLEAN": client.get_boolean_assignment, + "JSON": client.get_json_assignment, + }[test_case["variationType"]] + + assignments = get_assignments(test_case, get_typed_assignment) + for subject, assigned_variation in assignments: + assert ( + assigned_variation == subject["assignment"] + ), f"expected <{subject['assignment']}> for subject {subject['subjectKey']}, found <{assigned_variation}>" + + +@pytest.mark.parametrize("test_case", test_data, ids=lambda x: x["file_name"]) +@pytest.mark.rust_only +def test_eval_details(test_case): + client = eppo_client.get_instance() + print("---- Test case for {} Experiment".format(test_case["flag"])) + + get_typed_assignment = { + "STRING": client.get_string_assignment_details, + "INTEGER": client.get_integer_assignment_details, + "NUMERIC": client.get_numeric_assignment_details, + "BOOLEAN": client.get_boolean_assignment_details, + "JSON": client.get_json_assignment_details, + }[test_case["variationType"]] + + assignments = get_assignments(test_case, get_typed_assignment) + for subject, assigned_variation in assignments: + assert ( + assigned_variation.variation == subject["assignment"] + ), f"expected <{subject['assignment']}> for subject {subject['subjectKey']}, found <{assigned_variation}>" + + +def get_assignments(test_case, get_assignment_fn): + # client = eppo_client.get_instance() + # client.__is_graceful_mode = False + + print(test_case["flag"]) + assignments = [] + for subject in test_case.get("subjects", []): + variation = get_assignment_fn( + test_case["flag"], + subject["subjectKey"], + subject["subjectAttributes"], + test_case["defaultValue"], + ) + assignments.append((subject, variation)) + return assignments diff --git a/python-sdk/tests/test_version.py b/python-sdk/tests/test_version.py new file mode 100644 index 00000000..3e2a7258 --- /dev/null +++ b/python-sdk/tests/test_version.py @@ -0,0 +1,13 @@ +import pytest + +import eppo_client + + +@pytest.mark.rust_only +def test_version_available(): + assert isinstance(eppo_client.__version__, str) + + +@pytest.mark.rust_only +def test_min_version(): + assert eppo_client.__version__ >= "4.0.0" diff --git a/python-sdk/tests/util.py b/python-sdk/tests/util.py new file mode 100644 index 00000000..6191d3ef --- /dev/null +++ b/python-sdk/tests/util.py @@ -0,0 +1,47 @@ +import time +import os +import json + +import eppo_client +from eppo_client.config import Config, AssignmentLogger + + +def init(suite, *, wait_for_init=True): + client = eppo_client.init( + Config( + api_key="blah", + base_url=f"http://localhost:8378/{suite}/api", + assignment_logger=AssignmentLogger(), + ) + ) + if wait_for_init: + wait_for_initialization() + return client + + +def wait_for_initialization(): + client = eppo_client.get_instance() + if not client.is_initialized(): + if hasattr(client, "wait_for_initialization"): + client.wait_for_initialization() + else: + time.sleep(0.1) + + +def load_test_files(dir): + TEST_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "../../sdk-test-data/ufc/", dir + ) + test_data = [] + for file_name in os.listdir(TEST_DIR): + # dynamic-typing tests allow passing arbitrary/invalid values to + # ContextAttributes. Our implementation is more strongly typed and + # checks that attributes have proper types and throws TypeError + # otherwise (at ContextAttributes construction, not + # evaluation). Therefore, these tests are not applicable. + if not file_name.endswith(".dynamic-typing.json"): + with open("{}/{}".format(TEST_DIR, file_name)) as test_case_json: + test_case_dict = json.load(test_case_json) + test_case_dict["file_name"] = file_name + test_data.append(test_case_dict) + return test_data diff --git a/python-sdk/tox.ini b/python-sdk/tox.ini new file mode 100644 index 00000000..250b1556 --- /dev/null +++ b/python-sdk/tox.ini @@ -0,0 +1,17 @@ +[tox] +envlist = py3{8,9,10,11,12} +isolated_build = True + +[testenv] +passenv = + # If native dependencies are managed by Nix, we need to pass some + # nix variables, so the build process can find the libraries. + # + # These seems to be the most important: + # NIX_LDFLAGS, NIX_CFLAGS_COMPILE, NIX_*_WRAPPER_TARGET_HOST_* + # but it's probably fine to pass all NIX_ variables. + NIX_* +deps = + pytest + cachetools +commands = pytest diff --git a/ruby-sdk/Cargo.lock b/ruby-sdk/Cargo.lock index 558736ff..39d050d6 100644 --- a/ruby-sdk/Cargo.lock +++ b/ruby-sdk/Cargo.lock @@ -317,9 +317,7 @@ dependencies = [ [[package]] name = "eppo_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aff693ab3cd82f61d90249aa02c6f7e173202d92727d1036b8fc1468201f9848" +version = "3.0.0" dependencies = [ "chrono", "derive_more", diff --git a/ruby-sdk/ext/eppo_client/Cargo.toml b/ruby-sdk/ext/eppo_client/Cargo.toml index 0fa1b0c9..170e6241 100644 --- a/ruby-sdk/ext/eppo_client/Cargo.toml +++ b/ruby-sdk/ext/eppo_client/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] [dependencies] env_logger = { version = "0.11.3", features = ["unstable-kv"] } -eppo_core = { version = "2.0.0" } +eppo_core = { version = "3.0.0" } log = { version = "0.4.21", features = ["kv_serde"] } magnus = { version = "0.6.2" } serde = { version = "1.0.203", features = ["derive"] } diff --git a/rust-sdk/Cargo.toml b/rust-sdk/Cargo.toml index 8ed16e92..5e71a6bb 100644 --- a/rust-sdk/Cargo.toml +++ b/rust-sdk/Cargo.toml @@ -11,7 +11,7 @@ categories = ["config"] rust-version = "1.71.1" [dependencies] -eppo_core = { version = "2.0.0", path = "../eppo_core" } +eppo_core = { version = "3.0.0", path = "../eppo_core" } log = { version = "0.4.21", features = ["kv", "kv_serde"] } serde_json = "1.0.116" diff --git a/rust-sdk/src/client.rs b/rust-sdk/src/client.rs index 81e712fc..e9a5ac9b 100644 --- a/rust-sdk/src/client.rs +++ b/rust-sdk/src/client.rs @@ -571,7 +571,7 @@ mod tests { ); // updating configuration after client is created - configuration_store.set_configuration(Configuration::from_server_response( + configuration_store.set_configuration(Arc::new(Configuration::from_server_response( UniversalFlagConfig { created_at: chrono::Utc::now(), environment: Environment { @@ -610,7 +610,7 @@ mod tests { bandits: HashMap::new(), }, None, - )); + ))); assert_eq!( client