From fcea2d1751641fbd43d68642dfe4aa242a5f9381 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Thu, 4 Jun 2026 18:13:48 +0530 Subject: [PATCH 1/5] fix autopagination loop on non paginated api endpoint --- src/pytfe/resources/_base.py | 40 ++++++++-- src/pytfe/resources/variable.py | 8 +- tests/units/test_variable.py | 132 ++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 tests/units/test_variable.py diff --git a/src/pytfe/resources/_base.py b/src/pytfe/resources/_base.py index b60c17f2..6491eefd 100644 --- a/src/pytfe/resources/_base.py +++ b/src/pytfe/resources/_base.py @@ -7,6 +7,7 @@ from typing import Any from .._http import HTTPTransport +from .._logging import logger def _to_int(value: Any) -> int | None: @@ -26,9 +27,26 @@ def __init__(self, t: HTTPTransport) -> None: self.t = t def _list( - self, path: str, *, params: dict | None = None + self, path: str, *, params: dict | None = None, paginated: bool = True ) -> Iterator[dict[str, Any]]: base_params = dict(params or {}) + + # Some TFE endpoints are not paginated: they return the full collection + # in a single response, ignore page[number]/page[size], and emit no + # meta.pagination block (e.g. workspace /vars and /all-vars). Callers + # opt those out so we issue exactly one request and never loop trying to + # fetch a "next" page that re-returns the same set + # (see hashicorp/python-tfe#181). + if not paginated: + r = self.t.request("GET", path, params=base_params) + json_response = r.json() + if not isinstance(json_response, dict): + json_response = {} + data = json_response.get("data", []) + if isinstance(data, list): + yield from data + return + page = int(base_params.get("page[number]", 1)) while True: p = dict(base_params) @@ -81,8 +99,18 @@ def _list( # Metadata present and indicates no next page. break - # Fallback for endpoints that do not return pagination metadata. - page_size = int(p["page[size]"]) - if len(data) < page_size: - break - page += 1 + # No pagination metadata. Genuine TFE list endpoints always include + # meta.pagination, so a response without it is a non-paginated + # collection returned in full. Treat it as a single complete page + # and stop. Re-requesting would loop forever when the endpoint + # ignores page[number]/page[size] and re-returns the same full set + # (the root cause of hashicorp/python-tfe#181). + if len(data) >= int(p["page[size]"]): + logger.debug( + "List endpoint %s returned a full page (%d rows) without " + "pagination metadata; treating it as a single, " + "non-paginated response.", + path, + len(data), + ) + break diff --git a/src/pytfe/resources/variable.py b/src/pytfe/resources/variable.py index 60ee9245..3df2a6c1 100644 --- a/src/pytfe/resources/variable.py +++ b/src/pytfe/resources/variable.py @@ -30,13 +30,16 @@ def list( if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) + # The /vars endpoint is not paginated: it returns every variable in a + # single response and ignores page[number]/page[size]. Opt out of the + # pagination loop so we issue exactly one request (python-tfe#181). path = f"/api/v2/workspaces/{workspace_id}/vars" params: dict[str, Any] = {} if options: # Add any options if needed in the future pass - for item in self._list(path, params=params): + for item in self._list(path, params=params, paginated=False): attr = item.get("attributes", {}) or {} var_id = item.get("id", "") variable_data = dict(attr) @@ -50,13 +53,14 @@ def list_all( if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) + # Like /vars, the /all-vars endpoint is not paginated; request once. path = f"/api/v2/workspaces/{workspace_id}/all-vars" params: dict[str, Any] = {} if options: # Add any options if needed in the future pass - for item in self._list(path, params=params): + for item in self._list(path, params=params, paginated=False): attr = item.get("attributes", {}) or {} var_id = item.get("id", "") variable_data = dict(attr) diff --git a/tests/units/test_variable.py b/tests/units/test_variable.py new file mode 100644 index 00000000..32d694de --- /dev/null +++ b/tests/units/test_variable.py @@ -0,0 +1,132 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the workspace Variables resource. + +The headline cases here are regression tests for hashicorp/python-tfe#181: +``variables.list()`` infinite-looping on workspaces with >= 100 variables +because the ``/vars`` (and ``/all-vars``) endpoints are not paginated. +""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ERR_INVALID_WORKSPACE_ID +from pytfe.models.variable import Variable +from pytfe.resources._base import _Service +from pytfe.resources.variable import Variables + + +def _vars_payload(count: int) -> dict: + """A /vars-style response: full set in one page, no meta.pagination.""" + return { + "data": [ + { + "id": f"var-{i}", + "type": "vars", + "attributes": {"key": f"key-{i}", "value": f"value-{i}"}, + } + for i in range(count) + ] + } + + +class TestVariablesList: + """Tests for Variables.list / list_all.""" + + def setup_method(self): + self.mock_transport = Mock(spec=HTTPTransport) + self.variables = Variables(self.mock_transport) + self.workspace_id = "ws-test123" + + def test_list_validations(self): + with pytest.raises(ValueError, match=ERR_INVALID_WORKSPACE_ID): + list(self.variables.list("")) + with pytest.raises(ValueError, match=ERR_INVALID_WORKSPACE_ID): + list(self.variables.list(None)) + + def test_list_all_validations(self): + with pytest.raises(ValueError, match=ERR_INVALID_WORKSPACE_ID): + list(self.variables.list_all("")) + with pytest.raises(ValueError, match=ERR_INVALID_WORKSPACE_ID): + list(self.variables.list_all(None)) + + def test_list_does_not_paginate_with_100_plus_variables(self): + """Regression for #181: a workspace with >= 100 vars must not loop. + + The endpoint ignores page params and re-returns the full set, so the + old pagination heuristic looped forever. We now issue exactly one + request and return each variable once. + """ + response = Mock() + response.json.return_value = _vars_payload(150) + self.mock_transport.request.return_value = response + + result = list(self.variables.list(self.workspace_id)) + + # Exactly one request — no follow-up page fetches. + self.mock_transport.request.assert_called_once_with( + "GET", + f"/api/v2/workspaces/{self.workspace_id}/vars", + params={}, + ) + # All 150 variables, no duplication. + assert len(result) == 150 + assert all(isinstance(v, Variable) for v in result) + assert [v.id for v in result] == [f"var-{i}" for i in range(150)] + + def test_list_all_does_not_paginate_with_100_plus_variables(self): + response = Mock() + response.json.return_value = _vars_payload(120) + self.mock_transport.request.return_value = response + + result = list(self.variables.list_all(self.workspace_id)) + + self.mock_transport.request.assert_called_once_with( + "GET", + f"/api/v2/workspaces/{self.workspace_id}/all-vars", + params={}, + ) + assert len(result) == 120 + assert [v.id for v in result] == [f"var-{i}" for i in range(120)] + + def test_list_exactly_100_variables(self): + """The exactly-page-size boundary also looped under the old logic.""" + response = Mock() + response.json.return_value = _vars_payload(100) + self.mock_transport.request.return_value = response + + result = list(self.variables.list(self.workspace_id)) + + self.mock_transport.request.assert_called_once() + assert len(result) == 100 + + def test_list_empty(self): + response = Mock() + response.json.return_value = {"data": []} + self.mock_transport.request.return_value = response + + result = list(self.variables.list(self.workspace_id)) + + self.mock_transport.request.assert_called_once() + assert result == [] + + +class TestListSafetyNet: + """The generic _list safety net protects any non-paginated endpoint.""" + + def test_full_page_without_metadata_is_treated_as_single_page(self): + """A paginated (paginated=True) call that gets a full page with no + meta.pagination must stop after one request rather than loop.""" + transport = Mock(spec=HTTPTransport) + response = Mock() + response.json.return_value = _vars_payload(100) # full page, no meta + transport.request.return_value = response + + service = _Service(transport) + result = list(service._list("/api/v2/some/unpaginated", params={})) + + transport.request.assert_called_once() + assert len(result) == 100 From eb72f59f84b1631438aaf880d3b8dd113e25f9f1 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Thu, 4 Jun 2026 18:37:41 +0530 Subject: [PATCH 2/5] fix lints and update docs --- AGENTS.md | 1 + CHANGELOG.md | 5 +++++ docs/ITERATORS.md | 21 +++++++++++++++++++-- docs/MODELS.md | 2 +- docs/pagination.md | 8 ++++++-- src/pytfe/resources/task_stage.py | 4 ++-- tests/units/test_task_stage.py | 4 +++- 7 files changed, 37 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b0caee2..4cbfcb1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ These are mistakes a competent Python developer would make if they hadn't read t - **Don't catch `httpx` errors directly.** The transport already translates them into `TFEError` subclasses. Catching `httpx.HTTPError` in a resource means the typed error never propagates. - **Always send the bearer token, even to absolute URLs returned by the API.** Endpoints like `hosted_state_download_url`, `hosted_state_upload_url`, plan `json-output`, and apply `errored-state` redirect to `archivist.terraform.io` — which is HashiCorp infrastructure that *requires* the bearer. go-tfe does the same (see `state_version.go::Download` + `tfe.go::NewRequest`). Stripping the bearer breaks downstream consumers (notably the Ansible collection's statefile + dynamic-inventory flows). `HTTPTransport.request` accepts `include_auth=False` only as an opt-out for the hypothetical case of calling a genuinely non-HashiCorp host; do not use it for Archivist URLs. - **Don't write a custom page loop.** `self._list(path, params=...)` handles pagination + non-paginated endpoints transparently. Rolling your own loop will diverge from the rest of the codebase. +- **Disable pagination for endpoints that ignore page params.** A few endpoints return the *whole* collection on every request and ignore `page[number]`/`page[size]` (workspace `/vars` and `/all-vars`). Call `self._list(path, params=..., paginated=False)` for those — otherwise they infinite-loop once the collection reaches the page size (the [#181](https://github.com/hashicorp/python-tfe/issues/181) bug). See [ITERATORS.md](docs/ITERATORS.md). - **Don't reuse generators.** Iterators returned by `list_*` are single-use. If you need to traverse twice, `materialized = list(client.foo.list_bars(...))` first. - **Don't add features beyond what was asked.** This codebase is approaching v1.0.0. Adding "while I'm here" refactors or speculative abstractions slows reviews and risks breaking the Ansible collection. - **Don't assume every successful response is `{"data": ...}`.** Check the docs/go-tfe/spec for each endpoint: some return a JSON:API envelope, some return a bare resource object, `204 No Content`, `null`, raw bytes, or a redirect to a blob URL. Add tests for non-standard shapes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7c26f8..50265ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Unreleased +## Bug Fixes + +### Variables +* Fixed `variables.list()` / `variables.list_all()` infinite-looping on workspaces with 100 or more variables, so they are now fetched with a single request. The generic list helper also treats any response without `meta.pagination` as a single complete page, preventing the same loop on other non-paginated endpoints. [#181](https://github.com/hashicorp/python-tfe/issues/181) + # Released # v1.0.0 diff --git a/docs/ITERATORS.md b/docs/ITERATORS.md index e49c16ed..7837221d 100644 --- a/docs/ITERATORS.md +++ b/docs/ITERATORS.md @@ -56,7 +56,23 @@ def list( yield self._workspace_from(item) ``` -That's it. `self._list()` lives in `_base.py` and handles `page[number]` / `page[size]` and follow-through automatically. It is also robust to endpoints that **don't paginate** — if the response has no pagination metadata and the returned data is smaller than the requested page size, the helper just breaks after one round-trip. So you do not need a different code path for relationship reads like `GET /workspaces/{id}/tag-bindings` (single response) versus list endpoints like `GET /organizations/{org}/workspaces` (paginated). The same `for item in self._list(path): yield ...` works for both. +That's it. `self._list()` lives in `_base.py` and handles `page[number]` / `page[size]` and follow-through automatically. When a response carries no `meta.pagination` block, `_list` treats it as a single complete page and stops after one round-trip — so the same `for item in self._list(path): yield ...` works for ordinary paginated endpoints (`GET /organizations/{org}/workspaces`) and for single-response relationship reads (`GET /workspaces/{id}/tag-bindings`) alike. + +### Endpoints that ignore pagination entirely — pass `paginated=False` + +A few HCP Terraform endpoints return the **whole** collection on every request and ignore `page[number]` / `page[size]`. The workspace **`/vars`** and **`/all-vars`** endpoints are the known ones. For these you must opt out of the page loop explicitly: + +```python +# variable.py — /vars is not paginated +for item in self._list(path, params=params, paginated=False): + yield self._variable_from(item) +``` + +With `paginated=False`, `_list` issues exactly one request and yields every row. + +Why this matters: skipping the flag on such an endpoint with **≥ `page_size` rows (default 100)** used to spin forever — the helper saw a "full" page, asked for page 2, got the *same* full set back (the endpoint ignored the page param), and re-yielded it, indefinitely. That was the root cause of [#181](https://github.com/hashicorp/python-tfe/issues/181). The generic "no `meta.pagination` ⇒ single page" rule now catches this as a safety net, but **still set `paginated=False`** on a known non-paginated endpoint: it documents intent and avoids sending meaningless page params. + +> Rule of thumb: if the endpoint returns no `meta.pagination` and ignores `page[size]` (check go-tfe or the API docs — it returns the full collection in one shot), pass `paginated=False`. ### Note on lazy validation @@ -131,7 +147,7 @@ def list_versions( `registry_module.list_versions` is the only method in the codebase that does this. Add a docstring note explaining the reason if you find yourself reaching for this pattern, so future readers don't mistake it for something to copy. -Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Use `self._list()` for those — that's the convention. +Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Use `self._list()` for those — that's the convention (with `paginated=False` if the endpoint ignores `page[size]` and returns the full set, as described above). ### Shape that does **not** match the convention (don't do this) @@ -186,6 +202,7 @@ Don't assert `isinstance(result, list)` against the raw return — that asserts - [ ] Every `list*` method returns `Iterator[X]`, not `list[X]` or `Iterable[X]` - [ ] `Iterator` is imported from `collections.abc`, not `typing` - [ ] The body uses the canonical `for item in self._list(path, params=params): yield ...` pattern — including for non-paginated single-shot endpoints +- [ ] Endpoints that ignore `page[size]` and return the whole collection (e.g. workspace `/vars`, `/all-vars`) pass `paginated=False` to `self._list(...)` — otherwise they infinite-loop at ≥ 100 rows (see [#181](https://github.com/hashicorp/python-tfe/issues/181)) - [ ] Hand-rolled `iter(materialized_list)` only appears if the method has a try/except fallback to a different endpoint (extremely rare — has a docstring note explaining why) - [ ] If a class defines `def list(...)`, later annotations in that class avoid bare `list[...]` so mypy does not resolve `list` to the method - [ ] Examples that call the method use `list(client.foo.list_bars(...))` (or stream with a `for` loop) — never assume list semantics on the bare return diff --git a/docs/MODELS.md b/docs/MODELS.md index fae5fc4f..67ac645a 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -59,7 +59,7 @@ class Run(BaseModel): Rules: - **Every multi-word JSON:API attribute** gets an alias. Don't try to invent a snake_case-to-hyphen mapper — be explicit per field. -- **Page params** use the JSON:API square-bracket form: `Field(None, alias="page[number]")`, `Field(None, alias="page[size]")`. +- **Page params** use the JSON:API square-bracket form: `Field(None, alias="page[number]")`, `Field(None, alias="page[size]")`. Note that a few endpoints (workspace `/vars`, `/all-vars`) are not paginated and ignore these — their resource methods call `self._list(..., paginated=False)`, so a `page_size` field on those options models would be a no-op. See [ITERATORS.md](ITERATORS.md). - **Filter params** use the same convention: `Field(None, alias="filter[workspace][name]")`. - **`include`** is a comma-separated string on the wire but exposed as `list[SomeEnum] | None` in Python; the resource layer dumps options with `mode="json"` and joins the resulting values (`",".join(params["include"])`). See the `policy_set.read_with_options` pattern. diff --git a/docs/pagination.md b/docs/pagination.md index 370d1c9b..ae802ea8 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -82,8 +82,12 @@ for run in client.runs.list("ws-abc123", options): - `list` / `list_*` methods are lazy. If an invalid-id check is inside a generator method, the exception is raised when you iterate, not when you create the iterator. -- Some relationship endpoints are not paginated by the server, but pyTFE still - exposes them as iterators for a consistent public API. +- Some endpoints are not paginated by the server, but pyTFE still exposes them + as iterators for a consistent public API. A few — notably + `variables.list()` / `variables.list_all()` (the workspace `/vars` and + `/all-vars` endpoints) — return the entire collection in a single request and + ignore `page_size`. You still iterate them the same way; the SDK just fetches + everything in one round-trip. - A small number of older methods intentionally return concrete lists for backward compatibility. Prefer the iterator rule for new code, and check the method's return type if you are unsure. diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index 526c035e..541df004 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -76,9 +76,9 @@ def list( raise InvalidRunIDError() path = f"/api/v2/runs/{run_id}/task-stages" - kwargs = {"params": options.model_dump(by_alias=True)} if options else {} + params = options.model_dump(by_alias=True) if options else None - for item in self._list(path, **kwargs): + for item in self._list(path, params=params): yield self._parse_task_stage(item) # Override diff --git a/tests/units/test_task_stage.py b/tests/units/test_task_stage.py index 135af35d..a0934d77 100644 --- a/tests/units/test_task_stage.py +++ b/tests/units/test_task_stage.py @@ -201,7 +201,9 @@ def test_list_calls_internal_list(mocker): assert len(result) == 1 assert isinstance(result[0], TaskStage) - service._list.assert_called_once_with("/api/v2/runs/run-123/task-stages") + service._list.assert_called_once_with( + "/api/v2/runs/run-123/task-stages", params=None + ) # Override method tests From c65528cb78b93be375024177600a4a3f02e11847 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Thu, 4 Jun 2026 18:57:24 +0530 Subject: [PATCH 3/5] fix for other resources --- CHANGELOG.md | 4 ++-- src/pytfe/resources/no_code_module.py | 3 ++- src/pytfe/resources/projects.py | 3 ++- src/pytfe/resources/run_event.py | 3 ++- src/pytfe/resources/workspaces.py | 6 ++++-- tests/units/test_run_events.py | 3 +++ tests/units/test_workspaces.py | 6 ++++-- 7 files changed, 19 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50265ba1..cbc0a1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## Bug Fixes -### Variables -* Fixed `variables.list()` / `variables.list_all()` infinite-looping on workspaces with 100 or more variables, so they are now fetched with a single request. The generic list helper also treats any response without `meta.pagination` as a single complete page, preventing the same loop on other non-paginated endpoints. [#181](https://github.com/hashicorp/python-tfe/issues/181) +### Pagination +* Fixed `list_*` infinite-looping for API call which are non paginated, so they are now fetched with a single request. The generic list helper also treats any response without `meta.pagination` as a single complete page, preventing the same loop on other non-paginated endpoints. [#181](https://github.com/hashicorp/python-tfe/issues/181) # Released diff --git a/src/pytfe/resources/no_code_module.py b/src/pytfe/resources/no_code_module.py index e285b9e2..59f1bafb 100644 --- a/src/pytfe/resources/no_code_module.py +++ b/src/pytfe/resources/no_code_module.py @@ -255,11 +255,12 @@ def read_variables( if not valid_string(version): raise InvalidVersionError() + # module-variables is not paginated; fetch the full set in one request. path = ( f"/api/v2/no-code-modules/{no_code_module_id}" f"/versions/{version}/module-variables" ) - for item in self._list(path): + for item in self._list(path, paginated=False): attrs = item.get("attributes") or {} yield RegistryModuleVariable.model_validate( { diff --git a/src/pytfe/resources/projects.py b/src/pytfe/resources/projects.py index 44fb63a1..6a965899 100644 --- a/src/pytfe/resources/projects.py +++ b/src/pytfe/resources/projects.py @@ -320,8 +320,9 @@ def list_effective_tag_bindings( if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") + # effective-tag-bindings is not paginated. path = f"/api/v2/projects/{project_id}/effective-tag-bindings" - for item in self._list(path): + for item in self._list(path, paginated=False): attr = item.get("attributes", {}) or {} links = item.get("links", {}) or {} yield EffectiveTagBinding( diff --git a/src/pytfe/resources/run_event.py b/src/pytfe/resources/run_event.py index 5c85c874..4f1a842d 100644 --- a/src/pytfe/resources/run_event.py +++ b/src/pytfe/resources/run_event.py @@ -26,8 +26,9 @@ def list( params: dict[str, Any] = {} if options and options.include: params["include"] = ",".join(options.include) + # The run-events endpoint is not paginated; fetch the full set in one request. path = f"/api/v2/runs/{run_id}/run-events" - for item in self._list(path, params=params): + for item in self._list(path, params=params, paginated=False): attrs = item.get("attributes", {}) attrs["id"] = item.get("id") yield RunEvent.model_validate(attrs) diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index 3fe7caac..92c82cf5 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -744,8 +744,9 @@ def list_tag_bindings(self, workspace_id: str) -> Iterator[TagBinding]: if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() + # tag-bindings is not paginated. path = f"/api/v2/workspaces/{workspace_id}/tag-bindings" - for item in self._list(path): + for item in self._list(path, paginated=False): attr = item.get("attributes", {}) or {} yield TagBinding( id=item.get("id"), @@ -759,8 +760,9 @@ def list_effective_tag_bindings( if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() + # effective-tag-bindings is not paginated. path = f"/api/v2/workspaces/{workspace_id}/effective-tag-bindings" - for item in self._list(path): + for item in self._list(path, paginated=False): attr = item.get("attributes", {}) or {} yield EffectiveTagBinding( id=item.get("id", ""), diff --git a/tests/units/test_run_events.py b/tests/units/test_run_events.py index be994528..704076c5 100644 --- a/tests/units/test_run_events.py +++ b/tests/units/test_run_events.py @@ -72,6 +72,7 @@ def test_list_run_events_success(self, run_events_service): mock_list.assert_called_once_with( "/api/v2/runs/run-123/run-events", params={"include": "actor"}, + paginated=False, ) # Verify results @@ -113,6 +114,7 @@ def test_list_run_events_with_multiple_includes(self, run_events_service): mock_list.assert_called_once_with( "/api/v2/runs/run-456/run-events", params={"include": "actor,comment"}, + paginated=False, ) assert len(results) == 1 @@ -140,6 +142,7 @@ def test_list_run_events_no_options(self, run_events_service): mock_list.assert_called_once_with( "/api/v2/runs/run-789/run-events", params={}, + paginated=False, ) assert len(results) == 1 diff --git a/tests/units/test_workspaces.py b/tests/units/test_workspaces.py index 5c93f034..6c27d3fd 100644 --- a/tests/units/test_workspaces.py +++ b/tests/units/test_workspaces.py @@ -1040,10 +1040,11 @@ def test_list_tag_bindings_basic(self, workspaces_service, mock_transport): tag_bindings = list(workspaces_service.list_tag_bindings("ws-123")) # Verify API call + # tag-bindings is non-paginated: a single request with no page params. mock_transport.request.assert_called_once_with( "GET", "/api/v2/workspaces/ws-123/tag-bindings", - params={"page[number]": 1, "page[size]": 100}, + params={}, ) # Verify returned data @@ -1096,10 +1097,11 @@ def test_list_effective_tag_bindings_basic( ) # Verify API call + # effective-tag-bindings is non-paginated: one request, no page params. mock_transport.request.assert_called_once_with( "GET", "/api/v2/workspaces/ws-123/effective-tag-bindings", - params={"page[number]": 1, "page[size]": 100}, + params={}, ) # Verify returned data From e2817596f6d87fc74a271a1f75eb74a067bc6415 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Thu, 4 Jun 2026 19:01:59 +0530 Subject: [PATCH 4/5] upd comment --- src/pytfe/resources/_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pytfe/resources/_base.py b/src/pytfe/resources/_base.py index 6491eefd..35d9f6ff 100644 --- a/src/pytfe/resources/_base.py +++ b/src/pytfe/resources/_base.py @@ -36,7 +36,6 @@ def _list( # meta.pagination block (e.g. workspace /vars and /all-vars). Callers # opt those out so we issue exactly one request and never loop trying to # fetch a "next" page that re-returns the same set - # (see hashicorp/python-tfe#181). if not paginated: r = self.t.request("GET", path, params=base_params) json_response = r.json() From 3e823e37f21fd2e0c5b90204365fd52533cb72e8 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Thu, 4 Jun 2026 19:02:19 +0530 Subject: [PATCH 5/5] upd comment --- src/pytfe/resources/_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pytfe/resources/_base.py b/src/pytfe/resources/_base.py index 35d9f6ff..4c584334 100644 --- a/src/pytfe/resources/_base.py +++ b/src/pytfe/resources/_base.py @@ -103,7 +103,6 @@ def _list( # collection returned in full. Treat it as a single complete page # and stop. Re-requesting would loop forever when the endpoint # ignores page[number]/page[size] and re-returns the same full set - # (the root cause of hashicorp/python-tfe#181). if len(data) >= int(p["page[size]"]): logger.debug( "List endpoint %s returned a full page (%d rows) without "