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.
- office365/communications/callrecords/call_record.py +18 -0
- office365/communications/calls/call.py +3 -0
- office365/communications/presences/presence.py +3 -0
- office365/communications/quotas.py +66 -0
- office365/communications/virtualevents/virtual_event.py +3 -0
- office365/directory/domains/domain.py +3 -0
- office365/directory/licenses/subscribed_sku.py +3 -0
- office365/directory/objects/object.py +3 -0
- office365/directory/quotas.py +34 -0
- office365/entity_collection.py +2 -2
- office365/graph_client.py +46 -2
- office365/limits.py +66 -0
- office365/migration/__init__.py +6 -1
- office365/migration/adapters/__init__.py +14 -4
- office365/migration/adapters/filesystem.py +4 -1
- office365/migration/assessment/export.py +5 -5
- office365/migration/assessment/issue.py +19 -0
- office365/migration/assessment/report.py +11 -0
- office365/migration/assessment/scanners/base.py +37 -49
- office365/migration/base.py +33 -8
- office365/migration/checkpoint.py +1 -1
- office365/migration/outlook/assessor.py +4 -4
- office365/migration/package/__init__.py +82 -0
- office365/migration/package/builder.py +603 -0
- office365/migration/package/crypto.py +54 -0
- office365/migration/package/manifest_xml.py +495 -0
- office365/migration/package/staging.py +180 -0
- office365/migration/report.py +7 -2
- office365/migration/report_io.py +79 -0
- office365/migration/runner.py +117 -10
- office365/migration/server_job.py +126 -14
- office365/migration/session.py +132 -49
- office365/migration/settings.py +67 -0
- office365/migration/sharepoint/__init__.py +2 -0
- office365/migration/sharepoint/adapters.py +207 -41
- office365/migration/sharepoint/assessor.py +45 -9
- office365/migration/sharepoint/options.py +57 -0
- office365/migration/sharepoint/package_target.py +209 -0
- office365/migration/sharepoint/registry.py +22 -0
- office365/migration/sharepoint/resolvers.py +96 -0
- office365/migration/sharepoint/scanners/__init__.py +29 -0
- office365/migration/sharepoint/scanners/browser_file_handling.py +55 -0
- office365/migration/sharepoint/scanners/checked_out_files.py +57 -0
- office365/migration/sharepoint/scanners/file_versions.py +56 -0
- office365/migration/sharepoint/scanners/files.py +5 -2
- office365/migration/sharepoint/scanners/large_excel_files.py +56 -0
- office365/migration/sharepoint/scanners/large_lists.py +58 -0
- office365/migration/sharepoint/scanners/large_sites.py +4 -3
- office365/migration/sharepoint/scanners/long_onedrive_urls.py +52 -0
- office365/migration/sharepoint/scanners/lookup.py +38 -0
- office365/migration/sharepoint/scanners/paths.py +9 -4
- office365/migration/sharepoint/scanners/permissions.py +20 -2
- office365/migration/sharepoint/scanners/smat.py +51 -0
- office365/migration/sharepoint/scanners/summary.py +1 -0
- office365/migration/sharepoint/scanners/thicket_folders.py +38 -0
- office365/migration/sharepoint/scanners/unsupported_site_templates.py +90 -0
- office365/migration/sharepoint/tenant_assessor.py +5 -3
- office365/migration/sharepoint/transfer.py +4 -2
- office365/migration/tasks.py +121 -0
- office365/migration/validators.py +2 -19
- office365/onedrive/driveitems/driveItem.py +116 -12
- office365/onedrive/termstore/groups/collection.py +2 -9
- office365/onedrive/termstore/sets/collection.py +2 -9
- office365/onedrive/termstore/terms/collection.py +3 -10
- office365/onedrive/workbooks/workbook.py +7 -0
- office365/outlook/contacts/folders/collection.py +4 -13
- office365/runtime/auth/authentication_context.py +5 -0
- office365/runtime/client_object.py +18 -0
- office365/runtime/client_object_collection.py +75 -245
- office365/runtime/client_request.py +77 -1
- office365/runtime/client_request_exception.py +127 -39
- office365/runtime/client_runtime_context.py +79 -8
- office365/runtime/converters/csv_writer.py +10 -4
- office365/runtime/converters/dataframe.py +84 -4
- office365/runtime/converters/json_file.py +12 -3
- office365/runtime/converters/parquet.py +67 -0
- office365/runtime/converters/records.py +31 -12
- office365/runtime/converters/registry.py +185 -0
- office365/runtime/converters/scalars.py +31 -0
- office365/runtime/converters/sql.py +69 -0
- office365/runtime/converters/streamers.py +105 -0
- office365/runtime/converters/streams.py +42 -0
- office365/runtime/converters/upsert.py +101 -0
- office365/runtime/exceptions.py +105 -0
- office365/runtime/http/prefer.py +26 -0
- office365/runtime/http/throttling.py +258 -56
- office365/runtime/imports.py +571 -0
- office365/runtime/limits.py +455 -0
- office365/runtime/odata/request.py +11 -2
- office365/runtime/odata/type.py +1 -0
- office365/runtime/odata/v3/batch_request.py +4 -2
- office365/runtime/odata/v4/batch_request.py +22 -7
- office365/runtime/operations.py +29 -0
- office365/runtime/parallel.py +12 -14
- office365/runtime/paths/v4/entity.py +2 -2
- office365/runtime/queries/batch.py +18 -1
- office365/runtime/queries/get_or_create.py +109 -0
- office365/runtime/record_collection.py +443 -0
- office365/runtime/retry.py +92 -26
- office365/runtime/transport/base.py +6 -1
- office365/runtime/transport/requests_transport.py +4 -0
- office365/runtime/transport/throttled_transport.py +67 -0
- office365/runtime/verification.py +40 -0
- office365/sharepoint/activities/logger.py +118 -8
- office365/sharepoint/administration/analytics/usage_service.py +15 -7
- office365/sharepoint/administration/sitemove/service.py +791 -8
- office365/sharepoint/alerts/alert.py +79 -0
- office365/sharepoint/apps/app_collection.py +25 -0
- office365/sharepoint/apps/launcher.py +16 -0
- office365/sharepoint/authpolicy/tenant/compliancereport.py +5 -0
- office365/sharepoint/businessdata/app_bdc_catalog.py +135 -0
- office365/sharepoint/businessdata/entityfieldvaluedictionary.py +65 -0
- office365/sharepoint/businessdata/entityidentifier.py +23 -0
- office365/sharepoint/businessdata/entityinstance.py +52 -0
- office365/sharepoint/businessdata/entityview.py +31 -0
- office365/sharepoint/businessdata/externalsubscriptionstore.py +9 -0
- office365/sharepoint/businessdata/typedescriptor.py +37 -0
- office365/sharepoint/client_context.py +40 -23
- office365/sharepoint/clientsidecomponent/hostedapps/app.py +21 -0
- office365/sharepoint/clientsidecomponent/hostedapps/manager.py +16 -0
- office365/sharepoint/comments/comment.py +84 -2
- office365/sharepoint/contentcenter/machinelearning/models/model.py +0 -23
- office365/sharepoint/contentcenter/machinelearning/publications/publication.py +0 -9
- office365/sharepoint/contentcenter/machinelearning/samples/sample.py +0 -14
- office365/sharepoint/contentcenter/machinelearning/workitems/item.py +0 -80
- office365/sharepoint/contentsolution/template_api.py +148 -0
- office365/sharepoint/contenttypes/collection.py +32 -10
- office365/sharepoint/convergence/addnoliveblobsinodcservicestateapi.py +9 -0
- office365/sharepoint/convergence/consumer_permissions.py +15 -0
- office365/sharepoint/convergence/migration_complete_state_api.py +25 -0
- office365/sharepoint/convergence/odcmetadatacleanedupapi.py +9 -0
- office365/sharepoint/documentmanagement/document_id.py +12 -0
- office365/sharepoint/entity.py +11 -2
- office365/sharepoint/entity_collection.py +2 -2
- office365/sharepoint/exceptions.py +194 -0
- office365/sharepoint/fields/builtin_field_id.py +45 -34
- office365/sharepoint/fields/builtin_field_name.py +123 -0
- office365/sharepoint/fields/coercion.py +131 -0
- office365/sharepoint/fields/collection.py +54 -37
- office365/sharepoint/fields/field.py +17 -0
- office365/sharepoint/fields/name.py +46 -2
- office365/sharepoint/files/collection.py +7 -1
- office365/sharepoint/files/file.py +54 -8
- office365/sharepoint/files/versions/version.py +38 -13
- office365/sharepoint/folders/collection.py +4 -0
- office365/sharepoint/folders/folder.py +91 -21
- office365/sharepoint/htmlpages/html_page.py +16 -0
- office365/sharepoint/htmlpages/html_page_service.py +19 -0
- office365/sharepoint/ir/migration.py +9 -0
- office365/sharepoint/listitems/caml/__init__.py +31 -0
- office365/sharepoint/listitems/caml/builder.py +129 -0
- office365/sharepoint/listitems/caml/expressions.py +424 -0
- office365/sharepoint/listitems/caml/fields.py +68 -0
- office365/sharepoint/listitems/caml/guard.py +63 -0
- office365/sharepoint/listitems/caml/query.py +71 -2
- office365/sharepoint/listitems/caml/values.py +113 -0
- office365/sharepoint/listitems/collection.py +21 -20
- office365/sharepoint/listitems/upsert.py +75 -0
- office365/sharepoint/lists/collection.py +29 -22
- office365/sharepoint/lists/exporter.py +4 -3
- office365/sharepoint/lists/list.py +397 -39
- office365/sharepoint/lists/smart_wiki_library_utility.py +58 -0
- office365/sharepoint/logger/export.py +12 -0
- office365/sharepoint/marketplace/app_metadata.py +97 -1
- office365/sharepoint/marketplace/appstatequeryjobdefinition.py +9 -0
- office365/sharepoint/marketplace/corporatecuratedgallery/carddesigns/card_designs.py +35 -0
- office365/sharepoint/marketplace/sitecollection/appcatalog/allowed_item.py +5 -0
- office365/sharepoint/marketplace/tenant/appcatalog/accessor.py +107 -30
- office365/sharepoint/microfeed/data.py +0 -6
- office365/sharepoint/microservice/manager.py +61 -0
- office365/sharepoint/migrationcenter/common/task.py +193 -4
- office365/sharepoint/migrationcenter/common/task_entity_data.py +1 -1
- office365/sharepoint/migrationcenter/deploy_status.py +13 -0
- office365/sharepoint/migrationcenter/service/properties.py +20 -0
- office365/sharepoint/migrationcenter/service/storage.py +34 -0
- office365/sharepoint/migrationcenter/spflight.py +13 -0
- office365/sharepoint/migrationcenter/urlparseresult.py +77 -0
- office365/sharepoint/multigeo/service/spmultigeoproxy.py +13 -0
- office365/sharepoint/navigation/servicerest.py +127 -0
- office365/sharepoint/oauth/authentication.py +13 -0
- office365/sharepoint/portal/theme_manager.py +120 -8
- office365/sharepoint/principal/users/user.py +10 -0
- office365/sharepoint/publishing/campaign/campaign.py +36 -1
- office365/sharepoint/publishing/feedvideopage.py +0 -7
- office365/sharepoint/publishing/pages/3d/page.py +0 -6
- office365/sharepoint/publishing/pages/page.py +0 -117
- office365/sharepoint/publishing/pages/reposts/repost.py +0 -7
- office365/sharepoint/publishing/pages/topics/topic.py +0 -7
- office365/sharepoint/publishing/subtitlefile.py +0 -40
- office365/sharepoint/quotamanagement/consumer/consumer_quota_info_api.py +14 -0
- office365/sharepoint/quotamanagement/consumer/non_quota_backfill_api.py +23 -0
- office365/sharepoint/quotamanagement/consumer/nonquotamigrationapi.py +9 -0
- office365/sharepoint/quotamanagement/consumer/quotamigrationapi.py +13 -0
- office365/sharepoint/request.py +57 -16
- office365/sharepoint/search/administration/document_crawl_log.py +24 -7
- office365/sharepoint/search/administration/providers/crawl_versions_info.py +29 -0
- office365/sharepoint/search/administration/providers/recycle_bin_info.py +23 -0
- office365/sharepoint/search/administration/providers/site_content_processing_info.py +67 -0
- office365/sharepoint/sites/site.py +976 -65
- office365/sharepoint/sites/siteprovisioninginfoprovider.py +13 -0
- office365/sharepoint/tenant/administration/datagovernance/insight_client.py +292 -23
- office365/sharepoint/tenant/administration/ddiadapter.py +97 -0
- office365/sharepoint/tenant/administration/internal/aad/collection.py +20 -0
- office365/sharepoint/tenant/administration/internal/aad/permission_grant.py +0 -19
- office365/sharepoint/tenant/administration/internal/appservice/permission_request.py +72 -1
- office365/sharepoint/tenant/administration/internal/appservice/principal.py +1 -3
- office365/sharepoint/tenant/administration/odb_license_enforcement_service.py +26 -0
- office365/sharepoint/tenant/administration/sites/properties.py +564 -6
- office365/sharepoint/tenant/administration/spdatagovernancesarrestapiclient.py +60 -1
- office365/sharepoint/tenant/insights/reports/manager.py +24 -12
- office365/sharepoint/tenant/management/office365_tenant.py +451 -2
- office365/sharepoint/tenantadministration/catalog_management_collection.py +69 -0
- office365/sharepoint/tenantadministration/catalog_management_custom_properties_collection.py +237 -0
- office365/sharepoint/tenantadministration/catalog_management_restricted_content_discovery_collection.py +56 -0
- office365/sharepoint/tenantadministration/sp_data_governance_client_exception.py +7 -0
- office365/sharepoint/thresholds.py +529 -0
- office365/sharepoint/translation/jobs/job.py +72 -2
- office365/sharepoint/translation/jobs/status.py +47 -9
- office365/sharepoint/translation/sync_translator.py +15 -2
- office365/sharepoint/utilities/move_copy_util.py +2 -1
- office365/sharepoint/viva/connections/page.py +27 -0
- office365/sharepoint/viva/home.py +38 -0
- office365/sharepoint/viva/learningmanager.py +9 -0
- office365/sharepoint/viva/vivaresources.py +60 -0
- office365/sharepoint/webhooks/subscription.py +0 -11
- office365/sharepoint/webparts/definitions/definition.py +13 -0
- office365/sharepoint/webparts/limited_manager.py +29 -7
- office365/sharepoint/webs/context_web_information.py +14 -9
- office365/sharepoint/webs/templates/__init__.py +11 -0
- office365/sharepoint/webs/templates/type.py +74 -15
- office365/sharepoint/workflow/definition.py +6 -0
- office365/sharepoint/workflow/deployment_service.py +155 -0
- office365/sharepoint/workflow/instances/service.py +33 -0
- office365/sharepoint/workflow/interopservice.py +68 -0
- office365/sharepoint/workflow/messagingservice.py +24 -0
- office365/sharepoint/workflow/servicesmanager.py +41 -2
- office365/sharepoint/workflow/subscriptions/service.py +67 -0
- office365/sharepoint/workflow/subscriptions/subscription.py +31 -0
- office365/sharepoint/workflow/task.py +425 -0
- office365_rest_python_client-3.2.0.dist-info/METADATA +605 -0
- {office365_rest_python_client-3.1.0.dist-info → office365_rest_python_client-3.2.0.dist-info}/RECORD +242 -185
- office365/migration/_util.py +0 -73
- office365/sharepoint/listitems/caml/types.py +0 -70
- office365_rest_python_client-3.1.0.dist-info/METADATA +0 -475
- {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
|
|
8
|
+
Two distinct concerns live here:
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
22
|
-
from
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
except
|
|
131
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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__(
|
|
149
|
-
self
|
|
150
|
-
|
|
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.
|
|
368
|
+
wait = wait_delay(self._state, now=self._clock())
|
|
162
369
|
if wait <= 0:
|
|
163
370
|
return
|
|
164
|
-
|
|
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
|
-
|
|
181
|
-
self.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
"""
|
|
392
|
+
"""Pace every request of a context (or ``clone``) by wrapping its transport.
|
|
188
393
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
399
|
+
context: The client context to gate.
|
|
195
400
|
"""
|
|
196
|
-
|
|
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.
|
|
407
|
+
self._state = PaceState()
|