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,79 @@
1
+ """Report record IO — write record datasets in a chosen format.
2
+
3
+ Per-format, not CSV+JSON-coupled: :func:`write_dataset` picks a writer by name so
4
+ callers write whichever formats they need (``csv``/``json``/``ndjson``) by
5
+ looping, and adding a format is one registration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from os import PathLike
11
+ from pathlib import Path
12
+ from typing import Callable, List, Optional, Sequence
13
+
14
+ RecordWriter = Callable[[List[dict], Path, Optional[Sequence[str]]], None]
15
+
16
+ _EXTENSIONS = {"csv": "csv", "json": "json", "ndjson": "ndjson"}
17
+
18
+
19
+ def _write_csv(records: list[dict], path: Path, columns: Optional[Sequence[str]] = None) -> None:
20
+ from office365.runtime.converters.csv_writer import write_records
21
+
22
+ with open(path, "w", newline="", encoding="utf-8") as f:
23
+ write_records(records, f, list(columns) if columns else None)
24
+
25
+
26
+ def _write_json(records: list[dict], path: Path, columns: Optional[Sequence[str]] = None) -> None:
27
+ from office365.runtime.converters.json_file import write_json
28
+
29
+ with open(path, "w", encoding="utf-8") as f:
30
+ write_json(records, f, indent=2)
31
+
32
+
33
+ def _write_ndjson(records: list[dict], path: Path, columns: Optional[Sequence[str]] = None) -> None:
34
+ from office365.runtime.converters.ndjson import write_ndjson
35
+
36
+ with open(path, "w", encoding="utf-8") as f:
37
+ write_ndjson(records, f)
38
+
39
+
40
+ _WRITERS: dict[str, RecordWriter] = {"csv": _write_csv, "json": _write_json, "ndjson": _write_ndjson}
41
+
42
+
43
+ def write_dataset(
44
+ records: list[dict],
45
+ dir_: str | PathLike,
46
+ stem: str,
47
+ *,
48
+ fmt: str = "csv",
49
+ columns: Optional[Sequence[str]] = None,
50
+ ) -> str:
51
+ """Write ``records`` under ``dir_`` as ``stem.<ext>`` in ``fmt``; returns the path.
52
+
53
+ Args:
54
+ records: The record rows.
55
+ dir_: Destination directory (created when missing).
56
+ stem: File stem (without extension).
57
+ fmt: One of ``csv``/``json``/``ndjson``.
58
+ columns: Optional column order for CSV.
59
+ """
60
+ try:
61
+ writer = _WRITERS[fmt]
62
+ except KeyError:
63
+ raise KeyError(f"Unknown report format {fmt!r}; known: {sorted(_WRITERS)}") from None
64
+ path = Path(dir_) / f"{stem}.{_EXTENSIONS[fmt]}"
65
+ path.parent.mkdir(parents=True, exist_ok=True)
66
+ writer(records, path, columns)
67
+ return str(path)
68
+
69
+
70
+ def write_formats(
71
+ records: list[dict],
72
+ dir_: str | PathLike,
73
+ stem: str,
74
+ *,
75
+ formats: Sequence[str] = ("csv", "json"),
76
+ columns: Optional[Sequence[str]] = None,
77
+ ) -> list[str]:
78
+ """Write the same records in each of ``formats``; returns the written paths."""
79
+ return [write_dataset(records, dir_, stem, fmt=fmt, columns=columns) for fmt in formats]
@@ -17,7 +17,6 @@ from __future__ import annotations
17
17
  from pathlib import Path
18
18
  from typing import TYPE_CHECKING, Callable, Iterable
19
19
 
20
- from office365.migration._util import emit_progress
21
20
  from office365.migration.adapters import DataTarget, resolve_dest
22
21
  from office365.migration.base import (
23
22
  ConflictResolution,
@@ -28,11 +27,80 @@ from office365.migration.base import (
28
27
  MigrationStats,
29
28
  )
30
29
  from office365.migration.checkpoint import Checkpoint
30
+ from office365.runtime.operations import emit_progress
31
31
 
32
32
  if TYPE_CHECKING:
33
33
  from office365.runtime.operations import Progress
34
34
 
35
35
 
36
+ def _assert_fidelity_supported(options: MigrationOptions, source, target: DataTarget) -> None:
37
+ """Reject fidelity flags the adapter pair cannot honor.
38
+
39
+ ``preserve_versions`` always needs the server-side Migration API (REST can't
40
+ restore version history). ``preserve_timestamps`` / ``preserve_permissions``
41
+ are applied client-side on a best-effort basis, but only when the adapters
42
+ expose the matching optional hooks — failing fast beats silently migrating
43
+ without the requested fidelity.
44
+ """
45
+ if options.preserve_versions:
46
+ raise NotImplementedError(
47
+ "preserve_versions is not supported by the client-side runner: REST cannot restore "
48
+ "version history. Use the server-side Migration API (MigrationServerJob) or set "
49
+ "preserve_versions to False."
50
+ )
51
+ if options.preserve_timestamps and not callable(getattr(target, "apply_timestamps", None)):
52
+ raise NotImplementedError(
53
+ "preserve_timestamps is not supported by this target: it does not implement apply_timestamps(item)."
54
+ )
55
+ if options.preserve_permissions and not (
56
+ callable(getattr(source, "read_permissions", None)) and callable(getattr(target, "apply_permissions", None))
57
+ ):
58
+ raise NotImplementedError(
59
+ "preserve_permissions is not supported by this adapter pair: it needs "
60
+ "source.read_permissions(item) and target.apply_permissions(item, permissions)."
61
+ )
62
+
63
+
64
+ def _apply_fidelity(source, target: DataTarget, item: MigrationItem, options: MigrationOptions) -> None:
65
+ """Best-effort fidelity after a successful write (timestamps / ACLs).
66
+
67
+ Failures propagate (and are captured per-item by the caller) so a requested
68
+ fidelity that couldn't be applied is visible and the item stays resumable.
69
+ """
70
+ if options.preserve_timestamps:
71
+ _call_optional(target, "apply_timestamps", item)
72
+ if options.preserve_permissions:
73
+ permissions = source.read_permissions(item)
74
+ if permissions:
75
+ _call_optional(target, "apply_permissions", item, permissions)
76
+
77
+
78
+ class _Watermark:
79
+ """Incremental watermark — the highest source ``modified`` migrated so far.
80
+
81
+ Persisted in ``Checkpoint.source_watermark``: a resumed incremental run skips
82
+ every item at or below it, so only new/changed items are re-scanned.
83
+ """
84
+
85
+ def __init__(self, checkpoint: Checkpoint) -> None:
86
+ self._checkpoint = checkpoint
87
+ self._value = checkpoint.source_watermark
88
+
89
+ @property
90
+ def value(self) -> str | None:
91
+ return self._value
92
+
93
+ def is_stale(self, item: MigrationItem) -> bool:
94
+ """Whether the item is at or below the watermark (already migrated)."""
95
+ return self._value is not None and item.modified is not None and item.modified <= self._value
96
+
97
+ def advance(self, item: MigrationItem) -> None:
98
+ """Raise the watermark to the item's ``modified`` when it is newer."""
99
+ if item.modified is not None and (self._value is None or item.modified > self._value):
100
+ self._value = item.modified
101
+ self._checkpoint.source_watermark = item.modified
102
+
103
+
36
104
  class MigrationRunner:
