castor-extractor 0.16.6__py3-none-any.whl → 0.16.9__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 +13 -0
- castor_extractor/utils/__init__.py +2 -1
- castor_extractor/utils/collection.py +32 -0
- castor_extractor/utils/collection_test.py +60 -0
- castor_extractor/utils/time.py +9 -1
- castor_extractor/utils/time_test.py +8 -1
- castor_extractor/visualization/domo/client/client.py +28 -43
- castor_extractor/visualization/domo/client/client_test.py +1 -23
- castor_extractor/visualization/domo/client/endpoints.py +13 -6
- castor_extractor/visualization/domo/client/pagination.py +4 -0
- castor_extractor/visualization/looker/api/client.py +21 -17
- castor_extractor/visualization/looker/api/sdk.py +10 -58
- castor_extractor/visualization/looker/api/utils.py +1 -1
- castor_extractor/visualization/looker/extract.py +2 -1
- castor_extractor/visualization/looker/multithreading.py +1 -1
- castor_extractor/visualization/tableau_revamp/client/client.py +76 -13
- castor_extractor/visualization/tableau_revamp/client/gql_queries.py +18 -15
- castor_extractor/visualization/tableau_revamp/client/tsc_fields.py +4 -0
- castor_extractor/warehouse/databricks/client.py +12 -5
- castor_extractor/warehouse/databricks/client_test.py +22 -3
- castor_extractor/warehouse/databricks/format.py +5 -1
- {castor_extractor-0.16.6.dist-info → castor_extractor-0.16.9.dist-info}/METADATA +6 -3
- {castor_extractor-0.16.6.dist-info → castor_extractor-0.16.9.dist-info}/RECORD +26 -25
- {castor_extractor-0.16.6.dist-info → castor_extractor-0.16.9.dist-info}/LICENCE +0 -0
- {castor_extractor-0.16.6.dist-info → castor_extractor-0.16.9.dist-info}/WHEEL +0 -0
- {castor_extractor-0.16.6.dist-info → castor_extractor-0.16.9.dist-info}/entry_points.txt +0 -0
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.9 - 2024-05-28
|
|
4
|
+
|
|
5
|
+
* Tableau: extract only fields that are necessary
|
|
6
|
+
|
|
7
|
+
## 0.16.8 - 2024-05-21
|
|
8
|
+
|
|
9
|
+
* Add compatibility with python 3.12
|
|
10
|
+
* Looker: Bump looker-sdk and refactor client
|
|
11
|
+
|
|
12
|
+
## 0.16.7 - 2024-05-16
|
|
13
|
+
|
|
14
|
+
* Databricks: allow no emails on user
|
|
15
|
+
|
|
3
16
|
## 0.16.6 - 2024-05-14
|
|
4
17
|
|
|
5
18
|
* Introducing the revamped connector for Tableau
|
|
@@ -5,7 +5,7 @@ from .client import (
|
|
|
5
5
|
SqlalchemyClient,
|
|
6
6
|
uri_encode,
|
|
7
7
|
)
|
|
8
|
-
from .collection import group_by
|
|
8
|
+
from .collection import 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
|
|
@@ -32,6 +32,7 @@ from .time import (
|
|
|
32
32
|
current_timestamp,
|
|
33
33
|
date_after,
|
|
34
34
|
past_date,
|
|
35
|
+
timestamp_ms,
|
|
35
36
|
)
|
|
36
37
|
from .type import Callback, Getter, JsonType, SerializedAsset
|
|
37
38
|
from .validation import validate_baseurl
|
|
@@ -12,3 +12,35 @@ def group_by(identifier: Getter, elements: Sequence) -> Dict[Any, List]:
|
|
|
12
12
|
groups[key].append(element)
|
|
13
13
|
|
|
14
14
|
return groups
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mapping_from_rows(rows: List[Dict], key: Any, value: Any) -> Dict:
|
|
18
|
+
"""
|
|
19
|
+
Create a dictionary mapping from a list of dictionaries using specified keys for mapping.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
rows (list[dict]): A list of dictionaries from which to create the mapping.
|
|
23
|
+
key (Any): The key to use for the keys of the resulting dictionary.
|
|
24
|
+
value (Any): The key to use for the values of the resulting dictionary.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
dict: A dictionary where each key-value pair corresponds to the specified key and value
|
|
28
|
+
from each dictionary in the input list. Only dictionaries with both specified key
|
|
29
|
+
and value present are included in the result.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
rows = [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
|
|
33
|
+
mapping = mapping_from_rows(rows, 'id', 'name')
|
|
34
|
+
# mapping will be {1: 'Alice', 2: 'Bob'}
|
|
35
|
+
"""
|
|
36
|
+
mapping = {}
|
|
37
|
+
|
|
38
|
+
for row in rows:
|
|
39
|
+
mapping_key = row.get(key)
|
|
40
|
+
mapping_value = row.get(value)
|
|
41
|
+
|
|
42
|
+
if not mapping_key or not mapping_value:
|
|
43
|
+
continue
|
|
44
|
+
mapping[mapping_key] = mapping_value
|
|
45
|
+
|
|
46
|
+
return mapping
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from .collection import mapping_from_rows
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test__mapping_from_rows__basic_mapping():
|
|
7
|
+
rows = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
|
8
|
+
result = mapping_from_rows(rows, "id", "name")
|
|
9
|
+
expected = {1: "Alice", 2: "Bob"}
|
|
10
|
+
assert result == expected
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test__mapping_from_rows__missing_key():
|
|
14
|
+
rows = [{"id": 1, "name": "Alice"}, {"name": "Bob"}]
|
|
15
|
+
result = mapping_from_rows(rows, "id", "name")
|
|
16
|
+
expected = {1: "Alice"}
|
|
17
|
+
assert result == expected
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test__mapping_from_rows__missing_value():
|
|
21
|
+
rows = [{"id": 1, "name": "Alice"}, {"id": 2}]
|
|
22
|
+
result = mapping_from_rows(rows, "id", "name")
|
|
23
|
+
expected = {1: "Alice"}
|
|
24
|
+
assert result == expected
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test__mapping_from_rows__empty_list():
|
|
28
|
+
rows = []
|
|
29
|
+
result = mapping_from_rows(rows, "id", "name")
|
|
30
|
+
expected = {}
|
|
31
|
+
assert result == expected
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test__mapping_from_rows__non_existent_key_value():
|
|
35
|
+
rows = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
|
36
|
+
result = mapping_from_rows(rows, "nonexistent_key", "nonexistent_value")
|
|
37
|
+
expected = {}
|
|
38
|
+
assert result == expected
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test__mapping_from_rows__none_key_value():
|
|
42
|
+
rows = [
|
|
43
|
+
{"id": 1, "name": "Alice"},
|
|
44
|
+
{"id": None, "name": "Bob"},
|
|
45
|
+
{"id": 2, "name": None},
|
|
46
|
+
]
|
|
47
|
+
result = mapping_from_rows(rows, "id", "name")
|
|
48
|
+
expected = {1: "Alice"}
|
|
49
|
+
assert result == expected
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test__mapping_from_rows__multiple_valid_rows():
|
|
53
|
+
rows = [
|
|
54
|
+
{"id": 1, "name": "Alice"},
|
|
55
|
+
{"id": 2, "name": "Bob"},
|
|
56
|
+
{"id": 3, "name": "Charlie"},
|
|
57
|
+
]
|
|
58
|
+
result = mapping_from_rows(rows, "id", "name")
|
|
59
|
+
expected = {1: "Alice", 2: "Bob", 3: "Charlie"}
|
|
60
|
+
assert result == expected
|
castor_extractor/utils/time.py
CHANGED
|
@@ -15,7 +15,7 @@ def current_timestamp() -> int:
|
|
|
15
15
|
"""
|
|
16
16
|
Returns the current timestamp from epoch (rounded to the nearest second)
|
|
17
17
|
"""
|
|
18
|
-
return int(
|
|
18
|
+
return int(current_datetime().timestamp())
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _set_uct_timezone(ts: datetime) -> datetime:
|
|
@@ -24,6 +24,14 @@ def _set_uct_timezone(ts: datetime) -> datetime:
|
|
|
24
24
|
return ts
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def timestamp_ms(ts: datetime) -> int:
|
|
28
|
+
"""
|
|
29
|
+
Return ts timestamp in millisecond (rounded)
|
|
30
|
+
"""
|
|
31
|
+
ts_utc = _set_uct_timezone(ts)
|
|
32
|
+
return int(ts_utc.timestamp() * 1000)
|
|
33
|
+
|
|
34
|
+
|
|
27
35
|
def now(tz: bool = False) -> datetime:
|
|
28
36
|
"""
|
|
29
37
|
provide current time
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from datetime import date, datetime
|
|
2
2
|
|
|
3
|
-
from .time import at_midnight, date_after
|
|
3
|
+
from .time import at_midnight, date_after, timestamp_ms
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def test_at_midnight():
|
|
@@ -10,3 +10,10 @@ def test_at_midnight():
|
|
|
10
10
|
def test_date_after():
|
|
11
11
|
day = date(1999, 12, 31)
|
|
12
12
|
assert date_after(day, 3) == date(2000, 1, 3)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_timestamp_ms():
|
|
16
|
+
dt = datetime(1991, 4, 3, 0, 0)
|
|
17
|
+
result = timestamp_ms(dt)
|
|
18
|
+
expected = 670636800000
|
|
19
|
+
assert result == expected
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from datetime import
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
3
|
from http import HTTPStatus
|
|
4
4
|
from typing import Iterator, List, Optional, Set, Tuple
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
|
+
from ....utils import at_midnight, current_date, past_date, retry, timestamp_ms
|
|
8
9
|
from ..assets import DomoAsset
|
|
9
10
|
from .credentials import DomoCredentials
|
|
10
11
|
from .endpoints import Endpoint, EndpointFactory
|
|
@@ -13,8 +14,7 @@ from .pagination import Pagination
|
|
|
13
14
|
RawData = Iterator[dict]
|
|
14
15
|
|
|
15
16
|
DOMO_PUBLIC_URL = "https://api.domo.com"
|
|
16
|
-
|
|
17
|
-
DEFAULT_TIMEOUT = 500
|
|
17
|
+
DEFAULT_TIMEOUT = 120
|
|
18
18
|
TOKEN_EXPIRATION_SECONDS = timedelta(seconds=3000) # auth token lasts 1 hour
|
|
19
19
|
|
|
20
20
|
IGNORED_ERROR_CODES = (
|
|
@@ -23,12 +23,14 @@ IGNORED_ERROR_CODES = (
|
|
|
23
23
|
)
|
|
24
24
|
ERROR_TPL = "Request failed with status code {status_code} and reason {reason}"
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
_RETRY_EXCEPTIONS = [
|
|
27
|
+
requests.exceptions.ConnectTimeout,
|
|
28
|
+
requests.exceptions.ReadTimeout,
|
|
29
|
+
]
|
|
30
|
+
_RETRY_COUNT = 2
|
|
31
|
+
_RETRY_BASE_MS = 10 * 60 * 1000 # 10 minutes
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
"""convert date into datetime at midnight: 00:00:00"""
|
|
31
|
-
return datetime.combine(date_, datetime.min.time())
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
def _handle_response(response: requests.Response) -> requests.Response:
|
|
@@ -36,21 +38,6 @@ def _handle_response(response: requests.Response) -> requests.Response:
|
|
|
36
38
|
return response
|
|
37
39
|
|
|
38
40
|
|
|
39
|
-
def _is_expired(last_result: dict) -> bool:
|
|
40
|
-
"""
|
|
41
|
-
Checks if the date given is expired
|
|
42
|
-
i.e. date < yesterday (00:00 AM)
|
|
43
|
-
"""
|
|
44
|
-
|
|
45
|
-
last_timestamp = datetime.strptime(last_result["time"], FORMAT)
|
|
46
|
-
logger.info(f"Last audit input has timestamp: {last_timestamp}")
|
|
47
|
-
yesterday = date.today() - timedelta(days=1)
|
|
48
|
-
threshold = _at_midnight(yesterday)
|
|
49
|
-
if last_timestamp < threshold:
|
|
50
|
-
return True
|
|
51
|
-
return False
|
|
52
|
-
|
|
53
|
-
|
|
54
41
|
def _ignore_or_raise(
|
|
55
42
|
error: requests.RequestException,
|
|
56
43
|
ignore_error_codes: Optional[Tuple[int, ...]],
|
|
@@ -82,6 +69,7 @@ class DomoClient:
|
|
|
82
69
|
def __init__(self, credentials: DomoCredentials):
|
|
83
70
|
self._authentication = credentials.authentication
|
|
84
71
|
self._bearer_headers: Optional[dict] = None
|
|
72
|
+
self._session = requests.session()
|
|
85
73
|
self._token_creation_time: datetime = datetime.min
|
|
86
74
|
self._endpoint_factory = EndpointFactory(credentials.base_url)
|
|
87
75
|
self._private_headers = credentials.private_headers
|
|
@@ -102,7 +90,7 @@ class DomoClient:
|
|
|
102
90
|
basic_authentication = self._authentication
|
|
103
91
|
endpoint = self._endpoint_factory.authentication
|
|
104
92
|
|
|
105
|
-
response =
|
|
93
|
+
response = self._session.get(
|
|
106
94
|
endpoint.url(),
|
|
107
95
|
auth=basic_authentication,
|
|
108
96
|
timeout=self._timeout,
|
|
@@ -115,6 +103,11 @@ class DomoClient:
|
|
|
115
103
|
|
|
116
104
|
return self._bearer_headers
|
|
117
105
|
|
|
106
|
+
@retry(
|
|
107
|
+
exceptions=_RETRY_EXCEPTIONS,
|
|
108
|
+
max_retries=_RETRY_COUNT,
|
|
109
|
+
base_ms=_RETRY_BASE_MS,
|
|
110
|
+
)
|
|
118
111
|
def _get(
|
|
119
112
|
self,
|
|
120
113
|
endpoint: Endpoint,
|
|
@@ -124,12 +117,14 @@ class DomoClient:
|
|
|
124
117
|
params = params if params else {}
|
|
125
118
|
is_private = endpoint.is_private
|
|
126
119
|
headers = self._private_headers if is_private else self._bearer_auth()
|
|
127
|
-
|
|
120
|
+
|
|
121
|
+
response = self._session.get(
|
|
128
122
|
url=endpoint.url(asset_id),
|
|
129
123
|
headers=headers,
|
|
130
124
|
params=params,
|
|
131
125
|
timeout=self._timeout,
|
|
132
126
|
)
|
|
127
|
+
|
|
133
128
|
if response.status_code != HTTPStatus.OK:
|
|
134
129
|
logger.warning(
|
|
135
130
|
ERROR_TPL.format(
|
|
@@ -165,28 +160,14 @@ class DomoClient:
|
|
|
165
160
|
"""Used when the response is paginated and need iterations"""
|
|
166
161
|
pagination = Pagination()
|
|
167
162
|
all_results: List[dict] = []
|
|
163
|
+
|
|
168
164
|
while pagination.needs_increment:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
params={
|
|
172
|
-
"offset": pagination.offset,
|
|
173
|
-
"limit": pagination.per_page,
|
|
174
|
-
},
|
|
175
|
-
)
|
|
165
|
+
params = {**pagination.params, **endpoint.params}
|
|
166
|
+
results = self._get_many(endpoint=endpoint, params=params)
|
|
176
167
|
all_results.extend(results)
|
|
177
168
|
number_of_items = len(results)
|
|
178
169
|
pagination.increment_offset(number_of_items)
|
|
179
170
|
|
|
180
|
-
if endpoint == self._endpoint_factory.audit:
|
|
181
|
-
last_result = results[-1]
|
|
182
|
-
# We decided to fetch audits until the audit is prior to a particular date
|
|
183
|
-
# defined in _is_expired function. To be more precise, we could have instead use
|
|
184
|
-
# params start and end of the audit endpoint (timestamp in milliseconds).
|
|
185
|
-
# https://developer.domo.com/portal/a4e18ca6a0c0b-retrieve-activity-log-entries
|
|
186
|
-
# But we need then to manage properly the timezone conversion of the results
|
|
187
|
-
# which adds more complexity than benefits
|
|
188
|
-
if _is_expired(last_result):
|
|
189
|
-
pagination.should_stop = True
|
|
190
171
|
return all_results
|
|
191
172
|
|
|
192
173
|
def _datasources(self, page_id: str) -> RawData:
|
|
@@ -300,7 +281,11 @@ class DomoClient:
|
|
|
300
281
|
)
|
|
301
282
|
|
|
302
283
|
def _audit(self) -> RawData:
|
|
303
|
-
|
|
284
|
+
yesterday = timestamp_ms(at_midnight(past_date(1)))
|
|
285
|
+
today = timestamp_ms(at_midnight(current_date()))
|
|
286
|
+
yield from self._get_paginated(
|
|
287
|
+
self._endpoint_factory.audit(yesterday, today)
|
|
288
|
+
)
|
|
304
289
|
|
|
305
290
|
def _dataflows(self) -> RawData:
|
|
306
291
|
dataflows = self._get_many(self._endpoint_factory.dataflows)
|
|
@@ -1,33 +1,11 @@
|
|
|
1
|
-
from datetime import date, datetime, timedelta
|
|
2
1
|
from unittest.mock import patch
|
|
3
2
|
|
|
4
3
|
import pytest
|
|
5
4
|
import requests
|
|
6
5
|
|
|
7
|
-
from .client import
|
|
8
|
-
FORMAT,
|
|
9
|
-
DomoClient,
|
|
10
|
-
DomoCredentials,
|
|
11
|
-
_at_midnight,
|
|
12
|
-
_is_expired,
|
|
13
|
-
)
|
|
6
|
+
from .client import DomoClient, DomoCredentials
|
|
14
7
|
from .endpoints import EndpointFactory
|
|
15
8
|
|
|
16
|
-
NEVER_EXPIRING_DATE = datetime.max
|
|
17
|
-
TODAY_DATE = _at_midnight(date.today())
|
|
18
|
-
EXPIRED_DATE = _at_midnight(date.today() - timedelta(days=2))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test__is_expired():
|
|
22
|
-
test_datetimes = (
|
|
23
|
-
({"time": NEVER_EXPIRING_DATE.strftime(FORMAT)}, False),
|
|
24
|
-
({"time": TODAY_DATE.strftime(FORMAT)}, False),
|
|
25
|
-
({"time": EXPIRED_DATE.strftime(FORMAT)}, True),
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
for test_datetime, is_expired in test_datetimes:
|
|
29
|
-
assert _is_expired(test_datetime) == is_expired
|
|
30
|
-
|
|
31
9
|
|
|
32
10
|
class FakeResponse:
|
|
33
11
|
"""
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
2
|
from typing import Optional
|
|
3
3
|
|
|
4
4
|
_DOMO_PUBLIC_URL = "https://api.domo.com"
|
|
@@ -13,6 +13,7 @@ class Endpoint:
|
|
|
13
13
|
|
|
14
14
|
base_url: str
|
|
15
15
|
is_private: bool = False
|
|
16
|
+
params: dict = field(default_factory=dict)
|
|
16
17
|
|
|
17
18
|
def url(self, asset_id: Optional[str] = None):
|
|
18
19
|
return f"{self.base_url}/{asset_id}" if asset_id else self.base_url
|
|
@@ -40,10 +41,6 @@ class EndpointFactory:
|
|
|
40
41
|
def users(self) -> Endpoint:
|
|
41
42
|
return Endpoint(f"{_DOMO_PUBLIC_URL}/v1/users")
|
|
42
43
|
|
|
43
|
-
@property
|
|
44
|
-
def audit(self) -> Endpoint:
|
|
45
|
-
return Endpoint(f"{_DOMO_PUBLIC_URL}/v1/audit")
|
|
46
|
-
|
|
47
44
|
@property
|
|
48
45
|
def dataflows(self) -> Endpoint:
|
|
49
46
|
return Endpoint(
|
|
@@ -51,6 +48,16 @@ class EndpointFactory:
|
|
|
51
48
|
is_private=True,
|
|
52
49
|
)
|
|
53
50
|
|
|
51
|
+
def audit(self, start: int, end: int) -> Endpoint:
|
|
52
|
+
"""
|
|
53
|
+
start and end are timestamps since epoch in milliseconds
|
|
54
|
+
See [documentation](https://developer.domo.com/portal/a4e18ca6a0c0b-retrieve-activity-log-entries)
|
|
55
|
+
"""
|
|
56
|
+
return Endpoint(
|
|
57
|
+
base_url=f"{_DOMO_PUBLIC_URL}/v1/audit",
|
|
58
|
+
params={"start": start, "end": end},
|
|
59
|
+
)
|
|
60
|
+
|
|
54
61
|
def lineage(self, dataflow_id: str) -> Endpoint:
|
|
55
62
|
return Endpoint(
|
|
56
63
|
base_url=f"{self.base_url}/api/data/v1/lineage/DATAFLOW/{dataflow_id}",
|
|
@@ -65,6 +72,6 @@ class EndpointFactory:
|
|
|
65
72
|
|
|
66
73
|
def page_content(self, page_id: str) -> Endpoint:
|
|
67
74
|
return Endpoint(
|
|
68
|
-
f"{self.base_url}/api/content/v3/stacks/{page_id}/cards?parts=datasources",
|
|
75
|
+
base_url=f"{self.base_url}/api/content/v3/stacks/{page_id}/cards?parts=datasources",
|
|
69
76
|
is_private=True,
|
|
70
77
|
)
|
|
@@ -23,6 +23,10 @@ class Pagination:
|
|
|
23
23
|
|
|
24
24
|
return True
|
|
25
25
|
|
|
26
|
+
@property
|
|
27
|
+
def params(self) -> dict:
|
|
28
|
+
return {"offset": self.offset, "limit": self.per_page}
|
|
29
|
+
|
|
26
30
|
def increment_offset(self, number_results: int) -> None:
|
|
27
31
|
self.offset += number_results
|
|
28
32
|
self.number_results = number_results
|
|
@@ -4,7 +4,22 @@ from datetime import date, timedelta
|
|
|
4
4
|
from typing import Callable, Iterator, List, Optional, Sequence, Tuple
|
|
5
5
|
|
|
6
6
|
from dateutil.utils import today
|
|
7
|
-
from looker_sdk
|
|
7
|
+
from looker_sdk import init40
|
|
8
|
+
from looker_sdk.sdk.api40.models import (
|
|
9
|
+
ContentView,
|
|
10
|
+
Dashboard,
|
|
11
|
+
DBConnection,
|
|
12
|
+
Folder,
|
|
13
|
+
GroupHierarchy,
|
|
14
|
+
GroupSearch,
|
|
15
|
+
Look,
|
|
16
|
+
LookmlModel,
|
|
17
|
+
LookmlModelExplore,
|
|
18
|
+
Project,
|
|
19
|
+
User,
|
|
20
|
+
UserAttribute,
|
|
21
|
+
)
|
|
22
|
+
from looker_sdk.sdk.constants import sdk_version
|
|
8
23
|
|
|
9
24
|
from ....utils import Pager, PagerLogger, SafeMode, past_date, safe_mode
|
|
10
25
|
from ..env import page_size
|
|
@@ -24,21 +39,7 @@ from .constants import (
|
|
|
24
39
|
USER_FIELDS,
|
|
25
40
|
USERS_ATTRIBUTES_FIELDS,
|
|
26
41
|
)
|
|
27
|
-
from .sdk import
|
|
28
|
-
Credentials,
|
|
29
|
-
Dashboard,
|
|
30
|
-
DBConnection,
|
|
31
|
-
Folder,
|
|
32
|
-
GroupHierarchy,
|
|
33
|
-
GroupSearch,
|
|
34
|
-
Look,
|
|
35
|
-
LookmlModel,
|
|
36
|
-
LookmlModelExplore,
|
|
37
|
-
Project,
|
|
38
|
-
User,
|
|
39
|
-
has_admin_permissions,
|
|
40
|
-
init40,
|
|
41
|
-
)
|
|
42
|
+
from .sdk import CastorApiSettings, Credentials, has_admin_permissions
|
|
42
43
|
|
|
43
44
|
logger = logging.getLogger(__name__)
|
|
44
45
|
|
|
@@ -81,7 +82,10 @@ class ApiClient:
|
|
|
81
82
|
on_api_call: OnApiCall = lambda: None,
|
|
82
83
|
safe_mode: Optional[SafeMode] = None,
|
|
83
84
|
):
|
|
84
|
-
|
|
85
|
+
settings = CastorApiSettings(
|
|
86
|
+
credentials=credentials, sdk_version=sdk_version
|
|
87
|
+
)
|
|
88
|
+
sdk = init40(config_settings=settings)
|
|
85
89
|
if not has_admin_permissions(sdk):
|
|
86
90
|
raise PermissionError("User does not have admin access.")
|
|
87
91
|
else:
|
|
@@ -4,35 +4,9 @@ This file is a proxy to Looker SDK functions using Castor ApiSettings.
|
|
|
4
4
|
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
-
from looker_sdk.
|
|
8
|
-
from looker_sdk.rtl import
|
|
9
|
-
auth_session,
|
|
10
|
-
requests_transport,
|
|
11
|
-
serialize,
|
|
12
|
-
transport,
|
|
13
|
-
)
|
|
14
|
-
from looker_sdk.rtl.api_settings import PApiSettings, SettingsConfig
|
|
15
|
-
from looker_sdk.sdk import constants
|
|
7
|
+
from looker_sdk.rtl import transport
|
|
8
|
+
from looker_sdk.rtl.api_settings import ApiSettings, SettingsConfig
|
|
16
9
|
from looker_sdk.sdk.api40 import methods as methods40
|
|
17
|
-
from looker_sdk.sdk.api40.models import (
|
|
18
|
-
Dashboard,
|
|
19
|
-
DashboardElement,
|
|
20
|
-
DBConnection,
|
|
21
|
-
Folder,
|
|
22
|
-
GroupHierarchy,
|
|
23
|
-
GroupSearch,
|
|
24
|
-
Look,
|
|
25
|
-
LookmlModel,
|
|
26
|
-
LookmlModelExplore,
|
|
27
|
-
LookmlModelExploreField,
|
|
28
|
-
LookmlModelExploreFieldset,
|
|
29
|
-
LookmlModelExploreJoins,
|
|
30
|
-
LookmlModelNavExplore,
|
|
31
|
-
Project,
|
|
32
|
-
Query,
|
|
33
|
-
User,
|
|
34
|
-
)
|
|
35
|
-
from typing_extensions import Protocol
|
|
36
10
|
|
|
37
11
|
from ..env import timeout_second
|
|
38
12
|
|
|
@@ -76,46 +50,24 @@ def has_admin_permissions(sdk_: methods40.Looker40SDK) -> bool:
|
|
|
76
50
|
)
|
|
77
51
|
|
|
78
52
|
|
|
79
|
-
|
|
80
|
-
"""Default dependency configuration"""
|
|
81
|
-
settings = ApiSettings(config=config, sdk_version=constants.sdk_version)
|
|
82
|
-
settings.is_configured()
|
|
83
|
-
transport = requests_transport.RequestsTransport.configure(settings)
|
|
84
|
-
return methods40.Looker40SDK(
|
|
85
|
-
auth=auth_session.AuthSession(
|
|
86
|
-
settings,
|
|
87
|
-
transport,
|
|
88
|
-
serialize.deserialize40,
|
|
89
|
-
"4.0",
|
|
90
|
-
),
|
|
91
|
-
deserialize=serialize.deserialize40,
|
|
92
|
-
serialize=serialize.serialize40,
|
|
93
|
-
transport=transport,
|
|
94
|
-
api_version="4.0",
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
class CastorApiSettings(PApiSettings):
|
|
99
|
-
"""This is an intermediate class that is meant to be extended (for typing purpose)"""
|
|
100
|
-
|
|
101
|
-
def read_config(self) -> SettingsConfig:
|
|
102
|
-
raise NotImplementedError()
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
class ApiSettings(CastorApiSettings):
|
|
53
|
+
class CastorApiSettings(ApiSettings):
|
|
106
54
|
"""SDK settings with initialisation using a credential object instead of a path to a .ini file"""
|
|
107
55
|
|
|
108
|
-
def __init__(
|
|
56
|
+
def __init__(
|
|
57
|
+
self, credentials: Credentials, sdk_version: Optional[str] = ""
|
|
58
|
+
):
|
|
109
59
|
"""Configure using a config dict"""
|
|
110
|
-
self.config =
|
|
60
|
+
self.config = credentials.to_settings_config()
|
|
111
61
|
self.verify_ssl = True
|
|
112
62
|
self.base_url = self.config.get("base_url", "")
|
|
113
|
-
self.timeout =
|
|
63
|
+
self.timeout = credentials.timeout
|
|
114
64
|
self.headers = {"Content-Type": "application/json"}
|
|
115
65
|
self.agent_tag = f"{transport.AGENT_PREFIX}"
|
|
116
66
|
if sdk_version:
|
|
117
67
|
self.agent_tag += f" {sdk_version}"
|
|
118
68
|
|
|
69
|
+
super().__init__()
|
|
70
|
+
|
|
119
71
|
def read_config(self) -> SettingsConfig:
|
|
120
72
|
"""Returns a serialization of the credentials"""
|
|
121
73
|
return self.config
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from typing import Iterable, Optional, Set, Tuple, Union
|
|
3
3
|
|
|
4
|
+
from looker_sdk.sdk.api40.models import LookmlModel
|
|
5
|
+
|
|
4
6
|
from ...logger import add_logging_file_handler, set_stream_handler_to_stdout
|
|
5
7
|
from ...utils import (
|
|
6
8
|
SafeMode,
|
|
@@ -12,7 +14,6 @@ from ...utils import (
|
|
|
12
14
|
write_summary,
|
|
13
15
|
)
|
|
14
16
|
from .api import ApiClient, Credentials, lookml_explore_names
|
|
15
|
-
from .api.sdk import LookmlModel
|
|
16
17
|
from .assets import LookerAsset
|
|
17
18
|
from .multithreading import MultithreadingFetcher
|
|
18
19
|
from .parameters import get_parameters
|
|
@@ -4,11 +4,11 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
4
4
|
from functools import partial
|
|
5
5
|
from typing import Iterable, List, Set
|
|
6
6
|
|
|
7
|
+
from looker_sdk.error import SDKError
|
|
7
8
|
from tqdm import tqdm # type: ignore
|
|
8
9
|
|
|
9
10
|
from ...utils import RetryStrategy, deep_serialize, retry
|
|
10
11
|
from . import ApiClient
|
|
11
|
-
from .api.sdk import SDKError
|
|
12
12
|
from .assets import LookerAsset
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
from typing import Dict, Iterator, List, Optional
|
|
3
3
|
|
|
4
4
|
import tableauserverclient as TSC # type: ignore
|
|
5
|
+
from tableauserverclient import Pager
|
|
5
6
|
|
|
6
7
|
from ....utils import SerializedAsset
|
|
7
8
|
from ..assets import TableauRevampAsset
|
|
@@ -12,7 +13,7 @@ from ..constants import (
|
|
|
12
13
|
)
|
|
13
14
|
from .credentials import TableauRevampCredentials
|
|
14
15
|
from .errors import TableauApiError
|
|
15
|
-
from .gql_queries import GQL_QUERIES, QUERY_TEMPLATE
|
|
16
|
+
from .gql_queries import FIELDS_QUERIES, GQL_QUERIES, QUERY_TEMPLATE
|
|
16
17
|
from .tsc_fields import TSC_FIELDS
|
|
17
18
|
|
|
18
19
|
logger = logging.getLogger(__name__)
|
|
@@ -27,13 +28,15 @@ _TSC_ASSETS = (
|
|
|
27
28
|
TableauRevampAsset.USAGE,
|
|
28
29
|
)
|
|
29
30
|
|
|
31
|
+
# speed up extraction: fields and columns are smaller but volumes are bigger
|
|
30
32
|
_CUSTOM_PAGE_SIZE: Dict[TableauRevampAsset, int] = {
|
|
31
33
|
TableauRevampAsset.FIELD: 1000,
|
|
34
|
+
TableauRevampAsset.COLUMN: 1000,
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
|
|
35
38
|
def _pick_fields(
|
|
36
|
-
data:
|
|
39
|
+
data: Pager,
|
|
37
40
|
asset: TableauRevampAsset,
|
|
38
41
|
) -> SerializedAsset:
|
|
39
42
|
fields = TSC_FIELDS[asset]
|
|
@@ -44,7 +47,7 @@ def _pick_fields(
|
|
|
44
47
|
return [_pick(row) for row in data]
|
|
45
48
|
|
|
46
49
|
|
|
47
|
-
def
|
|
50
|
+
def _enrich_datasources_with_tsc(
|
|
48
51
|
datasources: SerializedAsset,
|
|
49
52
|
tsc_datasources: SerializedAsset,
|
|
50
53
|
) -> SerializedAsset:
|
|
@@ -69,6 +72,32 @@ def _enrich_with_tsc(
|
|
|
69
72
|
return datasources
|
|
70
73
|
|
|
71
74
|
|
|
75
|
+
def _enrich_workbooks_with_tsc(
|
|
76
|
+
workbooks: SerializedAsset,
|
|
77
|
+
tsc_workbooks: SerializedAsset,
|
|
78
|
+
) -> SerializedAsset:
|
|
79
|
+
"""
|
|
80
|
+
Enrich workbooks with fields coming from TableauServerClient:
|
|
81
|
+
- project_luid
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
mapping = {row["id"]: row for row in tsc_workbooks}
|
|
85
|
+
|
|
86
|
+
for workbook in workbooks:
|
|
87
|
+
luid = workbook["luid"]
|
|
88
|
+
tsc_workbook = mapping.get(luid)
|
|
89
|
+
if not tsc_workbook:
|
|
90
|
+
# it happens that a workbook is in Metadata API but not in TSC
|
|
91
|
+
# in this case, we push the workbook with default project
|
|
92
|
+
logger.warning(f"Workbook {luid} was not found in TSC")
|
|
93
|
+
workbook["projectLuid"] = None
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
workbook["projectLuid"] = tsc_workbook["project_id"]
|
|
97
|
+
|
|
98
|
+
return workbooks
|
|
99
|
+
|
|
100
|
+
|
|
72
101
|
def gql_query_scroll(
|
|
73
102
|
server,
|
|
74
103
|
query: str,
|
|
@@ -176,29 +205,32 @@ class TableauRevampClient:
|
|
|
176
205
|
asset: TableauRevampAsset,
|
|
177
206
|
) -> SerializedAsset:
|
|
178
207
|
|
|
179
|
-
if asset == TableauRevampAsset.
|
|
180
|
-
data = TSC.Pager(self._server.
|
|
208
|
+
if asset == TableauRevampAsset.DATASOURCE:
|
|
209
|
+
data = TSC.Pager(self._server.datasources)
|
|
181
210
|
|
|
182
211
|
elif asset == TableauRevampAsset.PROJECT:
|
|
183
212
|
data = TSC.Pager(self._server.projects)
|
|
184
213
|
|
|
185
|
-
elif asset == TableauRevampAsset.DATASOURCE:
|
|
186
|
-
data = TSC.Pager(self._server.datasources)
|
|
187
|
-
|
|
188
214
|
elif asset == TableauRevampAsset.USAGE:
|
|
189
215
|
data = TSC.Pager(self._server.views, usage=True)
|
|
190
216
|
|
|
217
|
+
elif asset == TableauRevampAsset.USER:
|
|
218
|
+
data = TSC.Pager(self._server.users)
|
|
219
|
+
|
|
220
|
+
elif asset == TableauRevampAsset.WORKBOOK:
|
|
221
|
+
data = TSC.Pager(self._server.workbooks)
|
|
222
|
+
|
|
191
223
|
else:
|
|
192
224
|
raise AssertionError(f"Fetching from TSC not supported for {asset}")
|
|
193
225
|
|
|
194
226
|
return _pick_fields(data, asset)
|
|
195
227
|
|
|
196
|
-
def
|
|
228
|
+
def _run_graphql_query(
|
|
197
229
|
self,
|
|
198
|
-
|
|
230
|
+
resource: str,
|
|
231
|
+
fields: str,
|
|
232
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
199
233
|
) -> SerializedAsset:
|
|
200
|
-
resource, fields = GQL_QUERIES[asset]
|
|
201
|
-
page_size = _CUSTOM_PAGE_SIZE.get(asset) or DEFAULT_PAGE_SIZE
|
|
202
234
|
query = QUERY_TEMPLATE.format(
|
|
203
235
|
resource=resource,
|
|
204
236
|
fields=fields,
|
|
@@ -207,13 +239,40 @@ class TableauRevampClient:
|
|
|
207
239
|
result_pages = gql_query_scroll(self._server, query, resource)
|
|
208
240
|
return [asset for page in result_pages for asset in page]
|
|
209
241
|
|
|
242
|
+
def _fetch_fields(self) -> SerializedAsset:
|
|
243
|
+
result: SerializedAsset = []
|
|
244
|
+
page_size = _CUSTOM_PAGE_SIZE[TableauRevampAsset.FIELD]
|
|
245
|
+
for resource, fields in FIELDS_QUERIES:
|
|
246
|
+
current = self._run_graphql_query(resource, fields, page_size)
|
|
247
|
+
result.extend(current)
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
def _fetch_from_metadata_api(
|
|
251
|
+
self,
|
|
252
|
+
asset: TableauRevampAsset,
|
|
253
|
+
) -> SerializedAsset:
|
|
254
|
+
if asset == TableauRevampAsset.FIELD:
|
|
255
|
+
return self._fetch_fields()
|
|
256
|
+
|
|
257
|
+
page_size = _CUSTOM_PAGE_SIZE.get(asset) or DEFAULT_PAGE_SIZE
|
|
258
|
+
resource, fields = GQL_QUERIES[asset]
|
|
259
|
+
return self._run_graphql_query(resource, fields, page_size)
|
|
260
|
+
|
|
210
261
|
def _fetch_datasources(self) -> SerializedAsset:
|
|
211
262
|
asset = TableauRevampAsset.DATASOURCE
|
|
212
263
|
|
|
213
264
|
datasources = self._fetch_from_metadata_api(asset)
|
|
214
265
|
datasource_projects = self._fetch_from_tsc(asset)
|
|
215
266
|
|
|
216
|
-
return
|
|
267
|
+
return _enrich_datasources_with_tsc(datasources, datasource_projects)
|
|
268
|
+
|
|
269
|
+
def _fetch_workbooks(self) -> SerializedAsset:
|
|
270
|
+
asset = TableauRevampAsset.WORKBOOK
|
|
271
|
+
|
|
272
|
+
workbooks = self._fetch_from_metadata_api(asset)
|
|
273
|
+
workbook_projects = self._fetch_from_tsc(asset)
|
|
274
|
+
|
|
275
|
+
return _enrich_workbooks_with_tsc(workbooks, workbook_projects)
|
|
217
276
|
|
|
218
277
|
def fetch(
|
|
219
278
|
self,
|
|
@@ -226,6 +285,10 @@ class TableauRevampClient:
|
|
|
226
285
|
# both APIs are required to extract datasources
|
|
227
286
|
return self._fetch_datasources()
|
|
228
287
|
|
|
288
|
+
if asset == TableauRevampAsset.WORKBOOK:
|
|
289
|
+
# both APIs are required to extract workbooks
|
|
290
|
+
return self._fetch_workbooks()
|
|
291
|
+
|
|
229
292
|
if asset in _TSC_ASSETS:
|
|
230
293
|
# some assets can only be extracted via TSC
|
|
231
294
|
return self._fetch_from_tsc(asset)
|
|
@@ -37,12 +37,10 @@ workbook { id }
|
|
|
37
37
|
|
|
38
38
|
_DATASOURCES_QUERY = """
|
|
39
39
|
__typename
|
|
40
|
-
createdAt
|
|
41
40
|
downstreamDashboards { id }
|
|
42
41
|
downstreamWorkbooks { id }
|
|
43
42
|
id
|
|
44
43
|
name
|
|
45
|
-
updatedAt
|
|
46
44
|
... on PublishedDatasource {
|
|
47
45
|
description
|
|
48
46
|
luid
|
|
@@ -64,7 +62,6 @@ name
|
|
|
64
62
|
connectionType
|
|
65
63
|
fullName
|
|
66
64
|
schema
|
|
67
|
-
tableType
|
|
68
65
|
}
|
|
69
66
|
... on CustomSQLTable {
|
|
70
67
|
query
|
|
@@ -80,7 +77,6 @@ id
|
|
|
80
77
|
luid
|
|
81
78
|
name
|
|
82
79
|
owner { luid }
|
|
83
|
-
projectLuid
|
|
84
80
|
site { name }
|
|
85
81
|
tags { name }
|
|
86
82
|
updatedAt
|
|
@@ -96,16 +92,17 @@ downstreamWorkbooks { id }
|
|
|
96
92
|
folderName
|
|
97
93
|
id
|
|
98
94
|
name
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
95
|
+
dataType
|
|
96
|
+
role
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_FIELDS_QUERY_WITH_COLUMNS = f"""
|
|
101
|
+
{_FIELDS_QUERY}
|
|
102
|
+
columns {{
|
|
103
|
+
name
|
|
104
|
+
table {{ name }}
|
|
105
|
+
}}
|
|
109
106
|
"""
|
|
110
107
|
|
|
111
108
|
_SHEETS_QUERY = """
|
|
@@ -124,8 +121,14 @@ GQL_QUERIES: Dict[TableauRevampAsset, Tuple[str, str]] = {
|
|
|
124
121
|
TableauRevampAsset.COLUMN: ("columns", _COLUMNS_QUERY),
|
|
125
122
|
TableauRevampAsset.DASHBOARD: ("dashboards", _DASHBOARDS_QUERY),
|
|
126
123
|
TableauRevampAsset.DATASOURCE: ("datasources", _DATASOURCES_QUERY),
|
|
127
|
-
TableauRevampAsset.FIELD: ("fields", _FIELDS_QUERY),
|
|
128
124
|
TableauRevampAsset.SHEET: ("sheets", _SHEETS_QUERY),
|
|
129
125
|
TableauRevampAsset.TABLE: ("tables", _TABLES_QUERY),
|
|
130
126
|
TableauRevampAsset.WORKBOOK: ("workbooks", _WORKBOOKS_QUERY),
|
|
131
127
|
}
|
|
128
|
+
|
|
129
|
+
FIELDS_QUERIES = (
|
|
130
|
+
("binFields", _FIELDS_QUERY),
|
|
131
|
+
("calculatedFields", _FIELDS_QUERY),
|
|
132
|
+
("columnFields", _FIELDS_QUERY_WITH_COLUMNS),
|
|
133
|
+
("groupFields", _FIELDS_QUERY),
|
|
134
|
+
)
|
|
@@ -3,7 +3,7 @@ from datetime import date
|
|
|
3
3
|
from functools import partial
|
|
4
4
|
from typing import Any, Dict, List, Optional, Set
|
|
5
5
|
|
|
6
|
-
from ...utils import at_midnight, date_after
|
|
6
|
+
from ...utils import at_midnight, date_after, mapping_from_rows
|
|
7
7
|
from ...utils.client.api import APIClient
|
|
8
8
|
from ...utils.pager import PagerOnToken
|
|
9
9
|
from ..abstract.time_filter import TimeFilter
|
|
@@ -88,15 +88,22 @@ class DatabricksClient(APIClient):
|
|
|
88
88
|
)
|
|
89
89
|
|
|
90
90
|
@staticmethod
|
|
91
|
-
def _match_table_with_user(table: dict,
|
|
91
|
+
def _match_table_with_user(table: dict, user_mapping: dict) -> dict:
|
|
92
92
|
table_owner_email = table.get("owner_email")
|
|
93
93
|
if not table_owner_email:
|
|
94
94
|
return table
|
|
95
|
-
owner_external_id =
|
|
95
|
+
owner_external_id = user_mapping.get(table_owner_email)
|
|
96
96
|
if not owner_external_id:
|
|
97
97
|
return table
|
|
98
98
|
return {**table, "owner_external_id": owner_external_id}
|
|
99
99
|
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _get_user_mapping(users: List[dict]) -> dict:
|
|
102
|
+
return {
|
|
103
|
+
**mapping_from_rows(users, "email", "id"),
|
|
104
|
+
**mapping_from_rows(users, "user_name", "id"),
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
def tables_and_columns(
|
|
101
108
|
self, schemas: List[dict], users: List[dict]
|
|
102
109
|
) -> TablesColumns:
|
|
@@ -105,11 +112,11 @@ class DatabricksClient(APIClient):
|
|
|
105
112
|
"""
|
|
106
113
|
tables: List[dict] = []
|
|
107
114
|
columns: List[dict] = []
|
|
108
|
-
|
|
115
|
+
user_mapping = self._get_user_mapping(users)
|
|
109
116
|
for schema in schemas:
|
|
110
117
|
t_to_add, c_to_add = self._tables_columns_of_schema(schema)
|
|
111
118
|
t_with_owner = [
|
|
112
|
-
self._match_table_with_user(table,
|
|
119
|
+
self._match_table_with_user(table, user_mapping)
|
|
113
120
|
for table in t_to_add
|
|
114
121
|
]
|
|
115
122
|
tables.extend(t_with_owner)
|
|
@@ -66,15 +66,34 @@ def test_DatabricksClient__keep_catalog():
|
|
|
66
66
|
assert not client._keep_catalog("something_unknown")
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def test_DatabricksClient__get_user_mapping():
|
|
70
|
+
client = MockDatabricksClient()
|
|
71
|
+
users = [
|
|
72
|
+
{"id": "both", "email": "hello@world.com", "user_name": "hello world"},
|
|
73
|
+
{"id": "no_email", "email": "", "user_name": "no email"},
|
|
74
|
+
{"id": "no_name", "email": "no@name.fr", "user_name": ""},
|
|
75
|
+
{"id": "no_both", "email": "", "user_name": ""},
|
|
76
|
+
{"id": "", "email": "no@id.com", "user_name": "no id"},
|
|
77
|
+
]
|
|
78
|
+
expected = {
|
|
79
|
+
"hello@world.com": "both",
|
|
80
|
+
"hello world": "both",
|
|
81
|
+
"no@name.fr": "no_name",
|
|
82
|
+
"no email": "no_email",
|
|
83
|
+
}
|
|
84
|
+
mapping = client._get_user_mapping(users)
|
|
85
|
+
assert mapping == expected
|
|
86
|
+
|
|
87
|
+
|
|
69
88
|
def test_DatabricksClient__match_table_with_user():
|
|
70
89
|
client = MockDatabricksClient()
|
|
71
|
-
|
|
90
|
+
user_mapping = {"bob@castordoc.com": 3}
|
|
72
91
|
|
|
73
92
|
table = {"id": 1, "owner_email": "bob@castordoc.com"}
|
|
74
|
-
table_with_owner = client._match_table_with_user(table,
|
|
93
|
+
table_with_owner = client._match_table_with_user(table, user_mapping)
|
|
75
94
|
|
|
76
95
|
assert table_with_owner == {**table, "owner_external_id": 3}
|
|
77
96
|
|
|
78
97
|
table_without_owner = {"id": 1, "owner_email": None}
|
|
79
|
-
actual = client._match_table_with_user(table_without_owner,
|
|
98
|
+
actual = client._match_table_with_user(table_without_owner, user_mapping)
|
|
80
99
|
assert actual == table_without_owner
|
|
@@ -127,13 +127,17 @@ class DatabricksFormatter:
|
|
|
127
127
|
return email["value"]
|
|
128
128
|
return emails[0]["value"]
|
|
129
129
|
|
|
130
|
+
def _email(self, user: dict) -> Optional[str]:
|
|
131
|
+
emails = user.get("emails")
|
|
132
|
+
return self._primary(emails) if emails else None
|
|
133
|
+
|
|
130
134
|
def format_user(self, raw_users: List[dict]) -> List[dict]:
|
|
131
135
|
users = []
|
|
132
136
|
for user in raw_users:
|
|
133
137
|
users.append(
|
|
134
138
|
{
|
|
135
139
|
"id": user["id"],
|
|
136
|
-
"email": self.
|
|
140
|
+
"email": self._email(user),
|
|
137
141
|
"first_name": None,
|
|
138
142
|
"last_name": user.get("displayName") or user["userName"],
|
|
139
143
|
"user_name": user["userName"],
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: castor-extractor
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.9
|
|
4
4
|
Summary: Extract your metadata assets.
|
|
5
5
|
Home-page: https://www.castordoc.com/
|
|
6
6
|
License: EULA
|
|
7
7
|
Author: Castor
|
|
8
8
|
Author-email: support@castordoc.com
|
|
9
|
-
Requires-Python: >=3.8,<3.
|
|
9
|
+
Requires-Python: >=3.8,<3.13
|
|
10
10
|
Classifier: License :: Other/Proprietary License
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.9
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
18
|
Provides-Extra: all
|
|
18
19
|
Provides-Extra: bigquery
|
|
19
20
|
Provides-Extra: dbt
|
|
@@ -34,8 +35,10 @@ Requires-Dist: google-cloud-core (>=2.1.0,<3.0.0)
|
|
|
34
35
|
Requires-Dist: google-cloud-storage (>=2,<3)
|
|
35
36
|
Requires-Dist: google-resumable-media (>=2.0.3,<3.0.0)
|
|
36
37
|
Requires-Dist: googleapis-common-protos (>=1.53.0,<2.0.0)
|
|
37
|
-
Requires-Dist: looker-sdk (>=
|
|
38
|
+
Requires-Dist: looker-sdk (>=23.0.0) ; extra == "looker" or extra == "all"
|
|
38
39
|
Requires-Dist: msal (>=1.20.0,<2.0.0) ; extra == "powerbi" or extra == "all"
|
|
40
|
+
Requires-Dist: numpy (<1.25) ; python_version >= "3.8" and python_version < "3.9"
|
|
41
|
+
Requires-Dist: numpy (>=1.26,<2) ; python_version >= "3.12" and python_version < "3.13"
|
|
39
42
|
Requires-Dist: psycopg2-binary (>=2.0.0,<3.0.0) ; extra == "metabase" or extra == "postgres" or extra == "redshift" or extra == "all"
|
|
40
43
|
Requires-Dist: pycryptodome (>=3.0.0,<4.0.0) ; extra == "metabase" or extra == "all"
|
|
41
44
|
Requires-Dist: pydantic (>=2.6,<3.0)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
CHANGELOG.md,sha256=
|
|
1
|
+
CHANGELOG.md,sha256=WwEWPQQuGqVnWLhPtEh3SuOlBrNgHyHcLsYuvahpN7E,10437
|
|
2
2
|
Dockerfile,sha256=HcX5z8OpeSvkScQsN-Y7CNMUig_UB6vTMDl7uqzuLGE,303
|
|
3
3
|
LICENCE,sha256=sL-IGa4hweyya1HgzMskrRdybbIa2cktzxb5qmUgDg8,8254
|
|
4
4
|
README.md,sha256=uF6PXm9ocPITlKVSh9afTakHmpLx3TvawLf-CbMP3wM,3578
|
|
@@ -44,7 +44,7 @@ castor_extractor/uploader/env_test.py,sha256=ClCWWtwd2N-5ClIDUxVMeKkWfhhOTxpppsX
|
|
|
44
44
|
castor_extractor/uploader/upload.py,sha256=5Aj3UOx8cpSVvzjYRz7S6nLk249IqUiCia70utU_970,3363
|
|
45
45
|
castor_extractor/uploader/upload_test.py,sha256=BfGjAYEEDBmEcUS6_b3SlKyiQNR1iRf6-qmADDirTJI,328
|
|
46
46
|
castor_extractor/uploader/utils.py,sha256=NCe0tkB28BVhqzOaDhDjaSfODjjcPWB17X6chnvyCWs,478
|
|
47
|
-
castor_extractor/utils/__init__.py,sha256=
|
|
47
|
+
castor_extractor/utils/__init__.py,sha256=bmzAOc-PKsVreMJtF7DGpPQeHrVqxWel_BblRftt6Ag,1186
|
|
48
48
|
castor_extractor/utils/client/__init__.py,sha256=CRE-xJKm6fVV9dB8ljzB5YoOxX4I1sCD1KSgqs3Y8_Y,161
|
|
49
49
|
castor_extractor/utils/client/abstract.py,sha256=aA5Qcb9TwWDSMq8WpXbGkOB20hehwX2VTpqQAwV76wk,2048
|
|
50
50
|
castor_extractor/utils/client/api.py,sha256=tHa7eC11sS_eOCXhlnvUa2haRfOLENmjKgjB09Ijt0s,1664
|
|
@@ -53,7 +53,8 @@ castor_extractor/utils/client/postgres.py,sha256=n6ulaT222WWPY0_6qAZ0MHF0m91HtI9
|
|
|
53
53
|
castor_extractor/utils/client/query.py,sha256=O6D5EjD1KmBlwa786Uw4D4kzxx97_HH50xIIeSWt0B8,205
|
|
54
54
|
castor_extractor/utils/client/uri.py,sha256=jmP9hY-6PRqdc3-vAOdtll_U6q9VCqSqmBAN6QRs3ZI,150
|
|
55
55
|
castor_extractor/utils/client/uri_test.py,sha256=1XKF6qSseCeD4G4ckaNO07JXfGbt7XUVinOZdpEYrDQ,259
|
|
56
|
-
castor_extractor/utils/collection.py,sha256=
|
|
56
|
+
castor_extractor/utils/collection.py,sha256=L4KLCjpgYBEwC7lSPFWBkiUzeLOTv5e-X6c-nibdnmQ,1555
|
|
57
|
+
castor_extractor/utils/collection_test.py,sha256=I9xLnBUPiAGj7UDqEd0G6pcgqjcNHJ5DYXbmTXVhBM8,1713
|
|
57
58
|
castor_extractor/utils/constants.py,sha256=qBQprS9U66mS-RIBXiLujdTSV3WvGv40Bc0khP4Abdk,39
|
|
58
59
|
castor_extractor/utils/dbt/__init__.py,sha256=LHQROlMqYWCc7tcmhdjXtROFpJqUvCg9jPC8avHgD4I,107
|
|
59
60
|
castor_extractor/utils/dbt/assets.py,sha256=JY1nKEGySZ84wNoe7dnizwAYw2q0t8NVaIfqhB2rSw0,148
|
|
@@ -92,8 +93,8 @@ castor_extractor/utils/salesforce/credentials_test.py,sha256=FQRyNk2Jsh6KtYiW20o
|
|
|
92
93
|
castor_extractor/utils/store.py,sha256=D_pVaPsu1MKAJC0K47O_vYTs-Afl6oejravAJdvjmGc,2040
|
|
93
94
|
castor_extractor/utils/string.py,sha256=aW6bbjqEGnh9kT5KZBnMlV6fhdgOJ0ENCkCTDon1xA0,2377
|
|
94
95
|
castor_extractor/utils/string_test.py,sha256=OmRVCJUXMcCTwY-QJDhUViYpxkvQQgNRJLCaXY0iUnk,2535
|
|
95
|
-
castor_extractor/utils/time.py,sha256=
|
|
96
|
-
castor_extractor/utils/time_test.py,sha256=
|
|
96
|
+
castor_extractor/utils/time.py,sha256=Rll1KJVDZdCKjWuiXA_BuB9OHeEfb_f6Z637Kkxnobs,1385
|
|
97
|
+
castor_extractor/utils/time_test.py,sha256=pEwpcHI7wGPnfgwrH1DNHEbPz3HEAryNF5yPL7Dqkp8,448
|
|
97
98
|
castor_extractor/utils/type.py,sha256=87t32cTctEjX-_BqZLtPLWu-M9OVvw_lFU4DbaQ6V0U,313
|
|
98
99
|
castor_extractor/utils/validation.py,sha256=NNMkdyvMzConslnyCM3gmciEtPPvefW0vAT2gNsMhvE,1909
|
|
99
100
|
castor_extractor/utils/validation_test.py,sha256=aSetitOCkH_K-Wto9ISOVGso5jGfTUOBLm3AZnvavO8,1181
|
|
@@ -102,29 +103,29 @@ castor_extractor/visualization/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5
|
|
|
102
103
|
castor_extractor/visualization/domo/__init__.py,sha256=_mAYVfoVLizfLGF_f6ZiwBhdPpvoJY_diySf33dt3Jo,127
|
|
103
104
|
castor_extractor/visualization/domo/assets.py,sha256=bK1urFR2tnlWkVkkhR32mAKMoKbESNlop-CNGx-65PY,206
|
|
104
105
|
castor_extractor/visualization/domo/client/__init__.py,sha256=UDszV3IXNC9Wp_j55NZ-6ey2INo0TYtAg2QNIJOjglE,88
|
|
105
|
-
castor_extractor/visualization/domo/client/client.py,sha256=
|
|
106
|
-
castor_extractor/visualization/domo/client/client_test.py,sha256=
|
|
106
|
+
castor_extractor/visualization/domo/client/client.py,sha256=8bQgK6TFJcEvSH19ZzVNlsyz86jgIypOAheOCXLORVU,10203
|
|
107
|
+
castor_extractor/visualization/domo/client/client_test.py,sha256=pEIL8AALixq9a7sckW-TVqaz1k4oKUtfGUjGpTPOJi0,1790
|
|
107
108
|
castor_extractor/visualization/domo/client/credentials.py,sha256=CksQ9W9X6IGjTlYN0okwGAmURMRJKAjctxODAvAJUAo,1148
|
|
108
|
-
castor_extractor/visualization/domo/client/endpoints.py,sha256=
|
|
109
|
-
castor_extractor/visualization/domo/client/pagination.py,sha256=
|
|
109
|
+
castor_extractor/visualization/domo/client/endpoints.py,sha256=6UI5psMYaIa1Pq_Gulb4cNna7NZ16xMotScX7yg5TRQ,2367
|
|
110
|
+
castor_extractor/visualization/domo/client/pagination.py,sha256=ukVkHVzoH4mfZ29H9YcnC2YrdVolP10wv25J6Q3ehRw,821
|
|
110
111
|
castor_extractor/visualization/domo/client/pagination_test.py,sha256=nV4yZWfus13QFCr-tlBUgwva21VqfpF6P-0ks_Awwis,581
|
|
111
112
|
castor_extractor/visualization/domo/constants.py,sha256=AriJZPrCY5Z3HRUANrMu-4U0b7hQK_jRDcxiB-hbrQ4,233
|
|
112
113
|
castor_extractor/visualization/domo/extract.py,sha256=GWWRfPEMt4SgzBGFaTcoOabsoOqLRFIEFAtgXwb8LDI,2567
|
|
113
114
|
castor_extractor/visualization/looker/__init__.py,sha256=Xu5bJ3743kaP8szMMp2NXCgvM1EdOQgtic4utUlO9Cc,145
|
|
114
115
|
castor_extractor/visualization/looker/api/__init__.py,sha256=rN03VMucxIqc0yfd17dIe3ZNFpcg5CA09epn1fKJg90,99
|
|
115
|
-
castor_extractor/visualization/looker/api/client.py,sha256=
|
|
116
|
+
castor_extractor/visualization/looker/api/client.py,sha256=tfV-zx5FAtPCtu_NFIxfSFiQ1AtxxIeAj1sHT5Vug3I,9790
|
|
116
117
|
castor_extractor/visualization/looker/api/client_test.py,sha256=wsi20-neBXHaahDqf4nwCp8Ew5fRFCmVHG3OqrePKFs,1868
|
|
117
118
|
castor_extractor/visualization/looker/api/constants.py,sha256=pZpq09tqcGi2Vh8orXxn9eil8ewfPUOLKfVuqgV2W-A,4126
|
|
118
|
-
castor_extractor/visualization/looker/api/sdk.py,sha256=
|
|
119
|
+
castor_extractor/visualization/looker/api/sdk.py,sha256=RiCb-3BAL59iGqLfPEO9490cM05LRv-xGoo-AM-Z4No,2251
|
|
119
120
|
castor_extractor/visualization/looker/api/sdk_test.py,sha256=NHtKZTflPhqzBFHs1TyAQaubgxfzLLwYKFT8rEqR55I,1742
|
|
120
|
-
castor_extractor/visualization/looker/api/utils.py,sha256=
|
|
121
|
+
castor_extractor/visualization/looker/api/utils.py,sha256=TJqq9UBVFtS33VB1zHzT6kU8f9MOyqXsUcr57ZurJy4,588
|
|
121
122
|
castor_extractor/visualization/looker/assets.py,sha256=K08nV6MMIpfF9r91TmCO7_62smHzGRv3gR4aIOootMQ,827
|
|
122
123
|
castor_extractor/visualization/looker/constant.py,sha256=JvmAfbyAg-LQIoI48Slg9k5T0mYggs_76D_6vD-mP88,695
|
|
123
124
|
castor_extractor/visualization/looker/env.py,sha256=vPqirdeGKm3as2T-tBTjbpulQe8W7-3UE2j-Z57wFXk,1174
|
|
124
|
-
castor_extractor/visualization/looker/extract.py,sha256=
|
|
125
|
+
castor_extractor/visualization/looker/extract.py,sha256=Y7ZsiUO3uaAkBX0inhjMcEXX4nyBTo9ewthtLfbQhzU,5132
|
|
125
126
|
castor_extractor/visualization/looker/fields.py,sha256=WmiSehmczWTufCLg4r2Ozq2grUpzxDNvIAHyGuOoGs4,636
|
|
126
127
|
castor_extractor/visualization/looker/fields_test.py,sha256=7Cwq8Qky6aTZg8nCHp1gmPJtd9pGNB4QeMIRRWdHo5w,782
|
|
127
|
-
castor_extractor/visualization/looker/multithreading.py,sha256=
|
|
128
|
+
castor_extractor/visualization/looker/multithreading.py,sha256=6CrMOy9kMBfhHnZI7XrpNNyBiYYCO3CE4AuIjQVlLH0,2610
|
|
128
129
|
castor_extractor/visualization/looker/parameters.py,sha256=Nk2hfrg3L9twU-51Q7Wdp9uaxy8M2_juEebWoLfIMPc,2427
|
|
129
130
|
castor_extractor/visualization/metabase/__init__.py,sha256=hSIoVgPzhQh-9H8XRUzga4EZSOYejGdH-qY_hBNGbyw,125
|
|
130
131
|
castor_extractor/visualization/metabase/assets.py,sha256=47fMax7QjFKyjEuH2twsjd-l9EM4RJutAZb4GbASdkU,2836
|
|
@@ -243,11 +244,11 @@ castor_extractor/visualization/tableau/usage.py,sha256=LlFwlbEr-EnYUJjKZha99CRCR
|
|
|
243
244
|
castor_extractor/visualization/tableau_revamp/__init__.py,sha256=a3DGjQhaz17gBqW-E84TAgupKbqLC40y5Ajo1yn-ot4,156
|
|
244
245
|
castor_extractor/visualization/tableau_revamp/assets.py,sha256=owlwaI2E4UKk1YhkaHgaAXx6gu3Op6EqZ7bjp0tHI6s,351
|
|
245
246
|
castor_extractor/visualization/tableau_revamp/client/__init__.py,sha256=wmS9uLtUiqNYVloi0-DgD8d2qzu3RVZEAtWiaDp6G_M,90
|
|
246
|
-
castor_extractor/visualization/tableau_revamp/client/client.py,sha256=
|
|
247
|
+
castor_extractor/visualization/tableau_revamp/client/client.py,sha256=8BO7J-HFM2j6_f-Hjj3uSWip11eKeZ0cjhxGEqMTPRA,9428
|
|
247
248
|
castor_extractor/visualization/tableau_revamp/client/credentials.py,sha256=fHG32egq6ll2U4BNazalMof_plzfCMQjrN9WOs6kezk,3014
|
|
248
249
|
castor_extractor/visualization/tableau_revamp/client/errors.py,sha256=dTe1shqmWmAXpDpCz-E24m8dGYjt6rvIGV9qQb4jnvI,150
|
|
249
|
-
castor_extractor/visualization/tableau_revamp/client/gql_queries.py,sha256=
|
|
250
|
-
castor_extractor/visualization/tableau_revamp/client/tsc_fields.py,sha256=
|
|
250
|
+
castor_extractor/visualization/tableau_revamp/client/gql_queries.py,sha256=jBxvjQnOIWfFjMJpr7S_ZPnQhdzabxoO3jyEKi8A8ns,2112
|
|
251
|
+
castor_extractor/visualization/tableau_revamp/client/tsc_fields.py,sha256=WsDliPCo-XsQ7wN-j0gpW9bdxCHvgH-aePywiltzfbU,688
|
|
251
252
|
castor_extractor/visualization/tableau_revamp/constants.py,sha256=PcdudAogQhi3e-knalhgliMKjy5ahN0em_-7XSLrnxM,87
|
|
252
253
|
castor_extractor/visualization/tableau_revamp/extract.py,sha256=2SLUxp5okM4AcEJJ61ZgcC2ikfZZl9MH17CEXMXmgl0,1450
|
|
253
254
|
castor_extractor/warehouse/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -276,11 +277,11 @@ castor_extractor/warehouse/bigquery/queries/view_ddl.sql,sha256=obCm-IN9V8_YSZTw
|
|
|
276
277
|
castor_extractor/warehouse/bigquery/query.py,sha256=hrFfjd5jW2oQnZ6ozlkn-gDe6sCIzu5zSX19T9W6fIk,4162
|
|
277
278
|
castor_extractor/warehouse/bigquery/types.py,sha256=LZVWSmE57lOemNbB5hBRyYmDk9bFAU4nbRaJWALl6N8,140
|
|
278
279
|
castor_extractor/warehouse/databricks/__init__.py,sha256=bTvDxjGQGM2J3hOnVhfNmFP1y8DK0tySiD_EXe5_xWE,200
|
|
279
|
-
castor_extractor/warehouse/databricks/client.py,sha256=
|
|
280
|
-
castor_extractor/warehouse/databricks/client_test.py,sha256=
|
|
280
|
+
castor_extractor/warehouse/databricks/client.py,sha256=FsHlpHZ9JTG92Rf_8Z7277o9HBaAD0CKxSEHiujOgXg,8271
|
|
281
|
+
castor_extractor/warehouse/databricks/client_test.py,sha256=Y-LBveZFRVaaL49Lo2MwbcJReBcYLNRdHtR_w7xWNWQ,3381
|
|
281
282
|
castor_extractor/warehouse/databricks/credentials.py,sha256=PpGv5_GP320UQjV_gvaxSpOw58AmqSznmjGhGfe6bdU,655
|
|
282
283
|
castor_extractor/warehouse/databricks/extract.py,sha256=-vJhAIxSu1lD_xGl-GXZYTmc5BGu0aXM3l-U0UghREM,5773
|
|
283
|
-
castor_extractor/warehouse/databricks/format.py,sha256=
|
|
284
|
+
castor_extractor/warehouse/databricks/format.py,sha256=Nd5L89yWhpIl0OEMV7WK1H3JYUa9WGPC0c-NUOT_uXM,5101
|
|
284
285
|
castor_extractor/warehouse/databricks/format_test.py,sha256=iPmdJof43fBYL1Sa_fBrCWDQHCHgm7IWCZag1kWkj9E,1970
|
|
285
286
|
castor_extractor/warehouse/databricks/types.py,sha256=T2SyLy9pY_olLtstdC77moPxIiikVsuQLMxh92YMJQo,78
|
|
286
287
|
castor_extractor/warehouse/mysql/__init__.py,sha256=2KFDogo9GNbApHqw3Vm5t_uNmIRjdp76nmP_WQQMfQY,116
|
|
@@ -367,8 +368,8 @@ castor_extractor/warehouse/synapse/queries/schema.sql,sha256=aX9xNrBD_ydwl-znGSF
|
|
|
367
368
|
castor_extractor/warehouse/synapse/queries/table.sql,sha256=mCE8bR1Vb7j7SwZW2gafcXidQ2fo1HwxcybA8wP2Kfs,1049
|
|
368
369
|
castor_extractor/warehouse/synapse/queries/user.sql,sha256=sTb_SS7Zj3AXW1SggKPLNMCd0qoTpL7XI_BJRMaEpBg,67
|
|
369
370
|
castor_extractor/warehouse/synapse/queries/view_ddl.sql,sha256=3EVbp5_yTgdByHFIPLHmnoOnqqLE77SrjAwFDvu4e54,249
|
|
370
|
-
castor_extractor-0.16.
|
|
371
|
-
castor_extractor-0.16.
|
|
372
|
-
castor_extractor-0.16.
|
|
373
|
-
castor_extractor-0.16.
|
|
374
|
-
castor_extractor-0.16.
|
|
371
|
+
castor_extractor-0.16.9.dist-info/LICENCE,sha256=sL-IGa4hweyya1HgzMskrRdybbIa2cktzxb5qmUgDg8,8254
|
|
372
|
+
castor_extractor-0.16.9.dist-info/METADATA,sha256=qRP78w8BztI4N8IyOLoESkFdhKWByXf7PQQjFLTvu6A,6582
|
|
373
|
+
castor_extractor-0.16.9.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
374
|
+
castor_extractor-0.16.9.dist-info/entry_points.txt,sha256=SbyPk58Gh-FRztfCNnUZQ6w7SatzNJFZ6GIJLNsy7tI,1427
|
|
375
|
+
castor_extractor-0.16.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|