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.
- CHANGELOG.md +20 -0
- castor_extractor/commands/extract_metabase_api.py +2 -1
- castor_extractor/commands/extract_metabase_db.py +3 -1
- castor_extractor/commands/extract_mode.py +2 -3
- castor_extractor/utils/__init__.py +2 -1
- castor_extractor/utils/collection.py +8 -0
- castor_extractor/utils/safe_request.py +57 -0
- castor_extractor/utils/safe_request_test.py +77 -0
- castor_extractor/utils/salesforce/__init__.py +1 -2
- castor_extractor/utils/salesforce/constants.py +0 -11
- castor_extractor/utils/salesforce/credentials.py +22 -45
- castor_extractor/visualization/domo/__init__.py +1 -1
- castor_extractor/visualization/domo/client/__init__.py +1 -1
- castor_extractor/visualization/domo/client/client.py +37 -52
- castor_extractor/visualization/domo/client/credentials.py +14 -27
- castor_extractor/visualization/domo/extract.py +6 -12
- castor_extractor/visualization/looker/__init__.py +6 -1
- castor_extractor/visualization/looker/api/__init__.py +2 -1
- castor_extractor/visualization/looker/api/client.py +6 -4
- castor_extractor/visualization/looker/api/client_test.py +5 -3
- castor_extractor/visualization/looker/api/credentials.py +33 -0
- castor_extractor/visualization/looker/api/extraction_parameters.py +38 -0
- castor_extractor/visualization/looker/api/sdk.py +2 -28
- castor_extractor/visualization/looker/constant.py +4 -21
- castor_extractor/visualization/looker/constants.py +17 -0
- castor_extractor/visualization/looker/extract.py +30 -29
- castor_extractor/visualization/metabase/__init__.py +6 -1
- castor_extractor/visualization/metabase/client/__init__.py +2 -2
- castor_extractor/visualization/metabase/client/api/__init__.py +1 -0
- castor_extractor/visualization/metabase/client/api/client.py +8 -14
- castor_extractor/visualization/metabase/client/api/credentials.py +13 -40
- castor_extractor/visualization/metabase/client/db/__init__.py +1 -0
- castor_extractor/visualization/metabase/client/db/client.py +13 -34
- castor_extractor/visualization/metabase/client/db/credentials.py +19 -73
- castor_extractor/visualization/metabase/errors.py +5 -3
- castor_extractor/visualization/metabase/extract.py +1 -1
- castor_extractor/visualization/mode/__init__.py +1 -1
- castor_extractor/visualization/mode/client/__init__.py +1 -0
- castor_extractor/visualization/mode/client/client.py +9 -12
- castor_extractor/visualization/mode/client/client_test.py +3 -3
- castor_extractor/visualization/mode/client/credentials.py +18 -51
- castor_extractor/visualization/mode/extract.py +5 -3
- castor_extractor/visualization/powerbi/__init__.py +1 -1
- castor_extractor/visualization/powerbi/client/__init__.py +2 -1
- castor_extractor/visualization/powerbi/client/credentials.py +17 -9
- castor_extractor/visualization/powerbi/client/credentials_test.py +12 -4
- castor_extractor/visualization/powerbi/client/rest.py +2 -2
- castor_extractor/visualization/powerbi/client/rest_test.py +2 -2
- castor_extractor/visualization/powerbi/extract.py +2 -2
- castor_extractor/visualization/qlik/__init__.py +5 -1
- castor_extractor/visualization/qlik/client/__init__.py +1 -0
- castor_extractor/visualization/qlik/client/engine/__init__.py +1 -0
- castor_extractor/visualization/qlik/client/engine/client.py +5 -6
- castor_extractor/visualization/qlik/client/engine/credentials.py +26 -0
- castor_extractor/visualization/qlik/client/master.py +5 -11
- castor_extractor/visualization/qlik/client/rest.py +4 -4
- castor_extractor/visualization/qlik/client/rest_test.py +6 -2
- castor_extractor/visualization/qlik/extract.py +4 -8
- castor_extractor/visualization/sigma/__init__.py +1 -1
- castor_extractor/visualization/sigma/client/__init__.py +1 -1
- castor_extractor/visualization/sigma/client/client.py +12 -5
- castor_extractor/visualization/sigma/client/credentials.py +12 -28
- castor_extractor/visualization/sigma/extract.py +4 -8
- castor_extractor/visualization/tableau_revamp/client/credentials.py +40 -87
- castor_extractor/warehouse/redshift/queries/column.sql +0 -5
- castor_extractor/warehouse/salesforce/extract.py +2 -2
- castor_extractor/warehouse/snowflake/queries/column.sql +0 -1
- castor_extractor/warehouse/synapse/queries/column.sql +0 -1
- {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/METADATA +9 -9
- {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/RECORD +73 -73
- castor_extractor/visualization/domo/client/client_test.py +0 -60
- castor_extractor/visualization/domo/constants.py +0 -6
- castor_extractor/visualization/looker/env.py +0 -48
- castor_extractor/visualization/looker/parameters.py +0 -78
- castor_extractor/visualization/qlik/constants.py +0 -3
- castor_extractor/visualization/sigma/constants.py +0 -4
- {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/LICENCE +0 -0
- {castor_extractor-0.17.3.dist-info → castor_extractor-0.18.2.dist-info}/WHEEL +0 -0
- {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 ..
|
|
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 .
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
2
|
+
Safe mode parameters
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
KEY_LOOKER_TIMEOUT_SECOND = "CASTOR_LOOKER_TIMEOUT_SECOND"
|
|
5
|
+
from looker_sdk.error import SDKError
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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(
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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,
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
135
|
+
if extraction_parameters.log_to_stdout:
|
|
132
136
|
set_stream_handler_to_stdout()
|
|
133
137
|
|
|
134
|
-
|
|
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
|
-
|
|
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=
|
|
149
|
-
thread_pool_size=
|
|
150
|
-
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,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
|
|
@@ -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
|
|
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
|
-
|
|
33
|
+
credentials: MetabaseApiCredentials,
|
|
34
34
|
):
|
|
35
|
-
self.
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
|
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
|
|
98
|
+
credentials_info=self._credentials,
|
|
105
99
|
error_details=response.json(),
|
|
106
100
|
)
|
|
107
101
|
|
|
@@ -1,45 +1,18 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
1
|
+
from pydantic import Field, SecretStr
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
METABASE_API_ENV_PREFIX = "CASTOR_METABASE_API_"
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
class
|
|
8
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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,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
|
|
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
|
-
|
|
37
|
+
credentials: MetabaseDbCredentials,
|
|
56
38
|
):
|
|
57
|
-
self._credentials =
|
|
58
|
-
self.
|
|
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.
|
|
42
|
+
super().__init__(self._credentials.dict())
|
|
66
43
|
except OperationalError as err:
|
|
67
44
|
raise MetabaseLoginError(
|
|
68
|
-
credentials_info=self._credentials
|
|
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.
|
|
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
|
-
|
|
75
|
+
encryption_key = self._credentials.encryption_secret_key
|
|
76
|
+
if not encryption_key:
|
|
98
77
|
raise EncryptionSecretKeyRequired(
|
|
99
|
-
credentials_info=self._credentials
|
|
78
|
+
credentials_info=self._credentials,
|
|
100
79
|
error_details=err.args,
|
|
101
80
|
)
|
|
102
|
-
decrypted = decrypt(db[DETAILS_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
|
|
2
|
-
from typing import Dict
|
|
1
|
+
from typing import Optional
|
|
3
2
|
|
|
4
|
-
from
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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]
|