37
105
  """Executes migration items between a source and a target adapter."""
38
106
 
@@ -47,16 +115,20 @@ class MigrationRunner:
47
115
  progress: Callable[["Progress"], None] | None = None,
48
116
  stop_event: Callable[[], bool] | None = None,
49
117
  ) -> MigrationStats:
118
+ _assert_fidelity_supported(options, source, target)
119
+ watermark = _Watermark(checkpoint) if options.incremental else None
50
120
  parallel = (
51
121
  options.concurrency > 1
52
122
  and hasattr(target, "write_many")
53
123
  and options.conflict_resolution != ConflictResolution.RENAME
54
124
  )
55
125
  if parallel:
56
- stats = self._run_parallel(source, target, items, options, checkpoint, checkpoint_path, progress, stop_event)
126
+ stats = self._run_parallel(
127
+ source, target, items, options, checkpoint, checkpoint_path, progress, stop_event, watermark
128
+ )
57
129
  else:
58
130
  stats = self._run_sequential(
59
- source, target, items, options, checkpoint, checkpoint_path, progress, stop_event
131
+ source, target, items, options, checkpoint, checkpoint_path, progress, stop_event, watermark
60
132
  )
61
133
  _call_optional(target, "commit", options)
62
134
 
@@ -78,6 +150,7 @@ class MigrationRunner:
78
150
  checkpoint_path: str | Path | None,
