looker-sdk 24.18.0__py3-none-any.whl → 24.20.0__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.
@@ -0,0 +1,353 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2019 Looker Data Sciences, Inc.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+
23
+ """AuthSession to provide automatic authentication
24
+ """
25
+ import hashlib
26
+ import secrets
27
+ from typing import cast, Dict, Optional, Union
28
+ import urllib.parse
29
+
30
+ import attr
31
+
32
+ from looker_sdk import error
33
+ from looker_sdk.rtl import api_settings
34
+ from looker_sdk.rtl import auth_token
35
+ from looker_sdk.rtl import model
36
+ from looker_sdk.rtl import serialize
37
+ from looker_sdk.rtl import transport
38
+
39
+
40
+ class AuthSession:
41
+ """AuthSession to provide automatic authentication"""
42
+
43
+ def __init__(
44
+ self,
45
+ settings: api_settings.PApiSettings,
46
+ transport: transport.Transport,
47
+ deserialize: serialize.TDeserialize,
48
+ api_version: str,
49
+ ):
50
+ settings.is_configured()
51
+ self.settings = settings
52
+ self.api_version = api_version
53
+ self.sudo_token: auth_token.AuthToken = auth_token.AuthToken()
54
+ self.token: auth_token.AuthToken = auth_token.AuthToken()
55
+ self._sudo_id: Optional[int] = None
56
+ self.transport = transport
57
+ self.deserialize = deserialize
58
+ self.token_model = auth_token.AccessToken
59
+
60
+ def _is_authenticated(self, token: auth_token.AuthToken) -> bool:
61
+ """Determines if current token is active."""
62
+ if not (token.access_token):
63
+ return False
64
+ return token.is_active
65
+
66
+ @property
67
+ def is_sudo_authenticated(self) -> bool:
68
+ return self._is_authenticated(self.sudo_token)
69
+
70
+ @property
71
+ def is_authenticated(self) -> bool:
72
+ return self._is_authenticated(self.token)
73
+
74
+ def _get_sudo_token(
75
+ self, transport_options: transport.TransportOptions
76
+ ) -> auth_token.AuthToken:
77
+ """Returns an active sudo token."""
78
+ if not self.is_sudo_authenticated:
79
+ self._login_sudo(transport_options)
80
+ return self.sudo_token
81
+
82
+ def _get_token(
83
+ self, transport_options: transport.TransportOptions
84
+ ) -> auth_token.AuthToken:
85
+ """Returns an active token."""
86
+ if not self.is_authenticated:
87
+ self._login(transport_options)
88
+ return self.token
89
+
90
+ def authenticate(
91
+ self, transport_options: transport.TransportOptions
92
+ ) -> Dict[str, str]:
93
+ """Return the Authorization header to authenticate each API call.
94
+
95
+ Expired token renewal happens automatically.
96
+ """
97
+ if self._sudo_id:
98
+ token = self._get_sudo_token(transport_options)
99
+ else:
100
+ token = self._get_token(transport_options)
101
+
102
+ return {"Authorization": f"Bearer {token.access_token}"}
103
+
104
+ def login_user(
105
+ self,
106
+ sudo_id: int,
107
+ transport_options: Optional[transport.TransportOptions] = None,
108
+ ) -> None:
109
+ """Authenticate using settings credentials and sudo as sudo_id.
110
+
111
+ Make API calls as if authenticated as sudo_id. The sudo_id
112
+ token is automatically renewed when it expires. In order to
113
+ subsequently login_user() as another user you must first logout()
114
+ """
115
+ if self._sudo_id is None:
116
+ self._sudo_id = sudo_id
117
+ try:
118
+ self._login_sudo(transport_options or {})
119
+ except error.SDKError:
120
+ self._sudo_id = None
121
+ raise
122
+
123
+ else:
124
+ if self._sudo_id != sudo_id:
125
+ raise error.SDKError(
126
+ f"Another user ({self._sudo_id}) "
127
+ "is already logged in. Log them out first."
128
+ )
129
+ elif not self.is_sudo_authenticated:
130
+ self._login_sudo(transport_options or {})
131
+
132
+ def _login(self, transport_options: transport.TransportOptions) -> None:
133
+ client_id = self.settings.read_config().get("client_id")
134
+ client_secret = self.settings.read_config().get("client_secret")
135
+ if not (client_id and client_secret):
136
+ raise error.SDKError("Required auth credentials not found.")
137
+
138
+ login = {
139
+ "client_id": cast(str, client_id),
140
+ "client_secret": cast(str, client_secret),
141
+ }
142
+
143
+ serialized = urllib.parse.urlencode(login).encode("utf-8")
144
+
145
+ transport_options.setdefault("headers", {}).update(
146
+ {"Content-Type": "application/x-www-form-urlencoded"}
147
+ )
148
+ response = self._ok(
149
+ self.transport.request(
150
+ transport.HttpMethod.POST,
151
+ f"{self.settings.base_url}/api/{self.api_version}/login",
152
+ body=serialized,
153
+ transport_options=transport_options,
154
+ )
155
+ )
156
+
157
+ # ignore type: mypy bug doesn't recognized kwarg `structure` to partial func
158
+ access_token = self.deserialize(
159
+ data=response, structure=self.token_model
160
+ ) # type: ignore
161
+ assert isinstance(access_token, auth_token.AccessToken)
162
+ self.token = auth_token.AuthToken(access_token)
163
+
164
+ def _login_sudo(self, transport_options: transport.TransportOptions) -> None:
165
+ def authenticator(
166
+ transport_options: transport.TransportOptions,
167
+ ) -> Dict[str, str]:
168
+ return {
169
+ "Authorization": f"Bearer {self._get_token(transport_options).access_token}"
170
+ }
171
+
172
+ response = self._ok(
173
+ self.transport.request(
174
+ transport.HttpMethod.POST,
175
+ f"{self.settings.base_url}/api/{self.api_version}/login/{self._sudo_id}",
176
+ authenticator=authenticator,
177
+ transport_options=transport_options,
178
+ )
179
+ )
180
+ # ignore type: mypy bug doesn't recognized kwarg `structure` to partial func
181
+ access_token = self.deserialize(
182
+ data=response, structure=self.token_model
183
+ ) # type: ignore
184
+ assert isinstance(access_token, auth_token.AccessToken)
185
+ self.sudo_token = auth_token.AuthToken(access_token)
186
+
187
+ def logout(
188
+ self,
189
+ full: bool = False,
190
+ transport_options: Optional[transport.TransportOptions] = None,
191
+ ) -> None:
192
+ """Logout of API.
193
+
194
+ If the session is authenticated as sudo_id, logout() "undoes"
195
+ the sudo and deactivates that sudo_id's current token. By default
196
+ the current api3credential session is active at which point
197
+ you can continue to make API calls as the api3credential user
198
+ or logout(). If you want to logout completely in one step pass
199
+ full=True
200
+ """
201
+ if self._sudo_id:
202
+ self._sudo_id = None
203
+ if self.is_sudo_authenticated:
204
+ self._logout(sudo=True, transport_options=transport_options)
205
+ if full:
206
+ self._logout(transport_options=transport_options)
207
+
208
+ elif self.is_authenticated:
209
+ self._logout(transport_options=transport_options)
210
+
211
+ def _logout(
212
+ self,
213
+ sudo: bool = False,
214
+ transport_options: Optional[transport.TransportOptions] = None,
215
+ ) -> None:
216
+
217
+ if sudo:
218
+ token = self.sudo_token.access_token
219
+ self.sudo_token = auth_token.AuthToken()
220
+ else:
221
+ token = self.token.access_token
222
+ self.token = auth_token.AuthToken()
223
+
224
+ def authenticator(
225
+ _transport_options: transport.TransportOptions,
226
+ ) -> Dict[str, str]:
227
+ return {"Authorization": f"Bearer {token}"}
228
+
229
+ self._ok(
230
+ self.transport.request(
231
+ transport.HttpMethod.DELETE,
232
+ f"{self.settings.base_url}/api/logout",
233
+ authenticator=authenticator,
234
+ transport_options=transport_options,
235
+ )
236
+ )
237
+
238
+ def _ok(self, response: transport.Response) -> str:
239
+ if not response.ok:
240
+ raise error.SDKError(response.value.decode(encoding="utf-8"))
241
+ return response.value.decode(encoding="utf-8")
242
+
243
+
244
+ class CryptoHash:
245
+ def secure_random(self, byte_count: int) -> str:
246
+ return secrets.token_urlsafe(byte_count)
247
+
248
+ def sha256_hash(self, message: str) -> str:
249
+ value = hashlib.sha256()
250
+ value.update(bytes(message, "utf8"))
251
+ return value.hexdigest()
252
+
253
+
254
+ class OAuthSession(AuthSession):
255
+ def __init__(
256
+ self,
257
+ *,
258
+ settings: api_settings.PApiSettings,
259
+ transport: transport.Transport,
260
+ deserialize: serialize.TDeserialize,
261
+ serialize: serialize.TSerialize,
262
+ crypto: CryptoHash,
263
+ version: str,
264
+ ):
265
+ super().__init__(settings, transport, deserialize, version)
266
+ self.crypto = crypto
267
+ self.serialize = serialize
268
+ config_data = self.settings.read_config()
269
+ for required in ["client_id", "redirect_uri", "looker_url"]:
270
+ if required not in config_data:
271
+ raise error.SDKError(f"Missing required configuration value {required}")
272
+
273
+ # would have prefered using setattr(self, required, ...) in loop above
274
+ # but mypy can't follow it
275
+ self.client_id = config_data["client_id"]
276
+ self.redirect_uri = config_data.get("redirect_uri", "")
277
+ self.looker_url = config_data.get("looker_url", "")
278
+ self.code_verifier = ""
279
+
280
+ def create_auth_code_request_url(self, scope: str, state: str) -> str:
281
+ self.code_verifier = self.crypto.secure_random(32)
282
+ code_challenge = self.crypto.sha256_hash(self.code_verifier)
283
+ params: Dict[str, str] = {
284
+ "response_type": "code",
285
+ "client_id": self.client_id,
286
+ "redirect_uri": self.redirect_uri,
287
+ "scope": scope,
288
+ "state": state,
289
+ "code_challenge_method": "S256",
290
+ "code_challenge": code_challenge,
291
+ }
292
+ path = urllib.parse.urljoin(self.looker_url, "auth")
293
+ query = urllib.parse.urlencode(params)
294
+ return f"{path}?{query}"
295
+
296
+ @attr.s(auto_attribs=True, kw_only=True)
297
+ class GrantTypeParams(model.Model):
298
+ client_id: str
299
+ redirect_uri: str
300
+
301
+ @attr.s(auto_attribs=True, kw_only=True)
302
+ class AuthCodeGrantTypeParams(GrantTypeParams):
303
+ code: str
304
+ code_verifier: str
305
+ grant_type: str = "authorization_code"
306
+
307
+ @attr.s(auto_attribs=True, kw_only=True)
308
+ class RefreshTokenGrantTypeParams(GrantTypeParams):
309
+ refresh_token: str
310
+ grant_type: str = "refresh_token"
311
+
312
+ def _request_token(
313
+ self,
314
+ grant_type: Union[AuthCodeGrantTypeParams, RefreshTokenGrantTypeParams],
315
+ transport_options: transport.TransportOptions,
316
+ ) -> auth_token.AccessToken:
317
+ response = self.transport.request(
318
+ transport.HttpMethod.POST,
319
+ urllib.parse.urljoin(self.settings.base_url, "/api/token"),
320
+ body=self.serialize(api_model=grant_type), # type: ignore
321
+ )
322
+ if not response.ok:
323
+ raise error.SDKError(response.value.decode(encoding=response.encoding))
324
+
325
+ # ignore type: mypy bug doesn't recognized kwarg `structure` to partial func
326
+ return self.deserialize(
327
+ data=response.value, structure=self.token_model
328
+ ) # type: ignore
329
+
330
+ def redeem_auth_code(
331
+ self,
332
+ auth_code: str,
333
+ code_verifier: Optional[str] = None,
334
+ transport_options: Optional[transport.TransportOptions] = None,
335
+ ) -> None:
336
+ params = self.AuthCodeGrantTypeParams(
337
+ client_id=self.client_id,
338
+ redirect_uri=self.redirect_uri,
339
+ code=auth_code,
340
+ code_verifier=code_verifier or self.code_verifier,
341
+ )
342
+
343
+ access_token = self._request_token(params, transport_options or {})
344
+ self.token = auth_token.AuthToken(access_token)
345
+
346
+ def _login(self, transport_options: transport.TransportOptions) -> None:
347
+ params = self.RefreshTokenGrantTypeParams(
348
+ client_id=self.client_id,
349
+ redirect_uri=self.redirect_uri,
350
+ refresh_token=self.token.refresh_token,
351
+ )
352
+ access_token = self._request_token(params, transport_options)
353
+ self.token = auth_token.AuthToken(access_token)
@@ -0,0 +1,101 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2019 Looker Data Sciences, Inc.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+
23
+ """AuthToken
24
+ """
25
+ from typing import Optional, Type, Union
26
+ import datetime
27
+
28
+ import attr
29
+
30
+ from looker_sdk.rtl import auth_session
31
+ from looker_sdk.rtl import model
32
+
33
+
34
+ # Same as the Looker API access token object
35
+ # Re-declared here to be independent of model generation
36
+ @attr.s(auto_attribs=True, init=False)
37
+ class AccessToken(model.Model):
38
+ """
39
+ Attributes:
40
+ access_token: Access Token used for API calls
41
+ token_type: Type of Token
42
+ expires_in: Number of seconds before the token expires
43
+ refresh_token: Refresh token which can be used to obtain a new access token
44
+ """
45
+
46
+ access_token: Optional[str] = None
47
+ token_type: Optional[str] = None
48
+ expires_in: Optional[int] = None
49
+ refresh_token: Optional[str] = None
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ access_token: Optional[str] = None,
55
+ token_type: Optional[str] = None,
56
+ expires_in: Optional[int] = None,
57
+ refresh_token: Optional[str] = None,
58
+ ):
59
+ self.access_token = access_token
60
+ self.token_type = token_type
61
+ self.expires_in = expires_in
62
+ self.refresh_token = refresh_token
63
+
64
+
65
+ class AuthToken:
66
+ """Used to instantiate or check expiry of an AccessToken object"""
67
+
68
+ def __init__(
69
+ self, token: Optional[AccessToken] = None,
70
+ ):
71
+ self.lag_time = 10
72
+ self.access_token: str = ""
73
+ self.refresh_token: str = ""
74
+ self.token_type: str = ""
75
+ self.expires_in: int = 0
76
+ self.expires_at = datetime.datetime.now() + datetime.timedelta(
77
+ seconds=-self.lag_time
78
+ )
79
+ if token is None:
80
+ token = AccessToken()
81
+ self.set_token(token)
82
+
83
+ def set_token(self, token: AccessToken):
84
+ """Assign the token and set its expiration."""
85
+ self.access_token = token.access_token or ""
86
+ if isinstance(token, AccessToken):
87
+ self.refresh_token = token.refresh_token or ""
88
+ self.token_type = token.token_type or ""
89
+ self.expires_in = token.expires_in or 0
90
+
91
+ lag = datetime.timedelta(seconds=-self.lag_time)
92
+ if token.access_token and token.expires_in:
93
+ lag = datetime.timedelta(seconds=token.expires_in - self.lag_time)
94
+ self.expires_at = datetime.datetime.now() + lag
95
+
96
+ @property
97
+ def is_active(self) -> bool:
98
+ """True if authentication token has not timed out"""
99
+ if not self.expires_at:
100
+ return False
101
+ return self.expires_at > datetime.datetime.now()
@@ -0,0 +1,31 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2019 Looker Data Sciences, Inc.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+
23
+
24
+ RESPONSE_STRING_MODE = (
25
+ r"(^application/.*"
26
+ r"(\bjson\b|\bxml\b|\bsql\b|\bgraphql\b|\bjavascript\b|\bx-www-form-urlencoded\b)"
27
+ r"|^text/|.*\+xml\b|;.*charset=)"
28
+ )
29
+
30
+ # note: string mode must be checked first
31
+ RESPONSE_BINARY_MODE = r"^image/|^audio/|^video/|^font/|^application/|^multipart/"
@@ -0,0 +1,86 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2022 Looker Data Sciences, Inc.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+
23
+ import datetime
24
+ import enum
25
+ import keyword
26
+ import sys
27
+ from typing import Type
28
+
29
+
30
+ def unstructure_hook(converter, api_model):
31
+ """cattr unstructure hook
32
+
33
+ Map reserved_ words in models to correct json field names.
34
+ Also handle stripping None fields from dict while setting
35
+ EXPLICIT_NULL fields to None so that we only send null
36
+ in the json for fields the caller set EXPLICIT_NULL on.
37
+ """
38
+ data = converter.unstructure_attrs_asdict(api_model)
39
+ for key, value in data.copy().items():
40
+ if value is None:
41
+ del data[key]
42
+ elif value == "EXPLICIT_NULL":
43
+ data[key] = None
44
+ # bug here: in the unittests cattrs unstructures this correctly
45
+ # as an enum calling .value but in the integration tests we see
46
+ # it doesn't for WriteCreateQueryTask.result_format for some reason
47
+ # Haven't been able to debug it fully, so catching and processing
48
+ # it here.
49
+ elif isinstance(value, enum.Enum):
50
+ data[key] = value.value
51
+ for reserved in keyword.kwlist:
52
+ if f"{reserved}_" in data:
53
+ data[reserved] = data.pop(f"{reserved}_")
54
+ return data
55
+
56
+
57
+ DATETIME_FMT = "%Y-%m-%dT%H:%M:%S.%f%z"
58
+ if sys.version_info < (3, 7):
59
+ from dateutil import parser
60
+
61
+ def datetime_structure_hook(
62
+ d: str, t: Type[datetime.datetime]
63
+ ) -> datetime.datetime:
64
+ return parser.isoparse(d)
65
+
66
+ else:
67
+
68
+ def datetime_structure_hook(
69
+ d: str, t: Type[datetime.datetime]
70
+ ) -> datetime.datetime:
71
+ return datetime.datetime.strptime(d, DATETIME_FMT)
72
+
73
+
74
+ def datetime_unstructure_hook(dt):
75
+ return dt.strftime(DATETIME_FMT)
76
+
77
+
78
+ def tr_data_keys(data):
79
+ """Map top level json keys to model property names.
80
+
81
+ Currently this translates reserved python keywords like "from" => "from_"
82
+ """
83
+ for reserved in keyword.kwlist:
84
+ if reserved in data and isinstance(data, dict):
85
+ data[f"{reserved}_"] = data.pop(reserved)
86
+ return data