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
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.2 - 2024-07-08
|
|
4
|
+
|
|
5
|
+
* Added StatusCode handling to SafeMode
|
|
6
|
+
|
|
7
|
+
## 0.18.1 - 2024-07-04
|
|
8
|
+
|
|
9
|
+
* Bump dependencies: numpy, setuptools, tableauserverclient
|
|
10
|
+
|
|
11
|
+
## 0.18.0 - 2024-07-03
|
|
12
|
+
|
|
13
|
+
* Dashboarding technologies : Reworked credentials using Pydantic
|
|
14
|
+
|
|
15
|
+
## 0.17.5 - 2024-07-03
|
|
16
|
+
|
|
17
|
+
* Snowflake, Synapse, Redshift: Remove default_value from the extracted column
|
|
18
|
+
|
|
19
|
+
## 0.17.4 - 2024-07-03
|
|
20
|
+
|
|
21
|
+
* Sigma: Add `input-table`, `pivot-table` and `viz` in the list of supported **Elements**
|
|
22
|
+
|
|
3
23
|
## 0.17.3 - 2024-06-24
|
|
4
24
|
|
|
5
25
|
* Databricks: extract tags for tables and column
|
|
@@ -17,11 +17,12 @@ def main():
|
|
|
17
17
|
|
|
18
18
|
args = parser.parse_args()
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
credentials = metabase.MetabaseApiCredentials(
|
|
21
21
|
base_url=args.base_url,
|
|
22
22
|
user=args.username,
|
|
23
23
|
password=args.password,
|
|
24
24
|
)
|
|
25
|
+
client = metabase.ApiClient(credentials)
|
|
25
26
|
|
|
26
27
|
metabase.extract_all(
|
|
27
28
|
client,
|
|
@@ -35,7 +35,7 @@ def main():
|
|
|
35
35
|
|
|
36
36
|
args = parser.parse_args()
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
credentials = metabase.MetabaseDbCredentials(
|
|
39
39
|
host=args.host,
|
|
40
40
|
port=args.port,
|
|
41
41
|
database=args.database,
|
|
@@ -46,6 +46,8 @@ def main():
|
|
|
46
46
|
require_ssl=args.require_ssl,
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
+
client = metabase.DbClient(credentials)
|
|
50
|
+
|
|
49
51
|
metabase.extract_all(
|
|
50
52
|
client,
|
|
51
53
|
output_directory=args.output,
|
|
@@ -25,8 +25,7 @@ def main():
|
|
|
25
25
|
parser.add_argument("-o", "--output", help="Directory to write to")
|
|
26
26
|
|
|
27
27
|
args = parser.parse_args()
|
|
28
|
-
|
|
29
|
-
client = mode.Client(
|
|
28
|
+
credentials = mode.Credentials(
|
|
30
29
|
host=args.host,
|
|
31
30
|
workspace=args.workspace,
|
|
32
31
|
token=args.token,
|
|
@@ -34,6 +33,6 @@ def main():
|
|
|
34
33
|
)
|
|
35
34
|
|
|
36
35
|
mode.extract_all(
|
|
37
|
-
|
|
36
|
+
credentials,
|
|
38
37
|
output_directory=args.output,
|
|
39
38
|
)
|
|
@@ -5,7 +5,7 @@ from .client import (
|
|
|
5
5
|
SqlalchemyClient,
|
|
6
6
|
uri_encode,
|
|
7
7
|
)
|
|
8
|
-
from .collection import group_by, mapping_from_rows
|
|
8
|
+
from .collection import empty_iterator, group_by, mapping_from_rows
|
|
9
9
|
from .constants import OUTPUT_DIR
|
|
10
10
|
from .deprecate import deprecate_python
|
|
11
11
|
from .env import from_env
|
|
@@ -23,6 +23,7 @@ from .pager import (
|
|
|
23
23
|
)
|
|
24
24
|
from .retry import RetryStrategy, retry
|
|
25
25
|
from .safe import SafeMode, safe_mode
|
|
26
|
+
from .safe_request import RequestSafeMode, ResponseJson, handle_response
|
|
26
27
|
from .store import AbstractStorage, LocalStorage
|
|
27
28
|
from .string import decode_when_bytes, string_to_tuple
|
|
28
29
|
from .time import (
|
|
@@ -44,3 +44,11 @@ def mapping_from_rows(rows: List[Dict], key: Any, value: Any) -> Dict:
|
|
|
44
44
|
mapping[mapping_key] = mapping_value
|
|
45
45
|
|
|
46
46
|
return mapping
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def empty_iterator():
|
|
50
|
+
"""
|
|
51
|
+
Utils to return empty iterator, mainly used for viz transformers
|
|
52
|
+
Remark: missing return type is on purpose, it breaks the typing
|
|
53
|
+
"""
|
|
54
|
+
return iter([])
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List, Tuple, Union
|
|
3
|
+
|
|
4
|
+
from requests import HTTPError, Response
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
ResponseJson = Union[dict, List[dict]]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestSafeMode:
|
|
12
|
+
"""
|
|
13
|
+
RequestSafeMode class to parameterize what should be done if response
|
|
14
|
+
raises due to the status code.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
self.status_codes: tuple of status codes that will be caught
|
|
18
|
+
self.errors_caught : list of errors caught
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
max_errors: Union[int, float] = 0,
|
|
24
|
+
status_codes: Tuple[int, ...] = (),
|
|
25
|
+
):
|
|
26
|
+
self.max_errors = max_errors
|
|
27
|
+
self.status_codes: List[int] = list(status_codes)
|
|
28
|
+
self.status_codes_caught: List[int] = []
|
|
29
|
+
|
|
30
|
+
def catch_response(self, exception: HTTPError, status_code: int):
|
|
31
|
+
if int(status_code) not in self.status_codes:
|
|
32
|
+
raise exception
|
|
33
|
+
|
|
34
|
+
self.status_codes_caught.append(int(status_code))
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def should_raise(self) -> bool:
|
|
38
|
+
return len(self.status_codes_caught) > self.max_errors
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def handle_response(
|
|
42
|
+
response: Response, safe_mode: RequestSafeMode
|
|
43
|
+
) -> ResponseJson:
|
|
44
|
+
"""
|
|
45
|
+
Util to handle a HTTP Response based on the response status code and the
|
|
46
|
+
safe mode used
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
response.raise_for_status()
|
|
50
|
+
except HTTPError as e:
|
|
51
|
+
safe_mode.catch_response(e, response.status_code)
|
|
52
|
+
if safe_mode.should_raise:
|
|
53
|
+
raise e
|
|
54
|
+
logger.error(f"Safe mode : skip request with error {e}")
|
|
55
|
+
logger.debug(e, exc_info=True)
|
|
56
|
+
return {}
|
|
57
|
+
return response.json()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from requests import HTTPError, Response
|
|
6
|
+
|
|
7
|
+
from .safe_request import RequestSafeMode, handle_response
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def mock_response(status_code: int):
|
|
11
|
+
response = Response()
|
|
12
|
+
response.status_code = status_code
|
|
13
|
+
response.raw = io.BytesIO(b'[{"data": "working"}]')
|
|
14
|
+
return response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_http_error_with_no_safe_mode():
|
|
18
|
+
safe_params = RequestSafeMode() # Caught
|
|
19
|
+
|
|
20
|
+
with pytest.raises(HTTPError):
|
|
21
|
+
handle_response(mock_response(HTTPStatus.FORBIDDEN), safe_params)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_http_error_with_no_status_code():
|
|
25
|
+
safe_params = RequestSafeMode(2) # Caught
|
|
26
|
+
|
|
27
|
+
with pytest.raises(HTTPError):
|
|
28
|
+
handle_response(mock_response(HTTPStatus.FORBIDDEN), safe_params)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_http_error_with_status_code():
|
|
32
|
+
safe_params = RequestSafeMode(2, (HTTPStatus.FORBIDDEN,)) # Caught
|
|
33
|
+
|
|
34
|
+
def call():
|
|
35
|
+
return handle_response(mock_response(HTTPStatus.FORBIDDEN), safe_params)
|
|
36
|
+
|
|
37
|
+
assert call() == {}
|
|
38
|
+
assert call() == {}
|
|
39
|
+
|
|
40
|
+
with pytest.raises(HTTPError):
|
|
41
|
+
call()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_http_error_with_multiple_status_code():
|
|
45
|
+
safe_params = RequestSafeMode(
|
|
46
|
+
2, (HTTPStatus.NOT_FOUND, HTTPStatus.FORBIDDEN)
|
|
47
|
+
) # Caught
|
|
48
|
+
|
|
49
|
+
def call():
|
|
50
|
+
return handle_response(mock_response(HTTPStatus.FORBIDDEN), safe_params)
|
|
51
|
+
|
|
52
|
+
def call_2():
|
|
53
|
+
return handle_response(mock_response(HTTPStatus.NOT_FOUND), safe_params)
|
|
54
|
+
|
|
55
|
+
assert call() == {}
|
|
56
|
+
assert call_2() == {}
|
|
57
|
+
with pytest.raises(HTTPError): # 3 failed calls > retries
|
|
58
|
+
call()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_http_error_with_wrong_status_code():
|
|
62
|
+
safe_params = RequestSafeMode(2, (HTTPStatus.NOT_FOUND,)) # Wrong Status
|
|
63
|
+
|
|
64
|
+
def call():
|
|
65
|
+
handle_response(mock_response(HTTPStatus.BAD_REQUEST), safe_params)
|
|
66
|
+
|
|
67
|
+
with pytest.raises(HTTPError):
|
|
68
|
+
call()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_http_error_with_return():
|
|
72
|
+
safe_params = RequestSafeMode(2, (HTTPStatus.NOT_FOUND,)) # Wrong Status
|
|
73
|
+
|
|
74
|
+
def call():
|
|
75
|
+
return handle_response(mock_response(HTTPStatus.OK), safe_params)
|
|
76
|
+
|
|
77
|
+
assert call() == [{"data": "working"}]
|
|
@@ -1,13 +1,2 @@
|
|
|
1
1
|
DEFAULT_API_VERSION = 59.0
|
|
2
2
|
DEFAULT_PAGINATION_LIMIT = 100
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class Keys:
|
|
6
|
-
"""Salesforce's credentials keys"""
|
|
7
|
-
|
|
8
|
-
USERNAME = "username"
|
|
9
|
-
PASSWORD = "password" # noqa: S105
|
|
10
|
-
CLIENT_ID = "client_id"
|
|
11
|
-
CLIENT_SECRET = "client_secret" # noqa: S105
|
|
12
|
-
SECURITY_TOKEN = "security_token" # noqa: S105
|
|
13
|
-
BASE_URL = "base_url"
|
|
@@ -1,36 +1,33 @@
|
|
|
1
1
|
from typing import Dict
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
_PASSWORD = "CASTOR_SALESFORCE_PASSWORD" # noqa: S105
|
|
8
|
-
_SECURITY_TOKEN = "CASTOR_SALESFORCE_SECURITY_TOKEN" # noqa: S105
|
|
9
|
-
_CLIENT_ID = "CASTOR_SALESFORCE_CLIENT_ID"
|
|
10
|
-
_CLIENT_SECRET = "CASTOR_SALESFORCE_CLIENT_SECRET" # noqa: S105
|
|
11
|
-
_BASE_URL = "CASTOR_SALESFORCE_BASE_URL"
|
|
6
|
+
CASTOR_ENV_PREFIX = "CASTOR_SALESFORCE_"
|
|
12
7
|
|
|
13
8
|
|
|
14
|
-
class SalesforceCredentials:
|
|
9
|
+
class SalesforceCredentials(BaseSettings):
|
|
15
10
|
"""
|
|
16
11
|
Class to handle Salesforce rest API permissions
|
|
17
12
|
"""
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
14
|
+
model_config = SettingsConfigDict(
|
|
15
|
+
env_prefix=CASTOR_ENV_PREFIX,
|
|
16
|
+
extra="ignore",
|
|
17
|
+
populate_by_name=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
base_url: str
|
|
21
|
+
client_id: str
|
|
22
|
+
client_secret: str = Field(repr=False)
|
|
23
|
+
password: str = Field(repr=False)
|
|
24
|
+
security_token: str = Field(repr=False)
|
|
25
|
+
username: str
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def password_token(self) -> str:
|
|
29
|
+
"""Generates the password for authentication"""
|
|
30
|
+
return self.password + self.security_token
|
|
34
31
|
|
|
35
32
|
def token_request_payload(self) -> Dict[str, str]:
|
|
36
33
|
"""
|
|
@@ -41,25 +38,5 @@ class SalesforceCredentials:
|
|
|
41
38
|
"client_id": self.client_id,
|
|
42
39
|
"client_secret": self.client_secret,
|
|
43
40
|
"username": self.username,
|
|
44
|
-
"password": self.
|
|
41
|
+
"password": self.password_token,
|
|
45
42
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def to_credentials(params: dict) -> SalesforceCredentials:
|
|
49
|
-
"""extract Salesforce credentials"""
|
|
50
|
-
username = params.get(Keys.USERNAME) or from_env(_USERNAME)
|
|
51
|
-
password = params.get(Keys.PASSWORD) or from_env(_PASSWORD)
|
|
52
|
-
security_token = params.get(Keys.SECURITY_TOKEN) or from_env(
|
|
53
|
-
_SECURITY_TOKEN
|
|
54
|
-
)
|
|
55
|
-
client_id = params.get(Keys.CLIENT_ID) or from_env(_CLIENT_ID)
|
|
56
|
-
client_secret = params.get(Keys.CLIENT_SECRET) or from_env(_CLIENT_SECRET)
|
|
57
|
-
base_url = params.get(Keys.BASE_URL) or from_env(_BASE_URL)
|
|
58
|
-
return SalesforceCredentials(
|
|
59
|
-
username=username,
|
|
60
|
-
password=password,
|
|
61
|
-
client_id=client_id,
|
|
62
|
-
client_secret=client_secret,
|
|
63
|
-
security_token=security_token,
|
|
64
|
-
base_url=base_url,
|
|
65
|
-
)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
from .client import DomoClient
|
|
2
|
-
from .credentials import
|
|
2
|
+
from .credentials import DomoCredentials
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from datetime import datetime, timedelta
|
|
3
3
|
from http import HTTPStatus
|
|
4
|
-
from typing import Iterator, List, Optional, Set
|
|
4
|
+
from typing import Iterator, List, Optional, Set
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
|
-
from ....utils import
|
|
8
|
+
from ....utils import (
|
|
9
|
+
RequestSafeMode,
|
|
10
|
+
ResponseJson,
|
|
11
|
+
at_midnight,
|
|
12
|
+
current_date,
|
|
13
|
+
empty_iterator,
|
|
14
|
+
handle_response,
|
|
15
|
+
past_date,
|
|
16
|
+
retry,
|
|
17
|
+
timestamp_ms,
|
|
18
|
+
)
|
|
9
19
|
from ..assets import DomoAsset
|
|
10
20
|
from .credentials import DomoCredentials
|
|
11
21
|
from .endpoints import Endpoint, EndpointFactory
|
|
@@ -17,11 +27,17 @@ DOMO_PUBLIC_URL = "https://api.domo.com"
|
|
|
17
27
|
DEFAULT_TIMEOUT = 120
|
|
18
28
|
TOKEN_EXPIRATION_SECONDS = timedelta(seconds=3000) # auth token lasts 1 hour
|
|
19
29
|
|
|
30
|
+
|
|
31
|
+
# Safe Mode
|
|
32
|
+
VOLUME_IGNORED = 10
|
|
20
33
|
IGNORED_ERROR_CODES = (
|
|
21
34
|
HTTPStatus.BAD_REQUEST,
|
|
22
35
|
HTTPStatus.NOT_FOUND,
|
|
23
36
|
)
|
|
24
|
-
|
|
37
|
+
DOMO_SAFE_MODE = RequestSafeMode(
|
|
38
|
+
max_errors=VOLUME_IGNORED,
|
|
39
|
+
status_codes=IGNORED_ERROR_CODES,
|
|
40
|
+
)
|
|
25
41
|
|
|
26
42
|
_RETRY_EXCEPTIONS = [
|
|
27
43
|
requests.exceptions.ConnectTimeout,
|
|
@@ -33,40 +49,17 @@ _RETRY_BASE_MS = 10 * 60 * 1000 # 10 minutes
|
|
|
33
49
|
logger = logging.getLogger(__name__)
|
|
34
50
|
|
|
35
51
|
|
|
36
|
-
def _handle_response(response: requests.Response) -> requests.Response:
|
|
37
|
-
response.raise_for_status()
|
|
38
|
-
return response
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _ignore_or_raise(
|
|
42
|
-
error: requests.RequestException,
|
|
43
|
-
ignore_error_codes: Optional[Tuple[int, ...]],
|
|
44
|
-
) -> dict:
|
|
45
|
-
"""
|
|
46
|
-
Raises the error unless the response status code is in the ignored error
|
|
47
|
-
codes list.
|
|
48
|
-
"""
|
|
49
|
-
if not ignore_error_codes:
|
|
50
|
-
raise error
|
|
51
|
-
|
|
52
|
-
response = error.response
|
|
53
|
-
if response is None:
|
|
54
|
-
raise error
|
|
55
|
-
|
|
56
|
-
if response.status_code in ignore_error_codes:
|
|
57
|
-
logger.warning(error)
|
|
58
|
-
return {}
|
|
59
|
-
|
|
60
|
-
raise error
|
|
61
|
-
|
|
62
|
-
|
|
63
52
|
class DomoClient:
|
|
64
53
|
"""
|
|
65
54
|
Connect to Domo API and fetch main assets.
|
|
66
55
|
https://developer.domo.com/portal/8ba9aedad3679-ap-is#platform-oauth-apis
|
|
67
56
|
"""
|
|
68
57
|
|
|
69
|
-
def __init__(
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
credentials: DomoCredentials,
|
|
61
|
+
safe_mode: Optional[RequestSafeMode] = None,
|
|
62
|
+
):
|
|
70
63
|
self._authentication = credentials.authentication
|
|
71
64
|
self._bearer_headers: Optional[dict] = None
|
|
72
65
|
self._session = requests.session()
|
|
@@ -76,6 +69,7 @@ class DomoClient:
|
|
|
76
69
|
self._timeout = DEFAULT_TIMEOUT
|
|
77
70
|
self.base_url = credentials.base_url
|
|
78
71
|
self.cloud_id = credentials.cloud_id
|
|
72
|
+
self.safe_mode = safe_mode or DOMO_SAFE_MODE
|
|
79
73
|
|
|
80
74
|
def _token_expired(self) -> bool:
|
|
81
75
|
token_lifetime = datetime.now() - self._token_creation_time
|
|
@@ -95,7 +89,8 @@ class DomoClient:
|
|
|
95
89
|
auth=basic_authentication,
|
|
96
90
|
timeout=self._timeout,
|
|
97
91
|
)
|
|
98
|
-
|
|
92
|
+
response.raise_for_status()
|
|
93
|
+
result = response.json()
|
|
99
94
|
|
|
100
95
|
bearer_token = result["access_token"]
|
|
101
96
|
self._bearer_headers = {"authorization": f"Bearer {bearer_token}"}
|
|
@@ -113,7 +108,7 @@ class DomoClient:
|
|
|
113
108
|
endpoint: Endpoint,
|
|
114
109
|
params: Optional[dict] = None,
|
|
115
110
|
asset_id: Optional[str] = None,
|
|
116
|
-
) ->
|
|
111
|
+
) -> ResponseJson:
|
|
117
112
|
params = params if params else {}
|
|
118
113
|
is_private = endpoint.is_private
|
|
119
114
|
headers = self._private_headers if is_private else self._bearer_auth()
|
|
@@ -125,27 +120,16 @@ class DomoClient:
|
|
|
125
120
|
timeout=self._timeout,
|
|
126
121
|
)
|
|
127
122
|
|
|
128
|
-
|
|
129
|
-
logger.warning(
|
|
130
|
-
ERROR_TPL.format(
|
|
131
|
-
status_code=response.status_code,
|
|
132
|
-
reason=response.reason,
|
|
133
|
-
)
|
|
134
|
-
)
|
|
135
|
-
return response
|
|
123
|
+
return handle_response(response, self.safe_mode)
|
|
136
124
|
|
|
137
125
|
def _get_element(
|
|
138
126
|
self,
|
|
139
127
|
endpoint: Endpoint,
|
|
140
128
|
params: Optional[dict] = None,
|
|
141
129
|
asset_id: Optional[str] = None,
|
|
142
|
-
ignore_error_codes: Optional[Tuple[int, ...]] = None,
|
|
143
130
|
) -> dict:
|
|
144
131
|
"""Used when the response only contains one element"""
|
|
145
|
-
|
|
146
|
-
return self._get(endpoint, params, asset_id).json()
|
|
147
|
-
except requests.RequestException as error:
|
|
148
|
-
return _ignore_or_raise(error, ignore_error_codes)
|
|
132
|
+
return self._get(endpoint, params, asset_id)
|
|
149
133
|
|
|
150
134
|
def _get_many(
|
|
151
135
|
self,
|
|
@@ -154,7 +138,7 @@ class DomoClient:
|
|
|
154
138
|
asset_id: Optional[str] = None,
|
|
155
139
|
) -> List[dict]:
|
|
156
140
|
"""Used when the response contains multiple elements"""
|
|
157
|
-
return self._get(endpoint, params, asset_id)
|
|
141
|
+
return self._get(endpoint, params, asset_id)
|
|
158
142
|
|
|
159
143
|
def _get_paginated(self, endpoint: Endpoint) -> List[dict]:
|
|
160
144
|
"""Used when the response is paginated and need iterations"""
|
|
@@ -172,10 +156,7 @@ class DomoClient:
|
|
|
172
156
|
|
|
173
157
|
def _datasources(self, page_id: str) -> RawData:
|
|
174
158
|
endpoint = self._endpoint_factory.page_content(page_id)
|
|
175
|
-
page_content = self._get_element(
|
|
176
|
-
endpoint,
|
|
177
|
-
ignore_error_codes=IGNORED_ERROR_CODES,
|
|
178
|
-
)
|
|
159
|
+
page_content = self._get_element(endpoint)
|
|
179
160
|
processed: set[str] = set()
|
|
180
161
|
for card in page_content.get("cards", []):
|
|
181
162
|
for datasource in card["datasources"]:
|
|
@@ -196,7 +177,7 @@ class DomoClient:
|
|
|
196
177
|
) -> Iterator[dict]:
|
|
197
178
|
"""Recursively fetch pages while building the folder architecture"""
|
|
198
179
|
if not page_tree:
|
|
199
|
-
return
|
|
180
|
+
return empty_iterator()
|
|
200
181
|
|
|
201
182
|
for page in page_tree:
|
|
202
183
|
page_id = page.get("id")
|
|
@@ -211,6 +192,10 @@ class DomoClient:
|
|
|
211
192
|
self._endpoint_factory.pages,
|
|
212
193
|
asset_id=page_id,
|
|
213
194
|
)
|
|
195
|
+
|
|
196
|
+
if not detail:
|
|
197
|
+
continue
|
|
198
|
+
|
|
214
199
|
datasources = self._datasources(page_id)
|
|
215
200
|
yield {
|
|
216
201
|
**detail,
|
|
@@ -1,39 +1,26 @@
|
|
|
1
|
-
from
|
|
2
|
-
from enum import Enum
|
|
3
|
-
from typing import Dict, Optional, Tuple
|
|
1
|
+
from typing import Dict, Optional
|
|
4
2
|
|
|
3
|
+
from pydantic import Field, SecretStr
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
5
|
from requests.auth import HTTPBasicAuth
|
|
6
6
|
|
|
7
|
+
DOMO_ENV_PREFIX = "CASTOR_DOMO_"
|
|
7
8
|
|
|
8
|
-
class CredentialsKey(Enum):
|
|
9
|
-
"""Value enum object for the credentials"""
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
BASE_URL = "base_url"
|
|
13
|
-
CLIENT_ID = "client_id"
|
|
14
|
-
CLOUD_ID = "cloud_id"
|
|
15
|
-
DEVELOPER_TOKEN = "developer_token" # noqa: S105
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
CLIENT_ALLOWED_KEYS: Tuple[str, ...] = tuple(c.value for c in CredentialsKey)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass
|
|
22
|
-
class DomoCredentials:
|
|
10
|
+
class DomoCredentials(BaseSettings):
|
|
23
11
|
"""Class to handle Domo rest API permissions"""
|
|
24
12
|
|
|
25
|
-
|
|
13
|
+
model_config = SettingsConfigDict(
|
|
14
|
+
env_prefix=DOMO_ENV_PREFIX,
|
|
15
|
+
extra="ignore",
|
|
16
|
+
populate_by_name=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
api_token: str = Field(repr=False)
|
|
26
20
|
base_url: str
|
|
27
21
|
client_id: str
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@classmethod
|
|
32
|
-
def from_secret(cls, secret: dict) -> "DomoCredentials":
|
|
33
|
-
credentials = {
|
|
34
|
-
k: v for k, v in secret.items() if k in CLIENT_ALLOWED_KEYS
|
|
35
|
-
}
|
|
36
|
-
return cls(**credentials)
|
|
22
|
+
cloud_id: Optional[str] = Field(validation_alias="CLOUD_ID", default=None)
|
|
23
|
+
developer_token: str = Field(repr=False)
|
|
37
24
|
|
|
38
25
|
@property
|
|
39
26
|
def authentication(self) -> HTTPBasicAuth:
|
|
@@ -12,7 +12,6 @@ from ...utils import (
|
|
|
12
12
|
)
|
|
13
13
|
from .assets import DomoAsset
|
|
14
14
|
from .client import DomoClient, DomoCredentials
|
|
15
|
-
from .constants import API_TOKEN, BASE_URL, CLIENT_ID, CLOUD_ID, DEVELOPER_TOKEN
|
|
16
15
|
|
|
17
16
|
logger = logging.getLogger(__name__)
|
|
18
17
|
|
|
@@ -57,18 +56,13 @@ def extract_all(
|
|
|
57
56
|
"""
|
|
58
57
|
|
|
59
58
|
_output_directory = output_directory or from_env(OUTPUT_DIR)
|
|
60
|
-
_client_id = client_id or from_env(CLIENT_ID)
|
|
61
|
-
_base_url = base_url or from_env(BASE_URL)
|
|
62
|
-
_api_token = api_token or from_env(API_TOKEN)
|
|
63
|
-
_developer_token = developer_token or from_env(DEVELOPER_TOKEN)
|
|
64
|
-
_cloud_id = cloud_id or from_env(CLOUD_ID)
|
|
65
59
|
|
|
66
60
|
credentials = DomoCredentials(
|
|
67
|
-
base_url=
|
|
68
|
-
client_id=
|
|
69
|
-
api_token=
|
|
70
|
-
developer_token=
|
|
71
|
-
cloud_id=
|
|
61
|
+
base_url=base_url,
|
|
62
|
+
client_id=client_id,
|
|
63
|
+
api_token=api_token,
|
|
64
|
+
developer_token=developer_token,
|
|
65
|
+
cloud_id=cloud_id,
|
|
72
66
|
)
|
|
73
67
|
client = DomoClient(credentials=credentials)
|
|
74
68
|
|
|
@@ -78,4 +72,4 @@ def extract_all(
|
|
|
78
72
|
filename = get_output_filename(key.name.lower(), _output_directory, ts)
|
|
79
73
|
write_json(filename, data)
|
|
80
74
|
|
|
81
|
-
write_summary(_output_directory, ts, base_url=
|
|
75
|
+
write_summary(_output_directory, ts, base_url=credentials.base_url)
|