79
151
  progress: Callable[["Progress"], None] | None,
80
152
  stop_event: Callable[[], bool] | None,
153
+ watermark: _Watermark | None = None,
81
154
  ) -> MigrationStats:
82
155
  items = list(items)
83
156
  stats = MigrationStats(total=len(items))
@@ -90,12 +163,19 @@ class MigrationRunner:
90
163
  if callable(stop_event) and stop_event():
91
164
  checkpoint.phase = MigrationPhase.PAUSED
92
165
  break
166
+ if watermark is not None and watermark.is_stale(item):
167
+ checkpoint.record(item, ItemStatus.SKIPPED)
168
+ stats.skipped += 1
169
+ self._report_progress(progress, stats, item)
170
+ continue
93
171
  checkpoint.record(item, ItemStatus.IN_PROGRESS)
94
172
  try:
95
173
  if self._migrate(source, target, item, options):
96
174
  checkpoint.record(item, ItemStatus.DONE)
97
175
  stats.success += 1
98
176
  stats.bytes_transferred += item.size_bytes
177
+ if watermark is not None:
178
+ watermark.advance(item)
99
179
  else:
100
180
  checkpoint.record(item, ItemStatus.SKIPPED)
101
181
  stats.skipped += 1
@@ -119,11 +199,17 @@ class MigrationRunner:
119
199
  checkpoint_path: str | Path | None,
120
200
  progress: Callable[["Progress"], None] | None,
121
201
  stop_event: Callable[[], bool] | None,
202
+ watermark: _Watermark | None = None,
122
203
  ) -> MigrationStats:
123
204
  items = list(items)
124
205
  stats = MigrationStats(total=len(items))
125
206
  chunk: list[MigrationItem] = []
126
207
 
208
+ def _skip(item: MigrationItem) -> None:
209
+ checkpoint.record(item, ItemStatus.SKIPPED)
210
+ stats.skipped += 1
211
+ self._report_progress(progress, stats, item)
212
+
127
213
  def _flush() -> None:
128
214
  if not chunk:
129
215
  return
@@ -137,9 +223,19 @@ class MigrationRunner:
137
223
  checkpoint.record(item, ItemStatus.FAILED)
138
224
  stats.errors += 1
139
225
  else:
140
- checkpoint.record(item, ItemStatus.DONE)
141
- stats.success += 1
142
- stats.bytes_transferred += item.size_bytes
226
+ try:
227
+ _apply_fidelity(source, target, item, options)
228
+ except Exception as e: # noqa: BLE001 — per-item errors are captured, not fatal
229
+ item.error = str(e)
230
+ item.error_code = type(e).__name__
231
+ checkpoint.record(item, ItemStatus.FAILED)
232
+ stats.errors += 1
233
+ else:
234
+ checkpoint.record(item, ItemStatus.DONE)
235
+ stats.success += 1
236
+ stats.bytes_transferred += item.size_bytes
237
+ if watermark is not None:
238
+ watermark.advance(item)
143
239
  self._report_progress(progress, stats, item)
