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
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
16
16
  from office365.runtime.client_object import ClientObject
17
17
 
18
18
 
19
- def iter_records(collection: "ClientObjectCollection") -> List[Dict[str, Any]]:
19
+ def iter_records(collection: "ClientObjectCollection", raw: bool = False) -> List[Dict[str, Any]]:
20
20
  """Project a loaded collection into plain dict records.
21
21
 
22
22
  Mirrors ``csv_writer.write_csv``: plain select fields become columns,
@@ -29,19 +29,33 @@ def iter_records(collection: "ClientObjectCollection") -> List[Dict[str, Any]]:
29
29
 
30
30
  Args:
31
31
  collection: A loaded collection (items populated after execute_query()).
32
+ raw: When True, keep native property values (skip ``serialize_value``
33
+ coercion) — used for lossless round-trips like the migration toolkit.
32
34
 
33
35
  Raises:
34
36
  ValueError: When dotted select fields reference more than one navigation
35
37
  property.
36
38
  """
37
- items = list(collection)
39
+ return records_from_items(
40
+ list(collection), collection.query_options.select, collection.query_options.expand, raw=raw
41
+ )
42
+
43
+
44
+ def records_from_items(
45
+ items: List["ClientObject"],
46
+ select: List[str],
47
+ expand: "set[str] | list[str]",
48
+ raw: bool = False,
49
+ ) -> List[Dict[str, Any]]:
50
+ """Project a list of loaded items into records (the page-level primitive).
51
+
52
+ Used by :func:`iter_records` and by the paged (streaming) exporter, so a
53
+ large collection can be exported page by page without materializing it all.
54
+ """
38
55
  if not items:
39
56
  return []
40
-
41
- select = collection.query_options.select
42
- if not select:
43
- select = sorted({key for item in items for key in item.properties.keys()})
44
- expand = set(collection.query_options.expand)
57
+ select = list(select) if select else sorted({key for item in items for key in item.properties.keys()})
58
+ expand = set(expand)
45
59
  dotted = [field.split("/", 1) for field in select if "/" in field]
46
60
  navs = {nav for nav, _ in dotted}
47
61
  if len(navs) > 1:
@@ -54,15 +68,20 @@ def iter_records(collection: "ClientObjectCollection") -> List[Dict[str, Any]]:
54
68
  records: List[Dict[str, Any]] = []
55
69
  for item in items:
56
70
  children = _resolve_property(item, nav, expand)
57
- base: Dict[str, Any] = {key: serialize_value(item.properties.get(key)) for key in plain}
71
+ base: Dict[str, Any] = {key: _value(item.properties.get(key), raw) for key in plain}
58
72
  for child in children:
59
73
  record = dict(base)
60
74
  for _nav_prop, field_name in dotted:
61
- record[f"{_nav_prop}/{field_name}"] = _property_value(child, field_name)
75
+ record[f"{_nav_prop}/{field_name}"] = _property_value(child, field_name, raw)
62
76
  records.append(record)
63
77
  return records
64
78
 
65
79
 
80
+ def _value(value: Any, raw: bool) -> Any:
81
+ """Coerce a property value to its JSON-safe form unless ``raw`` is requested."""
82
+ return value if raw else serialize_value(value)
83
+
84
+
66
85
  def _resolve_property(item: "ClientObject", nav: str | None, expand: set[str]) -> list[Any]:
67
86
  """Resolve an expanded navigation property into a list of child items."""
68
87
  if nav is None or nav not in expand:
@@ -75,8 +94,8 @@ def _resolve_property(item: "ClientObject", nav: str | None, expand: set[str]) -
75
94
  return [raw]
76
95
 
77
96
 
78
- def _property_value(prop: "dict | ClientObject", field_name: str) -> Any:
97
+ def _property_value(prop: "dict | ClientObject", field_name: str, raw: bool = False) -> Any:
79
98
  """Read a property value from an item that may be a ClientObject or dict."""
80
99
  if isinstance(prop, dict):
