Office365-REST-Python-Client 3.1.0__py3-none-any.whl → 3.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- office365/communications/callrecords/call_record.py +18 -0
- office365/communications/calls/call.py +3 -0
- office365/communications/presences/presence.py +3 -0
- office365/communications/quotas.py +66 -0
- office365/communications/virtualevents/virtual_event.py +3 -0
- office365/directory/domains/domain.py +3 -0
- office365/directory/licenses/subscribed_sku.py +3 -0
- office365/directory/objects/object.py +3 -0
- office365/directory/quotas.py +34 -0
- office365/entity_collection.py +2 -2
- office365/graph_client.py +46 -2
- office365/limits.py +66 -0
- office365/migration/__init__.py +6 -1
- office365/migration/adapters/__init__.py +14 -4
- office365/migration/adapters/filesystem.py +4 -1
- office365/migration/assessment/export.py +5 -5
- office365/migration/assessment/issue.py +19 -0
- office365/migration/assessment/report.py +11 -0
- office365/migration/assessment/scanners/base.py +37 -49
- office365/migration/base.py +33 -8
- office365/migration/checkpoint.py +1 -1
- office365/migration/outlook/assessor.py +4 -4
- office365/migration/package/__init__.py +82 -0
- office365/migration/package/builder.py +603 -0
- office365/migration/package/crypto.py +54 -0
- office365/migration/package/manifest_xml.py +495 -0
- office365/migration/package/staging.py +180 -0
- office365/migration/report.py +7 -2
- office365/migration/report_io.py +79 -0
- office365/migration/runner.py +117 -10
- office365/migration/server_job.py +126 -14
- office365/migration/session.py +132 -49
- office365/migration/settings.py +67 -0
- office365/migration/sharepoint/__init__.py +2 -0
- office365/migration/sharepoint/adapters.py +207 -41
- office365/migration/sharepoint/assessor.py +45 -9
- office365/migration/sharepoint/options.py +57 -0
- office365/migration/sharepoint/package_target.py +209 -0
- office365/migration/sharepoint/registry.py +22 -0
- office365/migration/sharepoint/resolvers.py +96 -0
- office365/migration/sharepoint/scanners/__init__.py +29 -0
- office365/migration/sharepoint/scanners/browser_file_handling.py +55 -0
- office365/migration/sharepoint/scanners/checked_out_files.py +57 -0
- office365/migration/sharepoint/scanners/file_versions.py +56 -0
- office365/migration/sharepoint/scanners/files.py +5 -2
- office365/migration/sharepoint/scanners/large_excel_files.py +56 -0
- office365/migration/sharepoint/scanners/large_lists.py +58 -0
- office365/migration/sharepoint/scanners/large_sites.py +4 -3
- office365/migration/sharepoint/scanners/long_onedrive_urls.py +52 -0
- office365/migration/sharepoint/scanners/lookup.py +38 -0
- office365/migration/sharepoint/scanners/paths.py +9 -4
- office365/migration/sharepoint/scanners/permissions.py +20 -2
- office365/migration/sharepoint/scanners/smat.py +51 -0
- office365/migration/sharepoint/scanners/summary.py +1 -0
- office365/migration/sharepoint/scanners/thicket_folders.py +38 -0
- office365/migration/sharepoint/scanners/unsupported_site_templates.py +90 -0
- office365/migration/sharepoint/tenant_assessor.py +5 -3
- office365/migration/sharepoint/transfer.py +4 -2
- office365/migration/tasks.py +121 -0
- office365/migration/validators.py +2 -19
- office365/onedrive/driveitems/driveItem.py +116 -12
- office365/onedrive/termstore/groups/collection.py +2 -9
- office365/onedrive/termstore/sets/collection.py +2 -9
- office365/onedrive/termstore/terms/collection.py +3 -10
- office365/onedrive/workbooks/workbook.py +7 -0
- office365/outlook/contacts/folders/collection.py +4 -13
- office365/runtime/auth/authentication_context.py +5 -0
- office365/runtime/client_object.py +18 -0
- office365/runtime/client_object_collection.py +75 -245
- office365/runtime/client_request.py +77 -1
- office365/runtime/client_request_exception.py +127 -39
- office365/runtime/client_runtime_context.py +79 -8
- office365/runtime/converters/csv_writer.py +10 -4
- office365/runtime/converters/dataframe.py +84 -4
- office365/runtime/converters/json_file.py +12 -3
- office365/runtime/converters/parquet.py +67 -0
- office365/runtime/converters/records.py +31 -12
- office365/runtime/converters/registry.py +185 -0
- office365/runtime/converters/scalars.py +31 -0
- office365/runtime/converters/sql.py +69 -0
- office365/runtime/converters/streamers.py +105 -0
- office365/runtime/converters/streams.py +42 -0
- office365/runtime/converters/upsert.py +101 -0
- office365/runtime/exceptions.py +105 -0
- office365/runtime/http/prefer.py +26 -0
- office365/runtime/http/throttling.py +258 -56
- office365/runtime/imports.py +571 -0
- office365/runtime/limits.py +455 -0
- office365/runtime/odata/request.py +11 -2
- office365/runtime/odata/type.py +1 -0
- office365/runtime/odata/v3/batch_request.py +4 -2
- office365/runtime/odata/v4/batch_request.py +22 -7
- office365/runtime/operations.py +29 -0
- office365/runtime/parallel.py +12 -14
- office365/runtime/paths/v4/entity.py +2 -2
- office365/runtime/queries/batch.py +18 -1
- office365/runtime/queries/get_or_create.py +109 -0
- office365/runtime/record_collection.py +443 -0
- office365/runtime/retry.py +92 -26
- office365/runtime/transport/base.py +6 -1
- office365/runtime/transport/requests_transport.py +4 -0
- office365/runtime/transport/throttled_transport.py +67 -0
- office365/runtime/verification.py +40 -0
- office365/sharepoint/activities/logger.py +118 -8
- office365/sharepoint/administration/analytics/usage_service.py +15 -7
- office365/sharepoint/administration/sitemove/service.py +791 -8
- office365/sharepoint/alerts/alert.py +79 -0
- office365/sharepoint/apps/app_collection.py +25 -0
- office365/sharepoint/apps/launcher.py +16 -0
- office365/sharepoint/authpolicy/tenant/compliancereport.py +5 -0
- office365/sharepoint/businessdata/app_bdc_catalog.py +135 -0
- office365/sharepoint/businessdata/entityfieldvaluedictionary.py +65 -0
- office365/sharepoint/businessdata/entityidentifier.py +23 -0
- office365/sharepoint/businessdata/entityinstance.py +52 -0
- office365/sharepoint/businessdata/entityview.py +31 -0
- office365/sharepoint/businessdata/externalsubscriptionstore.py +9 -0
- office365/sharepoint/businessdata/typedescriptor.py +37 -0
- office365/sharepoint/client_context.py +40 -23
- office365/sharepoint/clientsidecomponent/hostedapps/app.py +21 -0
- office365/sharepoint/clientsidecomponent/hostedapps/manager.py +16 -0
- office365/sharepoint/comments/comment.py +84 -2
- office365/sharepoint/contentcenter/machinelearning/models/model.py +0 -23
- office365/sharepoint/contentcenter/machinelearning/publications/publication.py +0 -9
- office365/sharepoint/contentcenter/machinelearning/samples/sample.py +0 -14
- office365/sharepoint/contentcenter/machinelearning/workitems/item.py +0 -80
- office365/sharepoint/contentsolution/template_api.py +148 -0
- office365/sharepoint/contenttypes/collection.py +32 -10
- office365/sharepoint/convergence/addnoliveblobsinodcservicestateapi.py +9 -0
- office365/sharepoint/convergence/consumer_permissions.py +15 -0
- office365/sharepoint/convergence/migration_complete_state_api.py +25 -0
- office365/sharepoint/convergence/odcmetadatacleanedupapi.py +9 -0
- office365/sharepoint/documentmanagement/document_id.py +12 -0
- office365/sharepoint/entity.py +11 -2
- office365/sharepoint/entity_collection.py +2 -2
- office365/sharepoint/exceptions.py +194 -0
- office365/sharepoint/fields/builtin_field_id.py +45 -34
- office365/sharepoint/fields/builtin_field_name.py +123 -0
- office365/sharepoint/fields/coercion.py +131 -0
- office365/sharepoint/fields/collection.py +54 -37
- office365/sharepoint/fields/field.py +17 -0
- office365/sharepoint/fields/name.py +46 -2
- office365/sharepoint/files/collection.py +7 -1
- office365/sharepoint/files/file.py +54 -8
- office365/sharepoint/files/versions/version.py +38 -13
- office365/sharepoint/folders/collection.py +4 -0
- office365/sharepoint/folders/folder.py +91 -21
- office365/sharepoint/htmlpages/html_page.py +16 -0
- office365/sharepoint/htmlpages/html_page_service.py +19 -0
- office365/sharepoint/ir/migration.py +9 -0
- office365/sharepoint/listitems/caml/__init__.py +31 -0
- office365/sharepoint/listitems/caml/builder.py +129 -0
- office365/sharepoint/listitems/caml/expressions.py +424 -0
- office365/sharepoint/listitems/caml/fields.py +68 -0
- office365/sharepoint/listitems/caml/guard.py +63 -0
- office365/sharepoint/listitems/caml/query.py +71 -2
- office365/sharepoint/listitems/caml/values.py +113 -0
- office365/sharepoint/listitems/collection.py +21 -20
- office365/sharepoint/listitems/upsert.py +75 -0
- office365/sharepoint/lists/collection.py +29 -22
- office365/sharepoint/lists/exporter.py +4 -3
- office365/sharepoint/lists/list.py +397 -39
- office365/sharepoint/lists/smart_wiki_library_utility.py +58 -0
- office365/sharepoint/logger/export.py +12 -0
- office365/sharepoint/marketplace/app_metadata.py +97 -1
- office365/sharepoint/marketplace/appstatequeryjobdefinition.py +9 -0
- office365/sharepoint/marketplace/corporatecuratedgallery/carddesigns/card_designs.py +35 -0
- office365/sharepoint/marketplace/sitecollection/appcatalog/allowed_item.py +5 -0
- office365/sharepoint/marketplace/tenant/appcatalog/accessor.py +107 -30
- office365/sharepoint/microfeed/data.py +0 -6
- office365/sharepoint/microservice/manager.py +61 -0
- office365/sharepoint/migrationcenter/common/task.py +193 -4
- office365/sharepoint/migrationcenter/common/task_entity_data.py +1 -1
- office365/sharepoint/migrationcenter/deploy_status.py +13 -0
- office365/sharepoint/migrationcenter/service/properties.py +20 -0
- office365/sharepoint/migrationcenter/service/storage.py +34 -0
- office365/sharepoint/migrationcenter/spflight.py +13 -0
- office365/sharepoint/migrationcenter/urlparseresult.py +77 -0
- office365/sharepoint/multigeo/service/spmultigeoproxy.py +13 -0
- office365/sharepoint/navigation/servicerest.py +127 -0
- office365/sharepoint/oauth/authentication.py +13 -0
- office365/sharepoint/portal/theme_manager.py +120 -8
- office365/sharepoint/principal/users/user.py +10 -0
- office365/sharepoint/publishing/campaign/campaign.py +36 -1
- office365/sharepoint/publishing/feedvideopage.py +0 -7
- office365/sharepoint/publishing/pages/3d/page.py +0 -6
- office365/sharepoint/publishing/pages/page.py +0 -117
- office365/sharepoint/publishing/pages/reposts/repost.py +0 -7
- office365/sharepoint/publishing/pages/topics/topic.py +0 -7
- office365/sharepoint/publishing/subtitlefile.py +0 -40
- office365/sharepoint/quotamanagement/consumer/consumer_quota_info_api.py +14 -0
- office365/sharepoint/quotamanagement/consumer/non_quota_backfill_api.py +23 -0
- office365/sharepoint/quotamanagement/consumer/nonquotamigrationapi.py +9 -0
- office365/sharepoint/quotamanagement/consumer/quotamigrationapi.py +13 -0
- office365/sharepoint/request.py +57 -16
- office365/sharepoint/search/administration/document_crawl_log.py +24 -7
- office365/sharepoint/search/administration/providers/crawl_versions_info.py +29 -0
- office365/sharepoint/search/administration/providers/recycle_bin_info.py +23 -0
- office365/sharepoint/search/administration/providers/site_content_processing_info.py +67 -0
- office365/sharepoint/sites/site.py +976 -65
- office365/sharepoint/sites/siteprovisioninginfoprovider.py +13 -0
- office365/sharepoint/tenant/administration/datagovernance/insight_client.py +292 -23
- office365/sharepoint/tenant/administration/ddiadapter.py +97 -0
- office365/sharepoint/tenant/administration/internal/aad/collection.py +20 -0
- office365/sharepoint/tenant/administration/internal/aad/permission_grant.py +0 -19
- office365/sharepoint/tenant/administration/internal/appservice/permission_request.py +72 -1
- office365/sharepoint/tenant/administration/internal/appservice/principal.py +1 -3
- office365/sharepoint/tenant/administration/odb_license_enforcement_service.py +26 -0
- office365/sharepoint/tenant/administration/sites/properties.py +564 -6
- office365/sharepoint/tenant/administration/spdatagovernancesarrestapiclient.py +60 -1
- office365/sharepoint/tenant/insights/reports/manager.py +24 -12
- office365/sharepoint/tenant/management/office365_tenant.py +451 -2
- office365/sharepoint/tenantadministration/catalog_management_collection.py +69 -0
- office365/sharepoint/tenantadministration/catalog_management_custom_properties_collection.py +237 -0
- office365/sharepoint/tenantadministration/catalog_management_restricted_content_discovery_collection.py +56 -0
- office365/sharepoint/tenantadministration/sp_data_governance_client_exception.py +7 -0
- office365/sharepoint/thresholds.py +529 -0
- office365/sharepoint/translation/jobs/job.py +72 -2
- office365/sharepoint/translation/jobs/status.py +47 -9
- office365/sharepoint/translation/sync_translator.py +15 -2
- office365/sharepoint/utilities/move_copy_util.py +2 -1
- office365/sharepoint/viva/connections/page.py +27 -0
- office365/sharepoint/viva/home.py +38 -0
- office365/sharepoint/viva/learningmanager.py +9 -0
- office365/sharepoint/viva/vivaresources.py +60 -0
- office365/sharepoint/webhooks/subscription.py +0 -11
- office365/sharepoint/webparts/definitions/definition.py +13 -0
- office365/sharepoint/webparts/limited_manager.py +29 -7
- office365/sharepoint/webs/context_web_information.py +14 -9
- office365/sharepoint/webs/templates/__init__.py +11 -0
- office365/sharepoint/webs/templates/type.py +74 -15
- office365/sharepoint/workflow/definition.py +6 -0
- office365/sharepoint/workflow/deployment_service.py +155 -0
- office365/sharepoint/workflow/instances/service.py +33 -0
- office365/sharepoint/workflow/interopservice.py +68 -0
- office365/sharepoint/workflow/messagingservice.py +24 -0
- office365/sharepoint/workflow/servicesmanager.py +41 -2
- office365/sharepoint/workflow/subscriptions/service.py +67 -0
- office365/sharepoint/workflow/subscriptions/subscription.py +31 -0
- office365/sharepoint/workflow/task.py +425 -0
- office365_rest_python_client-3.2.0.dist-info/METADATA +605 -0
- {office365_rest_python_client-3.1.0.dist-info → office365_rest_python_client-3.2.0.dist-info}/RECORD +242 -185
- office365/migration/_util.py +0 -73
- office365/sharepoint/listitems/caml/types.py +0 -70
- office365_rest_python_client-3.1.0.dist-info/METADATA +0 -475
- {office365_rest_python_client-3.1.0.dist-info → office365_rest_python_client-3.2.0.dist-info}/WHEEL +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
82
|
-
return
|
|
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
|