144
240
  chunk.clear()
145
241
  if checkpoint_path is not None:
@@ -154,11 +250,12 @@ class MigrationRunner:
154
250
  if callable(stop_event) and stop_event():
155
251
  checkpoint.phase = MigrationPhase.PAUSED
156
252
  break
253
+ if (watermark is not None and watermark.is_stale(item)) or _outside_window(item, options):
254
+ _skip(item)
255
+ continue
157
256
  checkpoint.record(item, ItemStatus.IN_PROGRESS)
158
257
  if options.incremental and _target_up_to_date(source, target, item):
159
- checkpoint.record(item, ItemStatus.SKIPPED)
160
- stats.skipped += 1
161
- self._report_progress(progress, stats, item)
258
+ _skip(item)
162
259
  continue
163
260
  if options.conflict_resolution == ConflictResolution.SKIP and target.exists(item):
164
261
  checkpoint.record(item, ItemStatus.SKIPPED)
@@ -178,7 +275,9 @@ class MigrationRunner:
178
275
 
179
276
  @staticmethod
180
277
  def _migrate(source, target: DataTarget, item: MigrationItem, options: MigrationOptions) -> bool:
181
- """Move one item; returns ``False`` when skipped (conflict/incremental)."""
278
+ """Move one item; returns ``False`` when skipped (conflict/incremental/window)."""
279
+ if _outside_window(item, options):
280
+ return False
182
281
  if options.incremental and _target_up_to_date(source, target, item):
183
282
  return False
184
283
  if options.conflict_resolution == ConflictResolution.SKIP and target.exists(item):
@@ -187,7 +286,15 @@ class MigrationRunner:
187
286
  item.dest_path = dest
188
287
  payload = source.read(item)
189
288
  target.write(item, payload)
289
+ _apply_fidelity(source, target, item, options)
290
+ return True
291
+
292
+
293
+ def _outside_window(item: MigrationItem, options: MigrationOptions) -> bool:
294
+ """Whether the item is outside the ``created_after``/``modified_after`` window (skip it)."""
295
+ if options.modified_after and item.modified is not None and item.modified <= options.modified_after:
190
296
  return True
297
+ return bool(options.created_after and item.created is not None and item.created <= options.created_after)
191
298
 
192
299
 
193
300
  def _target_up_to_date(source, target: DataTarget, item: MigrationItem) -> bool:
@@ -2,26 +2,68 @@
2
2
 
3
3
  The SharePoint Migration API runs large migrations server-side: content is
4
4
  packaged and uploaded to Azure Storage, then an *ingestion job* ingests it. This
5
- wrapper submits such a job (``Site.create_migration_ingestion_job``) and polls
6
- its status with a ``Progress`` hook.
5
+ wrapper submits such a job and polls its status with a ``Progress`` hook.
7
6
 
