From a1e3276e10b76330047f759123e3ef39e9e465c3 Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Thu, 29 Feb 2024 21:35:19 +0000 Subject: [PATCH 01/12] fix: augment universe_domain handling This PR revisits the universe resolution for the BQ client, and handles new requirements like env-based specification and validation. --- google/cloud/bigquery/_helpers.py | 54 +++++++++++++++++++++++++++++ google/cloud/bigquery/client.py | 20 +++++++---- tests/unit/helpers.py | 13 +++++++ tests/unit/test__helpers.py | 57 ++++++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/google/cloud/bigquery/_helpers.py b/google/cloud/bigquery/_helpers.py index 905d4aee1..ce47dab00 100644 --- a/google/cloud/bigquery/_helpers.py +++ b/google/cloud/bigquery/_helpers.py @@ -30,6 +30,8 @@ from google.cloud._helpers import _RFC3339_MICROS from google.cloud._helpers import _RFC3339_NO_FRACTION from google.cloud._helpers import _to_bytes +from google.auth import credentials as ga_credentials +from google.api_core import client_options as client_options_lib _RFC3339_MICROS_NO_ZULU = "%Y-%m-%dT%H:%M:%S.%f" _TIMEONLY_WO_MICROS = "%H:%M:%S" @@ -55,9 +57,61 @@ _DEFAULT_HOST = "https://bigquery.googleapis.com" """Default host for JSON API.""" +_DEFAULT_HOST_TEMPLATE = "https://bigquery.{UNIVERSE_DOMAIN}" +""" Templatized endpoint format. """ + _DEFAULT_UNIVERSE = "googleapis.com" """Default universe for the JSON API.""" +_UNIVERSE_DOMAIN_ENV = "GOOGLE_CLOUD_UNIVERSE_DOMAIN" +"""Environment variable for setting universe domain.""" + + +def _get_client_universe(client_options: Optional[Union[client_options_lib.ClientOptions, dict]]) -> str: + """Retrieves the specified universe setting. + + Args: + client_options: specified client options. + Returns: + str: resolved universe setting. + + """ + if isinstance(client_options, dict): + client_options = client_options_lib.from_dict(client_options) + universe = _DEFAULT_UNIVERSE + if hasattr(client_options, "universe_domain"): + options_universe = getattr(client_options, "universe_domain") + if options_universe is not None: + universe = options_universe + else: + env_universe = os.getenv(_UNIVERSE_DOMAIN_ENV) + if isinstance(env_universe, str): + universe = env_universe + return universe + + +def _validate_universe(client_universe: str, credentials: ga_credentials.Credentials): + """Validates that client provided universe and universe embedded in credentials match. + + Args: + client_universe (str): The universe domain configured via the client options. + credentials (ga_credentials.Credentials): The credentials being used in the client. + + Raises: + ValueError: when client_universe does not match the universe in credentials. + """ + if hasattr(credentials, "universe_domain"): + cred_universe = getattr(credentials, "universe_domain") + if isinstance(cred_universe, str): + if client_universe != cred_universe: + raise ValueError( + "The configured universe domain " + f"({client_universe}) does not match the universe domain " + f"found in the credentials ({cred_universe}). " + "If you haven't configured the universe domain explicitly, " + f"`{_DEFAULT_UNIVERSE}` is the default." + ) + def _get_bigquery_host(): return os.environ.get(BIGQUERY_EMULATOR_HOST, _DEFAULT_HOST) diff --git a/google/cloud/bigquery/client.py b/google/cloud/bigquery/client.py index 4708e753b..6dc9e212e 100644 --- a/google/cloud/bigquery/client.py +++ b/google/cloud/bigquery/client.py @@ -78,7 +78,10 @@ from google.cloud.bigquery._helpers import _verify_job_config_type from google.cloud.bigquery._helpers import _get_bigquery_host from google.cloud.bigquery._helpers import _DEFAULT_HOST +from google.cloud.bigquery._helpers import _DEFAULT_HOST_TEMPLATE from google.cloud.bigquery._helpers import _DEFAULT_UNIVERSE +from google.cloud.bigquery._helpers import _validate_universe +from google.cloud.bigquery._helpers import _get_client_universe from google.cloud.bigquery._job_helpers import make_job_id as _make_job_id from google.cloud.bigquery.dataset import Dataset from google.cloud.bigquery.dataset import DatasetListItem @@ -245,6 +248,7 @@ def __init__( kw_args = {"client_info": client_info} bq_host = _get_bigquery_host() kw_args["api_endpoint"] = bq_host if bq_host != _DEFAULT_HOST else None + client_universe = None if client_options: if isinstance(client_options, dict): client_options = google.api_core.client_options.from_dict( @@ -253,16 +257,18 @@ def __init__( if client_options.api_endpoint: api_endpoint = client_options.api_endpoint kw_args["api_endpoint"] = api_endpoint - elif ( - hasattr(client_options, "universe_domain") - and client_options.universe_domain - and client_options.universe_domain is not _DEFAULT_UNIVERSE - ): - kw_args["api_endpoint"] = _DEFAULT_HOST.replace( - _DEFAULT_UNIVERSE, client_options.universe_domain + else: + client_universe = _get_client_universe(client_options) + if client_universe != _DEFAULT_UNIVERSE: + kw_args["api_endpoint"] = _DEFAULT_HOST_TEMPLATE.replace( + "{UNIVERSE_DOMAIN}", client_universe ) + # Ensure credentials and universe are not in conflict. + if hasattr(self, "_credentials") and client_universe is not None: + _validate_universe(client_universe, self._credentials) self._connection = Connection(self, **kw_args) + self._location = location self._default_load_job_config = copy.deepcopy(default_load_job_config) diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py index 67aeaca35..a61a31ca1 100644 --- a/tests/unit/helpers.py +++ b/tests/unit/helpers.py @@ -42,6 +42,19 @@ def make_client(project="PROJECT", **kw): credentials = mock.Mock(spec=google.auth.credentials.Credentials) return google.cloud.bigquery.client.Client(project, credentials, **kw) +def make_creds(creds_universe: None): + from google.auth import credentials + + class TestingCreds(credentials.Credentials): + + def refresh(self, request): + raise NotImplemented + + @property + def universe_domain(self): + return creds_universe + + return TestingCreds() def make_dataset_reference_string(project, ds_id): return f"{project}.{ds_id}" diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index 87ab46669..3b0e73fcc 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -17,10 +17,65 @@ import decimal import json import unittest - +import os import mock +class Test_get_client_universe(unittest.TestCase): + def test_with_none(self): + from google.cloud.bigquery._helpers import _get_client_universe + self.assertEqual("googleapis.com", _get_client_universe(None)) + + def test_with_dict(self): + from google.cloud.bigquery._helpers import _get_client_universe + options = {"universe_domain": "foo.com"} + self.assertEqual("foo.com", _get_client_universe(options)) + + def test_with_clientoptions(self): + from google.cloud.bigquery._helpers import _get_client_universe + from google.api_core import client_options + options = client_options.from_dict({"universe_domain": "foo.com"}) + self.assertEqual("foo.com", _get_client_universe(options)) + + @mock.patch.dict(os.environ, {'GOOGLE_CLOUD_UNIVERSE_DOMAIN': 'foo.com'}) + def test_with_environ(self): + from google.cloud.bigquery._helpers import _get_client_universe + self.assertEqual("foo.com", _get_client_universe(None)) + + + + +class Test_validate_universe(unittest.TestCase): + + + + def test_with_none(self): + from google.cloud.bigquery._helpers import _validate_universe + # should not raise + _validate_universe("googleapis.com", None) + + def test_with_no_universe_creds(self): + from google.cloud.bigquery._helpers import _validate_universe + from .helpers import make_creds + creds = make_creds(None) + # should not raise + _validate_universe("googleapis.com", creds) + + def test_with_matched_universe_creds(self): + from google.cloud.bigquery._helpers import _validate_universe + from .helpers import make_creds + creds = make_creds("googleapis.com") + # should not raise + _validate_universe("googleapis.com", creds) + + def test_with_mismatched_universe_creds(self): + from google.cloud.bigquery._helpers import _validate_universe + from .helpers import make_creds + creds = make_creds("foo.com") + with self.assertRaises(ValueError): + _validate_universe("googleapis.com", creds) + + class Test_not_null(unittest.TestCase): def _call_fut(self, value, field): from google.cloud.bigquery._helpers import _not_null From a0126e6c505e967809ef0aaf834912ff465c067b Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Thu, 29 Feb 2024 21:39:23 +0000 Subject: [PATCH 02/12] lint --- google/cloud/bigquery/_helpers.py | 26 ++++++++++++++------------ google/cloud/bigquery/client.py | 2 +- tests/unit/helpers.py | 5 +++-- tests/unit/test__helpers.py | 21 ++++++++++++--------- 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/google/cloud/bigquery/_helpers.py b/google/cloud/bigquery/_helpers.py index ce47dab00..13569f5d4 100644 --- a/google/cloud/bigquery/_helpers.py +++ b/google/cloud/bigquery/_helpers.py @@ -67,13 +67,15 @@ """Environment variable for setting universe domain.""" -def _get_client_universe(client_options: Optional[Union[client_options_lib.ClientOptions, dict]]) -> str: +def _get_client_universe( + client_options: Optional[Union[client_options_lib.ClientOptions, dict]] +) -> str: """Retrieves the specified universe setting. - Args: - client_options: specified client options. - Returns: - str: resolved universe setting. + Args: + client_options: specified client options. + Returns: + str: resolved universe setting. """ if isinstance(client_options, dict): @@ -87,19 +89,19 @@ def _get_client_universe(client_options: Optional[Union[client_options_lib.Clien env_universe = os.getenv(_UNIVERSE_DOMAIN_ENV) if isinstance(env_universe, str): universe = env_universe - return universe + return universe def _validate_universe(client_universe: str, credentials: ga_credentials.Credentials): """Validates that client provided universe and universe embedded in credentials match. - Args: - client_universe (str): The universe domain configured via the client options. - credentials (ga_credentials.Credentials): The credentials being used in the client. + Args: + client_universe (str): The universe domain configured via the client options. + credentials (ga_credentials.Credentials): The credentials being used in the client. - Raises: - ValueError: when client_universe does not match the universe in credentials. - """ + Raises: + ValueError: when client_universe does not match the universe in credentials. + """ if hasattr(credentials, "universe_domain"): cred_universe = getattr(credentials, "universe_domain") if isinstance(cred_universe, str): diff --git a/google/cloud/bigquery/client.py b/google/cloud/bigquery/client.py index ac834492b..556b8ef49 100644 --- a/google/cloud/bigquery/client.py +++ b/google/cloud/bigquery/client.py @@ -262,7 +262,7 @@ def __init__( if client_universe != _DEFAULT_UNIVERSE: kw_args["api_endpoint"] = _DEFAULT_HOST_TEMPLATE.replace( "{UNIVERSE_DOMAIN}", client_universe - ) + ) # Ensure credentials and universe are not in conflict. if hasattr(self, "_credentials") and client_universe is not None: _validate_universe(client_universe, self._credentials) diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py index a61a31ca1..d14016fee 100644 --- a/tests/unit/helpers.py +++ b/tests/unit/helpers.py @@ -42,20 +42,21 @@ def make_client(project="PROJECT", **kw): credentials = mock.Mock(spec=google.auth.credentials.Credentials) return google.cloud.bigquery.client.Client(project, credentials, **kw) + def make_creds(creds_universe: None): from google.auth import credentials class TestingCreds(credentials.Credentials): - def refresh(self, request): raise NotImplemented @property def universe_domain(self): return creds_universe - + return TestingCreds() + def make_dataset_reference_string(project, ds_id): return f"{project}.{ds_id}" diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index 3b0e73fcc..e63236277 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -24,46 +24,48 @@ class Test_get_client_universe(unittest.TestCase): def test_with_none(self): from google.cloud.bigquery._helpers import _get_client_universe + self.assertEqual("googleapis.com", _get_client_universe(None)) - + def test_with_dict(self): from google.cloud.bigquery._helpers import _get_client_universe + options = {"universe_domain": "foo.com"} self.assertEqual("foo.com", _get_client_universe(options)) def test_with_clientoptions(self): from google.cloud.bigquery._helpers import _get_client_universe from google.api_core import client_options + options = client_options.from_dict({"universe_domain": "foo.com"}) self.assertEqual("foo.com", _get_client_universe(options)) - @mock.patch.dict(os.environ, {'GOOGLE_CLOUD_UNIVERSE_DOMAIN': 'foo.com'}) + @mock.patch.dict(os.environ, {"GOOGLE_CLOUD_UNIVERSE_DOMAIN": "foo.com"}) def test_with_environ(self): from google.cloud.bigquery._helpers import _get_client_universe - self.assertEqual("foo.com", _get_client_universe(None)) - + self.assertEqual("foo.com", _get_client_universe(None)) class Test_validate_universe(unittest.TestCase): - - - def test_with_none(self): from google.cloud.bigquery._helpers import _validate_universe + # should not raise _validate_universe("googleapis.com", None) def test_with_no_universe_creds(self): from google.cloud.bigquery._helpers import _validate_universe from .helpers import make_creds + creds = make_creds(None) # should not raise _validate_universe("googleapis.com", creds) - + def test_with_matched_universe_creds(self): from google.cloud.bigquery._helpers import _validate_universe from .helpers import make_creds + creds = make_creds("googleapis.com") # should not raise _validate_universe("googleapis.com", creds) @@ -71,10 +73,11 @@ def test_with_matched_universe_creds(self): def test_with_mismatched_universe_creds(self): from google.cloud.bigquery._helpers import _validate_universe from .helpers import make_creds + creds = make_creds("foo.com") with self.assertRaises(ValueError): _validate_universe("googleapis.com", creds) - + class Test_not_null(unittest.TestCase): def _call_fut(self, value, field): From 0cef28ba46c21e99a38e0ac7f322fa14f117592a Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Thu, 29 Feb 2024 23:29:20 +0000 Subject: [PATCH 03/12] skipif core too old --- tests/unit/test__helpers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index e63236277..c77797d09 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -19,8 +19,14 @@ import unittest import os import mock +import pytest +@pytest.mark.skipif( + packaging.version.parse(getattr(google.api_core, "__version__", "0.0.0")) + < packaging.version.Version("2.15.0"), + reason="universe_domain not supported with google-api-core < 2.15.0", +) class Test_get_client_universe(unittest.TestCase): def test_with_none(self): from google.cloud.bigquery._helpers import _get_client_universe From 506bb662abe371a10af4ede18ddab7bfb7872568 Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Thu, 29 Feb 2024 23:30:49 +0000 Subject: [PATCH 04/12] deps --- tests/unit/test__helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index c77797d09..b73331bfc 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -20,6 +20,7 @@ import os import mock import pytest +import packaging @pytest.mark.skipif( From e7cb75c6d1925bfc444ac8a01035afffcdf38aab Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Thu, 29 Feb 2024 23:37:29 +0000 Subject: [PATCH 05/12] add import --- tests/unit/test__helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index b73331bfc..d45f7d796 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -21,6 +21,7 @@ import mock import pytest import packaging +import google.api_core @pytest.mark.skipif( From 5564bfdca46078f12e9bd41d47cc04178abaeaea Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Mon, 4 Mar 2024 17:54:53 +0000 Subject: [PATCH 06/12] no-cover in test helper --- tests/unit/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py index d14016fee..55f763528 100644 --- a/tests/unit/helpers.py +++ b/tests/unit/helpers.py @@ -48,7 +48,7 @@ def make_creds(creds_universe: None): class TestingCreds(credentials.Credentials): def refresh(self, request): - raise NotImplemented + raise NotImplemented # pragma: no cover @property def universe_domain(self): From 5ff2b5f390c8f46723b300b16e0cc6821d4b5bbf Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Mon, 4 Mar 2024 18:44:02 +0000 Subject: [PATCH 07/12] lint --- tests/unit/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py index 55f763528..68634a70a 100644 --- a/tests/unit/helpers.py +++ b/tests/unit/helpers.py @@ -47,8 +47,8 @@ def make_creds(creds_universe: None): from google.auth import credentials class TestingCreds(credentials.Credentials): - def refresh(self, request): - raise NotImplemented # pragma: no cover + def refresh(self, request): # pragma: no cover + raise NotImplemented @property def universe_domain(self): From 0350b99715b556c6a57ef73ecc74262ac4f62440 Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Mon, 4 Mar 2024 18:53:00 +0000 Subject: [PATCH 08/12] ignore google.auth typing --- google/cloud/bigquery/_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/cloud/bigquery/_helpers.py b/google/cloud/bigquery/_helpers.py index 13569f5d4..7057f2141 100644 --- a/google/cloud/bigquery/_helpers.py +++ b/google/cloud/bigquery/_helpers.py @@ -30,7 +30,7 @@ from google.cloud._helpers import _RFC3339_MICROS from google.cloud._helpers import _RFC3339_NO_FRACTION from google.cloud._helpers import _to_bytes -from google.auth import credentials as ga_credentials +from google.auth import credentials as ga_credentials # type: ignore from google.api_core import client_options as client_options_lib _RFC3339_MICROS_NO_ZULU = "%Y-%m-%dT%H:%M:%S.%f" From b69f580b682e1dd996c3aacea03afa1d45ada0fe Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Mon, 4 Mar 2024 20:09:11 +0000 Subject: [PATCH 09/12] capitalization --- tests/unit/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py index 68634a70a..4bd268b65 100644 --- a/tests/unit/helpers.py +++ b/tests/unit/helpers.py @@ -47,7 +47,7 @@ def make_creds(creds_universe: None): from google.auth import credentials class TestingCreds(credentials.Credentials): - def refresh(self, request): # pragma: no cover + def refresh(self, request): # pragma: NO COVER raise NotImplemented @property From bccdae2745b2f64364812a572c8deb48bfd605ae Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Mon, 4 Mar 2024 21:05:44 +0000 Subject: [PATCH 10/12] change to raise in test code --- tests/unit/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py index 4bd268b65..bc92c0df6 100644 --- a/tests/unit/helpers.py +++ b/tests/unit/helpers.py @@ -48,7 +48,7 @@ def make_creds(creds_universe: None): class TestingCreds(credentials.Credentials): def refresh(self, request): # pragma: NO COVER - raise NotImplemented + raise NotImplementedError @property def universe_domain(self): From 2f5b49e094fb3fbb4339180970fddac0a319b18c Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Mon, 4 Mar 2024 23:48:45 +0000 Subject: [PATCH 11/12] reviewer feedback --- google/cloud/bigquery/_helpers.py | 4 ++-- google/cloud/bigquery/client.py | 1 - tests/unit/test__helpers.py | 14 +++++++++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/google/cloud/bigquery/_helpers.py b/google/cloud/bigquery/_helpers.py index 7057f2141..a53f33572 100644 --- a/google/cloud/bigquery/_helpers.py +++ b/google/cloud/bigquery/_helpers.py @@ -83,11 +83,11 @@ def _get_client_universe( universe = _DEFAULT_UNIVERSE if hasattr(client_options, "universe_domain"): options_universe = getattr(client_options, "universe_domain") - if options_universe is not None: + if options_universe is not None and len(env_universe) > 0: universe = options_universe else: env_universe = os.getenv(_UNIVERSE_DOMAIN_ENV) - if isinstance(env_universe, str): + if isinstance(env_universe, str) and len(env_universe) > 0: universe = env_universe return universe diff --git a/google/cloud/bigquery/client.py b/google/cloud/bigquery/client.py index 556b8ef49..cb4daa897 100644 --- a/google/cloud/bigquery/client.py +++ b/google/cloud/bigquery/client.py @@ -268,7 +268,6 @@ def __init__( _validate_universe(client_universe, self._credentials) self._connection = Connection(self, **kw_args) - self._location = location self._default_load_job_config = copy.deepcopy(default_load_job_config) diff --git a/tests/unit/test__helpers.py b/tests/unit/test__helpers.py index d45f7d796..019d2e7bd 100644 --- a/tests/unit/test__helpers.py +++ b/tests/unit/test__helpers.py @@ -41,7 +41,13 @@ def test_with_dict(self): options = {"universe_domain": "foo.com"} self.assertEqual("foo.com", _get_client_universe(options)) - def test_with_clientoptions(self): + def test_with_dict_empty(self): + from google.cloud.bigquery._helpers import _get_client_universe + + options = {"universe_domain": ""} + self.assertEqual("googleapis.com", _get_client_universe(options)) + + def test_with_client_options(self): from google.cloud.bigquery._helpers import _get_client_universe from google.api_core import client_options @@ -54,6 +60,12 @@ def test_with_environ(self): self.assertEqual("foo.com", _get_client_universe(None)) + @mock.patch.dict(os.environ, {"GOOGLE_CLOUD_UNIVERSE_DOMAIN": ""}) + def test_with_environ_empty(self): + from google.cloud.bigquery._helpers import _get_client_universe + + self.assertEqual("googleapis.com", _get_client_universe(None)) + class Test_validate_universe(unittest.TestCase): def test_with_none(self): From c1b8011fe90f42e829c1fc62f9acae6b1f38191f Mon Sep 17 00:00:00 2001 From: Seth Hollyman Date: Tue, 5 Mar 2024 00:15:32 +0000 Subject: [PATCH 12/12] var fix --- google/cloud/bigquery/_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/cloud/bigquery/_helpers.py b/google/cloud/bigquery/_helpers.py index a53f33572..ec4ac9970 100644 --- a/google/cloud/bigquery/_helpers.py +++ b/google/cloud/bigquery/_helpers.py @@ -83,7 +83,7 @@ def _get_client_universe( universe = _DEFAULT_UNIVERSE if hasattr(client_options, "universe_domain"): options_universe = getattr(client_options, "universe_domain") - if options_universe is not None and len(env_universe) > 0: + if options_universe is not None and len(options_universe) > 0: universe = options_universe else: env_universe = os.getenv(_UNIVERSE_DOMAIN_ENV)