Skip to content

Commit d27fd6a

Browse files
Gayathri Srividya RajavarapuGayathri Srividya Rajavarapu
authored andcommitted
Manual changes: lazy pagination and paginated tests verified
1 parent 6e3e085 commit d27fd6a

2 files changed

Lines changed: 134 additions & 112 deletions

File tree

pyiceberg/catalog/rest/__init__.py

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -437,49 +437,55 @@ def _create_session(self) -> Session:
437437
elif ssl_client_cert := ssl_client.get(CERT):
438438
session.cert = ssl_client_cert
439439

440+
auth_type = None
441+
source_env_var = None
442+
auth_config = None
440443
if raw_auth := self.properties.get(AUTH):
441444
# When auth is configured via an environment variable (e.g. PYICEBERG_CATALOG__<NAME>__AUTH),
442445
# the value arrives as a JSON string rather than a dict. Decode it before processing.
446+
source_env_var = f"PYICEBERG_CATALOG__{self.name.upper()}__AUTH"
443447
if isinstance(raw_auth, str):
444448
try:
445-
auth_config: dict[str, Any] = json.loads(raw_auth)
449+
auth_config = json.loads(raw_auth)
446450
except json.JSONDecodeError as e:
447-
raise ValueError(f"Failed to parse auth configuration as JSON: {raw_auth!r}") from e
451+
raise ValueError(f"Failed to parse auth configuration as JSON from {source_env_var}: {raw_auth!r}") from e
452+
if not isinstance(auth_config, dict):
453+
raise ValueError(
454+
f"Auth configuration loaded from {source_env_var} must be a JSON object (dict), got {type(auth_config).__name__}: {raw_auth!r}"
455+
)
448456
else:
457+
if not isinstance(raw_auth, dict):
458+
raise ValueError(
459+
f"Auth configuration for {source_env_var} must be a dict or JSON string, got {type(raw_auth).__name__}: {raw_auth!r}"
460+
)
449461
auth_config = raw_auth
450462
auth_type = auth_config.get("type")
451-
if auth_type is None:
452-
raise ValueError("auth.type must be defined")
453-
auth_type_config = auth_config.get(auth_type, {})
463+
auth_type_config = auth_config.get(auth_type, {}) if auth_type else None
454464
auth_impl = auth_config.get("impl")
455-
456-
if auth_type == CUSTOM and not auth_impl:
457-
raise ValueError("auth.impl must be specified when using custom auth.type")
458-
459-
if auth_type != CUSTOM and auth_impl:
460-
raise ValueError("auth.impl can only be specified when using custom auth.type")
461-
462-
self._auth_manager = AuthManagerFactory.create(auth_impl or auth_type, auth_type_config)
463-
session.auth = AuthManagerAdapter(self._auth_manager)
464465
elif auth_type := self.properties.get(f"{AUTH}.type"):
465466
# Support flattened env-var style configuration:
466467
# PYICEBERG_CATALOG__<NAME>__AUTH__TYPE=oauth2
467468
# PYICEBERG_CATALOG__<NAME>__AUTH__OAUTH2__CLIENT_ID=id
468469
# The env-var parser maps these to flat properties like "auth.type" and "auth.oauth2.client-id".
469470
# Key names are converted from kebab-case to snake_case to match AuthManager constructor parameters.
470471
auth_impl = self.properties.get(f"{AUTH}.impl")
471-
472-
if auth_type == CUSTOM and not auth_impl:
473-
raise ValueError("auth.impl must be specified when using custom auth.type")
474-
475-
if auth_type != CUSTOM and auth_impl:
476-
raise ValueError("auth.impl can only be specified when using custom auth.type")
477-
478472
type_prefix = f"{AUTH}.{auth_type}."
479473
auth_type_config = {
480474
k[len(type_prefix) :].replace("-", "_"): v for k, v in self.properties.items() if k.startswith(type_prefix)
481475
}
482-
476+
if auth_type:
477+
if auth_type is None:
478+
raise ValueError(
479+
f"auth.type must be defined in auth configuration{f' from {source_env_var}' if source_env_var else ''}"
480+
)
481+
if auth_type == CUSTOM and not auth_impl:
482+
raise ValueError(
483+
f"auth.impl must be specified when using custom auth.type{f' (from {source_env_var})' if source_env_var else ''}"
484+
)
485+
if auth_type != CUSTOM and auth_impl:
486+
raise ValueError(
487+
f"auth.impl can only be specified when using custom auth.type{f' (from {source_env_var})' if source_env_var else ''}"
488+
)
483489
self._auth_manager = AuthManagerFactory.create(auth_impl or auth_type, auth_type_config)
484490
session.auth = AuthManagerAdapter(self._auth_manager)
485491
else:

tests/catalog/test_rest.py

Lines changed: 106 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,10 @@ def test_token_200(rest_mock: Mocker) -> None:
198198
status_code=200,
199199
request_headers=OAUTH_TEST_HEADERS,
200200
)
201-
assert (
202-
RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS)._session.headers["Authorization"] # pylint: disable=W0212
203-
== f"Bearer {TEST_TOKEN}"
204-
)
201+
catalog = RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS)
202+
req = Request("GET", "http://example.com")
203+
prepped = catalog._session.prepare_request(req)
204+
assert prepped.headers["Authorization"] == f"Bearer {TEST_TOKEN}"
205205

206206

207207
@pytest.mark.filterwarnings(
@@ -218,10 +218,10 @@ def test_token_200_without_optional_fields(rest_mock: Mocker) -> None:
218218
status_code=200,
219219
request_headers=OAUTH_TEST_HEADERS,
220220
)
221-
assert (
222-
RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS)._session.headers["Authorization"] # pylint: disable=W0212
223-
== f"Bearer {TEST_TOKEN}"
224-
)
221+
catalog = RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS)
222+
req = Request("GET", "http://example.com")
223+
prepped = catalog._session.prepare_request(req)
224+
assert prepped.headers["Authorization"] == f"Bearer {TEST_TOKEN}"
225225

226226

227227
@pytest.mark.filterwarnings(
@@ -240,12 +240,10 @@ def test_token_with_optional_oauth_params(rest_mock: Mocker) -> None:
240240
status_code=200,
241241
request_headers=OAUTH_TEST_HEADERS,
242242
)
243-
assert (
244-
RestCatalog(
245-
"rest", uri=TEST_URI, credential=TEST_CREDENTIALS, audience=TEST_AUDIENCE, resource=TEST_RESOURCE
246-
)._session.headers["Authorization"]
247-
== f"Bearer {TEST_TOKEN}"
248-
)
243+
catalog = RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS, audience=TEST_AUDIENCE, resource=TEST_RESOURCE)
244+
req = Request("GET", "http://example.com")
245+
prepped = catalog._session.prepare_request(req)
246+
assert prepped.headers["Authorization"] == f"Bearer {TEST_TOKEN}"
249247
assert TEST_AUDIENCE in mock_request.last_request.text
250248
assert TEST_RESOURCE in mock_request.last_request.text
251249

@@ -266,10 +264,10 @@ def test_token_with_optional_oauth_params_as_empty(rest_mock: Mocker) -> None:
266264
status_code=200,
267265
request_headers=OAUTH_TEST_HEADERS,
268266
)
269-
assert (
270-
RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS, audience="", resource="")._session.headers["Authorization"]
271-
== f"Bearer {TEST_TOKEN}"
272-
)
267+
catalog = RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS, audience="", resource="")
268+
req = Request("GET", "http://example.com")
269+
prepped = catalog._session.prepare_request(req)
270+
assert prepped.headers["Authorization"] == f"Bearer {TEST_TOKEN}"
273271
assert TEST_AUDIENCE not in mock_request.last_request.text
274272
assert TEST_RESOURCE not in mock_request.last_request.text
275273

