diff --git a/CLAUDE.md b/CLAUDE.md index e748f4c..cd97d2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -863,6 +863,23 @@ class MldpConfig(BaseSettings): ) ``` +**YAML values enter through their own settings source, never as init kwargs** (#19; +`plan/tickets/19/plan.md`). `from_yaml()` flattens the file into a module-level `ContextVar`, calls +`cls()`, and resets the var in `finally`; the private `_YamlValuesSource` reads it, and +`settings_customise_sources` ranks it last (`init, env, dotenv, secrets, yaml`), so it sits just above +field defaults. Init kwargs are pydantic-settings' *top* priority, so passing file values as +`cls(**values)` — what `from_yaml()` did through 1.16.0 — makes the file silently beat every `MLDP_*` +variable. Validation runs on the merged result, so an invalid YAML value that an env var overrides +loads without error. `load_config(config_object=)` returns the object as-is: explicit is level 1. +`env_ignore_empty=True` makes an empty `MLDP_*` variable count as unset; once env outranked the file, +an empty variable (a compose `${VAR}` with its source unset) would otherwise have blanked a host the +file set. `tests/unit/test_config.py::TestConfigPrecedence` pins all of this against real files. + +Any test that asserts a value from a YAML file or a default must clear ambient `MLDP_*` variables, +since they now win: use `isolate_mldp_env(self)` in `setUp` or the `mldp_env()` context manager, both +in `tests/unit/mldp_env.py`. Otherwise the test passes in CI and fails in a developer shell that +exports `MLDP_*` to point integration tests elsewhere. + ### Key Configuration Classes - **`ServiceConfig`** - Individual service configuration (host, port, use_tls) with gRPC channel creation - **`MldpConfig`** - Main config container with flattened fields for environment variable support diff --git a/doc/cookbook/connecting.md b/doc/cookbook/connecting.md index 8185645..e76fa0e 100644 --- a/doc/cookbook/connecting.md +++ b/doc/cookbook/connecting.md @@ -11,7 +11,7 @@ See [API conventions](conventions.md) for the patterns every call shares once yo - [Model](#model) — one client, three services, three channels - [Configuration files](#configuration-files) - [Environment variables](#environment-variables) -- [Configuration priority](#configuration-priority) — **and a bug to be aware of** +- [Configuration priority](#configuration-priority) - [Sub-clients can be None](#sub-clients-can-be-none) - [TLS](#tls) - [Logging](#logging) @@ -143,41 +143,30 @@ export MLDP_CONFIG_FILE=/etc/mldp/config.yaml Names are case-insensitive. `USE_TLS` accepts the usual boolean spellings (`true`/`false`, `1`/`0`). +A variable set to the empty string counts as unset: `MLDP_INGESTION_HOST=` falls through to the +YAML file or the default rather than blanking the host. This matters in docker compose, where +`MLDP_INGESTION_HOST: ${INGESTION_HOST}` passes an empty string when `INGESTION_HOST` is not set. + ## Configuration priority -The intended order, highest first: +The order, highest first: 1. Explicit constructor parameters (channels, `config=`) 2. Environment variables (`MLDP_*`) 3. The YAML configuration file 4. Built-in defaults -> ### ⚠️ Known bug: YAML silently beats environment variables -> -> **As of 1.15.0, levels 2 and 3 are inverted whenever the key is present in the YAML file.** -> Tracked as [#19](https://github.com/osprey-dcs/dp-python-lib/issues/19). -> -> A setting written in YAML **cannot be overridden** by its `MLDP_*` environment variable. The -> env var is ignored, silently — no warning, no error: -> -> ``` -> # mldp-config.yaml contains: ingestion: {host: localhost} -> MLDP_INGESTION_HOST=prod.example.com -> resolves to "localhost" (env ignored) -> MLDP_INGESTION_PORT=443 -> resolves to 443 (works: port absent from YAML) -> ``` -> -> The rule is per-key: a key **absent** from the YAML file *is* overridable by its env var; a key -> **present** in the file is not. -> -> Cause: `MldpConfig.from_yaml()` passes YAML values as constructor keyword arguments, and in -> pydantic-settings init kwargs outrank environment variables. -> -> **Until this is fixed**, do not rely on env vars to override a deployed YAML file. Either keep -> the setting out of the YAML entirely, or point at a different file with `MLDP_CONFIG_FILE` (that -> variable is read before the file is loaded, so it works as documented). - -This also applies to the auto-load path, since a `mldp-config.yaml` in the working directory is -picked up automatically — which is how the surprise usually arrives. +The order is per key: a YAML file can set the host while `MLDP_INGESTION_PORT` sets the port, and +any key neither one sets takes its default. + +Level 1 applies only to the fields you actually passed. `MldpConfig(ingestion_host="x")` pins the +ingestion host, but the fields it leaves out were filled from `MLDP_*` variables or defaults when the +object was built, exactly as for any other `MldpConfig()`. + +An auto-discovered `mldp-config.yaml` is still level 3, so environment variables override it just +as they would an explicit `config_file=`. Releases before the fix for +[#19](https://github.com/osprey-dcs/dp-python-lib/issues/19) (1.16.0 and earlier) got this wrong: +a key present in the YAML file silently ignored its `MLDP_*` variable. ## Sub-clients can be None diff --git a/doc/release-notes/NEXT.md b/doc/release-notes/NEXT.md index 920f16d..dfaf6df 100644 --- a/doc/release-notes/NEXT.md +++ b/doc/release-notes/NEXT.md @@ -28,6 +28,7 @@ person cutting the release has any reason to re-read. - [Release pages are the notes file, verbatim (#56)](#release-pages-are-the-notes-file-verbatim-issue-56) - [Type checking in CI (#30)](#type-checking-in-ci-issue-30) - [Ready for typed gRPC stubs (#61)](#ready-for-typed-grpc-stubs-issue-61) +- [Environment variables override the config file (#19)](#environment-variables-override-the-config-file-issue-19) - [Cutting the release](#cutting-the-release) --- @@ -84,6 +85,35 @@ Nothing about the library's behavior changes. but no `.pyi`. - **The `[dev]` extra now includes `types-grpcio`**, so `grpc` is type-checked rather than ignored. +## Environment variables override the config file (Issue #19) + +**Silent behavior change.** An `MLDP_*` environment variable now overrides the same setting in the +YAML configuration file, as the documentation has always said it would. Through 1.16.0 the file +won whenever it contained the key, and the environment variable was ignored without a warning. + +Check before upgrading: **if you set an `MLDP_*` variable and your config file sets a *different* +value for the same key, the client will now connect using the environment variable's value.** No +error is raised; the connection simply goes somewhere else. This includes the `mldp-config.yaml` +that is discovered automatically in the working directory or project root. To keep the old +behavior, unset the variable. + +Two smaller consequences of the same fix: + +- **An invalid value in the file no longer raises if an environment variable overrides it** + (`port: abc` with `MLDP_INGESTION_PORT=443` now loads, using 443). An invalid value that is + actually used still raises the same `ValueError`. +- **An `MLDP_*` variable set to the empty string now counts as unset**, falling through to the file + or the default. Previously an empty `MLDP_INGESTION_HOST=` was lost to the file when the file set + the key, but blanked the host when it did not, and an empty port or `use_tls` the file did not set + raised. Without this, the fix above would have let an empty variable, for example a docker + compose `${VAR}` whose source is unset, override a working file with a blank host. +- **`load_config(config_object=...)` returns the object you passed** rather than a copy with the + same values. An explicit object was already level 1, above environment variables; only its + identity changes. + +See [#19](https://github.com/osprey-dcs/dp-python-lib/issues/19) and the configuration priority +section of [`doc/cookbook/connecting.md`](https://github.com/osprey-dcs/dp-python-lib/blob/main/doc/cookbook/connecting.md#configuration-priority). + ## Installing ```bash diff --git a/plan/tickets/19/plan.md b/plan/tickets/19/plan.md index 370fbc1..062bd40 100644 --- a/plan/tickets/19/plan.md +++ b/plan/tickets/19/plan.md @@ -195,8 +195,13 @@ to two small modules, and the prototype below shows it works. separate usability issue; file it if wanted. - `.env` / secrets-dir support: not configured today, and the source order leaves room for it. - Any change to discovery order (`find_config_file`), which already behaves as documented. -- Isolating the existing `test_config.py` tests from ambient `MLDP_*` variables (T6). They predate this - ticket and pass on a clean shell and in CI; the new tests clear the environment themselves. +- ~~Isolating the existing `test_config.py` tests from ambient `MLDP_*` variables (T6). They predate this + ticket and pass on a clean shell and in CI; the new tests clear the environment themselves.~~ + **Brought into scope 2026-09-24, in review of PR #63.** The premise was incomplete: the fix itself + exposes a test that the YAML file used to shield (`test_from_yaml_valid`), so with `MLDP_*` exported the + suite went from 8 failures on `main` to 9. All three config test classes now clear ambient variables in + `setUp` via `tests/unit/mldp_env.py`. The same review added `env_ignore_empty=True`: once env outranks + the file, an empty `MLDP_*` variable would otherwise beat a working file with a blank host. ## Dependencies and sequencing diff --git a/src/dp_python_lib/config/config.py b/src/dp_python_lib/config/config.py index 475aaa6..fe2d337 100644 --- a/src/dp_python_lib/config/config.py +++ b/src/dp_python_lib/config/config.py @@ -1,8 +1,28 @@ import logging +from contextvars import ContextVar +from typing import Any import grpc from pydantic import BaseModel -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic.fields import FieldInfo +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict + +# The flattened values of the YAML file being loaded by MldpConfig.from_yaml(), read by +# _YamlValuesSource. A ContextVar rather than a class attribute so concurrent loads in other +# threads or asyncio tasks cannot see each other's values; from_yaml() resets it in a +# `finally`, so a failed load cannot leak file values into a later plain MldpConfig(). +_yaml_values: ContextVar[dict[str, Any] | None] = ContextVar("_yaml_values", default=None) + + +class _YamlValuesSource(PydanticBaseSettingsSource): + """Settings source supplying the YAML file's values, ranked below environment variables (issue #19).""" + + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + # Required by the ABC; __call__ supplies every value at once, so this is never used. + return None, field_name, False + + def __call__(self) -> dict[str, Any]: + return dict(_yaml_values.get() or {}) class ServiceConfig(BaseModel): @@ -47,7 +67,26 @@ class MldpConfig(BaseSettings): annotation_port: int = 50053 annotation_use_tls: bool = False - model_config = SettingsConfigDict(env_prefix="MLDP_", case_sensitive=False) + # env_ignore_empty: an empty MLDP_* variable counts as unset, so it falls through to the YAML file or + # the default. Without it, `export MLDP_INGESTION_HOST=` (or a compose `${VAR}` that expands to "") + # would beat the file with an empty host and connect nowhere, or fail to parse as a port. + model_config = SettingsConfigDict(env_prefix="MLDP_", case_sensitive=False, env_ignore_empty=True) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + # Priority, high to low: explicit constructor arguments, MLDP_* environment variables, + # the YAML file, field defaults. This is pydantic-settings' default order with the YAML + # source added last. YAML values must never be passed as constructor arguments: init + # kwargs outrank every other source, which is how the file came to silently beat + # MLDP_* variables before issue #19. + return (init_settings, env_settings, dotenv_settings, file_secret_settings, _YamlValuesSource(settings_cls)) @property def ingestion(self) -> ServiceConfig: @@ -66,7 +105,11 @@ def annotation(self) -> ServiceConfig: @classmethod def from_yaml(cls, yaml_file: str) -> "MldpConfig": - """Load configuration from YAML file.""" + """Load configuration from YAML file. + + ``MLDP_*`` environment variables override values from the file; keys the file leaves + out fall back to the environment, then to the field defaults. + """ import yaml logger = logging.getLogger(__name__) @@ -86,7 +129,7 @@ def from_yaml(cls, yaml_file: str) -> "MldpConfig": raise ValueError(f"expected a mapping at the top level, got {type(data).__name__}") # Convert nested YAML structure to flat fields - flat_data = {} + flat_data: dict[str, Any] = {} for service in ["ingestion", "query", "annotation"]: service_config = data.get(service) @@ -104,7 +147,11 @@ def from_yaml(cls, yaml_file: str) -> "MldpConfig": logger.debug("Loaded %s_use_tls: %s", service, service_config["use_tls"]) logger.debug("Successfully loaded configuration from YAML, creating MldpConfig instance") - return cls(**flat_data) + token = _yaml_values.set(flat_data) + try: + return cls() + finally: + _yaml_values.reset(token) except FileNotFoundError: logger.warning("YAML configuration file not found: %s, using defaults", yaml_file) diff --git a/src/dp_python_lib/config/loader.py b/src/dp_python_lib/config/loader.py index fb4e6ef..8e60b32 100644 --- a/src/dp_python_lib/config/loader.py +++ b/src/dp_python_lib/config/loader.py @@ -82,21 +82,12 @@ def load_config(config_file: str | None = None, config_object: MldpConfig | None config_object is not None, ) - # If explicit config object provided, use it (but still allow env var overrides) - if config_object: - logger.info("Using explicit config object with environment variable overrides") - # Create a new instance that will pick up environment variables - return MldpConfig( - ingestion_host=config_object.ingestion.host, - ingestion_port=config_object.ingestion.port, - ingestion_use_tls=config_object.ingestion.use_tls, - query_host=config_object.query.host, - query_port=config_object.query.port, - query_use_tls=config_object.query.use_tls, - annotation_host=config_object.annotation.host, - annotation_port=config_object.annotation.port, - annotation_use_tls=config_object.annotation.use_tls, - ) + # An explicit config object is level 1: returned as-is, so environment variables cannot + # override the fields its caller set. Fields the caller left out already took env values + # or defaults when the object was built. + if config_object is not None: + logger.info("Using explicit config object") + return config_object # Find and load from YAML file (if available) yaml_file = find_config_file(config_file) diff --git a/tests/unit/mldp_env.py b/tests/unit/mldp_env.py new file mode 100644 index 0000000..b15d373 --- /dev/null +++ b/tests/unit/mldp_env.py @@ -0,0 +1,36 @@ +""" +Test support for isolating configuration tests from ambient ``MLDP_*`` environment variables. + +A developer shell may export ``MLDP_*`` (to point integration tests at a remote ecosystem, say). Since issue #19 +those variables override the YAML file, so any test asserting a value from a file or a default must run with them +removed, or it passes in CI and fails on that developer's machine. +""" + +import contextlib +import os +import unittest +from collections.abc import Iterator +from unittest.mock import patch + + +def _without_mldp() -> dict[str, str]: + return {k: v for k, v in os.environ.items() if not k.upper().startswith("MLDP_")} + + +@contextlib.contextmanager +def mldp_env(**overrides: str) -> Iterator[None]: + """Run with every ambient ``MLDP_*`` variable removed, plus ``overrides``.""" + env = _without_mldp() + env.update(overrides) + with patch.dict(os.environ, env, clear=True): + yield + + +def isolate_mldp_env(test: unittest.TestCase) -> None: + """From a ``setUp``: remove every ambient ``MLDP_*`` variable for the duration of the test. + + A test's own ``@patch.dict(os.environ, {...})`` still applies on top, since it is entered after ``setUp``. + """ + patcher = patch.dict(os.environ, _without_mldp(), clear=True) + patcher.start() + test.addCleanup(patcher.stop) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d117920..00e7228 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -8,6 +8,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../src")) +from mldp_env import isolate_mldp_env, mldp_env + from dp_python_lib.config import MldpConfig, ServiceConfig, load_config from dp_python_lib.config.loader import find_config_file, get_default_config @@ -65,6 +67,9 @@ def test_create_channel_secure(self, mock_secure_channel, mock_ssl_creds): class TestMldpConfig(unittest.TestCase): + def setUp(self): + isolate_mldp_env(self) + def test_mldp_config_defaults(self): """Test MldpConfig with default values.""" config = MldpConfig() @@ -253,6 +258,9 @@ def test_create_channels(self, mock_create_channel): class TestConfigLoader(unittest.TestCase): + def setUp(self): + isolate_mldp_env(self) + def test_find_config_file_explicit(self): """Test finding config file when explicitly provided.""" with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as f: @@ -300,20 +308,6 @@ def test_load_config_with_explicit_object(self): self.assertEqual(result.ingestion.host, "custom-host") - @patch("dp_python_lib.config.loader.find_config_file") - @patch("dp_python_lib.config.config.MldpConfig.from_yaml") - def test_load_config_from_yaml(self, mock_from_yaml, mock_find_config): - """Test loading config from YAML file.""" - mock_config = MldpConfig() - mock_find_config.return_value = "test-config.yaml" - mock_from_yaml.return_value = mock_config - - result = load_config() - - mock_find_config.assert_called_once_with(None) - mock_from_yaml.assert_called_once_with("test-config.yaml") - self.assertEqual(result, mock_config) - @patch("dp_python_lib.config.loader.find_config_file") def test_load_config_no_yaml_file(self, mock_find_config): """Test loading config when no YAML file found.""" @@ -334,5 +328,123 @@ def test_get_default_config(self): self.assertEqual(config.ingestion.port, 50051) +YAML_ALL_INGESTION = """ +ingestion: + host: yaml-host + port: 9001 + use_tls: false +""" + + +class TestConfigPrecedence(unittest.TestCase): + """Explicit > MLDP_* env > YAML file > defaults, driven through real files (issue #19).""" + + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmpdir.cleanup) + + def write_yaml(self, content: str) -> str: + path = os.path.join(self._tmpdir.name, "mldp-config.yaml") + with open(path, "w") as f: + f.write(content) + return path + + def test_env_overrides_yaml_via_load_config(self): + path = self.write_yaml(YAML_ALL_INGESTION) + with mldp_env(MLDP_INGESTION_HOST="env-host"): + config = load_config(config_file=path) + self.assertEqual(config.ingestion.host, "env-host") + # keys with no env var keep their YAML values + self.assertEqual(config.ingestion.port, 9001) + + def test_env_overrides_yaml_via_from_yaml(self): + path = self.write_yaml(YAML_ALL_INGESTION) + with mldp_env(MLDP_INGESTION_HOST="env-host"): + config = MldpConfig.from_yaml(path) + self.assertEqual(config.ingestion.host, "env-host") + self.assertEqual(config.ingestion.port, 9001) + + def test_yaml_overrides_defaults(self): + path = self.write_yaml(YAML_ALL_INGESTION) + with mldp_env(): + for config in (load_config(config_file=path), MldpConfig.from_yaml(path)): + self.assertEqual(config.ingestion.host, "yaml-host") + self.assertEqual(config.ingestion.port, 9001) + + def test_key_absent_from_yaml_falls_back_to_default_or_env(self): + path = self.write_yaml("ingestion:\n host: yaml-host\n") + with mldp_env(MLDP_QUERY_HOST="env-query"): + for config in (load_config(config_file=path), MldpConfig.from_yaml(path)): + self.assertEqual(config.ingestion.port, 50051) # default + self.assertEqual(config.query.host, "env-query") # env, key not in YAML + + def test_env_coerces_int_and_bool_over_yaml(self): + path = self.write_yaml(YAML_ALL_INGESTION) + with mldp_env(MLDP_INGESTION_PORT="443", MLDP_INGESTION_USE_TLS="true"): + for config in (load_config(config_file=path), MldpConfig.from_yaml(path)): + self.assertEqual(config.ingestion.port, 443) + self.assertTrue(config.ingestion.use_tls) + + def test_lower_case_env_var_overrides_yaml(self): + path = self.write_yaml(YAML_ALL_INGESTION) + with mldp_env(mldp_ingestion_host="env-host"): + self.assertEqual(MldpConfig.from_yaml(path).ingestion.host, "env-host") + + def test_mldp_config_file_with_env_override(self): + path = self.write_yaml(YAML_ALL_INGESTION) + with mldp_env(MLDP_CONFIG_FILE=path, MLDP_INGESTION_HOST="env-host"): + config = load_config() + self.assertEqual(config.ingestion.host, "env-host") + self.assertEqual(config.ingestion.port, 9001) # proves the file was the one selected + + def test_explicit_config_object_beats_env(self): + with mldp_env(MLDP_INGESTION_HOST="env-host"): + # Built inside the patched environment, so env was a candidate when it was constructed. + custom_config = MldpConfig(ingestion_host="explicit") + result = load_config(config_object=custom_config) + self.assertIs(result, custom_config) + self.assertEqual(result.ingestion.host, "explicit") + + def test_plain_config_after_failed_from_yaml_gets_defaults(self): + path = self.write_yaml("ingestion:\n host: yaml-host\n port: not-a-port\n") + with mldp_env(): + with self.assertRaises(ValueError): + MldpConfig.from_yaml(path) + config = MldpConfig() + # the failed load's values must not leak into a later plain construction + self.assertEqual(config.ingestion.host, "localhost") + self.assertEqual(config.ingestion.port, 50051) + + def test_plain_config_after_successful_from_yaml_gets_defaults(self): + path = self.write_yaml(YAML_ALL_INGESTION) + with mldp_env(): + MldpConfig.from_yaml(path) + self.assertEqual(MldpConfig().ingestion.host, "localhost") + + def test_invalid_yaml_value_overridden_by_env_loads(self): + path = self.write_yaml("ingestion:\n port: not-a-port\n") + with mldp_env(MLDP_INGESTION_PORT="443"): + config = MldpConfig.from_yaml(path) + self.assertEqual(config.ingestion.port, 443) + + def test_empty_env_var_falls_through_to_yaml(self): + path = self.write_yaml(YAML_ALL_INGESTION) + with mldp_env(MLDP_INGESTION_HOST="", MLDP_INGESTION_PORT="", MLDP_INGESTION_USE_TLS=""): + for config in (load_config(config_file=path), MldpConfig.from_yaml(path)): + self.assertEqual(config.ingestion.host, "yaml-host") + self.assertEqual(config.ingestion.port, 9001) + self.assertFalse(config.ingestion.use_tls) + + def test_empty_env_var_falls_through_to_default(self): + with mldp_env(MLDP_QUERY_HOST="", MLDP_QUERY_PORT=""): + config = MldpConfig() + self.assertEqual(config.query.host, "localhost") + self.assertEqual(config.query.port, 50052) + + def test_explicit_kwarg_beats_env_and_yaml_source(self): + with mldp_env(MLDP_INGESTION_HOST="env-host"): + self.assertEqual(MldpConfig(ingestion_host="explicit").ingestion.host, "explicit") + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_mldp_client.py b/tests/unit/test_mldp_client.py index 7fd50eb..ed08df6 100644 --- a/tests/unit/test_mldp_client.py +++ b/tests/unit/test_mldp_client.py @@ -7,6 +7,8 @@ # Add src directory to path for imports sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../src")) +from mldp_env import isolate_mldp_env, mldp_env + from dp_python_lib.client.mldp_client import MldpClient from dp_python_lib.config import MldpConfig @@ -194,6 +196,9 @@ def test_ingestion_client_creation(self, mock_ingestion_client_class): class TestMldpClientConfigIntegration(unittest.TestCase): """Integration tests for MldpClient with real configuration.""" + def setUp(self): + isolate_mldp_env(self) + def test_config_from_yaml_file(self): """Test loading MldpClient from YAML configuration.""" yaml_content = """ @@ -232,6 +237,28 @@ def test_config_from_yaml_file(self): finally: os.unlink(f.name) + def test_config_from_yaml_file_env_override(self): + """An MLDP_* variable overrides the same key in the YAML file, end to end (issue #19).""" + yaml_content = """ +ingestion: + host: yaml-ingestion.example.com + port: 9001 +""" + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "mldp-config.yaml") + with open(path, "w") as f: + f.write(yaml_content) + + with ( + mldp_env(MLDP_INGESTION_HOST="env-ingestion.example.com"), + patch("grpc.insecure_channel") as mock_insecure_channel, + ): + mock_insecure_channel.return_value = Mock() + + MldpClient(config_file=path) + + mock_insecure_channel.assert_any_call("env-ingestion.example.com:9001") + if __name__ == "__main__": unittest.main()