81
- return serialize_value(prop.get(field_name))
82
- return serialize_value(prop.properties.get(field_name))
100
+ return _value(prop.get(field_name), raw)
101
+ return _value(prop.properties.get(field_name), raw)
@@ -0,0 +1,185 @@
1
+ """Format registry — maps a format name to its record reader/writer.
2
+
3
+ Keeps the import/export surface of :class:`~office365.runtime.record_collection.RecordCollection`
4
+ data-driven: adding a format is a registration, not a new method. Readers turn a
5
+ source into plain dict records; writers persist a collection's records to a
6
+ target. Both import their (optional) dependency lazily, so registering a format
7
+ never pulls in pandas/openpyxl.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List
13
+
14
+ if TYPE_CHECKING:
15
+ from office365.runtime.client_object_collection import ClientObjectCollection
16
+
17
+ RecordReader = Callable[..., List[dict]]
18
+ RecordWriter = Callable[..., None]
19
+
20
+ _READERS: Dict[str, RecordReader] = {}
21
+ _WRITERS: Dict[str, RecordWriter] = {}
22
+
23
+
24
+ def register(name: str, *, reader: RecordReader, writer: RecordWriter) -> None:
25
+ """Register (or replace) a format's reader and writer."""
26
+ _READERS[name] = reader
27
+ _WRITERS[name] = writer
28
+
29
+
30
+ def reader_for(name: str) -> RecordReader:
31
+ """The reader for a format, or a ``KeyError`` listing the known formats."""
32
+ try:
33
+ return _READERS[name]
34
+ except KeyError:
35
+ raise KeyError(f"Unknown import format {name!r}; known: {sorted(_READERS)}") from None
36
+
37
+
38
+ def writer_for(name: str) -> RecordWriter:
39
+ """The writer for a format, or a ``KeyError`` listing the known formats."""
40
+ try:
41
+ return _WRITERS[name]
42
+ except KeyError:
43
+ raise KeyError(f"Unknown export format {name!r}; known: {sorted(_WRITERS)}") from None
44
+
45
+
46
+ def formats() -> List[str]:
47
+ """The registered format names."""
48
+ return sorted(set(_READERS) | set(_WRITERS))
49
+
50
+
51
+ # ── Built-in formats (lazy imports) ──────────────────────────────
52
+
53
+
54
+ def _read_csv(source: Any, **opts: Any) -> List[dict]:
55
+ from office365.runtime.converters.csv_reader import read_csv_records
56
+ from office365.runtime.converters.streams import open_text
57
+
58
+ with open_text(source, "r") as f:
59
+ return read_csv_records(f, opts.get("delimiter", ","))
60
+
61
+
62
+ def _write_csv(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
63
+ from office365.runtime.converters.csv_writer import write_csv
64
+ from office365.runtime.converters.streams import open_text
65
+
66
+ with open_text(target, "w") as f:
67
+ write_csv(collection, f, delimiter=opts.get("delimiter", ","))
68
+
69
+
70
+ def _read_tsv(source: Any, **opts: Any) -> List[dict]:
71
+ return _read_csv(source, delimiter="\t", **opts)
72
+
73
+
74
+ def _write_tsv(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
75
+ _write_csv(collection, target, delimiter="\t", **opts)
76
+
77
+
78
+ def _read_ndjson(source: Any, **opts: Any) -> List[dict]:
79
+ from office365.runtime.converters.ndjson import read_ndjson
80
+ from office365.runtime.converters.streams import open_text
81
+
82
+ with open_text(source, "r") as f:
83
+ return read_ndjson(f)
84
+
85
+
86
+ def _write_ndjson(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
87
+ from office365.runtime.converters.ndjson import write_ndjson
88
+ from office365.runtime.converters.records import iter_records
89
+ from office365.runtime.converters.streams import open_text
90
+
91
+ with open_text(target, "w") as f:
92
+ write_ndjson(iter_records(collection), f)
93
+
94
+
95
+ def _read_json_file(source: Any, **opts: Any) -> List[dict]:
96
+ from office365.runtime.converters.json_file import read_json
97
+ from office365.runtime.converters.streams import open_text
98
+
99
+ with open_text(source, "r") as f:
100
+ return read_json(f)
101
+
102
+
103
+ def _write_json_file(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
104
+ from office365.runtime.converters.json_file import write_json
105
+ from office365.runtime.converters.records import iter_records
106
+ from office365.runtime.converters.streams import open_text
107
+
108
+ with open_text(target, "w") as f:
109
+ write_json(iter_records(collection), f, indent=opts.get("indent"))
110
+
111
+
112
+ def _read_excel(source: Any, **opts: Any) -> List[dict]:
113
+ from office365.runtime.converters.excel import read_excel
114
+
115
+ return read_excel(source)
116
+
117
+
118
+ def _write_excel(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
119
+ from office365.runtime.converters.excel import write_excel
120
+ from office365.runtime.converters.records import iter_records
121
+
122
+ write_excel(iter_records(collection), target)
123
+
124
+
125
+ def _read_dataframe(df: Any, **opts: Any) -> List[dict]:
126
+ from office365.runtime.converters.dataframe import read_dataframe
127
+
128
+ return read_dataframe(df)
129
+
130
+
131
+ def _write_dataframe(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
132
+ from office365.runtime.converters.dataframe import write_dataframe
133
+
134
+ write_dataframe(collection, target)
135
+
136
+
137
+ def _read_parquet(source: Any, **opts: Any) -> List[dict]:
138
+ from office365.runtime.converters.parquet import read_parquet
139
+
140
+ return read_parquet(source)
141
+
142
+
143
+ def _write_parquet(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
144
+ from office365.runtime.converters.parquet import write_parquet
145
+ from office365.runtime.converters.records import iter_records
146
+
147
+ write_parquet(iter_records(collection), target)
148
+
149
+
150
+ def _read_orc(source: Any, **opts: Any) -> List[dict]:
151
+ from office365.runtime.converters.parquet import read_orc
152
+
153
+ return read_orc(source)
154
+
155
+
156
+ def _write_orc(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
157
+ from office365.runtime.converters.parquet import write_orc
158
+ from office365.runtime.converters.records import iter_records
159
+
160
+ write_orc(iter_records(collection), target)
161
+
162
+
163
+ def _read_feather(source: Any, **opts: Any) -> List[dict]:
164
+ from office365.runtime.converters.parquet import read_feather
165
+
166
+ return read_feather(source)
167
+
168
+
169
+ def _write_feather(collection: "ClientObjectCollection", target: Any, **opts: Any) -> None:
170
+ from office365.runtime.converters.parquet import write_feather
171
+ from office365.runtime.converters.records import iter_records
172
+
173
+ write_feather(iter_records(collection), target)
174
+
175
+
176
+ register("csv", reader=_read_csv, writer=_write_csv)
177
+ register("tsv", reader=_read_tsv, writer=_write_tsv)
178
+ register("json", reader=_read_json_file, writer=_write_json_file)
179
+ register("json_file", reader=_read_json_file, writer=_write_json_file) # deprecated alias
180
+ register("ndjson", reader=_read_ndjson, writer=_write_ndjson)
181
+ register("excel", reader=_read_excel, writer=_write_excel)
182
+ register("dataframe", reader=_read_dataframe, writer=_write_dataframe)
183
+ register("parquet", reader=_read_parquet, writer=_write_parquet)
184
+ register("orc", reader=_read_orc, writer=_write_orc)
185
+ register("feather", reader=_read_feather, writer=_write_feather)
@@ -53,6 +53,14 @@ def parse_bool(value: Any) -> Any:
53
53
  return value
54
54
 
55
55
 
56
+ def parse_int(value: Any) -> Optional[int]:
57
+ """Convert a value to an int, returning ``None`` when absent/invalid."""
58
+ try:
59
+ return int(value) if value is not None else None
60
+ except (TypeError, ValueError):
61
+ return None
62
+
63
+
56
64
  def try_int(value: Any) -> Any:
57
65
  """Convert a value to an int, falling back to the raw value on failure."""
58
66
  try:
@@ -69,6 +77,29 @@ def try_float(value: Any) -> Any:
69
77
  return value
70
78
 
71
79
 
80
+ def utc_now_iso() -> str:
81
+ """Current UTC time as an ISO-8601 string (second precision)."""
82
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
83
+
84
+
85
+ def iso(value: Any) -> str:
86
+ """ISO-8601 string for a datetime (second precision); ``""`` for ``None``."""
87
+ if value is None:
88
+ return ""
89
+ return value.isoformat(timespec="seconds")
90
+
91
+
92
+ def iso_or_none(value: Any) -> Optional[str]:
93
+ """ISO-8601 string for a datetime, or ``None`` when unset/absent.
94
+
95
+ Treats ``datetime.min`` (the "not loaded" sentinel used by some SharePoint
96
+ properties) as unset.
97
+ """
98
+ if value is None or value == datetime.min:
99
+ return None
100
+ return value.isoformat(timespec="seconds") if hasattr(value, "isoformat") else (str(value) or None)
101
+
102
+
72
103
  def _normalize_datetime_string(value: str) -> str:
73
104
  """Truncate or pad fractional seconds to exactly 6 digits for %f compatibility."""
74
105
  if "." not in value:
@@ -0,0 +1,69 @@
1
+ """SQL/DuckDB record streaming — the optional-dependency boundary.
2
+
3
+ ``from_sql``/``from_duckdb`` accept a connection object (SQLAlchemy
4
+ ``Engine``/``Connection``, DuckDB connection) or a connection string, plus a
5
+ query, and yield record batches (bounded memory). ``to_sql``/``to_duckdb`` write
6
+ loaded records to a table. Dependencies are imported lazily:
7
+ ``[sql]`` (SQLAlchemy) and ``[duckdb]``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Iterator, List
13
+
14
+
15
+ def require_sqlalchemy():
16
+ """Import SQLAlchemy or raise an actionable error."""
17
+ try:
18
+ import sqlalchemy # type: ignore[import-not-found]
19
+ except ImportError:
20
+ raise ImportError("pip install office365-rest-python-client[sql]") from None
21
+ return sqlalchemy
22
+
23
+
24
+ def sql_chunks(source: Any, query: str, chunksize: int) -> Iterator[List[Dict[str, Any]]]:
25
+ """Yield record batches from a SQLAlchemy engine/connection (or URL string)."""
26
+ from office365.runtime.converters.dataframe import require_pandas
27
+
28
+ engine = source
29
+ if isinstance(source, str):
30
+ engine = require_sqlalchemy().create_engine(source)
31
+ for chunk in require_pandas().read_sql_query(query, engine, chunksize=chunksize):
32
+ yield chunk.to_dict("records")
33
+
34
+
35
+ def write_sql(records: List[Dict[str, Any]], target: Any, *, table: str, **opts: Any) -> None:
36
+ """Write records to a SQL table (SQLAlchemy connection/engine or URL)."""
37
+ from office365.runtime.converters.dataframe import require_pandas
38
+
39
+ engine = target
40
+ if isinstance(target, str):
41
+ engine = require_sqlalchemy().create_engine(target)
42
+ require_pandas().DataFrame.from_records(records).to_sql(table, engine, **opts)
43
+
44
+
45
+ def duckdb_chunks(source: Any, query: str, chunksize: int) -> Iterator[List[Dict[str, Any]]]:
46
+ """Yield record batches from a DuckDB connection (or database path)."""
47
+ import duckdb # type: ignore[import-not-found]
48
+
49
+ connection = source if hasattr(source, "execute") else duckdb.connect(source)
50
+ result = connection.execute(query)
51
+ columns = [description[0] for description in result.description]
52
+ while True:
53
+ rows = result.fetchmany(chunksize)
54
+ if not rows:
55
+ break
56
+ yield [dict(zip(columns, row)) for row in rows]
57
+
58
+
59
+ def write_duckdb(records: List[Dict[str, Any]], target: Any, *, table: str, **opts: Any) -> None:
60
+ """Write records to a DuckDB table (connection or database path)."""
61
+ import duckdb # type: ignore[import-not-found]
62
+
63
+ from office365.runtime.converters.dataframe import require_pandas
64
+
65
+ connection = target if hasattr(target, "execute") else duckdb.connect(target)
66
+ df = require_pandas().DataFrame.from_records(records)
67
+ connection.register("_records_df", df)
68
+ connection.execute(f"CREATE OR REPLACE TABLE {table} AS SELECT * FROM _records_df")
69
+ connection.unregister("_records_df")
@@ -0,0 +1,105 @@
1
+ """Incremental (paged) writers for the streaming exporter.
2
+
3
+ Only row-appendable formats support paging (CSV/TSV/NDJSON/JSON); the exporter
4
+ falls back to a whole-collection write otherwise. Each streamer is a small
5
+ object with ``write(records)`` and ``close()``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class RecordStreamWriter(Protocol):
16
+ """Append-only writer used by ``export_to(..., page_size=...)``."""
17
+
18
+ def write(self, records: List[Dict[str, Any]]) -> None:
19
+ """Append a page of records."""
20
+ ...
21
+
22
+ def close(self) -> None:
23
+ """Finalize and release the target."""
24
+ ...
25
+
26
+
27
+ class CsvStreamWriter:
28
+ """Append CSV/TSV rows, writing the header on the first page."""
29
+
30
+ def __init__(self, target: Any, delimiter: str = ",") -> None:
31
+ from office365.runtime.converters.csv_writer import write_records, write_rows
32
+ from office365.runtime.converters.streams import open_text
33
+
34
+ self._write_records = write_records
35
+ self._write_rows = write_rows
36
+ self._delimiter = delimiter
37
+ self._columns: Optional[List[str]] = None
38
+ self._ctx = open_text(target, "w")
39
+ self._file = self._ctx.__enter__()
40
+
41
+ def write(self, records: List[Dict[str, Any]]) -> None:
42
+ if not records:
43
+ return
44
+ if self._columns is None:
45
+ self._columns = list(dict.fromkeys(key for record in records for key in record))
46
+ self._write_records(records, self._file, columns=self._columns, delimiter=self._delimiter)
47
+ else:
48
+ self._write_rows(records, self._file, self._columns, self._delimiter)
49
+
50
+ def close(self) -> None:
51
+ self._ctx.__exit__(None, None, None)
52
+
53
+
54
+ class NdjsonStreamWriter:
55
+ """Append NDJSON lines."""
56
+
57
+ def __init__(self, target: Any) -> None:
58
+ from office365.runtime.converters.ndjson import write_ndjson
59
+ from office365.runtime.converters.streams import open_text
60
+
61
+ self._write_ndjson = write_ndjson
62
+ self._ctx = open_text(target, "w")
63
+ self._file = self._ctx.__enter__()
64
+
65
+ def write(self, records: List[Dict[str, Any]]) -> None:
66
+ self._write_ndjson(records, self._file)
67
+
68
+ def close(self) -> None:
69
+ self._ctx.__exit__(None, None, None)
70
+
71
+
72
+ class JsonArrayStreamWriter:
73
+ """Append a single JSON array across pages."""
74
+
75
+ def __init__(self, target: Any) -> None:
76
+ from office365.runtime.converters.streams import open_text
77
+
78
+ self._ctx = open_text(target, "w")
79
+ self._file = self._ctx.__enter__()
80
+ self._file.write("[")
81
+ self._first = True
82
+
83
+ def write(self, records: List[Dict[str, Any]]) -> None:
84
+ for record in records:
85
+ if not self._first:
86
+ self._file.write(",")
87
+ json.dump(record, self._file, default=str)
88
+ self._first = False
89
+
90
+ def close(self) -> None:
91
+ self._file.write("]")
92
+ self._ctx.__exit__(None, None, None)
93
+
94
+
95
+ _STREAMERS: Dict[str, Callable[..., RecordStreamWriter]] = {
96
+ "csv": lambda target, **opts: CsvStreamWriter(target, opts.get("delimiter", ",")),
97
+ "tsv": lambda target, **opts: CsvStreamWriter(target, "\t"),
98
+ "ndjson": lambda target, **opts: NdjsonStreamWriter(target),
99
+ "json": lambda target, **opts: JsonArrayStreamWriter(target),
100
+ }
101
+
102
+
103
+ def streamer_for(name: str) -> Optional[Callable[..., RecordStreamWriter]]:
104
+ """The incremental writer factory for ``name``, or ``None`` (not appendable)."""
105
+ return _STREAMERS.get(name)
@@ -0,0 +1,42 @@
1
+ """Path | file-like resolution shared by the format readers/writers.
2
+
3
+ Pandas-style: every reader/writer accepts a filesystem path, a ``PathLike`` or an
4
+ already-open file object. Binary vs text mode is chosen by the format; an
5
+ existing stream is passed through untouched.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextlib import contextmanager
11
+ from os import PathLike
12
+ from typing import IO, Any, Iterator, Union, cast
13
+
14
+ Source = Union[str, PathLike, IO[Any]]
15
+
16
+
17
+ def is_stream(source: Any) -> bool:
18
+ """Whether ``source`` is an already-open file object (vs a path)."""
19
+ return hasattr(source, "read") or hasattr(source, "write")
20
+
21
+
22
+ @contextmanager
23
+ def open_text(source: Source, mode: str = "r", **kwargs: Any) -> Iterator[IO[Any]]:
24
+ """Yield a text stream for ``source`` (a path is opened, a stream passed through)."""
25
+ if is_stream(source):
26
+ yield source # type: ignore[misc]
27
+ return
28
+ kwargs.setdefault("encoding", "utf-8")
29
+ if "b" not in mode:
30
+ kwargs.setdefault("newline", "")
31
+ with open(cast(Any, source), mode, **kwargs) as f:
32
+ yield f
33
+
34
+
35
+ @contextmanager
36
+ def open_binary(source: Source, mode: str = "rb", **kwargs: Any) -> Iterator[IO[Any]]:
37
+ """Yield a binary stream for ``source`` (a path is opened, a stream passed through)."""
38
+ if is_stream(source):
39
+ yield source # type: ignore[misc]
40
+ return
41
+ with open(cast(Any, source), mode, **kwargs) as f:
42
+ yield f
@@ -0,0 +1,101 @@
1
+ """Key-based conflict resolution for record imports (destination-agnostic).
2
+
3
+ A natural key is hashed into a stable value stored on the destination; a target
4
+ implements :class:`UpsertTarget` to load existing keys and create/update records.
5
+ The same :func:`keyed_queue` drives every collection (and the migration toolkit),
6
+ so idempotent upsert is one implementation, not one per destination.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from typing import Any, Iterable, Mapping, MutableMapping, Protocol, runtime_checkable
14
+
15
+ ON_CONFLICT_MODES = ("skip", "upsert")
16
+
17
+
18
+ def record_key(record: dict, key_columns: Iterable[str]) -> str:
19
+ """Deterministic hash key for a record's natural key column(s).
20
+
21
+ Canonicalizes the key values as JSON and hashes them (SHA-256), so the key is
22
+ stable across runs and safe to store in a text column regardless of the
23
+ source values' length or characters.
24
+ """
25
+ values = [record.get(column) for column in key_columns]
26
+ payload = json.dumps(values, default=str, ensure_ascii=False, separators=(",", ":"))
27
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
28
+
29
+
30
+ @runtime_checkable
31
+ class UpsertTarget(Protocol):
32
+ """Destination capability needed for keyed skip/upsert."""
33
+
34
+ @property
35
+ def key_field(self) -> str:
36
+ """The column storing the derived key hash."""
37
+ ...
38
+
39
+ def ensure_key_field(self) -> None:
40
+ """Queue creation of the key column if missing (does not execute)."""
41
+ ...
42
+
43
+ def load_keys(self) -> Mapping[str, Any]:
44
+ """Return the existing ``{key: item_id}`` map (executes)."""
45
+ ...
46
+
47
+ def create_records(self, records: list[dict]) -> None:
48
+ """Queue a create for each record."""
49
+ ...
50
+
51
+ def update_record(self, item_id: Any, record: dict) -> None:
52
+ """Queue an update of an existing item."""
53
+ ...
54
+
55
+
56
+ def keyed_queue(
57
+ target: UpsertTarget,
58
+ records: list[dict],
59
+ *,
60
+ key_columns: Iterable[str],
61
+ existing: MutableMapping[str, Any],
62
+ on_conflict: str = "skip",
63
+ dry_run: bool = False,
64
+ ) -> tuple[int, int]:
65
+ """Queue a chunk with key-based conflict resolution.
66
+
67
+ Each record's natural key is hashed into ``target.key_field``. Records whose
68
+ key is already in ``existing`` are either skipped (``on_conflict="skip"``) or
69
+ queued as an update (``on_conflict="upsert"``); the rest are created.
70
+ ``existing`` is updated in place so duplicate keys within the same run are
71
+ deduplicated too. With ``dry_run`` the counts are computed without queuing
72
+ anything (a plan preview).
73
+
74
+ Returns:
75
+ ``(queued, skipped)`` — records queued (created or updated) and records
76
+ skipped as already present.
77
+ """
78
+ if on_conflict not in ON_CONFLICT_MODES:
79
+ raise ValueError(f"on_conflict must be one of {ON_CONFLICT_MODES}, got {on_conflict!r}")
80
+ key_field = target.key_field
81
+ creates: list[dict] = []
82
+ updates: list[tuple[Any, dict]] = []
83
+ skipped = 0
84
+ for record in records:
85
+ key = record_key(record, key_columns)
86
+ record[key_field] = key
87
+ if key in existing:
88
+ item_id = existing[key]
89
+ if item_id is not None and on_conflict == "upsert":
90
+ updates.append((item_id, record))
91
+ else:
92
+ skipped += 1
93
+ continue
94
+ existing[key] = None # created this run (id unknown) — dedupe later duplicates
95
+ creates.append(record)
96
+ if not dry_run:
97
+ if creates:
98
+ target.create_records(creates)
99
+ for item_id, record in updates:
100
+ target.update_record(item_id, record)
101
+ return len(creates) + len(updates), skipped