sg-python-api 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sg-python-api
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Python client for Autodesk Flow Production Tracking
5
5
  Project-URL: Documentation, https://github.com/dactylo/sg-python-api/tree/main/docs
6
6
  Project-URL: Homepage, https://github.com/dactylo/sg-python-api
@@ -44,9 +44,9 @@ This documentation uses SG for Flow Production Tracking, formerly named Shotgun
44
44
  for application tests.
45
45
 
46
46
  sg-python-api is an independent implementation of the documented API of Autodesk's Python client
47
- `3.10.2`. Some behavior differs on purpose, so validate your application's workflows against a test
48
- site before replacing its production client. The [migration guide](https://github.com/dactylo/sg-python-api/blob/v0.1.1/docs/migration-guide.md) walks
49
- through the switch, and the [compatibility reference](https://github.com/dactylo/sg-python-api/blob/v0.1.1/COMPATIBILITY.md) lists the differences.
47
+ `3.10.3`. Some behavior differs on purpose, so validate your application's workflows against a test
48
+ site before replacing its production client. The [migration guide](https://github.com/dactylo/sg-python-api/blob/v0.1.2/docs/migration-guide.md) walks
49
+ through the switch, and the [compatibility reference](https://github.com/dactylo/sg-python-api/blob/v0.1.2/COMPATIBILITY.md) lists the differences.
50
50
 
51
51
  ## Installation
52
52
 
@@ -55,7 +55,7 @@ requests to a remote site require HTTPS.
55
55
 
56
56
  `sg-python-api` and Autodesk's `shotgun-api3` both install the `shotgun_api3` package, so install
57
57
  exactly one of them in a Python environment. If the official client is already installed, follow
58
- the [migration steps](https://github.com/dactylo/sg-python-api/blob/v0.1.1/docs/migration-guide.md#install-the-replacement) to replace it.
58
+ the [migration steps](https://github.com/dactylo/sg-python-api/blob/v0.1.2/docs/migration-guide.md#install-the-replacement) to replace it.
59
59
 
60
60
  From a checkout, install into the Python environment that runs your application:
61
61
 
@@ -130,23 +130,23 @@ operation fails. Without the `async` extra, importing `AsyncShotgun` raises `Imp
130
130
 
131
131
  Share an `AsyncShotgun` only among tasks on one event loop, and cancel its work by cancelling the
132
132
  task. A cancelled write may already have reached the server, so check its outcome before repeating
133
- it. The [async reference](https://github.com/dactylo/sg-python-api/blob/v0.1.1/COMPATIBILITY.md#async-extension-surfaces) covers shutdown and
133
+ it. The [async reference](https://github.com/dactylo/sg-python-api/blob/v0.1.2/COMPATIBILITY.md#async-extension-surfaces) covers shutdown and
134
134
  cancellation in detail.
135
135
 
136
136
  ## Documentation
137
137
 
138
138
  | If you want to... | Read |
139
139
  | ----------------------------------------------------- | ---------------------------------------------------------------------------------- |
140
- | Replace Autodesk's client in an existing application | [Migration guide](https://github.com/dactylo/sg-python-api/blob/v0.1.1/docs/migration-guide.md) |
141
- | Check what behaves differently from Autodesk's client | [Compatibility](https://github.com/dactylo/sg-python-api/blob/v0.1.1/COMPATIBILITY.md) |
142
- | Tune pooling and page prefetch, or observe requests | [Configuration extensions](https://github.com/dactylo/sg-python-api/blob/v0.1.1/COMPATIBILITY.md#configuration-and-observer-extensions) |
143
- | Confirm which client loads and run a smoke test | [Testing an installation](https://github.com/dactylo/sg-python-api/blob/v0.1.1/docs/testing.md) |
144
- | Compare measured latency and transfer memory | [Benchmarks](https://github.com/dactylo/sg-python-api/blob/v0.1.1/docs/benchmarks.md) |
145
- | See changes by version | [Changelog](https://github.com/dactylo/sg-python-api/blob/v0.1.1/CHANGELOG.md) |
140
+ | Replace Autodesk's client in an existing application | [Migration guide](https://github.com/dactylo/sg-python-api/blob/v0.1.2/docs/migration-guide.md) |
141
+ | Check what behaves differently from Autodesk's client | [Compatibility](https://github.com/dactylo/sg-python-api/blob/v0.1.2/COMPATIBILITY.md) |
142
+ | Tune pooling and page prefetch, or observe requests | [Configuration extensions](https://github.com/dactylo/sg-python-api/blob/v0.1.2/COMPATIBILITY.md#configuration-and-observer-extensions) |
143
+ | Confirm which client loads and run a smoke test | [Testing an installation](https://github.com/dactylo/sg-python-api/blob/v0.1.2/docs/testing.md) |
144
+ | Compare measured latency and transfer memory | [Benchmarks](https://github.com/dactylo/sg-python-api/blob/v0.1.2/docs/benchmarks.md) |
145
+ | See changes by version | [Changelog](https://github.com/dactylo/sg-python-api/blob/v0.1.2/CHANGELOG.md) |
146
146
 
147
147
  ## Development
148
148
 
149
- See the [development guide](https://github.com/dactylo/sg-python-api/blob/v0.1.1/docs/development.md) for the required tools, CI, and dependency locks.
149
+ See the [development guide](https://github.com/dactylo/sg-python-api/blob/v0.1.2/docs/development.md) for the required tools, CI, and dependency locks.
150
150
  With those tools installed, run:
151
151
 
152
152
  ```sh
@@ -159,6 +159,6 @@ make check
159
159
 
160
160
  ## License
161
161
 
162
- sg-python-api is available under the [MIT License](https://github.com/dactylo/sg-python-api/blob/v0.1.1/LICENSE). It is an independent project and is
162
+ sg-python-api is available under the [MIT License](https://github.com/dactylo/sg-python-api/blob/v0.1.2/LICENSE). It is an independent project and is
163
163
  not affiliated with or endorsed by Autodesk. Flow Production Tracking is a product of Autodesk,
164
164
  Inc.
@@ -1,23 +1,23 @@
1
1
  shotgun_api3/__init__.py,sha256=fviJGOi_Gb9jHi5Q9ty5tIbeIvxMtLNrKx_4HJVgemI,2011
2
- shotgun_api3/_async_client.py,sha256=I5_4t_WwJNc-3hkDrAVnbofqw4Nil07Jcxfr2CSb18A,112160
2
+ shotgun_api3/_async_client.py,sha256=UYVk_IGzMcUguJ4oCV-Nof4mL2kvzYYGuiYvW4gHeMs,113167
3
3
  shotgun_api3/_async_driver.py,sha256=NnuMmQP5KrAoANBvWIhcIUe_ljD62pK_7xSIoEIltXw,32308
4
4
  shotgun_api3/_async_httpx2.py,sha256=3pjeFsb5racGArvlR50c3w0XtrttXuRU_6E9Y-cBU3c,24387
5
5
  shotgun_api3/_async_transport.py,sha256=8iAcoFafEHKRBirBkqdIqMFgqLs1YTgtzgACudD0kM4,4928
6
6
  shotgun_api3/_capabilities.py,sha256=RrStvExVC3TAADhfbY2JpYZUXmvE63vXIJy1R-nkEII,5527
7
- shotgun_api3/_client.py,sha256=7ICzGJGKKI-OzuKoCTfqhumrvRaNybN98-br7CJnB9Y,101143
7
+ shotgun_api3/_client.py,sha256=jtemxhdhFr1gwE7qB6K-62Sohb2C_S4dEO7uKpVh2Uo,102109
8
8
  shotgun_api3/_config.py,sha256=fALxy5xGI3A0nECsRDyls_9A8tvjB0DNSpTu1agNEHw,54931
9
9
  shotgun_api3/_crud.py,sha256=Ax36jA2E-lauRf2D-2rmRDj61SilOygtkjKgs5XDCUs,30319
10
10
  shotgun_api3/_driver.py,sha256=DAtnZW044TWiqscs0G1uwTiluAFtCoR2wKTbJYJHT2g,29498
11
11
  shotgun_api3/_errors.py,sha256=fk7v3XbmZK63M272r5wDwF_GgI-4RynPfAWv1P-J-GI,1480
12
12
  shotgun_api3/_execution.py,sha256=Kn2Yd9sXIA975stnof7MeuA8bT3dyOuWDIqQnP65LO0,8235
13
- shotgun_api3/_files.py,sha256=7onuC_ODjGtgysIy3YUpeHKtmUpBHQpYln6XtNe3NK4,36770
14
- shotgun_api3/_methods.py,sha256=yGyQv7tKpHX3e3nGIVHEbuTNzjsAxQdJVXpmlXXStsw,34184
15
- shotgun_api3/_protocol.py,sha256=dKr1XqB6Xo56QloZ2MAyN4ulqYkTjw8VYeJRd5QHufQ,45286
13
+ shotgun_api3/_files.py,sha256=-eXHyG0U9mEAdYTthgVEhlzBKYUOt4g-3k9D522L3zM,33900
14
+ shotgun_api3/_methods.py,sha256=pYzx2pN0nus6vPWf-Gl8Zz4MgRf8R4b7kvxN8lhoXgc,37685
15
+ shotgun_api3/_protocol.py,sha256=0ccL65aIm3v2w6dOtNnZuFrKQncA8WO4yadtxRP55-w,42194
16
16
  shotgun_api3/_sentinel.py,sha256=KAQzQjJVhTTZPLFY1f88-_vP7U06i2g6ZS7KJcn4vIA,116
17
17
  shotgun_api3/_sg_timezone.py,sha256=yr7Wdln9fwfTVGMQSFsgO-eduuTHQ5EbSY5xEVQ2tYw,115
18
18
  shotgun_api3/_signatures.py,sha256=_yhNnbkVF-0opaBfg9H6SauBN2RT4hJsHGa3ZO3Ug4s,1622
19
19
  shotgun_api3/_transport.py,sha256=LmQ0e2VvAKwFdNy4SyfRHwgKC6GWnwkIN5Wa2fin3AM,30047
20
- shotgun_api3/_version.py,sha256=LnmGg2me6J0MbE65mSg2nIknHNNg0Etx_3Jkh_k9BWc,102
20
+ shotgun_api3/_version.py,sha256=0NYED9oy2nGCvTERMwWJvajshS5qzH_v4w7QUAGAivY,102
21
21
  shotgun_api3/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  shotgun_api3/shotgun.py,sha256=X7PLlCC-gR8dJgY_uLYXw_LWN8mz5MsQsSYJldtavTk,1706
23
23
  shotgun_api3/lib/__init__.py,sha256=4WGYod6xHcj23rapx5bTDGdjT52H1CAQNMz4BmzNmn0,55
@@ -25,7 +25,7 @@ shotgun_api3/lib/sgtimezone.py,sha256=nYSaCsIeq_SJh7YgL4yWN7dTVVz9lAfx-G3mzsHb8i
25
25
  shotgun_api3/lib/mockgun/__init__.py,sha256=2mHjMJAV4mZ8JvnP8WHn6tkdMJoB7UQtNSs99YzNW30,703
26
26
  shotgun_api3/lib/mockgun/_async.py,sha256=7bTZFdlSzeoGq9fRbPBAf-X08qDmrkzV9rxskx5AGX8,10812
27
27
  shotgun_api3/lib/mockgun/_mockgun.py,sha256=k2cqLMCKpU5oIbUGiYulf1aIU0HLrMB6DQQ3KrCy7WQ,65093
28
- sg_python_api-0.1.1.dist-info/METADATA,sha256=bwDwuEXYzQ-8J-igveXXMna_Ulemsdz9xwNLVhdW3mI,7587
29
- sg_python_api-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
30
- sg_python_api-0.1.1.dist-info/licenses/LICENSE,sha256=V3moMsQxKDchTd3mtXl5J-ODaNBDvFrapOf1DK7Nx_U,1082
31
- sg_python_api-0.1.1.dist-info/RECORD,,
28
+ sg_python_api-0.1.2.dist-info/METADATA,sha256=2Zv6g9pJZ28QttHMO6Q1yYWvyczPypCHc-QCAYbjIaQ,7587
29
+ sg_python_api-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
30
+ sg_python_api-0.1.2.dist-info/licenses/LICENSE,sha256=V3moMsQxKDchTd3mtXl5J-ODaNBDvFrapOf1DK7Nx_U,1082
31
+ sg_python_api-0.1.2.dist-info/RECORD,,
@@ -59,7 +59,6 @@ from ._config import (
59
59
  server_host,
60
60
  server_page_limit,
61
61
  )
62
- from ._errors import ShotgunError
63
62
  from ._execution import SuppressedExceptionCapture, release_lease, retire_transport
64
63
  from ._signatures import bind_arguments, expose_signature
65
64
  from ._transport import (
@@ -1037,29 +1036,6 @@ class AsyncShotgun:
1037
1036
  lambda _text, _headers: None,
1038
1037
  )
1039
1038
 
1040
- @staticmethod
1041
- async def _upload_form(
1042
- driver: AsyncDriver,
1043
- context: RequestContext,
1044
- prepared: _files.PreparedUpload,
1045
- ) -> Never:
1046
- """Send the observed form request without guessing a success result."""
1047
- if context.canonical_origin is None:
1048
- raise ValueError('server parameters must be configured before upload I/O')
1049
- exchange = _files.build_form_upload_exchange(
1050
- context.canonical_origin,
1051
- context.user_agent,
1052
- prepared,
1053
- context.auth,
1054
- chunk_size=_ASYNC_FILE_BATCH_SIZE,
1055
- )
1056
-
1057
- def reject_success(_text: str, _headers: Mapping[str, str]) -> Never:
1058
- raise ShotgunError('form upload success response is not established')
1059
-
1060
- await _execute_upload_exchange(driver, context, exchange, reject_success)
1061
- raise AssertionError('form upload response parser returned')
1062
-
1063
1039
  @staticmethod
1064
1040
  async def _upload_link(
1065
1041
  driver: AsyncDriver,
@@ -1162,14 +1138,14 @@ class AsyncShotgun:
1162
1138
  arguments = prepared.options.arguments
1163
1139
  capabilities = await self._ensure_readiness(context, driver)
1164
1140
  operation_context = await self._authenticated_context(context, driver)
1165
- upload_path = _files.choose_upload_path(
1166
- capabilities.server_info,
1167
- arguments.entity_type,
1168
- arguments.field_name,
1169
- prepared.file_size,
1141
+ upload_path = _files.require_supported_upload_path(
1142
+ _files.choose_upload_path(
1143
+ capabilities.server_info,
1144
+ arguments.entity_type,
1145
+ arguments.field_name,
1146
+ prepared.file_size,
1147
+ )
1170
1148
  )
1171
- if upload_path == 'form':
1172
- return await self._upload_form(driver, operation_context, prepared)
1173
1149
  multipart = upload_path == 'multipart'
1174
1150
  upload_info = await self._upload_allocation(
1175
1151
  driver,
@@ -1208,6 +1184,16 @@ class AsyncShotgun:
1208
1184
  prepared: _files.PreparedUpload,
1209
1185
  ) -> int:
1210
1186
  """Execute the exact direct-storage thumbnail workflow."""
1187
+ capabilities = await self._ensure_readiness(context, driver)
1188
+ arguments = prepared.options.arguments
1189
+ _files.require_supported_upload_path(
1190
+ _files.choose_upload_path(
1191
+ capabilities.server_info,
1192
+ arguments.entity_type,
1193
+ arguments.field_name,
1194
+ prepared.file_size,
1195
+ )
1196
+ )
1211
1197
  upload_info = await self._upload_allocation(
1212
1198
  driver,
1213
1199
  context,
@@ -1702,6 +1688,9 @@ class AsyncShotgun:
1702
1688
  async def export_page(self, *args: object, **kwargs: object) -> str:
1703
1689
  """Export a page using the documented arguments.
1704
1690
 
1691
+ The Page or its layout must be marked API Exportable in the web UI, or
1692
+ the server rejects the export.
1693
+
1705
1694
  Args:
1706
1695
  *args: Positional values from the documented `export_page`
1707
1696
  signature.
@@ -2022,7 +2011,11 @@ class AsyncShotgun:
2022
2011
  entity,
2023
2012
  seed_entity_field,
2024
2013
  )
2025
- result = await self._call('nav_search_entity', params)
2014
+ result = await self._call(
2015
+ 'nav_search',
2016
+ params,
2017
+ _InvocationOptions(logical_method_name='nav_search_entity'),
2018
+ )
2026
2019
  return _methods.require_dictionary_list_result(result, 'nav_search_entity')
2027
2020
 
2028
2021
  async def nav_search_string(
@@ -2053,7 +2046,11 @@ class AsyncShotgun:
2053
2046
  search_string,
2054
2047
  seed_entity_field,
2055
2048
  )
2056
- result = await self._call('nav_search_string', params)
2049
+ result = await self._call(
2050
+ 'nav_search',
2051
+ params,
2052
+ _InvocationOptions(logical_method_name='nav_search_string'),
2053
+ )
2057
2054
  return _methods.require_dictionary_list_result(result, 'nav_search_string')
2058
2055
 
2059
2056
  async def note_thread_read(
@@ -2078,7 +2075,11 @@ class AsyncShotgun:
2078
2075
  policy.
2079
2076
  """
2080
2077
  params = _methods.build_note_thread_read_params(note_id, entity_fields)
2081
- result = await self._call('note_thread_read', params)
2078
+ result = await self._call(
2079
+ 'note_thread_contents',
2080
+ params,
2081
+ _InvocationOptions(logical_method_name='note_thread_read'),
2082
+ )
2082
2083
  return _methods.require_dictionary_list_result(result, 'note_thread_read')
2083
2084
 
2084
2085
  async def preferences_read(self, prefs: list[str] | None = None) -> dict[str, object]:
@@ -2357,7 +2358,8 @@ class AsyncShotgun:
2357
2358
 
2358
2359
  Supply exactly one of `thumbnail_path` and `source_entity`. A local
2359
2360
  thumbnail is uploaded to the first target before it is shared with the
2360
- remaining targets.
2361
+ remaining targets. A local thumbnail is rejected before any upload
2362
+ request if the server requires legacy form uploads.
2361
2363
 
2362
2364
  Args:
2363
2365
  entities: Nonempty list of target entities with `type` and `id`.
@@ -2374,7 +2376,8 @@ class AsyncShotgun:
2374
2376
  TypeError: If forwarded arguments do not match the upload signature
2375
2377
  or include `field_name`.
2376
2378
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2377
- or if the source selection, entities, or transport fails.
2379
+ or if the source selection, entities, or transport fails, or a
2380
+ local upload requires the legacy form route.
2378
2381
  ShotgunThumbnailNotReady: If an uploaded source stays transient.
2379
2382
  ProtocolError: If an upload endpoint returns an HTTP error.
2380
2383
  ValueError: If authenticated HTTP violates the configured origin
@@ -2647,9 +2650,8 @@ class AsyncShotgun:
2647
2650
  async def upload(self, *args: object, **kwargs: object) -> int:
2648
2651
  """Upload a file or thumbnail and return its Attachment ID.
2649
2652
 
2650
- A successful HTTP response from the legacy form-upload route still
2651
- raises `ShotgunError`; no Attachment ID is established for that route.
2652
- The form request has already been sent when this error is raised.
2653
+ A server requiring legacy form uploads raises `ShotgunError` before any
2654
+ upload request is sent. Server capability reads may occur.
2653
2655
 
2654
2656
  Args:
2655
2657
  *args: Positional arguments from the documented upload signature.
@@ -2661,8 +2663,8 @@ class AsyncShotgun:
2661
2663
  Raises:
2662
2664
  TypeError: If arguments do not match the documented signature.
2663
2665
  ShotgunError: If the file or an upload response is invalid, an RPC
2664
- fault or terminal transport failure occurs, or the legacy
2665
- form-upload route succeeds.
2666
+ fault or terminal transport failure occurs, or the server
2667
+ requires legacy form uploads.
2666
2668
  ProtocolError: If an upload endpoint returns an HTTP error.
2667
2669
  ValueError: If authenticated HTTP violates the configured origin
2668
2670
  policy.
@@ -2694,6 +2696,9 @@ class AsyncShotgun:
2694
2696
  ) -> int:
2695
2697
  """Upload a filmstrip thumbnail through `upload`.
2696
2698
 
2699
+ Servers requiring legacy form uploads are rejected before any upload
2700
+ request is sent.
2701
+
2697
2702
  Args:
2698
2703
  entity_type: Entity type receiving the thumbnail.
2699
2704
  entity_id: Numeric identifier of the receiving entity.
@@ -2706,7 +2711,8 @@ class AsyncShotgun:
2706
2711
  Raises:
2707
2712
  TypeError: If forwarded arguments do not match `upload`.
2708
2713
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2709
- or if the file, upload response, or transport fails.
2714
+ or if the file, upload response, or transport fails, or the
2715
+ server requires legacy form uploads.
2710
2716
  ProtocolError: If an upload endpoint returns an HTTP error.
2711
2717
  ValueError: If authenticated HTTP violates the configured origin
2712
2718
  policy.
@@ -2728,6 +2734,9 @@ class AsyncShotgun:
2728
2734
  ) -> int:
2729
2735
  """Upload a static thumbnail through `upload`.
2730
2736
 
2737
+ Servers requiring legacy form uploads are rejected before any upload
2738
+ request is sent.
2739
+
2731
2740
  Args:
2732
2741
  entity_type: Entity type receiving the thumbnail.
2733
2742
  entity_id: Numeric identifier of the receiving entity.
@@ -2740,7 +2749,8 @@ class AsyncShotgun:
2740
2749
  Raises:
2741
2750
  TypeError: If forwarded arguments do not match `upload`.
2742
2751
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2743
- or if the file, upload response, or transport fails.
2752
+ or if the file, upload response, or transport fails, or the
2753
+ server requires legacy form uploads.
2744
2754
  ProtocolError: If an upload endpoint returns an HTTP error.
2745
2755
  ValueError: If authenticated HTTP violates the configured origin
2746
2756
  policy.
@@ -2756,28 +2766,40 @@ class AsyncShotgun:
2756
2766
  async def user_subscriptions_create(self, users: list[dict[str, Any]]) -> bool:
2757
2767
  """Assign subscriptions to users.
2758
2768
 
2769
+ The server accepts this call from scripts and from human users with
2770
+ permission to manage subscriptions, such as members of the Admin
2771
+ permission group. It refuses other human users.
2772
+
2759
2773
  Args:
2760
- users: Subscription dictionaries containing `user_id` keys.
2774
+ users: Subscription dictionaries with a string or integer
2775
+ `humanUserId`; integers become decimal string keys and booleans
2776
+ are not accepted. Each user may appear only once.
2777
+ `subscription` is `None`, a string, or a list of strings.
2778
+ Lists are copied in order. Other entry keys are ignored.
2761
2779
 
2762
2780
  Returns:
2763
- Whether the server created the subscriptions.
2781
+ `True` when every per-user report is `None`, meaning the server
2782
+ reported no problem; `False` when any user has a non-`None` report.
2764
2783
 
2765
2784
  Raises:
2766
2785
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2767
- or if arguments or the server result are invalid.
2786
+ or if `users` contains invalid or duplicate entries, or the
2787
+ server result is not a mapping with string keys.
2768
2788
  ProtocolError: If a server request returns an HTTP error.
2769
2789
  ValueError: If authenticated HTTP violates the configured origin
2770
2790
  policy.
2771
2791
  """
2772
2792
  params = _methods.build_user_subscriptions_create_params(users)
2773
2793
  result = await self._call('user_subscriptions_create', params)
2774
- return _crud.require_boolean_result(result, 'user_subscriptions_create')
2794
+ return _methods.require_user_subscriptions_create_result(result)
2775
2795
 
2776
2796
  async def user_subscriptions_read(self) -> list[dict[str, object]]:
2777
- """List the current user's subscriptions.
2797
+ """List user subscriptions in server order.
2778
2798
 
2779
2799
  Returns:
2780
- User subscription dictionaries returned by the server.
2800
+ Subscription entries in server order, each with an unchanged string
2801
+ `humanUserId` and a `subscription` of `None`, a string, or a copied
2802
+ list of strings.
2781
2803
 
2782
2804
  Raises:
2783
2805
  ShotgunError: If an RPC fault or terminal transport failure occurs,
@@ -2787,7 +2809,7 @@ class AsyncShotgun:
2787
2809
  policy.
2788
2810
  """
2789
2811
  result = await self._call('user_subscriptions_read', None)
2790
- return _methods.require_dictionary_list_result(result, 'user_subscriptions_read')
2812
+ return _methods.require_user_subscriptions_result(result)
2791
2813
 
2792
2814
  async def work_schedule_read(
2793
2815
  self,
shotgun_api3/_client.py CHANGED
@@ -136,10 +136,8 @@ _CONSTRUCTOR_PARAMETERS = (
136
136
  )
137
137
  _UPLOAD_DOCSTRING = """Upload a file or thumbnail and return its Attachment ID.
138
138
 
139
- A successful HTTP response from the legacy form-upload route still
140
- raises `ShotgunError`; no Attachment ID is established for that
141
- route. The form request has already been sent when this error is
142
- raised.
139
+ A server requiring legacy form uploads raises `ShotgunError` before
140
+ any upload request is sent. Server capability reads may occur.
143
141
 
144
142
  Args:
145
143
  entity_type: Entity type receiving the upload.
@@ -156,8 +154,8 @@ Returns:
156
154
  Raises:
157
155
  TypeError: If arguments do not match the documented signature.
158
156
  ShotgunError: If the file or an upload response is invalid, an RPC
159
- fault or terminal transport failure occurs, or the legacy
160
- form-upload route succeeds.
157
+ fault or terminal transport failure occurs, or the server
158
+ requires legacy form uploads.
161
159
  ProtocolError: If an upload endpoint returns an HTTP error.
162
160
  ValueError: If authenticated HTTP violates the configured origin policy.
163
161
  """
@@ -881,28 +879,6 @@ class Shotgun:
881
879
  lambda _text, _headers: None,
882
880
  )
883
881
 
884
- @staticmethod
885
- def _upload_form(
886
- driver: Driver,
887
- context: RequestContext,
888
- prepared: _files.PreparedUpload,
889
- ) -> Never:
890
- """Send the observed form request without guessing a success result."""
891
- if context.canonical_origin is None:
892
- raise ValueError('server parameters must be configured before upload I/O')
893
- exchange = _files.build_form_upload_exchange(
894
- context.canonical_origin,
895
- context.user_agent,
896
- prepared,
897
- context.auth,
898
- )
899
-
900
- def reject_success(_text: str, _headers: Mapping[str, str]) -> Never:
901
- raise ShotgunError('form upload success response is not established')
902
-
903
- _execute_upload_exchange(driver, context, exchange, reject_success)
904
- raise AssertionError('form upload response parser returned')
905
-
906
882
  @staticmethod
907
883
  def _upload_link(
908
884
  driver: Driver,
@@ -999,14 +975,14 @@ class Shotgun:
999
975
  """Execute one prepared upload through a captured driver lease."""
1000
976
  arguments = prepared.options.arguments
1001
977
  capabilities = self._ensure_readiness(context, driver)
1002
- upload_path = _files.choose_upload_path(
1003
- capabilities.server_info,
1004
- arguments.entity_type,
1005
- arguments.field_name,
1006
- prepared.file_size,
978
+ upload_path = _files.require_supported_upload_path(
979
+ _files.choose_upload_path(
980
+ capabilities.server_info,
981
+ arguments.entity_type,
982
+ arguments.field_name,
983
+ prepared.file_size,
984
+ )
1007
985
  )
1008
- if upload_path == 'form':
1009
- return self._upload_form(driver, context, prepared)
1010
986
  multipart = upload_path == 'multipart'
1011
987
  upload_info = self._upload_allocation(driver, context, prepared, multipart)
1012
988
  if multipart:
@@ -1030,7 +1006,16 @@ class Shotgun:
1030
1006
  prepared: _files.PreparedUpload,
1031
1007
  ) -> int:
1032
1008
  """Execute the exact direct-storage thumbnail workflow."""
1033
- self._ensure_readiness(context, driver)
1009
+ capabilities = self._ensure_readiness(context, driver)
1010
+ arguments = prepared.options.arguments
1011
+ _files.require_supported_upload_path(
1012
+ _files.choose_upload_path(
1013
+ capabilities.server_info,
1014
+ arguments.entity_type,
1015
+ arguments.field_name,
1016
+ prepared.file_size,
1017
+ )
1018
+ )
1034
1019
  upload_info = self._upload_allocation(driver, context, prepared, multipart=False)
1035
1020
  self._upload_direct(driver, context, prepared, upload_info)
1036
1021
  return self._upload_link(driver, context, prepared, upload_info)
@@ -1480,6 +1465,9 @@ class Shotgun:
1480
1465
  def export_page(self, *args: object, **kwargs: object) -> str:
1481
1466
  """Export a page using the documented arguments.
1482
1467
 
1468
+ The Page or its layout must be marked API Exportable in the web UI, or
1469
+ the server rejects the export.
1470
+
1483
1471
  Args:
1484
1472
  *args: Positional values from the documented `export_page`
1485
1473
  signature.
@@ -1827,7 +1815,11 @@ class Shotgun:
1827
1815
  seed_entity_field,
1828
1816
  )
1829
1817
  return _methods.require_dictionary_list_result(
1830
- self._call('nav_search_entity', params),
1818
+ self._call(
1819
+ 'nav_search',
1820
+ params,
1821
+ invocation_options=_InvocationOptions(logical_method_name='nav_search_entity'),
1822
+ ),
1831
1823
  'nav_search_entity',
1832
1824
  )
1833
1825
 
@@ -1860,7 +1852,11 @@ class Shotgun:
1860
1852
  seed_entity_field,
1861
1853
  )
1862
1854
  return _methods.require_dictionary_list_result(
1863
- self._call('nav_search_string', params),
1855
+ self._call(
1856
+ 'nav_search',
1857
+ params,
1858
+ invocation_options=_InvocationOptions(logical_method_name='nav_search_string'),
1859
+ ),
1864
1860
  'nav_search_string',
1865
1861
  )
1866
1862
 
@@ -1887,7 +1883,11 @@ class Shotgun:
1887
1883
  """
1888
1884
  params = _methods.build_note_thread_read_params(note_id, entity_fields)
1889
1885
  return _methods.require_dictionary_list_result(
1890
- self._call('note_thread_read', params),
1886
+ self._call(
1887
+ 'note_thread_contents',
1888
+ params,
1889
+ invocation_options=_InvocationOptions(logical_method_name='note_thread_read'),
1890
+ ),
1891
1891
  'note_thread_read',
1892
1892
  )
1893
1893
 
@@ -2185,7 +2185,8 @@ class Shotgun:
2185
2185
 
2186
2186
  Supply exactly one of `thumbnail_path` and `source_entity`. A local
2187
2187
  thumbnail is uploaded to the first target before it is shared with the
2188
- remaining targets.
2188
+ remaining targets. A local thumbnail is rejected before any upload
2189
+ request if the server requires legacy form uploads.
2189
2190
 
2190
2191
  Args:
2191
2192
  entities: Nonempty list of target entities with `type` and `id`.
@@ -2202,7 +2203,8 @@ class Shotgun:
2202
2203
  TypeError: If forwarded arguments do not match the upload signature
2203
2204
  or include `field_name`.
2204
2205
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2205
- or if the source selection, entities, or transport fails.
2206
+ or if the source selection, entities, or transport fails, or a
2207
+ local upload requires the legacy form route.
2206
2208
  ShotgunThumbnailNotReady: If an uploaded source stays transient.
2207
2209
  ProtocolError: If an upload endpoint returns an HTTP error.
2208
2210
  ValueError: If authenticated HTTP violates the configured origin
@@ -2468,9 +2470,8 @@ class Shotgun:
2468
2470
  def upload(self, *args: object, **kwargs: object) -> int:
2469
2471
  """Upload a file or thumbnail and return its Attachment ID.
2470
2472
 
2471
- A successful HTTP response from the legacy form-upload route still
2472
- raises `ShotgunError`; no Attachment ID is established for that route.
2473
- The form request has already been sent when this error is raised.
2473
+ A server requiring legacy form uploads raises `ShotgunError` before any
2474
+ upload request is sent. Server capability reads may occur.
2474
2475
 
2475
2476
  Args:
2476
2477
  *args: Positional arguments from the documented upload signature;
@@ -2483,8 +2484,8 @@ class Shotgun:
2483
2484
  Raises:
2484
2485
  TypeError: If arguments do not match the documented signature.
2485
2486
  ShotgunError: If the file or an upload response is invalid, an RPC
2486
- fault or terminal transport failure occurs, or the legacy
2487
- form-upload route succeeds.
2487
+ fault or terminal transport failure occurs, or the server
2488
+ requires legacy form uploads.
2488
2489
  ProtocolError: If an upload endpoint returns an HTTP error.
2489
2490
  ValueError: If authenticated HTTP violates the configured origin
2490
2491
  policy.
@@ -2516,6 +2517,9 @@ class Shotgun:
2516
2517
  ) -> int:
2517
2518
  """Upload a filmstrip thumbnail through `upload`.
2518
2519
 
2520
+ Servers requiring legacy form uploads are rejected before any upload
2521
+ request is sent.
2522
+
2519
2523
  Args:
2520
2524
  entity_type: Entity type receiving the thumbnail.
2521
2525
  entity_id: Numeric identifier of the receiving entity.
@@ -2528,7 +2532,8 @@ class Shotgun:
2528
2532
  Raises:
2529
2533
  TypeError: If forwarded arguments do not match `upload`.
2530
2534
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2531
- or if the file, upload response, or transport fails.
2535
+ or if the file, upload response, or transport fails, or the
2536
+ server requires legacy form uploads.
2532
2537
  ProtocolError: If an upload endpoint returns an HTTP error.
2533
2538
  ValueError: If authenticated HTTP violates the configured origin
2534
2539
  policy.
@@ -2550,6 +2555,9 @@ class Shotgun:
2550
2555
  ) -> int:
2551
2556
  """Upload a static thumbnail through `upload`.
2552
2557
 
2558
+ Servers requiring legacy form uploads are rejected before any upload
2559
+ request is sent.
2560
+
2553
2561
  Args:
2554
2562
  entity_type: Entity type receiving the thumbnail.
2555
2563
  entity_id: Numeric identifier of the receiving entity.
@@ -2562,7 +2570,8 @@ class Shotgun:
2562
2570
  Raises:
2563
2571
  TypeError: If forwarded arguments do not match `upload`.
2564
2572
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2565
- or if the file, upload response, or transport fails.
2573
+ or if the file, upload response, or transport fails, or the
2574
+ server requires legacy form uploads.
2566
2575
  ProtocolError: If an upload endpoint returns an HTTP error.
2567
2576
  ValueError: If authenticated HTTP violates the configured origin
2568
2577
  policy.
@@ -2578,44 +2587,53 @@ class Shotgun:
2578
2587
  def user_subscriptions_create(self, users: list[dict[str, Any]]) -> bool:
2579
2588
  """Assign subscriptions to users.
2580
2589
 
2590
+ The server accepts this call from scripts and from human users with
2591
+ permission to manage subscriptions, such as members of the Admin
2592
+ permission group. It refuses other human users.
2593
+
2581
2594
  Args:
2582
- users: Subscription dictionaries that each contain a `user_id` key.
2595
+ users: Subscription dictionaries with a string or integer
2596
+ `humanUserId`; integers become decimal string keys and booleans
2597
+ are not accepted. Each user may appear only once.
2598
+ `subscription` is `None`, a string, or a list of strings.
2599
+ Lists are copied in order. Other entry keys are ignored.
2583
2600
 
2584
2601
  Returns:
2585
- Whether the server created the subscriptions.
2602
+ `True` when every per-user report is `None`, meaning the server
2603
+ reported no problem; `False` when any user has a non-`None` report.
2586
2604
 
2587
2605
  Raises:
2588
2606
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2589
- or if `users` is not a list of dictionaries that each contain a
2590
- `user_id` key, or the server result is not a boolean.
2607
+ or if `users` contains invalid or duplicate entries, or the
2608
+ server result is not a mapping with string keys.
2591
2609
  ProtocolError: If a server request returns an HTTP error.
2592
2610
  ValueError: If authenticated HTTP violates the configured origin
2593
2611
  policy.
2594
2612
  """
2595
- return _crud.require_boolean_result(
2613
+ return _methods.require_user_subscriptions_create_result(
2596
2614
  self._call(
2597
2615
  'user_subscriptions_create',
2598
2616
  _methods.build_user_subscriptions_create_params(users),
2599
2617
  ),
2600
- 'user_subscriptions_create',
2601
2618
  )
2602
2619
 
2603
2620
  def user_subscriptions_read(self) -> list[dict[str, object]]:
2604
- """List the current user's subscriptions.
2621
+ """List user subscriptions in server order.
2605
2622
 
2606
2623
  Returns:
2607
- User subscription dictionaries returned by the server.
2624
+ Subscription entries in server order, each with an unchanged string
2625
+ `humanUserId` and a `subscription` of `None`, a string, or a copied
2626
+ list of strings.
2608
2627
 
2609
2628
  Raises:
2610
2629
  ShotgunError: If an RPC fault or terminal transport failure occurs,
2611
- or if the server result is not a list of dictionaries.
2630
+ or if the server result is invalid.
2612
2631
  ProtocolError: If a server request returns an HTTP error.
2613
2632
  ValueError: If authenticated HTTP violates the configured origin
2614
2633
  policy.
2615
2634
  """
2616
- return _methods.require_dictionary_list_result(
2635
+ return _methods.require_user_subscriptions_result(
2617
2636
  self._call('user_subscriptions_read', None),
2618
- 'user_subscriptions_read',
2619
2637
  )
2620
2638
 
2621
2639
  def work_schedule_read(
shotgun_api3/_files.py CHANGED
@@ -10,7 +10,7 @@ import urllib.parse
10
10
  import urllib.request
11
11
  import uuid
12
12
  from collections.abc import Callable, Generator, Iterator, Mapping, Sequence
13
- from contextlib import ExitStack, contextmanager
13
+ from contextlib import contextmanager
14
14
  from pathlib import Path
15
15
  from typing import BinaryIO, Literal, TypeAlias, TypedDict, cast
16
16
 
@@ -35,7 +35,6 @@ _MULTIPART_CHUNK_SIZE = 20_000_000
35
35
  _Origin: TypeAlias = tuple[str, str, int]
36
36
  _PART_LINK_FIELD_COUNT = 2
37
37
  _THUMBNAIL_UPLOAD_FIELDS = ('image', 'filmstrip_image')
38
- _UPLOAD_FILE = '/upload/upload_file'
39
38
 
40
39
 
41
40
  @dataclasses.dataclass(frozen=True)
@@ -246,17 +245,6 @@ class _ExactOriginCookiePolicy(http.cookiejar.DefaultCookiePolicy):
246
245
  )
247
246
 
248
247
 
249
- def _build_form_upload_fields(prepared: PreparedUpload) -> dict[str, str]:
250
- """Build the non-authenticated legacy form-upload fields."""
251
- arguments = prepared.options.arguments
252
- return {
253
- 'entity_type': arguments.entity_type,
254
- 'entity_id': str(arguments.entity_id),
255
- 'field_name': arguments.field_name or '',
256
- 'display_name': arguments.display_name or prepared.filename,
257
- }
258
-
259
-
260
248
  def _entity_reference(entity: Mapping[str, object]) -> str:
261
249
  """Serialize one entity as the upload endpoint reference grammar."""
262
250
  entity_type = entity.get('type')
@@ -295,48 +283,6 @@ def _iter_file(
295
283
  remaining -= len(chunk)
296
284
 
297
285
 
298
- def _multipart_content_length(
299
- plan: _protocol.MultipartPlan,
300
- file_sizes: Mapping[str, int],
301
- ) -> int:
302
- """Return the exact byte length of a multipart plan."""
303
- return sum(
304
- file_sizes[segment.name] if isinstance(segment, _protocol.FileSlot) else len(segment)
305
- for segment in plan.segments
306
- )
307
-
308
-
309
- def _pump_multipart(
310
- plan: _protocol.MultipartPlan,
311
- opened: Mapping[str, BinaryIO],
312
- chunk_size: int = 65536,
313
- ) -> Generator[bytes, None, None]:
314
- """Yield multipart framing while reading file slots incrementally."""
315
- if chunk_size <= 0:
316
- raise ValueError('multipart chunk size must be positive')
317
- for segment in plan.segments:
318
- if isinstance(segment, _protocol.FileSlot):
319
- handle = opened[segment.name]
320
- while chunk := handle.read(chunk_size):
321
- yield chunk
322
- else:
323
- yield segment
324
-
325
-
326
- def _pump_multipart_from_disk(
327
- plan: _protocol.MultipartPlan,
328
- paths: Mapping[str, str],
329
- chunk_size: int = 65536,
330
- ) -> Generator[bytes, None, None]:
331
- """Pump multipart framing through lazily opened disk files."""
332
- with ExitStack() as stack:
333
- opened: dict[str, BinaryIO] = {}
334
- for segment in plan.segments:
335
- if isinstance(segment, _protocol.FileSlot) and segment.name not in opened:
336
- opened[segment.name] = stack.enter_context(Path(paths[segment.name]).open('rb'))
337
- yield from _pump_multipart(plan, opened, chunk_size)
338
-
339
-
340
286
  def _requires_direct_upload(
341
287
  server_info: Mapping[str, object],
342
288
  entity_type: str,
@@ -694,53 +640,6 @@ def build_download_redirect(
694
640
  return build_download_request(context, location, {'user-agent': context.user_agent})
695
641
 
696
642
 
697
- def build_form_upload_exchange(
698
- canonical_origin: str,
699
- user_agent: str,
700
- prepared: PreparedUpload,
701
- auth: Mapping[str, object],
702
- chunk_size: int = 65536,
703
- ) -> UploadExchange:
704
- """Plan one restartable legacy multipart form upload.
705
-
706
- The body can be rebuilt, but the operation is not replay-safe after
707
- transmission.
708
-
709
- Args:
710
- canonical_origin: Captured site origin.
711
- user_agent: Captured user-agent header value.
712
- prepared: Validated upload metadata and arguments.
713
- auth: Captured authentication form fields.
714
- chunk_size: Maximum file bytes read per iteration.
715
-
716
- Returns:
717
- Complete restartable form-upload exchange.
718
- """
719
- fields = add_auth_fields(_build_form_upload_fields(prepared), auth)
720
- slot = _protocol.FileSlot(
721
- name='file',
722
- filename=prepared.filename,
723
- content_type='application/octet-stream',
724
- )
725
- plan = _protocol.build_multipart_plan(fields, [slot])
726
- path = prepared.options.arguments.path
727
- return UploadExchange(
728
- body_factory=lambda: _pump_multipart_from_disk(
729
- plan,
730
- {'file': path},
731
- chunk_size,
732
- ),
733
- headers={
734
- 'content-length': str(_multipart_content_length(plan, {'file': prepared.file_size})),
735
- 'content-type': plan.content_type,
736
- 'user-agent': user_agent,
737
- },
738
- method='POST',
739
- operation=SHARE_THUMBNAIL_OPERATION,
740
- url=f'{canonical_origin}{_UPLOAD_FILE}',
741
- )
742
-
743
-
744
643
  def build_upload_endpoint_exchange(
745
644
  canonical_origin: str | None,
746
645
  user_agent: str,
@@ -1018,6 +917,27 @@ def prepare_upload(options: UploadOptions) -> PreparedUpload:
1018
917
  return PreparedUpload(filename=path.name, file_size=file_size, options=options)
1019
918
 
1020
919
 
920
+ def require_supported_upload_path(
921
+ upload_path: Literal['form', 'direct', 'multipart'],
922
+ ) -> Literal['direct', 'multipart']:
923
+ """Reject the unsupported legacy form route before upload I/O.
924
+
925
+ Args:
926
+ upload_path: Route selected from server capabilities.
927
+
928
+ Returns:
929
+ The selected supported route.
930
+
931
+ Raises:
932
+ ShotgunError: If the server requires legacy form uploads.
933
+ """
934
+ if upload_path == 'form':
935
+ raise ShotgunError(
936
+ 'server requires legacy form uploads, which this client does not support'
937
+ )
938
+ return upload_path
939
+
940
+
1021
941
  def resolve_attachment_url(attachment: object, base_url: str) -> str | None:
1022
942
  """Resolve a supported attachment reference to its download URL.
1023
943
 
shotgun_api3/_methods.py CHANGED
@@ -3,7 +3,7 @@
3
3
  import dataclasses
4
4
  import datetime
5
5
  import inspect
6
- from collections.abc import Callable
6
+ from collections.abc import Callable, Mapping
7
7
  from typing import Any, NotRequired, TypedDict, cast
8
8
 
9
9
  from . import _crud, _protocol
@@ -64,6 +64,9 @@ _ACTIVITY_STREAM_READ_SIGNATURE = inspect.Signature(
64
64
  )
65
65
  _EXPORT_PAGE_DOCSTRING = """Export a page using the documented arguments.
66
66
 
67
+ The Page or its layout must be marked API Exportable in the web UI,
68
+ or the server rejects the export.
69
+
67
70
  Args:
68
71
  page_id: Numeric page identifier.
69
72
  format: Required nonempty export format name.
@@ -328,6 +331,18 @@ def _entity(value: object) -> object:
328
331
  return _protocol.optimize_entity(value)
329
332
 
330
333
 
334
+ def _is_subscription_value(value: object) -> bool:
335
+ """Check the supported subscription value forms."""
336
+ return (
337
+ value is None
338
+ or isinstance(value, str)
339
+ or (
340
+ isinstance(value, list)
341
+ and all(isinstance(item, str) for item in cast('list[object]', value))
342
+ )
343
+ )
344
+
345
+
331
346
  def _project_entity(value: object) -> object:
332
347
  """Optimize an optional project entity."""
333
348
  return None if value is None else _entity(value)
@@ -920,11 +935,38 @@ def build_user_subscriptions_create_params(users: object) -> dict[str, object]:
920
935
  users,
921
936
  'user_subscriptions_create users must be a list',
922
937
  )
923
- message = 'user_subscriptions_create users must contain dictionaries with a user_id key'
938
+ entry_message = (
939
+ 'user_subscriptions_create users must contain dictionaries with '
940
+ 'humanUserId and subscription keys'
941
+ )
942
+ params: dict[str, object] = {}
924
943
  for member in validated:
925
- if not isinstance(member, dict) or 'user_id' not in member:
926
- raise ShotgunError(message)
927
- return {'users': cast('list[dict[str, object]]', validated)}
944
+ if not isinstance(member, dict):
945
+ raise ShotgunError(entry_message)
946
+ entry = cast('dict[str, object]', member)
947
+ if 'humanUserId' not in entry or 'subscription' not in entry:
948
+ raise ShotgunError(entry_message)
949
+ user_id = entry['humanUserId']
950
+ if isinstance(user_id, bool) or not isinstance(user_id, (str, int)):
951
+ raise ShotgunError(
952
+ 'user_subscriptions_create users must contain string or integer humanUserId values'
953
+ )
954
+ if not _is_subscription_value(entry['subscription']):
955
+ raise ShotgunError(
956
+ 'user_subscriptions_create users must contain valid subscription values'
957
+ )
958
+ key = str(user_id)
959
+ if key in params:
960
+ raise ShotgunError(
961
+ 'user_subscriptions_create users must not contain duplicate user IDs'
962
+ )
963
+ subscription = entry['subscription']
964
+ params[key] = (
965
+ cast('list[str]', subscription).copy()
966
+ if isinstance(subscription, list)
967
+ else subscription
968
+ )
969
+ return params
928
970
 
929
971
 
930
972
  def build_work_schedule_read_params(
@@ -1124,6 +1166,57 @@ def require_string_result(value: object, method_name: str) -> str:
1124
1166
  return value
1125
1167
 
1126
1168
 
1169
+ def require_user_subscriptions_create_result(value: object) -> bool:
1170
+ """Interpret a mapping of per-user subscription assignment reports.
1171
+
1172
+ Args:
1173
+ value: Raw result returned by the server.
1174
+
1175
+ Returns:
1176
+ `True` if every report is `None`, otherwise `False`.
1177
+
1178
+ Raises:
1179
+ ShotgunError: If the result is not a mapping with string keys.
1180
+ """
1181
+ message = 'user_subscriptions_create returned an invalid result mapping'
1182
+ if not isinstance(value, Mapping):
1183
+ raise ShotgunError(message)
1184
+ reports = cast('Mapping[object, object]', value)
1185
+ if any(not isinstance(key, str) for key in reports):
1186
+ raise ShotgunError(message)
1187
+ return all(report is None for report in reports.values())
1188
+
1189
+
1190
+ def require_user_subscriptions_result(value: object) -> list[dict[str, object]]:
1191
+ """Convert a subscription mapping to ordered public entries.
1192
+
1193
+ Args:
1194
+ value: Raw subscription mapping returned by the server.
1195
+
1196
+ Returns:
1197
+ Entries with unchanged string `humanUserId` and `subscription` values
1198
+ of `None`, string, or list of strings. Lists are copied.
1199
+
1200
+ Raises:
1201
+ ShotgunError: If the result is not a valid subscription mapping.
1202
+ """
1203
+ if not isinstance(value, Mapping):
1204
+ raise ShotgunError('user_subscriptions_read did not return a dictionary')
1205
+ subscriptions: list[dict[str, object]] = []
1206
+ for user_id, subscription in cast('Mapping[object, object]', value).items():
1207
+ if not isinstance(user_id, str) or not user_id:
1208
+ raise ShotgunError('user_subscriptions_read returned an invalid user ID')
1209
+ if not _is_subscription_value(subscription):
1210
+ raise ShotgunError('user_subscriptions_read returned an invalid subscription')
1211
+ public_subscription = (
1212
+ cast('list[str]', subscription).copy()
1213
+ if isinstance(subscription, list)
1214
+ else subscription
1215
+ )
1216
+ subscriptions.append({'humanUserId': user_id, 'subscription': public_subscription})
1217
+ return subscriptions
1218
+
1219
+
1127
1220
  def schema_alias_error(entity_type: object) -> ShotgunError:
1128
1221
  """Build the error raised by the deprecated `schema` alias.
1129
1222
 
shotgun_api3/_protocol.py CHANGED
@@ -7,7 +7,6 @@ import json
7
7
  import math
8
8
  import re
9
9
  import sys
10
- import uuid as _uuid
11
10
  from collections.abc import Callable, Mapping, Sequence
12
11
  from datetime import UTC
13
12
  from email.utils import parsedate_to_datetime
@@ -107,7 +106,6 @@ _IMF_FIXDATE_PATTERN = re.compile(
107
106
  _JITTER_BYTES = 8
108
107
  _JITTER_RANGE = 1 << (_JITTER_BYTES * 8)
109
108
  _MIN_CONDITION_PARTS = 2
110
- _MULTIPART_BOUNDARY_PATTERN = re.compile(r"[A-Za-z0-9'+._-]{1,70}")
111
109
  _OPTIMIZABLE_RELATIONS = frozenset({'is', 'is_not', 'in', 'not_in'})
112
110
  _RETRYABLE_STATUSES = frozenset({502, 503, 504})
113
111
  _RETRY_AFTER_TOO_LARGE: Final = 'too-large'
@@ -209,34 +207,6 @@ class Failure:
209
207
  retry_after: _RetryAfter
210
208
 
211
209
 
212
- @dataclasses.dataclass(frozen=True)
213
- class FileSlot:
214
- """Placeholder for file bytes inside a multipart plan.
215
-
216
- Attributes:
217
- name: Multipart form field name.
218
- filename: Filename exposed in the multipart part.
219
- content_type: Media type exposed in the multipart part.
220
- """
221
-
222
- name: str
223
- filename: str
224
- content_type: str
225
-
226
-
227
- @dataclasses.dataclass(frozen=True)
228
- class MultipartPlan:
229
- """Byte segments and file slots forming a multipart/form-data body.
230
-
231
- Attributes:
232
- content_type: Multipart content type including its boundary.
233
- segments: Ordered encoded segments and file placeholders.
234
- """
235
-
236
- content_type: str
237
- segments: list[bytes | FileSlot]
238
-
239
-
240
210
  @dataclasses.dataclass(frozen=True)
241
211
  class OperationDescriptor:
242
212
  """Describe the replay properties of one logical operation.
@@ -365,11 +335,6 @@ def _entity_optimization_disabled() -> bool:
365
335
  return cast('bool', module.SHOTGUN_API_DISABLE_ENTITY_OPTIMIZATION)
366
336
 
367
337
 
368
- def _escape_disposition(value: str) -> str:
369
- """Make a value safe inside a Content-Disposition quoted-string."""
370
- return value.replace('\\', '\\\\').replace('"', '%22').replace('\r', '').replace('\n', '')
371
-
372
-
373
338
  def _explicit_delay_is_safe(delay: float) -> bool:
374
339
  """Return whether a delay stays within the bounded retry policy."""
375
340
  return math.isfinite(delay) and 0 <= delay <= _BACKOFF_CAP
@@ -491,13 +456,6 @@ def _parse_retry_after(headers: Mapping[str, str], now: float) -> _RetryAfter:
491
456
  return delay
492
457
 
493
458
 
494
- def _validate_multipart_boundary(boundary: str) -> str:
495
- """Return a boundary safe for multipart framing and an unquoted header."""
496
- if not _MULTIPART_BOUNDARY_PATTERN.fullmatch(boundary):
497
- raise ValueError('multipart boundary must be 1 to 70 safe ASCII characters')
498
- return boundary
499
-
500
-
501
459
  def _validated_nonnegative_integer(value: object, name: str) -> int:
502
460
  """Return a non-boolean, nonnegative integer paging value."""
503
461
  if not isinstance(value, int) or isinstance(value, bool) or value < 0:
@@ -615,57 +573,6 @@ def classify_endpoint(
615
573
  )
616
574
 
617
575
 
618
- def build_multipart_plan(
619
- fields: dict[str, str],
620
- files: list[FileSlot],
621
- boundary: str | None = None,
622
- ) -> MultipartPlan:
623
- """Build a pure multipart/form-data framing plan.
624
-
625
- Args:
626
- fields: Text form fields in multipart emission order.
627
- files: File placeholders in multipart emission order.
628
- boundary: Explicit boundary of 1-70 ASCII letters, digits, apostrophes,
629
- plus signs, periods, underscores, or hyphens; `None` generates one.
630
-
631
- Returns:
632
- A content type and ordered framing segments for the multipart body.
633
-
634
- Raises:
635
- ValueError: If an explicit boundary is empty or unsafe for unquoted
636
- use.
637
- """
638
- multipart_boundary = (
639
- _uuid.uuid4().hex if boundary is None else _validate_multipart_boundary(boundary)
640
- )
641
- segments: list[bytes | FileSlot] = []
642
- for name, value in fields.items():
643
- segments.append(
644
- (
645
- f'--{multipart_boundary}\r\nContent-Disposition: form-data; '
646
- f'name="{_escape_disposition(name)}"'
647
- f'\r\n\r\n{value}\r\n'
648
- ).encode()
649
- )
650
- for slot in files:
651
- segments.append(
652
- (
653
- f'--{multipart_boundary}\r\nContent-Disposition: form-data; '
654
- f'name="{_escape_disposition(slot.name)}"; '
655
- f'filename="{_escape_disposition(slot.filename)}"'
656
- f'\r\nContent-Type: {slot.content_type}'
657
- '\r\n\r\n'
658
- ).encode()
659
- )
660
- segments.append(slot)
661
- segments.append(b'\r\n')
662
- segments.append(f'--{multipart_boundary}--\r\n'.encode())
663
- return MultipartPlan(
664
- content_type=f'multipart/form-data; boundary={multipart_boundary}',
665
- segments=segments,
666
- )
667
-
668
-
669
576
  def classify(
670
577
  method_name: str,
671
578
  normalized_params: object,
shotgun_api3/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Package version shared by public exports and compatibility metadata."""
2
2
 
3
- PACKAGE_VERSION = '0.1.1'
3
+ PACKAGE_VERSION = '0.1.2'