Skip to content

Commit 32b4343

Browse files
committed
♻️ Refactor Explorer codebase for improved quality and maintainability
1 parent 827a738 commit 32b4343

4 files changed

Lines changed: 206 additions & 88 deletions

File tree

examples/explorer.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -428,11 +428,9 @@ def main() -> None:
428428
updated = client.explorer.update_saved_view(org, created.id, update_opts)
429429
print(f" update_saved_view: name is now {updated.name!r}")
430430

431-
# client.explorer.delete_saved_view removes the saved view; some API
432-
# responses omit JSON, in which case the client still returns a minimal
433-
# ExplorerSavedView carrying the deleted id.
434-
deleted = client.explorer.delete_saved_view(org, created.id)
435-
print(f" delete_saved_view: completed for id {deleted.id}")
431+
# client.explorer.delete_saved_view removes the saved view and returns None.
432+
client.explorer.delete_saved_view(org, created.id)
433+
print(f" delete_saved_view: completed for id {created.id}")
436434
print("Summary: mutation sequence finished.")
437435
except TFEError as e:
438436
print(f" API error: {e}")

src/pytfe/resources/_base.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,79 @@
99
from .._http import HTTPTransport
1010

1111

12+
def _to_int(value: Any) -> int | None:
13+
"""Best-effort integer coercion for pagination metadata values."""
14+
if isinstance(value, int):
15+
return value
16+
if isinstance(value, str):
17+
try:
18+
return int(value)
19+
except ValueError:
20+
return None
21+
return None
22+
23+
1224
class _Service:
1325
def __init__(self, t: HTTPTransport) -> None:
1426
self.t = t
1527

1628
def _list(
1729
self, path: str, *, params: dict | None = None
1830
) -> Iterator[dict[str, Any]]:
19-
page = 1
31+
base_params = dict(params or {})
32+
page = int(base_params.get("page[number]", 1))
2033
while True:
21-
p = dict(params or {})
34+
p = dict(base_params)
2235
p["page[number]"] = page
2336
p.setdefault("page[size]", 100)
2437
r = self.t.request("GET", path, params=p)
2538

2639
# Handle cases where r.json() returns None or is not a dict
2740
json_response = r.json()
28-
if json_response is None:
41+
if json_response is None or not isinstance(json_response, dict):
2942
json_response = {}
3043

3144
data = json_response.get("data", [])
45+
if not isinstance(data, list):
46+
data = []
3247
yield from data
48+
if not data:
49+
# Defensive stop: some endpoints can return inconsistent pagination
50+
# metadata while yielding no rows; avoid unbounded follow-up requests.
51+
break
52+
53+
# Prefer server pagination metadata when available. This avoids
54+
# prematurely terminating when servers clamp requested page sizes.
55+
meta = json_response.get("meta")
56+
pagination = meta.get("pagination", {}) if isinstance(meta, dict) else {}
57+
if isinstance(pagination, dict) and pagination:
58+
next_page = _to_int(
59+
pagination.get("next-page", pagination.get("next_page"))
60+
)
61+
if next_page is not None and next_page > page:
62+
page = next_page
63+
continue
64+
65+
current_page = _to_int(
66+
pagination.get("current-page", pagination.get("current_page"))
67+
)
68+
total_pages = _to_int(
69+
pagination.get("total-pages", pagination.get("total_pages"))
70+
)
71+
if (
72+
current_page is not None
73+
and total_pages is not None
74+
and current_page < total_pages
75+
):
76+
candidate_page = current_page + 1
77+
if candidate_page > page:
78+
page = candidate_page
79+
continue
80+
81+
# Metadata present and indicates no next page.
82+
break
83+
84+
# Fallback for endpoints that do not return pagination metadata.
3385
page_size = int(p["page[size]"])
3486
if len(data) < page_size:
3587
break

src/pytfe/resources/explorer.py

Lines changed: 10 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ def _parse_row(item: dict[str, Any]) -> ExplorerRow:
121121
return ExplorerRow.model_validate(item)
122122

123123

