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,571 @@
|
|
|
1
|
+
"""Deferred, streaming, resumable import driver for client-object collections.
|
|
2
|
+
|
|
3
|
+
An import is expressed as a **stream of chunks** plus a format adapter
|
|
4
|
+
(``to_records``) that turns each chunk into plain dict records, so the same
|
|
5
|
+
driver serves DataFrames, CSV/JSON streams, or any future source. The driver
|
|
6
|
+
carries **no execution knobs** — the caller picks the terminal, mirroring how
|
|
7
|
+
mature SDKs separate a deferred handle (BigQuery ``QueryJob``, Azure
|
|
8
|
+
``LROPoller``) from its execution:
|
|
9
|
+
|
|
10
|
+
- :meth:`ImportResult.execute_query` — sequential (one request per item);
|
|
11
|
+
- :meth:`ImportResult.execute_batch` — server-side OData batches, optionally
|
|
12
|
+
concurrent;
|
|
13
|
+
- ``for collection in import_result:`` — stream chunk by chunk and drive
|
|
14
|
+
execution yourself.
|
|
15
|
+
|
|
16
|
+
Chunks are queued, executed, and then discarded (``collection.clear()``), so
|
|
17
|
+
memory stays bounded regardless of the total item count.
|
|
18
|
+
|
|
19
|
+
For long-running jobs, pass a ``checkpoint`` (an :class:`ImportCheckpoint` or a
|
|
20
|
+
path). The driver persists the committed cursor after each chunk, so an
|
|
21
|
+
interrupted run resumes by skipping the already-committed records — at-least-once
|
|
22
|
+
(a crash between the server commit and the checkpoint write can replay the last
|
|
23
|
+
chunk). With ``on_error="collect"`` a failing chunk is recorded and skipped
|
|
24
|
+
instead of aborting the run.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
import warnings
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from itertools import islice
|
|
35
|
+
from os import PathLike
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import (
|
|
38
|
+
TYPE_CHECKING,
|
|
39
|
+
Any,
|
|
40
|
+
Callable,
|
|
41
|
+
Iterable,
|
|
42
|
+
Iterator,
|
|
43
|
+
Optional,
|
|
44
|
+
Protocol,
|
|
45
|
+
Union,
|
|
46
|
+
cast,
|
|
47
|
+
runtime_checkable,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from typing_extensions import Self
|
|
51
|
+
|
|
52
|
+
from office365.runtime.client_result import ClientResult
|
|
53
|
+
from office365.runtime.limits import DEFAULT_BATCH_SIZE
|
|
54
|
+
from office365.runtime.operations import OperationStats
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from office365.runtime.operations import ProgressCallback
|
|
58
|
+
from office365.runtime.verification import VerificationReport
|
|
59
|
+
|
|
60
|
+
ON_ERROR_MODES = ("raise", "collect")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class RecordSink(Protocol):
|
|
65
|
+
"""Minimal target contract for :class:`ImportResult` (a queueable collection)."""
|
|
66
|
+
|
|
67
|
+
def queue_records(self, records: list[dict], progress: "ProgressCallback | None" = None) -> Any:
|
|
68
|
+
"""Queue a create per record."""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
def clear(self) -> Any:
|
|
72
|
+
"""Discard queued/loaded entities (keeps memory bounded)."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ImportStats(OperationStats):
|
|
78
|
+
"""Outcome of a streaming import (the ``ImportResult.value``).
|
|
79
|
+
|
|
80
|
+
Counts are **per invocation**: ``total`` records attempted, ``success``
|
|
81
|
+
committed, ``errors`` skipped under ``on_error="collect"`` (a chunk's size per
|
|
82
|
+
failed chunk). For the overall committed total across resumes, read
|
|
83
|
+
``ImportCheckpoint.cursor``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
chunks: int = 0
|
|
87
|
+
duration: float = 0.0
|
|
88
|
+
resumed_from: int = 0 # records already committed by a previous run
|
|
89
|
+
|
|
90
|
+
def summary(self) -> str:
|
|
91
|
+
resumed = f" (resumed at {self.resumed_from:,})" if self.resumed_from else ""
|
|
92
|
+
return (
|
|
93
|
+
f"Imported {self.success:,}/{self.total:,} item(s) "
|
|
94
|
+
f"in {self.chunks} chunk(s) over {self.duration:.1f}s{resumed}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ImportCheckpoint:
|
|
100
|
+
"""Persisted run state enabling resume of a streaming import.
|
|
101
|
+
|
|
102
|
+
``cursor`` is the number of records committed so far (always a chunk
|
|
103
|
+
boundary); a resumed run skips exactly that many leading records. ``failures``
|
|
104
|
+
holds chunk-level errors collected under ``on_error="collect"``.
|
|
105
|
+
|
|
106
|
+
``signature`` fingerprints the source (format, chunk size, key columns/key
|
|
107
|
+
field). When a resumed run's signature differs, the chunk-based skip is
|
|
108
|
+
discarded and the source is re-scanned from the start — the keyed skip then
|
|
109
|
+
keeps the import idempotent. This guards against a changed ``chunksize`` or
|
|
110
|
+
key definition silently skipping/duplicating records.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
cursor: int = 0
|
|
114
|
+
chunks: int = 0
|
|
115
|
+
errors: int = 0
|
|
116
|
+
failures: list[dict] = field(default_factory=list)
|
|
117
|
+
signature: dict = field(default_factory=dict)
|
|
118
|
+
updated_at: str = ""
|
|
119
|
+
|
|
120
|
+
def save(self, path: Union[str, PathLike]) -> None:
|
|
121
|
+
"""Persist the checkpoint as JSON, atomically (write-then-rename).
|
|
122
|
+
|
|
123
|
+
The rename is atomic, so a crash mid-write never leaves a truncated file
|
|
124
|
+
that would break the next resume.
|
|
125
|
+
"""
|
|
126
|
+
self.updated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
127
|
+
target = Path(path)
|
|
128
|
+
tmp = target.with_name(f".{target.name}.tmp")
|
|
129
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
130
|
+
json.dump(
|
|
131
|
+
{
|
|
132
|
+
"cursor": self.cursor,
|
|
133
|
+
"chunks": self.chunks,
|
|
134
|
+
"errors": self.errors,
|
|
135
|
+
"failures": self.failures,
|
|
136
|
+
"signature": self.signature,
|
|
137
|
+
"updated_at": self.updated_at,
|
|
138
|
+
},
|
|
139
|
+
f,
|
|
140
|
+
indent=2,
|
|
141
|
+
)
|
|
142
|
+
tmp.replace(target)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def load(cls, path: Union[str, PathLike]) -> "ImportCheckpoint":
|
|
146
|
+
"""Load a checkpoint previously persisted with :meth:`save`."""
|
|
147
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
148
|
+
data = json.load(f)
|
|
149
|
+
return cls(
|
|
150
|
+
cursor=int(data.get("cursor", 0)),
|
|
151
|
+
chunks=int(data.get("chunks", 0)),
|
|
152
|
+
errors=int(data.get("errors", 0)),
|
|
153
|
+
failures=list(data.get("failures", [])),
|
|
154
|
+
signature=dict(data.get("signature", {})),
|
|
155
|
+
updated_at=data.get("updated_at", ""),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@runtime_checkable
|
|
160
|
+
class CheckpointStore(Protocol):
|
|
161
|
+
"""Persistence for :class:`ImportCheckpoint` — pluggable, MSAL-cache style."""
|
|
162
|
+
|
|
163
|
+
def load(self) -> ImportCheckpoint:
|
|
164
|
+
"""Return the persisted checkpoint (a fresh one when none exists)."""
|
|
165
|
+
...
|
|
166
|
+
|
|
167
|
+
def save(self, checkpoint: ImportCheckpoint) -> None:
|
|
168
|
+
"""Persist the checkpoint."""
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class MemoryCheckpointStore:
|
|
173
|
+
"""In-memory checkpoint store (the default when no path is given)."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, checkpoint: Optional[ImportCheckpoint] = None) -> None:
|
|
176
|
+
self._checkpoint = checkpoint or ImportCheckpoint()
|
|
177
|
+
|
|
178
|
+
def load(self) -> ImportCheckpoint:
|
|
179
|
+
return self._checkpoint
|
|
180
|
+
|
|
181
|
+
def save(self, checkpoint: ImportCheckpoint) -> None:
|
|
182
|
+
self._checkpoint = checkpoint
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class FileCheckpointStore:
|
|
186
|
+
"""JSON-file checkpoint store (atomic write-then-rename)."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, path: Union[str, PathLike]) -> None:
|
|
189
|
+
self._path = Path(path)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def path(self) -> Path:
|
|
193
|
+
return self._path
|
|
194
|
+
|
|
195
|
+
def load(self) -> ImportCheckpoint:
|
|
196
|
+
return ImportCheckpoint.load(self._path) if self._path.exists() else ImportCheckpoint()
|
|
197
|
+
|
|
198
|
+
def save(self, checkpoint: ImportCheckpoint) -> None:
|
|
199
|
+
checkpoint.save(self._path)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ImportResult(ClientResult[ImportStats]):
|
|
203
|
+
"""Deferred, source-agnostic streaming import driver.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
context: The runtime context used to execute.
|
|
207
|
+
collection: The target ``ClientObjectCollection`` (queued via
|
|
208
|
+
``from_records`` and cleared per chunk).
|
|
209
|
+
chunks: Iterable of source chunks (format-defined).
|
|
210
|
+
to_records: Turns one chunk into plain dict records.
|
|
211
|
+
prepare: Optional once-only setup called with the first chunk (e.g.
|
|
212
|
+
schema/field provisioning); it may execute. Skipped when resuming.
|
|
213
|
+
total: Total items when known upfront (drives the progress hook).
|
|
214
|
+
progress: Optional ``ProgressCallback`` fired immediately (with the
|
|
215
|
+
resumed offset), after each committed chunk, and per completed batch
|
|
216
|
+
during ``execute_batch``; ``done`` is the overall committed count.
|
|
217
|
+
checkpoint: Persistence for the run state: a path
|
|
218
|
+
(:class:`FileCheckpointStore`), an :class:`ImportCheckpoint` or
|
|
219
|
+
``None`` (:class:`MemoryCheckpointStore`), or any
|
|
220
|
+
:class:`CheckpointStore`. Loaded at construction, written after each
|
|
221
|
+
chunk. ``ImportResult.resumed_from`` reports the loaded offset.
|
|
222
|
+
on_error: ``"raise"`` (default) aborts on the first failed chunk;
|
|
223
|
+
``"collect"`` records the failure (``ImportStats.errors`` +
|
|
224
|
+
``checkpoint.failures``), skips the chunk, and continues.
|
|
225
|
+
queue: Optional conflict-resolution hook ``queue(records) -> (queued, skipped)``
|
|
226
|
+
that queues the chunk's creates/updates and reports how many records
|
|
227
|
+
were queued and how many were skipped (already present). Defaults to
|
|
228
|
+
``collection.queue_records(records)`` (all created, none skipped).
|
|
229
|
+
dry_run: When True, compute the outcome (and the keyed create/update/skip
|
|
230
|
+
plan) without writing anything — a plan preview.
|
|
231
|
+
dead_letter: Optional JSONL path for failed records (used with
|
|
232
|
+
``on_error="collect"``). When set, a chunk is executed
|
|
233
|
+
record-by-record so each failing row is appended as
|
|
234
|
+
``{"row": ..., "error": ..., "record": {...}}``; without it a failed
|
|
235
|
+
chunk is recorded whole (``{"error": ..., "records": [...]}``).
|
|
236
|
+
signature: Optional source fingerprint (format, chunk size, key columns)
|
|
237
|
+
persisted in the checkpoint. When a resumed run's signature differs,
|
|
238
|
+
the chunk-based skip is discarded and the source is re-scanned from
|
|
239
|
+
the start (the keyed skip keeps it idempotent).
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
context,
|
|
245
|
+
collection: "RecordSink",
|
|
246
|
+
chunks: Iterable[Any],
|
|
247
|
+
*,
|
|
248
|
+
to_records: Callable[[Any], list[dict]],
|
|
249
|
+
prepare: Optional[Callable[[Any], None]] = None,
|
|
250
|
+
before_chunk: Optional[Callable[[Any], None]] = None,
|
|
251
|
+
queue: Optional[Callable[[list[dict]], tuple[int, int]]] = None,
|
|
252
|
+
total: Optional[int] = None,
|
|
253
|
+
progress: Optional["ProgressCallback"] = None,
|
|
254
|
+
checkpoint: Union["ImportCheckpoint", "CheckpointStore", str, PathLike, None] = None,
|
|
255
|
+
on_error: str = "raise",
|
|
256
|
+
dry_run: bool = False,
|
|
257
|
+
dead_letter: Union[str, PathLike, None] = None,
|
|
258
|
+
signature: Optional[dict] = None,
|
|
259
|
+
) -> None:
|
|
260
|
+
super().__init__(context, ImportStats())
|
|
261
|
+
if on_error not in ON_ERROR_MODES:
|
|
262
|
+
raise ValueError(f"on_error must be one of {ON_ERROR_MODES}, got {on_error!r}")
|
|
263
|
+
self._collection = collection
|
|
264
|
+
self._chunks = iter(chunks)
|
|
265
|
+
self._to_records = to_records
|
|
266
|
+
self._prepare = prepare
|
|
267
|
+
self._before_chunk = before_chunk
|
|
268
|
+
self._queue_fn = queue or self._default_queue
|
|
269
|
+
self._total = total
|
|
270
|
+
self._progress = progress
|
|
271
|
+
self._on_error = on_error
|
|
272
|
+
self._dry_run = dry_run
|
|
273
|
+
self._dead_letter = Path(dead_letter) if dead_letter is not None else None
|
|
274
|
+
self._started_at: Optional[float] = None
|
|
275
|
+
self._store = self._resolve_store(checkpoint)
|
|
276
|
+
self._checkpoint = self._store.load()
|
|
277
|
+
self._apply_signature(signature)
|
|
278
|
+
self._progress_base = self._checkpoint.cursor # records committed before this run
|
|
279
|
+
self._done = self._progress_base # overall committed count reported to ``progress``
|
|
280
|
+
self.value.resumed_from = self._progress_base
|
|
281
|
+
|
|
282
|
+
# ── Terminals ────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
def execute_query(self) -> Self:
|
|
285
|
+
"""Import sequentially (one request per item)."""
|
|
286
|
+
self._run(lambda: self._context.execute_query())
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
def execute_batch(
|
|
290
|
+
self,
|
|
291
|
+
items_per_batch: int = DEFAULT_BATCH_SIZE,
|
|
292
|
+
max_batch_bytes: Optional[int] = None,
|
|
293
|
+
concurrency: int = 1,
|
|
294
|
+
success_callback: Optional[Callable[[Any], None]] = None,
|
|
295
|
+
) -> Self:
|
|
296
|
+
"""Import via server-side OData batches (optionally concurrent).
|
|
297
|
+
|
|
298
|
+
Progress is reported **per completed batch** (not just per chunk), so a
|
|
299
|
+
long chunk advances the ``progress`` hook live as sub-batches land.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def _on_batch(return_types: Any) -> None:
|
|
303
|
+
self._report(self._done + (len(return_types) if return_types is not None else 0))
|
|
304
|
+
if callable(success_callback):
|
|
305
|
+
success_callback(return_types)
|
|
306
|
+
|
|
307
|
+
execute_batch = cast(Any, self._context).execute_batch
|
|
308
|
+
self._run(
|
|
309
|
+
lambda: execute_batch(
|
|
310
|
+
items_per_batch=items_per_batch,
|
|
311
|
+
max_batch_bytes=max_batch_bytes,
|
|
312
|
+
concurrency=concurrency,
|
|
313
|
+
success_callback=_on_batch,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
def run(
|
|
319
|
+
self,
|
|
320
|
+
items_per_batch: int = DEFAULT_BATCH_SIZE,
|
|
321
|
+
max_batch_bytes: Optional[int] = None,
|
|
322
|
+
concurrency: int = 1,
|
|
323
|
+
success_callback: Optional[Callable[[Any], None]] = None,
|
|
324
|
+
) -> Self:
|
|
325
|
+
"""Alias of :meth:`execute_batch` (migration-parity vocabulary)."""
|
|
326
|
+
return self.execute_batch(
|
|
327
|
+
items_per_batch=items_per_batch,
|
|
328
|
+
max_batch_bytes=max_batch_bytes,
|
|
329
|
+
concurrency=concurrency,
|
|
330
|
+
success_callback=success_callback,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def verify(
|
|
334
|
+
self,
|
|
335
|
+
source: Any,
|
|
336
|
+
*,
|
|
337
|
+
key: "str | list[str]",
|
|
338
|
+
format: str = "dataframe", # noqa: A002
|
|
339
|
+
key_field: str = "MigrationKey",
|
|
340
|
+
to_records: Optional[Callable[[Any], list[dict]]] = None,
|
|
341
|
+
) -> "VerificationReport":
|
|
342
|
+
"""Reconcile ``source`` against the target (delegates to the collection).
|
|
343
|
+
|
|
344
|
+
Mirrors ``MigrationJob.verify()``: run the import, then verify the source's
|
|
345
|
+
natural keys are present on the target.
|
|
346
|
+
"""
|
|
347
|
+
verify = getattr(self._collection, "verify", None)
|
|
348
|
+
if not callable(verify):
|
|
349
|
+
raise TypeError("the import target does not support verification")
|
|
350
|
+
return cast(
|
|
351
|
+
"VerificationReport", verify(source, key=key, format=format, key_field=key_field, to_records=to_records)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def __iter__(self) -> Iterator["RecordSink"]:
|
|
355
|
+
"""Yield the target collection per chunk; the caller executes each chunk.
|
|
356
|
+
|
|
357
|
+
The caller is responsible for execution (and its errors); checkpoint
|
|
358
|
+
advancement assumes each yielded chunk was committed successfully.
|
|
359
|
+
"""
|
|
360
|
+
self._started_at = time.monotonic()
|
|
361
|
+
self._emit(self._done)
|
|
362
|
+
for raw, records in self._iter_records():
|
|
363
|
+
if callable(self._before_chunk):
|
|
364
|
+
self._before_chunk(raw)
|
|
365
|
+
queued, _skipped = self._queue(records)
|
|
366
|
+
yield self._collection
|
|
367
|
+
self.value.success += queued
|
|
368
|
+
self._advance(records)
|
|
369
|
+
self._collection.clear()
|
|
370
|
+
self._report(self._progress_base + self.value.success)
|
|
371
|
+
self._finish()
|
|
372
|
+
|
|
373
|
+
# ── Core ─────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
def _run(self, execute: Callable[[], Any]) -> None:
|
|
376
|
+
"""Drive the chunk loop: skip committed records, queue, execute, persist.
|
|
377
|
+
|
|
378
|
+
With ``on_error="collect"`` **and** a ``dead_letter``, each chunk is
|
|
379
|
+
executed record-by-record so a failure is isolated to the exact row
|
|
380
|
+
(slower, but the dead-letter is row-level). Otherwise the chunk is
|
|
381
|
+
executed as one unit.
|
|
382
|
+
"""
|
|
383
|
+
self._started_at = time.monotonic()
|
|
384
|
+
self._emit(self._done)
|
|
385
|
+
isolate = self._on_error == "collect" and self._dead_letter is not None
|
|
386
|
+
for raw, records in self._iter_records():
|
|
387
|
+
if callable(self._before_chunk):
|
|
388
|
+
self._before_chunk(raw)
|
|
389
|
+
if isolate and not self._dry_run:
|
|
390
|
+
self._run_per_record(records, execute)
|
|
391
|
+
else:
|
|
392
|
+
self._run_chunk(records, execute)
|
|
393
|
+
self._save_checkpoint()
|
|
394
|
+
self._finish()
|
|
395
|
+
|
|
396
|
+
def _run_chunk(self, records: list[dict], execute: Callable[[], Any]) -> None:
|
|
397
|
+
"""Queue and execute one chunk as a unit (the default path)."""
|
|
398
|
+
queued, _skipped = self._queue(records)
|
|
399
|
+
if self._dry_run:
|
|
400
|
+
self.value.success += queued
|
|
401
|
+
self._advance(records)
|
|
402
|
+
self._collection.clear()
|
|
403
|
+
self._report(self._progress_base + self.value.success)
|
|
404
|
+
return
|
|
405
|
+
try:
|
|
406
|
+
execute()
|
|
407
|
+
except Exception as ex: # noqa: BLE001 — policy decides whether to abort
|
|
408
|
+
self._record_failure(queued, records, ex)
|
|
409
|
+
self._collection.clear()
|
|
410
|
+
if self._on_error != "collect":
|
|
411
|
+
self._save_checkpoint() # cursor unchanged — resume retries this chunk
|
|
412
|
+
raise
|
|
413
|
+
self._advance(records) # collect: skip the failed chunk, keep going
|
|
414
|
+
else:
|
|
415
|
+
self.value.success += queued
|
|
416
|
+
self._advance(records)
|
|
417
|
+
self._collection.clear()
|
|
418
|
+
self._report(self._progress_base + self.value.success)
|
|
419
|
+
|
|
420
|
+
def _run_per_record(self, records: list[dict], execute: Callable[[], Any]) -> None:
|
|
421
|
+
"""Queue/execute one record at a time, dead-lettering each failing row."""
|
|
422
|
+
start = self._checkpoint.cursor
|
|
423
|
+
for index, record in enumerate(records):
|
|
424
|
+
queued, _skipped = self._queue([record])
|
|
425
|
+
if self._dry_run:
|
|
426
|
+
self.value.success += queued
|
|
427
|
+
continue
|
|
428
|
+
try:
|
|
429
|
+
execute()
|
|
430
|
+
except Exception as ex: # noqa: BLE001 — collect: record the row, keep going
|
|
431
|
+
self._record_row_failure(start + index, record, ex)
|
|
432
|
+
else:
|
|
433
|
+
self.value.success += queued
|
|
434
|
+
finally:
|
|
435
|
+
self._collection.clear()
|
|
436
|
+
self._advance(records)
|
|
437
|
+
self._report(self._progress_base + self.value.success)
|
|
438
|
+
|
|
439
|
+
def _iter_records(self) -> Iterator[tuple[Any, list[dict]]]:
|
|
440
|
+
"""Yield ``(raw_chunk, records)``, skipping already-committed chunks.
|
|
441
|
+
|
|
442
|
+
Commits are whole chunks, so resume skips by the checkpoint's ``chunks``
|
|
443
|
+
count (cheap — no ``to_records`` on committed data). A record-level
|
|
444
|
+
``cursor`` fallback covers a hand-built checkpoint without a chunk count.
|
|
445
|
+
``prepare`` runs only on a fresh run (fields already exist when resuming).
|
|
446
|
+
"""
|
|
447
|
+
skip_chunks = self._checkpoint.chunks
|
|
448
|
+
skip_records = 0 if skip_chunks else self._checkpoint.cursor
|
|
449
|
+
prepared = skip_chunks > 0 or skip_records > 0
|
|
450
|
+
source = islice(self._chunks, skip_chunks, None) if skip_chunks else self._chunks
|
|
451
|
+
for raw in source:
|
|
452
|
+
records = self._to_records(raw)
|
|
453
|
+
if skip_records:
|
|
454
|
+
if len(records) <= skip_records:
|
|
455
|
+
skip_records -= len(records)
|
|
456
|
+
continue
|
|
457
|
+
records = records[skip_records:]
|
|
458
|
+
skip_records = 0
|
|
459
|
+
if not prepared:
|
|
460
|
+
if callable(self._prepare):
|
|
461
|
+
self._prepare(raw)
|
|
462
|
+
prepared = True
|
|
463
|
+
yield raw, records
|
|
464
|
+
|
|
465
|
+
# ── Bookkeeping ──────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
def _default_queue(self, records: list[dict]) -> tuple[int, int]:
|
|
468
|
+
"""Queue every record as a create (no conflict resolution)."""
|
|
469
|
+
if not self._dry_run:
|
|
470
|
+
self._collection.queue_records(records)
|
|
471
|
+
return len(records), 0
|
|
472
|
+
|
|
473
|
+
def _queue(self, records: list[dict]) -> tuple[int, int]:
|
|
474
|
+
"""Queue a chunk via the conflict-resolution hook; returns (queued, skipped)."""
|
|
475
|
+
queued, skipped = self._queue_fn(records)
|
|
476
|
+
self.value.total += len(records)
|
|
477
|
+
self.value.skipped += skipped
|
|
478
|
+
return queued, skipped
|
|
479
|
+
|
|
480
|
+
def _emit(self, done: int) -> None:
|
|
481
|
+
"""Fire the ``progress`` hook with an overall committed-count snapshot."""
|
|
482
|
+
if callable(self._progress):
|
|
483
|
+
from office365.runtime.operations import Progress
|
|
484
|
+
|
|
485
|
+
self._progress(Progress(done=done, total=self._total, stage="importing"))
|
|
486
|
+
|
|
487
|
+
def _report(self, done: int) -> None:
|
|
488
|
+
"""Emit progress for a new committed count (monotonic, de-duplicated).
|
|
489
|
+
|
|
490
|
+
Fired after each committed chunk and per batch during
|
|
491
|
+
:meth:`execute_batch`; the initial tick is emitted separately so the bar
|
|
492
|
+
appears with its total immediately.
|
|
493
|
+
"""
|
|
494
|
+
done = max(done, self._done)
|
|
495
|
+
if done == self._done:
|
|
496
|
+
return
|
|
497
|
+
self._done = done
|
|
498
|
+
self._emit(done)
|
|
499
|
+
|
|
500
|
+
def _advance(self, records: list[dict]) -> None:
|
|
501
|
+
self.value.chunks += 1
|
|
502
|
+
self._checkpoint.cursor += len(records)
|
|
503
|
+
self._checkpoint.chunks += 1
|
|
504
|
+
|
|
505
|
+
def _record_failure(self, count: int, records: list[dict], error: Exception) -> None:
|
|
506
|
+
"""Record a whole-chunk failure (the non-isolated path)."""
|
|
507
|
+
self.value.errors += count
|
|
508
|
+
self._checkpoint.errors += count
|
|
509
|
+
self._checkpoint.failures.append({"records": count, "error": str(error)})
|
|
510
|
+
if self._dead_letter is not None:
|
|
511
|
+
self._append_dead_letter({"error": str(error), "records": records})
|
|
512
|
+
|
|
513
|
+
def _record_row_failure(self, row: int, record: dict, error: Exception) -> None:
|
|
514
|
+
"""Record a single failed record (the isolated per-record path)."""
|
|
515
|
+
self.value.errors += 1
|
|
516
|
+
self._checkpoint.errors += 1
|
|
517
|
+
self._checkpoint.failures.append({"row": row, "error": str(error)})
|
|
518
|
+
self._append_dead_letter({"row": row, "error": str(error), "record": record})
|
|
519
|
+
|
|
520
|
+
def _append_dead_letter(self, entry: dict) -> None:
|
|
521
|
+
if self._dead_letter is None:
|
|
522
|
+
return
|
|
523
|
+
self._dead_letter.parent.mkdir(parents=True, exist_ok=True)
|
|
524
|
+
with open(self._dead_letter, "a", encoding="utf-8") as f:
|
|
525
|
+
f.write(json.dumps(entry, default=str) + "\n")
|
|
526
|
+
|
|
527
|
+
def _finish(self) -> None:
|
|
528
|
+
if self._started_at is not None:
|
|
529
|
+
self.value.duration = time.monotonic() - self._started_at
|
|
530
|
+
|
|
531
|
+
def _apply_signature(self, signature: Optional[dict]) -> None:
|
|
532
|
+
"""Persist the source signature; discard the chunk skip when it changed."""
|
|
533
|
+
if not signature:
|
|
534
|
+
return
|
|
535
|
+
stored = self._checkpoint.signature
|
|
536
|
+
if stored and stored != signature:
|
|
537
|
+
warnings.warn(
|
|
538
|
+
"Import checkpoint signature changed (source format/chunk size/key); "
|
|
539
|
+
"re-scanning from the start (keys keep the import idempotent).",
|
|
540
|
+
stacklevel=3,
|
|
541
|
+
)
|
|
542
|
+
self._checkpoint.cursor = 0
|
|
543
|
+
self._checkpoint.chunks = 0
|
|
544
|
+
self._checkpoint.signature = signature
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def _resolve_store(
|
|
548
|
+
checkpoint: Union["ImportCheckpoint", "CheckpointStore", str, PathLike, None],
|
|
549
|
+
) -> "CheckpointStore":
|
|
550
|
+
if checkpoint is None:
|
|
551
|
+
return MemoryCheckpointStore()
|
|
552
|
+
if isinstance(checkpoint, ImportCheckpoint):
|
|
553
|
+
return MemoryCheckpointStore(checkpoint)
|
|
554
|
+
if isinstance(checkpoint, CheckpointStore):
|
|
555
|
+
return checkpoint
|
|
556
|
+
return FileCheckpointStore(checkpoint)
|
|
557
|
+
|
|
558
|
+
def _save_checkpoint(self) -> None:
|
|
559
|
+
self._store.save(self._checkpoint)
|
|
560
|
+
|
|
561
|
+
# ── State ────────────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
@property
|
|
564
|
+
def checkpoint(self) -> ImportCheckpoint:
|
|
565
|
+
"""The live run state (cursor/errors/failures), updated after each chunk."""
|
|
566
|
+
return self._checkpoint
|
|
567
|
+
|
|
568
|
+
@property
|
|
569
|
+
def resumed_from(self) -> int:
|
|
570
|
+
"""Records already committed by a previous run (``0`` when fresh)."""
|
|
571
|
+
return self._progress_base
|