Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Zscaler Python SDK Changelog

## 1.9.42 (August 18 2026)

### Notes

- Python Versions: **v3.9, v3.10, v3.11, v3.12**

### Bug Fixes

* [PR #564](https://github.com/zscaler/zscaler-sdk-python/pull/564) - Fixed ZCC pagination so `has_next()`/`next()` properly traverse all pages on flat-array list endpoints (e.g. `list_devices`). [Issue #563](https://github.com/zscaler/zscaler-sdk-python/issues/563)

## 1.9.41 (August 6 2026)

### Notes
Expand Down
13 changes: 13 additions & 0 deletions docsrc/zs/guides/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ Release Notes
Zscaler Python SDK Changelog
----------------------------

1.9.42 (August 18 2026)
---------------------------

Notes
-------

- Python Versions: **v3.9, v3.10, v3.11, v3.12**

Bug Fixes
---------

(`#564 <https://github.com/zscaler/zscaler-sdk-python/pull/564>`_) - Fixed ZCC pagination so `has_next()`/`next()` properly traverse all pages on flat-array list endpoints (e.g. `list_devices`). (`Issue #563 <https://github.com/zscaler/zscaler-sdk-python/issues/563>`_)

1.9.41 (August 6 2026)
---------------------------

Expand Down
525 changes: 317 additions & 208 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ aenum==3.1.17 ; python_version >= "3.10" and python_version < "4.0"
arrow==1.4.0 ; python_version >= "3.10" and python_version < "4.0"
certifi==2026.7.22 ; python_version >= "3.10" and python_version < "4.0"
cffi==2.1.1 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
charset-normalizer==3.4.9 ; python_version >= "3.10" and python_version < "4.0"
charset-normalizer==3.5.1 ; python_version >= "3.10" and python_version < "4.0"
cryptography==50.0.0 ; python_version >= "3.10" and python_version < "4.0"
idna==3.18 ; python_version >= "3.10" and python_version < "4.0"
idna==3.19 ; python_version >= "3.10" and python_version < "4.0"
jmespath==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
jwcrypto==1.5.8 ; python_version >= "3.10" and python_version < "4.0"
pycparser==3.0 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy" and implementation_name != "PyPy"
Expand Down
162 changes: 162 additions & 0 deletions tests/unit/test_oneapi_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,168 @@ def test_next_zia_flat_list_pagination():
empty_resp.next()


def test_zcc_flat_list_pagination():
"""Test that ZCC flat list responses DO support pagination (issue #563)."""
mock_request_executor = Mock()

mock_res_details = Mock()
mock_res_details.headers = {"Content-Type": "application/json"}
mock_res_details.status_code = 200

req = {
"url": "https://api.example.com/zcc/papi/public/v1/getDevices",
"headers": {"Authorization": "Bearer token"},
"params": {"page": 1, "pageSize": 5000},
}

# ZCC returns flat JSON arrays for paginated list endpoints (e.g. getDevices)
response_body = json.dumps([{"udid": str(i)} for i in range(5000)])

response = ZscalerAPIResponse(
request_executor=mock_request_executor,
req=req,
service_type="zcc",
res_details=mock_res_details,
response_body=response_body,
)

# ZCC flat lists must NOT be marked as non-paginated
assert response._is_flat_list_response is False
# ZCC page-size limits (default 50 / max 5000) must not clamp the user's value
assert response._limit == 5000
# User-supplied "page" initializes the internal page counter
assert response._page == 1
assert response.has_next() is True # got results → might have more


def test_next_zcc_two_pages():
"""Test ZCC next() walks pages until a partial page is returned (issue #563)."""
mock_request_executor = Mock()
# Page 2 is a partial page (1 item < pageSize 3) → pagination stops after it
mock_request_executor.fire_request.return_value = (
None,
None,
'[{"udid": "device-4"}]',
None,
)

mock_res_details = Mock()
mock_res_details.headers = {"Content-Type": "application/json"}
mock_res_details.status_code = 200

req = {
"url": "https://api.example.com/zcc/papi/public/v1/getDevices",
"headers": {"Authorization": "Bearer token"},
"params": {"page": 1, "pageSize": 3},
}

# First page is full (3 items == pageSize)
response_body = json.dumps([{"udid": f"device-{i}"} for i in range(1, 4)])

response = ZscalerAPIResponse(
request_executor=mock_request_executor,
req=req,
service_type="zcc",
res_details=mock_res_details,
response_body=response_body,
)

assert response.has_next() is True

results, next_response, error = response.next()

assert error is None
assert results == [{"udid": "device-4"}]
assert next_response == response
# The next-page request must ask for 1-based page 2
assert response._params["page"] == 2
# Partial page (1 < 3) → no more pages
assert response.has_next() is False


def test_zcc_page_param_initializes_page_counter():
"""Test that a user-supplied ZCC 'page' param seeds the page counter."""
mock_request_executor = Mock()
mock_request_executor.fire_request.return_value = (None, None, "[]", None)

mock_res_details = Mock()
mock_res_details.headers = {"Content-Type": "application/json"}
mock_res_details.status_code = 200

req = {
"url": "https://api.example.com/zcc/papi/public/v1/getDevices",
"headers": {"Authorization": "Bearer token"},
"params": {"page": 3, "pageSize": 2},
}

response_body = '[{"udid": "a"}, {"udid": "b"}]'

response = ZscalerAPIResponse(
request_executor=mock_request_executor,
req=req,
service_type="zcc",
res_details=mock_res_details,
response_body=response_body,
)

assert response._page == 3

response.next()
# Continuing from page 3, the next request must ask for page 4
assert response._params["page"] == 4


def test_zcc_empty_first_page_has_no_next():
"""Test that an empty ZCC list response reports no further pages."""
mock_request_executor = Mock()
mock_res_details = Mock()
mock_res_details.headers = {"Content-Type": "application/json"}
mock_res_details.status_code = 200

req = {
"url": "https://api.example.com/zcc/papi/public/v1/getDevices",
"headers": {"Authorization": "Bearer token"},
"params": {"page": 1, "pageSize": 50},
}

response = ZscalerAPIResponse(
request_executor=mock_request_executor,
req=req,
service_type="zcc",
res_details=mock_res_details,
response_body="[]",
)

assert response.has_next() is False
with pytest.raises(StopIteration):
response.next()


def test_zcc_single_object_response_unchanged():
"""Test that ZCC single-object (dict) responses still wrap into a one-item list."""
mock_request_executor = Mock()
mock_res_details = Mock()
mock_res_details.headers = {"Content-Type": "application/json"}
mock_res_details.status_code = 200

req = {
"url": "https://api.example.com/zcc/papi/public/v1/getServiceStatus",
"headers": {"Authorization": "Bearer token"},
"params": {},
}

response = ZscalerAPIResponse(
request_executor=mock_request_executor,
req=req,
service_type="zcc",
res_details=mock_res_details,
response_body='{"id": 1, "name": "test"}',
)

assert response._list == [{"id": 1, "name": "test"}]
assert response._is_flat_list_response is False


def test_next_no_more_pages():
"""Test next method when no more pages are available."""
mock_request_executor = Mock()
Expand Down
14 changes: 12 additions & 2 deletions zscaler/oneapi_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class ZscalerAPIResponse:
"zpa": {"default": 100, "max": 500},
"zia": {"default": 500, "max": 10000},
"zdx": {"default": 10, "min": 1},
"zcc": {"default": 50, "max": 5000, "min": 1},
"zcell": {"default": 10, "max": 100, "min": 1},
}

