Skip to content

Commit 6863b2f

Browse files
authored
Merge branch 'develop' into bugfix/rest-retry-returns-previous-failure
2 parents c6b2021 + 0262af1 commit 6863b2f

5 files changed

Lines changed: 310 additions & 8 deletions

File tree

‎ayon_api/graphql.py‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -903,11 +903,13 @@ def parse_result(
903903
progress_data[cursor_key] = nodes_by_cursor
904904

905905
page_info = value["pageInfo"]
906+
# Continue from the last received edge for both orders. Server
907+
# returns edges of 'last' page from the newest item, so
908+
# 'startCursor' would move the cursor back only by one item.
909+
new_cursor = page_info["endCursor"]
906910
if self._order == SortOrder.ascending:
907-
new_cursor = page_info["endCursor"]
908911
self._need_query = page_info["hasNextPage"]
909912
else:
910-
new_cursor = page_info["startCursor"]
911913
self._need_query = page_info["hasPreviousPage"]
912914

913915
edges = value["edges"]
@@ -1016,11 +1018,7 @@ def calculate_query(self) -> str:
10161018
# Add page information
10171019
output.append(edges_offset + "pageInfo {")
10181020
for page_key in (
1019-
(
1020-
"endCursor"
1021-
if self._order == SortOrder.ascending
1022-
else "startCursor"
1023-
),
1021+
"endCursor",
10241022
(
10251023
"hasNextPage"
10261024
if self._order == SortOrder.ascending

‎ayon_api/server_api.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1521,7 +1521,7 @@ def _do_rest_request(
15211521
**kwargs
15221522
) -> RestApiResponse:
15231523
kwargs.setdefault("timeout", self.timeout)
1524-
max_retries = kwargs.get("max_retries", self.max_retries)
1524+
max_retries = kwargs.pop("max_retries", self.max_retries)
15251525
if max_retries < 1:
15261526
max_retries = 1
15271527

‎tests/graphql_fake_server.py‎

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"""Minimal in-memory GraphQL server that speaks the subset of the schema
2+
that 'ayon_api.graphql' generates. Used to exercise the pagination engine.
3+
"""
4+
import re
5+
import json
6+
7+
8+
class Node:
9+
def __init__(self, name, args, children):
10+
self.name = name
11+
self.args = args
12+
self.children = children
13+
14+
def child(self, name):
15+
for child in self.children:
16+
if child.name == name:
17+
return child
18+
return None
19+
20+
def __repr__(self):
21+
return f"<Node {self.name} args={self.args}>"
22+
23+
24+
def _parse_args(args_str, variables):
25+
"""Parse 'first: 300, after: "x", ids: $ids' into a dict."""
26+
if not args_str:
27+
return {}
28+
out = {}
29+
# split on top level commas
30+
parts = []
31+
depth = 0
32+
in_str = False
33+
current = ""
34+
for char in args_str:
35+
if in_str:
36+
current += char
37+
if char == '"':
38+
in_str = False
39+
continue
40+
if char == '"':
41+
in_str = True
42+
current += char
43+
continue
44+
if char in "[{":
45+
depth += 1
46+
elif char in "]}":
47+
depth -= 1
48+
if char == "," and depth == 0:
49+
parts.append(current)
50+
current = ""
51+
continue
52+
current += char
53+
if current.strip():
54+
parts.append(current)
55+
56+
for part in parts:
57+
key, _, value = part.partition(":")
58+
key = key.strip()
59+
value = value.strip()
60+
if value.startswith("$"):
61+
value = variables.get(value[1:])
62+
elif value.startswith('"'):
63+
value = json.loads(value)
64+
elif value.startswith("["):
65+
value = json.loads(value)
66+
elif value in ("true", "false"):
67+
value = value == "true"
68+
else:
69+
value = int(value)
70+
out[key] = value
71+
return out
72+
73+
74+
LINE_RE = re.compile(
75+
r"^(?P<name>\w+)(?:\((?P<args>.*)\))?(?P<open>\s*\{)?$"
76+
)
77+
78+
79+
def parse_query(query_str, variables):
80+
lines = [
81+
line.strip()
82+
for line in query_str.splitlines()
83+
if line.strip()
84+
]
85+
# Drop query header
86+
assert lines[0].startswith("query"), lines[0]
87+
root = Node("__root__", {}, [])
88+
stack = [root]
89+
for line in lines[1:]:
90+
if line == "}":
91+
stack.pop()
92+
continue
93+
match = LINE_RE.match(line)
94+
if match is None:
95+
raise ValueError(f"Unparsable line: {line!r}")
96+
args = _parse_args(match.group("args"), variables)
97+
node = Node(match.group("name"), args, [])
98+
stack[-1].children.append(node)
99+
if match.group("open"):
100+
stack.append(node)
101+
if stack:
102+
raise ValueError("Unbalanced query")
103+
return root
104+
105+
106+
class FakeServer:
107+
"""Resolve a parsed query against plain python data.
108+
109+
Data is a dict of entity collections, e.g.::
110+
111+
{
112+
"project": {
113+
"name": "proj",
114+
"folders": [
115+
{"id": "f1", "name": "a", "links": [{"id": "l1"}]},
116+
],
117+
}
118+
}
119+
120+
Any list value is served as a connection (edges/pageInfo), any dict
121+
value as a plain object.
122+
123+
"""
124+
def __init__(
125+
self,
126+
data,
127+
cursor_func=None,
128+
max_page_size=None,
129+
reverse_last_pages=False,
130+
):
131+
# AYON server returns edges of page queried with 'last' from
132+
# the newest item
133+
self._reverse_last_pages = reverse_last_pages
134+
self._data = data
135+
self._cursor_func = cursor_func or self._default_cursor
136+
self._max_page_size = max_page_size
137+
self.calls = []
138+
self.queries = []
139+
140+
@staticmethod
141+
def _default_cursor(path, index, entity):
142+
return f"{path}:{index}"
143+
144+
def query_graphql(self, query_str, variables):
145+
self.queries.append(query_str)
146+
self.calls.append((query_str, dict(variables)))
147+
root = parse_query(query_str, variables)
148+
data = {}
149+
for child in root.children:
150+
data[child.name] = self._resolve(child, self._data, child.name)
151+
return FakeResponse({"data": data})
152+
153+
def _resolve(self, node, parent_value, path):
154+
value = parent_value.get(node.name) if parent_value else None
155+
if isinstance(value, list):
156+
return self._resolve_connection(node, value, path)
157+
if isinstance(value, dict):
158+
return self._resolve_object(node, value, path)
159+
# leaf
160+
if node.children:
161+
raise ValueError(
162+
f"Requested sub fields of leaf {path}"
163+
)
164+
return value
165+
166+
def _resolve_object(self, node, value, path):
167+
out = {}
168+
for child in node.children:
169+
out[child.name] = self._resolve(
170+
child, value, f"{path}/{child.name}"
171+
)
172+
return out
173+
174+
def _resolve_connection(self, node, items, path):
175+
edges_field = node.child("edges")
176+
if edges_field is None:
177+
raise ValueError(f"Connection {path} misses 'edges'")
178+
cursors = [
179+
self._cursor_func(path, idx, item)
180+
for idx, item in enumerate(items)
181+
]
182+
args = node.args
183+
start = 0
184+
end = len(items)
185+
reverse_paging = "last" in args
186+
if "after" in args:
187+
cursor = args["after"]
188+
if cursor not in cursors:
189+
raise ValueError(
190+
f"Unknown 'after' cursor {cursor!r} for {path}"
191+
)
192+
start = cursors.index(cursor) + 1
193+
if "before" in args:
194+
cursor = args["before"]
195+
if cursor not in cursors:
196+
raise ValueError(
197+
f"Unknown 'before' cursor {cursor!r} for {path}"
198+
)
199+
end = cursors.index(cursor)
200+
201+
limit = args.get("first", args.get("last"))
202+
if limit is None:
203+
raise ValueError(f"Missing 'first'/'last' for {path}")
204+
if limit < 0:
205+
raise ValueError(f"Negative page size {limit} for {path}")
206+
if self._max_page_size is not None:
207+
limit = min(limit, self._max_page_size)
208+
209+
window = list(range(start, end))
210+
if reverse_paging:
211+
page_idxs = window[-limit:] if limit else []
212+
else:
213+
page_idxs = window[:limit]
214+
215+
if reverse_paging and self._reverse_last_pages:
216+
page_idxs.reverse()
217+
218+
node_field = edges_field.child("node")
219+
edges = []
220+
for idx in page_idxs:
221+
item = items[idx]
222+
edge = {}
223+
edges.append(edge)
224+
for child in edges_field.children:
225+
if child.name == "node":
226+
continue
227+
if child.name == "cursor":
228+
edge["cursor"] = cursors[idx]
229+
continue
230+
edge[child.name] = self._resolve(
231+
child, item, f"{path}[{idx}]/{child.name}"
232+
)
233+
if node_field is not None:
234+
edge["node"] = self._resolve_object(
235+
node_field, item, f"{path}[{idx}]"
236+
)
237+
238+
has_next = bool(page_idxs) and max(page_idxs) < end - 1
239+
has_prev = bool(page_idxs) and min(page_idxs) > start
240+
page_info = {
241+
"endCursor": cursors[page_idxs[-1]] if page_idxs else None,
242+
"startCursor": cursors[page_idxs[0]] if page_idxs else None,
243+
"hasNextPage": has_next,
244+
"hasPreviousPage": has_prev,
245+
}
246+
out = {"edges": edges, "pageInfo": {}}
247+
requested_page_info = node.child("pageInfo")
248+
if requested_page_info is not None:
249+
for child in requested_page_info.children:
250+
out["pageInfo"][child.name] = page_info[child.name]
251+
return out
252+
253+
254+
class FakeResponse:
255+
def __init__(self, data):
256+
self.data = data
257+
self.errors = data.get("errors")
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Descending GraphQl pagination must not return duplicates.
2+
3+
Does not require running AYON server.
4+
"""
5+
from ayon_api.graphql_queries import events_graphql_query
6+
from ayon_api.utils import SortOrder
7+
8+
from .graphql_fake_server import FakeServer
9+
10+
11+
def test_descending_pagination_has_no_duplicates():
12+
data = {"events": [{"id": f"e{idx}"} for idx in range(700)]}
13+
query = events_graphql_query({"id"}, SortOrder.descending)
14+
# AYON server returns edges of a page queried with 'last' from newest
15+
server = FakeServer(data, max_page_size=300, reverse_last_pages=True)
16+
17+
output = query.query(server)
18+
19+
ids = [event["id"] for event in output["events"]]
20+
assert ids == [f"e{idx}" for idx in reversed(range(700))]
21+
assert len(server.queries) == 3
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""'max_retries' request argument. Does not require running AYON server."""
2+
import requests
3+
4+
from ayon_api.server_api import ServerAPI
5+
6+
7+
def test_max_retries_kwarg_is_not_passed_to_request(monkeypatch):
8+
monkeypatch.setattr("time.sleep", lambda *args, **kwargs: None)
9+
con = ServerAPI("http://localhost:0", create_session=False, max_retries=5)
10+
calls = []
11+
12+
def request_func(url, **kwargs):
13+
# Real request functions raise TypeError on unknown arguments
14+
if "max_retries" in kwargs:
15+
raise TypeError("unexpected keyword argument 'max_retries'")
16+
calls.append(kwargs)
17+
raise requests.exceptions.ConnectionError("down")
18+
19+
response = con._do_rest_request(
20+
request_func,
21+
"http://localhost:0/api/x",
22+
handle_invalid_token=False,
23+
max_retries=2,
24+
)
25+
assert len(calls) == 2
26+
assert response.status_code == 500

0 commit comments

Comments
 (0)