Skip to content

Commit 064a726

Browse files
authored
Merge pull request #708 from hkad98/aac-productization
Accept config from analytics as a code
2 parents f1f0dae + 98087b9 commit 064a726

File tree

11 files changed

+212
-115
lines changed

11 files changed

+212
-115
lines changed

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ format-diff:
3333

3434
.PHONY: format-fix
3535
format-fix:
36-
.venv/bin/ruff check .
3736
.venv/bin/ruff format .
37+
.venv/bin/ruff check . --fix --fixable I
3838

3939

4040
define download_client

gooddata-pandas/tests/good_pandas/profiles/profiles.yaml

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,30 @@
11
# (C) 2023 GoodData Corporation
22
default:
33
host: http://abc:3000
4-
token: 123
4+
token: "123"
55
custom_headers:
66
Host: localhost
77
extra_user_agent: xyz
88
correct_1:
99
host: http://abc:3000
10-
token: 123
10+
token: "123"
1111
correct_2:
1212
host: http://abc:3000
13-
token: 123
13+
token: "123"
1414
custom_headers:
1515
Host: localhost
1616
correct_3:
1717
host: http://abc:3000
18-
token: 123
18+
token: "123"
1919
extra_user_agent: xyz
2020
correct_4:
2121
host: http://abc:3000
22-
token: 123
22+
token: "123"
2323
analytics_as_code_things:
2424
cool: True
2525
custom:
2626
host: http://xyz:3000
27-
token: 456
27+
token: "456"
2828
custom_headers:
2929
Host: localhost123
3030
extra_user_agent: abc

gooddata-pandas/tests/good_pandas/test_good_pandas.py

+18-52
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22
from __future__ import annotations
33

44
from pathlib import Path
5-
from typing import Any
5+
from typing import Any, Union
66

7+
import pytest
78
import yaml
89
from gooddata_pandas import GoodPandas
910

1011
_current_dir = Path(__file__).parent.absolute()
1112
PROFILES_PATH = _current_dir / "profiles" / "profiles.yaml"
1213

1314

14-
def load_profiles_content() -> dict:
15-
with open(PROFILES_PATH, "r", encoding="utf-8") as f:
15+
def load_profiles_content(path: Union[str, Path]) -> dict:
16+
with open(path, "r", encoding="utf-8") as f:
1617
return yaml.safe_load(f)
1718

1819

@@ -23,59 +24,24 @@ def are_same_check(profile_data: dict[str, Any], good_pandas: GoodPandas):
2324
assert profile_data["custom_headers"] == good_pandas.sdk.client._custom_headers
2425

2526

26-
def test_default_profile():
27-
profile = "default"
28-
good_pandas = GoodPandas.create_from_profile(profiles_path=PROFILES_PATH)
29-
data = load_profiles_content()
30-
31-
are_same_check(data[profile], good_pandas)
32-
33-
34-
def test_other_profile():
35-
profile = "custom"
27+
@pytest.mark.parametrize(
28+
"profile",
29+
[
30+
"custom",
31+
"default",
32+
"correct_1",
33+
"correct_2",
34+
"correct_3",
35+
"correct_4",
36+
],
37+
)
38+
def test_legacy_config(profile):
3639
good_pandas = GoodPandas.create_from_profile(profile=profile, profiles_path=PROFILES_PATH)
37-
data = load_profiles_content()
38-
40+
data = load_profiles_content(PROFILES_PATH)
3941
are_same_check(data[profile], good_pandas)
4042

4143

4244
def test_wrong_profile():
4345
profile = "wrong"
44-
try:
46+
with pytest.raises(ValueError):
4547
GoodPandas.create_from_profile(profile=profile, profiles_path=PROFILES_PATH)
46-
except ValueError:
47-
assert True
48-
else:
49-
assert False, "The ValueError was expected to be raised."
50-
51-
52-
def test_correct_1_profile():
53-
profile = "correct_1"
54-
sdk = GoodPandas.create_from_profile(profile=profile, profiles_path=PROFILES_PATH)
55-
data = load_profiles_content()
56-
57-
are_same_check(data[profile], sdk)
58-
59-
60-
def test_correct_2_profile():
61-
profile = "correct_2"
62-
sdk = GoodPandas.create_from_profile(profile=profile, profiles_path=PROFILES_PATH)
63-
data = load_profiles_content()
64-
65-
are_same_check(data[profile], sdk)
66-
67-
68-
def test_correct_3_profile():
69-
profile = "correct_3"
70-
sdk = GoodPandas.create_from_profile(profile=profile, profiles_path=PROFILES_PATH)
71-
data = load_profiles_content()
72-
73-
are_same_check(data[profile], sdk)
74-
75-
76-
def test_correct_4_profile():
77-
profile = "correct_4"
78-
sdk = GoodPandas.create_from_profile(profile=profile, profiles_path=PROFILES_PATH)
79-
data = load_profiles_content()
80-
81-
are_same_check(data[profile], sdk)

gooddata-sdk/gooddata_sdk/config.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# (C) 2024 GoodData Corporation
2+
import os
3+
from typing import Any, Dict, Optional, Type, TypeVar
4+
5+
import attrs
6+
from attrs import define
7+
from cattrs import structure
8+
from cattrs.errors import ClassValidationError
9+
from dotenv import load_dotenv
10+
11+
T = TypeVar("T", bound="ConfigBase")
12+
13+
14+
@define
15+
class ConfigBase:
16+
def to_dict(self) -> Dict[str, str]:
17+
return attrs.asdict(self)
18+
19+
@classmethod
20+
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
21+
return structure(data, cls)
22+
23+
@classmethod
24+
def can_structure(cls: Type[T], data: Dict[str, Any]) -> bool:
25+
try:
26+
cls.from_dict(data)
27+
return True
28+
except ClassValidationError:
29+
return False
30+
31+
32+
@define
33+
class Profile(ConfigBase):
34+
host: str
35+
token: str
36+
custom_headers: Optional[Dict[str, str]] = None
37+
extra_user_agent: Optional[str] = None
38+
39+
def to_dict(self, use_env: bool = False) -> Dict[str, str]:
40+
load_dotenv()
41+
if not use_env:
42+
return attrs.asdict(self)
43+
env_var = self.token[1:]
44+
if env_var not in os.environ:
45+
raise ValueError(f"Environment variable {env_var} not found")
46+
return {**attrs.asdict(self), "token": os.environ[env_var]}
47+
48+
49+
@define
50+
class AacConfig(ConfigBase):
51+
profiles: Dict[str, Profile]
52+
default_profile: str
53+
access: Dict[str, str]

gooddata-sdk/gooddata_sdk/utils.py

+45-5
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@
99
from pathlib import Path
1010
from shutil import rmtree
1111
from typing import Any, Callable, Dict, List, NamedTuple, Tuple, Union, cast, no_type_check
12+
from warnings import warn
1213
from xml.etree import ElementTree as ET
1314

1415
import yaml
16+
from cattrs import structure
17+
from cattrs.errors import ClassValidationError
1518
from gooddata_api_client import ApiAttributeError
1619
from gooddata_api_client.model_utils import OpenApiModel
1720

1821
from gooddata_sdk.compute.model.base import ObjId
22+
from gooddata_sdk.config import AacConfig, Profile
1923

