diff --git a/CHANGELOG.md b/CHANGELOG.md index bcda8ca..07cb23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## [3.8.2] - 2026-06-12 + +- [Fixed] `data_flatten` (and thus all `query`/`query_one`/`mutate` + subscription callbacks) now strips nested `list` items from list results. This prevents consumers (e.g. valiotworker `currentQueues = ...` from `all_queues_w_listeners`) from receiving lists as elements inside what should be list-of-dicts; such bad elements produced `TypeError: list indices must be integers or slices, not str` inside `__.find(..., lambda cq: ... cq['name'])` in `workerQueueEnabled` (and similar) when an event subscription callback fired. (OPS-3999) + ## [3.8.1] - 2026-06-11 - [Fixed] Stale `httpx.AsyncClient` instances are now best-effort `aclose()`d instead of being dropped to the garbage collector. Previously three paths leaked the client's transports: `_get_async_client` when it detected a closed event loop, `async_execute`'s "Event loop is closed" retry, and `_close()`/`__del__`. Orphaned `_SelectorTransport.__del__` socket finalizers then ran during cyclic-GC sweeps on arbitrary threads, contributing to false TMPRL1101 deadlock detections in Temporal workers (see valiot/python-tooling#151). A new private `_drop_async_client()` helper awaits `aclose()` (swallowing failures when the original loop is gone) and is used by `_get_async_client`, the `async_execute` retry, and `async_cleanup`; `_close()` now schedules `aclose()` on the running loop via `call_soon_threadsafe` when one exists, falling back to GC only when no loop is available. @@ -7,7 +11,6 @@ - [Changed] Removed the unused `_close_async_client` private method, superseded by `_drop_async_client`. ## [3.8.0] - 2026-05-28 - - [Fixed] `addEnvironment` no longer wipes an existing environment's `wss`/`url`/`headers`/`post_timeout`/`ipv4_only` when re-registering the same environment name without those arguments. Re-registration now MERGES — omitted arguments keep their previous values. Previously a second `addEnvironment("prod", url=..., headers=...)` on the shared (Singleton) client — e.g. a library configuring its own environment — silently reset `wss` to `None`, which crashed the WSS reconnect loop in `_new_conn` with `TypeError: argument of type 'NoneType' is not a container` and produced endless "Failed connecting to None" errors. This was the root cause of the production incident. (OPS-3496) - [Fixed] `_new_conn` now guards against an unset/unregistered environment or a missing `wss`, logging a clear ERROR and returning `False` instead of raising `AttributeError`/`TypeError` and killing the subscription router thread. Defense-in-depth alongside the `addEnvironment` fix. (OPS-3496) diff --git a/pygqlc/GraphQLClient.py b/pygqlc/GraphQLClient.py index aeacd1d..d09fad3 100644 --- a/pygqlc/GraphQLClient.py +++ b/pygqlc/GraphQLClient.py @@ -127,16 +127,18 @@ def _data_flatten_impl(data, single_child=False): if len(keys) == 1: return _data_flatten_impl(data[keys[0]], single_child) else: - return data # ! various elements, nothing to flatten + return data elif single_child and isinstance(data, list): if len(data) == 1: return _data_flatten_impl(data[0], single_child) elif len(data) == 0: - return None # * Return none if no child was found + return None else: - return data + return [x for x in data if not isinstance(x, list)] else: - return data # ! not a dict, nothing to flatten + if isinstance(data, list): + return [x for x in data if not isinstance(x, list)] + return data def data_flatten(data, single_child=False): diff --git a/pygqlc/__version__.py b/pygqlc/__version__.py index e4e78c0..2ae7a96 100644 --- a/pygqlc/__version__.py +++ b/pygqlc/__version__.py @@ -1 +1 @@ -__version__ = "3.8.1" +__version__ = "3.8.2" diff --git a/tests/pygqlc/test_helpers.py b/tests/pygqlc/test_helpers.py index 3dbbd16..1bace03 100644 --- a/tests/pygqlc/test_helpers.py +++ b/tests/pygqlc/test_helpers.py @@ -1,4 +1,4 @@ -from pygqlc.GraphQLClient import safe_pop +from pygqlc.GraphQLClient import safe_pop, data_flatten, _data_flatten_impl from pygqlc.helper_modules.Singleton import Singleton @@ -45,3 +45,32 @@ def __init__(self, letter="G"): # Third call: no cached instance, so create one third_instance = UselessLetterClass() assert first_instance is not third_instance, "Should be a different instance" + + +def test_data_flatten_sanitizes_lists_to_avoid_nested_lists_in_object_lists(): + """TDD for OPS-3999: data_flatten on list-field shapes must not let nested lists + (or other non-dicts) appear as elements; this prevents valiotworker finding + list instead of dict in currentQueues (and similar list-of-objects results) + which led to TypeError: list indices must be integers or slices, not str in + workerQueueEnabled lambdas etc. We filter to dict/None only for safety on + object list responses (scalars lists would be affected but not used in + hot paths like queues).""" + env = { + "queues": [ + {"name": "q1", "listeners": [{"id": 1}]}, + ["bad", "list", "as", "queue", "entry"], + {"name": "q2"}, + None, + "scalar-bad", + ] + } + result = data_flatten(env) + assert isinstance(result, list), "list field must still return list" + # no nested lists (the root cause of list-indices TypeError in consumers); + # scalars/None may remain for scalar lists but object lists won't get sublists + assert not any(isinstance(x, list) for x in result) + names = [x.get("name") for x in result if isinstance(x, dict)] + assert names == ["q1", "q2"] + # direct impl too + result_impl = _data_flatten_impl(env) + assert not any(isinstance(x, list) for x in result_impl)