Expand Down Expand Up @@ -86,6 +87,14 @@ def __init__(
self._page = 1
self._params.pop("page", None)

# ZCC uses a 1-based "page" query param; if the user supplied one,
# initialize self._page so next() continues from the right place.
if self._service_type == "zcc" and "page" in self._params:
try:
self._page = int(self._params["page"])
except Exception:
self._page = 1

# Resolve the user-supplied page size from the correct param key per service.
# ZIA uses "pageSize"; ZPA uses "pagesize"; ZCell uses "size"; others use "limit".
raw_page_size = (
Expand Down Expand Up @@ -173,10 +182,11 @@ def _build_json_response(self, response_body: str) -> None:
if isinstance(self._body, list):
self._list = self._body

# ZIA returns flat JSON arrays for paginated list endpoints.
# ZIA and ZCC return flat JSON arrays for paginated list endpoints
# (ZCC paginates via 1-based "page"/"pageSize" query params).
# Do NOT mark those as flat-list (non-paginated); let the
# page-size heuristic in _has_next() drive pagination instead.
if self._service_type == "zia":
if self._service_type in ("zia", "zcc"):
self._is_flat_list_response = False
else:
self._is_flat_list_response = True
Expand Down
2 changes: 1 addition & 1 deletion zscaler/zia/url_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def list_categories_lite(self, query_params: Optional[dict] = None) -> APIResult
except Exception as error:
return (None, response, error)
return (result, response, None)

def get_category(self, category_id: str) -> APIResult[URLCategory]:
"""
Returns URL category information for the provided category.
Expand Down
6 changes: 4 additions & 2 deletions zscaler/zpa/models/application_segment.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
self.hbr_enabled = config["hbrEnabled"] if "hbrEnabled" in config else None
self.sticky_entity = config["stickyEntity"] if "stickyEntity" in config else None
self.sticky_group = config["stickyGroup"] if "stickyGroup" in config else None

self.domain_names = ZscalerCollection.form_list(config["domainNames"] if "domainNames" in config else [], str)

self.server_groups = []
Expand All @@ -87,7 +87,9 @@ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:

self.pra_apps = ZscalerCollection.form_list(config["praApps"] if "praApps" in config else [], PRAApps)

self.guest_details = ZscalerCollection.form_list(config["guestDetails"] if "guestDetails" in config else [], GuestDetails)
self.guest_details = ZscalerCollection.form_list(
config["guestDetails"] if "guestDetails" in config else [], GuestDetails
)

self.inspection_apps = ZscalerCollection.form_list(
config["inspectionApps"] if "inspectionApps" in config else [], InspectionApps
Expand Down
Loading