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
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""SPO Migration API manifest package — XML models and serialization.
|
|
2
|
+
|
|
3
|
+
The SharePoint Migration API (``CreateMigrationJob`` /
|
|
4
|
+
``CreateMigrationJobEncrypted``) ingests content described by a set of XML
|
|
5
|
+
manifest files. This module models the **document-library subset** — webs, lists,
|
|
6
|
+
folders, files, and file versions — plus the small, fully-specified
|
|
7
|
+
``ExportSettings.xml`` / ``SystemData.xml`` / ``UserGroup.xml``.
|
|
8
|
+
|
|
9
|
+
The full ``DeploymentManifest`` schema has 100+ elements (content types, views,
|
|
10
|
+
web parts, list items, taxonomy, ...); only what a document-library migration
|
|
11
|
+
needs is modeled here. The generated XML follows the documented format but is
|
|
12
|
+
**not yet verified against a live tenant**.
|
|
13
|
+
|
|
14
|
+
References:
|
|
15
|
+
- https://learn.microsoft.com/en-us/sharepoint/dev/apis/migration-manifest
|
|
16
|
+
- https://learn.microsoft.com/en-us/sharepoint/dev/schema/deploymentmanifest-schema
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import xml.etree.ElementTree as ET
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
MANIFEST_NS = "urn:deployment-manifest-schema"
|
|
25
|
+
SYSTEM_DATA_NS = "urn:deployment-systemdata-schema"
|
|
26
|
+
EXPORT_SETTINGS_NS = "urn:deployment-exportsettings-schema"
|
|
27
|
+
USER_GROUP_MAP_NS = "urn:deployment-usergroupmap-schema"
|
|
28
|
+
REQUIREMENTS_NS = "urn:deployment-requirements-schema"
|
|
29
|
+
ROOT_OBJECT_MAP_NS = "urn:deployment-rootobjectmap-schema"
|
|
30
|
+
LOOKUP_LIST_MAP_NS = "urn:deployment-lookuplistmap-schema"
|
|
31
|
+
VIEW_FORMS_LIST_NS = "urn:deployment-viewformlist-schema"
|
|
32
|
+
|
|
33
|
+
# ``ExportSettings/@SourceType`` accepted values.
|
|
34
|
+
SOURCE_TYPES = (
|
|
35
|
+
"AmazonS3",
|
|
36
|
+
"AzureStorage",
|
|
37
|
+
"Box",
|
|
38
|
+
"Dropbox",
|
|
39
|
+
"Egnyte",
|
|
40
|
+
"FileShare",
|
|
41
|
+
"GoogleCloudStorage",
|
|
42
|
+
"GoogleDrive",
|
|
43
|
+
"MicrosoftStream",
|
|
44
|
+
"OneDrive",
|
|
45
|
+
"SharePointOnline",
|
|
46
|
+
"SharePointOnPremServer",
|
|
47
|
+
"Other",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"EXPORT_SETTINGS_NS",
|
|
52
|
+
"LOOKUP_LIST_MAP_NS",
|
|
53
|
+
"MANIFEST_NS",
|
|
54
|
+
"REQUIREMENTS_NS",
|
|
55
|
+
"ROOT_OBJECT_MAP_NS",
|
|
56
|
+
"SOURCE_TYPES",
|
|
57
|
+
"SYSTEM_DATA_NS",
|
|
58
|
+
"USER_GROUP_MAP_NS",
|
|
59
|
+
"VIEW_FORMS_LIST_NS",
|
|
60
|
+
"Assignment",
|
|
61
|
+
"DeploymentObject",
|
|
62
|
+
"ExportSettings",
|
|
63
|
+
"Group",
|
|
64
|
+
"Manifest",
|
|
65
|
+
"ManifestElement",
|
|
66
|
+
"ManifestObject",
|
|
67
|
+
"Role",
|
|
68
|
+
"RoleAssignment",
|
|
69
|
+
"RootObject",
|
|
70
|
+
"RootObjectMap",
|
|
71
|
+
"SharedWithEvent",
|
|
72
|
+
"SystemData",
|
|
73
|
+
"SystemObject",
|
|
74
|
+
"User",
|
|
75
|
+
"UserGroupMap",
|
|
76
|
+
"empty_document",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _q(ns: str, tag: str) -> str:
|
|
81
|
+
"""Qualify a tag with its XML namespace."""
|
|
82
|
+
return f"{{{ns}}}{tag}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _str(value: object) -> str:
|
|
86
|
+
"""Render an attribute value (booleans as lowercase ``true``/``false``)."""
|
|
87
|
+
if isinstance(value, bool):
|
|
88
|
+
return "true" if value else "false"
|
|
89
|
+
return str(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _set(element: ET.Element, attributes: dict) -> None:
|
|
93
|
+
"""Set non-``None`` attributes on an element, in insertion order."""
|
|
94
|
+
for name, value in attributes.items():
|
|
95
|
+
if value is not None:
|
|
96
|
+
element.set(name, _str(value))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _indent(element: ET.Element, level: int = 0) -> None:
|
|
100
|
+
"""Indent element content (``ET.indent`` is Python 3.9+; this library supports 3.8)."""
|
|
101
|
+
prefix = "\n" + " " * level
|
|
102
|
+
children = list(element)
|
|
103
|
+
if children:
|
|
104
|
+
if not (element.text or "").strip():
|
|
105
|
+
element.text = prefix + " "
|
|
106
|
+
for child in children:
|
|
107
|
+
_indent(child, level + 1)
|
|
108
|
+
if not (children[-1].tail or "").strip():
|
|
109
|
+
children[-1].tail = prefix
|
|
110
|
+
if level and not (element.tail or "").strip():
|
|
111
|
+
element.tail = prefix
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _serialize(root: ET.Element, ns: str) -> bytes:
|
|
115
|
+
"""Serialize a document with ``ns`` as the default namespace + XML declaration."""
|
|
116
|
+
ET.register_namespace("", ns)
|
|
117
|
+
_indent(root)
|
|
118
|
+
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def empty_document(ns: str, root: str) -> bytes:
|
|
122
|
+
"""A document with a childless root element (the API fetches these files by name)."""
|
|
123
|
+
return _serialize(ET.Element(_q(ns, root)), ns)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class ManifestElement:
|
|
128
|
+
"""A nested element inside an ``<SPObject>`` payload element.
|
|
129
|
+
|
|
130
|
+
Generic recursion (name + attributes + children) covers ``<Versions>``,
|
|
131
|
+
``<Roles>``, ``<RoleAssignments>``, ``<SharedWithEvents>``, etc.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
name: str
|
|
135
|
+
attributes: dict = field(default_factory=dict)
|
|
136
|
+
children: list["ManifestElement"] = field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
def to_etree(self, ns: str = MANIFEST_NS) -> ET.Element:
|
|
139
|
+
element = ET.Element(_q(ns, self.name))
|
|
140
|
+
_set(element, self.attributes)
|
|
141
|
+
for child in self.children:
|
|
142
|
+
element.append(child.to_etree(ns))
|
|
143
|
+
return element
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class ManifestObject:
|
|
148
|
+
"""A single ``<SPObject>`` entry in ``Manifest.xml``.
|
|
149
|
+
|
|
150
|
+
``attributes`` are rendered on the child element (``element``); ``children``
|
|
151
|
+
render nested blocks (file versions, roles, role assignments, sharing).
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
id: str
|
|
155
|
+
object_type: str # SPFolder | SPDocumentLibrary | SPFile | ListItem | ...
|
|
156
|
+
element: str # child element name (Folder/DocumentLibrary/File/ListItem/...)
|
|
157
|
+
attributes: dict = field(default_factory=dict)
|
|
158
|
+
children: list[ManifestElement] = field(default_factory=list)
|
|
159
|
+
parent_id: str | None = None
|
|
160
|
+
parent_web_id: str | None = None
|
|
161
|
+
parent_web_url: str | None = None
|
|
162
|
+
url: str | None = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class Manifest:
|
|
167
|
+
"""The ``Manifest.xml`` document — the content/structure catalog."""
|
|
168
|
+
|
|
169
|
+
objects: list[ManifestObject] = field(default_factory=list)
|
|
170
|
+
|
|
171
|
+
def add(self, obj: ManifestObject) -> "Manifest":
|
|
172
|
+
self.objects.append(obj)
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def to_xml(self) -> bytes:
|
|
176
|
+
root = ET.Element(_q(MANIFEST_NS, "SPObjects"))
|
|
177
|
+
for obj in self.objects:
|
|
178
|
+
sp = ET.SubElement(root, _q(MANIFEST_NS, "SPObject"))
|
|
179
|
+
_set(
|
|
180
|
+
sp,
|
|
181
|
+
{
|
|
182
|
+
"Id": obj.id,
|
|
183
|
+
"ObjectType": obj.object_type,
|
|
184
|
+
"ParentId": obj.parent_id,
|
|
185
|
+
"ParentWebId": obj.parent_web_id,
|
|
186
|
+
"ParentWebUrl": obj.parent_web_url,
|
|
187
|
+
"Url": obj.url,
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
element = ET.SubElement(sp, _q(MANIFEST_NS, obj.element))
|
|
191
|
+
_set(element, obj.attributes)
|
|
192
|
+
for child in obj.children:
|
|
193
|
+
element.append(child.to_etree(MANIFEST_NS))
|
|
194
|
+
return _serialize(root, MANIFEST_NS)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class Role:
|
|
199
|
+
"""A ``<Role>`` (permission level) inside a ``DeploymentRoles`` object."""
|
|
200
|
+
|
|
201
|
+
id: str # RoleId
|
|
202
|
+
title: str
|
|
203
|
+
perm_mask: str
|
|
204
|
+
hidden: bool = False
|
|
205
|
+
description: str | None = None
|
|
206
|
+
role_order: str | None = None
|
|
207
|
+
type: str | None = None
|
|
208
|
+
|
|
209
|
+
def to_element(self) -> ManifestElement:
|
|
210
|
+
return ManifestElement(
|
|
211
|
+
"Role",
|
|
212
|
+
{
|
|
213
|
+
"RoleId": self.id,
|
|
214
|
+
"Title": self.title,
|
|
215
|
+
"Description": self.description,
|
|
216
|
+
"PermMask": self.perm_mask,
|
|
217
|
+
"Hidden": self.hidden,
|
|
218
|
+
"RoleOrder": self.role_order,
|
|
219
|
+
"Type": self.type,
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class Assignment:
|
|
226
|
+
"""An ``<Assignment>`` — a role granted to a principal (a ``UserGroup`` id)."""
|
|
227
|
+
|
|
228
|
+
role_id: str
|
|
229
|
+
principal_id: int
|
|
230
|
+
|
|
231
|
+
def to_element(self) -> ManifestElement:
|
|
232
|
+
return ManifestElement("Assignment", {"RoleId": self.role_id, "PrincipalId": self.principal_id})
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class RoleAssignment:
|
|
237
|
+
"""A ``<RoleAssignment>`` (an ACL scope) inside a ``DeploymentRoleAssignments`` object.
|
|
238
|
+
|
|
239
|
+
``object_id``/``object_url``/``object_type`` identify the secured object (a
|
|
240
|
+
file/folder GUID), ``scope_id`` the scope it applies to; ``assignments`` map
|
|
241
|
+
roles to principals from ``UserGroup.xml``.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
object_id: str
|
|
245
|
+
object_url: str
|
|
246
|
+
object_type: str
|
|
247
|
+
role_def_web_id: str
|
|
248
|
+
role_def_web_url: str
|
|
249
|
+
scope_id: str
|
|
250
|
+
assignments: list[Assignment] = field(default_factory=list)
|
|
251
|
+
anonymous_perm_mask: str = "0"
|
|
252
|
+
|
|
253
|
+
def to_element(self) -> ManifestElement:
|
|
254
|
+
return ManifestElement(
|
|
255
|
+
"RoleAssignment",
|
|
256
|
+
{
|
|
257
|
+
"ScopeId": self.scope_id,
|
|
258
|
+
"RoleDefWebId": self.role_def_web_id,
|
|
259
|
+
"RoleDefWebUrl": self.role_def_web_url,
|
|
260
|
+
"ObjectId": self.object_id,
|
|
261
|
+
"ObjectType": self.object_type,
|
|
262
|
+
"ObjectUrl": self.object_url,
|
|
263
|
+
"AnonymousPermMask": self.anonymous_perm_mask,
|
|
264
|
+
},
|
|
265
|
+
children=[assignment.to_element() for assignment in self.assignments],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class SharedWithEvent:
|
|
271
|
+
"""A ``<SharedWithEvent>`` — a "Shared with Me" occurrence on a list item."""
|
|
272
|
+
|
|
273
|
+
shared_time: str
|
|
274
|
+
sharing_initiator_id: int
|
|
275
|
+
shared_by_id: int
|
|
276
|
+
members: list[int] = field(default_factory=list)
|
|
277
|
+
|
|
278
|
+
def to_element(self) -> ManifestElement:
|
|
279
|
+
members = ManifestElement(
|
|
280
|
+
"SharedWithMembers",
|
|
281
|
+
children=[ManifestElement("SharedWithMember", {"SharedWithId": member}) for member in self.members],
|
|
282
|
+
)
|
|
283
|
+
return ManifestElement(
|
|
284
|
+
"SharedWithEvent",
|
|
285
|
+
{
|
|
286
|
+
"SharedTime": self.shared_time,
|
|
287
|
+
"SharingInitiatorId": self.sharing_initiator_id,
|
|
288
|
+
"SharedById": self.shared_by_id,
|
|
289
|
+
},
|
|
290
|
+
children=[members],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class RootObject:
|
|
296
|
+
"""A ``<RootObject>`` mapping in ``RootObjectMap.xml``."""
|
|
297
|
+
|
|
298
|
+
id: str
|
|
299
|
+
type: str # Site | Web | Folder | List | ListItem | File
|
|
300
|
+
parent_id: str | None = None
|
|
301
|
+
web_url: str | None = None
|
|
302
|
+
url: str | None = None
|
|
303
|
+
is_dependency: bool = False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass
|
|
307
|
+
class RootObjectMap:
|
|
308
|
+
"""The ``RootObjectMap.xml`` document — dependent-object placement."""
|
|
309
|
+
|
|
310
|
+
objects: list[RootObject] = field(default_factory=list)
|
|
311
|
+
|
|
312
|
+
def to_xml(self) -> bytes:
|
|
313
|
+
root = ET.Element(_q(ROOT_OBJECT_MAP_NS, "RootObjects"))
|
|
314
|
+
for obj in self.objects:
|
|
315
|
+
_set(
|
|
316
|
+
ET.SubElement(root, _q(ROOT_OBJECT_MAP_NS, "RootObject")),
|
|
317
|
+
{
|
|
318
|
+
"Id": obj.id,
|
|
319
|
+
"Type": obj.type,
|
|
320
|
+
"ParentId": obj.parent_id,
|
|
321
|
+
"WebUrl": obj.web_url,
|
|
322
|
+
"Url": obj.url,
|
|
323
|
+
"IsDependency": obj.is_dependency,
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
return _serialize(root, ROOT_OBJECT_MAP_NS)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@dataclass
|
|
330
|
+
class DeploymentObject:
|
|
331
|
+
"""A ``<DeploymentObject>`` entry in ``ExportSettings.xml``."""
|
|
332
|
+
|
|
333
|
+
id: str
|
|
334
|
+
type: str # List | Web | ...
|
|
335
|
+
parent_id: str | None = None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@dataclass
|
|
339
|
+
class ExportSettings:
|
|
340
|
+
"""The required ``ExportSettings.xml`` document."""
|
|
341
|
+
|
|
342
|
+
site_url: str
|
|
343
|
+
source_type: str = "SharePointOnline"
|
|
344
|
+
include_security: str = "None"
|
|
345
|
+
file_location: str | None = None
|
|
346
|
+
detailed_source: str | None = None
|
|
347
|
+
export_objects: list[DeploymentObject] = field(default_factory=list)
|
|
348
|
+
|
|
349
|
+
def to_xml(self) -> bytes:
|
|
350
|
+
root = ET.Element(_q(EXPORT_SETTINGS_NS, "ExportSettings"))
|
|
351
|
+
_set(
|
|
352
|
+
root,
|
|
353
|
+
{
|
|
354
|
+
"SiteUrl": self.site_url,
|
|
355
|
+
"FileLocation": self.file_location,
|
|
356
|
+
"IncludeSecurity": self.include_security,
|
|
357
|
+
"SourceType": self.source_type,
|
|
358
|
+
"DetailedSource": self.detailed_source,
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
if self.export_objects:
|
|
362
|
+
objects = ET.SubElement(root, _q(EXPORT_SETTINGS_NS, "ExportObjects"))
|
|
363
|
+
for obj in self.export_objects:
|
|
364
|
+
_set(
|
|
365
|
+
ET.SubElement(objects, _q(EXPORT_SETTINGS_NS, "DeploymentObject")),
|
|
366
|
+
{"Id": obj.id, "Type": obj.type, "ParentId": obj.parent_id},
|
|
367
|
+
)
|
|
368
|
+
return _serialize(root, EXPORT_SETTINGS_NS)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass
|
|
372
|
+
class SystemObject:
|
|
373
|
+
"""A ``<SystemObject>`` entry (an immutable dependent object)."""
|
|
374
|
+
|
|
375
|
+
id: str
|
|
376
|
+
type: str # Web | List | Folder
|
|
377
|
+
url: str
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass
|
|
381
|
+
class SystemData:
|
|
382
|
+
"""The required ``SystemData.xml`` document."""
|
|
383
|
+
|
|
384
|
+
manifest_files: list[str] = field(default_factory=lambda: ["Manifest.xml"])
|
|
385
|
+
system_objects: list[SystemObject] = field(default_factory=list)
|
|
386
|
+
root_web_only_lists: list[str] = field(default_factory=list)
|
|
387
|
+
version: str = "15.0.0.0"
|
|
388
|
+
build: str = "16.0.3111.1200"
|
|
389
|
+
database_version: int = 11552
|
|
390
|
+
site_version: str = "15"
|
|
391
|
+
|
|
392
|
+
def to_xml(self) -> bytes:
|
|
393
|
+
root = ET.Element(_q(SYSTEM_DATA_NS, "SystemData"))
|
|
394
|
+
_set(
|
|
395
|
+
ET.SubElement(root, _q(SYSTEM_DATA_NS, "SchemaVersion")),
|
|
396
|
+
{
|
|
397
|
+
"Version": self.version,
|
|
398
|
+
"Build": self.build,
|
|
399
|
+
"DatabaseVersion": self.database_version,
|
|
400
|
+
"SiteVersion": self.site_version,
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
manifests = ET.SubElement(root, _q(SYSTEM_DATA_NS, "ManifestFiles"))
|
|
404
|
+
for name in self.manifest_files:
|
|
405
|
+
_set(ET.SubElement(manifests, _q(SYSTEM_DATA_NS, "ManifestFile")), {"Name": name})
|
|
406
|
+
objects = ET.SubElement(root, _q(SYSTEM_DATA_NS, "SystemObjects"))
|
|
407
|
+
for obj in self.system_objects:
|
|
408
|
+
_set(
|
|
409
|
+
ET.SubElement(objects, _q(SYSTEM_DATA_NS, "SystemObject")),
|
|
410
|
+
{"Id": obj.id, "Type": obj.type, "Url": obj.url},
|
|
411
|
+
)
|
|
412
|
+
ET.SubElement(root, _q(SYSTEM_DATA_NS, "RootWebOnlyLists"))
|
|
413
|
+
for url in self.root_web_only_lists:
|
|
414
|
+
_set(ET.SubElement(root, _q(SYSTEM_DATA_NS, "RootWebOnlyLists")), {"Url": url})
|
|
415
|
+
return _serialize(root, SYSTEM_DATA_NS)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@dataclass
|
|
419
|
+
class User:
|
|
420
|
+
"""A ``<User>`` entry in ``UserGroup.xml``.
|
|
421
|
+
|
|
422
|
+
``login`` must be a UPN-based login name (``i:0#.f|membership|user@contoso.com``)
|
|
423
|
+
— non-UPN emails cause unexpected behavior in SharePoint Online.
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
id: int
|
|
427
|
+
name: str
|
|
428
|
+
login: str
|
|
429
|
+
email: str | None = None
|
|
430
|
+
is_domain_group: bool = False
|
|
431
|
+
is_site_admin: bool = False
|
|
432
|
+
system_id: str | None = None
|
|
433
|
+
is_deleted: bool = False
|
|
434
|
+
flags: int = 0
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@dataclass
|
|
438
|
+
class Group:
|
|
439
|
+
"""A ``<Group>`` entry in ``UserGroup.xml``."""
|
|
440
|
+
|
|
441
|
+
id: int
|
|
442
|
+
name: str
|
|
443
|
+
description: str | None = None
|
|
444
|
+
owner: int | None = None
|
|
445
|
+
owner_is_user: bool = True
|
|
446
|
+
only_allow_members_view_membership: bool = True
|
|
447
|
+
members: list[int] = field(default_factory=list)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@dataclass
|
|
451
|
+
class UserGroupMap:
|
|
452
|
+
"""The required ``UserGroup.xml`` document (entries are optional)."""
|
|
453
|
+
|
|
454
|
+
users: list[User] = field(default_factory=list)
|
|
455
|
+
groups: list[Group] = field(default_factory=list)
|
|
456
|
+
|
|
457
|
+
def add_user(self, user: User) -> "UserGroupMap":
|
|
458
|
+
self.users.append(user)
|
|
459
|
+
return self
|
|
460
|
+
|
|
461
|
+
def to_xml(self) -> bytes:
|
|
462
|
+
root = ET.Element(_q(USER_GROUP_MAP_NS, "UserGroupMap"))
|
|
463
|
+
users = ET.SubElement(root, _q(USER_GROUP_MAP_NS, "Users"))
|
|
464
|
+
for user in self.users:
|
|
465
|
+
_set(
|
|
466
|
+
ET.SubElement(users, _q(USER_GROUP_MAP_NS, "User")),
|
|
467
|
+
{
|
|
468
|
+
"Id": user.id,
|
|
469
|
+
"Name": user.name,
|
|
470
|
+
"Login": user.login,
|
|
471
|
+
"Email": user.email,
|
|
472
|
+
"IsDomainGroup": user.is_domain_group,
|
|
473
|
+
"IsSiteAdmin": user.is_site_admin,
|
|
474
|
+
# the service schema requires the attribute even when unknown
|
|
475
|
+
"SystemId": user.system_id or "",
|
|
476
|
+
"IsDeleted": user.is_deleted,
|
|
477
|
+
},
|
|
478
|
+
)
|
|
479
|
+
groups = ET.SubElement(root, _q(USER_GROUP_MAP_NS, "Groups"))
|
|
480
|
+
for group in self.groups:
|
|
481
|
+
element = ET.SubElement(groups, _q(USER_GROUP_MAP_NS, "Group"))
|
|
482
|
+
_set(
|
|
483
|
+
element,
|
|
484
|
+
{
|
|
485
|
+
"Id": group.id,
|
|
486
|
+
"Name": group.name,
|
|
487
|
+
"Description": group.description,
|
|
488
|
+
"Owner": group.owner,
|
|
489
|
+
"OwnerIsUser": group.owner_is_user,
|
|
490
|
+
"OnlyAllowMembersViewMembership": group.only_allow_members_view_membership,
|
|
491
|
+
},
|
|
492
|
+
)
|
|
493
|
+
for member_id in group.members:
|
|
494
|
+
_set(ET.SubElement(element, _q(USER_GROUP_MAP_NS, "Member")), {"UserId": member_id})
|
|
495
|
+
return _serialize(root, USER_GROUP_MAP_NS)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Content staging — put a package's blobs where the Migration API can read them.
|
|
2
|
+
|
|
3
|
+
Staging is the **swappable seam** between the vendor-neutral package (documented
|
|
4
|
+
XML + content blobs) and the transport SharePoint's ingestion service reads from.
|
|
5
|
+
Two strategies are provided:
|
|
6
|
+
|
|
7
|
+
- :class:`FileSystemStaging` — writes blobs under a directory (offline/testing, or
|
|
8
|
+
a staging area for a later upload) — **no Azure**;
|
|
9
|
+
- :class:`BlobStaging` — uploads to the content/manifest containers (requires the
|
|
10
|
+
``[azure]`` extra: ``pip install office365-rest-python-client[azure]``); with an
|
|
11
|
+
``encryption_key`` every blob is AES-256-CBC encrypted (SharePoint-provided
|
|
12
|
+
containers require this).
|
|
13
|
+
|
|
14
|
+
:func:`create_staging` picks a strategy from the container URLs; it is the
|
|
15
|
+
extension point for a future S3 / Azure Files staging.
|
|
16
|
+
|
|
17
|
+
Content and manifest blobs go to **different** containers — the API rejects a job
|
|
18
|
+
whose content and manifest containers match.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import importlib
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
from office365.migration.package.crypto import decrypt, encrypt, normalize_key
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from office365.migration.package.builder import Package
|
|
33
|
+
|
|
34
|
+
__all__ = ["AzureBlobStaging", "BlobStaging", "FileSystemStaging", "Staging", "create_staging"]
|
|
35
|
+
|
|
36
|
+
_AZURE_EXTRA_HINT = "Azure staging requires the 'azure' extra: pip install office365-rest-python-client[azure]"
|
|
37
|
+
|
|
38
|
+
# Azure Blob hosts (public cloud + US Government).
|
|
39
|
+
_AZURE_BLOB_HOSTS = ("blob.core.windows.net", "blob.core.usgovcloudapi.net")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Staging:
|
|
43
|
+
"""Strategy that places a package's content + manifest blobs for ingestion."""
|
|
44
|
+
|
|
45
|
+
def stage(self, package: "Package") -> None:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FileSystemStaging(Staging):
|
|
50
|
+
"""Writes the package's blobs under ``content_dir`` / ``manifest_dir``."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, content_dir: str | Path, manifest_dir: str | Path | None = None) -> None:
|
|
53
|
+
self._content_dir = Path(content_dir)
|
|
54
|
+
self._manifest_dir = Path(manifest_dir) if manifest_dir is not None else self._content_dir / "manifest"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def content_dir(self) -> Path:
|
|
58
|
+
return self._content_dir
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def manifest_dir(self) -> Path:
|
|
62
|
+
return self._manifest_dir
|
|
63
|
+
|
|
64
|
+
def stage(self, package: "Package") -> None:
|
|
65
|
+
self._content_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
self._manifest_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
for name, data in package.content.items():
|
|
68
|
+
(self._content_dir / name).write_bytes(data)
|
|
69
|
+
for name, data in package.blobs().items():
|
|
70
|
+
(self._manifest_dir / name).write_bytes(data)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BlobStaging(Staging):
|
|
74
|
+
"""Uploads the package's blobs to the content/manifest Azure Blob containers.
|
|
75
|
+
|
|
76
|
+
The container URIs must embed a SAS token (e.g. the ``DataContainerUri`` /
|
|
77
|
+
``MetadataContainerUri`` returned by ``Site.provision_migration_containers``).
|
|
78
|
+
A snapshot is created for every content blob, as the API requires.
|
|
79
|
+
|
|
80
|
+
Pass ``encryption_key`` (the base64 ``EncryptionKey`` from
|
|
81
|
+
``provision_migration_containers``) for SharePoint-provided containers: every
|
|
82
|
+
content and manifest blob is then AES-256-CBC encrypted, with its base64 IV
|
|
83
|
+
stored as the ``IV`` blob property.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
content_uri: str,
|
|
89
|
+
manifest_uri: str,
|
|
90
|
+
encryption_key: str | bytes | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
if content_uri.rstrip("?") == manifest_uri.rstrip("?"):
|
|
93
|
+
raise ValueError("content and manifest containers must differ")
|
|
94
|
+
self._content_uri = content_uri
|
|
95
|
+
self._manifest_uri = manifest_uri
|
|
96
|
+
self._key = normalize_key(encryption_key) if encryption_key else None
|
|
97
|
+
|
|
98
|
+
def stage(self, package: "Package") -> None:
|
|
99
|
+
container_client = _container_client()
|
|
100
|
+
content = container_client.from_container_url(self._content_uri)
|
|
101
|
+
manifest = container_client.from_container_url(self._manifest_uri)
|
|
102
|
+
for name, data in package.content.items():
|
|
103
|
+
blob = content.get_blob_client(name)
|
|
104
|
+
self._upload(blob, data)
|
|
105
|
+
blob.create_snapshot() # the API reads the latest snapshot
|
|
106
|
+
for name, data in package.blobs().items():
|
|
107
|
+
self._upload(manifest.get_blob_client(name), data)
|
|
108
|
+
|
|
109
|
+
def _upload(self, blob, data: bytes) -> None:
|
|
110
|
+
if self._key is None:
|
|
111
|
+
blob.upload_blob(data, overwrite=True)
|
|
112
|
+
return
|
|
113
|
+
ciphertext, iv = encrypt(data, self._key)
|
|
114
|
+
blob.upload_blob(ciphertext, overwrite=True, metadata={"IV": iv})
|
|
115
|
+
|
|
116
|
+
def read_manifest_blob(self, name: str) -> bytes:
|
|
117
|
+
"""Download a blob (e.g. the API's import log) from the manifest container.
|
|
118
|
+
|
|
119
|
+
Provisioned containers grant ``Read`` (not ``List``), so the caller must
|
|
120
|
+
know the blob name — ``JobLogFileCreate`` events carry it. The containers
|
|
121
|
+
are AES-256-CBC encrypted end-to-end, so the API's own logs come back as
|
|
122
|
+
ciphertext; with a key set they are decrypted using the blob's ``IV``
|
|
123
|
+
metadata (falling back to a 16-byte IV prefixed to the payload).
|
|
124
|
+
"""
|
|
125
|
+
blob = _container_client().from_container_url(self._manifest_uri).get_blob_client(name)
|
|
126
|
+
data = blob.download_blob().readall()
|
|
127
|
+
if self._key is None:
|
|
128
|
+
return data
|
|
129
|
+
iv = _blob_iv(blob)
|
|
130
|
+
if iv is None and len(data) > 16: # noqa: PLR2004
|
|
131
|
+
iv = base64.b64encode(data[:16]).decode("ascii")
|
|
132
|
+
data = data[16:]
|
|
133
|
+
if iv is None:
|
|
134
|
+
return data
|
|
135
|
+
try:
|
|
136
|
+
return decrypt(data, self._key, iv)
|
|
137
|
+
except Exception: # noqa: BLE001 — not encrypted with our key; return raw
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Back-compat alias (the class was introduced as ``AzureBlobStaging``).
|
|
142
|
+
AzureBlobStaging = BlobStaging
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def create_staging(
|
|
146
|
+
content_url: str,
|
|
147
|
+
manifest_url: str,
|
|
148
|
+
encryption_key: str | bytes | None = None,
|
|
149
|
+
) -> Staging:
|
|
150
|
+
"""Return a :class:`Staging` for the given container URLs.
|
|
151
|
+
|
|
152
|
+
Azure Blob is the only backend today (the SharePoint Migration API requires it
|
|
153
|
+
for the server-side ingest leg). This factory is the extension point: a future
|
|
154
|
+
S3 / Azure Files staging can be selected here by URL host without changing
|
|
155
|
+
callers.
|
|
156
|
+
"""
|
|
157
|
+
host = urlparse(content_url).hostname or ""
|
|
158
|
+
if host.endswith(_AZURE_BLOB_HOSTS):
|
|
159
|
+
return BlobStaging(content_url, manifest_url, encryption_key=encryption_key)
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"unsupported staging container host {host!r}: only Azure Blob ({', '.join(_AZURE_BLOB_HOSTS)}) is supported"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _container_client():
|
|
166
|
+
"""Lazily import ``azure.storage.blob.ContainerClient`` (optional extra)."""
|
|
167
|
+
try:
|
|
168
|
+
module = importlib.import_module("azure.storage.blob")
|
|
169
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
170
|
+
raise ImportError(_AZURE_EXTRA_HINT) from exc
|
|
171
|
+
return module.ContainerClient
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _blob_iv(blob) -> str | None:
|
|
175
|
+
"""The base64 ``IV`` from a blob's metadata, when present."""
|
|
176
|
+
try:
|
|
177
|
+
metadata = blob.get_blob_properties().metadata or {}
|
|
178
|
+
except Exception: # noqa: BLE001 — metadata is best-effort
|
|
179
|
+
return None
|
|
180
|
+
return next((value for key, value in metadata.items() if key.lower() == "iv"), None)
|
office365/migration/report.py
CHANGED
|
@@ -12,7 +12,8 @@ from dataclasses import dataclass, field
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import TYPE_CHECKING
|
|
14
14
|
|
|
15
|
-
from office365.migration.
|
|
15
|
+
from office365.migration.report_io import write_formats
|
|
16
|
+
from office365.runtime.converters.scalars import iso
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
19
|
from office365.migration.job import MigrationJob
|
|
@@ -79,6 +80,10 @@ def build_report(job: "MigrationJob") -> MigrationReport:
|
|
|
79
80
|
"status": item.status.value,
|
|
80
81
|
"error": item.error or "",
|
|
81
82
|
"error_code": item.error_code or "",
|
|
83
|
+
"modified": item.modified or "",
|
|
84
|
+
"created": item.created or "",
|
|
85
|
+
"author_id": item.author_id,
|
|
86
|
+
"editor_id": item.editor_id,
|
|
82
87
|
}
|
|
83
88
|
for item in job.manifest.items
|
|
84
89
|
]
|
|
@@ -107,5 +112,5 @@ def export_reports(job: "MigrationJob", output_dir: str | Path) -> list[str]:
|
|
|
107
112
|
):
|
|
108
113
|
if not records:
|
|
109
114
|
continue
|
|
110
|
-
written +=
|
|
115
|
+
written += write_formats(records, output_dir, name)
|
|
111
116
|
return written
|