eppo_core/attributes/
context_attributes.rs

1use std::{collections::HashMap, sync::Arc};
2
3use serde::{Deserialize, Serialize};
4
5use crate::Str;
6
7use super::{
8    AttributeValue, AttributeValueImpl, Attributes, CategoricalAttribute, NumericAttribute,
9};
10
11/// `ContextAttributes` are subject or action attributes split by their semantics.
12// TODO(oleksii): I think we should hide fields of this type and maybe the whole type itself. Now
13// with `Attributes` being able to faithfully represent numeric and categorical attributes, there's
14// little reason for users of eppo_core to know about `ContextAttributes`, so it makes sense to hide
15// it and make it an internal type.
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18#[cfg_attr(feature = "pyo3", pyo3::pyclass(module = "eppo_client"))]
19pub struct ContextAttributes {
20    /// Numeric attributes are quantitative (e.g., real numbers) and define a scale.
21    ///
22    /// Not all numbers are numeric attributes. If a number is used to represent an enumeration or
23    /// on/off values, it is a categorical attribute.
24    #[serde(alias = "numericAttributes")]
25    pub numeric: Arc<HashMap<Str, NumericAttribute>>,
26    /// Categorical attributes are attributes that have a finite set of values that are not directly
27    /// comparable (i.e., enumeration).
28    #[serde(alias = "categoricalAttributes")]
29    pub categorical: Arc<HashMap<Str, CategoricalAttribute>>,
30}
31
32impl From<Attributes> for ContextAttributes {
33    fn from(value: Attributes) -> Self {
34        ContextAttributes::from_iter(value)
35    }
36}
37
38impl<K, V> FromIterator<(K, V)> for ContextAttributes
39where
40    K: Into<Str>,
41    V: Into<AttributeValue>,
42{
43    fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
44        let (categorical, numeric) = iter.into_iter().fold(
45            (HashMap::new(), HashMap::new()),
46            |(mut categorical, mut numeric), (key, value)| {
47                match value.into() {
48                    AttributeValue(AttributeValueImpl::Categorical(value)) => {
49                        categorical.insert(key.into(), value);
50                    }
51                    AttributeValue(AttributeValueImpl::Numeric(value)) => {
52                        numeric.insert(key.into(), value);
53                    }
54                    AttributeValue(AttributeValueImpl::Null) => {
55                        // Nulls are missing values and are ignored.
56                    }
57                }
58                (categorical, numeric)
59            },
60        );
61        ContextAttributes {
62            numeric: Arc::new(numeric),
63            categorical: Arc::new(categorical),
64        }
65    }
66}
67
68impl ContextAttributes {
69    /// Convert contextual attributes to generic `Attributes`.
70    pub fn to_generic_attributes(&self) -> Attributes {
71        let mut result = HashMap::with_capacity(self.numeric.len() + self.categorical.capacity());
72        for (key, value) in self.numeric.iter() {
73            result.insert(key.clone(), value.clone().into());
74        }
75        for (key, value) in self.categorical.iter() {
76            result.insert(key.clone(), value.clone().into());
77        }
78        result
79    }
80}
81
82#[cfg(feature = "pyo3")]
83mod pyo3_impl {
84    use std::{collections::HashMap, sync::Arc};
85
86    use pyo3::prelude::*;
87
88    use crate::{Attributes, CategoricalAttribute, NumericAttribute, Str};
89
90    use super::ContextAttributes;
91
92    #[pymethods]
93    impl ContextAttributes {
94        #[new]
95        fn new(
96            numeric_attributes: HashMap<Str, NumericAttribute>,
97            categorical_attributes: HashMap<Str, CategoricalAttribute>,
98        ) -> ContextAttributes {
99            ContextAttributes {
100                numeric: Arc::new(numeric_attributes),
101                categorical: Arc::new(categorical_attributes),
102            }
103        }
104
105        /// Create an empty Attributes instance with no numeric or categorical attributes.
106        ///
107        /// Returns:
108        ///     ContextAttributes: An instance of the ContextAttributes class with empty dictionaries
109        ///         for numeric and categorical attributes.
110        #[staticmethod]
111        fn empty() -> ContextAttributes {
112            ContextAttributes::default()
113        }
114
115        /// Create an ContextAttributes instance from a dictionary of attributes.
116
117        /// Args:
118        ///     attributes (Dict[str, Union[float, int, bool, str]]): A dictionary where keys are attribute names
119        ///         and values are attribute values which can be of type float, int, bool, or str.
120
121        /// Returns:
122        ///     ContextAttributes: An instance of the ContextAttributes class
123        ///         with numeric and categorical attributes separated.
124        #[staticmethod]
125        fn from_dict(attributes: Attributes) -> ContextAttributes {
126            attributes.into()
127        }
128
129        /// Note that this copies internal attributes, so changes to returned value won't have
130        /// effect. This may be mitigated by setting numeric attributes again.
131        #[getter]
132        fn get_numeric_attributes(&self, py: Python) -> PyObject {
133            self.numeric.to_object(py)
134        }
135
136        /// Note that this copies internal attributes, so changes to returned value won't have
137        /// effect. This may be mitigated by setting categorical attributes again.
138        #[getter]
139        fn get_categorical_attributes(&self, py: Python) -> PyObject {
140            self.categorical.to_object(py)
141        }
142    }
143}