8
- The status source is abstracted (``status_fn``) so callers can poll whichever
9
- endpoint reports the job — e.g. the Azure report queue, or the
10
- Graph ``SharePointMigrationJob`` progress events.
7
+ Status can be read two ways:
8
+
9
+ - ``GetMigrationJobProgress`` — the recommended API; use :meth:`status_fn` as the
10
+ ``monitor`` status source (or :meth:`progress` directly);
11
+ - a caller-supplied ``status_fn`` — e.g. an Azure queue or the Graph
12
+ ``SharePointMigrationJobProgressEvent`` stream.
11
13
  """
12
14
 
13
15
  from __future__ import annotations
14
16
 
17
+ import json
15
18
  import time
16
19
  import uuid
17
20
  from collections.abc import Callable
18
21
  from typing import TYPE_CHECKING
19
22
 
20
- from office365.migration._util import emit_progress
23
+ from office365.runtime.operations import emit_progress
21
24
 
22
25
  if TYPE_CHECKING:
23
26
  from office365.runtime.operations import Progress
24
27
 
28
+ __all__ = ["MigrationServerJob", "job_errors", "parse_progress_events"]
29
+
30
+ _TERMINAL = ("succeeded", "completed", "failed", "cancelled")
31
+
32
+ # Migration API progress events -> monitor statuses (JobEnd resolved separately).
33
+ _EVENT_STATUS = {
34
+ "JobQueued": "queued",
35
+ "JobStart": "processing",
36
+ "JobProgress": "processing",
37
+ "JobError": "processing",
38
+ }
39
+
40
+
41
+ def parse_progress_events(events: list[dict]) -> tuple[str, int, int | None]:
42
+ """Reduce accumulated Migration API progress events to ``(status, done, total)``.
43
+
44
+ Pass the events accumulated across polls (they are append-only), so the status
45
+ stays monotonic even when a poll returns no new events.
46
+ """
47
+ status = "queued"
48
+ done = 0
49
+ total: int | None = None
50
+ for event in events:
51
+ kind = event.get("Event")
52
+ if kind == "JobEnd":
53
+ status = "failed" if int(event.get("TotalErrors") or 0) else "succeeded"
54
+ elif kind in _EVENT_STATUS:
55
+ status = _EVENT_STATUS[kind]
56
+ if event.get("ObjectsProcessed") is not None:
57
+ done = int(event["ObjectsProcessed"])
58
+ if event.get("TotalExpectedSPObjects") is not None:
59
+ total = int(event["TotalExpectedSPObjects"])
60
+ return status, done, total
61
+
62
+
63
+ def job_errors(events: list[dict]) -> list[dict]:
64
+ """The ``JobError`` events from a progress log (each carries ``Message``/``ErrorType``/``Url``)."""
65
+ return [event for event in events if event.get("Event") == "JobError"]
66
+
25
67
 
26
68
  class MigrationServerJob:
27
69
  """A server-side migration job: submit an ingestion job, then monitor it."""
@@ -34,16 +76,16 @@ class MigrationServerJob:
34
76
  g_web_id,
35
77
  azure_container_source_uri: str,
36
78
  azure_container_manifest_uri: str,
37
- azure_queue_report_uri: str,
79
+ azure_queue_report_uri: str | None = None,
38
80
  ingestion_task_key: str | None = None,
39
81
  ) -> str:
40
- """Submit an ingestion job and return its job id.
82
+ """Submit an ingestion job for the staged package and return its job id.
41
83
 
42
84
  Args:
43
85
  g_web_id: Identifier of the destination web.
44
- azure_container_source_uri: Azure container URI holding the data.
45
- azure_container_manifest_uri: Azure container URI holding the manifest.
46
- azure_queue_report_uri: Azure queue URI receiving progress reports.
86
+ azure_container_source_uri: Content container URI (with SAS token).
87
+ azure_container_manifest_uri: Manifest container URI (with SAS token).
88
+ azure_queue_report_uri: Optional Azure queue URI receiving progress reports.
47
89
  ingestion_task_key: Optional task key (a UUID is generated when omitted).
48
90
  """
49
91
  result = self._site.create_migration_ingestion_job(
@@ -55,10 +97,79 @@ class MigrationServerJob:
55
97
  )
56
98
  return result.execute_query().value
57
99
 
100
+ def submit_encrypted(
101
+ self,
102
+ g_web_id,
103
+ azure_container_source_uri: str,
104
+ azure_container_manifest_uri: str,
105
+ aes256_cbc_key: str | bytes,
106
+ azure_queue_report_uri: str | None = None,
107
+ ) -> str:
108
+ """Submit an ingestion job for an AES-256-CBC encrypted package.
109
+
110
+ Required for SharePoint-provided containers; use the ``EncryptionKey`` from
111
+ ``Site.provision_migration_containers`` (a base64 string, passed through).
112
+ """
113
+ result = self._site.create_migration_job_encrypted(
114
+ g_web_id=g_web_id,
115
+ azure_container_source_uri=azure_container_source_uri,
116
+ azure_container_manifest_uri=azure_container_manifest_uri,
117
+ aes256_cbc_key=aes256_cbc_key,
118
+ azure_queue_report_uri=azure_queue_report_uri,
119
+ )
120
+ return result.execute_query().value
121
+
122
+ def progress(self, job_id: str, next_token: str = "0") -> tuple[list[dict], str]:
123
+ """Fetch a page of progress events and the next paging token.
124
+
125
+ Args:
126
+ job_id: The migration job id.
127
+ next_token: Paging token; use ``"0"`` for the initial request.
128
+
129
+ Returns:
130
+ ``(events, next_token)`` — events are decoded from the JSON log strings.
131
+ """
132
+ result = self._site.get_migration_job_progress(job_id, next_token).execute_query()
133
+ value = result.value
134
+ events = [json.loads(line) for line in (value.Logs or [])]
135
+ return events, value.NextToken or next_token
136
+
137
+ def all_events(self, job_id: str) -> list[dict]:
138
+ """All progress events for a job (paged until the token stops advancing).
139
+
140
+ For a completed job the API is idempotent per token, so this collects the
141
+ full lifecycle — including any ``JobError`` entries.
142
+ """
143
+ events: list[dict] = []
144
+ token = "0"
145
+ for _ in range(100): # bound: a page with no new events ends the loop
146
+ page, next_token = self.progress(job_id, token)
147
+ events.extend(page)
148
+ if not page or next_token == token:
149
+ break
150
+ token = next_token
151
+ return events
152
+
153
+ def errors(self, job_id: str) -> list[dict]:
154
+ """The ``JobError`` events for a job (message, type, url)."""
155
+ return job_errors(self.all_events(job_id))
156
+
157
+ def status_fn(self) -> Callable[[str], tuple[str, int, int | None]]:
158
+ """A ``monitor`` status function backed by ``GetMigrationJobProgress``."""
159
+ state: dict = {"token": "0", "events": []}
160
+
161
+ def _status(job_id: str) -> tuple[str, int, int | None]:
162
+ events, token = self.progress(job_id, state["token"])
163
+ state["token"] = token
164
+ state["events"].extend(events)
165
+ return parse_progress_events(state["events"])
166
+
167
+ return _status
168
+
58
169
  def monitor(
59
170
  self,
60
171
  job_id: str,
61
- status_fn: Callable[[str], tuple[str, int, int | None]],
172
+ status_fn: Callable[[str], tuple[str, int, int | None]] | None = None,
62
173
  interval: float = 5,
63
174
  timeout: float = 1800,
64
175
  progress: Callable[["Progress"], None] | None = None,
@@ -68,7 +179,7 @@ class MigrationServerJob:
68
179
  Args:
69
180
  job_id: The migration job id.
70
181
  status_fn: Callable returning ``(status, done, total)`` for a job id.
71
- ``done``/``total`` feed the ``Progress`` hook (total may be None).
182
+ Defaults to a ``GetMigrationJobProgress``-backed reader.
72
183
  interval: Seconds between polls.
73
184
  timeout: Maximum seconds to wait before raising ``TimeoutError``.
74
185
  progress: Optional hook fired per poll with a ``Progress`` snapshot.
@@ -79,11 +190,12 @@ class MigrationServerJob:
79
190
  Raises:
80
191
  TimeoutError: When the job doesn't finish within ``timeout`` seconds.
81
192
  """
193
+ status_fn = status_fn or self.status_fn()
82
194
  elapsed = 0.0
83
195
  while elapsed < timeout:
84
196
  status, done, total = status_fn(job_id)
85
197
  emit_progress(progress, done=done, total=total, stage="migrating")
86
- if status.lower() in ("succeeded", "completed", "failed", "cancelled"):
198
+ if status.lower() in _TERMINAL:
87
199
  return status
88
200
  time.sleep(interval)
89
201
  elapsed += interval