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
@@ -43,8 +43,9 @@ def run_parallel(
43
43
  given it is bound to every produced context.
44
44
  limiter: Optional shared :class:`RateLimiter` for fleet pacing; when
45
45
  ``context_factory`` is given one is created automatically.
46
- progress: Optional hook fired per completed task with a ``Progress``
47
- snapshot (``done``/``total``).
46
+ progress: Optional hook fired (on the calling thread) per completed task
47
+ with a ``Progress`` snapshot (``done``/``total`` and ``items=[result]``
48
+ for that task), so callers get live, ordered reporting.
48
49
  on_error: Optional ``(task, error) -> fallback`` handler; when absent a
49
50
  task failure re-raises from the pool.
50
51
 
@@ -60,8 +61,6 @@ def run_parallel(
60
61
  limiter = RateLimiter()
61
62
  results: List[Any] = [None] * len(tasks)
62
63
  local = threading.local()
63
- lock = threading.Lock()
64
- done = {"count": 0}
65
64
 
66
65
  def _context() -> Optional["ClientRuntimeContext"]:
67
66
  ctx = getattr(local, "context", None)
@@ -80,17 +79,16 @@ def run_parallel(
80
79
  results[index] = on_error(task, e)
81
80
  else:
82
81
  raise
83
- finally:
84
- with lock:
85
- done["count"] += 1
86
- n = done["count"]
87
- if callable(progress):
88
- from office365.runtime.operations import Progress
89
-
90
- progress(Progress(done=n, total=len(tasks), stage="parallel"))
91
82
 
92
83
  with ThreadPoolExecutor(max_workers=concurrency) as executor:
93
- futures = [executor.submit(_run, index, task) for index, task in enumerate(tasks)]
94
- for future in as_completed(futures):
84
+ future_to_index = {executor.submit(_run, index, task): index for index, task in enumerate(tasks)}
85
+ completed = 0
86
+ for future in as_completed(future_to_index):
87
+ index = future_to_index[future]
95
88
  future.result()
89
+ completed += 1
90
+ if callable(progress):
91
+ from office365.runtime.operations import Progress
92
+
93
+ progress(Progress(done=completed, total=len(tasks), stage="parallel", items=[results[index]]))
96
94
  return results
@@ -28,8 +28,8 @@ class EntityPath(ResourcePath):
28
28
  from office365.onedrive.internal.paths.children import ChildrenPath
29
29
  from office365.teams.internal.paths.joined_teams import JoinedTeamsPath
30
30
 
31
- if self._collection is not None:
32
- self._parent = self._collection
31
+ if self.collection is not None:
32
+ self._parent = self.collection
33
33
  elif isinstance(self.parent, ChildrenPath):
34
34
  self._parent = self.parent.collection
35
35
  elif isinstance(self.parent, JoinedTeamsPath):
@@ -27,19 +27,36 @@ def create_boundary(prefix: str, compact: bool = False) -> str:
27
27
  class BatchQuery(ClientQuery[Any], Generic[ReturnT]):
28
28
  """Client query collection for batch requests."""
29
29
 
30
- def __init__(self, context: ClientRuntimeContext, queries: list[ClientQuery[Any]] | None = None) -> None:
30
+ def __init__(
31
+ self,
32
+ context: ClientRuntimeContext,
33
+ queries: list[ClientQuery[Any]] | None = None,
34
+ sequential: bool = False,
35
+ ) -> None:
31
36
  """
32
37
  Initialize a batch query collection.
33
38
 
34
39
  Args:
35
40
  context: Client runtime context
36
41
  queries: List of queries to include in the batch (optional)
42
+ sequential: Whether sub-requests are chained with ``dependsOn`` so the
43
+ server runs them in order (Graph v4 only; ignored by v3).
37
44
  """
38
45
  super().__init__(context)
39
46
  self._current_boundary = create_boundary("batch_")
40
47
  if queries is None:
41
48
  queries = []
42
49
  self._queries: list[ClientQuery[Any]] = queries
50
+ self._sequential = sequential
51
+
52
+ @property
53
+ def sequential(self) -> bool:
54
+ """Whether sub-requests are chained with ``dependsOn`` (Graph v4 only)."""
55
+ return self._sequential
56
+
57
+ @sequential.setter
58
+ def sequential(self, value: bool) -> None:
59
+ self._sequential = value
43
60
 
44
61
  def add(self, query: ClientQuery[Any]) -> Self:
45
62
  """Add a query to the batch.
@@ -0,0 +1,109 @@
1
+ """Shared idempotent get-or-create helpers (the metadata analogue of upsert).
2
+
3
+ The ``ensure_*`` methods across the library all implement the same idea — return
4
+ an existing resource or create it — in one of two ways:
5
+
6
+ - **create-first** (:func:`create_or_get`): queue the create; if the server
7
+ reports a duplicate, load and return the existing resource.
8
+ - **get-first** (:func:`get_or_create`): queue a read; if it 404s, queue the
9
+ create in the read's queue slot (so later queries still run after it).
10
+
11
+ Both centralize the error classification (via the exception registry types) and
12
+ the ``copy_from``/``execute_first`` mechanics, so every ``ensure_*`` behaves the
13
+ same and a re-run is a no-op. ``on_conflict="update"`` reconciles an existing
14
+ resource's definition (the metadata upsert).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Callable, Optional, Type, TypeVar
20
+
21
+ from office365.runtime.exceptions import DuplicatedObjectException, ObjectNotFoundException
22
+ from office365.runtime.queries.deferred import DeferredOperationQuery
23
+
24
+ if TYPE_CHECKING:
25
+ from office365.runtime.client_object import ClientObject
26
+ from office365.runtime.queries.client_query import ClientQuery
27
+
28
+ T = TypeVar("T", bound="ClientObject")
29
+
30
+ ON_CONFLICT_MODES = ("skip", "update")
31
+
32
+
33
+ def create_or_get(
34
+ create: Callable[[], T],
35
+ find: Callable[[], T],
36
+ *,
37
+ on_duplicate: Type[Exception] = DuplicatedObjectException,
38
+ on_conflict: str = "skip",
39
+ reconcile: Optional[Callable[[T], None]] = None,
40
+ ) -> T:
41
+ """Queue a create; on a duplicate, load and return the existing resource.
42
+
43
+ Args:
44
+ create: Queues the create and returns the new entity.
45
+ find: Queues the read of the existing entity and returns it.
46
+ on_duplicate: The exception type signalling the resource already exists.
47
+ on_conflict: ``"skip"`` (keep existing) or ``"update"`` (reconcile it).
48
+ reconcile: Applied to the existing entity when ``on_conflict="update"``.
49
+ """
50
+ if on_conflict not in ON_CONFLICT_MODES:
51
+ raise ValueError(f"on_conflict must be one of {ON_CONFLICT_MODES}, got {on_conflict!r}")
52
+ return_type = create()
53
+
54
+ def _apply(existing: "ClientObject") -> None:
55
+ return_type.copy_from(existing)
56
+ if on_conflict == "update" and callable(reconcile):
57
+ reconcile(return_type)
58
+
59
+ def _on_duplicate(error: Exception) -> None:
60
+ if not isinstance(error, on_duplicate):
61
+ raise error
62
+ find().after_execute(_apply, execute_first=True)
63
+
64
+ return_type.on_error(_on_duplicate)
65
+ return return_type
66
+
67
+
68
+ def get_or_create(
69
+ find: Callable[[], T],
70
+ create_query: Callable[[], "ClientQuery"],
71
+ return_type: T,
72
+ *,
73
+ on_missing: Type[Exception] = ObjectNotFoundException,
74
+ on_conflict: str = "skip",
75
+ reconcile: Optional[Callable[[T], None]] = None,
76
+ ) -> T:
77
+ """Queue a read; on a not-found, queue the create in the read's queue slot.
78
+
79
+ The create runs in the placeholder's position, so anything queued after the
80
+ ensure still runs after the resource exists (e.g. item creates after field
81
+ creates).
82
+
83
+ Args:
84
+ find: Queues the read of the existing entity and returns it.
85
+ create_query: Builds (does not queue) the create query.
86
+ return_type: The entity to populate (existing or created).
87
+ on_missing: The exception type signalling the resource is absent.
88
+ on_conflict: ``"skip"`` (keep existing) or ``"update"`` (reconcile it).
89
+ reconcile: Applied to the existing entity when ``on_conflict="update"``.
90
+ """
91
+ if on_conflict not in ON_CONFLICT_MODES:
92
+ raise ValueError(f"on_conflict must be one of {ON_CONFLICT_MODES}, got {on_conflict!r}")
93
+ context = return_type.context
94
+ barrier = DeferredOperationQuery(context, return_type=return_type)
95
+
96
+ def _on_found(existing: "ClientObject") -> None:
97
+ return_type.copy_from(existing)
98
+ if on_conflict == "update" and callable(reconcile):
99
+ reconcile(return_type)
100
+ barrier.resolve()
101
+
102
+ def _on_missing(error: Exception) -> None:
103
+ if not isinstance(error, on_missing):
104
+ raise error
105
+ barrier.defer(create_query())
106
+
107
+ find().after_execute(_on_found).on_error(_on_missing)
108
+ context.add_query(barrier)
109
+ return return_type
@@ -0,0 +1,443 @@
1
+ """A collection that can be exported to / imported from tabular and record formats.
2
+
3
+ Keeps the data-interchange concern (pandas/CSV/JSON/NDJSON/Excel) out of the core
4
+ :class:`~office365.runtime.client_object_collection.ClientObjectCollection`, and
5
+ exposes named conveniences (``to_csv``/``from_dataframe``/``queue_dataframe`` …)
6
+ and unified ``export_to``/``from_records`` entry points backed by a format
7
+ registry. ``from_*`` is the **streaming** entry (returns an
8
+ :class:`~office365.runtime.imports.ImportResult` driver — bounded, resumable,
9
+ idempotent); ``queue_*`` is the deferred queue-all entry.
10
+
11
+ Keyed imports (skip/upsert) are opt-in: a subclass exposes an
12
+ :class:`~office365.runtime.converters.upsert.UpsertTarget` via :meth:`upsert_target`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from os import PathLike
18
+ from typing import IO, TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Union
19
+
20
+ from typing_extensions import Self
21
+
22
+ from office365.runtime.client_object import ClientObjectT
23
+ from office365.runtime.client_object_collection import ClientObjectCollection
24
+ from office365.runtime.converters import registry
25
+ from office365.runtime.limits import SAFE_PAGE_SIZE
26
+ from office365.runtime.verification import VerificationReport
27
+
28
+ if TYPE_CHECKING:
29
+ from office365.runtime.converters.dataframe import DataFrameResult
30
+ from office365.runtime.converters.upsert import UpsertTarget
31
+ from office365.runtime.imports import CheckpointStore, ImportCheckpoint, ImportResult
32
+ from office365.runtime.operations import ProgressCallback
33
+
34
+
35
+ # The pipeline and the migration toolkit share one reconciliation report type.
36
+ VerificationResult = VerificationReport
37
+
38
+
39
+ def _apply_mapping(convert: Callable[[Any], list[dict]], mapping: Dict[str, str]) -> Callable[[Any], list[dict]]:
40
+ """Wrap a chunk converter to rename record keys (``source -> target``)."""
41
+
42
+ def mapped(chunk: Any) -> list[dict]:
43
+ return [{mapping.get(k, k): v for k, v in record.items()} for record in convert(chunk)]
44
+
45
+ return mapped
46
+
47
+
48
+ class RecordCollection(ClientObjectCollection[ClientObjectT]):
49
+ """A collection with record import/export capabilities."""
50
+
51
+ # ── Export ───────────────────────────────────────────────────
52
+
53
+ def export_to(self, target: Any, *, format: str = "csv", page_size: Optional[int] = None, **opts: Any) -> Self: # noqa: A002
54
+ """Deferred export of the loaded items to ``target`` in ``format``.
55
+
56
+ The writer is resolved from the format registry, so formats are pluggable.
57
+ Pass ``page_size`` to **stream** an appendable format (CSV/TSV/NDJSON/JSON)
58
+ page by page — bounded memory for large collections:
59
+
60
+ >>> client.users.get_all().export_to(f, format="csv").execute_query()
61
+ >>> lst.items.export_to("out.csv", page_size=2000).execute_query()
62
+ """
63
+ if page_size:
64
+ return self._export_paged(target, format, page_size, opts)
65
+ writer = registry.writer_for(format)
66
+ return self.after_execute(lambda _: writer(self, target, **opts))
67
+
68
+ def _export_paged(self, target: Any, format: str, page_size: int, opts: Dict[str, Any]) -> Self: # noqa: A002
69
+ """Page through the collection and append records (bounded memory)."""
70
+ from office365.runtime.converters import streamers
71
+ from office365.runtime.converters.records import records_from_items
72
+
73
+ factory = streamers.streamer_for(format)
74
+ if factory is None: # not appendable — write the whole collection at once
75
+ writer = registry.writer_for(format)
76
+ return self.after_execute(lambda _: writer(self, target, **opts))
77
+
78
+ stream = factory(target, **opts)
79
+ state = {"start": 0}
80
+
81
+ def _on_page(col: ClientObjectCollection) -> None:
82
+ items = list(col)[state["start"] :]
83
+ state["start"] = len(col)
84
+ stream.write(records_from_items(items, col.query_options.select, col.query_options.expand))
85
+ if not col.has_next:
86
+ stream.close()
87
+
88
+ self.get_all(page_size=page_size, page_loaded=_on_page)
89
+ return self
90
+
91
+ def to_records(self, raw: bool = False) -> List[Dict[str, Any]]:
92
+ """Project loaded items into plain dict records — the neutral export form.
93
+
94
+ The projection shared by every exporter: ``.select()``/``.expand()``
95
+ columns, one record per expanded child item, native JSON-safe values.
96
+ Unlike ``to_csv`` this returns a value directly, so load first.
97
+
98
+ Args:
99
+ raw: When True, keep native property values (no JSON coercion).
100
+ """
101
+ from office365.runtime.converters.records import iter_records
102
+
103
+ return iter_records(self, raw=raw)
104
+
105
+ def to_csv(self, file: IO[str]) -> Self:
106
+ """Export loaded items to CSV (deferred)."""
107
+ return self.export_to(file, format="csv")
108
+
109
+ def to_ndjson(self, file: IO[str]) -> Self:
110
+ """Export loaded items as NDJSON — one record per line (deferred)."""
111
+ return self.export_to(file, format="ndjson")
112
+
113
+ def to_excel(self, target: Union[str, PathLike]) -> Self:
114
+ """Export loaded items to an Excel (.xlsx) worksheet (deferred).
115
+
116
+ Requires the optional dependency (``pip install
117
+ office365-rest-python-client[excel]``).
118
+ """
119
+ return self.export_to(target, format="excel")
120
+
121
+ def to_parquet(self, target: Any) -> Self:
122
+ """Export loaded items to Parquet (deferred; requires the ``[parquet]`` extra)."""
123
+ return self.export_to(target, format="parquet")
124
+
125
+ def to_orc(self, target: Any) -> Self:
126
+ """Export loaded items to ORC (deferred; requires the ``[parquet]`` extra)."""
127
+ return self.export_to(target, format="orc")
128
+
129
+ def to_feather(self, target: Any) -> Self:
130
+ """Export loaded items to Feather (deferred; requires the ``[parquet]`` extra)."""
131
+ return self.export_to(target, format="feather")
132
+
133
+ def to_sql(self, target: Any, *, table: str, **opts: Any) -> Self:
134
+ """Write loaded items to a SQL table (deferred; requires the ``[sql]`` extra)."""
135
+ from office365.runtime.converters.records import iter_records
136
+ from office365.runtime.converters.sql import write_sql
137
+
138
+ return self.after_execute(lambda _: write_sql(iter_records(self), target, table=table, **opts))
139
+
140
+ def to_duckdb(self, target: Any, *, table: str, **opts: Any) -> Self:
141
+ """Write loaded items to a DuckDB table (deferred; requires the ``[duckdb]`` extra)."""
142
+ from office365.runtime.converters.records import iter_records
143
+ from office365.runtime.converters.sql import write_duckdb
144
+
145
+ return self.after_execute(lambda _: write_duckdb(iter_records(self), target, table=table, **opts))
146
+
147
+ def to_dataframe(self) -> "DataFrameResult":
148
+ """Build a pandas DataFrame from the loaded items (deferred result).
149
+
150
+ Returns a ``DataFrameResult`` whose ``.value`` holds the DataFrame after
151
+ ``execute_query()``. Requires the ``[pandas]`` extra.
152
+ """
153
+ from office365.runtime.converters.dataframe import DataFrameResult, require_pandas, write_dataframe
154
+
155
+ require_pandas() # fail fast on a missing optional dependency
156
+ result = DataFrameResult(self.context)
157
+ self.after_execute(lambda _: write_dataframe(self, result))
158
+ return result
159
+
160
+ # ── Import: deferred (queue-all) ─────────────────────────────
161
+
162
+ def queue_records(self, records: List[dict], progress: "ProgressCallback | None" = None) -> Self:
163
+ """Queue a create per record (deferred, run with ``execute_query()``).
164
+
165
+ The neutral queue-all counterpart of :meth:`to_records`. Records are
166
+ normalized to the item type and each becomes an entity queued for
167
+ creation. Use the streaming :meth:`from_records` for large or
168
+ resumable/idempotent imports (bounded memory).
169
+ """
170
+ from office365.runtime.converters.csv_reader import coerce_records
171
+
172
+ return self._import_records(coerce_records(self._item_type, records), progress=progress)
173
+
174
+ def queue_dataframe(self, df, progress: "ProgressCallback | None" = None) -> Self:
175
+ """Queue a create per DataFrame row (deferred, run with ``execute_query()``)."""
176
+ return self.queue_records(registry.reader_for("dataframe")(df), progress=progress)
177
+
178
+ def _import_records(self, records: List[dict], progress: "ProgressCallback | None" = None) -> Self:
179
+ """Queue a create per record, appending the pending entities to this collection."""
180
+ from office365.runtime.operations import query_progress_hook
181
+ from office365.runtime.queries.create_entity import CreateEntityQuery
182
+
183
+ hook = query_progress_hook(len(records), progress) if callable(progress) else None
184
+ for record in records:
185
+ entity = self.create_typed_object(record)
186
+ self.add_child(entity)
187
+ qry = CreateEntityQuery(self, entity, entity)
188
+ self.context.add_query(qry)
189
+ if hook is not None:
190
+ self.context.after_execute(hook)
191
+ return self
192
+
193
+ # ── Import: streaming (bounded, resumable, idempotent) ───────
194
+
195
+ def from_records(
196
+ self,
197
+ source: Any,
198
+ *,
199
+ format: str = "records", # noqa: A002
200
+ chunksize: int = SAFE_PAGE_SIZE,
201
+ key: "str | list[str] | None" = None,
202
+ key_field: str = "MigrationKey",
203
+ on_conflict: str = "skip",
204
+ enforce_unique: bool = False,
205
+ checkpoint: "ImportCheckpoint | CheckpointStore | str | PathLike | None" = None,
206
+ on_error: str = "raise",
207
+ progress: "ProgressCallback | None" = None,
208
+ prepare: "Callable[[Any], None] | None" = None,
209
+ before_chunk: "Callable[[Any], None] | None" = None,
210
+ to_records: "Callable[[Any], list[dict]] | None" = None,
211
+ total: Optional[int] = None,
212
+ dry_run: bool = False,
213
+ dead_letter: "str | PathLike | None" = None,
214
+ mapping: "Dict[str, str] | None" = None,
215
+ coerce: "Dict[str, Callable[[Any], Any]] | None" = None,
216
+ ) -> "ImportResult":
217
+ """Stream a source into this collection, memory-bounded (the streaming entry).
218
+
219
+ ``source`` is chunked by ``format``: ``dataframe``/``csv`` (a DataFrame,
220
+ chunk iterable, or CSV path/URL/file), ``records`` (an iterable of record
221
+ batches), or any registered format (read whole). Returns an
222
+ :class:`~office365.runtime.imports.ImportResult`; choose the terminal
223
+ (``execute_query``/``execute_batch``) or iterate it. ``key`` enables
224
+ idempotent skip/upsert via :meth:`upsert_target`; ``mapping`` renames
225
+ source columns/keys; ``coerce`` maps a record key to a value converter
226
+ (used for typed destination fields).
227
+ """
228
+ from office365.runtime.imports import ImportResult
229
+
230
+ chunks, convert, inferred_total = self._resolve_source(source, format, chunksize, to_records)
231
+ if total is None:
232
+ total = inferred_total
233
+ if mapping:
234
+ convert = _apply_mapping(convert, mapping)
235
+
236
+ raw_key_columns = [key] if isinstance(key, str) else (list(key) if key else [])
237
+ key_columns = [self._key_column(c) for c in raw_key_columns]
238
+ target = self.upsert_target(key_field=key_field, enforce_unique=enforce_unique) if key_columns else None
239
+ if key_columns and target is None:
240
+ raise ValueError("key= requires an upsert-capable collection (override upsert_target())")
241
+ existing: Dict[str, Any] = {}
242
+ keys_loaded = False
243
+
244
+ def _prepare(first_chunk: Any) -> None:
245
+ if callable(prepare):
246
+ prepare(first_chunk)
247
+
248
+ def _ensure_keys() -> None:
249
+ """Ensure the key column and load existing keys (once, on every run).
250
+
251
+ Loading is lazy (on the first queued chunk) rather than part of the
252
+ fresh-run-only ``prepare`` so a **resumed** run still skips records
253
+ that are already present — the key load is what makes a replayed or
254
+ overlapping chunk idempotent, not just a fresh run.
255
+ """
256
+ nonlocal keys_loaded
257
+ if target is None or keys_loaded:
258
+ return
259
+ target.ensure_key_field()
260
+ self.context.execute_query()
261
+ existing.update(target.load_keys())
262
+ keys_loaded = True
263
+
264
+ def _queue(records: list[dict]) -> tuple[int, int]:
265
+ if coerce:
266
+ records = [
267
+ {
268
+ key: (coerce[key](value) if key in coerce and value is not None else value)
269
+ for key, value in record.items()
270
+ }
271
+ for record in records
272
+ ]
273
+ if target is None:
274
+ if not dry_run:
275
+ self.queue_records(records)
276
+ return len(records), 0
277
+ from office365.runtime.converters.upsert import keyed_queue
278
+
279
+ _ensure_keys()
280
+ return keyed_queue(
281
+ target,
282
+ records,
283
+ key_columns=key_columns,
284
+ existing=existing,
285
+ on_conflict=on_conflict,
286
+ dry_run=dry_run,
287
+ )
288
+
289
+ return ImportResult(
290
+ self.context,
291
+ self,
292
+ chunks,
293
+ to_records=convert,
294
+ prepare=_prepare,
295
+ before_chunk=before_chunk,
296
+ queue=_queue,
297
+ total=total,
298
+ progress=progress,
299
+ checkpoint=checkpoint,
300
+ on_error=on_error,
301
+ dry_run=dry_run,
302
+ dead_letter=dead_letter,
303
+ signature={
304
+ "format": format,
305
+ "chunk_size": chunksize,
306
+ "key": list(key_columns),
307
+ "key_field": key_field,
308
+ },
309
+ )
310
+
311
+ def from_dataframe(self, df, **opts: Any) -> "ImportResult":
312
+ """Stream a pandas DataFrame (or chunked CSV) into this collection."""
313
+ return self.from_records(df, format="dataframe", **opts)
314
+
315
+ def from_csv(self, source: Any, **opts: Any) -> "ImportResult":
316
+ """Stream a CSV source (path/URL/file or chunk iterable) into this collection."""
317
+ return self.from_records(source, format="csv", **opts)
318
+
319
+ def from_json(self, source: Any, **opts: Any) -> "ImportResult":
320
+ """Stream a JSON-array file into this collection."""
321
+ return self.from_records(source, format="json", **opts)
322
+
323
+ def from_ndjson(self, source: Any, **opts: Any) -> "ImportResult":
324
+ """Stream an NDJSON (JSON Lines) source into this collection."""
325
+ return self.from_records(source, format="ndjson", **opts)
326
+
327
+ def from_excel(self, source: Any, **opts: Any) -> "ImportResult":
328
+ """Stream an Excel (.xlsx) worksheet into this collection."""
329
+ return self.from_records(source, format="excel", **opts)
330
+
331
+ def from_parquet(self, source: Any, **opts: Any) -> "ImportResult":
332
+ """Stream a Parquet file into this collection (requires the ``[parquet]`` extra)."""
333
+ return self.from_records(source, format="parquet", **opts)
334
+
335
+ def from_orc(self, source: Any, **opts: Any) -> "ImportResult":
336
+ """Stream an ORC file into this collection (requires the ``[parquet]`` extra)."""
337
+ return self.from_records(source, format="orc", **opts)
338
+
339
+ def from_feather(self, source: Any, **opts: Any) -> "ImportResult":
340
+ """Stream a Feather file into this collection (requires the ``[parquet]`` extra)."""
341
+ return self.from_records(source, format="feather", **opts)
342
+
343
+ def from_sql(self, source: Any, *, query: str, chunksize: int = 2000, **opts: Any) -> "ImportResult":
344
+ """Stream a SQL query result into this collection (requires the ``[sql]`` extra).
345
+
346
+ ``source`` is a SQLAlchemy ``Engine``/``Connection`` or a connection URL
347
+ string; rows are fetched in ``chunksize`` batches (bounded memory).
348
+ """
349
+ from office365.runtime.converters.sql import sql_chunks
350
+
351
+ return self.from_records(sql_chunks(source, query, chunksize), format="records", **opts)
352
+
353
+ def from_duckdb(self, source: Any, *, query: str, chunksize: int = 2000, **opts: Any) -> "ImportResult":
354
+ """Stream a DuckDB query result into this collection (requires the ``[duckdb]`` extra).
355
+
356
+ ``source`` is a DuckDB connection or a database path; rows are fetched in
357
+ ``chunksize`` batches (bounded memory).
358
+ """
359
+ from office365.runtime.converters.sql import duckdb_chunks
360
+
361
+ return self.from_records(duckdb_chunks(source, query, chunksize), format="records", **opts)
362
+
363
+ # ── Verification ─────────────────────────────────────────────
364
+
365
+ def verify_keys(self, keys: Iterable[str], *, key_field: str = "MigrationKey") -> VerificationReport:
366
+ """Reconcile an import: assert every key hash exists in the target.
367
+
368
+ Uses the same keyed lookup as upsert (loads existing keys once), so it
369
+ verifies that an idempotent import actually landed every record.
370
+ """
371
+ target = self.upsert_target(key_field=key_field)
372
+ if target is None:
373
+ raise ValueError("verify_keys requires an upsert-capable collection")
374
+ existing = target.load_keys()
375
+ keys = list(keys)
376
+ missing = [key for key in keys if key not in existing]
377
+ return VerificationReport(
378
+ source_count=len(keys), target_count=len(keys) - len(missing), checked=len(keys), missing=missing
379
+ )
380
+
381
+ def verify(
382
+ self,
383
+ source: Any,
384
+ *,
385
+ key: "str | list[str]",
386
+ format: str = "dataframe", # noqa: A002
387
+ key_field: str = "MigrationKey",
388
+ to_records: "Callable[[Any], list[dict]] | None" = None,
389
+ ) -> VerificationReport:
390
+ """Reconcile a source's natural keys against this collection (bounded).
391
+
392
+ Streams the source, hashes each record's natural key and checks it against
393
+ the target's existing keys — the pipeline counterpart of the migration
394
+ toolkit's ``verify``. Use ``List.verify(df, key=...)`` for a list.
395
+ """
396
+ from office365.runtime.converters.upsert import record_key
397
+
398
+ raw = [key] if isinstance(key, str) else list(key)
399
+ key_columns = [self._key_column(c) for c in raw]
400
+ target = self.upsert_target(key_field=key_field)
401
+ if target is None:
402
+ raise ValueError("verify requires an upsert-capable collection")
403
+ existing = target.load_keys()
404
+ chunks, convert, _ = self._resolve_source(source, format, SAFE_PAGE_SIZE, to_records)
405
+ checked = 0
406
+ missing: list[str] = []
407
+ for chunk in chunks:
408
+ for record in convert(chunk):
409
+ checked += 1
410
+ if record_key(record, key_columns) not in existing:
411
+ missing.append(record_key(record, key_columns))
412
+ return VerificationReport(
413
+ source_count=checked, target_count=checked - len(missing), checked=checked, missing=missing
414
+ )
415
+
416
+ # ── Extension hooks ──────────────────────────────────────────
417
+
418
+ def upsert_target(self, *, key_field: str = "MigrationKey", enforce_unique: bool = False) -> "UpsertTarget | None":
419
+ """The keyed-import target, or ``None`` when the collection isn't keyable."""
420
+ return None
421
+
422
+ def _key_column(self, column: str) -> str:
423
+ """Map a source column name to the record key used after import coercion."""
424
+ return column
425
+
426
+ def _resolve_source(
427
+ self,
428
+ source: Any,
429
+ format: str, # noqa: A002
430
+ chunksize: int,
431
+ to_records: "Callable[[Any], list[dict]] | None",
432
+ ) -> "tuple[Iterable[Any], Callable[[Any], list[dict]], Optional[int]]":
433
+ """Resolve ``(chunks, to_records, total)`` for a streaming import."""
434
+ if format in ("dataframe", "csv"):
435
+ from office365.runtime.converters.dataframe import dataframe_chunks, records_from_dataframe
436
+
437
+ chunks, total = dataframe_chunks(source, chunksize)
438
+ convert = to_records or (lambda chunk: records_from_dataframe(chunk))
439
+ return chunks, convert, total
440
+ if format == "records":
441
+ return source, (to_records or (lambda batch: batch)), None
442
+ records = registry.reader_for(format)(source)
443
+ return [records], (to_records or (lambda batch: batch)), len(records)