@@ -290,9 +288,10 @@ def test_token_with_default_scope(rest_mock: Mocker) -> None:
290288
status_code=200,
291289
request_headers=OAUTH_TEST_HEADERS,
292290
)
293-
assert (
294-
RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS)._session.headers["Authorization"] == f"Bearer {TEST_TOKEN}"
295-
)
291+
catalog = RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS)
292+
req = Request("GET", "http://example.com")
293+
prepped = catalog._session.prepare_request(req)
294+
assert prepped.headers["Authorization"] == f"Bearer {TEST_TOKEN}"
296295
assert "catalog" in mock_request.last_request.text
297296

298297

@@ -312,10 +311,10 @@ def test_token_with_custom_scope(rest_mock: Mocker) -> None:
312311
status_code=200,
313312
request_headers=OAUTH_TEST_HEADERS,
314313
)
315-
assert (
316-
RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS, scope=TEST_SCOPE)._session.headers["Authorization"]
317-
== f"Bearer {TEST_TOKEN}"
318-
)
314+
catalog = RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS, scope=TEST_SCOPE)
315+
req = Request("GET", "http://example.com")
316+
prepped = catalog._session.prepare_request(req)
317+
assert prepped.headers["Authorization"] == f"Bearer {TEST_TOKEN}"
319318
assert TEST_SCOPE in mock_request.last_request.text
320319

321320

@@ -335,14 +334,10 @@ def test_token_200_w_oauth2_server_uri(rest_mock: Mocker) -> None:
335334
status_code=200,
336335
request_headers=OAUTH_TEST_HEADERS,
337336
)
338-
# pylint: disable=W0212
339-
assert (
340-
RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS, **{OAUTH2_SERVER_URI: OAUTH2_SERVER_URI})._session.headers[
341-
"Authorization"
342-
]
343-
== f"Bearer {TEST_TOKEN}"
344-
)
345-
# pylint: enable=W0212
337+
catalog = RestCatalog("rest", uri=TEST_URI, credential=TEST_CREDENTIALS, **{OAUTH2_SERVER_URI: OAUTH2_SERVER_URI})
338+
req = Request("GET", "http://example.com")
339+
prepped = catalog._session.prepare_request(req)
340+
assert prepped.headers["Authorization"] == f"Bearer {TEST_TOKEN}"
346341

347342

