ayon-python-api 1.2.23__tar.gz → 1.2.24__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/PKG-INFO +1 -1
  2. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api.py +4 -0
  3. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/dependency_packages.py +6 -0
  4. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/graphql.py +38 -34
  5. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/server_api.py +38 -9
  6. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/utils.py +2 -2
  7. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/version.py +1 -1
  8. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/PKG-INFO +1 -1
  9. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/SOURCES.txt +6 -0
  10. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/pyproject.toml +2 -2
  11. ayon_python_api-1.2.24/tests/test_download_resume.py +53 -0
  12. ayon_python_api-1.2.24/tests/test_graphql_descending_pagination.py +21 -0
  13. ayon_python_api-1.2.24/tests/test_graphql_infinite_loop.py +46 -0
  14. ayon_python_api-1.2.24/tests/test_operations_result.py +26 -0
  15. ayon_python_api-1.2.24/tests/test_request_max_retries_kwarg.py +26 -0
  16. ayon_python_api-1.2.24/tests/test_request_retries.py +35 -0
  17. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/LICENSE +0 -0
  18. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/README.md +0 -0
  19. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/__init__.py +0 -0
  20. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/__init__.py +0 -0
  21. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/actions.py +0 -0
  22. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/activities.py +0 -0
  23. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/attributes.py +0 -0
  24. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/base.py +0 -0
  25. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/bundles_addons.py +0 -0
  26. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/events.py +0 -0
  27. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/folders.py +0 -0
  28. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/installers.py +0 -0
  29. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/links.py +0 -0
  30. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/lists.py +0 -0
  31. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/products.py +0 -0
  32. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/projects.py +0 -0
  33. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/representations.py +0 -0
  34. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/secrets.py +0 -0
  35. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/tasks.py +0 -0
  36. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/thumbnails.py +0 -0
  37. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/versions.py +0 -0
  38. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/workfiles.py +0 -0
  39. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/constants.py +0 -0
  40. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/entity_hub.py +0 -0
  41. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/events.py +0 -0
  42. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/exceptions.py +0 -0
  43. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/graphql_queries.py +0 -0
  44. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/operations.py +0 -0
  45. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_api/typing.py +0 -0
  46. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/dependency_links.txt +0 -0
  47. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/requires.txt +0 -0
  48. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/top_level.txt +0 -0
  49. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/setup.cfg +0 -0
  50. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/setup.py +0 -0
  51. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/tests/test_entity_hub.py +0 -0
  52. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/tests/test_folder_hierarchy.py +0 -0
  53. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/tests/test_get_events.py +0 -0
  54. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/tests/test_graphql_queries.py +0 -0
  55. {ayon_python_api-1.2.23 → ayon_python_api-1.2.24}/tests/test_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ayon_python_api
3
- Version: 1.2.23
3
+ Version: 1.2.24
4
4
  Summary: AYON Python API
5
5
  Home-page: https://github.com/ynput/ayon-python-api
6
6
  Author: ynput.io
@@ -1943,6 +1943,7 @@ def create_dependency_package(
1943
1943
  checksum: str,
1944
1944
  checksum_algorithm: str,
1945
1945
  file_size: int,
1946
+ python_version: str | None = None,
1946
1947
  sources: Optional[list[dict[str, Any]]] = None,
1947
1948
  platform_name: Optional[str] = None,
1948
1949
  ) -> None:
@@ -1968,6 +1969,8 @@ def create_dependency_package(
1968
1969
  checksum (str): Checksum of archive file where dependencies are.
1969
1970
  checksum_algorithm (str): Algorithm used to calculate checksum.
1970
1971
  file_size (Optional[int]): Size of file.
1972
+ python_version (str | None): Python version for which
1973
+ is dependency package created.
1971
1974
  sources (Optional[list[dict[str, Any]]]): Information about
1972
1975
  sources from where it is possible to get file.
1973
1976
  platform_name (Optional[str]): Name of platform for which is
@@ -1984,6 +1987,7 @@ def create_dependency_package(
1984
1987
  checksum=checksum,
1985
1988
  checksum_algorithm=checksum_algorithm,
1986
1989
  file_size=file_size,
1990
+ python_version=python_version,
1987
1991
  sources=sources,
1988
1992
  platform_name=platform_name,
1989
1993
  )
@@ -57,6 +57,7 @@ class DependencyPackagesAPI(BaseServerAPI):
57
57
  checksum: str,
58
58
  checksum_algorithm: str,
59
59
  file_size: int,
60
+ python_version: str | None = None,
60
61
  sources: Optional[list[dict[str, Any]]] = None,
61
62
  platform_name: Optional[str] = None,
62
63
  ) -> None:
@@ -82,6 +83,8 @@ class DependencyPackagesAPI(BaseServerAPI):
82
83
  checksum (str): Checksum of archive file where dependencies are.
83
84
  checksum_algorithm (str): Algorithm used to calculate checksum.
84
85
  file_size (Optional[int]): Size of file.
86
+ python_version (str | None): Python version for which
87
+ is dependency package created.
85
88
  sources (Optional[list[dict[str, Any]]]): Information about
86
89
  sources from where it is possible to get file.
87
90
  platform_name (Optional[str]): Name of platform for which is
@@ -99,6 +102,9 @@ class DependencyPackagesAPI(BaseServerAPI):
99
102
  "size": file_size,
100
103
  "platform": platform_name or platform.system().lower(),
101
104
  }
105
+ if python_version:
106
+ post_body["pythonVersion"] = python_version
107
+
102
108
  if sources:
103
109
  post_body["sources"] = sources
104
110
 
@@ -353,6 +353,25 @@ class GraphQlQuery:
353
353
  for child in self._children:
354
354
  child.parse_result(data, output, progress_data)
355
355
 
356
+ def _query_data(self, con: ServerAPI) -> dict[str, Any]:
357
+ """Send single query to server and return 'data' of the response."""
358
+ query_str = self.calculate_query()
359
+ variables = self.get_variables_values()
360
+ response = con.query_graphql(query_str, variables)
361
+ if response.errors:
362
+ raise GraphQlQueryFailed(response.errors, query_str, variables)
363
+
364
+ data = response.data.get("data")
365
+ if data is None:
366
+ # Parsing 'None' would not change pagination state and the same
367
+ # query would be sent again in an infinite loop.
368
+ raise GraphQlQueryError(
369
+ f"GraphQl query '{self._name}' response does not contain"
370
+ f" 'data'. Response: {str(response.data)[:1000]}"
371
+ f"\nQuery:\n{query_str}\nVariables: {variables}"
372
+ )
373
+ return data
374
+
356
375
  def query(self, con: ServerAPI) -> dict[str, Any]:
357
376
  """Do a query from server.
358
377
 
@@ -366,15 +385,8 @@ class GraphQlQuery:
366
385
  progress_data = {}
367
386
  output = {}
368
387
  while self.need_query:
369
- query_str = self.calculate_query()
370
- variables = self.get_variables_values()
371
- response = con.query_graphql(
372
- query_str,
373
- variables
374
- )
375
- if response.errors:
376
- raise GraphQlQueryFailed(response.errors, query_str, variables)
377
- self.parse_result(response.data["data"], output, progress_data)
388
+ data = self._query_data(con)
389
+ self.parse_result(data, output, progress_data)
378
390
 
379
391
  return output
380
392
 
@@ -394,30 +406,16 @@ class GraphQlQuery:
394
406
  if self.has_multiple_edge_fields:
395
407
  output = {}
396
408
  while self.need_query:
397
- query_str = self.calculate_query()
398
- variables = self.get_variables_values()
399
-
400
- response = con.query_graphql(query_str, variables)
401
- if response.errors:
402
- raise GraphQlQueryFailed(
403
- response.errors, query_str, variables
404
- )
405
- self.parse_result(response.data["data"], output, progress_data)
409
+ data = self._query_data(con)
410
+ self.parse_result(data, output, progress_data)
406
411
 
407
412
  yield output
408
413
 
409
414
  else:
410
415
  while self.need_query:
411
416
  output = {}
412
- query_str = self.calculate_query()
413
- variables = self.get_variables_values()
414
- response = con.query_graphql(query_str, variables)
415
- if response.errors:
416
- raise GraphQlQueryFailed(
417
- response.errors, query_str, variables
418
- )
419
-
420
- self.parse_result(response.data["data"], output, progress_data)
417
+ data = self._query_data(con)
418
+ self.parse_result(data, output, progress_data)
421
419
 
422
420
  yield output
423
421
 
@@ -903,11 +901,13 @@ class GraphQlQueryEdgeField(BaseGraphQlQueryField):
903
901
  progress_data[cursor_key] = nodes_by_cursor
904
902
 
905
903
  page_info = value["pageInfo"]
904
+ # Continue from the last received edge for both orders. Server
905
+ # returns edges of 'last' page from the newest item, so
906
+ # 'startCursor' would move the cursor back only by one item.
907
+ new_cursor = page_info["endCursor"]
906
908
  if self._order == SortOrder.ascending:
907
- new_cursor = page_info["endCursor"]
908
909
  self._need_query = page_info["hasNextPage"]
909
910
  else:
910
- new_cursor = page_info["startCursor"]
911
911
  self._need_query = page_info["hasPreviousPage"]
912
912
 
913
913
  edges = value["edges"]
@@ -943,6 +943,14 @@ class GraphQlQueryEdgeField(BaseGraphQlQueryField):
943
943
  change_cursor = False
944
944
 
945
945
  if change_cursor and self._need_query:
946
+ if new_cursor is None:
947
+ # Without cursor the pagination would start from beginning
948
+ raise GraphQlQueryError(
949
+ f"Field '{self.path}' reported another page without"
950
+ " a cursor. Stopped pagination after"
951
+ f" {self._fetched_counter} items."
952
+ )
953
+
946
954
  if new_cursor == self._cursor:
947
955
  raise GraphQlQueryError(
948
956
  "Cursor didn't change during pagination."
@@ -1016,11 +1024,7 @@ class GraphQlQueryEdgeField(BaseGraphQlQueryField):
1016
1024
  # Add page information
1017
1025
  output.append(edges_offset + "pageInfo {")
1018
1026
  for page_key in (
1019
- (
1020
- "endCursor"
1021
- if self._order == SortOrder.ascending
1022
- else "startCursor"
1023
- ),
1027
+ "endCursor",
1024
1028
  (
1025
1029
  "hasNextPage"
1026
1030
  if self._order == SortOrder.ascending
@@ -1489,6 +1489,8 @@ class ServerAPI(
1489
1489
  response = self.raw_get(
1490
1490
  "info",
1491
1491
  handle_invalid_token=False,
1492
+ verify=self.ssl_verify,
1493
+ cert=self.cert
1492
1494
  )
1493
1495
  response.raise_for_status()
1494
1496
  return response.data
@@ -1519,7 +1521,7 @@ class ServerAPI(
1519
1521
  **kwargs
1520
1522
  ) -> RestApiResponse:
1521
1523
  kwargs.setdefault("timeout", self.timeout)
1522
- max_retries = kwargs.get("max_retries", self.max_retries)
1524
+ max_retries = kwargs.pop("max_retries", self.max_retries)
1523
1525
  if max_retries < 1:
1524
1526
  max_retries = 1
1525
1527
 
@@ -1558,6 +1560,7 @@ class ServerAPI(
1558
1560
  response = None
1559
1561
  new_response = None
1560
1562
  for retry_idx in reversed(range(max_retries)):
1563
+ new_response = None
1561
1564
  try:
1562
1565
  response = function(url, **kwargs)
1563
1566
 
@@ -1658,7 +1661,7 @@ class ServerAPI(
1658
1661
  url = self._endpoint_to_url(endpoint, use_rest=False)
1659
1662
  progress.set_source_url(url)
1660
1663
 
1661
- retries = self.get_default_max_retries()
1664
+ retries = max(self.max_retries, 1)
1662
1665
  api_prepended = False
1663
1666
  for attempt in range(retries):
1664
1667
  # Continue in download
@@ -1684,6 +1687,14 @@ class ServerAPI(
1684
1687
  progress.set_destination_url(url)
1685
1688
  continue
1686
1689
  response.raise_for_status()
1690
+ if offset > 0 and response.status_code != 206:
1691
+ # Server ignored 'Range' and sends whole file again,
1692
+ # already downloaded content must be discarded
1693
+ stream.seek(0)
1694
+ stream.truncate()
1695
+ progress.reset_transferred()
1696
+ headers.pop("Range", None)
1697
+
1687
1698
  if progress.get_content_size() is None:
1688
1699
  progress.set_content_size(
1689
1700
  response.headers["Content-length"]
@@ -1692,11 +1703,21 @@ class ServerAPI(
1692
1703
  for chunk in response.iter_content(chunk_size=chunk_size):
1693
1704
  stream.write(chunk)
1694
1705
  progress.add_transferred_chunk(len(chunk))
1706
+
1707
+ content_size = progress.get_content_size()
1708
+ transferred = progress.get_transferred_size()
1709
+ if content_size is not None and transferred != content_size:
1710
+ # Connection was closed before all content was received
1711
+ raise requests.exceptions.ConnectionError(
1712
+ f"Downloaded {transferred} out of {content_size}"
1713
+ f" bytes from '{url}'."
1714
+ )
1695
1715
  break
1696
1716
 
1697
1717
  except (
1698
1718
  requests.exceptions.Timeout,
1699
1719
  requests.exceptions.ConnectionError,
1720
+ requests.exceptions.ChunkedEncodingError,
1700
1721
  ):
1701
1722
  if attempt == retries - 1:
1702
1723
  raise
@@ -2093,7 +2114,7 @@ class ServerAPI(
2093
2114
  headers.pop(orig_key)
2094
2115
  headers[key] = value
2095
2116
 
2096
- retries = self.get_default_max_retries()
2117
+ retries = max(self.max_retries, 1)
2097
2118
  response = None
2098
2119
 
2099
2120
  # Get size of file
@@ -2676,24 +2697,32 @@ class ServerAPI(
2676
2697
  if result.get("success"):
2677
2698
  return None
2678
2699
 
2679
- print(result)
2680
- for op_result in result["operations"]:
2700
+ self.log.warning(
2701
+ "Operations failed. Server response:\n%s",
2702
+ json.dumps(result, indent=4, default=str),
2703
+ )
2704
+ for op_result in result.get("operations") or []:
2681
2705
  if op_result["success"]:
2682
2706
  continue
2683
2707
 
2684
2708
  operation_id = op_result["id"]
2685
2709
  operation = next(
2686
- op
2687
- for op in operations_body
2688
- if op["id"] == operation_id
2710
+ (op for op in operations_body if op["id"] == operation_id),
2711
+ op_result,
2689
2712
  )
2690
2713
  detail = op_result["detail"]
2691
2714
  raise FailedOperations(
2692
2715
  f"Operation \"{operation_id}\" failed with data:"
2693
- f"\n{json.dumps(operation, indent=4)}"
2716
+ f"\n{json.dumps(operation, indent=4, default=str)}"
2694
2717
  f"\nDetail: {detail}."
2695
2718
  )
2696
2719
 
2720
+ # Server did not report which operation failed
2721
+ raise FailedOperations(
2722
+ "Operations failed. Server response:"
2723
+ f"\n{json.dumps(result, indent=4, default=str)}"
2724
+ )
2725
+
2697
2726
  def _prepare_fields(
2698
2727
  self,
2699
2728
  entity_type: str,
@@ -955,7 +955,7 @@ class TransferProgress:
955
955
  """
