A PR is attached that fixes this — see Suggested fix below. Happy to
rescope it if you'd prefer a narrower or wider change.
Description
Every ZPA application segment resource reformats the caller's
server_group_ids into the wire-shaped serverGroups list behind a
key-presence test:
if "server_group_ids" in body:
body["serverGroups"] = [{"id": group_id} for group_id in body.pop("server_group_ids")]
Presence is not the same as usability. server_group_ids=None puts the key in
body with a None value, so the comprehension raises
TypeError: 'NoneType' object is not iterable
inside the SDK, before the request is built and before anything is sent.
None is not an exotic input — it is what you get from forwarding an optional
keyword argument whose default is None, which is the natural shape of a
partial update that does not intend to change server groups:
def retarget(client, segment_id, domain, server_group_ids=None):
return client.zpa.application_segment.update_segment(
segment_id,
domain_names=[domain],
server_group_ids=server_group_ids, # None unless the caller overrides it
)
The practical consequence is that this partial update cannot be performed at
all. To leave server groups untouched, the caller has to keep the key out of
the call entirely, which means building **kwargs conditionally rather than
just passing the argument through:
extra = {} if server_group_ids is None else {"server_group_ids": server_group_ids}
client.zpa.application_segment.update_segment(segment_id, domain_names=[domain], **extra)
That workaround has to be repeated at every call site, for a field the API
itself treats as optional on update.
Where
master at e7f5f7ef. Eleven occurrences, in both the create and update path
of each resource:
| file |
line |
method |
zscaler/zpa/application_segment.py |
273-274 |
add_segment |
zscaler/zpa/application_segment.py |
408-409 |
update_segment |
zscaler/zpa/application_segment.py |
769-770 |
add_segment_provision |
zscaler/zpa/app_segments_ba_v2.py |
257-258 |
add_segment_ba |
zscaler/zpa/app_segments_ba_v2.py |
360-361 |
update_segment_ba |
zscaler/zpa/app_segments_ba.py |
275-276 |
add_segment_ba |
zscaler/zpa/app_segments_ba.py |
412-413 |
update_segment_ba |
zscaler/zpa/app_segments_inspection.py |
244-245 |
add_segment_inspection |
zscaler/zpa/app_segments_inspection.py |
396-397 |
update_segment_inspection |
zscaler/zpa/app_segments_pra.py |
252-253 |
add_segment_pra |
zscaler/zpa/app_segments_pra.py |
356-357 |
update_segment_pra |
The update paths spell the loop variable gid and the create paths spell it
group_id; otherwise all eleven lines are identical.
Reproduction
No credentials, no tenant and no network access required — the failure happens
in the SDK's own body-building code, upstream of the request executor. The stub
below captures the body the SDK assembles and stops before any HTTP call.
# repro.py
from zscaler.zpa.application_segment import ApplicationSegmentAPI
class StubExecutor:
def create_request(self, method, endpoint, body=None, headers=None, params=None, **kwargs):
print("body the SDK built:", body)
return None, "stopped before HTTP"
api = ApplicationSegmentAPI(StubExecutor(), {"client": {"customerId": "1234567890"}})
# A partial update that does not intend to change server groups. This is what
# forwarding an optional keyword argument whose default is None produces.
api.update_segment(
"72058304855089379",
domain_names=["app.example.com"],
server_group_ids=None,
)
$ python repro.py
Traceback (most recent call last):
File "repro.py", line 19, in <module>
api.update_segment(
File ".../zscaler/zpa/application_segment.py", line 409, in update_segment
body["serverGroups"] = [{"id": group_id} for group_id in body.pop("server_group_ids")]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: 'NoneType' object is not iterable
Swap ApplicationSegmentAPI.update_segment for any of the other ten entry
points in the table above and the traceback is the same modulo file and line.
Expected behavior
server_group_ids=None means "not supplied": no serverGroups key in the
request body, and the segment's existing server groups left untouched — the
same outcome as omitting the argument. The server_group_ids snake_case key
should also not leak into the body.
With the attached fix, the same repro prints:
$ python repro.py
body the SDK built: {'domain_names': ['app.example.com'], 'tcpPortRanges': [], 'tcpPortRange': [], 'udpPortRanges': [], 'udpPortRange': []}
Actual behavior
TypeError: 'NoneType' object is not iterable, raised from the SDK before a
request object exists. The traceback points at an SDK internal rather than at
anything the caller can act on, and no debug logs are produced because no
request is ever made.
Is it a regression?
Not that I can find — the presence test appears to predate the current v1.9.x
line, and all eleven sites are consistent with each other, so this reads as
original behavior rather than a recent change.
Note on the empty-list case
Worth calling out because it constrains the fix: an explicitly supplied empty
list is currently meaningful and must stay that way.
These methods deliberately omit serverGroups from the body when the caller
does not mention server_group_ids, so on an update [] is the only way to
express "clear the server groups". Compare the sibling port-range fields in the
same update paths, which are unconditionally reset — e.g.
app_segments_pra.py:408-411:
if "udp_port_ranges" in body:
body["udpPortRanges"] = body.pop("udp_port_ranges")
else:
body["udpPortRanges"] = [] # Explicitly clear if not provided
So [] already means "clear" in this code, and a fix must not collapse None
and [] together. A truthiness guard (if body.get("server_group_ids"):)
would do exactly that, which is why the attached PR tests for is not None.
Suggested fix
Pop the value first and reformat only when it is not None:
server_group_ids = body.pop("server_group_ids", None)
if server_group_ids is not None:
body["serverGroups"] = [{"id": group_id} for group_id in server_group_ids]
Behavior per input:
server_group_ids |
before |
after |
| omitted |
no serverGroups |
unchanged |
["72058304855090128"] |
serverGroups: [{"id": "72058304855090128"}] |
unchanged |
[] |
serverGroups: [] |
unchanged |
None |
TypeError |
no serverGroups, no leftover key |
Unconditionally popping also keeps the snake_case key from surviving into the
request body, which matters for add_segment_provision — it uses
add_id_groups() rather than transform_common_id_fields(), and
add_id_groups() skips falsy values without popping them
(zscaler/utils.py:212-216), so a None left in body would be camel-cased
onto the wire as serverGroupIds: null.
The attached PR applies that three-line form at all eleven sites and adds
tests/unit/zpa/test_app_segment_server_group_ids.py, which parametrizes all
eleven entry points over the three inputs above. Eleven assertions fail on
e7f5f7ef and pass with the fix; the remaining twenty-two pass both before and
after, pinning the fact that nothing changes for callers who pass a real list
or an empty one.
Other information
- SDK version: 1.9.44 (
master, e7f5f7ef)
- Python: 3.12
- Found by reading the SDK source; reproduced locally with the stub above — no
tenant, credentials or live API involved.
Related — same pattern, not covered by the attached PR
server_group_ids is the field I hit, so the PR is scoped to it to keep the
diff reviewable. The same presence-then-iterate shape appears on other fields
across zscaler/zpa/, and None should hit each of them the same way:
application_segment.py, app_segments_ba*.py, app_segments_inspection.py,
app_segments_pra.py — tcp_port_range, udp_port_range
server_groups.py:215,218,291,294 — app_connector_group_ids, server_ids
service_edge_group.py:233,236,300,303 — trusted_network_ids,
service_edge_ids
pra_credential_pool.py:231,286 — credential_ids
user_portal_link.py:200,261 — user_portal_link_ids
pra_approval.py:204,296 — application_ids; note these already pass a
[] default to .pop(), which does not help when the key is present with
a None value
Happy to follow up with a second PR covering those in the same shape, or to
widen the attached one, whichever you prefer.
Description
Every ZPA application segment resource reformats the caller's
server_group_idsinto the wire-shapedserverGroupslist behind akey-presence test:
Presence is not the same as usability.
server_group_ids=Noneputs the key inbodywith aNonevalue, so the comprehension raisesinside the SDK, before the request is built and before anything is sent.
Noneis not an exotic input — it is what you get from forwarding an optionalkeyword argument whose default is
None, which is the natural shape of apartial update that does not intend to change server groups:
The practical consequence is that this partial update cannot be performed at
all. To leave server groups untouched, the caller has to keep the key out of
the call entirely, which means building
**kwargsconditionally rather thanjust passing the argument through:
That workaround has to be repeated at every call site, for a field the API
itself treats as optional on update.
Where
masterate7f5f7ef. Eleven occurrences, in both the create and update pathof each resource:
zscaler/zpa/application_segment.pyadd_segmentzscaler/zpa/application_segment.pyupdate_segmentzscaler/zpa/application_segment.pyadd_segment_provisionzscaler/zpa/app_segments_ba_v2.pyadd_segment_bazscaler/zpa/app_segments_ba_v2.pyupdate_segment_bazscaler/zpa/app_segments_ba.pyadd_segment_bazscaler/zpa/app_segments_ba.pyupdate_segment_bazscaler/zpa/app_segments_inspection.pyadd_segment_inspectionzscaler/zpa/app_segments_inspection.pyupdate_segment_inspectionzscaler/zpa/app_segments_pra.pyadd_segment_prazscaler/zpa/app_segments_pra.pyupdate_segment_praThe update paths spell the loop variable
gidand the create paths spell itgroup_id; otherwise all eleven lines are identical.Reproduction
No credentials, no tenant and no network access required — the failure happens
in the SDK's own body-building code, upstream of the request executor. The stub
below captures the body the SDK assembles and stops before any HTTP call.
Swap
ApplicationSegmentAPI.update_segmentfor any of the other ten entrypoints in the table above and the traceback is the same modulo file and line.
Expected behavior
server_group_ids=Nonemeans "not supplied": noserverGroupskey in therequest body, and the segment's existing server groups left untouched — the
same outcome as omitting the argument. The
server_group_idssnake_case keyshould also not leak into the body.
With the attached fix, the same repro prints:
Actual behavior
TypeError: 'NoneType' object is not iterable, raised from the SDK before arequest object exists. The traceback points at an SDK internal rather than at
anything the caller can act on, and no debug logs are produced because no
request is ever made.
Is it a regression?
Not that I can find — the presence test appears to predate the current v1.9.x
line, and all eleven sites are consistent with each other, so this reads as
original behavior rather than a recent change.
Note on the empty-list case
Worth calling out because it constrains the fix: an explicitly supplied empty
list is currently meaningful and must stay that way.
These methods deliberately omit
serverGroupsfrom the body when the callerdoes not mention
server_group_ids, so on an update[]is the only way toexpress "clear the server groups". Compare the sibling port-range fields in the
same update paths, which are unconditionally reset — e.g.
app_segments_pra.py:408-411:So
[]already means "clear" in this code, and a fix must not collapseNoneand
[]together. A truthiness guard (if body.get("server_group_ids"):)would do exactly that, which is why the attached PR tests for
is not None.Suggested fix
Pop the value first and reformat only when it is not
None:Behavior per input:
server_group_idsserverGroups["72058304855090128"]serverGroups: [{"id": "72058304855090128"}][]serverGroups: []NoneTypeErrorserverGroups, no leftover keyUnconditionally popping also keeps the snake_case key from surviving into the
request body, which matters for
add_segment_provision— it usesadd_id_groups()rather thantransform_common_id_fields(), andadd_id_groups()skips falsy values without popping them(
zscaler/utils.py:212-216), so aNoneleft inbodywould be camel-casedonto the wire as
serverGroupIds: null.The attached PR applies that three-line form at all eleven sites and adds
tests/unit/zpa/test_app_segment_server_group_ids.py, which parametrizes alleleven entry points over the three inputs above. Eleven assertions fail on
e7f5f7efand pass with the fix; the remaining twenty-two pass both before andafter, pinning the fact that nothing changes for callers who pass a real list
or an empty one.
Other information
master,e7f5f7ef)tenant, credentials or live API involved.
Related — same pattern, not covered by the attached PR
server_group_idsis the field I hit, so the PR is scoped to it to keep thediff reviewable. The same presence-then-iterate shape appears on other fields
across
zscaler/zpa/, andNoneshould hit each of them the same way:application_segment.py,app_segments_ba*.py,app_segments_inspection.py,app_segments_pra.py—tcp_port_range,udp_port_rangeserver_groups.py:215,218,291,294—app_connector_group_ids,server_idsservice_edge_group.py:233,236,300,303—trusted_network_ids,service_edge_idspra_credential_pool.py:231,286—credential_idsuser_portal_link.py:200,261—user_portal_link_idspra_approval.py:204,296—application_ids; note these already pass a[]default to.pop(), which does not help when the key is present witha
NonevalueHappy to follow up with a second PR covering those in the same shape, or to
widen the attached one, whichever you prefer.