348343
@pytest.mark.filterwarnings(
@@ -3173,66 +3168,87 @@ def test_load_table_without_storage_credentials(
31733168
# variables unless auth JSON strings are decoded.
31743169

31753170

3176-
def test_rest_catalog_with_basic_auth_as_json_string(rest_mock: Mocker) -> None:
3177-
"""When auth arrives as a JSON string (e.g. from an environment variable), it should be decoded correctly."""
3178-
import json
3179-
3180-
rest_mock.get(
3181-
f"{TEST_URI}v1/config",
3182-
json={"defaults": {}, "overrides": {}},
3183-
status_code=200,
3184-
)
3185-
auth_dict = {
3186-
"type": "basic",
3187-
"basic": {
3188-
"username": "one",
3189-
"password": "two",
3190-
},
3191-
}
3192-
catalog_properties = {
3193-
"uri": TEST_URI,
3194-
"auth": json.dumps(auth_dict),
3195-
}
3196-
catalog = RestCatalog("rest", **catalog_properties)
3197-
assert catalog.uri == TEST_URI
3198-
3199-
encoded_user_pass = base64.b64encode(b"one:two").decode()
3200-
expected_auth_header = f"Basic {encoded_user_pass}"
3201-
assert rest_mock.last_request.headers["Authorization"] == expected_auth_header
3171+
import json
32023172

3173+
import pytest
32033174

3204-
def test_rest_catalog_with_oauth2_auth_as_json_string(requests_mock: Mocker) -> None:
3205-
"""OAuth2 auth configured as a JSON string (e.g. from an environment variable) should work correctly."""
3206-
import json
32073175

3208-
requests_mock.post(
3209-
f"{TEST_URI}oauth2/token",
3210-
json={
3211-
"access_token": "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3",
3212-
"token_type": "Bearer",
3213-
"expires_in": 3600,
3214-
},
3215-
status_code=200,
3216-
)
3217-
requests_mock.get(
3218-
f"{TEST_URI}v1/config",
3219-
json={"defaults": {}, "overrides": {}},
3220-
status_code=200,
3221-
)
3222-
auth_dict = {
3223-
"type": "oauth2",
3224-
"oauth2": {
3225-
"client_id": "some_client_id",
3226-
"client_secret": "some_client_secret",
3227-
"token_url": f"{TEST_URI}oauth2/token",
3228-
},
3229-
}
3230-
catalog_properties = {
3231-
"uri": TEST_URI,
3232-
"auth": json.dumps(auth_dict),
3233-
}
3234-
catalog = RestCatalog("rest", **catalog_properties)
3235-
assert catalog.uri == TEST_URI
3176+
@pytest.mark.parametrize(
3177+
"auth_dict, expected_header, mocker_type",
3178+
[
3179+
# Basic auth
3180+
(
3181+
{"type": "basic", "basic": {"username": "one", "password": "two"}},
3182+
lambda: f"Basic {base64.b64encode(b'one:two').decode()}",
3183+
"rest_mock",
3184+
),
3185+
# OAuth2 auth
3186+
(
3187+
{
3188+
"type": "oauth2",
3189+
"oauth2": {
3190+
"client_id": "some_client_id",
3191+
"client_secret": "some_client_secret",
3192+
"token_url": f"{TEST_URI}oauth2/token",
3193+
},
3194+
},
3195+
None, # OAuth2 does not set Authorization header immediately
3196+
"requests_mock",
3197+
),
3198+
# OAuth2 with int fields
3199+
(
3200+
{
3201+
"type": "oauth2",
3202+
"oauth2": {
3203+
"client_id": "id",
3204+
"client_secret": "secret",
3205+
"token_url": f"{TEST_URI}oauth2/token",
3206+
"refresh_margin": 10,
3207+
"expires_in": 3600,
3208+
},
3209+
},
3210+
None,
3211+
"requests_mock",
3212+
),
3213+
],
3214+
)
3215+
def test_rest_catalog_with_auth_json_string(requests_mock, rest_mock, auth_dict, expected_header, mocker_type):
3216+
"""Test various auth configs as JSON string (from env var) are decoded and handled correctly."""
3217+
if mocker_type == "rest_mock":
3218+
rest_mock.get(
3219+
f"{TEST_URI}v1/config",
3220+
json={"defaults": {}, "overrides": {}},
3221+
status_code=200,
3222+
)
3223+
catalog_properties = {
3224+
"uri": TEST_URI,
3225+
"auth": json.dumps(auth_dict),
3226+
}
3227+
catalog = RestCatalog("rest", **catalog_properties)
3228+
assert catalog.uri == TEST_URI
3229+
if expected_header:
3230+
assert rest_mock.last_request.headers["Authorization"] == expected_header()
3231+
else:
3232+
requests_mock.post(
3233+
f"{TEST_URI}oauth2/token",
3234+
json={
3235+
"access_token": "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3",
3236+
"token_type": "Bearer",
3237+
"expires_in": 3600,
3238+
},
3239+
status_code=200,
3240+
)
3241+
requests_mock.get(
3242+
f"{TEST_URI}v1/config",
3243+
json={"defaults": {}, "overrides": {}},
3244+
status_code=200,
3245+
)
3246+
catalog_properties = {
3247+
"uri": TEST_URI,
3248+
"auth": json.dumps(auth_dict),
3249+
}
3250+
catalog = RestCatalog("rest", **catalog_properties)
3251+
assert catalog.uri == TEST_URI
32363252

32373253

32383254
def test_rest_catalog_with_invalid_json_auth_string() -> None:

0 commit comments

Comments
 (0)