Office365-REST-Python-Client 3.1.0__py3-none-any.whl → 3.2.0__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.
Files changed (245) hide show
  1. office365/communications/callrecords/call_record.py +18 -0
  2. office365/communications/calls/call.py +3 -0
  3. office365/communications/presences/presence.py +3 -0
  4. office365/communications/quotas.py +66 -0
  5. office365/communications/virtualevents/virtual_event.py +3 -0
  6. office365/directory/domains/domain.py +3 -0
  7. office365/directory/licenses/subscribed_sku.py +3 -0
  8. office365/directory/objects/object.py +3 -0
  9. office365/directory/quotas.py +34 -0
  10. office365/entity_collection.py +2 -2
  11. office365/graph_client.py +46 -2
  12. office365/limits.py +66 -0
  13. office365/migration/__init__.py +6 -1
  14. office365/migration/adapters/__init__.py +14 -4
  15. office365/migration/adapters/filesystem.py +4 -1
  16. office365/migration/assessment/export.py +5 -5
  17. office365/migration/assessment/issue.py +19 -0
  18. office365/migration/assessment/report.py +11 -0
  19. office365/migration/assessment/scanners/base.py +37 -49
  20. office365/migration/base.py +33 -8
  21. office365/migration/checkpoint.py +1 -1
  22. office365/migration/outlook/assessor.py +4 -4
  23. office365/migration/package/__init__.py +82 -0
  24. office365/migration/package/builder.py +603 -0
  25. office365/migration/package/crypto.py +54 -0
  26. office365/migration/package/manifest_xml.py +495 -0
  27. office365/migration/package/staging.py +180 -0
  28. office365/migration/report.py +7 -2
  29. office365/migration/report_io.py +79 -0
  30. office365/migration/runner.py +117 -10
  31. office365/migration/server_job.py +126 -14
  32. office365/migration/session.py +132 -49
  33. office365/migration/settings.py +67 -0
  34. office365/migration/sharepoint/__init__.py +2 -0
  35. office365/migration/sharepoint/adapters.py +207 -41
  36. office365/migration/sharepoint/assessor.py +45 -9
  37. office365/migration/sharepoint/options.py +57 -0
  38. office365/migration/sharepoint/package_target.py +209 -0
  39. office365/migration/sharepoint/registry.py +22 -0
  40. office365/migration/sharepoint/resolvers.py +96 -0
  41. office365/migration/sharepoint/scanners/__init__.py +29 -0
  42. office365/migration/sharepoint/scanners/browser_file_handling.py +55 -0
  43. office365/migration/sharepoint/scanners/checked_out_files.py +57 -0
  44. office365/migration/sharepoint/scanners/file_versions.py +56 -0
  45. office365/migration/sharepoint/scanners/files.py +5 -2
  46. office365/migration/sharepoint/scanners/large_excel_files.py +56 -0
  47. office365/migration/sharepoint/scanners/large_lists.py +58 -0
  48. office365/migration/sharepoint/scanners/large_sites.py +4 -3
  49. office365/migration/sharepoint/scanners/long_onedrive_urls.py +52 -0
  50. office365/migration/sharepoint/scanners/lookup.py +38 -0
  51. office365/migration/sharepoint/scanners/paths.py +9 -4
  52. office365/migration/sharepoint/scanners/permissions.py +20 -2
  53. office365/migration/sharepoint/scanners/smat.py +51 -0
  54. office365/migration/sharepoint/scanners/summary.py +1 -0
  55. office365/migration/sharepoint/scanners/thicket_folders.py +38 -0
  56. office365/migration/sharepoint/scanners/unsupported_site_templates.py +90 -0
  57. office365/migration/sharepoint/tenant_assessor.py +5 -3
  58. office365/migration/sharepoint/transfer.py +4 -2
  59. office365/migration/tasks.py +121 -0
  60. office365/migration/validators.py +2 -19
  61. office365/onedrive/driveitems/driveItem.py +116 -12
  62. office365/onedrive/termstore/groups/collection.py +2 -9
  63. office365/onedrive/termstore/sets/collection.py +2 -9
  64. office365/onedrive/termstore/terms/collection.py +3 -10
  65. office365/onedrive/workbooks/workbook.py +7 -0
  66. office365/outlook/contacts/folders/collection.py +4 -13
  67. office365/runtime/auth/authentication_context.py +5 -0
  68. office365/runtime/client_object.py +18 -0
  69. office365/runtime/client_object_collection.py +75 -245
  70. office365/runtime/client_request.py +77 -1
  71. office365/runtime/client_request_exception.py +127 -39
  72. office365/runtime/client_runtime_context.py +79 -8
  73. office365/runtime/converters/csv_writer.py +10 -4
  74. office365/runtime/converters/dataframe.py +84 -4
  75. office365/runtime/converters/json_file.py +12 -3
  76. office365/runtime/converters/parquet.py +67 -0
  77. office365/runtime/converters/records.py +31 -12
  78. office365/runtime/converters/registry.py +185 -0
  79. office365/runtime/converters/scalars.py +31 -0
  80. office365/runtime/converters/sql.py +69 -0
  81. office365/runtime/converters/streamers.py +105 -0
  82. office365/runtime/converters/streams.py +42 -0
  83. office365/runtime/converters/upsert.py +101 -0
  84. office365/runtime/exceptions.py +105 -0
  85. office365/runtime/http/prefer.py +26 -0
  86. office365/runtime/http/throttling.py +258 -56
  87. office365/runtime/imports.py +571 -0
  88. office365/runtime/limits.py +455 -0
  89. office365/runtime/odata/request.py +11 -2
  90. office365/runtime/odata/type.py +1 -0
  91. office365/runtime/odata/v3/batch_request.py +4 -2
  92. office365/runtime/odata/v4/batch_request.py +22 -7
  93. office365/runtime/operations.py +29 -0
  94. office365/runtime/parallel.py +12 -14
  95. office365/runtime/paths/v4/entity.py +2 -2
  96. office365/runtime/queries/batch.py +18 -1
  97. office365/runtime/queries/get_or_create.py +109 -0
  98. office365/runtime/record_collection.py +443 -0
  99. office365/runtime/retry.py +92 -26
  100. office365/runtime/transport/base.py +6 -1
  101. office365/runtime/transport/requests_transport.py +4 -0
  102. office365/runtime/transport/throttled_transport.py +67 -0
  103. office365/runtime/verification.py +40 -0
  104. office365/sharepoint/activities/logger.py +118 -8
  105. office365/sharepoint/administration/analytics/usage_service.py +15 -7
  106. office365/sharepoint/administration/sitemove/service.py +791 -8
  107. office365/sharepoint/alerts/alert.py +79 -0
  108. office365/sharepoint/apps/app_collection.py +25 -0
  109. office365/sharepoint/apps/launcher.py +16 -0
  110. office365/sharepoint/authpolicy/tenant/compliancereport.py +5 -0
  111. office365/sharepoint/businessdata/app_bdc_catalog.py +135 -0
  112. office365/sharepoint/businessdata/entityfieldvaluedictionary.py +65 -0
  113. office365/sharepoint/businessdata/entityidentifier.py +23 -0
  114. office365/sharepoint/businessdata/entityinstance.py +52 -0
  115. office365/sharepoint/businessdata/entityview.py +31 -0
  116. office365/sharepoint/businessdata/externalsubscriptionstore.py +9 -0
  117. office365/sharepoint/businessdata/typedescriptor.py +37 -0
  118. office365/sharepoint/client_context.py +40 -23
  119. office365/sharepoint/clientsidecomponent/hostedapps/app.py +21 -0
  120. office365/sharepoint/clientsidecomponent/hostedapps/manager.py +16 -0
  121. office365/sharepoint/comments/comment.py +84 -2
  122. office365/sharepoint/contentcenter/machinelearning/models/model.py +0 -23
  123. office365/sharepoint/contentcenter/machinelearning/publications/publication.py +0 -9
  124. office365/sharepoint/contentcenter/machinelearning/samples/sample.py +0 -14
  125. office365/sharepoint/contentcenter/machinelearning/workitems/item.py +0 -80
  126. office365/sharepoint/contentsolution/template_api.py +148 -0
  127. office365/sharepoint/contenttypes/collection.py +32 -10
  128. office365/sharepoint/convergence/addnoliveblobsinodcservicestateapi.py +9 -0
  129. office365/sharepoint/convergence/consumer_permissions.py +15 -0
  130. office365/sharepoint/convergence/migration_complete_state_api.py +25 -0
  131. office365/sharepoint/convergence/odcmetadatacleanedupapi.py +9 -0
  132. office365/sharepoint/documentmanagement/document_id.py +12 -0
  133. office365/sharepoint/entity.py +11 -2
  134. office365/sharepoint/entity_collection.py +2 -2
  135. office365/sharepoint/exceptions.py +194 -0
  136. office365/sharepoint/fields/builtin_field_id.py +45 -34
  137. office365/sharepoint/fields/builtin_field_name.py +123 -0
  138. office365/sharepoint/fields/coercion.py +131 -0
  139. office365/sharepoint/fields/collection.py +54 -37
  140. office365/sharepoint/fields/field.py +17 -0
  141. office365/sharepoint/fields/name.py +46 -2
  142. office365/sharepoint/files/collection.py +7 -1
  143. office365/sharepoint/files/file.py +54 -8
  144. office365/sharepoint/files/versions/version.py +38 -13
  145. office365/sharepoint/folders/collection.py +4 -0
  146. office365/sharepoint/folders/folder.py +91 -21
  147. office365/sharepoint/htmlpages/html_page.py +16 -0
  148. office365/sharepoint/htmlpages/html_page_service.py +19 -0
  149. office365/sharepoint/ir/migration.py +9 -0
  150. office365/sharepoint/listitems/caml/__init__.py +31 -0
  151. office365/sharepoint/listitems/caml/builder.py +129 -0
  152. office365/sharepoint/listitems/caml/expressions.py +424 -0
  153. office365/sharepoint/listitems/caml/fields.py +68 -0
  154. office365/sharepoint/listitems/caml/guard.py +63 -0
  155. office365/sharepoint/listitems/caml/query.py +71 -2
  156. office365/sharepoint/listitems/caml/values.py +113 -0
  157. office365/sharepoint/listitems/collection.py +21 -20
  158. office365/sharepoint/listitems/upsert.py +75 -0
  159. office365/sharepoint/lists/collection.py +29 -22
  160. office365/sharepoint/lists/exporter.py +4 -3
  161. office365/sharepoint/lists/list.py +397 -39
  162. office365/sharepoint/lists/smart_wiki_library_utility.py +58 -0
  163. office365/sharepoint/logger/export.py +12 -0
  164. office365/sharepoint/marketplace/app_metadata.py +97 -1
  165. office365/sharepoint/marketplace/appstatequeryjobdefinition.py +9 -0
  166. office365/sharepoint/marketplace/corporatecuratedgallery/carddesigns/card_designs.py +35 -0
  167. office365/sharepoint/marketplace/sitecollection/appcatalog/allowed_item.py +5 -0
  168. office365/sharepoint/marketplace/tenant/appcatalog/accessor.py +107 -30
  169. office365/sharepoint/microfeed/data.py +0 -6
  170. office365/sharepoint/microservice/manager.py +61 -0
  171. office365/sharepoint/migrationcenter/common/task.py +193 -4
  172. office365/sharepoint/migrationcenter/common/task_entity_data.py +1 -1
  173. office365/sharepoint/migrationcenter/deploy_status.py +13 -0
  174. office365/sharepoint/migrationcenter/service/properties.py +20 -0
  175. office365/sharepoint/migrationcenter/service/storage.py +34 -0
  176. office365/sharepoint/migrationcenter/spflight.py +13 -0
  177. office365/sharepoint/migrationcenter/urlparseresult.py +77 -0
  178. office365/sharepoint/multigeo/service/spmultigeoproxy.py +13 -0
  179. office365/sharepoint/navigation/servicerest.py +127 -0
  180. office365/sharepoint/oauth/authentication.py +13 -0
  181. office365/sharepoint/portal/theme_manager.py +120 -8
  182. office365/sharepoint/principal/users/user.py +10 -0
  183. office365/sharepoint/publishing/campaign/campaign.py +36 -1
  184. office365/sharepoint/publishing/feedvideopage.py +0 -7
  185. office365/sharepoint/publishing/pages/3d/page.py +0 -6
  186. office365/sharepoint/publishing/pages/page.py +0 -117
  187. office365/sharepoint/publishing/pages/reposts/repost.py +0 -7
  188. office365/sharepoint/publishing/pages/topics/topic.py +0 -7
  189. office365/sharepoint/publishing/subtitlefile.py +0 -40
  190. office365/sharepoint/quotamanagement/consumer/consumer_quota_info_api.py +14 -0
  191. office365/sharepoint/quotamanagement/consumer/non_quota_backfill_api.py +23 -0
  192. office365/sharepoint/quotamanagement/consumer/nonquotamigrationapi.py +9 -0
  193. office365/sharepoint/quotamanagement/consumer/quotamigrationapi.py +13 -0
  194. office365/sharepoint/request.py +57 -16
  195. office365/sharepoint/search/administration/document_crawl_log.py +24 -7
  196. office365/sharepoint/search/administration/providers/crawl_versions_info.py +29 -0
  197. office365/sharepoint/search/administration/providers/recycle_bin_info.py +23 -0
  198. office365/sharepoint/search/administration/providers/site_content_processing_info.py +67 -0
  199. office365/sharepoint/sites/site.py +976 -65
  200. office365/sharepoint/sites/siteprovisioninginfoprovider.py +13 -0
  201. office365/sharepoint/tenant/administration/datagovernance/insight_client.py +292 -23
  202. office365/sharepoint/tenant/administration/ddiadapter.py +97 -0
  203. office365/sharepoint/tenant/administration/internal/aad/collection.py +20 -0
  204. office365/sharepoint/tenant/administration/internal/aad/permission_grant.py +0 -19
  205. office365/sharepoint/tenant/administration/internal/appservice/permission_request.py +72 -1
  206. office365/sharepoint/tenant/administration/internal/appservice/principal.py +1 -3
  207. office365/sharepoint/tenant/administration/odb_license_enforcement_service.py +26 -0
  208. office365/sharepoint/tenant/administration/sites/properties.py +564 -6
  209. office365/sharepoint/tenant/administration/spdatagovernancesarrestapiclient.py +60 -1
  210. office365/sharepoint/tenant/insights/reports/manager.py +24 -12
  211. office365/sharepoint/tenant/management/office365_tenant.py +451 -2
  212. office365/sharepoint/tenantadministration/catalog_management_collection.py +69 -0
  213. office365/sharepoint/tenantadministration/catalog_management_custom_properties_collection.py +237 -0
  214. office365/sharepoint/tenantadministration/catalog_management_restricted_content_discovery_collection.py +56 -0
  215. office365/sharepoint/tenantadministration/sp_data_governance_client_exception.py +7 -0
  216. office365/sharepoint/thresholds.py +529 -0
  217. office365/sharepoint/translation/jobs/job.py +72 -2
  218. office365/sharepoint/translation/jobs/status.py +47 -9
  219. office365/sharepoint/translation/sync_translator.py +15 -2
  220. office365/sharepoint/utilities/move_copy_util.py +2 -1
  221. office365/sharepoint/viva/connections/page.py +27 -0
  222. office365/sharepoint/viva/home.py +38 -0
  223. office365/sharepoint/viva/learningmanager.py +9 -0
  224. office365/sharepoint/viva/vivaresources.py +60 -0
  225. office365/sharepoint/webhooks/subscription.py +0 -11
  226. office365/sharepoint/webparts/definitions/definition.py +13 -0
  227. office365/sharepoint/webparts/limited_manager.py +29 -7
  228. office365/sharepoint/webs/context_web_information.py +14 -9
  229. office365/sharepoint/webs/templates/__init__.py +11 -0
  230. office365/sharepoint/webs/templates/type.py +74 -15
  231. office365/sharepoint/workflow/definition.py +6 -0
  232. office365/sharepoint/workflow/deployment_service.py +155 -0
  233. office365/sharepoint/workflow/instances/service.py +33 -0
  234. office365/sharepoint/workflow/interopservice.py +68 -0
  235. office365/sharepoint/workflow/messagingservice.py +24 -0
  236. office365/sharepoint/workflow/servicesmanager.py +41 -2
  237. office365/sharepoint/workflow/subscriptions/service.py +67 -0
  238. office365/sharepoint/workflow/subscriptions/subscription.py +31 -0
  239. office365/sharepoint/workflow/task.py +425 -0
  240. office365_rest_python_client-3.2.0.dist-info/METADATA +605 -0
  241. {office365_rest_python_client-3.1.0.dist-info → office365_rest_python_client-3.2.0.dist-info}/RECORD +242 -185
  242. office365/migration/_util.py +0 -73
  243. office365/sharepoint/listitems/caml/types.py +0 -70
  244. office365_rest_python_client-3.1.0.dist-info/METADATA +0 -475
  245. {office365_rest_python_client-3.1.0.dist-info → office365_rest_python_client-3.2.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,105 @@
1
+ """Generic runtime request exceptions.
2
+
3
+ Concrete error types shared across products (Graph, SharePoint, ...). They
4
+ self-register with the dispatcher in
5
+ :mod:`office365.runtime.client_request_exception`, which imports this module
6
+ lazily so classification works regardless of import order.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Optional
13
+
14
+ from office365.runtime.client_request_exception import (
15
+ ClientRequestException,
16
+ ErrorPayload,
17
+ register_error_type,
18
+ )
19
+
20
+
21
+ class DuplicatedObjectException(ClientRequestException):
22
+ """Raised when creating an object that already exists (HTTP 400 + ConflictingObjects)."""
23
+
24
+ MATCH_PRIORITY = 10
25
+ _CODES = frozenset({"nameAlreadyExists", "ErrorFolderExists", "-2130575342"})
26
+
27
+ @classmethod
28
+ def matches(cls, payload: ErrorPayload) -> bool:
29
+ return (
30
+ payload.hresult in cls._CODES
31
+ or payload.code.lower() in cls._CODES
32
+ or any(detail.get("code") == "ConflictingObjects" for detail in payload.details)
33
+ # The generic ``-1, System.Exception`` carries no code, so fall back to
34
+ # the (English) message — scoped to that opaque case only.
35
+ or (payload.hresult == "-1" and "already exists" in payload.message.lower())
36
+ )
37
+
38
+
39
+ class ObjectNotFoundException(ClientRequestException):
40
+ """Raised when a requested object is not found (HTTP 404 or ResourceNotFound code)."""
41
+
42
+ MATCH_PRIORITY = 10
43
+ _CODES = frozenset({"itemnotfound", "resourcenotfound", "notfound"})
44
+ _TYPE_NAMES = ("FileNotFoundException", "DirectoryNotFoundException", "ResourceNotFoundException")
45
+
46
+ @classmethod
47
+ def matches(cls, payload: ErrorPayload) -> bool:
48
+ error_type = (payload.error_type or "").lower()
49
+ return (
50
+ "-2147024809" in payload.code # System.ArgumentException (kept for back-compat)
51
+ or "-2147024894" in payload.code # System.IO.FileNotFoundException
52
+ or payload.code.lower() in cls._CODES
53
+ or any(name.lower() in error_type for name in cls._TYPE_NAMES)
54
+ )
55
+
56
+
57
+ register_error_type(DuplicatedObjectException)
58
+ register_error_type(ObjectNotFoundException)
59
+
60
+
61
+ class FileLockedException(ClientRequestException):
62
+ """Raised when a file is locked and the requested change is rejected (HTTP 423).
63
+
64
+ The usual cause is a **shared lock**: the file is open for coauthoring in
65
+ Office (web or desktop), so SharePoint/Graph refuses the write. Unlike a
66
+ check-out lock, a shared lock cannot be broken through the API. See
67
+ :attr:`GUIDANCE` for the available options.
68
+
69
+ HTTP 423 is also used by ``Microsoft.SharePoint.SPFileCheckOutException``;
70
+ that case is classified separately as
71
+ :class:`~office365.sharepoint.exceptions.SPFileCheckOutException` (a higher
72
+ priority match), so this type only claims status-only 423 responses.
73
+ """
74
+
75
+ MATCH_PRIORITY = 10
76
+ _CODES = ("-2147018894", "spfilelockexception", "resourcelocked")
77
+
78
+ GUIDANCE = (
79
+ "The file is locked - typically because it is open for editing in Office. A shared lock "
80
+ "cannot be broken through the API. Either retry until the other session closes "
81
+ "(execute_query_retry(is_retriable=retry_on(FileLockedException))), update metadata only "
82
+ "with ListItem.update_ex(bypass_shared_lock=True), or - for delete - pass "
83
+ "bypass_shared_lock=True. The lock holder is available via .lock_owner."
84
+ )
85
+
86
+ @classmethod
87
+ def matches(cls, payload: ErrorPayload) -> bool:
88
+ code = payload.code.lower()
89
+ if any(marker in code for marker in cls._CODES):
90
+ return True
91
+ return payload.status == 423 # noqa: PLR2004
92
+
93
+ @property
94
+ def lock_owner(self) -> Optional[str]:
95
+ """Best-effort display name of the user holding the lock (parsed from the message)."""
96
+ match = re.search(r"locked for shared use by (.+?)\s*\[", self.message, re.IGNORECASE)
97
+ return match.group(1).strip() if match else None
98
+
99
+ def __str__(self) -> str:
100
+ owner = self.lock_owner
101
+ described = f"{super().__str__()} (locked by {owner})" if owner else super().__str__()
102
+ return f"{described}\n{self.GUIDANCE}"
103
+
104
+
105
+ register_error_type(FileLockedException)
@@ -0,0 +1,26 @@
1
+ """``Prefer`` request-header helpers.
2
+
3
+ The ``Prefer`` header opts a single request into non-default server behaviour.
4
+ Currently the client exposes the shared-lock bypass used when a file is open
5
+ for editing (e.g. in Office) and would otherwise be rejected with HTTP 423.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from office365.runtime.http.request_options import RequestOptions
11
+
12
+ #: Value of the ``Prefer`` header that bypasses a *shared lock* on a file.
13
+ BYPASS_SHARED_LOCK = "bypass-shared-lock"
14
+
15
+
16
+ def prefer_bypass_shared_lock(request: RequestOptions) -> None:
17
+ """Attach ``Prefer: bypass-shared-lock`` to a request.
18
+
19
+ A shared lock (as opposed to a check-out lock) is placed on a file while it
20
+ is open for coauthoring. Only some operations honour the bypass - notably
21
+ delete. Content uploads cannot bypass a shared lock.
22
+
23
+ Attach it to a single request with ``context.before_execute``, or pass
24
+ ``bypass_shared_lock=True`` to ``delete_object``.
25
+ """
26
+ request.set_header("Prefer", BYPASS_SHARED_LOCK)
@@ -5,12 +5,21 @@ response and ``Retry-After`` on throttled (429/503) responses. Per the current
5
5
  Microsoft guidance it does **not** return IETF ``RateLimit-*`` headers — those
6
6
  are parsed defensively here in case a proxy or other source provides them.
7
7
 
8
- Two ways to use it:
8
+ Two distinct concerns live here:
9
9
 
10
- - Attach :func:`rate_limit_hook` per query (``after_execute(..., include_response=True)``)
11
- or to the request event handler for every response;
12
- - Wrap a workload in :func:`throttle_guard` for a scoped, self-removing guard
13
- that hides the event plumbing entirely.
10
+ **Control** — act on the signals to pace the fleet. :class:`RateLimiter` +
11
+ :func:`paced` (used by ``ThrottledTransport``) gate every send and observe every
12
+ response, including failures, without touching error propagation. This is the
13
+ mature pipeline/transport-policy pattern (Azure ``RetryPolicy``, botocore
14
+ adaptive mode, urllib3 ``Retry``).
15
+
16
+ **Observability** — report the signals to the caller. :func:`rate_limit_hook`
17
+ and :func:`throttle_guard` attach to the request's ``afterExecute`` event and
18
+ never change control flow. Use these when you only want to *know* about
19
+ throttling; use the limiter when you want to *act* on it.
20
+
21
+ Note: request ``onError`` handlers mark an error as handled (they swallow it —
22
+ see :meth:`ClientRequest.on_error`), so they must not be used for observation.
14
23
  """
15
24
 
16
25
  from __future__ import annotations
@@ -18,25 +27,65 @@ from __future__ import annotations
18
27
  import threading
19
28
  import time
20
29
  from contextlib import contextmanager
21
- from dataclasses import astuple, dataclass
22
- from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional
30
+ from dataclasses import dataclass, fields
31
+ from enum import Enum
32
+ from typing import TYPE_CHECKING, Callable, Iterator, Optional
23
33
 
24
34
  from requests import Response
25
35
 
36
+ from office365.runtime.converters.scalars import parse_int
37
+
26
38
  if TYPE_CHECKING:
27
39
  from office365.runtime.client_runtime_context import ClientRuntimeContext
28
40
 
29
41
 
42
+ class ThrottleProtocol(Enum):
43
+ """Which API's throttling headers to parse.
44
+
45
+ ``AUTO`` (default) reads every known header — Microsoft Graph's
46
+ ``x-ms-throttle-*`` / ``x-ms-resource-unit`` and SharePoint's
47
+ ``X-SharePointHealthScore`` / ``RateLimit-*`` — so callers rarely need to
48
+ pick one. The explicit values are an escape hatch for future quirks.
49
+ """
50
+
51
+ AUTO = "auto"
52
+ GRAPH = "graph"
53
+ SHAREPOINT = "sharepoint"
54
+
55
+
56
+ #: Fields that carry a server signal (``status`` is context, not a signal).
57
+ _SIGNAL_FIELDS = (
58
+ "limit",
59
+ "remaining",
60
+ "reset",
61
+ "retry_after",
62
+ "health_score",
63
+ "limit_percentage",
64
+ "resource_unit",
65
+ "scope",
66
+ "reason",
67
+ )
68
+
69
+
30
70
  @dataclass(frozen=True)
31
71
  class ThrottleLimits:
32
- """Throttling state reported by the server on a successful response.
72
+ """Throttling state reported by the server, protocol-agnostic.
33
73
 
34
74
  Attributes:
35
- limit: Maximum requests permitted in the current window.
75
+ limit: Maximum requests permitted in the current window (IETF ``RateLimit-*``).
36
76
  remaining: Requests still permitted before throttling kicks in.
37
77
  reset: Seconds until the quota window resets.
38
78
  retry_after: Seconds the server asks us to wait (``Retry-After``).
39
79
  health_score: SharePoint health score (0-100), if reported.
80
+ limit_percentage: Graph ``x-ms-throttle-limit-percentage`` (0.8-1.8) —
81
+ how much of the granted limit the app consumed; >= 1.0 means requests
82
+ are being throttled.
83
+ resource_unit: Graph ``x-ms-resource-unit`` — the cost of this request.
84
+ scope: Graph ``x-ms-throttle-scope`` — the throttled scope
85
+ (``<Scope>/<Limit>/<AppId>/<TenantId|UserId|ResourceId>``).
86
+ reason: Graph ``x-ms-throttle-information`` (``CPULimitExceeded``,
87
+ ``WriteLimitExceeded``, ``ResourceUnitLimitExceeded``, ...).
88
+ status: The HTTP status code.
40
89
  """
41
90
 
42
91
  limit: Optional[int] = None
@@ -44,35 +93,101 @@ class ThrottleLimits:
44
93
  reset: Optional[int] = None
45
94
  retry_after: Optional[int] = None
46
95
  health_score: Optional[int] = None
96
+ limit_percentage: Optional[float] = None
97
+ resource_unit: Optional[int] = None
98
+ scope: Optional[str] = None
99
+ reason: Optional[str] = None
100
+ status: Optional[int] = None
101
+
102
+ @property
103
+ def has_signals(self) -> bool:
104
+ """Whether any throttling signal header was present."""
105
+ return any(getattr(self, name) is not None for name in _SIGNAL_FIELDS)
106
+
107
+
108
+ #: Alias for the newer, protocol-agnostic name.
109
+ ThrottleSignal = ThrottleLimits
110
+
111
+ _ALL_FIELDS = tuple(f.name for f in fields(ThrottleLimits))
112
+
113
+
114
+ def _parse_float(value: Optional[str]) -> Optional[float]:
115
+ if value is None:
116
+ return None
117
+ try:
118
+ return float(value)
119
+ except (TypeError, ValueError):
120
+ return None
121
+
122
+
123
+ def _parse_graph(response: Response) -> ThrottleLimits:
124
+ """Parse Microsoft Graph throttling headers (``x-ms-throttle-*``)."""
125
+ headers = response.headers
126
+ return ThrottleLimits(
127
+ retry_after=parse_int(headers.get("Retry-After")),
128
+ limit_percentage=_parse_float(headers.get("x-ms-throttle-limit-percentage")),
129
+ resource_unit=parse_int(headers.get("x-ms-resource-unit")),
130
+ scope=headers.get("x-ms-throttle-scope"),
131
+ reason=headers.get("x-ms-throttle-information"),
132
+ status=getattr(response, "status_code", None),
133
+ )
134
+
135
+
136
+ def _parse_sharepoint(response: Response) -> ThrottleLimits:
137
+ """Parse SharePoint throttling/health headers."""
138
+ headers = response.headers
139
+ return ThrottleLimits(
140
+ limit=parse_int(headers.get("RateLimit-Limit")),
141
+ remaining=parse_int(headers.get("RateLimit-Remaining")),
142
+ reset=parse_int(headers.get("RateLimit-Reset")),
143
+ retry_after=parse_int(headers.get("Retry-After")),
144
+ health_score=parse_int(headers.get("X-SharePointHealthScore")),
145
+ status=getattr(response, "status_code", None),
146
+ )
147
+
47
148
 
149
+ _PARSERS: dict[ThrottleProtocol, Callable[[Response], ThrottleLimits]] = {
150
+ ThrottleProtocol.GRAPH: _parse_graph,
151
+ ThrottleProtocol.SHAREPOINT: _parse_sharepoint,
152
+ }
48
153
 
49
- def parse_throttling(response: Response) -> Optional[ThrottleLimits]:
154
+
155
+ def parse_throttling(
156
+ response: Response,
157
+ protocol: ThrottleProtocol = ThrottleProtocol.AUTO,
158
+ ) -> Optional[ThrottleLimits]:
50
159
  """Parse throttling / health headers from a response into a ``ThrottleLimits``.
51
160
 
52
- Returns ``None`` when the response carries none of the tracked headers
53
- (e.g. a Microsoft Graph response), so hooks can stay silent for other APIs.
161
+ Returns ``None`` when the response carries none of the tracked headers, so
162
+ hooks can stay silent for ordinary responses. ``protocol=AUTO`` (default)
163
+ reads every known header; pass an explicit protocol to restrict it.
54
164
 
55
165
  Args:
56
166
  response: The raw HTTP response.
167
+ protocol: Which API's headers to read (default ``AUTO``).
57
168
 
58
169
  Returns:
59
170
  ThrottleLimits, or None when no tracked headers are present.
60
171
  """
61
- limits = ThrottleLimits(
62
- limit=_to_int(response.headers.get("RateLimit-Limit")),
63
- remaining=_to_int(response.headers.get("RateLimit-Remaining")),
64
- reset=_to_int(response.headers.get("RateLimit-Reset")),
65
- retry_after=_to_int(response.headers.get("Retry-After")),
66
- health_score=_to_int(response.headers.get("X-SharePointHealthScore")),
67
- )
68
- if any(value is not None for value in astuple(limits)):
69
- return limits
70
- return None
172
+ if protocol is ThrottleProtocol.AUTO:
173
+ # ``Retry-After`` is common to both, so merge rather than short-circuit.
174
+ graph = _parse_graph(response)
175
+ sharepoint = _parse_sharepoint(response)
176
+ limits = ThrottleLimits(
177
+ **{
178
+ name: (getattr(graph, name) if getattr(graph, name) is not None else getattr(sharepoint, name))
179
+ for name in _ALL_FIELDS
180
+ }
181
+ )
182
+ else:
183
+ limits = _PARSERS[protocol](response)
184
+ return limits if limits.has_signals else None
71
185
 
72
186
 
73
187
  def rate_limit_hook(callback: Optional[Callable[[ThrottleLimits], None]] = None) -> Callable[[Response], None]:
74
188
  """Return an after-execute hook that reports parsed throttling / health state.
75
189
 
190
+ Observation only — it never changes control flow (unlike :class:`RateLimiter`).
76
191
  The returned hook is compatible with ``after_execute`` (it expects the raw
77
192
  ``Response``). It fires ``callback`` only when the response carries any of
78
193
  the tracked headers — silent otherwise.
@@ -123,12 +238,83 @@ def throttle_guard(
123
238
  handler -= hook
124
239
 
125
240
 
126
- def _to_int(value: Any) -> Optional[int]:
127
- """Parse a header value into an int, returning None when absent/invalid."""
241
+ @dataclass(frozen=True)
242
+ class PaceState:
243
+ """The group gate: a monotonic deadline before which requests must wait.
244
+
245
+ Produced only by :func:`pace` (pure) so the pacing policy is trivially
246
+ testable without a clock, a lock, or a sleep.
247
+ """
248
+
249
+ next_available_at: float = 0.0
250
+
251
+
252
+ def pace(
253
+ state: PaceState,
254
+ limits: Optional[ThrottleLimits],
255
+ *,
256
+ now: float,
257
+ health_threshold: int = 80,
258
+ min_interval: float = 0.0,
259
+ percentage_threshold: float = 0.8,
260
+ percentage_interval: float = 1.0,
261
+ ) -> PaceState:
262
+ """Pure transition: observed server limits -> next gate state.
263
+
264
+ A ``Retry-After`` gates the fleet for that long. A high Graph
265
+ ``x-ms-throttle-limit-percentage`` (>= ``percentage_threshold``) applies a
266
+ short, scaled pace so the group eases off *before* being throttled; a high
267
+ ``X-SharePointHealthScore`` does the same as the farm heats up. Returns
268
+ ``state`` unchanged when nothing applies.
269
+
270
+ Args:
271
+ state: The current gate state.
272
+ limits: Parsed throttling/health signals (or ``None``).
273
+ now: Monotonic "now" (injected — the function reads no clock).
274
+ health_threshold: Health score at/above which the group paces.
275
+ min_interval: Minimum pause applied on a high health score.
276
+ percentage_threshold: Graph limit-percentage at/above which the group paces.
277
+ percentage_interval: Base pause for the Graph limit-percentage pace
278
+ (scales 1x..2x across 0.8..1.8).
279
+ """
280
+ if limits is None:
281
+ return state
282
+ delay = 0.0
283
+ if limits.retry_after is not None and limits.retry_after > 0:
284
+ delay = max(delay, float(limits.retry_after))
285
+ if limits.limit_percentage is not None and limits.limit_percentage >= percentage_threshold:
286
+ delay = max(delay, percentage_interval * (1.0 + (limits.limit_percentage - percentage_threshold)))
287
+ if limits.health_score is not None and limits.health_score >= health_threshold:
288
+ delay = max(delay, (limits.health_score - health_threshold) / 20.0, min_interval)
289
+ if delay <= 0:
290
+ return state
291
+ return PaceState(max(state.next_available_at, now + delay))
292
+
293
+
294
+ def wait_delay(state: PaceState, *, now: float) -> float:
295
+ """Pure: seconds until the gate opens (``0`` when already open)."""
296
+ return max(0.0, state.next_available_at - now)
297
+
298
+
299
+ def paced(func: Callable[[], Response], gate: "RateLimiter") -> Response:
300
+ """Run ``func`` under a shared rate limiter — the functional guard.
301
+
302
+ Mirrors :func:`~office365.runtime.retry.retry`: it waits for the group gate,
303
+ then feeds the outcome (returned response or ``exception.response``) back to
304
+ the limiter. No call-site side effects; composable and thread-safe.
305
+
306
+ Args:
307
+ func: Callable performing one request/operation.
308
+ gate: The shared :class:`RateLimiter` to pace against.
309
+ """
310
+ gate.acquire()
128
311
  try:
129
- return int(value)
130
- except (TypeError, ValueError):
131
- return None
312
+ result = func()
313
+ except Exception as ex:
314
+ gate.observe(getattr(ex, "response", None))
315
+ raise
316
+ gate.observe(result)
317
+ return result
132
318
 
133
319
 
134
320
  class RateLimiter:
@@ -139,17 +325,38 @@ class RateLimiter:
139
325
  before any request is sent it blocks until the group gate is open, and when
140
326
  any worker observes throttling it pauses the whole group.
141
327
 
142
- Opt-in — attach it to a context (or a clone) with :meth:`bind`; the existing
143
- per-request retry remains as the safety net.
328
+ The pacing policy lives in the pure :func:`pace` / :func:`wait_delay`; this
329
+ class is only the thread-safe state holder with injectable ``clock`` and
330
+ ``sleep`` (so tests can run without real time). Opt-in — wrap a transport
331
+ with :class:`~office365.runtime.transport.throttled_transport.ThrottledTransport`
332
+ or attach it to a context with :meth:`bind`; the existing per-request retry
333
+ remains as the safety net.
144
334
  """
145
335
 
146
336
  _SLEEP_GRANULARITY = 0.05
147
337
 
148
- def __init__(self, health_threshold: int = 80, min_interval: float = 0.0) -> None:
149
- self._lock = threading.RLock()
150
- self._next_available_at = 0.0
338
+ def __init__(
339
+ self,
340
+ health_threshold: int = 80,
341
+ min_interval: float = 0.0,
342
+ percentage_threshold: float = 0.8,
343
+ percentage_interval: float = 1.0,
344
+ clock: Callable[[], float] = time.monotonic,
345
+ sleep: Callable[[float], None] = time.sleep,
346
+ ) -> None:
347
+ self._lock = threading.Lock()
348
+ self._state = PaceState()
151
349
  self._health_threshold = health_threshold
152
350
  self._min_interval = min_interval
351
+ self._percentage_threshold = percentage_threshold
352
+ self._percentage_interval = percentage_interval
353
+ self._clock = clock
354
+ self._sleep = sleep
355
+
356
+ def snapshot(self) -> PaceState:
357
+ """Return the current gate state (a pure value)."""
358
+ with self._lock:
359
+ return self._state
153
360
 
154
361
  def acquire(self) -> None:
155
362
  """Block the calling thread until the group gate opens.
@@ -158,48 +365,43 @@ class RateLimiter:
158
365
  """
159
366
  while True:
160
367
  with self._lock:
161
- wait = self._next_available_at - time.monotonic()
368
+ wait = wait_delay(self._state, now=self._clock())
162
369
  if wait <= 0:
163
370
  return
164
- time.sleep(min(wait, self._SLEEP_GRANULARITY))
371
+ self._sleep(min(wait, self._SLEEP_GRANULARITY))
165
372
 
166
373
  def observe(self, response: Optional[Response]) -> None:
167
- """Record throttling signals and pause the group when the server asks.
168
-
169
- A throttled response (``Retry-After``) gates the fleet for that long;
170
- a high ``X-SharePointHealthScore`` applies a short, scaled pace so the
171
- group eases off as the farm heats up.
172
- """
374
+ """Record throttling signals and pause the group when the server asks."""
173
375
  if response is None:
174
376
  return
175
377
  limits = parse_throttling(response)
176
378
  if limits is None:
177
379
  return
178
- now = time.monotonic()
179
380
  with self._lock:
180
- if limits.retry_after is not None and limits.retry_after > 0:
181
- self._next_available_at = max(self._next_available_at, now + limits.retry_after)
182
- if limits.health_score is not None and limits.health_score >= self._health_threshold:
183
- pace = (limits.health_score - self._health_threshold) / 20.0
184
- self._next_available_at = max(self._next_available_at, now + max(pace, self._min_interval))
381
+ self._state = pace(
382
+ self._state,
383
+ limits,
384
+ now=self._clock(),
385
+ health_threshold=self._health_threshold,
386
+ min_interval=self._min_interval,
387
+ percentage_threshold=self._percentage_threshold,
388
+ percentage_interval=self._percentage_interval,
389
+ )
185
390
 
186
391
  def bind(self, context: "ClientRuntimeContext") -> "RateLimiter":
187
- """Attach this limiter to a context (or ``clone``) — every request paces as a group.
392
+ """Pace every request of a context (or ``clone``) by wrapping its transport.
188
393
 
189
- Health signals are read on successful responses (``afterExecute``);
190
- throttled responses (429/503) raise before ``afterExecute``, so their
191
- ``Retry-After`` is read via ``onError`` instead.
394
+ Delegates to :meth:`ClientRequest.with_rate_limiter` on the context's
395
+ request, so the shared limiter gates each send and observes each response
396
+ (and any error's ``response``).
192
397
 
193
398
  Args:
194
- context: The client context to monitor and gate.
399
+ context: The client context to gate.
195
400
  """
196
- request = context.pending_request()
197
- request.beforeExecute += lambda _: self.acquire()
198
- request.afterExecute += lambda response: self.observe(response)
199
- request.onError += lambda ex: self.observe(getattr(ex, "response", None))
401
+ context.pending_request().with_rate_limiter(self)
200
402
  return self
201
403
 
202
404
  def reset(self) -> None:
203
405
  """Clear the current gate (used by tests / recovery)."""
204
406
  with self._lock:
205
- self._next_available_at = 0.0
407
+ self._state = PaceState()