mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
refactor(redis-resolve): make SETTINGS the one table of how Rust handles each redis-py kwarg
Each row now carries its handling with the warning's consequence, whether the value is plain data or a Python object, whether a url query carries it, its REDIS_* name and its get_secret fallbacks. HOST_OBJECTS, consequence(), is_met_by_rustls and the url setting lists in tls.rs fold into those columns. The 29 kwargs that had no row get one, so setting them now warns instead of passing through silently. redis-py-kwargs.json keeps only the Python facts: which redis-py versions name each kwarg and whether Python reads its REDIS_* name. tests/catalog.rs fails until SETTINGS has exactly one row per kwarg in it with the same REDIS_* flag, and test_redis.py checks the file against the redis-py each redis-compat CI job installs
This commit is contained in:
parent
b7e07948c8
commit
185c026603
10 changed files with 555 additions and 3079 deletions
1
litellm-rust/Cargo.lock
generated
1
litellm-rust/Cargo.lock
generated
|
|
@ -4465,6 +4465,7 @@ dependencies = [
|
|||
"litellm-redis-types",
|
||||
"litellm-secret-value",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum",
|
||||
"thiserror 2.0.19",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
- The boundary is merged redis-py kwargs in, `litellm_redis_types::Resolution` out. `interpret` takes the dict a host already merged the way `_merge_redis_kwargs` in `litellm/_redis.py` does, with its `REDIS_*` values and `get_secret` fallbacks filled, and stands in for redis-py's constructors and `from_url`. It never reads the environment or a secret, so both hosts share it. Every `Entry` keeps the source a warning or error names, which `kwargs` sets to the kwarg name and the gateway merge sets to `REDIS_<KWARG>` or the fallback name
|
||||
- The `Resolution` holds the target, the auth and the timeouts Rust models, every other plain setting verbatim in `passthrough`, the names of object-valued settings in `host_objects`, and every setting the Rust client ignores as an `Ignored` with its source and consequence, which the host logs. A host passes its `credential_provider` object as a `ProviderHandle`, and a `credential_provider` key without one is `RedisError::ProviderWithoutHandle`. Azure's username is read from `os.environ` by Python, so `interpret` leaves `AzureAdAuth::username` unset and the host fills it in
|
||||
- `options.rs` reads the kwargs into the typed `Options` and splits the unmodeled settings into passthrough and host objects and collects ignored ones. `endpoint.rs`, `auth.rs` and `tls.rs` pick the target, the credentials and TLS from `Options` alone
|
||||
- Rust never introspects redis-py. Every Redis setting Python reads is one row in `SETTINGS` in `src/keys.rs`: how Rust handles it (`Read`, `Warn` or `Reject`), whether `_get_redis_env_kwarg_mapping` reads it as `REDIS_<KWARG>`, and the names `_merge_redis_kwargs` falls back to. The last two columns are only read by the gateway merge, and they stay on the row so there is one table. A `Read` row is also a field of `Options`. A kwarg with no row goes to `passthrough`, or to `host_objects` if `HOST_OBJECTS` lists it, without a warning. `redis-py-kwargs.json` records which redis-py version accepts each kwarg next to its row, so check it when adding or changing a row
|
||||
- Rust never introspects redis-py. Every Redis kwarg redis-py or `litellm/_redis.py` accepts is one row in `SETTINGS` in `src/keys.rs`, and that row is the whole answer for the kwarg: its `Handling` (`Read`, `Warn` with the consequence the warning names, or `Reject`), its `Shape` (plain data lands in `passthrough`, a Python object in `host_objects`), whether a url query carries it (`FromUrl`), whether `_get_redis_env_kwarg_mapping` reads it as `REDIS_<KWARG>`, and the names `_merge_redis_kwargs` falls back to. The last two columns are only read by the gateway merge, and they stay on the row so there is one table. A `Read` row is a field of `Options`, which an inline test pins. A kwarg with no row is not a Redis setting, so it passes through without a warning
|
||||
- `redis-py-kwargs.json` holds the Python facts each row answers: which redis-py versions name the kwarg in which signature, and whether Python reads its `REDIS_*` name under the locked redis-py. `test_redis_py_kwargs_catalog_matches_the_installed_redis_py` in `tests/unit/test_redis.py` checks it against each version the `redis-compat` CI matrix installs, and `tests/catalog.rs` fails until `SETTINGS` has exactly one row per kwarg in it with the same `environment`. A redis-py bump that adds a kwarg therefore cannot land without a row deciding how Rust handles it
|
||||
- The contract is `resolves_like_python_redis` in `tests/interpret.rs` and its mirror `test_redis_settings_resolve_like_the_rust_resolver_expects` in `tests/unit/test_redis.py`. Each case holds the merged dict, the sources the merge gave it, and the expected view written next to it. The Python side asserts that `_merge_redis_kwargs` returns that merged dict and reads the effective redis-py 5.3.1 connection objects without connecting, and `merges_like_python_redis` in `gateway/src/redis/surface_tests.rs` pins the same merged dicts from the Rust merge. A new rule is a new case in every file, never a generated fixture. Python and Rust pods share one Redis during the switch, so a change here is a change to a wire contract
|
||||
- A url carries its own TLS. redis-py's url allowlist is the plain `Connection` signature, so every `ssl_*` setting outside the url is dropped, and a credential provider strips the url's userinfo. A `rediss://` url's `ssl_cert_reqs`, `ssl_ca_certs`, `ssl_ca_data`, `ssl_ca_path`, `ssl_certfile`, `ssl_keyfile`, `ssl_password` and `ssl_check_hostname` query parameters are read like `parse_url` reads them
|
||||
- TLS options and `db` are only read for the endpoint that uses them, so a bad `ssl_cert_reqs` never fails a url target. A cluster with a non-zero `db`, and Sentinel with a `host` or `port`, are errors because redis-py raises building those clients
|
||||
|
|
@ -11,12 +12,13 @@
|
|||
- Precedence matches Python. The target is cluster, then URL, then Sentinel (`sentinel_nodes` plus `service_name`), then host. Auth is a `credential_provider` handle, then GCP, then Azure, then AWS, and the first one wins with a warning
|
||||
- Numbers are read with `litellm_python_compat::number`, so whitespace and digit underscores parse like Python's `int()` and `float()`. A value they cannot parse (`port: abc`, `socket_timeout: slow`) is dropped with a warning and the default is used, like `_coerce_redis_kwargs_types`. A number that parses but is out of range (negative seconds, a port above 65535) fails with `RedisError::Invalid`
|
||||
- Flags (`ssl`, `ssl_check_hostname`, `aws_iam_auth`, `aws_iam_serverless`) are read one way on every surface. `true`, `1` and `yes` are true and `false`, `0`, `no`, `off`, `f` and `n` are false, the text redis-py's `_str_to_bool` and the proxy's pydantic models agree on. Any other text is `RedisError::Invalid`, so a TLS setting is never guessed
|
||||
- The resolver fails on a setting it cannot honor only when that setting is a security check. A `Warn` row is dropped and returned as an `Ignored` naming its source (`ssl_ciphers` or `REDIS_SSL_CIPHERS`) and the consequence from `keys::consequence`, because a Python deployment that worked must keep working when its stores move to Rust. Only a value that is malformed (`RedisError::Invalid`) or a target that is missing fails. Promoting a `Warn` row to `Read` is then a pure improvement with no behavior cliff
|
||||
- `ssl_ciphers`, `ssl_min_version` above TLS 1.2, `ssl_ocsp_context`, `ssl_ocsp_expected_cert`, `connection_pool`, `event_dispatcher`, `redis_connect_func` and `retry` are ignored
|
||||
- The resolver fails on a setting it cannot honor only when that setting is a security check. A `Warn` row is dropped and returned as an `Ignored` naming its source (`ssl_ciphers` or `REDIS_SSL_CIPHERS`) and the consequence on its row, because a Python deployment that worked must keep working when its stores move to Rust. Only a value that is malformed (`RedisError::Invalid`) or a target that is missing fails. Promoting a `Warn` row to `Read` is then a pure improvement with no behavior cliff
|
||||
- A `Warn` row with a `met` check is dropped silently when its value asks for nothing more than Rust does, so `ssl_min_version` of TLS 1.2 or lower never warns
|
||||
- A `Reject` row fails closed. `ssl_validate_ocsp` and `ssl_validate_ocsp_stapled` set to true, from the kwargs, `REDIS_*` or a url query, are `RedisError::Unenforceable`, because rustls has no client-side OCSP and connecting without the check would silently drop it. redis-py 5.3.1's async client refuses both with a `TypeError`, so this matches the proxy's path. Set to false they ask for nothing and are dropped without a warning
|
||||
- `ssl_ca_data` and `ssl_ca_path` are read into `TlsOptions` and trusted next to `ssl_ca_certs`. `ssl_password` is read into `TlsOptions::key_password`, which `redis-client` uses to decrypt a PKCS#8 key like the sync client's `load_cert_chain(password=...)`. redis-py 5.3.1's async client refuses it, so this is a superset of the path the proxy uses, like `ssl_ca_path` `unix_socket_path` resolves to `Target::Unix`, which like redis-py's `UnixDomainSocketConnection` replaces host, port and TLS. Python still requires `host` next to it, so Rust does too
|
||||
- A url's `?db=`, `?username=` and `?password=` are read like `parse_url` reads them, so `?db=` wins over the path and the userinfo wins over `?username=` and `?password=`. Any other query parameter except the TLS ones on a `rediss://` url is ignored naming `url ?<name>`. redis-py turns each one into a client kwarg
|
||||
- `retry_on_timeout`, `retry_on_error`, `protocol`, `cache`, `cache_config`, `socket_keepalive` and `max_connections` are ignored. Cluster clients still turn TCP keepalive on, like Python's async cluster default
|
||||
- `ssl_ca_data` and `ssl_ca_path` are read into `TlsOptions` and trusted next to `ssl_ca_certs`. `ssl_password` is read into `TlsOptions::key_password`, which `redis-client` uses to decrypt a PKCS#8 key like the sync client's `load_cert_chain(password=...)`. redis-py 5.3.1's async client refuses it, so this is a superset of the path the proxy uses, like `ssl_ca_path`
|
||||
- `unix_socket_path` resolves to `Target::Unix`, which like redis-py's `UnixDomainSocketConnection` replaces host, port and TLS. Python still requires `host` next to it, so Rust does too
|
||||
- A url's `?db=`, `?username=` and `?password=` are read like `parse_url` reads them, so `?db=` wins over the path and the userinfo wins over `?username=` and `?password=`. Any other query parameter, including one with no row, is ignored naming `url ?<name>`, because redis-py turns each one into a client kwarg. Which ones a url carries is the `FromUrl` column
|
||||
- `socket_keepalive` is warned, but cluster clients still turn TCP keepalive on, like Python's async cluster default
|
||||
- `health_check_interval` is read as an `int` of seconds like `_coerce_redis_kwargs_types` into `HealthCheck`. Zero is `Disabled` and stays off on every target, a negative interval checks before every command like redis-py, and only `Unset` takes a default. `redis-connect` gives an unset cluster Python's 25s default and `redis-client` pings every node before the next command once the client sat idle that long, outside the breaker and the call events
|
||||
- Cases where Rust and Python disagree on purpose are cases in both contract tests with a different expected outcome on each side
|
||||
- `ssl_ca_path`: redis-py 5.3.1's async `Redis` has no such parameter and raises `TypeError` building the client, while Rust trusts the directory like `load_verify_locations(capath=...)`
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ url.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,184 +1,265 @@
|
|||
use litellm_redis_types::Ignored;
|
||||
use serde_json::Value;
|
||||
|
||||
// Every Redis setting litellm/_redis.py reads, with how Rust handles it and where else Python looks for it.
|
||||
// Warn rows are read by Python and dropped here with a warning naming the consequence, never an error.
|
||||
// Reject rows are security checks Rust cannot perform, so asking for one fails instead of connecting without it.
|
||||
// environment marks the kwargs _get_redis_env_kwarg_mapping reads as REDIS_<KWARG>, and fallback lists the names
|
||||
// _get_redis_client_logic consults with get_secret when the merged kwargs lack the setting
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
/// How the Rust client honors a redis-py kwarg.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Handling {
|
||||
/// Modeled by the resolver into a field of `Options`.
|
||||
Read,
|
||||
Warn,
|
||||
/// Dropped and returned as an `Ignored` naming `consequence`, unless `met` says the value asks for nothing the
|
||||
/// Rust client does not already do.
|
||||
Warn {
|
||||
consequence: &'static str,
|
||||
met: Option<fn(&Value) -> bool>,
|
||||
},
|
||||
/// A security check the Rust client cannot perform, so setting it to true fails instead of connecting without it.
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// What the value is, which decides where an unread setting lands in a `Resolution`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Shape {
|
||||
/// Plain data, kept verbatim in `passthrough`.
|
||||
Plain,
|
||||
/// A Python object (a class, callable or instance), named in `host_objects` for the host to reattach.
|
||||
Object,
|
||||
}
|
||||
|
||||
/// Whether redis-py's `parse_url` reads the setting from a url's query and the Rust resolver does too.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FromUrl {
|
||||
No,
|
||||
Any,
|
||||
Rediss,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Setting {
|
||||
pub kwarg: &'static str,
|
||||
pub handling: Handling,
|
||||
pub shape: Shape,
|
||||
pub from_url: FromUrl,
|
||||
/// `_get_redis_env_kwarg_mapping` reads it as `REDIS_<KWARG>`.
|
||||
pub environment: bool,
|
||||
/// The names `_merge_redis_kwargs` reads with `get_secret` when the merged kwargs lack the setting.
|
||||
pub fallback: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl Setting {
|
||||
const fn new(
|
||||
kwarg: &'static str,
|
||||
handling: Handling,
|
||||
environment: bool,
|
||||
fallback: &'static [&'static str],
|
||||
) -> Self {
|
||||
const fn new(kwarg: &'static str, handling: Handling) -> Self {
|
||||
Self {
|
||||
kwarg,
|
||||
handling,
|
||||
environment,
|
||||
fallback,
|
||||
shape: Shape::Plain,
|
||||
from_url: FromUrl::No,
|
||||
environment: false,
|
||||
fallback: &[],
|
||||
}
|
||||
}
|
||||
|
||||
const fn env(self) -> Self {
|
||||
Self {
|
||||
environment: true,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
const fn object(self) -> Self {
|
||||
Self {
|
||||
shape: Shape::Object,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
const fn in_url(self, from_url: FromUrl) -> Self {
|
||||
Self { from_url, ..self }
|
||||
}
|
||||
|
||||
const fn or_secrets(self, fallback: &'static [&'static str]) -> Self {
|
||||
Self { fallback, ..self }
|
||||
}
|
||||
|
||||
pub fn environment_name(&self) -> Option<String> {
|
||||
self.environment
|
||||
.then(|| format!("REDIS_{}", self.kwarg.to_ascii_uppercase()))
|
||||
}
|
||||
}
|
||||
|
||||
pub const SETTINGS: &[Setting] = &[
|
||||
Setting::new("aws_iam_auth", Handling::Read, true, &[]),
|
||||
Setting::new("aws_iam_cache_name", Handling::Read, true, &[]),
|
||||
Setting::new(
|
||||
"aws_iam_region",
|
||||
Handling::Read,
|
||||
true,
|
||||
&["AWS_REGION", "AWS_DEFAULT_REGION"],
|
||||
),
|
||||
Setting::new("aws_iam_serverless", Handling::Read, true, &[]),
|
||||
Setting::new("aws_iam_user_name", Handling::Read, true, &[]),
|
||||
Setting::new(
|
||||
"azure_client_id",
|
||||
Handling::Read,
|
||||
true,
|
||||
&["AZURE_CLIENT_ID"],
|
||||
),
|
||||
Setting::new(
|
||||
"azure_client_secret",
|
||||
Handling::Read,
|
||||
true,
|
||||
&["AZURE_CLIENT_SECRET"],
|
||||
),
|
||||
Setting::new(
|
||||
"azure_redis_ad_token",
|
||||
Handling::Read,
|
||||
true,
|
||||
&["REDIS_AZURE_AD_TOKEN"],
|
||||
),
|
||||
Setting::new(
|
||||
"azure_tenant_id",
|
||||
Handling::Read,
|
||||
true,
|
||||
&["AZURE_TENANT_ID"],
|
||||
),
|
||||
Setting::new("cache", Handling::Warn, true, &[]),
|
||||
Setting::new("cache_config", Handling::Warn, true, &[]),
|
||||
Setting::new("charset", Handling::Warn, true, &[]),
|
||||
Setting::new("client_name", Handling::Warn, true, &[]),
|
||||
Setting::new("connection_pool", Handling::Warn, false, &[]),
|
||||
Setting::new("credential_provider", Handling::Read, false, &[]),
|
||||
Setting::new("db", Handling::Read, true, &[]),
|
||||
Setting::new("decode_responses", Handling::Warn, true, &[]),
|
||||
Setting::new("encoding", Handling::Warn, true, &[]),
|
||||
Setting::new("encoding_errors", Handling::Warn, true, &[]),
|
||||
Setting::new("errors", Handling::Warn, true, &[]),
|
||||
Setting::new("event_dispatcher", Handling::Warn, true, &[]),
|
||||
Setting::new(
|
||||
"gcp_service_account",
|
||||
Handling::Read,
|
||||
true,
|
||||
&["REDIS_GCP_SERVICE_ACCOUNT"],
|
||||
),
|
||||
Setting::new(
|
||||
"gcp_ssl_ca_certs",
|
||||
Handling::Read,
|
||||
true,
|
||||
&["REDIS_GCP_SSL_CA_CERTS"],
|
||||
),
|
||||
Setting::new("health_check_interval", Handling::Read, true, &[]),
|
||||
Setting::new("host", Handling::Read, true, &[]),
|
||||
Setting::new("lib_name", Handling::Warn, true, &[]),
|
||||
Setting::new("lib_version", Handling::Warn, true, &[]),
|
||||
Setting::new("max_connections", Handling::Warn, true, &[]),
|
||||
Setting::new("namespace", Handling::Read, false, &[]),
|
||||
Setting::new("password", Handling::Read, true, &[]),
|
||||
Setting::new("port", Handling::Read, true, &[]),
|
||||
Setting::new("protocol", Handling::Warn, true, &[]),
|
||||
Setting::new("redis_connect_func", Handling::Warn, true, &[]),
|
||||
Setting::new("retry", Handling::Warn, false, &[]),
|
||||
Setting::new("retry_on_error", Handling::Warn, true, &[]),
|
||||
Setting::new("retry_on_timeout", Handling::Warn, true, &[]),
|
||||
Setting::new(
|
||||
"sentinel_nodes",
|
||||
Handling::Read,
|
||||
false,
|
||||
&["REDIS_SENTINEL_NODES"],
|
||||
),
|
||||
Setting::new(
|
||||
"sentinel_password",
|
||||
Handling::Read,
|
||||
false,
|
||||
&["REDIS_SENTINEL_PASSWORD"],
|
||||
),
|
||||
Setting::new(
|
||||
"service_name",
|
||||
Handling::Read,
|
||||
false,
|
||||
&["REDIS_SERVICE_NAME"],
|
||||
),
|
||||
Setting::new("single_connection_client", Handling::Warn, true, &[]),
|
||||
Setting::new("socket_connect_timeout", Handling::Read, true, &[]),
|
||||
Setting::new("socket_keepalive", Handling::Warn, true, &[]),
|
||||
Setting::new("socket_keepalive_options", Handling::Warn, true, &[]),
|
||||
Setting::new("socket_timeout", Handling::Read, true, &[]),
|
||||
Setting::new("ssl", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_ca_certs", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_ca_data", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_ca_path", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_cert_reqs", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_certfile", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_check_hostname", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_ciphers", Handling::Warn, true, &[]),
|
||||
Setting::new("ssl_keyfile", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_min_version", Handling::Warn, true, &[]),
|
||||
Setting::new("ssl_ocsp_context", Handling::Warn, true, &[]),
|
||||
Setting::new("ssl_ocsp_expected_cert", Handling::Warn, true, &[]),
|
||||
Setting::new("ssl_password", Handling::Read, true, &[]),
|
||||
Setting::new("ssl_validate_ocsp", Handling::Reject, true, &[]),
|
||||
Setting::new("ssl_validate_ocsp_stapled", Handling::Reject, true, &[]),
|
||||
Setting::new(
|
||||
"startup_nodes",
|
||||
Handling::Read,
|
||||
false,
|
||||
&["REDIS_CLUSTER_NODES"],
|
||||
),
|
||||
Setting::new("unix_socket_path", Handling::Read, true, &[]),
|
||||
Setting::new("url", Handling::Read, true, &[]),
|
||||
Setting::new("username", Handling::Read, true, &[]),
|
||||
];
|
||||
pub fn is_read(&self) -> bool {
|
||||
matches!(self.handling, Handling::Read)
|
||||
}
|
||||
|
||||
pub(crate) fn is_refused_when_set(kwarg: &str) -> bool {
|
||||
setting(kwarg).is_some_and(|setting| setting.handling == Handling::Reject)
|
||||
}
|
||||
|
||||
pub(crate) fn ignored(setting: String, kwarg: &str) -> Ignored {
|
||||
Ignored {
|
||||
setting,
|
||||
consequence: consequence(kwarg),
|
||||
/// The warning a set value earns, or `None` when the setting is read, refused or already met.
|
||||
pub(crate) fn warning(&self, value: &Value) -> Option<&'static str> {
|
||||
match self.handling {
|
||||
Handling::Warn { consequence, met } if !met.is_some_and(|met| met(value)) => {
|
||||
Some(consequence)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn read(kwarg: &'static str) -> Setting {
|
||||
Setting::new(kwarg, Handling::Read)
|
||||
}
|
||||
|
||||
const fn warn(kwarg: &'static str, consequence: &'static str) -> Setting {
|
||||
Setting::new(
|
||||
kwarg,
|
||||
Handling::Warn {
|
||||
consequence,
|
||||
met: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const fn warn_unless(
|
||||
kwarg: &'static str,
|
||||
consequence: &'static str,
|
||||
met: fn(&Value) -> bool,
|
||||
) -> Setting {
|
||||
Setting::new(
|
||||
kwarg,
|
||||
Handling::Warn {
|
||||
consequence,
|
||||
met: Some(met),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const fn reject(kwarg: &'static str) -> Setting {
|
||||
Setting::new(kwarg, Handling::Reject)
|
||||
}
|
||||
|
||||
const NO_EQUIVALENT: &str = "the Rust client has no equivalent";
|
||||
const OWN_STACK: &str = "the Rust client uses its own pool, retries and events";
|
||||
const OWN_CONNECTION: &str = "it tunes redis-py's own connection, and the Rust client has its own";
|
||||
const OWN_CLUSTER: &str = "the Rust cluster client discovers, routes and retries on its own";
|
||||
const MAINTENANCE: &str = "the Rust client does not follow server maintenance notifications";
|
||||
const RUSTLS: &str = "rustls negotiates TLS 1.2+ with its own ECDHE AEAD suites";
|
||||
const OCSP: &str = "it only configures ssl_validate_ocsp, which the Rust client refuses when set";
|
||||
const VERIFY_FLAGS: &str = "rustls verifies certificates with its own fixed rules, so no extra X.509 flag such as CRL checking applies";
|
||||
|
||||
/// Every Redis kwarg redis-py or `litellm/_redis.py` accepts, one row each. `redis-py-kwargs.json` records which
|
||||
/// redis-py versions and `REDIS_*` names accept them, and `tests/catalog.rs` keeps the two in step.
|
||||
pub const SETTINGS: &[Setting] = &[
|
||||
warn("address_remap", OWN_CLUSTER).object(),
|
||||
warn("auto_close_connection_pool", OWN_STACK),
|
||||
read("aws_iam_auth").env(),
|
||||
read("aws_iam_cache_name").env(),
|
||||
read("aws_iam_region")
|
||||
.env()
|
||||
.or_secrets(&["AWS_REGION", "AWS_DEFAULT_REGION"]),
|
||||
read("aws_iam_serverless").env(),
|
||||
read("aws_iam_user_name").env(),
|
||||
read("azure_client_id")
|
||||
.env()
|
||||
.or_secrets(&["AZURE_CLIENT_ID"]),
|
||||
read("azure_client_secret")
|
||||
.env()
|
||||
.or_secrets(&["AZURE_CLIENT_SECRET"]),
|
||||
read("azure_redis_ad_token")
|
||||
.env()
|
||||
.or_secrets(&["REDIS_AZURE_AD_TOKEN"]),
|
||||
read("azure_tenant_id")
|
||||
.env()
|
||||
.or_secrets(&["AZURE_TENANT_ID"]),
|
||||
warn("cache", NO_EQUIVALENT).env().object(),
|
||||
warn("cache_config", NO_EQUIVALENT).env().object(),
|
||||
warn("charset", NO_EQUIVALENT).env(),
|
||||
warn("client_name", NO_EQUIVALENT).env(),
|
||||
warn("cluster_error_retry_attempts", OWN_CLUSTER),
|
||||
warn("command_packer", OWN_CONNECTION).object(),
|
||||
warn("connection_class", OWN_STACK).object(),
|
||||
warn("connection_error_retry_attempts", OWN_CLUSTER),
|
||||
warn("connection_pool", OWN_STACK).object(),
|
||||
read("credential_provider"),
|
||||
read("db").env().in_url(FromUrl::Any),
|
||||
warn("decode_responses", NO_EQUIVALENT).env(),
|
||||
warn("driver_info", NO_EQUIVALENT).object(),
|
||||
warn("dynamic_startup_nodes", OWN_CLUSTER),
|
||||
warn("encoder_class", OWN_CONNECTION).object(),
|
||||
warn("encoding", NO_EQUIVALENT).env(),
|
||||
warn("encoding_errors", NO_EQUIVALENT).env(),
|
||||
warn("errors", NO_EQUIVALENT).env(),
|
||||
warn("event_dispatcher", OWN_STACK).env().object(),
|
||||
read("gcp_service_account")
|
||||
.env()
|
||||
.or_secrets(&["REDIS_GCP_SERVICE_ACCOUNT"]),
|
||||
read("gcp_ssl_ca_certs")
|
||||
.env()
|
||||
.or_secrets(&["REDIS_GCP_SSL_CA_CERTS"]),
|
||||
read("health_check_interval").env(),
|
||||
read("host").env(),
|
||||
warn("legacy_responses", NO_EQUIVALENT),
|
||||
warn("lib_name", NO_EQUIVALENT).env(),
|
||||
warn("lib_version", NO_EQUIVALENT).env(),
|
||||
warn("load_balancing_strategy", OWN_CLUSTER),
|
||||
warn("maint_notifications_config", MAINTENANCE).object(),
|
||||
warn("maint_notifications_pool_handler", MAINTENANCE).object(),
|
||||
warn("maintenance_notification_hash", MAINTENANCE),
|
||||
warn("maintenance_state", MAINTENANCE),
|
||||
warn("max_connections", "the Rust client has no connection cap").env(),
|
||||
read("namespace"),
|
||||
warn("orig_host_address", MAINTENANCE),
|
||||
warn("orig_socket_connect_timeout", MAINTENANCE),
|
||||
warn("orig_socket_timeout", MAINTENANCE),
|
||||
warn("oss_cluster_maint_notifications_handler", MAINTENANCE).object(),
|
||||
warn("parser", OWN_CONNECTION),
|
||||
warn("parser_class", OWN_CONNECTION).object(),
|
||||
read("password").env().in_url(FromUrl::Any),
|
||||
warn("path", OWN_CLUSTER),
|
||||
warn("policy_resolver", OWN_CLUSTER).object(),
|
||||
read("port").env(),
|
||||
warn("protocol", NO_EQUIVALENT).env(),
|
||||
warn("read_from_replicas", OWN_CLUSTER),
|
||||
warn(
|
||||
"redis_connect_func",
|
||||
"the Python object is not callable from Rust, so static username and password are used",
|
||||
)
|
||||
.env()
|
||||
.object(),
|
||||
warn("reinitialize_steps", OWN_CLUSTER),
|
||||
warn("require_full_coverage", OWN_CLUSTER),
|
||||
warn("retry", OWN_STACK).object(),
|
||||
warn("retry_on_error", NO_EQUIVALENT).env().object(),
|
||||
warn("retry_on_timeout", NO_EQUIVALENT).env(),
|
||||
read("sentinel_nodes").or_secrets(&["REDIS_SENTINEL_NODES"]),
|
||||
read("sentinel_password").or_secrets(&["REDIS_SENTINEL_PASSWORD"]),
|
||||
read("service_name").or_secrets(&["REDIS_SERVICE_NAME"]),
|
||||
warn("single_connection_client", NO_EQUIVALENT).env(),
|
||||
read("socket_connect_timeout").env(),
|
||||
warn("socket_keepalive", NO_EQUIVALENT).env(),
|
||||
warn("socket_keepalive_options", NO_EQUIVALENT).env(),
|
||||
warn("socket_read_size", OWN_CONNECTION),
|
||||
read("socket_timeout").env(),
|
||||
warn("socket_type", OWN_CONNECTION),
|
||||
read("ssl").env(),
|
||||
read("ssl_ca_certs").env().in_url(FromUrl::Rediss),
|
||||
read("ssl_ca_data").env().in_url(FromUrl::Rediss),
|
||||
read("ssl_ca_path").env().in_url(FromUrl::Rediss),
|
||||
read("ssl_cert_reqs").env().in_url(FromUrl::Rediss),
|
||||
read("ssl_certfile").env().in_url(FromUrl::Rediss),
|
||||
read("ssl_check_hostname").env().in_url(FromUrl::Rediss),
|
||||
warn("ssl_ciphers", RUSTLS).env(),
|
||||
warn("ssl_exclude_verify_flags", VERIFY_FLAGS),
|
||||
warn("ssl_include_verify_flags", VERIFY_FLAGS),
|
||||
read("ssl_keyfile").env().in_url(FromUrl::Rediss),
|
||||
warn_unless("ssl_min_version", RUSTLS, is_met_by_rustls).env(),
|
||||
warn("ssl_ocsp_context", OCSP).env().object(),
|
||||
warn("ssl_ocsp_expected_cert", OCSP).env(),
|
||||
read("ssl_password").env().in_url(FromUrl::Rediss),
|
||||
reject("ssl_validate_ocsp").env(),
|
||||
reject("ssl_validate_ocsp_stapled").env(),
|
||||
read("startup_nodes").or_secrets(&["REDIS_CLUSTER_NODES"]),
|
||||
read("unix_socket_path").env(),
|
||||
read("url").env(),
|
||||
read("username").env().in_url(FromUrl::Any),
|
||||
];
|
||||
|
||||
// rustls only speaks TLS 1.2 and 1.3, so a minimum of 1.2 or lower is already met. redis-py takes an
|
||||
// ssl.TLSVersion, which reaches Rust as its name or its value
|
||||
pub(crate) fn is_met_by_rustls(kwarg: &str, value: &Value) -> bool {
|
||||
if kwarg != "ssl_min_version" {
|
||||
return false;
|
||||
}
|
||||
fn is_met_by_rustls(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Number(number) => matches!(number.as_i64(), Some(-2 | 769 | 770 | 771)),
|
||||
Value::String(text) => matches!(
|
||||
|
|
@ -192,48 +273,36 @@ pub(crate) fn is_met_by_rustls(kwarg: &str, value: &Value) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
// redis-py parameters whose value is a Python object rather than plain data. credential_provider is modeled as
|
||||
// Auth::Provider instead
|
||||
const HOST_OBJECTS: &[&str] = &[
|
||||
"address_remap",
|
||||
"cache",
|
||||
"cache_config",
|
||||
"connection_class",
|
||||
"connection_pool",
|
||||
"driver_info",
|
||||
"event_dispatcher",
|
||||
"maint_notifications_config",
|
||||
"parser_class",
|
||||
"redis_connect_func",
|
||||
"retry",
|
||||
"retry_on_error",
|
||||
"ssl_ocsp_context",
|
||||
];
|
||||
|
||||
pub(crate) fn is_host_object(kwarg: &str) -> bool {
|
||||
HOST_OBJECTS.contains(&kwarg)
|
||||
}
|
||||
|
||||
/// What ignoring a warned setting means for the connection, so the operator reads the consequence
|
||||
/// rather than only the key name.
|
||||
fn consequence(kwarg: &str) -> &'static str {
|
||||
match kwarg {
|
||||
"ssl_ocsp_context" | "ssl_ocsp_expected_cert" => {
|
||||
"it only configures ssl_validate_ocsp, which the Rust client refuses when set"
|
||||
}
|
||||
"ssl_ciphers" | "ssl_min_version" => {
|
||||
"rustls negotiates TLS 1.2+ with its own ECDHE AEAD suites"
|
||||
}
|
||||
"credential_provider" | "redis_connect_func" => {
|
||||
"the Python object is not callable from Rust, so static username and password are used"
|
||||
}
|
||||
"connection_pool" | "retry" | "event_dispatcher" => {
|
||||
"the Rust client uses its own pool, retries and events"
|
||||
}
|
||||
_ => "the Rust client has no equivalent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setting(kwarg: &str) -> Option<&'static Setting> {
|
||||
SETTINGS.iter().find(|setting| setting.kwarg == kwarg)
|
||||
}
|
||||
|
||||
pub(crate) fn is_refused_when_set(kwarg: &str) -> bool {
|
||||
setting(kwarg).is_some_and(|setting| matches!(setting.handling, Handling::Reject))
|
||||
}
|
||||
|
||||
/// A warned setting the kwargs set, with what dropping it means. A kwarg with no row is not a Redis setting, so it
|
||||
/// passes through without a warning.
|
||||
pub(crate) fn ignored(source: &str, kwarg: &str, value: &Value) -> Option<Ignored> {
|
||||
let consequence = setting(kwarg)?.warning(value)?;
|
||||
Some(Ignored {
|
||||
setting: source.to_owned(),
|
||||
consequence,
|
||||
})
|
||||
}
|
||||
|
||||
/// A url query parameter the resolver does not read. redis-py turns every one into a client kwarg, so even one with
|
||||
/// no row is reported.
|
||||
pub(crate) fn ignored_query(source: &str, name: &str, value: &Value) -> Option<Ignored> {
|
||||
let consequence = match setting(name) {
|
||||
None => NO_EQUIVALENT,
|
||||
Some(setting) if setting.is_read() => {
|
||||
"the Rust client does not read it from this url's query"
|
||||
}
|
||||
Some(setting) => setting.warning(value)?,
|
||||
};
|
||||
Some(Ignored {
|
||||
setting: source.to_owned(),
|
||||
consequence,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ mod options;
|
|||
mod tls;
|
||||
|
||||
pub use error::{Expected, RedisError};
|
||||
pub use keys::{Handling, SETTINGS, Setting, setting};
|
||||
pub use keys::{FromUrl, Handling, SETTINGS, Setting, Shape, setting};
|
||||
pub use options::is_present;
|
||||
|
||||
use options::Options;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use serde_json::Value;
|
|||
|
||||
use crate::{
|
||||
Entry, Expected, Kwargs, RedisError,
|
||||
keys::{self, Handling},
|
||||
keys::{self, Shape},
|
||||
};
|
||||
|
||||
/// Every setting the resolver reads. A setting is `None` when it is unset, null or empty.
|
||||
|
|
@ -84,10 +84,11 @@ impl Unmodeled {
|
|||
let unmodeled = || {
|
||||
kwargs
|
||||
.iter()
|
||||
.filter(|(key, _)| handling(key) != Some(Handling::Read))
|
||||
.filter(|(key, _)| !keys::setting(key).is_some_and(keys::Setting::is_read))
|
||||
};
|
||||
let (objects, plain): (Vec<_>, Vec<_>) =
|
||||
unmodeled().partition(|(key, _)| keys::is_host_object(key));
|
||||
let (objects, plain): (Vec<_>, Vec<_>) = unmodeled().partition(|(key, _)| {
|
||||
keys::setting(key).is_some_and(|setting| setting.shape == Shape::Object)
|
||||
});
|
||||
let passthrough = plain
|
||||
.into_iter()
|
||||
.map(|(key, entry)| (key.clone(), entry.value.clone()))
|
||||
|
|
@ -96,11 +97,7 @@ impl Unmodeled {
|
|||
passthrough: Passthrough::new(passthrough),
|
||||
host_objects: objects.into_iter().map(|(key, _)| key.clone()).collect(),
|
||||
ignored: unmodeled()
|
||||
.filter(|(key, entry)| {
|
||||
handling(key) == Some(Handling::Warn)
|
||||
&& !keys::is_met_by_rustls(key, &entry.value)
|
||||
})
|
||||
.map(|(key, entry)| keys::ignored(entry.source.clone(), key))
|
||||
.filter_map(|(key, entry)| keys::ignored(&entry.source, key, &entry.value))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
|
@ -110,13 +107,17 @@ impl Options {
|
|||
/// The typed settings and the ones the resolver leaves to the host.
|
||||
pub(super) fn read(kwargs: &Kwargs) -> Result<(Self, Unmodeled), RedisError> {
|
||||
let unmodeled = Unmodeled::read(kwargs)?;
|
||||
let get = |key: &str| {
|
||||
let options = Self::from_lookup(|key| {
|
||||
kwargs
|
||||
.get(key)
|
||||
.filter(|entry| is_present(&entry.value))
|
||||
.cloned()
|
||||
};
|
||||
let options = Self {
|
||||
});
|
||||
Ok((options, unmodeled))
|
||||
}
|
||||
|
||||
fn from_lookup(get: impl Fn(&'static str) -> Option<Entry>) -> Self {
|
||||
Self {
|
||||
url: get("url"),
|
||||
host: get("host"),
|
||||
port: get("port"),
|
||||
|
|
@ -163,8 +164,7 @@ impl Options {
|
|||
socket_timeout: get("socket_timeout"),
|
||||
socket_connect_timeout: get("socket_connect_timeout"),
|
||||
health_check_interval: get("health_check_interval"),
|
||||
};
|
||||
Ok((options, unmodeled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,10 +183,6 @@ pub(super) fn refuse_unenforceable<'a>(
|
|||
})
|
||||
}
|
||||
|
||||
fn handling(key: &str) -> Option<Handling> {
|
||||
keys::setting(key).map(|setting| setting.handling)
|
||||
}
|
||||
|
||||
// Python reads most of these with `x or fallback`, so an empty value counts as unset everywhere
|
||||
pub fn is_present(value: &Value) -> bool {
|
||||
match value {
|
||||
|
|
@ -283,3 +279,30 @@ fn coerced<N, T>(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
|
||||
use rstest::rstest;
|
||||
|
||||
use super::Options;
|
||||
use crate::SETTINGS;
|
||||
|
||||
// a Read row that Options never asks for would be neither modeled nor passed through nor warned about
|
||||
#[rstest]
|
||||
fn options_ask_for_exactly_the_read_rows() {
|
||||
let asked = RefCell::new(BTreeSet::new());
|
||||
let _ = Options::from_lookup(|kwarg| {
|
||||
asked.borrow_mut().insert(kwarg);
|
||||
None
|
||||
});
|
||||
|
||||
let read: BTreeSet<&str> = SETTINGS
|
||||
.iter()
|
||||
.filter(|setting| setting.is_read())
|
||||
.map(|setting| setting.kwarg)
|
||||
.collect();
|
||||
assert_eq!(asked.into_inner(), read);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,25 +5,11 @@ use litellm_secret_value::SecretValue;
|
|||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
Entry, Expected, RedisError, keys,
|
||||
Entry, Expected, RedisError,
|
||||
keys::{self, FromUrl},
|
||||
options::{self, TlsInput, flag, parsed, secret, text},
|
||||
};
|
||||
|
||||
// redis-py's parse_url turns every query parameter into a client kwarg. Only these and, on a rediss:// url, the
|
||||
// TLS ones have a redis-rs equivalent, so any other parameter is ignored
|
||||
const URL_CONNECTION_SETTINGS: &[&str] = &["db", "username", "password"];
|
||||
|
||||
const URL_TLS_SETTINGS: &[&str] = &[
|
||||
"ssl_cert_reqs",
|
||||
"ssl_ca_certs",
|
||||
"ssl_ca_data",
|
||||
"ssl_ca_path",
|
||||
"ssl_certfile",
|
||||
"ssl_keyfile",
|
||||
"ssl_password",
|
||||
"ssl_check_hostname",
|
||||
];
|
||||
|
||||
/// `gcp_ca_certs` replaces `ssl_ca_certs` when GCP IAM is on, as `_get_redis_client_logic` does.
|
||||
pub(super) fn tls_options(
|
||||
input: &TlsInput,
|
||||
|
|
@ -85,8 +71,11 @@ pub(super) fn url_query(url: &str) -> Result<UrlQuery, RedisError> {
|
|||
.collect();
|
||||
options::refuse_unenforceable(query.iter().map(|(name, entry)| (name, entry)))?;
|
||||
let (query, ignored): (Vec<_>, Vec<_>) = query.into_iter().partition(|(name, _)| {
|
||||
URL_CONNECTION_SETTINGS.contains(&name.as_str())
|
||||
|| (tls_scheme && URL_TLS_SETTINGS.contains(&name.as_str()))
|
||||
keys::setting(name).is_some_and(|setting| match setting.from_url {
|
||||
FromUrl::Any => true,
|
||||
FromUrl::Rediss => tls_scheme,
|
||||
FromUrl::No => false,
|
||||
})
|
||||
});
|
||||
let get = |key: &str| {
|
||||
query
|
||||
|
|
@ -118,9 +107,8 @@ pub(super) fn url_query(url: &str) -> Result<UrlQuery, RedisError> {
|
|||
username: secret(get("username").as_ref()),
|
||||
password: secret(get("password").as_ref()),
|
||||
ignored: ignored
|
||||
.into_iter()
|
||||
.filter(|(name, _)| !keys::is_refused_when_set(name))
|
||||
.map(|(name, entry)| keys::ignored(entry.source, &name))
|
||||
.iter()
|
||||
.filter_map(|(name, entry)| keys::ignored_query(&entry.source, name, &entry.value))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
94
litellm-rust/crates/redis-resolve/tests/catalog.rs
Normal file
94
litellm-rust/crates/redis-resolve/tests/catalog.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use litellm_redis_resolve::{Handling, RedisError, SETTINGS, Shape, interpret, kwargs};
|
||||
use rstest::rstest;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Catalog {
|
||||
args: BTreeMap<String, Arg>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Arg {
|
||||
environment: bool,
|
||||
}
|
||||
|
||||
fn catalog() -> Catalog {
|
||||
serde_json::from_str(include_str!("../redis-py-kwargs.json")).unwrap()
|
||||
}
|
||||
|
||||
fn host_with(kwarg: &str, value: serde_json::Value) -> litellm_redis_resolve::Kwargs {
|
||||
kwargs([
|
||||
("host".to_owned(), json!("redis")),
|
||||
(kwarg.to_owned(), value),
|
||||
])
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn every_catalogued_kwarg_has_exactly_one_settings_row() {
|
||||
let rows: Vec<&str> = SETTINGS.iter().map(|setting| setting.kwarg).collect();
|
||||
let unique: BTreeSet<&str> = rows.iter().copied().collect();
|
||||
assert_eq!(rows.len(), unique.len(), "a kwarg has two SETTINGS rows");
|
||||
|
||||
let catalog = catalog();
|
||||
let catalogued: BTreeSet<&str> = catalog.args.keys().map(String::as_str).collect();
|
||||
assert_eq!(unique, catalogued);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn settings_read_redis_environment_names_exactly_where_python_does() {
|
||||
let catalog = catalog();
|
||||
|
||||
let differing: Vec<&str> = SETTINGS
|
||||
.iter()
|
||||
.filter(|setting| {
|
||||
catalog
|
||||
.args
|
||||
.get(setting.kwarg)
|
||||
.is_some_and(|arg| arg.environment != setting.environment)
|
||||
})
|
||||
.map(|setting| setting.kwarg)
|
||||
.collect();
|
||||
assert!(differing.is_empty(), "{differing:?}");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn every_warned_setting_is_reported_and_still_reaches_the_host() {
|
||||
let warned = SETTINGS
|
||||
.iter()
|
||||
.filter(|setting| matches!(setting.handling, Handling::Warn { met: None, .. }));
|
||||
|
||||
for setting in warned {
|
||||
let resolved = interpret(&host_with(setting.kwarg, json!("set")), None).unwrap();
|
||||
|
||||
let reported: Vec<&str> = resolved
|
||||
.ignored
|
||||
.iter()
|
||||
.map(|ignored| ignored.setting.as_str())
|
||||
.collect();
|
||||
assert_eq!(reported, vec![setting.kwarg]);
|
||||
let reached = match setting.shape {
|
||||
Shape::Plain => resolved.passthrough.get(setting.kwarg) == Some(&json!("set")),
|
||||
Shape::Object => resolved.host_objects.contains(setting.kwarg),
|
||||
};
|
||||
assert!(reached, "{} did not reach the host", setting.kwarg);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn every_rejected_setting_fails_when_asked_for() {
|
||||
let rejected = SETTINGS
|
||||
.iter()
|
||||
.filter(|setting| matches!(setting.handling, Handling::Reject));
|
||||
|
||||
for setting in rejected {
|
||||
let error = interpret(&host_with(setting.kwarg, json!(true)), None).unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(&error, RedisError::Unenforceable { setting: source } if source == setting.kwarg),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import inspect
|
|||
import json
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -3249,3 +3250,46 @@ def test_redis_settings_resolve_like_the_rust_resolver_expects(monkeypatch, kwar
|
|||
outcome = {"rejected": {"type": type(error).__name__, "message": str(error)}}
|
||||
|
||||
assert outcome == expected
|
||||
|
||||
|
||||
_REDIS_PY_KWARGS: Final = json.loads(
|
||||
(Path(__file__).parents[2] / "litellm-rust/crates/redis-resolve/redis-py-kwargs.json").read_text()
|
||||
)
|
||||
|
||||
|
||||
def _named_args(*classes: type) -> frozenset[str]:
|
||||
return frozenset(
|
||||
name
|
||||
for cls in classes
|
||||
for spec in (inspect.getfullargspec(inspect.unwrap(cls.__init__)),)
|
||||
for name in spec.args + spec.kwonlyargs
|
||||
if name != "self"
|
||||
)
|
||||
|
||||
|
||||
def test_redis_py_kwargs_catalog_matches_the_installed_redis_py():
|
||||
assert redis.__version__ in _REDIS_PY_KWARGS["meta"]["redis_py_versions"]
|
||||
signatures: Final = {
|
||||
"sync": _named_args(redis.Redis),
|
||||
"async": _named_args(async_redis.Redis),
|
||||
"connection": _named_args(
|
||||
*(cls for cls in (*redis.Connection.__mro__, *async_redis.Connection.__mro__) if cls is not object)
|
||||
),
|
||||
"cluster": _named_args(async_redis.RedisCluster),
|
||||
}
|
||||
args: Final = _REDIS_PY_KWARGS["args"]
|
||||
names: Final = frozenset(args).union(*signatures.values())
|
||||
|
||||
catalogued: Final = {name: args.get(name, {}).get("redis_py", {}).get(redis.__version__, []) for name in names}
|
||||
installed: Final = {name: [kind for kind, named in signatures.items() if name in named] for name in names}
|
||||
assert catalogued == installed
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
redis.__version__ != _REDIS_PY_KWARGS["meta"]["redis_py_locked"],
|
||||
reason="the catalog records REDIS_* names for the locked redis-py only",
|
||||
)
|
||||
def test_redis_py_kwargs_catalog_names_the_environment_kwargs_python_reads():
|
||||
catalogued: Final = frozenset(name for name, arg in _REDIS_PY_KWARGS["args"].items() if arg["environment"])
|
||||
|
||||
assert catalogued == frozenset(_get_redis_env_kwarg_mapping().values())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue