castor-extractor 0.17.3__py3-none-any.whl → 0.18.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of castor-extractor might be problematic. Click here for more details.

Files changed (79) hide show
  1. CHANGELOG.md +20 -0
  2. castor_extractor/commands/extract_metabase_api.py +2 -1
  3. castor_extractor/commands/extract_metabase_db.py +3 -1
  4. castor_extractor/commands/extract_mode.py +2 -3
  5. castor_extractor/utils/__init__.py +2 -1
  6. castor_extractor/utils/collection.py +8 -0
  7. castor_extractor/utils/safe_request.py +57 -0
  8. castor_extractor/utils/safe_request_test.py +77 -0
  9. castor_extractor/utils/salesforce/__init__.py +1 -2
  10. castor_extractor/utils/salesforce/constants.py +0 -11
  11. castor_extractor/utils/salesforce/credentials.py +22 -45
  12. castor_extractor/visualization/domo/__init__.py +1 -1
  13. castor_extractor/visualization/domo/client/__init__.py +1 -1
  14. castor_extractor/visualization/domo/client/client.py +37 -52
  15. castor_extractor/visualization/domo/client/credentials.py +14 -27
  16. castor_extractor/visualization/domo/extract.py +6 -12
  17. castor_extractor/visualization/looker/__init__.py +6 -1
  18. castor_extractor/visualization/looker/api/__init__.py +2 -1
  19. castor_extractor/visualization/looker/api/client.py +6 -4
  20. castor_extractor/visualization/looker/api/client_test.py +5 -3
  21. castor_extractor/visualization/looker/api/credentials.py +33 -0
  22. castor_extractor/visualization/looker/api/extraction_parameters.py +38 -0
  23. castor_extractor/visualization/looker/api/sdk.py +2 -28
  24. castor_extractor/visualization/looker/constant.py +4 -21
  25. castor_extractor/visualization/looker/constants.py +17 -0
  26. castor_extractor/visualization/looker/extract.py +30 -29
  27. castor_extractor/visualization/metabase/__init__.py +6 -1
  28. castor_extractor/visualization/metabase/client/__init__.py +2 -2
  29. castor_extractor/visualization/metabase/client/api/__init__.py +1 -0
  30. castor_extractor/visualization/metabase/client/api/client.py +8 -14
  31. castor_extractor/visualization/metabase/client/api/credentials.py +13 -40
  32. castor_extractor/visualization/metabase/client/db/__init__.py +1 -0
  33. castor_extractor/visualization/metabase/client/db/client.py +13 -34
  34. castor_extractor/visualization/metabase/client/db/credentials.py +19 -73
  35. castor_extractor/visualization/metabase/errors.py +5 -3
  36. castor_extractor/visualization/metabase/extract.py +1 -1
  37. castor_extractor/visualization/mode/__init__.py +1 -1
  38. castor_extractor/visualization/mode/client/__init__.py +1 -0
  39. castor_extractor/visualization/mode/client/client.py +9 -12
  40. castor_extractor/visualization/mode/client/client_test.py +3 -3
  41. castor_extractor/visualization/mode/client/credentials.py +18 -51
  42. castor_extractor/visualization/mode/extract.py +5 -3
  43. castor_extractor/visualization/powerbi/__init__.py +1 -1
  44. castor_extractor/visualization/powerbi/client/__init__.py +2 -1
  45. castor_extractor/visualization/powerbi/client/credentials.py +17 -9
  46. castor_extractor/visualization/powerbi/client/credentials_test.py +12 -4
  47. castor_extractor/visualization/powerbi/client/rest.py +2 -2
  48. castor_extractor/visualization/powerbi/client/rest_test.py +2 -2
  49. castor_extractor/visualization/powerbi/extract.py +2 -2
  50. castor_extractor/visualization/qlik/__init__.py +5 -1
  51. castor_extractor/visualization/qlik/client/__init__.py +1 -0
  52. castor_extractor/visualization/qlik/client/engine/__init__.py +1 -0
  53. castor_extractor/visualization/qlik/client/engine/client.py +5 -6
  54. castor_extractor/visualization/qlik/client/engine/credentials.py +26 -0
  55. castor_extractor/visualization/qlik/client/master.py +5 -11
  56. castor_extractor/visualization/qlik/client/rest.py +4 -4
  57. castor_extractor/visualization/qlik/client/rest_test.py +6 -2
  58. castor_extractor/visualization/qlik/extract.py +4 -8
  59. castor_extractor/visualization/sigma/__init__.py +1 -1
  60. castor_extractor/visualization/sigma/client/__init__.py +1 -1
  61. castor_extractor/visualization/sigma/client/client.py +12 -5
  62. castor_extractor/visualization/sigma/client/credentials.py +12 -28
  63. castor_extractor/visualization/sigma/extract.py +4 -8
  64. castor_extractor/visualization/tableau_revamp/client/credentials.py +40 -87
  65. castor_extractor/warehouse/redshift/queries/column.sql +0 -5
  66. castor_extractor/warehouse/salesforce/extract.py +2 -2
  67. castor_extractor/warehouse/snowflake/queries/column.sql +0 -1
  68. castor_extractor/warehouse/synapse/queries/column.sql +0 -1
  69. {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/METADATA +9 -9
  70. {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/RECORD +73 -73
  71. castor_extractor/visualization/domo/client/client_test.py +0 -60
  72. castor_extractor/visualization/domo/constants.py +0 -6
  73. castor_extractor/visualization/looker/env.py +0 -48
  74. castor_extractor/visualization/looker/parameters.py +0 -78
  75. castor_extractor/visualization/qlik/constants.py +0 -3
  76. castor_extractor/visualization/sigma/constants.py +0 -4
  77. {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/LICENCE +0 -0
  78. {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/WHEEL +0 -0
  79. {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/entry_points.txt +0 -0
@@ -22,7 +22,7 @@ from looker_sdk.sdk.api40.models import (
22
22
  from looker_sdk.sdk.constants import sdk_version
23
23
 
24
24
  from ....utils import Pager, PagerLogger, SafeMode, past_date, safe_mode
25
- from ..env import page_size
25
+ from ..constants import DEFAULT_LOOKER_PAGE_SIZE
26
26
  from ..fields import format_fields
27
27
  from .constants import (
28
28
  CONNECTION_FIELDS,
@@ -39,7 +39,8 @@ from .constants import (
39
39
  USER_FIELDS,
40
40
  USERS_ATTRIBUTES_FIELDS,
41
41
  )
42
- from .sdk import CastorApiSettings, Credentials, has_admin_permissions
42
+ from .credentials import LookerCredentials
43
+ from .sdk import CastorApiSettings, has_admin_permissions
43
44
 
44
45
  logger = logging.getLogger(__name__)
45
46
 
@@ -78,9 +79,10 @@ class ApiClient:
78
79
 
79
80
  def __init__(
80
81
  self,
81
- credentials: Credentials,
82
+ credentials: LookerCredentials,
82
83
  on_api_call: OnApiCall = lambda: None,
83
84
  safe_mode: Optional[SafeMode] = None,
85
+ page_size: int = DEFAULT_LOOKER_PAGE_SIZE,
84
86
  ):
85
87
  settings = CastorApiSettings(
86
88
  credentials=credentials, sdk_version=sdk_version
@@ -92,7 +94,7 @@ class ApiClient:
92
94
  self._sdk = sdk
93
95
  self._on_api_call = on_api_call
94
96
  self._logger = ApiPagerLogger(on_api_call)
95
- self.per_page = page_size()
97
+ self.per_page = page_size
96
98
  self._safe_mode = safe_mode
97
99
 
98
100
  def folders(self) -> List[Folder]:
@@ -2,9 +2,11 @@ import datetime
2
2
  from unittest.mock import patch
3
3
 
4
4
  import pytest
5
- from castor_extractor.visualization.looker import ( # type: ignore
5
+ from castor_extractor.visualization.looker.api.client import ( # type: ignore
6
6
  ApiClient,
7
- Credentials,
7
+ )
8
+ from castor_extractor.visualization.looker.api.credentials import ( # type: ignore
9
+ LookerCredentials,
8
10
  )
9
11
  from dateutil.utils import today
10
12
  from freezegun import freeze_time
@@ -13,7 +15,7 @@ from .client import _mondays
13
15
 
14
16
 
15
17
  def _credentials():
16
- return Credentials( # noqa: S106
18
+ return LookerCredentials( # noqa: S106
17
19
  base_url="base_url",
18
20
  client_id="client_id",
19
21
  client_secret="secret",
@@ -0,0 +1,33 @@
1
+ from looker_sdk.rtl.api_settings import SettingsConfig
2
+ from pydantic import Field, SecretStr
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+ from ..constants import DEFAULT_LOOKER_TIMEOUT_SECOND, LOOKER_ENV_PREFIX
6
+
7
+ KEY_LOOKER_TIMEOUT_SECOND = f"{LOOKER_ENV_PREFIX}TIMEOUT_SECOND"
8
+
9
+
10
+ class LookerCredentials(BaseSettings):
11
+ """ValueObject for the credentials"""
12
+
13
+ model_config = SettingsConfigDict(
14
+ env_prefix=LOOKER_ENV_PREFIX,
15
+ extra="ignore",
16
+ populate_by_name=True,
17
+ )
18
+
19
+ base_url: str
20
+ client_id: str
21
+ client_secret: str = Field(repr=False)
22
+ timeout: int = Field(
23
+ validation_alias=KEY_LOOKER_TIMEOUT_SECOND,
24
+ default=DEFAULT_LOOKER_TIMEOUT_SECOND,
25
+ )
26
+
27
+ def to_settings_config(self) -> SettingsConfig:
28
+ return SettingsConfig(
29
+ base_url=self.base_url,
30
+ client_id=self.client_id,
31
+ client_secret=self.client_secret,
32
+ timeout=str(self.timeout),
33
+ )
@@ -0,0 +1,38 @@
1
+ from pydantic import Field, field_validator
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+ from ..constants import (
5
+ DEFAULT_LOOKER_PAGE_SIZE,
6
+ DEFAULT_LOOKER_THREAD_POOL_SIZE,
7
+ LOOKER_ENV_PREFIX,
8
+ MAX_THREAD_POOL_SIZE,
9
+ MIN_THREAD_POOL_SIZE,
10
+ )
11
+
12
+
13
+ class ExtractionParameters(BaseSettings):
14
+ """
15
+ Class holding all the parameters needed for the extraction of
16
+ Looker metadata
17
+ """
18
+
19
+ model_config = SettingsConfigDict(
20
+ env_prefix=LOOKER_ENV_PREFIX,
21
+ extra="ignore",
22
+ populate_by_name=True,
23
+ )
24
+
25
+ is_safe_mode: bool = False
26
+ log_to_stdout: bool
27
+ output_directory: str
28
+ search_per_folder: bool
29
+ page_size: int = Field(default=DEFAULT_LOOKER_PAGE_SIZE)
30
+ thread_pool_size: int = Field(default=DEFAULT_LOOKER_THREAD_POOL_SIZE)
31
+
32
+ @field_validator("thread_pool_size", mode="before")
33
+ @classmethod
34
+ def _check_thread_pool_size(cls, thread_pool_size: int) -> int:
35
+ thread_pool_size = thread_pool_size or DEFAULT_LOOKER_THREAD_POOL_SIZE
36
+ if MIN_THREAD_POOL_SIZE <= thread_pool_size <= MAX_THREAD_POOL_SIZE:
37
+ return thread_pool_size
38
+ raise ValueError("Thread pool size must be between 1 and 200 inclusive")
@@ -8,33 +8,7 @@ from looker_sdk.rtl import transport
8
8
  from looker_sdk.rtl.api_settings import ApiSettings, SettingsConfig
9
9
  from looker_sdk.sdk.api40 import methods as methods40
10
10
 
11
- from ..env import timeout_second
12
-
13
-
14
- class Credentials:
15
- """ValueObject for the credentials"""
16
-
17
- def __init__(
18
- self,
19
- *,
20
- base_url: str,
21
- client_id: str,
22
- client_secret: str,
23
- timeout: Optional[int] = None,
24
- **_kwargs,
25
- ):
26
- self.base_url = base_url
27
- self.client_id = client_id
28
- self.client_secret = client_secret
29
- self.timeout: int = timeout or timeout_second()
30
-
31
- def to_settings_config(self) -> SettingsConfig:
32
- return SettingsConfig(
33
- base_url=self.base_url,
34
- client_id=self.client_id,
35
- client_secret=self.client_secret,
36
- timeout=str(self.timeout),
37
- )
11
+ from .credentials import LookerCredentials
38
12
 
39
13
 
40
14
  def has_admin_permissions(sdk_: methods40.Looker40SDK) -> bool:
@@ -54,7 +28,7 @@ class CastorApiSettings(ApiSettings):
54
28
  """SDK settings with initialisation using a credential object instead of a path to a .ini file"""
55
29
 
56
30
  def __init__(
57
- self, credentials: Credentials, sdk_version: Optional[str] = ""
31
+ self, credentials: LookerCredentials, sdk_version: Optional[str] = ""
58
32
  ):
59
33
  """Configure using a config dict"""
60
34
  self.config = credentials.to_settings_config()
@@ -1,25 +1,8 @@
1
1
  """
2
- Request timeout in seconds for Looker API
2
+ Safe mode parameters
3
3
  """
4
4
 
5
- DEFAULT_LOOKER_TIMEOUT_SECOND = 120
6
- KEY_LOOKER_TIMEOUT_SECOND = "CASTOR_LOOKER_TIMEOUT_SECOND"
5
+ from looker_sdk.error import SDKError
7
6
 
8
- """
9
- Number of items per page when requesting Looker API
10
- """
11
- DEFAULT_LOOKER_PAGE_SIZE = 500
12
- KEY_LOOKER_PAGE_SIZE = "CASTOR_LOOKER_PAGE_SIZE"
13
-
14
- """
15
- Maximum concurrent threads to run when fetching
16
- """
17
- DEFAULT_LOOKER_THREAD_POOL_SIZE = 20
18
- KEY_LOOKER_THREAD_POOL_SIZE = "CASTOR_LOOKER_THREAD_POOL_SIZE"
19
-
20
- # env variables
21
- BASE_URL = "CASTOR_LOOKER_BASE_URL"
22
- CLIENT_ID = "CASTOR_LOOKER_CLIENT_ID"
23
- CLIENT_SECRET = "CASTOR_LOOKER_CLIENT_SECRET" # noqa: S105
24
- SEARCH_PER_FOLDER = "CASTOR_LOOKER_SEARCH_PER_FOLDER"
25
- LOG_TO_STDOUT = "CASTOR_LOOKER_LOG_TO_STDOUT"
7
+ SAFE_MODE_MAX_ERRORS = 3
8
+ SAFE_MODE_EXCEPTIONS = (SDKError,)
@@ -0,0 +1,17 @@
1
+ """
2
+ Request timeout in seconds for Looker API
3
+ """
4
+
5
+ LOOKER_ENV_PREFIX = "CASTOR_LOOKER_"
6
+
7
+ DEFAULT_LOOKER_TIMEOUT_SECOND = 120
8
+ """
9
+ Number of items per page when requesting Looker API
10
+ """
11
+ DEFAULT_LOOKER_PAGE_SIZE = 500
12
+ """
13
+ Maximum concurrent threads to run when fetching
14
+ """
15
+ DEFAULT_LOOKER_THREAD_POOL_SIZE = 20
16
+ MIN_THREAD_POOL_SIZE = 1
17
+ MAX_THREAD_POOL_SIZE = 200
@@ -13,10 +13,14 @@ from ...utils import (
13
13
  write_json,
14
14
  write_summary,
15
15
  )
16
- from .api import ApiClient, Credentials, lookml_explore_names
16
+ from .api import (
17
+ ApiClient,
18
+ ExtractionParameters,
19
+ LookerCredentials,
20
+ lookml_explore_names,
21
+ )
17
22
  from .assets import LookerAsset
18
23
  from .multithreading import MultithreadingFetcher
19
- from .parameters import get_parameters
20
24
 
21
25
  logger = logging.getLogger(__name__)
22
26
 
@@ -30,25 +34,23 @@ def _extract_explores_by_name(
30
34
  yield deep_serialize(explore) # type: ignore
31
35
 
32
36
 
33
- def _safe_mode(directory: str) -> SafeMode:
34
- add_logging_file_handler(directory)
37
+ def _safe_mode(
38
+ extraction_parameters: ExtractionParameters,
39
+ ) -> Optional[SafeMode]:
40
+ if extraction_parameters.is_safe_mode:
41
+ return None
42
+ add_logging_file_handler(extraction_parameters.output_directory)
35
43
  return SafeMode((Exception,), float("inf"))
36
44
 
37
45
 
38
46
  def _client(
39
- base_url: str,
40
- client_id: str,
41
- client_secret: str,
42
- timeout: Optional[int],
47
+ credentials: LookerCredentials,
43
48
  safe_mode: Optional[SafeMode],
49
+ page_size: int,
44
50
  ) -> ApiClient:
45
- credentials = Credentials(
46
- base_url=base_url,
47
- client_id=client_id,
48
- client_secret=client_secret,
49
- timeout=timeout,
51
+ return ApiClient(
52
+ credentials=credentials, safe_mode=safe_mode, page_size=page_size
50
53
  )
51
- return ApiClient(credentials=credentials, safe_mode=safe_mode)
52
54
 
53
55
 
54
56
  def iterate_all_data(
@@ -56,7 +58,7 @@ def iterate_all_data(
56
58
  search_per_folder: bool,
57
59
  thread_pool_size: int,
58
60
  log_to_stdout: bool,
59
- ) -> Union[StreamableList, Iterable[Tuple[LookerAsset, list]]]:
61
+ ) -> Iterable[Union[StreamableList, Tuple[LookerAsset, list]]]:
60
62
  """Iterate over the extracted Data From looker"""
61
63
 
62
64
  logger.info("Extracting users from Looker API")
@@ -124,33 +126,32 @@ def extract_all(**kwargs) -> None:
124
126
  Extract Data From looker and store it locally in files under the
125
127
  output_directory
126
128
  """
127
- parameters = get_parameters(**kwargs)
128
- output_directory = parameters.output_directory
129
- base_url = parameters.base_url
129
+ args = {arg: value for arg, value in kwargs.items() if value is not None}
130
+ extraction_parameters = ExtractionParameters(**args)
131
+ output_directory = extraction_parameters.output_directory
132
+
133
+ credentials = LookerCredentials(**args)
130
134
 
131
- if parameters.log_to_stdout:
135
+ if extraction_parameters.log_to_stdout:
132
136
  set_stream_handler_to_stdout()
133
137
 
134
- is_safe_mode = parameters.is_safe_mode
135
- safe_mode = _safe_mode(output_directory) if is_safe_mode else None
138
+ safe_mode = _safe_mode(extraction_parameters)
136
139
  client = _client(
137
- base_url=base_url,
138
- client_id=parameters.client_id,
139
- client_secret=parameters.client_secret,
140
- timeout=parameters.timeout,
140
+ credentials=credentials,
141
141
  safe_mode=safe_mode,
142
+ page_size=extraction_parameters.page_size,
142
143
  )
143
144
 
144
145
  ts = current_timestamp()
145
146
 
146
147
  data = iterate_all_data(
147
148
  client=client,
148
- search_per_folder=parameters.search_per_folder,
149
- thread_pool_size=parameters.thread_pool_size,
150
- log_to_stdout=parameters.log_to_stdout,
149
+ search_per_folder=extraction_parameters.search_per_folder,
150
+ thread_pool_size=extraction_parameters.thread_pool_size,
151
+ log_to_stdout=extraction_parameters.log_to_stdout,
151
152
  )
152
153
  for asset, data in data:
153
154
  filename = get_output_filename(asset.value, output_directory, ts)
154
155
  write_json(filename, data)
155
156
 
156
- write_summary(output_directory, ts, base_url=base_url)
157
+ write_summary(output_directory, ts, base_url=credentials.base_url)
@@ -1,3 +1,8 @@
1
1
  from .assets import MetabaseAsset
2
- from .client import ApiClient, DbClient
2
+ from .client import (
3
+ ApiClient,
4
+ DbClient,
5
+ MetabaseApiCredentials,
6
+ MetabaseDbCredentials,
7
+ )
3
8
  from .extract import extract_all, iterate_all_data
@@ -1,2 +1,2 @@
1
- from .api import ApiClient
2
- from .db import DbClient
1
+ from .api import ApiClient, MetabaseApiCredentials
2
+ from .db import DbClient, MetabaseDbCredentials
@@ -1 +1,2 @@
1
1
  from .client import ApiClient
2
+ from .credentials import MetabaseApiCredentials
@@ -9,7 +9,7 @@ from ...assets import EXPORTED_FIELDS, MetabaseAsset
9
9
  from ...errors import MetabaseLoginError, SuperuserCredentialsRequired
10
10
  from ...types import IdsType
11
11
  from ..shared import DETAILS_KEY, get_dbname_from_details
12
- from .credentials import CredentialsApi, CredentialsApiKey, get_value
12
+ from .credentials import MetabaseApiCredentials
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
@@ -30,13 +30,11 @@ class ApiClient:
30
30
 
31
31
  def __init__(
32
32
  self,
33
- **kwargs,
33
+ credentials: MetabaseApiCredentials,
34
34
  ):
35
- self._credentials = CredentialsApi(
36
- base_url=get_value(CredentialsApiKey.BASE_URL, kwargs),
37
- user=get_value(CredentialsApiKey.USER, kwargs),
38
- password=get_value(CredentialsApiKey.PASSWORD, kwargs),
39
- )
35
+ self.base_url = credentials.base_url
36
+
37
+ self._credentials = credentials
40
38
  self._session = requests.Session()
41
39
  self._session_id = self._login()
42
40
  self._check_permissions() # verify that the given user is superuser
@@ -46,13 +44,9 @@ class ApiClient:
46
44
  """return the name of the client"""
47
45
  return "Metabase/API"
48
46
 
49
- def base_url(self) -> str:
50
- """Return base_url from credentials"""
51
- return self._credentials.base_url
52
-
53
47
  def _url(self, endpoint: str) -> str:
54
48
  return URL_TEMPLATE.format(
55
- base_url=self._credentials.base_url,
49
+ base_url=self.base_url,
56
50
  endpoint=endpoint,
57
51
  )
58
52
 
@@ -85,7 +79,7 @@ class ApiClient:
85
79
  except HTTPError as err:
86
80
  if err.response.status_code == 403: # forbidden
87
81
  raise SuperuserCredentialsRequired(
88
- credentials_info=self._credentials.to_dict(hide=True),
82
+ credentials_info=self._credentials,
89
83
  error_details=err.args,
90
84
  )
91
85
  raise
@@ -101,7 +95,7 @@ class ApiClient:
101
95
 
102
96
  if not response.json().get("id"):
103
97
  raise MetabaseLoginError(
104
- credentials_info=self._credentials.to_dict(hide=True),
98
+ credentials_info=self._credentials,
105
99
  error_details=response.json(),
106
100
  )
107
101
 
@@ -1,45 +1,18 @@
1
- from enum import Enum
2
- from typing import Dict
1
+ from pydantic import Field, SecretStr
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
3
 
4
- from .....utils import from_env
4
+ METABASE_API_ENV_PREFIX = "CASTOR_METABASE_API_"
5
5
 
6
6
 
7
- class CredentialsApiKey(Enum):
8
- BASE_URL = "base_url"
9
- USER = "user"
10
- PASSWORD = "password" # noqa: S105
7
+ class MetabaseApiCredentials(BaseSettings):
8
+ """Metabase's credentials to connect to Metabase API"""
11
9
 
10
+ model_config = SettingsConfigDict(
11
+ env_prefix=METABASE_API_ENV_PREFIX,
12
+ extra="ignore",
13
+ populate_by_name=True,
14
+ )
12
15
 
13
- CREDENTIALS_ENV: Dict[CredentialsApiKey, str] = {
14
- CredentialsApiKey.BASE_URL: "CASTOR_METABASE_API_BASE_URL",
15
- CredentialsApiKey.USER: "CASTOR_METABASE_API_USERNAME",
16
- CredentialsApiKey.PASSWORD: "CASTOR_METABASE_API_PASSWORD",
17
- }
18
-
19
-
20
- def get_value(key: CredentialsApiKey, kwargs: dict) -> str:
21
- """
22
- Returns the value of the given key:
23
- - from kwargs in priority
24
- - from ENV if not provided (raises an error if not found in ENV)
25
- """
26
- env_key = CREDENTIALS_ENV[key]
27
- return str(kwargs.get(key.value) or from_env(env_key))
28
-
29
-
30
- class CredentialsApi:
31
- """ValueObject for the credentials"""
32
-
33
- def __init__(self, base_url: str, user: str, password: str):
34
- self.base_url = base_url
35
- self.user = user
36
- self.password = password
37
-
38
- def to_dict(self, hide: bool = False) -> Dict[str, str]:
39
- safe = (CredentialsApiKey.BASE_URL, CredentialsApiKey.USER)
40
- unsafe = (CredentialsApiKey.PASSWORD,)
41
-
42
- def val(k: CredentialsApiKey, v: str) -> str:
43
- return "*" + v[-4:] if hide and k in unsafe else v
44
-
45
- return {a.value: val(a, getattr(self, a.value)) for a in safe + unsafe}
16
+ base_url: str
17
+ user: str = Field(validation_alias="CASTOR_METABASE_API_USERNAME")
18
+ password: str = Field(repr=False)
@@ -1 +1,2 @@
1
1
  from .client import DbClient
2
+ from .credentials import MetabaseDbCredentials
@@ -1,23 +1,15 @@
1
1
  import json
2
2
  import logging
3
3
  import os
4
- from typing import Optional
5
4
 
6
- from sqlalchemy import create_engine
7
- from sqlalchemy.engine import Engine
8
5
  from sqlalchemy.exc import OperationalError
9
6
 
10
- from .....utils import (
11
- ExtractionQuery,
12
- PostgresClient,
13
- SerializedAsset,
14
- from_env,
15
- )
7
+ from .....utils import ExtractionQuery, PostgresClient, SerializedAsset
16
8
  from ...assets import EXPORTED_FIELDS, MetabaseAsset
17
9
  from ...errors import EncryptionSecretKeyRequired, MetabaseLoginError
18
10
  from ..decryption import decrypt
19
11
  from ..shared import DETAILS_KEY, get_dbname_from_details
20
- from .credentials import CredentialsDb, CredentialsDbKey, get_value
12
+ from .credentials import MetabaseDbCredentials
21
13
 
22
14
  logger = logging.getLogger(__name__)
23
15
 
@@ -25,16 +17,6 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
25
17
 
26
18
  SQL_FILE_PATH = "queries/{name}.sql"
27
19
 
28
- ENCRYPTION_SECRET_KEY = "CASTOR_METABASE_ENCRYPTION_SECRET_KEY" # noqa: S105
29
- REQUIRE_SSL_KEY = "CASTOR_METABASE_REQUIRE_SSL_KEY" # noqa: S105
30
-
31
-
32
- def _optional_arg(args: dict, client_key: str, env_key: str) -> Optional[str]:
33
- arg_value = args.get(client_key)
34
- if arg_value is not None:
35
- return arg_value
36
- return from_env(env_key, allow_missing=True)
37
-
38
20
 
39
21
  class DbClient(PostgresClient):
40
22
  """
@@ -52,20 +34,15 @@ class DbClient(PostgresClient):
52
34
 
53
35
  def __init__(
54
36
  self,
55
- **kwargs,
37
+ credentials: MetabaseDbCredentials,
56
38
  ):
57
- self._credentials = CredentialsDb.from_env(kwargs)
58
- self.encryption_secret_key = _optional_arg(
59
- kwargs, "encryption_secret_key", ENCRYPTION_SECRET_KEY
60
- )
61
- self.require_ssl = bool(
62
- _optional_arg(kwargs, "require_ssl", REQUIRE_SSL_KEY)
63
- )
39
+ self._credentials = credentials
40
+ self.require_ssl = self._credentials.require_ssl
64
41
  try:
65
- super().__init__(self._credentials.to_dict())
42
+ super().__init__(self._credentials.dict())
66
43
  except OperationalError as err:
67
44
  raise MetabaseLoginError(
68
- credentials_info=self._credentials.to_dict(hide=True),
45
+ credentials_info=self._credentials,
69
46
  error_details=err.args,
70
47
  )
71
48
 
@@ -75,9 +52,10 @@ class DbClient(PostgresClient):
75
52
  path = os.path.join(CURRENT_DIR, filename)
76
53
  with open(path, "r") as f:
77
54
  content = f.read()
78
- statement = content.format(schema=self._credentials.schema)
55
+ statement = content.format(schema=self._credentials.schema_)
79
56
  return ExtractionQuery(statement=statement, params={})
80
57
 
58
+ @property
81
59
  def base_url(self) -> str:
82
60
  """Fetches the `base_url` of the Metabase instance"""
83
61
  query = self._load_query(name="base_url")
@@ -94,12 +72,13 @@ class DbClient(PostgresClient):
94
72
  try:
95
73
  details = json.loads(db[DETAILS_KEY])
96
74
  except json.decoder.JSONDecodeError as err:
97
- if not self.encryption_secret_key:
75
+ encryption_key = self._credentials.encryption_secret_key
76
+ if not encryption_key:
98
77
  raise EncryptionSecretKeyRequired(
99
- credentials_info=self._credentials.to_dict(hide=True),
78
+ credentials_info=self._credentials,
100
79
  error_details=err.args,
101
80
  )
102
- decrypted = decrypt(db[DETAILS_KEY], self.encryption_secret_key)
81
+ decrypted = decrypt(db[DETAILS_KEY], encryption_key)
103
82
  details = json.loads(decrypted)
104
83
 
105
84
  db["dbname"] = get_dbname_from_details(details)
@@ -1,80 +1,26 @@
1
- from enum import Enum
2
- from typing import Dict
1
+ from typing import Optional
3
2
 
4
- from .....utils import from_env
3
+ from pydantic import Field, SecretStr
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
5
 
6
+ METABASE_DB_ENV_PREFIX = "CASTOR_METABASE_DB_"
6
7
 
7
- class CredentialsDbKey(Enum):
8
- HOST = "host"
9
- PORT = "port"
10
- DATABASE = "database"
11
- SCHEMA = "schema"
12
- USER = "user"
13
- PASSWORD = "password" # noqa: S105
14
8
 
9
+ class MetabaseDbCredentials(BaseSettings):
10
+ """Metabase's credentials to connect to Metabase DB"""
15
11
 
16
- CREDENTIALS_ENV: Dict[CredentialsDbKey, str] = {
17
- CredentialsDbKey.HOST: "CASTOR_METABASE_DB_HOST",
18
- CredentialsDbKey.PORT: "CASTOR_METABASE_DB_PORT",
19
- CredentialsDbKey.DATABASE: "CASTOR_METABASE_DB_DATABASE",
20
- CredentialsDbKey.SCHEMA: "CASTOR_METABASE_DB_SCHEMA",
21
- CredentialsDbKey.USER: "CASTOR_METABASE_DB_USERNAME",
22
- CredentialsDbKey.PASSWORD: "CASTOR_METABASE_DB_PASSWORD", # noqa: S105
23
- }
12
+ model_config = SettingsConfigDict(
13
+ env_prefix=METABASE_DB_ENV_PREFIX,
14
+ extra="ignore",
15
+ populate_by_name=True,
16
+ )
24
17
 
18
+ host: str
19
+ port: str
20
+ database: str
21
+ schema_: str = Field(validation_alias=f"{METABASE_DB_ENV_PREFIX}SCHEMA")
22
+ user: str = Field(validation_alias=f"{METABASE_DB_ENV_PREFIX}USERNAME")
23
+ password: str = Field(repr=False)
25
24
 
26
- def get_value(key: CredentialsDbKey, kwargs: dict) -> str:
27
- """
28
- Returns the value of the given key:
29
- - from kwargs in priority
30
- - from ENV if not provided (raises an error if not found in ENV)
31
- """
32
- env_key = CREDENTIALS_ENV[key]
33
- return str(kwargs.get(key.value) or from_env(env_key))
34
-
35
-
36
- class CredentialsDb:
37
- """ValueObject for the credentials"""
38
-
39
- def __init__(
40
- self,
41
- host: str,
42
- port: str,
43
- database: str,
44
- schema: str,
45
- user: str,
46
- password: str,
47
- ):
48
- self.host = host
49
- self.port = port
50
- self.database = database
51
- self.schema = schema
52
- self.user = user
53
- self.password = password
54
-
55
- @classmethod
56
- def from_env(cls, env: dict) -> "CredentialsDb":
57
- """returns a new CredentialsDb with values from ENV"""
58
- return CredentialsDb(
59
- host=get_value(CredentialsDbKey.HOST, env),
60
- port=get_value(CredentialsDbKey.PORT, env),
61
- database=get_value(CredentialsDbKey.DATABASE, env),
62
- schema=get_value(CredentialsDbKey.SCHEMA, env),
63
- user=get_value(CredentialsDbKey.USER, env),
64
- password=get_value(CredentialsDbKey.PASSWORD, env),
65
- )
66
-
67
- def to_dict(self, hide: bool = False) -> Dict[str, str]:
68
- safe = (
69
- CredentialsDbKey.HOST,
70
- CredentialsDbKey.PORT,
71
- CredentialsDbKey.DATABASE,
72
- CredentialsDbKey.SCHEMA,
73
- CredentialsDbKey.USER,
74
- )
75
- unsafe = (CredentialsDbKey.PASSWORD,)
76
-
77
- def val(k: CredentialsDbKey, v: str) -> str:
78
- return "*" + v[-4:] if hide and k in unsafe else v
79
-
80
- return {a.value: val(a, getattr(self, a.value)) for a in safe + unsafe}
25
+ encryption_secret_key: Optional[str] = Field(repr=False)
26
+ require_ssl: Optional[str]