2024
# Use typing collection types to support python < py3.9
2125
IdObjType = Union[str, ObjId, Dict[str, Dict[str, str]], Dict[str, str]]
@@ -241,6 +245,43 @@ def mandatory_profile_content_check(profile: str, profile_content_keys: KeysView
241245
raise ValueError(f"Profile {profile} is missing mandatory parameter or parameters {missing_str}.")
242246

243247

248+
def _create_profile_legacy(content: Dict) -> Dict:
249+
try:
250+
return structure(content, Profile).to_dict()
251+
except ClassValidationError as e:
252+
errors = []
253+
for error in e.exceptions:
254+
if isinstance(error, KeyError):
255+
errors.append(f"Profile file does not contain mandatory parameter: {e}")
256+
msg = "\n".join(errors)
257+
if not msg:
258+
msg = "An error occurred while parsing the profile file."
259+
raise ValueError(msg)
260+
261+
262+
def _create_profile_aac(profile: str, content: Dict) -> Dict:
263+
aac_config = AacConfig.from_dict(content)
264+
selected_profile = aac_config.default_profile if profile == "default" else profile
265+
if selected_profile not in aac_config.profiles:
266+
raise ValueError(f"Profile file does not contain the specified profile: {profile}")
267+
return aac_config.profiles[selected_profile].to_dict(use_env=True)
268+
269+
270+
def _get_profile(profile: str, content: Dict) -> Dict[str, Any]:
271+
is_aac_config = AacConfig.can_structure(content)
272+
if not is_aac_config and profile not in content:
273+
raise ValueError("Configuration is invalid. Please check the documentation for the valid configuration.")
274+
if is_aac_config:
275+
return _create_profile_aac(profile, content)
276+
else:
277+
warn(
278+
"Used configuration is deprecated and will be removed in the future. Please use the new configuration.",
279+
DeprecationWarning,
280+
stacklevel=2,
281+
)
282+
return _create_profile_legacy(content[profile])
283+
284+
244285
def profile_content(profile: str = "default", profiles_path: Path = PROFILES_FILE_PATH) -> dict[str, Any]:
245286
"""Get the profile content from a given file.
246287
@@ -263,10 +304,9 @@ def profile_content(profile: str = "default", profiles_path: Path = PROFILES_FIL
263304
if not profiles_path.exists():
264305
raise ValueError(f"There is no profiles file located for path {profiles_path}.")
265306
content = read_layout_from_file(profiles_path)
266-
if not content.get(profile):
267-
raise ValueError(f"Profiles file does not contain profile {profile}.")
268-
mandatory_profile_content_check(profile, content[profile].keys())
269-
return {key: content[profile][key] for key in content[profile] if key in SDK_PROFILE_KEYS}
307+
if content is None:
308+
raise ValueError(f"The config file is empty {profiles_path}.")
309+
return _get_profile(profile, content)
270310

271311

272312
def good_pandas_profile_content(
@@ -288,7 +328,7 @@ def good_pandas_profile_content(
288328
The content and custom Headers.
289329
"""
290330
content = profile_content(profile, profiles_path)
291-
custom_headers = content.pop("custom_headers", {})
331+
custom_headers = content.pop("custom_headers", {}) or {}
292332
content.pop("extra_user_agent", None)
293333
return content, custom_headers
294334

gooddata-sdk/mypy.ini

+3
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ ignore_missing_imports = True
1414

1515
[mypy-requests.*]
1616
ignore_missing_imports = True
17+
18+
[mypy-dotenv.*]
19+
ignore_missing_imports = True

gooddata-sdk/tests/conftest.py

+11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# (C) 2022 GoodData Corporation
2+
import os
23
from pathlib import Path
4+
from unittest import mock
35

46
import pytest
57
import yaml
@@ -22,3 +24,12 @@ def test_config(request):
2224
config = yaml.safe_load(f)
2325

2426
return config
27+
28+
29+
@pytest.fixture()
30+
def setenvvar(monkeypatch):
31+
with mock.patch.dict(os.environ, clear=True):
32+
envvars = {"OK_TOKEN": "secret_password", "ENV_VAR": "secret"}
33+
for k, v in envvars.items():
34+
monkeypatch.setenv(k, v)
35+
yield
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# (C) 2024 GoodData Corporation
2+
abc:
3+
def:
4+
xyz: 1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# (C) 2024 GoodData Corporation
2+
profiles:
3+
abc:
4+
host: https://abc.com/
5+
token: $OK_TOKEN
6+
workspace_id: demo
7+
data_source_id: demo_ds
8+
def:
9+
host: https://def.com/
10+
token: $NOT_EXISTING_ENV_VAR
11+
workspace_id: demo
12+
data_source_id: demo_ds
13+
xyz:
14+
host: https://xyz.com/
15+
token: $ENV_VAR
16+
workspace_id: demo
17+
data_source_id: demo_ds
18+
source_dir: analytics
19+
default_profile: abc
20+
21+
access:
22+
demo_ds: $DS_PASSWORD

gooddata-sdk/tests/sdk/profiles/profiles.yaml

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,30 @@
11
# (C) 2022 GoodData Corporation
22
default:
33
host: http://abc:3000
4-
token: 123
4+
token: "123"
55
custom_headers:
66
Host: localhost
77
extra_user_agent: xyz
88
correct_1:
99
host: http://abc:3000
10-
token: 123
10+
token: "123"
1111
correct_2:
1212
host: http://abc:3000
13-
token: 123
13+
token: "123"
1414
custom_headers:
1515
Host: localhost
1616
correct_3:
1717
host: http://abc:3000
18-
token: 123
18+
token: "123"
1919
extra_user_agent: xyz
2020
correct_4:
2121
host: http://abc:3000
22-
token: 123
22+
token: "123"
2323
analytics_as_code_things:
2424
cool: True
2525
custom:
2626
host: http://xyz:3000
27-
token: 456
27+
token: "456"
2828
custom_headers:
2929
Host: localhost123
3030
extra_user_agent: abc

0 commit comments

Comments
 (0)