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,455 @@
|
|
|
1
|
+
"""Product-agnostic service limits, metadata and guardrails.
|
|
2
|
+
|
|
3
|
+
A :class:`Limit` describes one service limit — a static threshold *or* a rate
|
|
4
|
+
quota — with its value, kind, unit, scope and docs. Limits are declared on the
|
|
5
|
+
model with the :func:`limit` decorator (mirroring ``@odata`` / ``@require_permission``):
|
|
6
|
+
the declaration is stamped on the callable, appended to its docstring, and
|
|
7
|
+
collected into ``ClientObject._limit_meta``. ``arg=`` also *enforces* a numeric
|
|
8
|
+
argument (warn by default, ``raise`` or ``clamp``).
|
|
9
|
+
|
|
10
|
+
The helpers :func:`exceeds` / :func:`warn_if_exceeds` / :func:`ensure_within`
|
|
11
|
+
apply a limit imperatively at a call site; :func:`limits_of` / :func:`verify_limits`
|
|
12
|
+
inspect what a method/class declares.
|
|
13
|
+
|
|
14
|
+
Product packages (e.g. ``office365.sharepoint.thresholds``,
|
|
15
|
+
``office365.graph_limits``) declare the concrete values; this module only owns
|
|
16
|
+
the mechanics so the runtime and every product share them.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
import warnings
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from functools import wraps
|
|
26
|
+
from typing import Any, Callable, Iterable, Optional, Protocol, Tuple, TypeVar, overload, runtime_checkable
|
|
27
|
+
|
|
28
|
+
from typing_extensions import ParamSpec
|
|
29
|
+
|
|
30
|
+
P = ParamSpec("P")
|
|
31
|
+
R = TypeVar("R")
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
_ON_EXCEED = ("warn", "raise")
|
|
35
|
+
_LIMIT_MARKER = "__limit_decls__"
|
|
36
|
+
|
|
37
|
+
#: A conservative default page/chunk size that stays below SharePoint's 5,000-item
|
|
38
|
+
#: list view threshold, so paged reads and imports are safe by default.
|
|
39
|
+
SAFE_PAGE_SIZE = 2000
|
|
40
|
+
|
|
41
|
+
#: Default number of items per OData batch (also SharePoint's bulk-operation limit).
|
|
42
|
+
DEFAULT_BATCH_SIZE = 100
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LimitKind(Enum):
|
|
46
|
+
"""How strictly a limit is enforced (per Microsoft's definitions)."""
|
|
47
|
+
|
|
48
|
+
BOUNDARY = "boundary" # absolute, can't be exceeded by design
|
|
49
|
+
THRESHOLD = "threshold" # configurable default
|
|
50
|
+
SUPPORTED = "supported" # tested value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _human_bytes(value: int) -> str:
|
|
54
|
+
"""Format a byte count as ``250 GB`` when it divides evenly, else ``8,000 bytes``."""
|
|
55
|
+
for suffix, step in (("TB", 1024**4), ("GB", 1024**3), ("MB", 1024**2), ("KB", 1024)):
|
|
56
|
+
if value >= step and value % step == 0:
|
|
57
|
+
return f"{value // step} {suffix}"
|
|
58
|
+
return f"{value:,} bytes"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Limit:
|
|
63
|
+
"""A single service limit — a static threshold or a rate quota.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
name: Human-readable name (e.g. ``"list view threshold"``).
|
|
67
|
+
value: The numeric limit.
|
|
68
|
+
kind: :class:`LimitKind` — boundary / threshold / supported.
|
|
69
|
+
unit: What is counted (``items``, ``bytes``, ``requests``, ...).
|
|
70
|
+
scope: The object it applies to (``list``, ``file``, ``app+tenant``, ...).
|
|
71
|
+
note: Extra guidance surfaced in warnings.
|
|
72
|
+
doc: Link to the authoritative documentation.
|
|
73
|
+
window_seconds: The rate window in seconds (``None`` for a static
|
|
74
|
+
threshold; ``0`` for a concurrency limit).
|
|
75
|
+
request_type: ``""`` (n/a) | ``read`` | ``write`` | ``any``.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
name: str
|
|
79
|
+
value: int
|
|
80
|
+
kind: LimitKind = LimitKind.THRESHOLD
|
|
81
|
+
unit: str = "items"
|
|
82
|
+
scope: str = ""
|
|
83
|
+
note: str = ""
|
|
84
|
+
doc: str = ""
|
|
85
|
+
window_seconds: Optional[int] = None
|
|
86
|
+
request_type: str = ""
|
|
87
|
+
|
|
88
|
+
def __str__(self) -> str:
|
|
89
|
+
if self.window_seconds is not None:
|
|
90
|
+
window = f" / {self.window_seconds:,}s" if self.window_seconds else ""
|
|
91
|
+
return f"{self.value:,} {self.unit}{window}".strip()
|
|
92
|
+
if self.unit == "bytes":
|
|
93
|
+
return _human_bytes(self.value)
|
|
94
|
+
return f"{self.value:,} {self.unit}".strip()
|
|
95
|
+
|
|
96
|
+
def exceeds(self, value: int) -> bool:
|
|
97
|
+
"""Whether ``value`` is over this limit."""
|
|
98
|
+
return value > self.value
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def is_rate(self) -> bool:
|
|
102
|
+
"""Whether this is a rate quota (has a window)."""
|
|
103
|
+
return self.window_seconds is not None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class LimitExceededError(ValueError):
|
|
107
|
+
"""Raised when a value exceeds a service limit."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, limit: Limit, value: int, *, context: str = "") -> None:
|
|
110
|
+
self.limit = limit
|
|
111
|
+
self.value = value
|
|
112
|
+
self.context = context
|
|
113
|
+
super().__init__(hint(limit, context=context, value=value))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def exceeds(limit: Limit, value: int) -> bool:
|
|
117
|
+
"""Whether ``value`` is over ``limit``."""
|
|
118
|
+
return limit.exceeds(value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def hint(limit: Limit, *, context: str = "", value: Optional[int] = None) -> str:
|
|
122
|
+
"""An actionable message for a limit, optionally naming the offending value."""
|
|
123
|
+
where = f" for {context}" if context else ""
|
|
124
|
+
if value is None:
|
|
125
|
+
text = f"limit '{limit.name}'{where} is {limit}"
|
|
126
|
+
else:
|
|
127
|
+
text = f"{_format_value(limit, value)}{where} exceeds the limit '{limit.name}' ({limit})"
|
|
128
|
+
if limit.note:
|
|
129
|
+
text += f" — {limit.note}"
|
|
130
|
+
if limit.doc:
|
|
131
|
+
text += f" See {limit.doc}"
|
|
132
|
+
return text
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _format_value(limit: Limit, value: int) -> str:
|
|
136
|
+
if limit.unit == "bytes":
|
|
137
|
+
return _human_bytes(value)
|
|
138
|
+
return f"{value:,} {limit.unit}".strip()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def warn_if_exceeds(limit: Limit, value: int, *, context: str = "", stacklevel: int = 3) -> bool:
|
|
142
|
+
"""Warn when ``value`` is over ``limit``. Returns whether it warned."""
|
|
143
|
+
if not limit.exceeds(value):
|
|
144
|
+
return False
|
|
145
|
+
warnings.warn(hint(limit, context=context, value=value), UserWarning, stacklevel=stacklevel)
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ensure_within(
|
|
150
|
+
limit: Limit,
|
|
151
|
+
value: int,
|
|
152
|
+
*,
|
|
153
|
+
context: str = "",
|
|
154
|
+
on_exceed: str = "raise",
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Enforce ``limit`` for ``value`` — raise :class:`LimitExceededError` or warn.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
on_exceed: ``"raise"`` (default) or ``"warn"``.
|
|
160
|
+
"""
|
|
161
|
+
if on_exceed not in _ON_EXCEED:
|
|
162
|
+
raise ValueError(f"on_exceed must be one of {_ON_EXCEED}, got {on_exceed!r}")
|
|
163
|
+
if not limit.exceeds(value):
|
|
164
|
+
return
|
|
165
|
+
if on_exceed == "warn":
|
|
166
|
+
warn_if_exceeds(limit, value, context=context, stacklevel=4)
|
|
167
|
+
else:
|
|
168
|
+
raise LimitExceededError(limit, value, context=context)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class LimitDecl:
|
|
173
|
+
"""One declared limit — a :class:`Limit`, optionally bound to an argument.
|
|
174
|
+
|
|
175
|
+
``arg`` names the numeric argument the limit is enforced against (``None``
|
|
176
|
+
for a documentation-only declaration); ``on_exceed`` / ``clamp`` decide the
|
|
177
|
+
enforcement behaviour.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
limit: Limit
|
|
181
|
+
arg: Optional[str] = None
|
|
182
|
+
on_exceed: str = "warn"
|
|
183
|
+
clamp: bool = False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _append_doc(target: Callable[..., Any], decls: Tuple[LimitDecl, ...]) -> None:
|
|
187
|
+
lines: list[str] = []
|
|
188
|
+
for decl in decls:
|
|
189
|
+
bound = f" (<= {decl.limit})" if decl.arg else ""
|
|
190
|
+
lines.append(f" {decl.limit.name}: {decl.limit}{bound}")
|
|
191
|
+
if lines:
|
|
192
|
+
target.__doc__ = (target.__doc__ or "") + "\n Limits:\n" + "\n".join(lines)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@overload
|
|
196
|
+
def limit(
|
|
197
|
+
*limits: Limit,
|
|
198
|
+
arg: None = ...,
|
|
199
|
+
on_exceed: str = ...,
|
|
200
|
+
clamp: bool = ...,
|
|
201
|
+
) -> Callable[[T], T]: ...
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@overload
|
|
205
|
+
def limit(
|
|
206
|
+
*limits: Limit,
|
|
207
|
+
arg: str,
|
|
208
|
+
on_exceed: str = ...,
|
|
209
|
+
clamp: bool = ...,
|
|
210
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def limit(
|
|
214
|
+
*limits: Limit,
|
|
215
|
+
arg: Optional[str] = None,
|
|
216
|
+
on_exceed: str = "warn",
|
|
217
|
+
clamp: bool = False,
|
|
218
|
+
) -> Any:
|
|
219
|
+
"""Declare service limits on a class, method or property (and optionally enforce one).
|
|
220
|
+
|
|
221
|
+
Metadata-only by default (like ``@odata`` / ``@require_permission``): the
|
|
222
|
+
declared :class:`Limit` values are stamped on the target and appended to its
|
|
223
|
+
docstring, then collected into ``_limit_meta`` / ``_class_limit_decls``.
|
|
224
|
+
Pass ``arg`` to also enforce that argument against the (single) limit — warn
|
|
225
|
+
by default, ``on_exceed="raise"`` to raise, or ``clamp=True`` to lower it
|
|
226
|
+
(methods only).
|
|
227
|
+
|
|
228
|
+
Usage::
|
|
229
|
+
|
|
230
|
+
@limit(Limits.FILE_UPLOAD) # document
|
|
231
|
+
@limit(Limits.LIST_VIEW, arg="page_size") # document + warn
|
|
232
|
+
@limit(Limits.BATCH_ITEMS, arg="items_per_batch", on_exceed="raise")
|
|
233
|
+
@limit(*IDENTITY_QUOTAS) # on a class
|
|
234
|
+
class DirectoryObject(Entity): ...
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
*limits: The :class:`Limit` values to declare.
|
|
238
|
+
arg: Argument name to enforce (requires exactly one limit).
|
|
239
|
+
on_exceed: ``"warn"`` (default) or ``"raise"``.
|
|
240
|
+
clamp: Lower the argument to the limit instead of warning/raising.
|
|
241
|
+
"""
|
|
242
|
+
if arg is not None and len(limits) != 1:
|
|
243
|
+
raise ValueError("arg= requires exactly one limit")
|
|
244
|
+
if on_exceed not in _ON_EXCEED:
|
|
245
|
+
raise ValueError(f"on_exceed must be one of {_ON_EXCEED}, got {on_exceed!r}")
|
|
246
|
+
|
|
247
|
+
decls = tuple(
|
|
248
|
+
LimitDecl(limit=item, arg=arg if index == 0 else None, on_exceed=on_exceed, clamp=clamp)
|
|
249
|
+
for index, item in enumerate(limits)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def decorator(func: Any) -> Any:
|
|
253
|
+
if isinstance(func, type):
|
|
254
|
+
if arg is not None:
|
|
255
|
+
raise ValueError("arg= cannot be used on a class")
|
|
256
|
+
_stamp(func, decls)
|
|
257
|
+
# ``__init_subclass__`` ran before this decorator (members already
|
|
258
|
+
# collected); merge the class-level declaration so subclasses inherit.
|
|
259
|
+
func._class_limit_decls = collect_class_limits(func)
|
|
260
|
+
register_catalog(func)
|
|
261
|
+
return func
|
|
262
|
+
if isinstance(func, property):
|
|
263
|
+
if arg is not None:
|
|
264
|
+
raise ValueError("arg= cannot be used on a property (properties take no arguments)")
|
|
265
|
+
_stamp(func.fget, decls)
|
|
266
|
+
return func
|
|
267
|
+
|
|
268
|
+
_stamp(func, decls)
|
|
269
|
+
if arg is None:
|
|
270
|
+
return func
|
|
271
|
+
|
|
272
|
+
arg_name: str = arg
|
|
273
|
+
signature = inspect.signature(func)
|
|
274
|
+
enforced = decls[0]
|
|
275
|
+
|
|
276
|
+
@wraps(func)
|
|
277
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
278
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
279
|
+
value: Any = bound.arguments.get(arg_name)
|
|
280
|
+
if value is None or not enforced.limit.exceeds(value):
|
|
281
|
+
return func(*args, **kwargs)
|
|
282
|
+
if enforced.clamp:
|
|
283
|
+
bound.arguments[arg_name] = enforced.limit.value
|
|
284
|
+
return func(*bound.args, **bound.kwargs)
|
|
285
|
+
if enforced.on_exceed == "raise":
|
|
286
|
+
raise LimitExceededError(enforced.limit, value, context=func.__qualname__)
|
|
287
|
+
warn_if_exceeds(enforced.limit, value, context=func.__qualname__, stacklevel=2)
|
|
288
|
+
return func(*args, **kwargs)
|
|
289
|
+
|
|
290
|
+
return wrapper
|
|
291
|
+
|
|
292
|
+
return decorator
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _stamp(target: Any, decls: Tuple[LimitDecl, ...]) -> None:
|
|
296
|
+
setattr(target, _LIMIT_MARKER, decls)
|
|
297
|
+
_append_doc(target, decls)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
_limit = limit # module alias (the ``bounded`` param name shadows ``limit`` below)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def bounded(
|
|
304
|
+
arg: str,
|
|
305
|
+
limit: Limit,
|
|
306
|
+
*,
|
|
307
|
+
on_exceed: str = "warn",
|
|
308
|
+
clamp: bool = False,
|
|
309
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
310
|
+
"""Back-compat: ``@bounded(arg, limit)`` is ``@limit(limit, arg=arg)``."""
|
|
311
|
+
return _limit(limit, arg=arg, on_exceed=on_exceed, clamp=clamp)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def collect_limit_meta(cls: type) -> dict[str, Tuple[LimitDecl, ...]]:
|
|
315
|
+
"""Collect ``@limit`` declarations from a class's methods/properties.
|
|
316
|
+
|
|
317
|
+
Called from ``__init_subclass__`` (``ClientObject`` / ``ClientRuntimeContext``)
|
|
318
|
+
so a class carries its own limits — keyed by attribute/method name, inheriting
|
|
319
|
+
the base class's entries.
|
|
320
|
+
"""
|
|
321
|
+
meta: dict[str, Tuple[LimitDecl, ...]] = dict(getattr(cls, "_limit_meta", {}))
|
|
322
|
+
for attr_name, attr in cls.__dict__.items():
|
|
323
|
+
target = attr.fget if isinstance(attr, property) else attr
|
|
324
|
+
decls = getattr(target, _LIMIT_MARKER, None)
|
|
325
|
+
if decls is not None:
|
|
326
|
+
meta[attr_name] = decls
|
|
327
|
+
return meta
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@runtime_checkable
|
|
331
|
+
class LimitCatalog(Protocol):
|
|
332
|
+
"""A source of :class:`Limit` values — anything with a ``catalog()`` method."""
|
|
333
|
+
|
|
334
|
+
def catalog(self) -> Iterable[Limit]: ...
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
#: Registered limit sources, keyed by product (``"model"`` for ``@limit`` classes).
|
|
338
|
+
_SOURCES: dict[str, list[Any]] = {}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _limits_from(source: Any) -> list[Limit]:
|
|
342
|
+
catalog_fn = getattr(source, "catalog", None)
|
|
343
|
+
if catalog_fn is not None:
|
|
344
|
+
return list(catalog_fn())
|
|
345
|
+
declared = getattr(source, "declared_limits", None)
|
|
346
|
+
if declared is not None:
|
|
347
|
+
return list(declared())
|
|
348
|
+
raise TypeError(f"{source!r} is not a Limit catalog (needs catalog() or declared_limits())")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def register_catalog(*sources: Any, product: str = "model") -> None:
|
|
352
|
+
"""Register limit sources — catalogs (``catalog()``) or classes (``declared_limits()``).
|
|
353
|
+
|
|
354
|
+
Products register their own catalogs at import time (e.g.
|
|
355
|
+
``register_catalog(Limits, product="sharepoint")``); ``@limit`` on a class
|
|
356
|
+
registers that class under ``product="model"``.
|
|
357
|
+
"""
|
|
358
|
+
bucket = _SOURCES.setdefault(product, [])
|
|
359
|
+
for source in sources:
|
|
360
|
+
if source not in bucket:
|
|
361
|
+
bucket.append(source)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def catalog(product: Optional[str] = None) -> list[Limit]:
|
|
365
|
+
"""All registered limits (optionally for one product), de-duplicated by identity."""
|
|
366
|
+
products = [product] if product is not None else list(_SOURCES)
|
|
367
|
+
seen: set[int] = set()
|
|
368
|
+
result: list[Limit] = []
|
|
369
|
+
for name in products:
|
|
370
|
+
for source in _SOURCES.get(name, ()):
|
|
371
|
+
for item in _limits_from(source):
|
|
372
|
+
if id(item) not in seen:
|
|
373
|
+
seen.add(id(item))
|
|
374
|
+
result.append(item)
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def collect_class_limits(cls: type) -> Tuple[LimitDecl, ...]:
|
|
379
|
+
"""The class-level ``@limit`` declarations of ``cls``.
|
|
380
|
+
|
|
381
|
+
Accumulates up the hierarchy: the base class's declarations plus the class's
|
|
382
|
+
own (a subclass is subject to both).
|
|
383
|
+
"""
|
|
384
|
+
inherited = getattr(cls, "_class_limit_decls", ())
|
|
385
|
+
own = cls.__dict__.get(_LIMIT_MARKER)
|
|
386
|
+
if own is None:
|
|
387
|
+
return inherited
|
|
388
|
+
return (*inherited, *own)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def limits_of(target: Any) -> Tuple[LimitDecl, ...]:
|
|
392
|
+
"""The limits declared on a method/property, or a class's collected limits.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
target: A callable (reads ``__limit_decls__``), a ``property``, or a
|
|
396
|
+
class (reads ``_limit_meta``).
|
|
397
|
+
"""
|
|
398
|
+
if isinstance(target, type):
|
|
399
|
+
meta = getattr(target, "_limit_meta", {})
|
|
400
|
+
return tuple(decl for decls in meta.values() for decl in decls)
|
|
401
|
+
if isinstance(target, property):
|
|
402
|
+
target = target.fget
|
|
403
|
+
return tuple(getattr(target, _LIMIT_MARKER, ()))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@dataclass
|
|
407
|
+
class LimitReport:
|
|
408
|
+
"""Diagnostic result of inspecting the limits declared on a target.
|
|
409
|
+
|
|
410
|
+
``violations`` holds the ``(decl, value)`` pairs whose supplied value is over
|
|
411
|
+
the limit. This never raises and never touches the network.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
target: str
|
|
415
|
+
decls: Tuple[LimitDecl, ...] = ()
|
|
416
|
+
violations: Tuple[Tuple[LimitDecl, int], ...] = field(default_factory=tuple)
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def limits(self) -> Tuple[Limit, ...]:
|
|
420
|
+
return tuple(decl.limit for decl in self.decls)
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def ok(self) -> bool:
|
|
424
|
+
"""Whether all supplied values are within their limits."""
|
|
425
|
+
return not self.violations
|
|
426
|
+
|
|
427
|
+
def __str__(self) -> str:
|
|
428
|
+
head = f"{self.target}: {len(self.decls)} limit(s)"
|
|
429
|
+
if self.violations:
|
|
430
|
+
over = ", ".join(f"{decl.limit.name}={value:,}" for decl, value in self.violations)
|
|
431
|
+
return f"{head}; over: {over}"
|
|
432
|
+
return head
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def verify_limits(target: Any, **values: int) -> LimitReport:
|
|
436
|
+
"""Inspect the limits declared on a method/property/class (diagnostic-only).
|
|
437
|
+
|
|
438
|
+
Keyword ``values`` are checked against the arg-bound declarations
|
|
439
|
+
(``@limit(..., arg=...)``); other limits are reported for reference.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
target: A callable, ``property``, or class.
|
|
443
|
+
**values: Argument name -> value to check.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
A :class:`LimitReport`.
|
|
447
|
+
"""
|
|
448
|
+
name = getattr(target, "__qualname__", None) or getattr(target, "__name__", str(target))
|
|
449
|
+
decls = limits_of(target)
|
|
450
|
+
violations = tuple(
|
|
451
|
+
(decl, values[decl.arg])
|
|
452
|
+
for decl in decls
|
|
453
|
+
if decl.arg is not None and decl.arg in values and decl.limit.exceeds(values[decl.arg])
|
|
454
|
+
)
|
|
455
|
+
return LimitReport(target=name, decls=decls, violations=violations)
|
|
@@ -20,19 +20,28 @@ from office365.runtime.queries.delete_entity import DeleteEntityQuery
|
|
|
20
20
|
from office365.runtime.queries.function import FunctionQuery
|
|
21
21
|
from office365.runtime.queries.service_operation import ServiceOperationQuery
|
|
22
22
|
from office365.runtime.queries.update_entity import UpdateEntityQuery
|
|
23
|
+
from office365.runtime.transport.base import BaseTransport
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class ODataRequest(ClientRequest):
|
|
26
27
|
"""Handles OData protocol specific request/response processing for API calls."""
|
|
27
28
|
|
|
28
|
-
def __init__(
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
base_url: str,
|
|
32
|
+
json_format: ODataJsonFormat,
|
|
33
|
+
transport: "BaseTransport | None" = None,
|
|
34
|
+
) -> None:
|
|
29
35
|
"""
|
|
30
36
|
Initialize a new OData request processor.
|
|
31
37
|
|
|
32
38
|
Args:
|
|
39
|
+
base_url: The service base URL.
|
|
33
40
|
json_format: The JSON format handler for OData serialization/deserialization
|
|
41
|
+
transport: Optional shared transport (e.g. one pooled ``Session`` for
|
|
42
|
+
parallel batch requests); defaults to a new ``RequestsTransport``.
|
|
34
43
|
"""
|
|
35
|
-
super().__init__()
|
|
44
|
+
super().__init__(transport=transport)
|
|
36
45
|
self._base_url = base_url
|
|
37
46
|
self._default_json_format = json_format
|
|
38
47
|
self.beforeExecute += self._ensure_http_headers
|
office365/runtime/odata/type.py
CHANGED
|
@@ -22,6 +22,7 @@ _PRIMITIVE_TYPES = {
|
|
|
22
22
|
"Edm.Duration": datetime.timedelta,
|
|
23
23
|
"Edm.Date": datetime.date,
|
|
24
24
|
"Edm.TimeOfDay": datetime.time,
|
|
25
|
+
"Edm.Time": datetime.time, # OData v3 name for a time-of-day (kept after TimeOfDay for reverse lookup)
|
|
25
26
|
"Edm.Json": dict,
|
|
26
27
|
"Collection(SP.KeyValue)": dict,
|
|
27
28
|
"Collection(Edm.Guid)": GuidCollection,
|
|
@@ -53,6 +53,7 @@ class ODataBatchV3Request(ODataRequest):
|
|
|
53
53
|
query: The original batch query
|
|
54
54
|
"""
|
|
55
55
|
for sub_qry, sub_resp in self._extract_response(response, query):
|
|
56
|
+
self._observe_throttle(sub_resp)
|
|
56
57
|
sub_resp.raise_for_status()
|
|
57
58
|
super().process_response(sub_resp, sub_qry)
|
|
58
59
|
|
|
@@ -77,7 +78,7 @@ class ODataBatchV3Request(ODataRequest):
|
|
|
77
78
|
base_delay: Base delay for exponential backoff (seconds)
|
|
78
79
|
jitter: Whether to randomize the delay (default True)
|
|
79
80
|
"""
|
|
80
|
-
from office365.runtime.retry import TRANSIENT_STATUS_CODES, response_retry_after, retry
|
|
81
|
+
from office365.runtime.retry import TRANSIENT_STATUS_CODES, response_retry_after, retry, retry_after_delay
|
|
81
82
|
|
|
82
83
|
state: dict = {"pending": query, "retry_after": None}
|
|
83
84
|
|
|
@@ -94,6 +95,7 @@ class ODataBatchV3Request(ODataRequest):
|
|
|
94
95
|
failures: list[tuple[ClientQuery, Response]] = []
|
|
95
96
|
retry_after: Optional[int] = None
|
|
96
97
|
for sub_qry, sub_resp in self._extract_response(response, state["pending"]):
|
|
98
|
+
self._observe_throttle(sub_resp)
|
|
97
99
|
if sub_resp.status_code in TRANSIENT_STATUS_CODES:
|
|
98
100
|
failures.append((sub_qry, sub_resp))
|
|
99
101
|
retry_after = max(retry_after or 0, response_retry_after(sub_resp) or 0)
|
|
@@ -113,7 +115,7 @@ class ODataBatchV3Request(ODataRequest):
|
|
|
113
115
|
max_retry=max_retry,
|
|
114
116
|
timeout_secs=base_delay,
|
|
115
117
|
jitter=jitter,
|
|
116
|
-
on_failure=lambda _attempt_num,
|
|
118
|
+
on_failure=lambda _attempt_num, ex: state["retry_after"] or retry_after_delay(ex),
|
|
117
119
|
)
|
|
118
120
|
except WholeBatchRejected as reject:
|
|
119
121
|
self._split_and_retry(query, reject, max_retry, base_delay, jitter)
|
|
@@ -51,6 +51,7 @@ class ODataV4BatchRequest(ODataRequest):
|
|
|
51
51
|
HTTPError: If any sub-request in the batch fails
|
|
52
52
|
"""
|
|
53
53
|
for sub_qry, sub_resp in self._extract_response(response, query):
|
|
54
|
+
self._observe_throttle(sub_resp)
|
|
54
55
|
sub_resp.raise_for_status()
|
|
55
56
|
super().process_response(sub_resp, sub_qry)
|
|
56
57
|
|
|
@@ -76,7 +77,7 @@ class ODataV4BatchRequest(ODataRequest):
|
|
|
76
77
|
base_delay: Base delay for exponential backoff (seconds)
|
|
77
78
|
jitter: Whether to randomize the delay (default True)
|
|
78
79
|
"""
|
|
79
|
-
from office365.runtime.retry import TRANSIENT_STATUS_CODES, response_retry_after, retry
|
|
80
|
+
from office365.runtime.retry import TRANSIENT_STATUS_CODES, response_retry_after, retry, retry_after_delay
|
|
80
81
|
|
|
81
82
|
state: dict = {"pending": query, "retry_after": None}
|
|
82
83
|
|
|
@@ -93,6 +94,7 @@ class ODataV4BatchRequest(ODataRequest):
|
|
|
93
94
|
failures: list[tuple[ClientQuery, Response]] = []
|
|
94
95
|
retry_after: Optional[int] = None
|
|
95
96
|
for sub_qry, sub_resp in self._extract_response(response, state["pending"]):
|
|
97
|
+
self._observe_throttle(sub_resp)
|
|
96
98
|
if sub_resp.status_code in TRANSIENT_STATUS_CODES:
|
|
97
99
|
failures.append((sub_qry, sub_resp))
|
|
98
100
|
retry_after = max(retry_after or 0, response_retry_after(sub_resp) or 0)
|
|
@@ -103,7 +105,7 @@ class ODataV4BatchRequest(ODataRequest):
|
|
|
103
105
|
self.afterExecute(response)
|
|
104
106
|
return
|
|
105
107
|
state["retry_after"] = retry_after or None
|
|
106
|
-
state["pending"] = BatchQuery(query.context, [qry for qry, _ in failures])
|
|
108
|
+
state["pending"] = BatchQuery(query.context, [qry for qry, _ in failures], sequential=query.sequential)
|
|
107
109
|
raise ClientRequestException.from_response(failures[0][1])
|
|
108
110
|
|
|
109
111
|
try:
|
|
@@ -112,7 +114,7 @@ class ODataV4BatchRequest(ODataRequest):
|
|
|
112
114
|
max_retry=max_retry,
|
|
113
115
|
timeout_secs=base_delay,
|
|
114
116
|
jitter=jitter,
|
|
115
|
-
on_failure=lambda _attempt_num,
|
|
117
|
+
on_failure=lambda _attempt_num, ex: state["retry_after"] or retry_after_delay(ex),
|
|
116
118
|
)
|
|
117
119
|
except WholeBatchRejected as reject:
|
|
118
120
|
self._split_and_retry(query, reject, max_retry, base_delay, jitter)
|
|
@@ -134,7 +136,9 @@ class ODataV4BatchRequest(ODataRequest):
|
|
|
134
136
|
raise ClientRequestException(message, response=reject.response) from reject
|
|
135
137
|
mid = len(queries) // 2 # noqa: PLR2004
|
|
136
138
|
for half in (queries[:mid], queries[mid:]):
|
|
137
|
-
self.execute_query_with_retry(
|
|
139
|
+
self.execute_query_with_retry(
|
|
140
|
+
BatchQuery(query.context, half, sequential=query.sequential), max_retry, base_delay, jitter
|
|
141
|
+
)
|
|
138
142
|
|
|
139
143
|
@staticmethod
|
|
140
144
|
def _extract_response(response: Response, query: BatchQuery) -> Iterator[Tuple[ClientQuery, Response]]:
|
|
@@ -154,22 +158,33 @@ class ODataV4BatchRequest(ODataRequest):
|
|
|
154
158
|
resp.headers = CaseInsensitiveDict(json_resp["headers"])
|
|
155
159
|
resp._content = json.dumps(json_resp["body"]).encode("utf-8")
|
|
156
160
|
qry_id = int(json_resp["id"])
|
|
157
|
-
|
|
161
|
+
# Ids are assigned in submission order (see ``_prepare_payload``), so
|
|
162
|
+
# map back with the same list — ``ordered_queries`` re-groups (non-GET
|
|
163
|
+
# first) and would attribute mixed batches to the wrong queries.
|
|
164
|
+
qry = query.queries[qry_id]
|
|
158
165
|
yield qry, resp
|
|
159
166
|
|
|
160
167
|
def _prepare_payload(self, query: BatchQuery) -> Dict[str, Any]:
|
|
161
168
|
"""Prepares the batch request payload.
|
|
162
169
|
|
|
170
|
+
With ``query.sequential`` the sub-requests are chained with ``dependsOn``
|
|
171
|
+
so Graph runs them in order (a failed dependency yields ``424``). Ids are
|
|
172
|
+
assigned in submission order — the same order :meth:`_extract_response`
|
|
173
|
+
maps responses back with.
|
|
174
|
+
|
|
163
175
|
Args:
|
|
164
176
|
query: The BatchQuery containing individual queries
|
|
165
177
|
|
|
166
178
|
Returns:
|
|
167
179
|
Dictionary containing the JSON batch request structure
|
|
168
180
|
"""
|
|
169
|
-
requests_json = []
|
|
181
|
+
requests_json: list[dict] = []
|
|
182
|
+
previous_id: Optional[str] = None
|
|
170
183
|
for qry in query.queries:
|
|
171
184
|
qry_id = str(len(requests_json))
|
|
172
|
-
|
|
185
|
+
depends_on = [previous_id] if (query.sequential and previous_id is not None) else None
|
|
186
|
+
requests_json.append(self._normalize_request(qry, qry_id, depends_on))
|
|
187
|
+
previous_id = qry_id
|
|
173
188
|
|
|
174
189
|
return {"requests": requests_json}
|
|
175
190
|
|
office365/runtime/operations.py
CHANGED
|
@@ -49,6 +49,35 @@ class Progress(Generic[T_co]):
|
|
|
49
49
|
ProgressCallback = Callable[[Progress[Any]], None]
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def emit_progress(
|
|
53
|
+
progress: Optional[ProgressCallback],
|
|
54
|
+
*,
|
|
55
|
+
done: int,
|
|
56
|
+
total: Optional[int] = None,
|
|
57
|
+
stage: str = "",
|
|
58
|
+
items: Optional[Sequence[Any]] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Invoke a progress hook (if any) with a :class:`Progress` snapshot."""
|
|
61
|
+
if callable(progress):
|
|
62
|
+
progress(Progress(done=done, total=total, stage=stage, items=items))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class OperationStats:
|
|
67
|
+
"""Common counters shared by bulk operations (imports, migrations).
|
|
68
|
+
|
|
69
|
+
The base carries only the fields every operation can report, so a consumer
|
|
70
|
+
that needs just totals accepts any specialization (``ImportStats``,
|
|
71
|
+
``MigrationStats``) without a lossy conversion. Specializations add their own
|
|
72
|
+
extras (``chunks``/``duration``, ``bytes_transferred``).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
total: int = 0
|
|
76
|
+
success: int = 0
|
|
77
|
+
skipped: int = 0
|
|
78
|
+
errors: int = 0
|
|
79
|
+
|
|
80
|
+
|
|
52
81
|
class ProgressTracker:
|
|
53
82
|
"""Shared emitter that turns operation sub-steps into ``Progress`` snapshots.
|
|
54
83
|
|