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
office365/runtime/parallel.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
94
|
-
|
|
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.
|
|
32
|
-
self._parent = self.
|
|
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__(
|
|
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)
|