124+
def _normalize_filter_field_name(raw_field: Any) -> str:
125+
"""Normalize filter field names to SDK model style."""
126+
return str(raw_field).replace("-", "_")
127+
128+
124129
def _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]:
125130
"""Map {field, operator, value} filter rows to nested {field: {operator: [...]}} JSON."""
126131
query = dict(raw_query)
@@ -134,7 +139,7 @@ def _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]:
134139
if "field" not in entry or "operator" not in entry:
135140
mapped_filters.append(entry)
136141
continue
137-
field = str(entry.get("field", "")).replace("-", "_")
142+
field = _normalize_filter_field_name(entry.get("field", ""))
138143
operator = str(entry.get("operator", ""))
139144
values = entry.get("value", [])
140145
if not isinstance(values, list):
@@ -166,7 +171,7 @@ def _normalize_saved_query(
166171
value = [str(value)]
167172
normalized_filters.append(
168173
{
169-
"field": str(entry["field"]).replace("-", "_"),
174+
"field": _normalize_filter_field_name(entry["field"]),
170175
"operator": str(entry["operator"]),
171176
"value": [str(v) for v in value],
172177
}
@@ -182,7 +187,7 @@ def _normalize_saved_query(
182187
vals = values if isinstance(values, list) else [values]
183188
normalized_filters.append(
184189
{
185-
"field": str(field_name).replace("-", "_"),
190+
"field": _normalize_filter_field_name(field_name),
186191
"operator": str(operator),
187192
"value": [str(v) for v in vals],
188193
}
@@ -220,18 +225,6 @@ def _parse_saved_view(item: dict[str, Any]) -> ExplorerSavedView:
220225
)
221226

222227

223-
def _deleted_saved_view_fallback(view_id: str) -> ExplorerSavedView:
224-
"""Build a minimal saved view when delete responses have no body."""
225-
return ExplorerSavedView.model_validate(
226-
{
227-
"id": view_id,
228-
"name": "",
229-
"query-type": "workspaces",
230-
"query": {"type": "workspaces"},
231-
}
232-
)
233-
234-
235228
def _query_options_from_saved_view(
236229
saved_view: ExplorerSavedView,
237230
) -> ExplorerQueryOptions:
@@ -550,38 +543,10 @@ def update_saved_view(
550543
_log.info("explorer.update_saved_view org=%r id=%r", organization, view.id)
551544
return view
552545

553-
def delete_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView:
546+
def delete_saved_view(self, organization: str, view_id: str) -> None:
554547
_require_organization_and_view(organization, view_id)
555548
path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}"
556-
resp = self.t.request("DELETE", path)
557-
# DELETE often returns an empty body; callers still receive a minimal ExplorerSavedView.
558-
raw_text = (resp.text or "").strip()
559-
if not raw_text:
560-
_log.debug(
561-
"explorer.delete_saved_view: empty body, returning stub org=%r id=%r",
562-
organization,
563-
view_id,
564-
)
565-
return _deleted_saved_view_fallback(view_id)
566-
567-
try:
568-
payload = resp.json()
569-
except ValueError:
570-
_log.debug(
571-
"explorer.delete_saved_view: non-JSON body, returning stub org=%r id=%r",
572-
organization,
573-
view_id,
574-
)
575-
return _deleted_saved_view_fallback(view_id)
576-
577-
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
578-
return _parse_saved_view(payload["data"])
579-
_log.debug(
580-
"explorer.delete_saved_view: no data object, returning stub org=%r id=%r",
581-
organization,
582-
view_id,
583-
)
584-
return _deleted_saved_view_fallback(view_id)
549+
self.t.request("DELETE", path)
585550

586551
def saved_view_results(
587552
self, organization: str, view_id: str

tests/units/test_explorer.py

Lines changed: 138 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,138 @@ def test_query_with_filter_and_pagination(self, explorer_service, mock_transport
181181
mock_transport.request.assert_has_calls(expected_calls)
182182
assert mock_transport.request.call_count == 4
183183

184+
def test_query_uses_pagination_meta_when_server_caps_page_size(
185+
self, explorer_service, mock_transport
186+
):
187+
first = Mock()
188+
first.json.return_value = {
189+
"data": [_row_payload("ws-1"), _row_payload("ws-2")],
190+
"meta": {
191+
"pagination": {
192+
"current-page": 1,
193+
"page-size": 2,
194+
"next-page": 2,
195+
"total-pages": 2,
196+
}
197+
},
198+
}
199+
second = Mock()
200+
second.json.return_value = {
201+
"data": [_row_payload("ws-3")],
202+
"meta": {
203+
"pagination": {
204+
"current-page": 2,
205+
"page-size": 2,
206+
"next-page": None,
207+
"total-pages": 2,
208+
}
209+
},
210+
}
211+
mock_transport.request.side_effect = [first, second]
212+
213+
options = ExplorerQueryOptions(
214+
view_type=ExplorerViewType.WORKSPACES,
215+
page_size=50,
216+
)
217+
218+
rows = list(explorer_service.query(ORG, options))
219+
assert [row.id for row in rows] == ["ws-1", "ws-2", "ws-3"]
220+
221+
expected_calls = [
222+
call(
223+
"GET",
224+
EXPLORER_PATH,
225+
params={"type": "workspaces", "page[size]": 50, "page[number]": 1},
226+
),
227+
call(
228+
"GET",
229+
EXPLORER_PATH,
230+
params={"type": "workspaces", "page[size]": 50, "page[number]": 2},
231+
),
232+
]
233+
mock_transport.request.assert_has_calls(expected_calls)
234+
assert mock_transport.request.call_count == 2
235+
236+
def test_query_uses_current_and_total_pages_when_next_page_missing(
237+
self, explorer_service, mock_transport
238+
):
239+
first = Mock()
240+
first.json.return_value = {
241+
"data": [_row_payload("ws-1")],
242+
"meta": {"pagination": {"current-page": 1, "total-pages": 2}},
243+
}
244+
second = Mock()
245+
second.json.return_value = {
246+
"data": [_row_payload("ws-2")],
247+
"meta": {"pagination": {"current-page": 2, "total-pages": 2}},
248+
}
249+
mock_transport.request.side_effect = [first, second]
250+
251+
rows = list(
252+
explorer_service.query(
253+
ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES)
254+
)
255+
)
256+
assert [row.id for row in rows] == ["ws-1", "ws-2"]
257+
258+
expected_calls = [
259+
call(
260+
"GET",
261+
EXPLORER_PATH,
262+
params={"type": "workspaces", "page[number]": 1, "page[size]": 100},
263+
),
264+
call(
265+
"GET",
266+
EXPLORER_PATH,
267+
params={"type": "workspaces", "page[number]": 2, "page[size]": 100},
268+
),
269+
]
270+
mock_transport.request.assert_has_calls(expected_calls)
271+
assert mock_transport.request.call_count == 2
272+
273+
def test_query_stops_when_pagination_meta_does_not_advance(
274+
self, explorer_service, mock_transport
275+
):
276+
first = Mock()
277+
first.json.return_value = {
278+
"data": [_row_payload("ws-1")],
279+
"meta": {"pagination": {"current-page": 1, "total-pages": 2}},
280+
}
281+
second = Mock()
282+
second.json.return_value = {
283+
"data": [_row_payload("ws-1")],
284+
"meta": {"pagination": {"current-page": 1, "total-pages": 2}},
285+
}
286+
mock_transport.request.side_effect = [first, second]
287+
288+
rows = list(
289+
explorer_service.query(
290+
ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES)
291+
)
292+
)
293+
assert [row.id for row in rows] == ["ws-1", "ws-1"]
294+
assert mock_transport.request.call_count == 2
295+
296+
def test_query_stops_on_empty_page_even_if_next_page_present(
297+
self, explorer_service, mock_transport
298+
):
299+
first = Mock()
300+
first.json.return_value = {
301+
"data": [],
302+
"meta": {
303+
"pagination": {"current-page": 1, "next-page": 2, "total-pages": 5}
304+
},
305+
}
306+
mock_transport.request.return_value = first
307+
308+
rows = list(
309+
explorer_service.query(
310+
ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES)
311+
)
312+
)
313+
assert rows == []
314+
assert mock_transport.request.call_count == 1
315+
184316
def test_query_invalid_org(self, explorer_service):
185317
with pytest.raises(InvalidOrgError):
186318
list(
@@ -379,50 +511,21 @@ def test_update_saved_view_invalid_data_shape_raises(
379511
explorer_service.update_saved_view(ORG, VIEW_ID, options)
380512

381513
def test_delete_saved_view(self, explorer_service, mock_transport):
382-
response = Mock()
383-
response.json.return_value = {"data": _saved_view_payload("sq-1")}
384-
response.text = '{"data":{"id":"sq-1"}}'
385-
mock_transport.request.return_value = response
386-
387-
view = explorer_service.delete_saved_view(ORG, VIEW_ID)
388-
assert view.id == "sq-1"
514+
result = explorer_service.delete_saved_view(ORG, VIEW_ID)
515+
assert result is None
389516

390517
_assert_single_request_call(mock_transport, "DELETE", f"{VIEWS_PATH}/{VIEW_ID}")
391518

392-
def test_delete_saved_view_empty_response(self, explorer_service, mock_transport):
393-
response = Mock()
394-
response.text = ""
395-
response.json.side_effect = ValueError("No JSON body")
396-
mock_transport.request.return_value = response
397-
398-
view = explorer_service.delete_saved_view(ORG, VIEW_ID)
399-
assert view.id == "sq-1"
400-
401-
def test_delete_saved_view_non_json_body_returns_stub(
519+
def test_delete_saved_view_ignores_response_body(
402520
self, explorer_service, mock_transport
403521
):
404522
response = Mock()
405-
response.text = "deleted"
523+
response.text = '{"data":{"id":"unexpected"}}'
406524
response.json.side_effect = ValueError("No JSON body")
407525
mock_transport.request.return_value = response
408526

409-
view = explorer_service.delete_saved_view(ORG, VIEW_ID)
410-
assert view.id == "sq-1"
411-
assert view.name == ""
412-
assert view.query_type == ExplorerViewType.WORKSPACES
413-
414-
def test_delete_saved_view_invalid_data_shape_returns_stub(
415-
self, explorer_service, mock_transport
416-
):
417-
response = Mock()
418-
response.text = '{"data":[]}'
419-
response.json.return_value = {"data": []}
420-
mock_transport.request.return_value = response
421-
422-
view = explorer_service.delete_saved_view(ORG, VIEW_ID)
423-
assert view.id == "sq-1"
424-
assert view.name == ""
425-
assert view.query_type == ExplorerViewType.WORKSPACES
527+
result = explorer_service.delete_saved_view(ORG, VIEW_ID)
528+
assert result is None
426529

427530
def test_saved_view_results(self, explorer_service, mock_transport):
428531
first = Mock()

0 commit comments

Comments
 (0)