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,284 @@
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
+ import json
24
+ import pytest # type: ignore
25
+ import urllib.parse
26
+
27
+ from looker_sdk import error
28
+ from looker_sdk.rtl import auth_session as auth
29
+ from looker_sdk.rtl import api_settings
30
+ from looker_sdk.rtl import serialize
31
+ from looker_sdk.rtl import transport
32
+
33
+
34
+ @pytest.fixture(scope="function")
35
+ def config_file(tmpdir_factory, monkeypatch):
36
+ """Creates a sample looker.ini file and returns it"""
37
+ # make sure test is only using these settings
38
+ for setting in ["BASE_URL", "CLIENT_ID", "CLIENT_SECRET"]:
39
+ monkeypatch.delenv(f"LOOKERSDK_{setting}", raising=False)
40
+ filename = tmpdir_factory.mktemp("settings").join("looker.ini")
41
+ filename.write(
42
+ """
43
+ [Looker]
44
+ # Base URL for API. Do not include /api/* in the url
45
+ base_url=https://host1.looker.com:19999
46
+ # API 3 client id
47
+ client_id=your_API3_client_id
48
+ # API 3 client secret
49
+ client_secret=your_API3_client_secret
50
+ # Set to false if testing locally against self-signed certs. Otherwise leave True
51
+ verify_ssl=True
52
+ looker_url=https://webserver.looker.com:9999
53
+ redirect_uri=https://alice.com/auth
54
+
55
+ [NO_CREDENTIALS]
56
+ base_url=https://host1.looker.com:19999
57
+
58
+ [EMPTY_STRING_CREDENTIALS]
59
+ base_url=https://host1.looker.com:19999
60
+ client_id=
61
+ client_secret=
62
+ """
63
+ )
64
+ return filename
65
+
66
+
67
+ @pytest.fixture(scope="function")
68
+ def auth_session(config_file):
69
+ settings = api_settings.ApiSettings(filename=config_file, env_prefix="LOOKERSDK")
70
+ return auth.AuthSession(
71
+ settings, MockTransport.configure(settings), serialize.deserialize40, "4.0"
72
+ )
73
+
74
+
75
+ class MockTransport(transport.Transport):
76
+ """A mock transport layer used for testing purposes"""
77
+
78
+ @classmethod
79
+ def configure(cls, settings):
80
+ return cls()
81
+
82
+ def request(
83
+ self,
84
+ method,
85
+ path,
86
+ query_params=None,
87
+ body=None,
88
+ authenticator=None,
89
+ transport_options=None,
90
+ ):
91
+ if authenticator:
92
+ authenticator(transport_options)
93
+ if method == transport.HttpMethod.POST:
94
+ if path.endswith(("login", "login/5")):
95
+ if path.endswith("login"):
96
+ token = "AdminAccessToken"
97
+ expected_headers = {
98
+ "Content-Type": "application/x-www-form-urlencoded"
99
+ }
100
+ expected_headers.update(transport_options.get("headers", {}))
101
+ if transport_options["headers"] != expected_headers:
102
+ raise TypeError(f"Must send {expected_headers}")
103
+ else:
104
+ token = "UserAccessToken"
105
+ access_token = json.dumps(
106
+ {"access_token": token, "token_type": "Bearer", "expires_in": 3600}
107
+ )
108
+ response = transport.Response(
109
+ ok=True,
110
+ value=bytes(access_token, encoding="utf-8"),
111
+ response_mode=transport.ResponseMode.STRING,
112
+ )
113
+ elif path.endswith("/api/token"):
114
+ access_token = {
115
+ "access_token": "anOauthToken",
116
+ "token_type": "Bearer",
117
+ "expires_in": 3600,
118
+ "refresh_token": "anOauthRefreshToken",
119
+ }
120
+ response = transport.Response(
121
+ ok=True,
122
+ value=json.dumps(access_token).encode("utf8"),
123
+ response_mode=transport.ResponseMode.STRING,
124
+ )
125
+ elif method == transport.HttpMethod.DELETE and path.endswith("logout"):
126
+ response = transport.Response(
127
+ ok=True, value=b"", response_mode=transport.ResponseMode.STRING
128
+ )
129
+ else:
130
+ raise TypeError("Bad transport layer call")
131
+ return response
132
+
133
+
134
+ def test_auto_login(auth_session: auth.AuthSession):
135
+ assert not auth_session.is_authenticated
136
+ auth_header = auth_session.authenticate({})
137
+ assert auth_header["Authorization"] == "Bearer AdminAccessToken"
138
+ assert auth_session.is_authenticated
139
+
140
+ # even after explicit logout
141
+ auth_session.logout()
142
+ assert not auth_session.is_authenticated
143
+ auth_header = auth_session.authenticate({})
144
+ assert isinstance(auth_header, dict)
145
+ assert auth_header["Authorization"] == "Bearer AdminAccessToken"
146
+ assert auth_session.is_authenticated
147
+
148
+
149
+ def test_auto_login_with_transport_options(auth_session: auth.AuthSession):
150
+ assert not auth_session.is_authenticated
151
+ auth_header = auth_session.authenticate({"headers": {"foo": "bar"}})
152
+ assert auth_header["Authorization"] == "Bearer AdminAccessToken"
153
+ assert auth_session.is_authenticated
154
+
155
+
156
+ def test_sudo_login_auto_logs_in(auth_session: auth.AuthSession):
157
+ assert not auth_session.is_authenticated
158
+ assert not auth_session.is_sudo_authenticated
159
+ auth_session.login_user(5)
160
+ assert auth_session.is_authenticated
161
+ assert auth_session.is_sudo_authenticated
162
+ auth_header = auth_session.authenticate({})
163
+ assert auth_header["Authorization"] == "Bearer UserAccessToken"
164
+
165
+
166
+ def test_sudo_logout_leaves_logged_in(auth_session: auth.AuthSession):
167
+ auth_session.login_user(5)
168
+ auth_session.logout()
169
+ assert not auth_session.is_sudo_authenticated
170
+ assert auth_session.is_authenticated
171
+
172
+
173
+ def test_login_sudo_login_sudo(auth_session: auth.AuthSession):
174
+ auth_session.login_user(5)
175
+ with pytest.raises(error.SDKError):
176
+ auth_session.login_user(10)
177
+
178
+
179
+ @pytest.mark.parametrize(
180
+ "test_section, test_env_client_id, test_env_client_secret",
181
+ [
182
+ ("NO_CREDENTIALS", "", ""),
183
+ ("NO_CREDENTIALS", "id123", ""),
184
+ ("NO_CREDENTIALS", "", "secret123"),
185
+ ("EMPTY_STRING_CREDENTIALS", "", ""),
186
+ ("EMPTY_STRING_CREDENTIALS", "id123", ""),
187
+ ("EMPTY_STRING_CREDENTIALS", "", "secret123"),
188
+ ],
189
+ )
190
+ def test_it_fails_with_missing_credentials(
191
+ config_file, monkeypatch, test_section, test_env_client_id, test_env_client_secret
192
+ ):
193
+ monkeypatch.setenv("LOOKERSDK_CLIENT_ID", test_env_client_id)
194
+ monkeypatch.setenv("LOOKERSDK_CLIENT_SECRET", test_env_client_secret)
195
+
196
+ settings = api_settings.ApiSettings(filename=config_file, section=test_section)
197
+ settings.api_version = "4.0"
198
+
199
+ auth_session = auth.AuthSession(
200
+ settings, MockTransport.configure(settings), serialize.deserialize40, "4.0"
201
+ )
202
+
203
+ with pytest.raises(error.SDKError) as exc_info:
204
+ auth_session.authenticate({})
205
+ assert "auth credentials not found" in str(exc_info.value)
206
+
207
+
208
+ @pytest.mark.parametrize(
209
+ "test_env_client_id, test_env_client_secret, expected_id, expected_secret",
210
+ [
211
+ ("", "", "your_API3_client_id", "your_API3_client_secret"),
212
+ ("id123", "secret123", "id123", "secret123"),
213
+ ],
214
+ )
215
+ def test_env_variables_override_config_file_credentials(
216
+ auth_session: auth.AuthSession,
217
+ mocker,
218
+ monkeypatch,
219
+ test_env_client_id: str,
220
+ test_env_client_secret: str,
221
+ expected_id: str,
222
+ expected_secret: str,
223
+ ):
224
+ monkeypatch.setenv("LOOKERSDK_CLIENT_ID", test_env_client_id)
225
+ monkeypatch.setenv("LOOKERSDK_CLIENT_SECRET", test_env_client_secret)
226
+ mocked_request = mocker.patch.object(MockTransport, "request")
227
+ mocked_request.return_value = transport.Response(
228
+ ok=True,
229
+ value=json.dumps(
230
+ {
231
+ "access_token": "AdminAccessToken",
232
+ "token_type": "Bearer",
233
+ "expires_in": 3600,
234
+ }
235
+ ).encode("utf-8"),
236
+ response_mode=transport.ResponseMode.STRING,
237
+ )
238
+
239
+ auth_session.authenticate({})
240
+
241
+ expected_body = urllib.parse.urlencode(
242
+ {"client_id": expected_id, "client_secret": expected_secret}
243
+ ).encode("utf-8")
244
+ mocked_request.assert_called()
245
+ actual_request_body = mocked_request.call_args[1]["body"]
246
+ assert actual_request_body == expected_body
247
+
248
+
249
+ @pytest.fixture(scope="function")
250
+ def oauth_session(config_file):
251
+ settings = api_settings.ApiSettings(filename=config_file)
252
+ return auth.OAuthSession(
253
+ settings=settings,
254
+ transport=MockTransport.configure(settings),
255
+ deserialize=serialize.deserialize40,
256
+ serialize=serialize.serialize40,
257
+ crypto=auth.CryptoHash(),
258
+ version="4.0",
259
+ )
260
+
261
+
262
+ def test_oauth_create_auth_code_request_url(oauth_session):
263
+ url = oauth_session.create_auth_code_request_url("api", "mystate")
264
+ url = urllib.parse.urlparse(url)
265
+ assert url.hostname == "webserver.looker.com"
266
+ assert url.port == 9999
267
+ assert url.path == "/auth"
268
+ query = urllib.parse.parse_qs(url.query)
269
+ assert query.get("response_type") == ["code"]
270
+ # Re-using client_id from config. For oauth this is not an API3Credentials
271
+ # client_id but rather the app client_id
272
+ assert query.get("client_id") == ["your_API3_client_id"]
273
+ assert query.get("redirect_uri") == ["https://alice.com/auth"]
274
+ assert query.get("scope") == ["api"]
275
+ assert query.get("state") == ["mystate"]
276
+ assert query.get("code_challenge_method") == ["S256"]
277
+ assert query.get("code_challenge")
278
+
279
+
280
+ def test_oauth_redeem_auth_code(oauth_session):
281
+ oauth_session.redeem_auth_code("foobar")
282
+ assert oauth_session.token.access_token == "anOauthToken"
283
+ assert oauth_session.token.refresh_token == "anOauthRefreshToken"
284
+ assert oauth_session.token.token_type == "Bearer"
@@ -0,0 +1,70 @@
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
+ from looker_sdk.rtl import auth_token
24
+
25
+
26
+ def test_defaults_with_empty_token():
27
+ """Confirm the defaults when initializing AuthToken without arguments."""
28
+ actual = auth_token.AuthToken()
29
+
30
+ assert actual.access_token == ""
31
+ assert actual.token_type == ""
32
+ assert actual.expires_in == 0
33
+ assert actual.is_active is False
34
+
35
+
36
+ def test_is_active_with_full_token():
37
+ """Confirm active token when AuthToken is initialized properly."""
38
+ actual = auth_token.AuthToken(
39
+ auth_token.AccessToken(
40
+ access_token="all-access", token_type="backstage", expires_in=3600
41
+ ),
42
+ )
43
+
44
+ assert actual.access_token == "all-access"
45
+ assert actual.token_type == "backstage"
46
+ assert actual.expires_in == 3600
47
+ assert actual.is_active is True
48
+
49
+
50
+ def test_lag_time_is_used():
51
+ """Confirm active token when expiration is > lag time."""
52
+ actual = auth_token.AuthToken(
53
+ auth_token.AccessToken(
54
+ access_token="all-access", token_type="backstage", expires_in=9
55
+ ),
56
+ )
57
+
58
+ assert actual.access_token == "all-access"
59
+ assert actual.token_type == "backstage"
60
+ assert actual.expires_in == 9
61
+ assert actual.is_active is False
62
+
63
+ actual = auth_token.AuthToken(
64
+ auth_token.AccessToken(
65
+ access_token="all-access", token_type="backstage", expires_in=11
66
+ ),
67
+ )
68
+
69
+ assert actual.expires_in == 11
70
+ assert actual.is_active is True
@@ -0,0 +1,171 @@
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
+ from typing import cast, MutableMapping, Optional
24
+
25
+ import attr
26
+ import pytest # type: ignore
27
+ import requests
28
+
29
+ from looker_sdk.rtl import requests_transport
30
+ from looker_sdk.rtl import transport
31
+
32
+
33
+ @attr.s(auto_attribs=True)
34
+ class Response:
35
+ """Fake requests.Response
36
+ """
37
+
38
+ ok: bool
39
+ content: bytes
40
+ headers: MutableMapping[str, str]
41
+
42
+
43
+ class Session:
44
+ """Fake requests.Session
45
+ """
46
+
47
+ def __init__(self, ret_val, error=False):
48
+ self.headers = {}
49
+ self.ret_val = ret_val
50
+ self.error = error
51
+
52
+ def request(self, method, url, auth, params, data, headers, timeout):
53
+ """Fake request.Session.request
54
+ """
55
+ if self.error:
56
+ raise IOError((54, "Connection reset by peer"))
57
+ return self.ret_val
58
+
59
+
60
+ @attr.s(auto_attribs=True, kw_only=True)
61
+ class TransportSettings:
62
+ """Fake TransportSettings
63
+ """
64
+
65
+ base_url: str = ""
66
+ verify_ssl: bool = True
67
+ timeout: int = 120
68
+ headers: Optional[MutableMapping[str, str]] = None
69
+ agent_tag: str = "foobar"
70
+
71
+ def is_configured(self) -> bool:
72
+ return bool(self.base_url)
73
+
74
+
75
+ @pytest.fixture
76
+ def settings():
77
+ return TransportSettings(base_url="/some/path", headers=None, verify_ssl=True)
78
+
79
+
80
+ def test_configure(settings):
81
+ """Test configuration creates instance.
82
+ """
83
+
84
+ test = requests_transport.RequestsTransport.configure(settings)
85
+ assert isinstance(test, requests_transport.RequestsTransport)
86
+ assert test.session.headers.get("x-looker-appid") == f"foobar"
87
+
88
+
89
+ parametrize = [
90
+ ({"Content-Type": "application/json"}, "utf-8", transport.ResponseMode.STRING),
91
+ ({"Content-Type": "image/png"}, "utf-8", transport.ResponseMode.BINARY),
92
+ (
93
+ {"Content-Type": "text/xml; charset=latin1"},
94
+ "latin1",
95
+ transport.ResponseMode.STRING,
96
+ ),
97
+ (
98
+ {"Content-Type": "text/plain; charset=arabic"},
99
+ "arabic",
100
+ transport.ResponseMode.STRING,
101
+ ),
102
+ (
103
+ {"Content-Type": "text/plain; charset=utf-8"},
104
+ "utf-8",
105
+ transport.ResponseMode.STRING,
106
+ ),
107
+ ({"Content-Type": "audio/gzip"}, "utf-8", transport.ResponseMode.BINARY),
108
+ ]
109
+
110
+
111
+ @pytest.mark.parametrize(
112
+ "headers, expected_encoding, expected_response_mode", parametrize
113
+ )
114
+ def test_request_ok(
115
+ settings: transport.PTransportSettings,
116
+ headers: MutableMapping[str, str],
117
+ expected_response_mode: transport.ResponseMode,
118
+ expected_encoding: str,
119
+ ):
120
+ """Test basic successful round trip
121
+ """
122
+ value = b"yay!"
123
+ ret_val = Response(
124
+ ok=True, content=value, headers=requests.structures.CaseInsensitiveDict(headers)
125
+ )
126
+ session = cast(requests.Session, Session(ret_val))
127
+ test = requests_transport.RequestsTransport(settings, session)
128
+ resp = test.request(transport.HttpMethod.GET, "/some/path")
129
+ assert isinstance(resp, transport.Response)
130
+ assert resp.value == value
131
+ assert resp.ok is True
132
+ assert resp.response_mode == expected_response_mode
133
+ assert resp.encoding == expected_encoding
134
+
135
+
136
+ @pytest.mark.parametrize(
137
+ "headers, expected_encoding, expected_response_mode", parametrize
138
+ )
139
+ def test_request_not_ok(
140
+ settings: transport.PTransportSettings,
141
+ headers: MutableMapping[str, str],
142
+ expected_response_mode: transport.ResponseMode,
143
+ expected_encoding: str,
144
+ ):
145
+ """Test API error response
146
+ """
147
+ value = b"Some API error"
148
+ ret_val = Response(
149
+ ok=False,
150
+ content=value,
151
+ headers=requests.structures.CaseInsensitiveDict(headers),
152
+ )
153
+ session = cast(requests.Session, Session(ret_val))
154
+ test = requests_transport.RequestsTransport(settings, session)
155
+ resp = test.request(transport.HttpMethod.GET, "/some/path")
156
+ assert isinstance(resp, transport.Response)
157
+ assert resp.value == value
158
+ assert resp.ok is False
159
+ assert resp.response_mode == expected_response_mode
160
+ assert resp.encoding == expected_encoding
161
+
162
+
163
+ def test_request_error(settings):
164
+ """Test network error response
165
+ """
166
+ session = cast(requests.Session, Session(None, True))
167
+ test = requests_transport.RequestsTransport(settings, session)
168
+ resp = test.request(transport.HttpMethod.GET, "/some/path")
169
+ assert isinstance(resp, transport.Response)
170
+ assert resp.value == b"(54, 'Connection reset by peer')"
171
+ assert resp.ok is False