ayon-python-api 1.2.22__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.22 → ayon_python_api-1.2.24}/PKG-INFO +1 -1
  2. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/__init__.py +2 -0
  3. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api.py +61 -9
  4. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/activities.py +7 -3
  5. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/base.py +1 -0
  6. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/dependency_packages.py +6 -0
  7. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/links.py +101 -3
  8. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/lists.py +4 -4
  9. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/graphql.py +38 -34
  10. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/operations.py +27 -4
  11. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/server_api.py +54 -16
  12. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/typing.py +16 -1
  13. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/utils.py +2 -2
  14. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/version.py +1 -1
  15. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/PKG-INFO +1 -1
  16. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/SOURCES.txt +6 -0
  17. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/pyproject.toml +2 -2
  18. ayon_python_api-1.2.24/tests/test_download_resume.py +53 -0
  19. ayon_python_api-1.2.24/tests/test_graphql_descending_pagination.py +21 -0
  20. ayon_python_api-1.2.24/tests/test_graphql_infinite_loop.py +46 -0
  21. ayon_python_api-1.2.24/tests/test_operations_result.py +26 -0
  22. ayon_python_api-1.2.24/tests/test_request_max_retries_kwarg.py +26 -0
  23. ayon_python_api-1.2.24/tests/test_request_retries.py +35 -0
  24. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/LICENSE +0 -0
  25. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/README.md +0 -0
  26. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/__init__.py +0 -0
  27. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/actions.py +0 -0
  28. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/attributes.py +0 -0
  29. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/bundles_addons.py +0 -0
  30. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/events.py +0 -0
  31. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/folders.py +0 -0
  32. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/installers.py +0 -0
  33. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/products.py +0 -0
  34. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/projects.py +0 -0
  35. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/representations.py +0 -0
  36. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/secrets.py +0 -0
  37. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/tasks.py +0 -0
  38. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/thumbnails.py +0 -0
  39. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/versions.py +0 -0
  40. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/_api_helpers/workfiles.py +0 -0
  41. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/constants.py +0 -0
  42. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/entity_hub.py +0 -0
  43. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/events.py +0 -0
  44. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/exceptions.py +0 -0
  45. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_api/graphql_queries.py +0 -0
  46. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/dependency_links.txt +0 -0
  47. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/requires.txt +0 -0
  48. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/ayon_python_api.egg-info/top_level.txt +0 -0
  49. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/setup.cfg +0 -0
  50. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/setup.py +0 -0
  51. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/tests/test_entity_hub.py +0 -0
  52. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/tests/test_folder_hierarchy.py +0 -0
  53. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/tests/test_get_events.py +0 -0
  54. {ayon_python_api-1.2.22 → ayon_python_api-1.2.24}/tests/test_graphql_queries.py +0 -0
  55. {ayon_python_api-1.2.22 → 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.22
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
@@ -275,6 +275,7 @@ from ._api import (
275
275
  delete_link_type,
276
276
  make_sure_link_type_exists,
277
277
  create_link,
278
+ create_links,
278
279
  delete_link,
279
280
  get_entities_links,
280
281
  get_folders_links,
@@ -594,6 +595,7 @@ __all__ = (
594
595
  "delete_link_type",
595
596
  "make_sure_link_type_exists",
596
597
  "create_link",
598
+ "create_links",
597
599
  "delete_link",
598
600
  "get_entities_links",
599
601
  "get_folders_links",
@@ -53,6 +53,8 @@ if typing.TYPE_CHECKING:
53
53
  BackgroundOperationTask,
54
54
  LinkDirection,
55
55
  CreateLinkData,
56
+ CreateLinkResponseData,
57
+ CreateLinksResponseItem,
56
58
  EventFilter,
57
59
  EventStatus,
58
60
  EnrollEventData,
@@ -1620,7 +1622,9 @@ def get_rest_entity_by_id(
1620
1622
  def send_batch_operations(
1621
1623
  project_name: str,
1622
1624
  operations: list[dict[str, Any]],
1625
+ *,
1623
1626
  can_fail: bool = False,
1627
+ wait_for_events: bool = False,
1624
1628
  raise_on_fail: bool = True,
1625
1629
  ) -> list[dict[str, Any]]:
1626
1630
  """Post multiple CRUD operations to server.
@@ -1635,6 +1639,7 @@ def send_batch_operations(
1635
1639
  operations (list[dict[str, Any]]): Operations to be processed.
1636
1640
  can_fail (bool): Server will try to process all
1637
1641
  operations even if one of them fails.
1642
+ wait_for_events (bool): Wait for events to be processed on server.
1638
1643
  raise_on_fail (bool): Raise exception if an operation
1639
1644
  fails. You can handle failed operations on your own
1640
1645
  when set to 'False'.
@@ -1653,6 +1658,7 @@ def send_batch_operations(
1653
1658
  project_name=project_name,
1654
1659
  operations=operations,
1655
1660
  can_fail=can_fail,
1661
+ wait_for_events=wait_for_events,
1656
1662
  raise_on_fail=raise_on_fail,
1657
1663
  )
1658
1664
 
@@ -1662,8 +1668,9 @@ def send_background_batch_operations(
1662
1668
  operations: list[dict[str, Any]],
1663
1669
  *,
1664
1670
  can_fail: bool = False,
1665
- wait: bool = False,
1671
+ wait_for_events: bool = False,
1666
1672
  raise_on_fail: bool = True,
1673
+ wait: bool = False,
1667
1674
  ) -> BackgroundOperationTask:
1668
1675
  """Post multiple CRUD operations to server.
1669
1676
 
@@ -1686,10 +1693,11 @@ def send_background_batch_operations(
1686
1693
  operations (list[dict[str, Any]]): Operations to be processed.
1687
1694
  can_fail (bool): Server will try to process all
1688
1695
  operations even if one of them fails.
1689
- wait (bool): Wait for operations to end.
1696
+ wait_for_events (bool): Wait for events to be processed on server.
1690
1697
  raise_on_fail (bool): Raise exception if an operation
1691
1698
  fails. You can handle failed operations on your own
1692
1699
  when set to 'False'. Used when 'wait' is enabled.
1700
+ wait (bool): Wait for operations to end.
1693
1701
 
1694
1702
  Raises:
1695
1703
  ValueError: Operations can't be converted to json string.
@@ -1705,8 +1713,9 @@ def send_background_batch_operations(
1705
1713
  project_name=project_name,
1706
1714
  operations=operations,
1707
1715
  can_fail=can_fail,
1708
- wait=wait,
1716
+ wait_for_events=wait_for_events,
1709
1717
  raise_on_fail=raise_on_fail,
1718
+ wait=wait,
1710
1719
  )
1711
1720
 
1712
1721
 
@@ -1934,6 +1943,7 @@ def create_dependency_package(
1934
1943
  checksum: str,
1935
1944
  checksum_algorithm: str,
1936
1945
  file_size: int,
1946
+ python_version: str | None = None,
1937
1947
  sources: Optional[list[dict[str, Any]]] = None,
1938
1948
  platform_name: Optional[str] = None,
1939
1949
  ) -> None:
@@ -1959,6 +1969,8 @@ def create_dependency_package(
1959
1969
  checksum (str): Checksum of archive file where dependencies are.
1960
1970
  checksum_algorithm (str): Algorithm used to calculate checksum.
1961
1971
  file_size (Optional[int]): Size of file.
1972
+ python_version (str | None): Python version for which
1973
+ is dependency package created.
1962
1974
  sources (Optional[list[dict[str, Any]]]): Information about
1963
1975
  sources from where it is possible to get file.
1964
1976
  platform_name (Optional[str]): Name of platform for which is
@@ -1975,6 +1987,7 @@ def create_dependency_package(
1975
1987
  checksum=checksum,
1976
1988
  checksum_algorithm=checksum_algorithm,
1977
1989
  file_size=file_size,
1990
+ python_version=python_version,
1978
1991
  sources=sources,
1979
1992
  platform_name=platform_name,
1980
1993
  )
@@ -2708,7 +2721,9 @@ def set_entity_watchers(
2708
2721
  def send_activities_batch_operations(
2709
2722
  project_name: str,
2710
2723
  operations: list[dict[str, Any]],
2724
+ *,
2711
2725
  can_fail: bool = False,
2726
+ wait_for_events: bool = False,
2712
2727
  raise_on_fail: bool = True,
2713
2728
  ) -> list[dict[str, Any]]:
2714
2729
  """Post multiple CRUD activities operations to server.
@@ -2723,6 +2738,7 @@ def send_activities_batch_operations(
2723
2738
  operations (list[dict[str, Any]]): Operations to be processed.
2724
2739
  can_fail (Optional[bool]): Server will try to process all
2725
2740
  operations even if one of them fails.
2741
+ wait_for_events (bool): Wait for events to be processed on server.
2726
2742
  raise_on_fail (Optional[bool]): Raise exception if an operation
2727
2743
  fails. You can handle failed operations on your own
2728
2744
  when set to 'False'.
@@ -2741,6 +2757,7 @@ def send_activities_batch_operations(
2741
2757
  project_name=project_name,
2742
2758
  operations=operations,
2743
2759
  can_fail=can_fail,
2760
+ wait_for_events=wait_for_events,
2744
2761
  raise_on_fail=raise_on_fail,
2745
2762
  )
2746
2763
 
@@ -7504,7 +7521,7 @@ def create_link(
7504
7521
  output_type: str,
7505
7522
  link_name: Optional[str] = None,
7506
7523
  data: Optional[dict[str, Any]] = None,
7507
- ) -> CreateLinkData:
7524
+ ) -> CreateLinkResponseData:
7508
7525
  """Create link between 2 entities.
7509
7526
 
7510
7527
  Link has a type which must already exists on a project.
@@ -7527,7 +7544,7 @@ def create_link(
7527
7544
  with the link.
7528
7545
 
7529
7546
  Returns:
7530
- CreateLinkData: Information about link.
7547
+ CreateLinkResponseData: Information about link.
7531
7548
 
7532
7549
  Raises:
7533
7550
  HTTPRequestError: Server error happened.
@@ -7546,6 +7563,41 @@ def create_link(
7546
7563
  )
7547
7564
 
7548
7565
 
7566
+ def create_links(
7567
+ project_name: str,
7568
+ links: list[CreateLinkData],
7569
+ ) -> list[CreateLinksResponseItem]:
7570
+ """Create multiple links in a single request.
7571
+
7572
+ Example of link data::
7573
+ [
7574
+ {
7575
+ "input": "59a212c0d2e211eda0e20242ac120001",
7576
+ "output": "59a212c0d2e211eda0e20242ac120002",
7577
+ "linkType": "reference|folder|folder",
7578
+ "name": "my_link",
7579
+ "data": {"key": "value"}
7580
+ }
7581
+ ]
7582
+
7583
+ Args:
7584
+ project_name (str): Project where links are created.
7585
+ links (list[CreateLinkData]): List of link data.
7586
+
7587
+ Returns:
7588
+ list[CreateLinksResponseItem]: Information about created links.
7589
+
7590
+ Raises:
7591
+ ValueError: Link data is invalid.
7592
+
7593
+ """
7594
+ con = get_server_api_connection()
7595
+ return con.create_links(
7596
+ project_name=project_name,
7597
+ links=links,
7598
+ )
7599
+
7600
+
7549
7601
  def delete_link(
7550
7602
  project_name: str,
7551
7603
  link_id: str,
@@ -8003,8 +8055,8 @@ def create_entity_list(
8003
8055
  *,
8004
8056
  list_type: Optional[str] = None,
8005
8057
  access: Optional[dict[str, Any]] = None,
8006
- attrib: Optional[list[dict[str, Any]]] = None,
8007
- data: Optional[list[dict[str, Any]]] = None,
8058
+ attrib: Optional[dict[str, Any]] = None,
8059
+ data: Optional[dict[str, Any]] = None,
8008
8060
  tags: Optional[list[str]] = None,
8009
8061
  template: Optional[dict[str, Any]] = None,
8010
8062
  entity_list_folder_id: Optional[str] = None,
@@ -8060,8 +8112,8 @@ def update_entity_list(
8060
8112
  *,
8061
8113
  label: Optional[str] = None,
8062
8114
  access: Optional[dict[str, Any]] = None,
8063
- attrib: Optional[list[dict[str, Any]]] = None,
8064
- data: Optional[list[dict[str, Any]]] = None,
8115
+ attrib: Optional[dict[str, Any]] = None,
8116
+ data: Optional[dict[str, Any]] = None,
8065
8117
  tags: Optional[list[str]] = None,
8066
8118
  entity_list_folder_id: str | None | type[NOT_SET] = NOT_SET,
8067
8119
  owner: Optional[str] = None,
@@ -370,8 +370,10 @@ class ActivitiesAPI(BaseServerAPI):
370
370
  self,
371
371
  project_name: str,
372
372
  operations: list[dict[str, Any]],
373
+ *,
373
374
  can_fail: bool = False,
374
- raise_on_fail: bool = True
375
+ wait_for_events: bool = False,
376
+ raise_on_fail: bool = True,
375
377
  ) -> list[dict[str, Any]]:
376
378
  """Post multiple CRUD activities operations to server.
377
379
 
@@ -385,6 +387,7 @@ class ActivitiesAPI(BaseServerAPI):
385
387
  operations (list[dict[str, Any]]): Operations to be processed.
386
388
  can_fail (Optional[bool]): Server will try to process all
387
389
  operations even if one of them fails.
390
+ wait_for_events (bool): Wait for events to be processed on server.
388
391
  raise_on_fail (Optional[bool]): Raise exception if an operation
389
392
  fails. You can handle failed operations on your own
390
393
  when set to 'False'.
@@ -401,6 +404,7 @@ class ActivitiesAPI(BaseServerAPI):
401
404
  return self._send_batch_operations(
402
405
  f"projects/{project_name}/operations/activities",
403
406
  operations,
404
- can_fail,
405
- raise_on_fail,
407
+ can_fail=can_fail,
408
+ wait_for_events=wait_for_events,
409
+ raise_on_fail=raise_on_fail,
406
410
  )
@@ -167,6 +167,7 @@ class BaseServerAPI:
167
167
  uri: str,
168
168
  operations: list[dict[str, Any]],
169
169
  can_fail: bool,
170
+ wait_for_events: bool,
170
171
  raise_on_fail: bool
171
172
  ) -> list[dict[str, Any]]:
172
173
  raise NotImplementedError()
@@ -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
 
@@ -15,7 +15,12 @@ from ayon_api.graphql_queries import (
15
15
  from .base import BaseServerAPI
16
16
 
17
17
  if typing.TYPE_CHECKING:
18
- from ayon_api.typing import LinkDirection, CreateLinkData
18
+ from ayon_api.typing import (
19
+ LinkDirection,
20
+ CreateLinkData,
21
+ CreateLinkResponseData,
22
+ CreateLinksResponseItem,
23
+ )
19
24
 
20
25
 
21
26
  class LinksAPI(BaseServerAPI):
@@ -200,7 +205,7 @@ class LinksAPI(BaseServerAPI):
200
205
  output_type: str,
201
206
  link_name: Optional[str] = None,
202
207
  data: Optional[dict[str, Any]] = None,
203
- ) -> CreateLinkData:
208
+ ) -> CreateLinkResponseData:
204
209
  """Create link between 2 entities.
205
210
 
206
211
  Link has a type which must already exists on a project.
@@ -223,7 +228,7 @@ class LinksAPI(BaseServerAPI):
223
228
  with the link.
224
229
 
225
230
  Returns:
226
- CreateLinkData: Information about link.
231
+ CreateLinkResponseData: Information about link.
227
232
 
228
233
  Raises:
229
234
  HTTPRequestError: Server error happened.
@@ -249,6 +254,70 @@ class LinksAPI(BaseServerAPI):
249
254
  response.raise_for_status()
250
255
  return response.data
251
256
 
257
+ def create_links(
258
+ self,
259
+ project_name: str,
260
+ links: list[CreateLinkData],
261
+ ) -> list[CreateLinksResponseItem]:
262
+ """Create multiple links in a single request.
263
+
264
+ Example of link data::
265
+ [
266
+ {
267
+ "input": "59a212c0d2e211eda0e20242ac120001",
268
+ "output": "59a212c0d2e211eda0e20242ac120002",
269
+ "linkType": "reference|folder|folder",
270
+ "name": "my_link",
271
+ "data": {"key": "value"}
272
+ }
273
+ ]
274
+
275
+ Args:
276
+ project_name (str): Project where links are created.
277
+ links (list[CreateLinkData]): List of link data.
278
+
279
+ Returns:
280
+ list[CreateLinksResponseItem]: Information about created links.
281
+
282
+ Raises:
283
+ ValueError: Link data is invalid.
284
+
285
+ """
286
+ if not links:
287
+ return []
288
+
289
+ for link in links:
290
+ self._validate_link_data(link)
291
+
292
+ if self.get_server_version_tuple() < (1, 15, 8):
293
+ created_links = []
294
+ for link in links:
295
+ link_type, in_type, out_type = link["linkType"].split("|")
296
+ data = self.create_link(
297
+ project_name,
298
+ link_type,
299
+ link["input"],
300
+ in_type,
301
+ link["output"],
302
+ out_type,
303
+ link_name=link.get("name") or None,
304
+ data=link.get("data") or None,
305
+ )
306
+ created_links.append({
307
+ "id": data["id"],
308
+ "input": link["input"],
309
+ "output": link["output"],
310
+ "linkType": link["linkType"]
311
+ })
312
+ return created_links
313
+
314
+ response = self.post(
315
+ f"projects/{project_name}/links/bulk",
316
+ links=links
317
+ )
318
+ response.raise_for_status()
319
+ return response.data["created"]
320
+
252
321
  def delete_link(self, project_name: str, link_id: str) -> None:
253
322
  """Remove link by id.
254
323
 
@@ -619,6 +688,35 @@ class LinksAPI(BaseServerAPI):
619
688
  project_name, [representation_id], link_types, link_direction
620
689
  )[representation_id]
621
690
 
691
+ def _validate_link_data(self, link_data: dict[str, Any]) -> None:
692
+ """Validate link data before sending to server.
693
+
694
+ Args:
695
+ link_data (dict[str, Any]): Link data to validate.
696
+
697
+ Raises:
698
+ ValueError: Link data is invalid.
699
+
700
+ """
701
+ required_keys = {"input", "output", "linkType"}
702
+ missing_keys = required_keys - link_data.keys()
703
+ if missing_keys:
704
+ mk = ", ".join(f"'{key}'" for key in sorted(missing_keys))
705
+ raise ValueError(f"Missing required keys in link data: {mk}")
706
+
707
+ link_type = link_data["linkType"]
708
+ if not isinstance(link_type, str):
709
+ raise ValueError(
710
+ f"Invalid linkType type: {type(link_type)}. Expected 'str'"
711
+ )
712
+
713
+ link_type_parts = link_type.split("|")
714
+ if len(link_type_parts) != 3:
715
+ raise ValueError(
716
+ f"Invalid linkType format: {link_type}. Expected format:"
717
+ " 'link_type|input_type|output_type'"
718
+ )
719
+
622
720
  def _prepare_link_filters(
623
721
  self,
624
722
  filters: dict[str, Any],
@@ -157,8 +157,8 @@ class ListsAPI(BaseServerAPI):
157
157
  *,
158
158
  list_type: Optional[str] = None,
159
159
  access: Optional[dict[str, Any]] = None,
160
- attrib: Optional[list[dict[str, Any]]] = None,
161
- data: Optional[list[dict[str, Any]]] = None,
160
+ attrib: Optional[dict[str, Any]] = None,
161
+ data: Optional[dict[str, Any]] = None,
162
162
  tags: Optional[list[str]] = None,
163
163
  template: Optional[dict[str, Any]] = None,
164
164
  entity_list_folder_id: Optional[str] = None,
@@ -225,8 +225,8 @@ class ListsAPI(BaseServerAPI):
225
225
  *,
226
226
  label: Optional[str] = None,
227
227
  access: Optional[dict[str, Any]] = None,
228
- attrib: Optional[list[dict[str, Any]]] = None,
229
- data: Optional[list[dict[str, Any]]] = None,
228
+ attrib: Optional[dict[str, Any]] = None,
229
+ data: Optional[dict[str, Any]] = None,
230
230
  tags: Optional[list[str]] = None,
231
231
  entity_list_folder_id: str | None | type[NOT_SET] = NOT_SET,
232
232
  owner: Optional[str] = None,
@@ -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
@@ -741,12 +741,21 @@ class OperationsSession(object):
741
741
  Args:
742
742
  con (Optional[ServerAPI]): Connection to server. Global connection
743
743
  is used if not passed.
744
+ wait_for_events (bool): Wait for events to be processed on server.
745
+ Default value of the argument. Can be changed when 'commit'
746
+ is called.
744
747
 
745
748
  """
746
- def __init__(self, con: Optional[ServerAPI] = None) -> None:
749
+ def __init__(
750
+ self,
751
+ con: Optional[ServerAPI] = None,
752
+ *,
753
+ wait_for_events: bool = False,
754
+ ) -> None:
747
755
  if con is None:
748
756
  con = get_server_api_connection()
749
757
  self._con = con
758
+ self._wait_for_events = wait_for_events
750
759
  self._project_cache = {}
751
760
  self._operations = []
752
761
  self._nested_operations = collections.defaultdict(list)
@@ -817,8 +826,15 @@ class OperationsSession(object):
817
826
  for operation in self._operations
818
827
  ]
819
828
 
820
- def commit(self) -> None:
821
- """Commit session operations."""
829
+ def commit(self, *, wait_for_events: bool | None = None) -> None:
830
+ """Commit session operations.
831
+
832
+ Args:
833
+ wait_for_events (bool | None): Wait for events to be processed
834
+ on server. Use 'None' to use the default value defined on
835
+ OperationsSession object.
836
+
837
+ """
822
838
  operations, self._operations = self._operations, []
823
839
  if not operations:
824
840
  return
@@ -827,6 +843,9 @@ class OperationsSession(object):
827
843
  for operation in operations:
828
844
  operations_by_project[operation.project_name].append(operation)
829
845
 
846
+ if wait_for_events is None:
847
+ wait_for_events = self._wait_for_events
848
+
830
849
  for project_name, operations in operations_by_project.items():
831
850
  operations_body = []
832
851
  for operation in operations:
@@ -835,7 +854,11 @@ class OperationsSession(object):
835
854
  operations_body.append(body)
836
855
 
837
856
  self._con.send_background_batch_operations(
838
- project_name, operations_body, wait=True, can_fail=False
857
+ project_name,
858
+ operations_body,
859
+ can_fail=False,
860
+ wait_for_events=wait_for_events,
861
+ wait=True,
839
862
  )
840
863
 
841
864
  def create_entity(
@@ -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
@@ -2475,7 +2496,9 @@ class ServerAPI(
2475
2496
  self,
2476
2497
  project_name: str,
2477
2498
  operations: list[dict[str, Any]],
2499
+ *,
2478
2500
  can_fail: bool = False,
2501
+ wait_for_events: bool = False,
2479
2502
  raise_on_fail: bool = True,
2480
2503
  ) -> list[dict[str, Any]]:
2481
2504
  """Post multiple CRUD operations to server.
@@ -2490,6 +2513,7 @@ class ServerAPI(
2490
2513
  operations (list[dict[str, Any]]): Operations to be processed.
2491
2514
  can_fail (bool): Server will try to process all
2492
2515
  operations even if one of them fails.
2516
+ wait_for_events (bool): Wait for events to be processed on server.
2493
2517
  raise_on_fail (bool): Raise exception if an operation
2494
2518
  fails. You can handle failed operations on your own
2495
2519
  when set to 'False'.
@@ -2506,8 +2530,9 @@ class ServerAPI(
2506
2530
  return self._send_batch_operations(
2507
2531
  f"projects/{project_name}/operations",
2508
2532
  operations,
2509
- can_fail,
2510
- raise_on_fail,
2533
+ can_fail=can_fail,
2534
+ wait_for_events=wait_for_events,
2535
+ raise_on_fail=raise_on_fail,
2511
2536
  )
2512
2537
 
2513
2538
  def send_background_batch_operations(
@@ -2516,8 +2541,9 @@ class ServerAPI(
2516
2541
  operations: list[dict[str, Any]],
2517
2542
  *,
2518
2543
  can_fail: bool = False,
2519
- wait: bool = False,
2544
+ wait_for_events: bool = False,
2520
2545
  raise_on_fail: bool = True,
2546
+ wait: bool = False,
2521
2547
  ) -> BackgroundOperationTask:
2522
2548
  """Post multiple CRUD operations to server.
2523
2549
 
@@ -2540,10 +2566,11 @@ class ServerAPI(
2540
2566
  operations (list[dict[str, Any]]): Operations to be processed.
2541
2567
  can_fail (bool): Server will try to process all
2542
2568
  operations even if one of them fails.
2543
- wait (bool): Wait for operations to end.
2569
+ wait_for_events (bool): Wait for events to be processed on server.
2544
2570
  raise_on_fail (bool): Raise exception if an operation
2545
2571
  fails. You can handle failed operations on your own
2546
2572
  when set to 'False'. Used when 'wait' is enabled.
2573
+ wait (bool): Wait for operations to end.
2547
2574
 
2548
2575
  Raises:
2549
2576
  ValueError: Operations can't be converted to json string.
@@ -2558,7 +2585,8 @@ class ServerAPI(
2558
2585
  response = self.post(
2559
2586
  f"projects/{project_name}/operations/background",
2560
2587
  operations=operations_body,
2561
- canFail=can_fail
2588
+ canFail=can_fail,
2589
+ waitForEvents=wait_for_events,
2562
2590
  )
2563
2591
  response.raise_for_status()
2564
2592
  if not wait:
@@ -2631,7 +2659,8 @@ class ServerAPI(
2631
2659
  uri: str,
2632
2660
  operations: list[dict[str, Any]],
2633
2661
  can_fail: bool,
2634
- raise_on_fail: bool
2662
+ wait_for_events: bool,
2663
+ raise_on_fail: bool,
2635
2664
  ) -> list[dict[str, Any]]:
2636
2665
  if not operations:
2637
2666
  return []
@@ -2643,7 +2672,8 @@ class ServerAPI(
2643
2672
  response = self.post(
2644
2673
  uri,
2645
2674
  operations=operations_body,
2646
- canFail=can_fail
2675
+ canFail=can_fail,
2676
+ waitForEvents=wait_for_events,
2647
2677
  )
2648
2678
 
2649
2679
  op_results = response.get("operations")
@@ -2667,24 +2697,32 @@ class ServerAPI(
2667
2697
  if result.get("success"):
2668
2698
  return None
2669
2699
 
2670
- print(result)
2671
- 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 []:
2672
2705
  if op_result["success"]:
2673
2706
  continue
2674
2707
 
2675
2708
  operation_id = op_result["id"]
2676
2709
  operation = next(
2677
- op
2678
- for op in operations_body
2679
- if op["id"] == operation_id
2710
+ (op for op in operations_body if op["id"] == operation_id),
2711
+ op_result,
2680
2712
  )
2681
2713
  detail = op_result["detail"]
2682
2714
  raise FailedOperations(
2683
2715
  f"Operation \"{operation_id}\" failed with data:"
2684
- f"\n{json.dumps(operation, indent=4)}"
2716
+ f"\n{json.dumps(operation, indent=4, default=str)}"
2685
2717
  f"\nDetail: {detail}."
2686
2718
  )
2687
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
+
2688
2726
  def _prepare_fields(
2689
2727
  self,
2690
2728
  entity_type: str,
@@ -127,8 +127,23 @@ AttributeType = Literal[
127
127
  LinkDirection = Literal["in", "out"]
128
128
 
129
129
 
130
- class CreateLinkData(TypedDict):
130
+ class CreateLinkResponseData(TypedDict):
131
+ id: str
132
+
133
+
134
+ class CreateLinksResponseItem(TypedDict):
131
135
  id: str
136
+ input: str
137
+ output: str
138
+ linkType: str
139
+
140
+
141
+ class CreateLinkData(TypedDict):
142
+ input: str
143
+ output: str
144
+ linkType: str
145
+ data: NotRequired[dict[str, Any] | None]
146
+ name: NotRequired[str | None]
132
147
 
133
148
 
134
149
  class AttributeEnumItemDict(TypedDict):
@@ -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.22"
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.22
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.22"
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.22"
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}