Office365-REST-Python-Client 3.1.0__py3-none-any.whl → 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. office365/communications/callrecords/call_record.py +18 -0
  2. office365/communications/calls/call.py +3 -0
  3. office365/communications/presences/presence.py +3 -0
  4. office365/communications/quotas.py +66 -0
  5. office365/communications/virtualevents/virtual_event.py +3 -0
  6. office365/directory/domains/domain.py +3 -0
  7. office365/directory/licenses/subscribed_sku.py +3 -0
  8. office365/directory/objects/object.py +3 -0
  9. office365/directory/quotas.py +34 -0
  10. office365/entity_collection.py +2 -2
  11. office365/graph_client.py +46 -2
  12. office365/limits.py +66 -0
  13. office365/migration/__init__.py +6 -1
  14. office365/migration/adapters/__init__.py +14 -4
  15. office365/migration/adapters/filesystem.py +4 -1
  16. office365/migration/assessment/export.py +5 -5
  17. office365/migration/assessment/issue.py +19 -0
  18. office365/migration/assessment/report.py +11 -0
  19. office365/migration/assessment/scanners/base.py +37 -49
  20. office365/migration/base.py +33 -8
  21. office365/migration/checkpoint.py +1 -1
  22. office365/migration/outlook/assessor.py +4 -4
  23. office365/migration/package/__init__.py +82 -0
  24. office365/migration/package/builder.py +603 -0
  25. office365/migration/package/crypto.py +54 -0
  26. office365/migration/package/manifest_xml.py +495 -0
  27. office365/migration/package/staging.py +180 -0
  28. office365/migration/report.py +7 -2
  29. office365/migration/report_io.py +79 -0
  30. office365/migration/runner.py +117 -10
  31. office365/migration/server_job.py +126 -14
  32. office365/migration/session.py +132 -49
  33. office365/migration/settings.py +67 -0
  34. office365/migration/sharepoint/__init__.py +2 -0
  35. office365/migration/sharepoint/adapters.py +207 -41
  36. office365/migration/sharepoint/assessor.py +45 -9
  37. office365/migration/sharepoint/options.py +57 -0
  38. office365/migration/sharepoint/package_target.py +209 -0
  39. office365/migration/sharepoint/registry.py +22 -0
  40. office365/migration/sharepoint/resolvers.py +96 -0
  41. office365/migration/sharepoint/scanners/__init__.py +29 -0
  42. office365/migration/sharepoint/scanners/browser_file_handling.py +55 -0
  43. office365/migration/sharepoint/scanners/checked_out_files.py +57 -0
  44. office365/migration/sharepoint/scanners/file_versions.py +56 -0
  45. office365/migration/sharepoint/scanners/files.py +5 -2
  46. office365/migration/sharepoint/scanners/large_excel_files.py +56 -0
  47. office365/migration/sharepoint/scanners/large_lists.py +58 -0
  48. office365/migration/sharepoint/scanners/large_sites.py +4 -3
  49. office365/migration/sharepoint/scanners/long_onedrive_urls.py +52 -0
  50. office365/migration/sharepoint/scanners/lookup.py +38 -0
  51. office365/migration/sharepoint/scanners/paths.py +9 -4
  52. office365/migration/sharepoint/scanners/permissions.py +20 -2
  53. office365/migration/sharepoint/scanners/smat.py +51 -0
  54. office365/migration/sharepoint/scanners/summary.py +1 -0
  55. office365/migration/sharepoint/scanners/thicket_folders.py +38 -0
  56. office365/migration/sharepoint/scanners/unsupported_site_templates.py +90 -0
  57. office365/migration/sharepoint/tenant_assessor.py +5 -3
  58. office365/migration/sharepoint/transfer.py +4 -2
  59. office365/migration/tasks.py +121 -0
  60. office365/migration/validators.py +2 -19
  61. office365/onedrive/driveitems/driveItem.py +116 -12
  62. office365/onedrive/termstore/groups/collection.py +2 -9
  63. office365/onedrive/termstore/sets/collection.py +2 -9
  64. office365/onedrive/termstore/terms/collection.py +3 -10
  65. office365/onedrive/workbooks/workbook.py +7 -0
  66. office365/outlook/contacts/folders/collection.py +4 -13
  67. office365/runtime/auth/authentication_context.py +5 -0
  68. office365/runtime/client_object.py +18 -0
  69. office365/runtime/client_object_collection.py +75 -245
  70. office365/runtime/client_request.py +77 -1
  71. office365/runtime/client_request_exception.py +127 -39
  72. office365/runtime/client_runtime_context.py +79 -8
  73. office365/runtime/converters/csv_writer.py +10 -4
  74. office365/runtime/converters/dataframe.py +84 -4
  75. office365/runtime/converters/json_file.py +12 -3
  76. office365/runtime/converters/parquet.py +67 -0
  77. office365/runtime/converters/records.py +31 -12
  78. office365/runtime/converters/registry.py +185 -0
  79. office365/runtime/converters/scalars.py +31 -0
  80. office365/runtime/converters/sql.py +69 -0
  81. office365/runtime/converters/streamers.py +105 -0
  82. office365/runtime/converters/streams.py +42 -0
  83. office365/runtime/converters/upsert.py +101 -0
  84. office365/runtime/exceptions.py +105 -0
  85. office365/runtime/http/prefer.py +26 -0
  86. office365/runtime/http/throttling.py +258 -56
  87. office365/runtime/imports.py +571 -0
  88. office365/runtime/limits.py +455 -0
  89. office365/runtime/odata/request.py +11 -2
  90. office365/runtime/odata/type.py +1 -0
  91. office365/runtime/odata/v3/batch_request.py +4 -2
  92. office365/runtime/odata/v4/batch_request.py +22 -7
  93. office365/runtime/operations.py +29 -0
  94. office365/runtime/parallel.py +12 -14
  95. office365/runtime/paths/v4/entity.py +2 -2
  96. office365/runtime/queries/batch.py +18 -1
  97. office365/runtime/queries/get_or_create.py +109 -0
  98. office365/runtime/record_collection.py +443 -0
  99. office365/runtime/retry.py +92 -26
  100. office365/runtime/transport/base.py +6 -1
  101. office365/runtime/transport/requests_transport.py +4 -0
  102. office365/runtime/transport/throttled_transport.py +67 -0
  103. office365/runtime/verification.py +40 -0
  104. office365/sharepoint/activities/logger.py +118 -8
  105. office365/sharepoint/administration/analytics/usage_service.py +15 -7
  106. office365/sharepoint/administration/sitemove/service.py +791 -8
  107. office365/sharepoint/alerts/alert.py +79 -0
  108. office365/sharepoint/apps/app_collection.py +25 -0
  109. office365/sharepoint/apps/launcher.py +16 -0
  110. office365/sharepoint/authpolicy/tenant/compliancereport.py +5 -0
  111. office365/sharepoint/businessdata/app_bdc_catalog.py +135 -0
  112. office365/sharepoint/businessdata/entityfieldvaluedictionary.py +65 -0
  113. office365/sharepoint/businessdata/entityidentifier.py +23 -0
  114. office365/sharepoint/businessdata/entityinstance.py +52 -0
  115. office365/sharepoint/businessdata/entityview.py +31 -0
  116. office365/sharepoint/businessdata/externalsubscriptionstore.py +9 -0
  117. office365/sharepoint/businessdata/typedescriptor.py +37 -0
  118. office365/sharepoint/client_context.py +40 -23
  119. office365/sharepoint/clientsidecomponent/hostedapps/app.py +21 -0
  120. office365/sharepoint/clientsidecomponent/hostedapps/manager.py +16 -0
  121. office365/sharepoint/comments/comment.py +84 -2
  122. office365/sharepoint/contentcenter/machinelearning/models/model.py +0 -23
  123. office365/sharepoint/contentcenter/machinelearning/publications/publication.py +0 -9
  124. office365/sharepoint/contentcenter/machinelearning/samples/sample.py +0 -14
  125. office365/sharepoint/contentcenter/machinelearning/workitems/item.py +0 -80
  126. office365/sharepoint/contentsolution/template_api.py +148 -0
  127. office365/sharepoint/contenttypes/collection.py +32 -10
  128. office365/sharepoint/convergence/addnoliveblobsinodcservicestateapi.py +9 -0
  129. office365/sharepoint/convergence/consumer_permissions.py +15 -0
  130. office365/sharepoint/convergence/migration_complete_state_api.py +25 -0
  131. office365/sharepoint/convergence/odcmetadatacleanedupapi.py +9 -0
  132. office365/sharepoint/documentmanagement/document_id.py +12 -0
  133. office365/sharepoint/entity.py +11 -2
  134. office365/sharepoint/entity_collection.py +2 -2
  135. office365/sharepoint/exceptions.py +194 -0
  136. office365/sharepoint/fields/builtin_field_id.py +45 -34
  137. office365/sharepoint/fields/builtin_field_name.py +123 -0
  138. office365/sharepoint/fields/coercion.py +131 -0
  139. office365/sharepoint/fields/collection.py +54 -37
  140. office365/sharepoint/fields/field.py +17 -0
  141. office365/sharepoint/fields/name.py +46 -2
  142. office365/sharepoint/files/collection.py +7 -1
  143. office365/sharepoint/files/file.py +54 -8
  144. office365/sharepoint/files/versions/version.py +38 -13
  145. office365/sharepoint/folders/collection.py +4 -0
  146. office365/sharepoint/folders/folder.py +91 -21
  147. office365/sharepoint/htmlpages/html_page.py +16 -0
  148. office365/sharepoint/htmlpages/html_page_service.py +19 -0
  149. office365/sharepoint/ir/migration.py +9 -0
  150. office365/sharepoint/listitems/caml/__init__.py +31 -0
  151. office365/sharepoint/listitems/caml/builder.py +129 -0
  152. office365/sharepoint/listitems/caml/expressions.py +424 -0
  153. office365/sharepoint/listitems/caml/fields.py +68 -0
  154. office365/sharepoint/listitems/caml/guard.py +63 -0
  155. office365/sharepoint/listitems/caml/query.py +71 -2
  156. office365/sharepoint/listitems/caml/values.py +113 -0
  157. office365/sharepoint/listitems/collection.py +21 -20
  158. office365/sharepoint/listitems/upsert.py +75 -0
  159. office365/sharepoint/lists/collection.py +29 -22
  160. office365/sharepoint/lists/exporter.py +4 -3
  161. office365/sharepoint/lists/list.py +397 -39
  162. office365/sharepoint/lists/smart_wiki_library_utility.py +58 -0
  163. office365/sharepoint/logger/export.py +12 -0
  164. office365/sharepoint/marketplace/app_metadata.py +97 -1
  165. office365/sharepoint/marketplace/appstatequeryjobdefinition.py +9 -0
  166. office365/sharepoint/marketplace/corporatecuratedgallery/carddesigns/card_designs.py +35 -0
  167. office365/sharepoint/marketplace/sitecollection/appcatalog/allowed_item.py +5 -0
  168. office365/sharepoint/marketplace/tenant/appcatalog/accessor.py +107 -30
  169. office365/sharepoint/microfeed/data.py +0 -6
  170. office365/sharepoint/microservice/manager.py +61 -0
  171. office365/sharepoint/migrationcenter/common/task.py +193 -4
  172. office365/sharepoint/migrationcenter/common/task_entity_data.py +1 -1
  173. office365/sharepoint/migrationcenter/deploy_status.py +13 -0
  174. office365/sharepoint/migrationcenter/service/properties.py +20 -0
  175. office365/sharepoint/migrationcenter/service/storage.py +34 -0
  176. office365/sharepoint/migrationcenter/spflight.py +13 -0
  177. office365/sharepoint/migrationcenter/urlparseresult.py +77 -0
  178. office365/sharepoint/multigeo/service/spmultigeoproxy.py +13 -0
  179. office365/sharepoint/navigation/servicerest.py +127 -0
  180. office365/sharepoint/oauth/authentication.py +13 -0
  181. office365/sharepoint/portal/theme_manager.py +120 -8
  182. office365/sharepoint/principal/users/user.py +10 -0
  183. office365/sharepoint/publishing/campaign/campaign.py +36 -1
  184. office365/sharepoint/publishing/feedvideopage.py +0 -7
  185. office365/sharepoint/publishing/pages/3d/page.py +0 -6
  186. office365/sharepoint/publishing/pages/page.py +0 -117
  187. office365/sharepoint/publishing/pages/reposts/repost.py +0 -7
  188. office365/sharepoint/publishing/pages/topics/topic.py +0 -7
  189. office365/sharepoint/publishing/subtitlefile.py +0 -40
  190. office365/sharepoint/quotamanagement/consumer/consumer_quota_info_api.py +14 -0
  191. office365/sharepoint/quotamanagement/consumer/non_quota_backfill_api.py +23 -0
  192. office365/sharepoint/quotamanagement/consumer/nonquotamigrationapi.py +9 -0
  193. office365/sharepoint/quotamanagement/consumer/quotamigrationapi.py +13 -0
  194. office365/sharepoint/request.py +57 -16
  195. office365/sharepoint/search/administration/document_crawl_log.py +24 -7
  196. office365/sharepoint/search/administration/providers/crawl_versions_info.py +29 -0
  197. office365/sharepoint/search/administration/providers/recycle_bin_info.py +23 -0
  198. office365/sharepoint/search/administration/providers/site_content_processing_info.py +67 -0
  199. office365/sharepoint/sites/site.py +976 -65
  200. office365/sharepoint/sites/siteprovisioninginfoprovider.py +13 -0
  201. office365/sharepoint/tenant/administration/datagovernance/insight_client.py +292 -23
  202. office365/sharepoint/tenant/administration/ddiadapter.py +97 -0
  203. office365/sharepoint/tenant/administration/internal/aad/collection.py +20 -0
  204. office365/sharepoint/tenant/administration/internal/aad/permission_grant.py +0 -19
  205. office365/sharepoint/tenant/administration/internal/appservice/permission_request.py +72 -1
  206. office365/sharepoint/tenant/administration/internal/appservice/principal.py +1 -3
  207. office365/sharepoint/tenant/administration/odb_license_enforcement_service.py +26 -0
  208. office365/sharepoint/tenant/administration/sites/properties.py +564 -6
  209. office365/sharepoint/tenant/administration/spdatagovernancesarrestapiclient.py +60 -1
  210. office365/sharepoint/tenant/insights/reports/manager.py +24 -12
  211. office365/sharepoint/tenant/management/office365_tenant.py +451 -2
  212. office365/sharepoint/tenantadministration/catalog_management_collection.py +69 -0
  213. office365/sharepoint/tenantadministration/catalog_management_custom_properties_collection.py +237 -0
  214. office365/sharepoint/tenantadministration/catalog_management_restricted_content_discovery_collection.py +56 -0
  215. office365/sharepoint/tenantadministration/sp_data_governance_client_exception.py +7 -0
  216. office365/sharepoint/thresholds.py +529 -0
  217. office365/sharepoint/translation/jobs/job.py +72 -2
  218. office365/sharepoint/translation/jobs/status.py +47 -9
  219. office365/sharepoint/translation/sync_translator.py +15 -2
  220. office365/sharepoint/utilities/move_copy_util.py +2 -1
  221. office365/sharepoint/viva/connections/page.py +27 -0
  222. office365/sharepoint/viva/home.py +38 -0
  223. office365/sharepoint/viva/learningmanager.py +9 -0
  224. office365/sharepoint/viva/vivaresources.py +60 -0
  225. office365/sharepoint/webhooks/subscription.py +0 -11
  226. office365/sharepoint/webparts/definitions/definition.py +13 -0
  227. office365/sharepoint/webparts/limited_manager.py +29 -7
  228. office365/sharepoint/webs/context_web_information.py +14 -9
  229. office365/sharepoint/webs/templates/__init__.py +11 -0
  230. office365/sharepoint/webs/templates/type.py +74 -15
  231. office365/sharepoint/workflow/definition.py +6 -0
  232. office365/sharepoint/workflow/deployment_service.py +155 -0
  233. office365/sharepoint/workflow/instances/service.py +33 -0
  234. office365/sharepoint/workflow/interopservice.py +68 -0
  235. office365/sharepoint/workflow/messagingservice.py +24 -0
  236. office365/sharepoint/workflow/servicesmanager.py +41 -2
  237. office365/sharepoint/workflow/subscriptions/service.py +67 -0
  238. office365/sharepoint/workflow/subscriptions/subscription.py +31 -0
  239. office365/sharepoint/workflow/task.py +425 -0
  240. office365_rest_python_client-3.2.0.dist-info/METADATA +605 -0
  241. {office365_rest_python_client-3.1.0.dist-info → office365_rest_python_client-3.2.0.dist-info}/RECORD +242 -185
  242. office365/migration/_util.py +0 -73
  243. office365/sharepoint/listitems/caml/types.py +0 -70
  244. office365_rest_python_client-3.1.0.dist-info/METADATA +0 -475
  245. {office365_rest_python_client-3.1.0.dist-info → office365_rest_python_client-3.2.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,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