Skip to content

Commit cf90a1a

Browse files
Keyfile dict can be dict not str (#29135)
* Keyfile dict can be dict not str This makes it much easier to define a connection with json. We can do this: ```json {"extra": {"keyfile_dict": {"foo": "bar", "private_key": "hi"}}} ``` Instead of this: ```json {"extra": {"keyfile_dict": "{\"foo\": \"bar\", \"private_key\": \"hi\"}"}} ``` I.e. instead of json-dumping the contents of the key first, we can just paste the key as is into the "keyfile_dict" field in extra. Co-authored-by: Jed Cunningham <[email protected]>
1 parent bfbe2cb commit cf90a1a

File tree

2 files changed

+42
-3
lines changed

2 files changed

+42
-3
lines changed

airflow/providers/google/common/hooks/base_google.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,13 @@ def get_credentials_and_project_id(self) -> tuple[google.auth.credentials.Creden
242242

243243
key_path: str | None = self._get_field("key_path", None)
244244
try:
245-
keyfile_dict: str | None = self._get_field("keyfile_dict", None)
245+
keyfile_dict: str | dict[str, str] | None = self._get_field("keyfile_dict", None)
246246
keyfile_dict_json: dict[str, str] | None = None
247247
if keyfile_dict:
248-
keyfile_dict_json = json.loads(keyfile_dict)
248+
if isinstance(keyfile_dict, dict):
249+
keyfile_dict_json = keyfile_dict
250+
else:
251+
keyfile_dict_json = json.loads(keyfile_dict)
249252
except json.decoder.JSONDecodeError:
250253
raise AirflowException("Invalid key JSON.")
251254
key_secret_name: str | None = self._get_field("key_secret_name", None)
@@ -494,7 +497,7 @@ def provide_gcp_credential_file_as_context(self) -> Generator[str | None, None,
494497
file in ``GOOGLE_APPLICATION_CREDENTIALS`` environment variable.
495498
"""
496499
key_path: str | None = self._get_field("key_path", None)
497-
keyfile_dict: str | None = self._get_field("keyfile_dict", None)
500+
keyfile_dict: str | dict[str, str] | None = self._get_field("keyfile_dict", None)
498501
if key_path and keyfile_dict:
499502
raise AirflowException(
500503
"The `keyfile_dict` and `key_path` fields are mutually exclusive. "
@@ -507,6 +510,8 @@ def provide_gcp_credential_file_as_context(self) -> Generator[str | None, None,
507510
yield key_path
508511
elif keyfile_dict:
509512
with tempfile.NamedTemporaryFile(mode="w+t") as conf_file:
513+
if isinstance(keyfile_dict, dict):
514+
keyfile_dict = json.dumps(keyfile_dict)
510515
conf_file.write(keyfile_dict)
511516
conf_file.flush()
512517
with patch_environ({CREDENTIALS: conf_file.name}):

tests/providers/google/common/hooks/test_base_google.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
import os
2222
import re
2323
from io import StringIO
24+
from pathlib import Path
2425
from unittest import mock
26+
from unittest.mock import patch
2527

2628
import google.auth
2729
import pytest
@@ -34,6 +36,7 @@
3436
from airflow.exceptions import AirflowException
3537
from airflow.providers.google.cloud.utils.credentials_provider import _DEFAULT_SCOPES
3638
from airflow.providers.google.common.hooks import base_google as hook
39+
from airflow.providers.google.common.hooks.base_google import GoogleBaseHook
3740
from tests.providers.google.cloud.utils.base_gcp_mock import mock_base_gcp_hook_default_project_id
3841

3942
default_creds_available = True
@@ -166,6 +169,37 @@ def assert_gcp_credential_file_in_env(_):
166169
):
167170
assert_gcp_credential_file_in_env(self.instance)
168171

172+
def test_provide_gcp_credential_keyfile_dict_json(self):
173+
"""
174+
Historically, keyfile_dict had to be str in the conn extra. Now it
175+
can be dict and this is verified here.
176+
"""
177+
conn_dict = {
178+
"extra": {
179+
"keyfile_dict": {"foo": "bar", "private_key": "hi"}, # notice keyfile_dict is dict not str
180+
}
181+
}
182+
183+
@GoogleBaseHook.provide_gcp_credential_file
184+
def assert_gcp_credential_file_in_env(instance):
185+
assert Path(os.environ[CREDENTIALS]).read_text() == json.dumps(conn_dict["extra"]["keyfile_dict"])
186+
187+
with patch.dict("os.environ", AIRFLOW_CONN_MY_GOOGLE=json.dumps(conn_dict)):
188+
# keyfile dict is handled in two different areas
189+
190+
hook = GoogleBaseHook("my_google")
191+
192+
# the first is in provide_gcp_credential_file
193+
assert_gcp_credential_file_in_env(hook)
194+
195+
with patch("google.oauth2.service_account.Credentials.from_service_account_info") as m:
196+
# the second is in get_credentials_and_project_id
197+
hook.get_credentials_and_project_id()
198+
m.assert_called_once_with(
199+
conn_dict["extra"]["keyfile_dict"],
200+
scopes=("https://www.googleapis.com/auth/cloud-platform",),
201+
)
202+
169203
def test_provide_gcp_credential_file_decorator_key_path(self):
170204
key_path = "/test/key-path"
171205
self.instance.extras = {"key_path": key_path}

0 commit comments

Comments
 (0)