956
956
  return self._content_size
957
957
 
958
- def set_content_size(self, content_size: int) -> None:
958
+ def set_content_size(self, content_size: int | str) -> None:
959
959
  """Set content size in bytes.
960
960
 
961
961
  Args:
@@ -967,7 +967,7 @@ class TransferProgress:
967
967
  """
968
968
  if self._content_size is not None:
969
969
  raise ValueError("Content size was set more then once")
970
- self._content_size = content_size
970
+ self._content_size = int(content_size)
971
971
 
972
972
  def get_started(self) -> bool:
973
973
  """Transfer was started.
@@ -1,2 +1,2 @@
1
1
  """Package declaring Python API for AYON server."""
2
- __version__ = "1.2.23"
2
+ __version__ = "1.2.24"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ayon_python_api
3
- Version: 1.2.23
3
+ Version: 1.2.24
4
4
  Summary: AYON Python API
5
5
  Home-page: https://github.com/ynput/ayon-python-api
6
6
  Author: ynput.io
@@ -40,8 +40,14 @@ ayon_python_api.egg-info/SOURCES.txt
40
40
  ayon_python_api.egg-info/dependency_links.txt
41
41
  ayon_python_api.egg-info/requires.txt
42
42
  ayon_python_api.egg-info/top_level.txt
43
+ tests/test_download_resume.py
43
44
  tests/test_entity_hub.py
44
45
  tests/test_folder_hierarchy.py
45
46
  tests/test_get_events.py
47
+ tests/test_graphql_descending_pagination.py
48
+ tests/test_graphql_infinite_loop.py
46
49
  tests/test_graphql_queries.py
50
+ tests/test_operations_result.py
51
+ tests/test_request_max_retries_kwarg.py
52
+ tests/test_request_retries.py
47
53
  tests/test_server.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ayon_python_api"
3
- version = "1.2.23"
3
+ version = "1.2.24"
4
4
  description = "AYON Python API"
5
5
  license = {file = "LICENSE"}
6
6
  readme = {file = "README.md", content-type = "text/markdown"}
@@ -28,7 +28,7 @@ build-backend = "poetry.core.masonry.api"
28
28
 
29
29
  [tool.poetry]
30
30
  name = "ayon_python_api"
31
- version = "1.2.23"
31
+ version = "1.2.24"
32
32
  description = "AYON Python API"
33
33
  authors = [
34
34
  "ynput.io <info@ynput.io>"
@@ -0,0 +1,53 @@
1
+ """Interrupted downloads. Does not require running AYON server."""
2
+ import io
3
+
4
+ import pytest
5
+
6
+ from ayon_api.server_api import ServerAPI
7
+ from ayon_api.utils import RequestTypes
8
+
9
+ from .fake_transfer import FakeResponse
10
+
11
+ CONTENT = b"0123456789"
12
+ SIZE_HEADER = {"Content-Length": str(len(CONTENT))}
13
+
14
+
15
+ @pytest.fixture
16
+ def con(monkeypatch):
17
+ monkeypatch.setattr("time.sleep", lambda *args, **kwargs: None)
18
+ return ServerAPI("http://localhost:0", create_session=False, max_retries=3)
19
+
20
+
21
+ def _download(con, get_func):
22
+ con._base_functions_mapping[RequestTypes.get] = get_func
23
+ stream = io.BytesIO()
24
+ progress = con.download_file_to_stream("api/file", stream)
25
+ return stream.getvalue(), progress
26
+
27
+
28
+ def test_incomplete_download_is_continued(con):
29
+ def get_func(url, **kwargs):
30
+ if "Range" not in kwargs["headers"]:
31
+ # Connection closed after 4 bytes without an exception
32
+ return FakeResponse(200, CONTENT[:4], SIZE_HEADER)
33
+ assert kwargs["headers"]["Range"] == "bytes=4-"
34
+ return FakeResponse(206, CONTENT[4:], {"Content-Length": "6"})
35
+
36
+ content, progress = _download(con, get_func)
37
+ assert content == CONTENT
38
+ assert progress.transferred_size == len(CONTENT)
39
+
40
+
41
+ def test_resume_without_range_support_does_not_duplicate(con):
42
+ calls = []
43
+
44
+ def get_func(url, **kwargs):
45
+ calls.append(url)
46
+ if len(calls) == 1:
47
+ return FakeResponse(200, CONTENT[:4], SIZE_HEADER)
48
+ # Server ignores 'Range' header and sends whole file again
49
+ return FakeResponse(200, CONTENT, SIZE_HEADER)
50
+
51
+ content, progress = _download(con, get_func)
52
+ assert content == CONTENT
53
+ assert progress.transferred_size == len(CONTENT)
@@ -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
@@ -0,0 +1,46 @@
1
+ """Unexpected GraphQl responses must not cause infinite loop.
2
+
3
+ Does not require running AYON server.
4
+ """
5
+ import pytest
6
+
7
+ from ayon_api.exceptions import GraphQlQueryError
8
+ from ayon_api.graphql_queries import events_graphql_query
9
+ from ayon_api.utils import SortOrder
10
+
11
+ from .graphql_fake_server import FakeResponse
12
+
13
+
14
+ class _ScriptedServer:
15
+ """Return prepared responses, fail if queried too many times."""
16
+ def __init__(self, responses):
17
+ self._responses = responses
18
+ self.queries = []
19
+
20
+ def query_graphql(self, query_str, variables):
21
+ self.queries.append(query_str)
22
+ if len(self.queries) > 10:
23
+ raise RuntimeError("Infinite query loop")
24
+ idx = min(len(self.queries), len(self._responses)) - 1
25
+ return FakeResponse(self._responses[idx])
26
+
27
+
28
+ def test_null_data_raises_instead_of_infinite_loop():
29
+ server = _ScriptedServer([{"data": None}])
30
+ query = events_graphql_query({"id"}, SortOrder.ascending)
31
+ with pytest.raises(GraphQlQueryError, match="does not contain 'data'"):
32
+ query.query(server)
33
+
34
+
35
+ def test_missing_cursor_stops_pagination():
36
+ def page(ids, end_cursor):
37
+ return {"data": {"events": {
38
+ "edges": [{"node": {"id": id_}} for id_ in ids],
39
+ "pageInfo": {"endCursor": end_cursor, "hasNextPage": True},
40
+ }}}
41
+
42
+ server = _ScriptedServer([page(["e0"], "c0"), page([], None)])
43
+ query = events_graphql_query({"id"}, SortOrder.ascending)
44
+
45
+ with pytest.raises(GraphQlQueryError, match="page without a cursor"):
46
+ query.query(server)
@@ -0,0 +1,26 @@
1
+ """Validation of operations result. Does not require running AYON server."""
2
+ import pytest
3
+
4
+ from ayon_api.exceptions import FailedOperations
5
+ from ayon_api.server_api import ServerAPI
6
+
7
+
8
+ @pytest.fixture
9
+ def con():
10
+ return ServerAPI("http://localhost:0", create_session=False)
11
+
12
+
13
+ def test_failed_result_without_failed_operation_raises(con, capsys):
14
+ result = {"success": False, "operations": [{"id": "a", "success": True}]}
15
+ with pytest.raises(FailedOperations, match="Server response"):
16
+ con._validate_operations_result(result, [{"id": "a"}])
17
+ assert capsys.readouterr().out == ""
18
+
19
+
20
+ def test_failed_operation_raises_with_detail(con):
21
+ result = {
22
+ "success": False,
23
+ "operations": [{"id": "a", "success": False, "detail": "Boom"}],
24
+ }
25
+ with pytest.raises(FailedOperations, match="Boom"):
26
+ con._validate_operations_result(result, [{"id": "a", "type": "x"}])
@@ -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,0 +1,35 @@
1
+ """Retries of REST requests. Does not require running AYON server."""
2
+ import pytest
3
+ import requests
4
+
5
+ from ayon_api.server_api import ServerAPI
6
+
7
+ from .fake_transfer import FakeResponse
8
+
9
+
10
+ @pytest.fixture
11
+ def con(monkeypatch):
12
+ monkeypatch.setattr("time.sleep", lambda *args, **kwargs: None)
13
+ return ServerAPI("http://localhost:0", create_session=False, max_retries=3)
14
+
15
+
16
+ @pytest.mark.parametrize(
17
+ "failure",
18
+ [requests.exceptions.ConnectionError("down"), FakeResponse(503)],
19
+ )
20
+ def test_successful_retry_returns_successful_response(con, failure):
21
+ responses = [failure, FakeResponse(200, json_data={"ok": True})]
22
+
23
+ # `_do_rest_request` expects a callable that takes a URL and returns a
24
+ # response. So we just return fake responses to simulate it.
25
+ def request_func(url, **kwargs):
26
+ item = responses.pop(0)
27
+ if isinstance(item, Exception):
28
+ raise item
29
+ return item
30
+
31
+ response = con._do_rest_request(
32
+ request_func, "http://localhost:0/api/x", handle_invalid_token=False
33
+ )
34
+ assert response.status_code == 200
35
+ assert response.data == {"ok": True}