Skip to content

Migration

Move content between Microsoft 365 and other systems — resumable, verifiable, and with best-effort fidelity. The toolkit is product-agnostic (a DataSource → DataTarget pair) and ships adapters for the filesystem, SharePoint lists, and SharePoint document libraries.

The model

Piece Role
DataSource / DataTarget adapters — read/checksum and exists/write/list_paths
MigrationJob lifecycle assess → plan → run → verify, resumable
Manifest the persisted plan (a list of MigrationItem)
Checkpoint per-item status, incremental watermarks, phase (pause/resume/cancel)
MigrationServerJob server-side Migration API ingestion (submit + monitor)

Client-side vs server-side

Two engines ship in this package. They are not interchangeable — they move different content and deliver different fidelity:

  • Client-side (SharePointLibrarySource/Target, SharePointListSource/Target) drives the ordinary SharePoint REST API item by item: files and folders, record lists and their attachments, and — via capture_list_structure / provision_list_structure — columns, content types, views, list container settings and column/view formatting JSON. No Azure.
  • Server-side (SharePointPackageTarget + MigrationServerJob) builds a Migration API package in Azure Storage and submits a bulk ingestion job. It is the only path that can carry version history.
Axis Client-side (REST) Server-side (Migration API)
Content files + folders, record lists + attachments, list structure document libraries (files/folders + version history)
Version history ✗ ✓ the runner reads each file's history and the package carries <Versions>
Authors / ModifiedBy not preserved ✓ applied from UserGroup.xml (integer principal id, matched by PUID)
Permissions best-effort same-tenant ACLs at all three container scopes: items, a list's own ACLs and web permission levels, groups and role assignments (with --preserve-permissions) best-effort same-tenant ACL — resolved users (by PUID) and SharePoint groups (by title); anything else is skipped with a warning
Columns / content types / views / formatting captured + provisioned over REST (column + view CustomFormatter JSON included) not emitted by the builder today
Azure Storage not needed required (SharePoint-owned containers; no Azure account)
Throughput per-item HTTP, throttle-aware, resumable bulk server-side ingestion; poll a job
Verification job.verify() — counts, md5 (Office files compared canonically), identity marker job progress/events + diagnose() (no content diff)

Choose client-side when you need list items, structure, or verification, and can tolerate REST throughput. Choose server-side when the content is a document library and version history matters. Mix them: provision the target's columns, content types, and views client-side, then hand the library's files to the server side.

The server-side engine reuses the same MigrationJob / runner. Version history is preserved automatically when the source exposes read_version_history (the SharePoint library source) and the target is SharePointPackageTarget — so preserve_versions is honored there and rejected only on the client-side REST target. Authorship and resolved-user ACLs are applied on the server-side path too — see Fidelity.

Quick start

Filesystem → SharePoint document library:

from office365.migration import MigrationJob, MigrationOptions
from office365.migration.adapters.filesystem import FileSystemSource
from office365.migration.sharepoint.adapters import SharePointLibraryTarget
from office365.sharepoint.client_context import ClientContext

ctx = ClientContext(site_url).with_client_certificate(tenant, client_id, thumbprint, cert_path)
library = ctx.web.lists.get_by_title("Documents").root_folder

job = MigrationJob(
    FileSystemSource("src"),
    SharePointLibraryTarget(library),
    MigrationOptions(concurrency=4),
)
job.plan()
job.run()
print(job.stats.summary())
print(job.verify().summary())
job.export_reports("reports/")

Runs are idempotent and resumable: pass manifest_path / checkpoint_path and a paused or failed run continues where it stopped (job.resume()). Set MigrationOptions(incremental=True) to skip items at or below the persisted watermark. Checkpoints are persisted atomically (temp file → flush → rename), so a crash mid-save never corrupts the file the next run resumes from.

An item is checkpointed under its source identity — source_id when the adapter reports one, otherwise its source path — so a resume still recognises an item the target path was resolved differently for (a rename policy, a collision suffix). A checkpoint written by an earlier version keys the target path and is simply re-driven; the default SKIP policy makes that a no-op on the target.

Give checkpoint_path a .sqlite / .sqlite3 / .db extension to keep the same state in a SQLite ledger instead of JSON: one row per item, written in a single transaction, readable while the run is live (sqlite3 state/run.sqlite 'select status, count(*) from item group by 1'). It costs more bytes and more time per save than JSON — take it for the queryable view and the single-transaction write, not for speed.

A resume does not start where the crash was: the source is read again from the beginning of the list and each item is matched against the checkpoint, then skipped. Measured on a dev tenant: 200 already-done items across three lists cost 1 m 09 s and 851 requests, and re-ensured the four folders — they have no source_id, so their key is the path and their branch is idempotent, which is why they are counted as written. Prefer job.resume() while the process is alive: it keeps the adapter, and so the caches built during the first attempt. A run that needs the target principals again (authorship preservation) re-ensures each distinct login once per process, not per item.

Idempotency & conflicts

A re-run must not duplicate content. Two mechanisms cooperate:

  • Per-item checkpoint — items already done/skipped aren't re-driven, so a resumed run continues where it stopped.
  • Conflict policy — MigrationOptions(conflict_resolution=…) decides what a target that already exists should do:
  • SKIP (default) — leave it untouched;
  • OVERWRITE — replace it;
  • RENAME — land beside it under a numeric suffix.

The runner applies the policy to file targets directly. Record targets (such as SharePointListTarget with a key) receive it through an optional configure(options) hook: OVERWRITE maps to the record pipeline's on_conflict="update" (keyed create-or-reconcile), everything else to on_conflict="skip". Passing an explicit on_conflict= to a record target pins it regardless of the options. This is the same on_conflict vocabulary the data pipeline uses (see Data pipeline).

SPMT, accurately. The SharePoint Migration Tool's exactly-once reliability comes from a server-managed, keyed bulk import (the Migration API) that reconciles identity server-side — not from an HTTP PATCH/PUT upsert pattern. The client-side runner therefore can't reproduce SPMT's semantics; the server-side path (MigrationServerJob) is what does.

Delta re-runs

A re-run reconciles content, not drift: an item deleted at the source leaves an orphan on the target, and a rename or move looks like a brand-new item — the old target copy stays where it was, so the re-run duplicates it. Point a job at the manifest a previous run persisted (manifest_path=, or manifest_dir= for a whole-site task) and it diffs that plan against the fresh one before replacing it:

job = MigrationJob(source, target, manifest_path="state/manifests/docs.json")
job.plan()  # the delta is computed here, before the plan is replaced
print(job.delta.summary())  # delta vs 412 planned item(s): 1 new, 2 gone, 3 moved, 0 changed
print(job.delta.is_stale)  # True — a deletion or a move cannot converge on its own
for note in job.delta.notes():  # ready-to-print, sampled to five paths per kind
    print(note)

job.delta (a DeltaReport) names four kinds of drift: added (in the fresh plan only), missing (planned before, gone from the source now), moved (the same source identity at a different path) and changed (same path, different modification time — under the default skip policy an existing target copy is left as it was). session.delta() returns one report per task, and MovedItem/compute_delta are exported for a report of your own.

Detection is read-only: nothing is deleted on the target, because a target may legitimately hold content the copy did not put there (extra in the verification report is the same orphan seen from the other side). The limits are stated instead of guessed at: a move is only recognizable when the source exposes a stable identity (MigrationItem.source_id — a SharePoint file's UniqueId), so a renamed folder is reported as one missing path plus the moved paths of its children, and a list item is keyed by its source item id — retitling it is a changed item, never a move.

Adapters

Adapter Direction Content
FileSystemSource / FileSystemTarget both files + folders
JsonFileSource / JsonFileTarget both record payloads (one JSON file per item)
SharePointLibrarySource / SharePointLibraryTarget both files + folders (recursive) + folder column values and system metadata
SharePointListSource / SharePointListTarget both list items as records
MailFolderSource / MailExportTarget export mail as a portable EML tree (office365.migration.outlook)
TeamsArchiveSource / TeamsArchiveTarget both Teams archives

Migrating list items

SharePointListSource projects every readable field of each item into the record payload (excluding the AttachmentFiles navigation, which is captured separately). Attachments become their own manifest items and are uploaded to the matching parent after the records have been flushed.

To pair source and target items for verification — and to make the default SKIP policy idempotent, so re-running does not duplicate records — the target stamps every item with a source-id marker, MigrationSourceId by default. This adds a real column to every migrated list; pass identity_field=None to SharePointListTarget to opt out (verification then falls back to target ids and can no longer reconcile a re-run).

Two honest limits remain:

  • Record content is verified over the item's scalar content columns: the source fingerprint and the target read-back hash the same fields, so a value difference is a real mismatch. Server-assigned identity (Id/GUID), the separately-checked timestamps/authorship/permissions, the remapped ContentTypeId, and version bookkeeping are excluded by design. Typed columns are remapped rather than dropped where the target can: a Lookup value is translated from the source list's item id to the matching target item through each migrated list's MigrationSourceId marker, and a Person value is carried as a login name and resolved to the target's own site-user id with EnsureUser. Single- and multi-value Lookup/Person columns are covered (a multi-value column is written as {"results": [...]}). Managed Metadata values are carried by term id only — Label and the site-local WssId are cosmetic and are not compared: a single-value column is written as a typed SP.Taxonomy.TaxonomyFieldValue, a multi-value column through its hidden note companion (-1;#Label|guid;#..., the only shape the server accepts), and both sides are verified by a sorted term-id list. Structure provisioning recreates a taxonomy column with TaxonomyField.create(...) against the same term set, so it binds to the target web's TaxonomyHiddenList instead of the source's. The term store itself is not migrated: a term absent on the target is rejected by the server, so this is a same-tenant (or pre-provisioned store) capability. A value that cannot be resolved (a lookup into a list outside this migration, a term absent from the target store, an unknown login) is dropped with a warning rather than written wrong. Typed values in hidden columns are dropped too: structure provisioning does not replicate hidden columns, so there is no target column to write them to. A multi-choice column needs no remapping — it holds plain strings — but its shape does: a live read returns the selection as a positional mapping, so the source records it as a bare list and the target wraps it as {"results": [...]} for the create. The read-back mapping is canonicalized to the same list before hashing, which makes a lost selection a real mismatch; only TypeAsString == "MultiChoice" counts as multi-value, because a Type="Choice" Mult="TRUE" column keeps that flag while storing a single value. A Hyperlink value travels as the {"Url", "Description"} object both sides read and a create accepts as-is. Other value objects — an image/thumbnail, a geolocation point — are still dropped with a warning instead of being sent in a shape the server may reject.
  • Document library folders are list items too, so their own column values — a document set's metadata, a managed folder's department — travel as the folder's payload. A library adapter built with its library (sp_list=, which the site resolvers always pass) reads them with an explicit projection of the folder's writable columns — a * read fails on a library, which has no Attachments column — and the target MERGEs them onto the folder's list item once the structure exists. Writable means non-hidden, not underscore-prefixed (SharePoint's storage columns are not projectable), not read-only and not a computed type (Computed/File/Attachments/ WorkflowStatus, i.e. LinkFilename, DocIcon, …); the target drops the same columns from its read-back so the fingerprints are comparable. The folder's ContentTypeId is carried (a MERGE does change a folder's content type — live-checked) but stays out of the fingerprint, as for item records, and an empty value counts as absent on both sides. A failed value write is a warning, never fatal: the folder's structure matters more than its metadata. Verification samples folders for the content check alongside items, and only when both adapters were built with their library — a filesystem pair carries no folder values and stays silent rather than reporting them as unverifiable.
  • A folder's own system metadata — Created/Modified and Author/Editor — is carried as well: a folder is a list item with a timestamp just like a file, so the same diff reports it. It is read back from the folder resource on both sides (the recursive listing already returns tz-aware timestamps, while a list item's form values are web-local naive strings and would never compare equal). Two live-probed details shape the implementation:
  • The stamps are applied in a post-content pass, after all content has been written, not item by item: a folder's Modified follows its children, so uploading a file into it — or creating a subfolder — resets it to now. Stamping a parent before its subtree exists silently loses the value.
  • An existing folder is not skipped the way an existing file is. A folder holds no content to duplicate, and a resumed run has to re-apply its metadata, otherwise a re-verify reports every untouched folder as drifted. Files keep the skip: a re-run neither re-uploads nor re-stamps content that is already there. Folder fidelity rides on the same supports_folder_records capability as the folder values, so a filesystem pair stays silent instead of reporting it as unverifiable.
  • A document library file's approval state (content moderation) is carried when the library enables it: the source reads OData__ModerationStatus/ OData__ModerationComments alongside the author ids (one extra projection on the read it already does) and the target restores them. Only a draft/pending or rejected file needs this — a fresh upload lands approved — so an approved file costs no extra write. Two live-probed details shape the implementation:
  • It is a separate, final write, not folded into the timestamp one: a document rejects a moderation change together with other properties (You cannot change moderation status and set other item properties at that same time).
  • Every ValidateUpdateListItem write on a document approves it (whatever its bNewDocumentUpdate flag), which is exactly why the state has to be written last. The moderation write still moves the list item's own Modified (it cannot be combined with anything else), so a draft file reports the migration time there — its File.TimeLastModified, the value the metadata diff compares, keeps the source timestamp. A failed state write is a warning, never fatal.
  • A checked-out file is copied from its last checked-in version, because that is all REST exposes — the author's in-progress edits live in the check-out and never reach a reader. Carrying the check-out itself is not an option either: it would leave the copy locked by the migrating app. The copy is therefore faithful to the source but incomplete, and verification cannot notice — it compares the same read on both sides — so the source reports it instead: one warning per run, naming the files (N checked-out file(s) were copied from their last checked-in version (in-progress edits are not visible over REST): …). The CheckoutUser detection rides the single item read that already supplies the author ids.
  • A file on an un-published (minor) version is copied as its draft, and that is the honest half of the story: REST returns the draft content (live-probed — a file at 1.1 returns the draft, not the published 1.0), so the copy matches what the source reports, while the published version the draft supersedes stays behind — client-side SP→SP does not restore version history. Verification again compares the same read and cannot notice, so the source reports it: one warning per run naming the files (N file(s) are on an un-published (minor) version: the draft content is copied, the published version it supersedes is not (version history is not preserved): …). The signal rides the file listing the source already reads (MinorVersion): a published file is x.0, a draft x.n with n > 0. The draft state itself is not lost — the approval state carries it when the target library moderates — so the warning names the version history, not the content.
  • Per-item timestamps/permissions/authorship (preserve_timestamps/preserve_permissions/preserve_authorship) are applied by the list adapters on a best-effort basis: timestamps and authorship are written with ValidateUpdateListItem; only items with a unique source ACL are copied. Unresolved principals and unhonorable facets surface as mismatches/unverifiable in the post-run verification, never as a silent drop.

Migrating mail

A mailbox export is the same job as a file migration: MailFolderSource enumerates messages as items (one per message, its envelope carried in item.metadata), and MailExportTarget writes a portable EML tree.

from office365.migration import MigrationJob
from office365.migration.outlook import MailExportOptions, MailExportTarget, MailFolderSource

folder = client.me.mail_folders["inbox"].get().execute_query()
job = MigrationJob(
    MailFolderSource(folder, MailExportOptions(include_subfolders=True)),
    MailExportTarget("./mailbox-export"),
)
job.plan()
job.run()
print(job.verify().summary())  # each .eml's MD5 vs the source message's MIME

The target layout:

<root>/Inbox/2024-03-01_Quarterly-report-Q1_cb65ad88.eml   # RFC 822 bytes
<root>/Inbox/2024-03-01_Quarterly-report-Q1_cb65ad88.json  # sidecar record
<root>/Inbox/Archive/2023-12-24_Old-thread_1f9c07aa.eml
<root>/manifest.jsonl                                      # one sidecar per line

Names are derived from the message (date_subject_id-digest.eml), not from the enumeration order, so a re-run lands on the same paths and skips what is already there. The sidecar carries what MIME cannot — Graph ids, read state, Outlook categories, conversation id, folder path — and manifest.jsonl is what a later import reads to restore them.

Three honest limits:

  • Attachments are inside the MIME. They are part of the message, so they are not separate manifest items and not separately verified.
  • The MIME body is fetched per message. Message.get_content() is batchable since 3.3.0, so batching those reads is a tuning step, not a redesign.
  • Drafts may have no internetMessageId. Those are keyed by Graph id, which changes if the draft moves — a re-run then reports a second copy rather than overwriting the first.

For a pre-migration inventory (folder counts, large-folder flags) use MailboxAssessor; for the mailbox-wide move into Exchange, the Exchange migration service ingests this EML tree — the toolkit produces the artefact, it does not wrap the service. The example shows the flags (--flat, --no-drafts, --preserve-timestamps).

Importing an EML tree

The inverse is the same job with the adapters swapped: EmlTreeSource reads the .eml files (the sidecar becomes the item's envelope) and MailboxTarget writes each message through Graph's MIME import, recreating the folder chain.

from office365.migration import MigrationJob
from office365.migration.outlook import EmlTreeSource, MailImportOptions, MailboxTarget

inbox = client.me.mail_folders["inbox"].get().execute_query()
job = MigrationJob(EmlTreeSource("./mailbox-export"), MailboxTarget(inbox, MailImportOptions()))
job.plan()
job.run()
print(job.verify().summary())  # semantic digest, not bytes: Exchange re-serializes MIME
  • The export's top directory is the target folder. Directories below it are created under the target using the sidecar's folder_display_path, so a folder called All Mail does not come back as All-Mail. MailImportOptions(root_name=...) nests the whole tree one level deeper.
  • Idempotent. exists() matches on internetMessageId inside the target folder, so a resumed or re-run job skips what it already delivered instead of duplicating it — Exchange itself accepts duplicate Message-IDs with distinct ids.
  • Flags MIME cannot carry are replayed (one PATCH per message, only when the value differs): read state, Outlook categories, importance. restore_read_state=False / restore_categories=False turn that off.

Four honest limits, all Exchange's rather than the adapter's:

  • An imported message is a draft. Graph creates it with isDraft: true and ignores a PATCH that tries to clear it; only a real delivery (the Exchange migration service, IMAP APPEND) produces a non-draft.
  • receivedDateTime is the import time. The original date survives in the Date header, which Graph exposes as sentDateTime, and that is restored for free.
  • Timestamps are not restorable, so preserve_timestamps is refused up front by the runner rather than silently dropped.
  • 4 MB per message. Graph's MIME import caps the request body; a larger message needs an upload session, which this target does not use, so it raises rather than truncating.

Exporting a calendar

A calendar exports through the same job with a different pair of adapters: CalendarSource enumerates one item per event, IcsExportTarget writes an iCalendar document plus the same kind of sidecar.

from office365.migration import MigrationJob
from office365.migration.outlook import CalendarSource, IcsExportTarget

calendar = client.me.calendar.get().execute_query()
job = MigrationJob(CalendarSource(calendar), IcsExportTarget("./calendar-export"))
job.plan()
job.run()
print(job.verify().summary())  # each .ics's MD5 vs the document rendered from the source event

The layout mirrors the mail export:

<root>/Calendar/2026-03-02_Quarterly-review_4b1e9f02.ics   # iCalendar document
<root>/Calendar/2026-03-02_Quarterly-review_4b1e9f02.json  # sidecar record
<root>/manifest.jsonl                                      # one sidecar per line

Verification is a byte checksum here — unlike mail, nothing downstream re-serializes an .ics: the file is rendered from the event record, so the same event always produces the same bytes (DTSTAMP is the event's own last-modified time, never the export's). An all-day event becomes a VALUE=DATE range, an HTML body goes into the standard X-ALT-DESC;FMTTYPE=text/html instead of being flattened into lossy plain text, and the reminder becomes a VALARM.

Verification is exact only if the names are exact, so the export keys on the Graph event id, not on iCalUId: Exchange gives a meeting an iCalUId but not a plain appointment (an event created through the API reads back iCalUId: null), so keying on it would let a re-run see the same event under a new identity and write a second copy. The iCalUId is still recorded, and becomes the .ics UID when present, because that is what other calendar clients match on. The wire name is iCalUId — capital ID — which is also what the generated Event.ical_uid getter reads; get_property() answers None for a name it does not know, so getting one letter wrong is silent.

Time zones are written as Graph states them — a wall clock plus TZID=Europe/Kiev when the zone is named, a Z timestamp when it is not — so no IANA/Windows translation can drift. Note that Exchange renders times in the mailbox's zone on read: an event created as 09:00 Europe/Kiev is echoed back in the zone that was sent in the create response, but re-read as 07:00 UTC from a mailbox set to UTC, so a differently-zoned export is normal rather than a bug. A read can ask for another frame with Prefer: outlook.timezone (the office365.runtime.http.prefer.prefer_outlook_timezone helper), which is what the import's verification does. The body is normalised the same way (text comes back as converted HTML), and a default 15-minute reminder appears on events created without one.

Four honest limits:

  • A recurring event is exported once, as its series master: an RRULE in the .ics and Graph's own recurrence object in the sidecar. The occurrences are not separate items, because importing them as well as the series would duplicate every meeting. CalendarExportOptions(include_recurring_instances=True) exports them anyway for archive-style backups.
  • Cancelled events are skipped unless include_cancelled=True: a cancelled event is a tombstone, and the import creates live events.
  • The .ics is for other clients; the sidecar is for the import. iCalendar cannot carry Graph's recurrence object, the online-meeting provider or the sensitivity/importance pair without loss, so an import reads the sidecar and treats the .ics as the portable copy. The sidecar is the event as Graph spells it — Event.to_json() plus the three keys a model cannot know (calendar_path, calendar_display_path, ics_path) — so an import replays it verbatim and a field the model gains needs no exporter change. One calendar per source — a mailbox with several is one job per calendar.
  • It is a projection, not a backup of the mailbox item. Attachments (hasAttachments), extended properties and per-attendee proposed times are recorded as flags, not carried: calendar attachments are the event's, not the .ics's.

Importing a calendar

The inverse is the same job with the adapters swapped: IcsTreeSource reads the .ics documents (the sidecar becomes the item's envelope) and CalendarTarget creates each event through Graph.

from office365.migration import MigrationJob
from office365.migration.outlook import CalendarImportOptions, CalendarTarget, IcsTreeSource

calendar = client.me.calendar.get().execute_query()
options = CalendarImportOptions()
job = MigrationJob(IcsTreeSource("./calendar-export"), CalendarTarget(calendar, options))
job.plan()
job.run()
print(job.verify().summary())  # digest of the restorable fields, not the .ics bytes
  • The sidecar is the import contract. An .ics on its own is refused: iCalendar cannot carry Graph's recurrence object, so replaying a series from the document alone would silently flatten every meeting into a single event.
  • The payload is a Graph-spelled dict, POSTed raw. EventCollection.add() cannot be used: it rewrites timeZone to UTC, drops transactionId, and rejects a dict start — all three measured.
  • A re-run skips what it already created. A created event comes back with iCalUId: null and a fresh Graph id, so neither source identity survives; the target matches on (subject, wall clock, zone) instead. Two source events that share all three are both imported — each claims its own entry in the target index.
  • transactionId is deterministic (sha1 of the source id), so two concurrent runs collide on the server instead of both writing. A collision is adopted: the event the other run created is the work.
  • Pass one options object to both adapters. CalendarImportOptions decides which fields the import restores, and the verification digest counts attendees and categories only when the options say they are restored, so two copies of the options make the two sides of a job disagree.
  • A recurrence written by an older exporter is refused with an actionable error instead of being dropped: a sidecar that carries repr text rather than a JSON object is a series that cannot be replayed, so re-export and import that tree.

Four honest limits, all Exchange's rather than the adapter's:

  • Attendees are not restored by default. Creating an event with attendees makes Exchange send an invitation to every one of them, and responseRequested: false does not suppress it (measured), so an import would mail every invitee of every meeting in the tree. CalendarImportOptions(restore_attendees=True) opts in, and the run reports how many attendee entries it left out.
  • Exchange owns the meeting metadata. iCalUId, the organizer, per-attendee responses, and the created/modified times are assigned by the service; the authenticated user becomes the organizer whatever the sidecar says.
  • The body is re-serialized, gaining Exchange's <html><head><style>… envelope, so an import is verified by a digest of the restorable fields rather than by bytes.
  • Verification asks for the source's timezone. Exchange renders event times in the signed-in mailbox's own zone otherwise, so importing into a mailbox set to a different zone would report every event as a content mismatch while the instants were identical: a read of an event created as 09:00 Europe/Kiev comes back as 06:00 UTC unasked and as 09:00 Europe/Kiev with Prefer: outlook.timezone (measured).

Exporting contacts

Contacts follow the same shape as a calendar: one item per contact, a .vcf plus a sidecar, and a manifest.jsonl at the root.

from office365.migration import MigrationJob
from office365.migration.outlook import ContactSource, VcfExportTarget

contacts = client.me.contact_folders["contacts"].get().execute_query()
job = MigrationJob(ContactSource(contacts), VcfExportTarget("./contacts-export"))
job.plan()
job.run()
print(job.verify().summary())  # each .vcf's MD5 vs the document rendered from the source contact
<root>/Contacts/Alice-Example_4b1e9f02.vcf        # vCard 3.0 document
<root>/Contacts/Alice-Example_4b1e9f02.json      # sidecar record
<root>/manifest.jsonl                            # one sidecar per line

The sidecar is the contact as Graph spells it — Contact.to_json() — plus the three keys a model cannot know (folder_path, folder_display_path, vcf_path). That record is ContactRecord: of(contact, folder) builds it from a loaded contact, from_json(sidecar) reads it back with no client and no context, and it answers the artefacts a migration needs — vcard(), payload(), digest(), dest_path, dedup_key:

import json
from pathlib import Path

from office365.migration.outlook import ContactRecord

sidecar = Path("./contacts-export/Contacts/Alice-Example_4b1e9f02.json")
record = ContactRecord.from_json(json.loads(sidecar.read_text(encoding="utf-8")))
record.payload()  # the POST /contacts body an import posts
record.digest()  # what verification compares, on either machine

ContactSource walks the folder's children too, and a contact from a child folder lands in the matching subdirectory (Contacts/Vendors/…), so the tree carries the folder chain. Verification is a byte checksum, for the same reason as the calendar export: the .vcf is rendered from the contact record, so the same contact always produces the same bytes.

A contact has no internetMessageId and no iCalUId — Exchange assigns neither an internetMessageId nor an iCalUId equivalent — so the export keys on the Graph contact id and the file name carries a hash of it.

Four honest limits:

  • No photo. The contact entity on this endpoint exposes no hasPhoto column (measured: not in a full property read), so detecting one costs a GET /photo that 404s for every contact that has no image. Photos are therefore declared unsupported rather than probed.
  • A birthday compares by date, not instant. Exchange stores 11:59 in the mailbox's zone whatever is sent — 11:59:00Z and 11:59:00+00:00 both read back as 1953-05-01 11:59 — so the export records the date and the digest normalises it. A birthday's original time of day is not recoverable.
  • The .vcf is for other clients; the sidecar is for the import. vCard 3.0 cannot carry the office fields, the address components or the categories without a lossy mapping, so an import reads the sidecar and treats the .vcf as the portable copy.
  • It is a projection, not a backup of the mailbox item. Categories travel, but a category that contains a comma does not exist in Exchange (the service rejects the whole contact with ErrorPropertyUpdate), so the .vcf's escaped CATEGORIES values split back into names that Exchange will accept.

Importing contacts

from office365.migration import MigrationJob
from office365.migration.outlook import ContactImportOptions, ContactTarget, VcfTreeSource

contacts = client.me.contact_folders["contacts"].get().execute_query()
options = ContactImportOptions()
job = MigrationJob(VcfTreeSource("./contacts-export"), ContactTarget(contacts, options))
job.plan()
job.run()
print(job.verify().summary())  # digest of the restorable fields, not the .vcf bytes
  • The sidecar is mandatory. An .vcf without its .json raises rather than importing a contact with the office fields, address components and categories dropped — unlike mail, where a missing sidecar degrades to the MIME itself.
  • The payload is a Graph-spelled dict, POSTed raw. ContactCollection.add() takes four arguments and drops everything else (measured), so the target posts ContactRecord.payload() itself.
  • A re-run skips what it already created. A contact has no transactionId, so concurrency is not handled by the service refusing a second write; the target matches on (display name, first address) instead. Two contacts differing only by that pair are both imported, and two concurrent runs can import the same contact twice.
  • A category that carries a comma is refused with the offending name, because Exchange answers ErrorPropertyUpdate ("Property update did not succeed.") to the whole contact — measured, and the message alone names neither the field nor the value.

Four honest limits, all Exchange's rather than the adapter's:

  • Timestamps are not restorable. Exchange stamps createdDateTime and lastModifiedDateTime with the import time, so preserve_timestamps is refused up front by the runner rather than silently dropped.
  • A re-loaded contact doubles its lists. Reading a contact back through the object the create returned appends to every collection-valued property (emailAddresses, businessPhones, homePhones, imAddresses, categories, children — all measured), so the target verifies through a fresh handle on the folder the contact lives in.
  • The name of an address-only email is filled in. An emailAddresses entry without a name reads back named after the address, so the digest compares (address, name or address).
  • Child folders are created through the specialised collection. ContactFolder.child_folders is the generated plain EntityCollection: it has no idempotent ensure, and its add(name) posts an empty body (measured: "The property 'wellKnownName or displayName' is required"). The target therefore builds a ContactFolderCollection on the parent's childFolders path.

SPMT-style sessions

MigrationSession mirrors the SPMT PowerShell cmdlets — register a session, add tasks, start, show, stop:

cmdlet method
Register-SPMTMigration session.register(settings=..., context=...)
Add-SPMTTask session.add_task(file_share_source=..., target_site_url=..., target_list=...)
Show-SPMTMigration session.show()
Start-SPMTMigration session.start()
Stop-SPMTMigration session.stop()
Unregister-SPMTMigration session.unregister()
from office365.migration import MigrationSession, MigrationSettings

session = MigrationSession().register(
    context=ctx,  # a SharePoint ClientContext
    settings=MigrationSettings(use_migration_api=True),  # server-side Migration API
)
session.add_task(
    file_share_source="C:/src",
    target_site_url="https://contoso.sharepoint.com/sites/team",
    target_list="Documents",
)
session.start()
print(session.show())
session.unregister()

MigrationSettings mirrors Register-SPMTMigration (permissions, timestamps, versions, filters, user mapping, Azure storage…); the subset the core enforces is mapped by to_options(), which the session applies to every task by default (pass options= to a task, or MigrationSession(options=...), to override). Tasks can also be declared as MigrationTask objects or parsed from the SPMT JSON task format (MigrationTask.from_json(...)). A resolved SharePoint task replays the source list's columns, content types and views onto the (existing) target list before content is copied; pass provision_structure=False to add_task / add_site_task to skip that. This replay happens even when MigrationSettings(use_migration_api=True) routes content through a server-side package, because the package builder does not emit columns, content types, or views today — the target's structure is still captured and replayed over REST first. A package target built directly (no task resolver) does the same when it is handed the snapshot — see SharePointPackageTarget(structure=...) under Provisioning list structure.

use_migration_api=True applies to document libraries. A package can express an SPFile and an SPFolder and nothing else — Manifest.xml has no <List> object — so a plain list has no shape on that path. Rather than failing the run, a list task falls back to the REST adapters (SharePointListTarget) and the target carries a warning saying why; nothing is lost, because a list record has no version history and REST restores authorship and timestamps just as well. The package target also refuses a record outright if one reaches it (ValueError), since it previously wrote the record's dict repr into an SPFile's content — a corrupt file, no exception, no warning.

A pair that disagrees about files versus records is refused when the task is resolved. A file has no list-record shape and a record is not a file body, so a file-share or library source aimed at a plain list used to resolve and then fail every item (TypeError: object is not iterable), and a list source aimed at a document library wrote each record's dict repr into a file named after the item's id — Total: 2 | Success: 2 | Errors: 0, two files named 1 and 2 (both measured). resolve_task now raises a ValueError naming the two lists and what to point the task at, and each adapter refuses the wrong payload shape as well, so a hand-built pair cannot corrupt a target either. Migrate files into a document library and records into a list.

The settings surface is wider than the engine, and that is reported, not implied. MigrationSettings mirrors the cmdlet, but a client-side run cannot do everything SPMT does — it does not migrate the term store, it cannot skip lists by name, its user map is a CSV you supply (no directory lookup) and it has no multi-round or Azure storage plumbing. A knob that silently does nothing is a trap, so MigrationSettings.unhonored() returns one ready-to-print note per setting whose current value asks for behavior the engine does not implement (a setting whose default is what the engine does stays silent), and a session appends them to session.warnings() once:

from office365.migration import MigrationSettings

MigrationSettings(skip_lists_with_name="Archive").unhonored()
# ['skip_lists_with_name=...'] plus the term-group default, which is never honored

Filter lists with the include/exclude arguments of add_task/add_site_task instead, and migrate a term store with StoreManager.export_tree() / import_tree().

Whole-site migration

A MigrationTask(migrate_all=True) covers every list and document library on the source site. session.add_site_task(...) resolves it into one job per list — each with its own manifest and checkpoint, so a large site resumes by list instead of restarting:

from office365.migration import MigrationSession, MigrationTask

session = MigrationSession().register(context=target_ctx)
session.add_site_task(
    task=MigrationTask.sharepoint(source_site_url, target_site_url, migrate_all=True),
    exclude=["Site Assets"],
    manifest_dir="state/manifests",
    checkpoint_dir="state/checkpoints",
)
session.start()

The registered context is the target; a source on a different site collection in the same tenant is reached through an authenticated clone (ClientContext.clone), reusing the registered credentials. Document libraries use the file adapters, other lists use the record adapters. Hidden lists and the lists the service marks as its own -- protected from deletion and flagged IsSystemList, which is how the galleries, the user information list and the taxonomy list are spelled -- are skipped. The site path reads the source lists through LIST_SCOPE_SELECT, because the default list read carries neither of those two facts and a scope decision taken on one would keep every service list.

Subsites are not migrated by default. They are detected and named in session.warnings(), so a whole-site run never quietly ignores them. Pass include_subwebs=True to mirror the source subsite tree on the target and migrate their lists too: each missing target subsite is created from the source's title and web template, and every web is addressed through its own clone (a subweb's sub-collections resolve against the context root otherwise).

The recursion is live-verified, end to end: on a source site whose tree held 41 subsites, each missing target subsite was created from the source's web template with its lists replayed (same titles and base templates), and a two-row subsite list with a custom column came out with that column provisioned, both values intact, the source/target verification reporting OK. Two limits are worth naming: a web-scope site content type is not created — content types are replayed only through the lists that use them (see Provisioning list structure below) — and the mirrored webs are recreations, so their ids, web-part data bindings and version history do not match the source's (pages are copied per web, subwebs included — see Site pages below).

Artifacts a copy cannot carry are named, never dropped silently. Modern site pages and classic wiki pages are copied (see Site pages below), but three categories are outside what a client-side copy can produce — workflow associations, InfoPath forms and business-data (BCS) external lists, plus any page with no copyable body (a publishing page) — so the resolver appends a handoff note for each one it finds (see detect_handoff(web, lists) / handoff_notes(handoff) in office365.migration.sharepoint.handoff, also exported from office365.migration.sharepoint). Detection is cheap and best-effort: two extra reads per web (the web's workflow associations, then one projected lists read that carries each list's declared form URLs and its workflow associations), and a detector that fails degrades to a note instead of aborting the run.

Target lists are created on demand (by title, with the source template type) and, by default, each list's columns, content types, views and container settings are replayed from the source before content is copied (provision_structure=True) — the same snapshot/replay used by capture_list_structure / provision_list_structure (below). Pass provision_structure=False to only create the containers. The server-side Migration API path is per-library and not supported for a whole-site task.

Many lists at once

A whole-site task is a queue of one job per list, and session.start() walks that queue serially by default. For a site of many small lists that is the dominant cost, because each job pays its own fixed overhead — resolve the list, ensure its container, replay its structure, read its items — and none of it overlaps. Pass concurrency to run several jobs at once:

from office365.migration.logging import ProgressReporter

reporter = ProgressReporter(interval=15, emit=print)
session.add_site_task(task=..., manifest_dir="state/manifests", progress=reporter)
session.start(progress=reporter, concurrency=4)

What is shared and what is not is deliberate:

Per job Shared by every job
pending-query queue, form digest, phase/stats authentication context (single-flight token cache)
manifest + checkpoint (<index>-<list>.…) HTTP transport: one connection pool
ProgressReporter counters (derive) retry policy and RateLimiter (pacing)

A ClientContext owns a single pending-query queue, so two jobs cannot use one context concurrently. Each worker therefore gets a clone (ClientContext.clone, which shares the auth context and transport by reference) and the adapters are re-addressed on it (MigrationJob.rebind() → adapter.rebind()): a list is looked up by id, a folder by server-relative path, which costs one read per container and buys a run that is safe on threads. Context-free adapters (filesystem, json) are shared unchanged, so a mixed session keeps working.

This changes when work happens, not what lands on the target: manifests, checkpoints, conflict handling, verification, throttling accounting and warning collection are the same, statuses are returned in registration order, and a job that already completed is still skipped on a re-run. A task failure stops the session as it does serially — after the in-flight jobs finish, so nothing is left half-written. Keep concurrency and --pace together: the limiter is shared, so pacing still bounds the whole run no matter how many workers are in flight.

Resolution reports progress too. add_site_task(progress=...) fires the same Progress heartbeat the runner uses while the plan is resolved — lists, then containers (per target container ensured), structure (per list replayed), and the indeterminate pages, navigation and branding stages — so the run is not silent between the inventory and the first copy.

Capturing list structure

capture_list_structure(sp_list) reads a list's columns (with SchemaXml prepared for CreateFieldAsXml), content types (with their ordered field links), views (with their ordered columns) and container settings (versioning and its limits, moderation, draft visibility, attachments, item-level read/write security, list validation, and — for a document library — minor versions and required check-out) into a read-only, JSON-serializable ListStructure snapshot. Built-in/system and framework columns, hidden content types, and hidden/personal views are skipped, and only GET requests are issued:

import json

from office365.migration.sharepoint import ListStructure, capture_list_structure

sp_list = source_ctx.web.lists.get_by_title("Projects")
structure = capture_list_structure(sp_list)
print(structure.title, len(structure.fields), len(structure.content_types), len(structure.views))

with open("projects.structure.json", "w", encoding="utf-8") as fh:
    json.dump(structure.to_dict(), fh, indent=2)

with open("projects.structure.json", encoding="utf-8") as fh:
    restored = ListStructure.from_dict(json.load(fh))

A snapshot can be inspected, persisted, and later replayed.

Provisioning list structure

provision_list_structure(sp_list, structure) replays a snapshot onto a target list: it creates the missing columns from their SchemaXml, the custom content types (matched by name), and the views, applies the captured container settings, and turns content types on when the snapshot says so. It is idempotent — running it again with the default on_conflict="skip" is a no-op — and it executes the requests, so call it right after the target list exists and before content is copied:

from office365.migration.sharepoint import provision_list_structure

warnings = provision_list_structure(target_ctx.web.lists.get_by_title("Projects"), structure)
for warning in warnings:
    print("structure warning:", warning)

The server-side package target does the same for you when it is handed a snapshot — the manifest carries a file's list item, not the library's columns, so they have to exist before the ingestion job runs. Pass sp_list= (the target library object) and structure=, and configure() — which the runner calls before the first write — replays it; commit() repeats the guard for a hand-built job, and both are no-ops on a re-run:

from office365.migration.sharepoint.package_target import SharePointPackageTarget
from office365.migration.sharepoint.structure import capture_list_structure

target = SharePointPackageTarget(
    target_ctx.site,
    web.id,
    content_uri=...,
    manifest_uri=...,
    sp_list=target_library,
    structure=capture_list_structure(source_library),
)

This is the path for a caller that has no source list to capture from in the same run — a bundle produced elsewhere. A task resolved by resolve_task already replays the structure itself, before it picks the target, so it does not pass one.

Two SharePoint Online REST limits are surfaced honestly rather than hidden:

  • Content type identifiers are not preserved. The server assigns a content type its own Id and ignores the requested one (at both list and web scope), so content types are matched and recreated by name.

Because the identifier changes, an item's ContentTypeId (which carries the source identifier) no longer exists on the target, and SharePoint silently coerces it to the list's default content type. content_type_map captures the source→target identifier so the record projection can rewrite it:

from office365.migration.map import ContentTypeMap
from office365.migration.sharepoint import SharePointListTarget

# The target fills the map while it replays the snapshot (see below), so the
# projection can rewrite every record's ContentTypeId to the recreated one.
target = SharePointListTarget(target_list, content_type_map=ContentTypeMap(), structure=structure)

The whole-site (resolve_site_task) and single-list resolvers build and apply the map automatically, so a task-based migration needs nothing extra.

  • Formatting is replayed after the columns and views exist, and reported when it does not take. Column formatting (Field.CustomFormatter) and row formatting (View.CustomFormatter) are JSON blobs on the entity itself, so they are a MERGE each, written only when the target's differs. A sealed column accepts the MERGE and silently drops the value, so the pass reads both sides back and returns a warning (column 'X' kept its own formatting) instead of claiming success — verification compares the two blobs in its own formatting column. An SPFx field customizer (ClientSideComponentId) is a pointer into a deployed solution: it cannot work on a target that does not have that solution, so it is reported, never copied.
  • A fresh column cannot be attached to a custom content type. Adding a field link for a column that is not already inherited or auto-linked is rejected (Column '<name>' does not exist). Those columns are skipped and returned in the warnings list (and logged) instead of failing the whole provisioning. Captured content types also name every built-in/SharePoint-owned column (Title, FileLeafRef, framework columns) that capture deliberately does not recreate; those are left to the target list's own defaults rather than sent as guaranteed link failures.
  • A view column the target list cannot hold is dropped and reported. A view is recreated with the captured columns the target list actually has, so a pair whose templates disagree on a built-in column (LinkTitle on a plain list, LinkFilename on a document library) provisions the view instead of failing the whole replay on Column 'LinkTitle' does not exist. The drop comes back as view 'X' was created without LinkTitle: the target list has no such column, and costs one projected Fields read per list that carries views.
  • Site columns are registered at web scope before any list is replayed. A site column (one whose Scope is the web's server-relative URL, e.g. /sites/team_in, not the list's) used to be seen only through the lists that use it; provision_list_structure now has a web-level companion in the resolver: provision_web_scope_columns runs once per web, before the per-list replay, and creates every source site column the target web lacks. Ordering is the point — a list's Fields collection includes the site columns it inherits, so the replayed list then reuses the web column instead of minting a second, list-scoped copy of it. Each column is recreated from its source SchemaXml (SourceID/Version stripped by Field.schema_xml_for_copy, because SharePoint rejects a copied origin header), a lookup is repointed at the target list, and a managed-metadata column goes through TaxonomyField.create so it binds the target web's hidden taxonomy list and note field instead of the source's. The create is verified by presence afterwards: SharePoint Online registers the column and then raises SPFieldValidationException ("The object has been updated by another user since it was last fetched"), so the exception is not evidence of failure. Re-running is a no-op (already present).

A web-scope content type is still not created. Content types are replayed per list, by name, description and group — SharePoint Online assigns the new content type its own identifier, so the source's string_id is not preserved — and attaching a fresh site column to a content type is impossible over REST anyway: FieldLinks rejects it (Column '<name>' does not exist) until the column is reachable from the list. A definition no migrated list uses is therefore still missing, and its inheritance by the target web's other lists is not reproduced; create those out of band (CSOM/PnP).

What is left is reported rather than left silent: the resolver compares the two webs after provisioning (compare_web_scope_structure) and appends what the target web still lacks — web-scope structure is incomplete: the target of <url> lacks 1 content type(s): Contract — …. That comparison is read-only (two projected GETs per web), matches columns by internal name and content types by name, and ignores hidden, system, framework and sealed columns, so a note names real gaps rather than platform plumbing. It counts definitions, not user intent: a site collection root also carries app- and feature-provisioned site columns (compliance, taxonomy, community, tenant "modern" UI columns), so a long list means "the target web is not a schema clone of the source web", not "recreate N columns".

  • Field links are replayed in batches, and read back to decide the warnings. All content types of a list are reconciled together: their links are read in one $batch, the missing ones are added in a second, and the links are read again after the write. That round of three requests replaces the one-per-link exchange, which was the bulk of a structure replay (~35 requests for a ten-column, five-view list; ~60 % of them field links). The read-back is not redundant: the adds travel in a change set, which SharePoint applies atomically, so one refused column rolls back its siblings while their sub-responses still answer 200 — the re-read is what lets warnings name every column that is not linked, with the server's reason attached to the column that caused it. Link order is restored per content type, only when the read-back shows the server kept a different order, so a column the server refused is never sent in a Reorder array and a re-run over an already-correct list writes nothing.

A structure-provisioning warning is also recorded on the resolved target adapter, so a whole-site session reports it through session.warnings() instead of only in the log.

Exporting and importing a library as a bundle

A bundle is a directory of content plus one sidecar holding the list's shape:

bundle/
  content/                     # the payload: folders and files, library-relative
    Report.docx
    Archive/old.docx
  Documents.structure.json     # the shape: columns, content types, views, settings

The sidecar sits beside content/, never inside it — a files source migrates every file it finds, so a snapshot in the tree would be copied into the library. save_list_structure / load_list_structure read and write it (the file is plain ListStructure.to_dict() JSON, which capture_structure.py also writes):

from office365.migration import ConflictResolution, MigrationJob, MigrationOptions
from office365.migration.adapters.filesystem import FileSystemSource, FileSystemTarget
from office365.migration.sharepoint import (
    SharePointLibrarySource,
    SharePointLibraryTarget,
    capture_list_structure,
    load_list_structure,
    save_list_structure,
)

# export
job = MigrationJob(
    SharePointLibrarySource(source_folder),
    FileSystemTarget("bundle/content", include_folders=True),
    options=MigrationOptions(conflict_resolution=ConflictResolution.OVERWRITE),
)
job.plan()
job.run()
save_list_structure(capture_list_structure(source_library), "bundle/Documents.structure.json")

# import — the shape is replayed onto the target library before the first upload
job = MigrationJob(
    FileSystemSource("bundle/content"),
    SharePointLibraryTarget(
        target_folder,
        sp_list=target_library,
        structure=load_list_structure("bundle/Documents.structure.json"),
    ),
)
job.plan()
job.run()

The replay is configure()'s job — the hook the runner calls before the first write — so it also happens when the target is used directly, and a re-run is a no-op because provision_list_structure conflicts by skipping. A record bundle imports the same way through SharePointListTarget(target_list, structure=...), which fills a content_type_map you pass in as a side effect so records land under the recreated content types. Measured on a live library pair: the target had no CostCenter column before the import and had it after, with the file content verified OK | source: 1, target: 1, checked: 1, missing: 0, extra: 0, issues: 0.

What a bundle cannot carry is the part the manifest also cannot: workflows, InfoPath forms, publishing pages, alerts, the term store and site-collection settings are handoff notes, not payload (see Fidelity).

List container settings

The snapshot also carries the list's own policy — EnableVersioning, MajorVersionLimit, EnableModeration, DraftVersionVisibility, EnableAttachments, ReadSecurity, WriteSecurity, ValidationFormula / ValidationMessage, plus EnableMinorVersions, MajorWithMinorVersionsLimit and ForceCheckout on a document library — and provisioning replays it as per-property MERGE requests — the differing ones together in one $batch. Four details are worth knowing, all live-verified:

  • Only differing settings are written. The target's current values are read first (one GET), so a list created from the same template — which already carries most of them — is left alone and a re-run writes nothing. This is not only an optimisation: SharePoint rejects some writes whose value is already correct (a version limit while versioning is off, a system library that grants no write), which would otherwise surface as warnings about settings that match.
  • Settings are applied regardless of on_conflict. The snapshot is the wanted policy, and the item copy depends on it: an item's approval status only exists on a list that enables moderation, so enabling it after the items landed would be too late.
  • Order matters. Versioning is applied before its limits, versioning and moderation before the draft-visibility policy, and validation after the columns exist (its formula names them). A version limit whose versioning flag is off in the snapshot is not sent at all — the server rejects it outright (Versioning must be enabled and the number of major versions must be between 1 and 50000).
  • A setting the list cannot accept is a warning, not a failure. A plain list rejects minor versions, their limit and required check-out (The list does not support minor versioning.), which is why those three are captured and applied only for a document library. The differing settings go out in one $batch rather than one request each; because a change set is atomic, a refusal makes the whole write fall back to sending them one by one through their own handles, so one rejection still cannot leak into the next and cannot leave a half-configured list behind a 200 per survivor.

The server canonicalizes a validation formula (=IF([Title]="",FALSE,TRUE) reads back as =IF(Title="",FALSE,TRUE)), so verify_list_structure compares it with whitespace and brackets stripped and reports any other difference as a settings_mismatch.

All twelve names are declared on List by the model — TypeSchema.of(List) answers each of them, and none is read-only — so the vocabulary is the library's. The three verdicts about that vocabulary stay in this package, because each has exactly one reader and a keyword nobody else reads is a promise nothing keeps:

Fact Reads it today What would promote it into the library
Minor versions, their limit and required check-out exist only on a document library _LIBRARY_SETTING_NAMES a call that writes versioning itself, e.g. ensure_list(versioning=...) — it would need the same split, beside is_document_library
A limit is only expressible while its flavour is on (MajorVersionLimit → EnableVersioning, MajorWithMinorVersionsLimit → EnableMinorVersions) _SETTING_DEPENDS_ON an @odata(depends_on=...) keyword, once a second reader exists — a preflight that reports a list's policy is the likely one
A validation formula is rewritten on write comparable_setting a @odata(compare=...) for the member, which would apply the rule to every reader of the formula

Noting them here is what keeps the move a decision rather than a rediscovery: the trigger is a second reader, and until one exists the seam is this module.

Site settings

Replaying a list's structure reproduces its columns, content types, views and container settings — never the web that hosts them, so without this a migrated site would keep the target web's identity where people actually read it (site header, site card, search results). The web-scope settings that are plain writable strings over REST are copied (one projected GET per side, then a single MERGE):

  • Title
  • Description

Only differing values are written, so the copy is idempotent and a re-run does not bump the target web's metadata; an unset source value clears the target's (these properties reject null). Copy is on by default (MigrationSettings.migrate_site_settings="PRESERVE_ALL_SETTINGS", or --no-site-settings in the example); "NONE" leaves the target web alone, and any other value raises rather than silently doing nothing.

from office365.migration.sharepoint import apply_web_settings, verify_web_settings

apply_web_settings(source_web, target_web)  # -> WebSettingsReport(applied={"Title": "team"})
verify_web_settings(source_web, target_web)  # -> [] once the copy landed

The site-settings surface is live-verified on SharePoint Online: the copy wrote only the property that differed, a second run wrote nothing, verify_web_settings was clean afterwards and reported the drift again once the target was restored, and a subsite was copied the same way (including clearing a Description back to the source's empty one).

What a web-settings copy leaves behind, by design:

  • Branding — master page, theme, SiteLogoUrl, alternate CSS. Carried by its own module with its own rules: see Site branding.
  • Site-collection and regional settings — time zone, locale, quotas, sharing capability, hub association. REST exposes no working write path (setting a TimeZone id does not change the web's zone), so they are neither copied nor attempted; the site_settings fidelity dimension carries that note.

Each web is copied through its own context, so with include_subwebs=True a mirrored subsite also gets the source subsite's title and description.

Site branding

Copying content and replaying structure still left the web looking like the target: its own master page, its own theme, no site logo — the first thing a user sees. office365.migration.sharepoint.branding carries the properties REST accepts (MasterUrl, CustomMasterUrl, SiteLogoUrl, AlternateCssUrl, ThemedCssFolderUrl) through apply_web_branding(source_web, target_web): one projected GET per side, then a single MERGE of only the properties that differ, so a re-run is a no-op. verify_web_branding(source_web, target_web) is the read-only half (one finding per property), and verify_site(...) reports it as its own branding dimension next to settings.

Three rules make a blind copy wrong, and each is enforced rather than assumed:

  • every value is a path inside the source site, so it is rebased onto the target's before it is written — copying it verbatim would leave the migrated web pointing back at the site it came from (/sites/team_in/… → /sites/team_out/…, including when the value is an absolute URL). Master pages read back as site-relative paths on every web, so without the rebase every migrated site would report a master-page mismatch;
  • every one of them names an asset, so a value is written only where the target resolves it. A master page or theme folder lives in a gallery this migration never copies (_catalogs/masterpage and _catalogs/theme are hidden lists); a logo may be the site's group image (/sites/x/_api/GroupService/GetGroupImage?id=…), a service endpoint with no counterpart to copy. In both cases the web keeps its own value and the run reports branding: … keeps its own MasterUrl — the target has no …: a dangling master page would break every page on the site, so this is a reported gap rather than a hopeful write. An absolute URL is resolved to its path for that check;
  • only the logo and the alternate CSS can be cleared — an unset source value writes "", which the service accepts and which a second run leaves alone. A blank master page is never written.

Branding is applied after the content, because an alternate-CSS URL may point into a library this run has just populated. Live-verified on SharePoint Online: a MERGE of SiteLogoUrl + AlternateCssUrl on a web is accepted and reads back, writing null clears the logo, MasterUrl reads back as a site-relative path, and a source web's alternate CSS arrived rebased on the target with verify_site reporting branding: 0.

Site pages

A Site Pages library (base_template 119) holds .aspx pages rather than documents, so the item adapters do not migrate it. Both kinds of page it holds are reachable over REST, so the whole-site resolver copies them (resolve_site_task(..., migrate_pages=True), the default):

  1. read the source page library once, projecting office365.sharepoint.publishing.pages.fields.PAGE_FIELDS — the eleven fields a page item carries: identity, both bodies, the layout, and the page metadata the library stores as list item properties;
  2. classify each page — modern when is_modern_page(...) holds: it serializes a canvas, or its content type id derives from "Site Page" (SPBuiltInContentTypeId.SitePage), classic wiki when only WikiField has content, and uncopyable when it has neither;
  3. create the target page — a modern page through POST /_api/sitepages/pages (Web.add_site_page(name, title=...)), a wiki page through List.create_wiki_page(name, body) — and read the new id back (Id; the modern create response carries no Name);
  4. write the page content onto the created item: for a modern page a MERGE of Title, Description, CanvasContent1, LayoutWebpartsContent, BannerImageUrl, PromotedState (only the values the source actually carries, and an empty canvas is left alone because a new page already ships a default one); for a wiki page the source WikiField body plus Title/Description;
  5. publish it when the source page was published — for a modern page POST /_api/sitepages/pages(<id>)/publish, for a wiki page item.file.publish(comment) — then read the body back once to confirm it landed;
  6. point the target web's home page at the copy of the source's (apply_home_page, called at the end of copy_site_pages): the source's WelcomePage is resolved through the copy's renames — a page copied as Home-1.aspx becomes the home page — and written onto the target's root folder only when it differs. A source home page that was not copied is reported, not guessed at, and the target keeps its own.

Four server facts this relies on are live-verified on SharePoint Online: a page created that way is a draft until the publish operation runs (a MERGE of OData__ModerationStatus does not publish either kind of page), a page's CanvasContent1 round-trips byte-identical through an item MERGE (the source canvas is stored already entity-encoded, so no normalization is needed), a WikiField body does too — the server normalizes a body once on create (it wraps it in an ExternalClass div and entity-encodes braces), so a copy reuses the source value verbatim instead of re-authoring the body — and a web's WelcomePage is not writable through the web: a MERGE of it on /_api/web returns success and changes nothing, while the same MERGE on /_api/web/rootfolder moves the home page. That trap is why apply_home_page writes the root folder (Web.root_folder) rather than Web.set_property("WelcomePage", ...).

Copies are add-only — an existing target page is never deleted. The duplicate behaviour is explicit, using the SPMT vocabulary (MigrationSettings.duplicate_page_behavior, or --duplicate-pages in the example): RENAME (default: Home.aspx → Home-1.aspx, never reusing a taken name), SKIP (leave the target page as it is) or OVERWRITE (MERGE onto the existing page). verify_page_copy(source_web, target_web) reconciles the result read-only: a source page counts as delivered when the target has the same name or an identical canvas, anything else is reported as missing or drifted, and a target whose home page is not the copy of the source's is named as such.

The copy is idempotent: a page's body is its identity (a copy writes the source body verbatim and publishes it), so a second run over the same source and target finds its own earlier copy — including the one it renamed around an existing page — and leaves it alone instead of minting Home-2.aspx, Home-3.aspx, … on every run. Such pages are listed in PageCopyReport.unchanged, and the home page designation follows them, so a re-run keeps the site opening on the copy. OVERWRITE and SKIP are unaffected; each target page accounts for at most one source page, so two source pages with an identical body stay two copies.

What a copy still cannot do, stated plainly:

  • Publishing pages, and pages with neither a canvas nor a wiki body, are transformation work, not copy work. They stay a handoff: detect_page_handoff inventories them, and page_handoff_note(handoff, modern_copied=True) names only the ones that were not copied. A copied page is listed in PageCopyReport.copied, with the classic ones also in PageCopyReport.wiki.
  • A client-side web part serializes the source list/library identifiers it binds to; those are not remapped, so a copied page can render a web part pointed at the source. The note reports the web-part count per page. A classic wiki body is copied verbatim, so wiki markup that hard-codes the source site — internal links, wiki-embedded web parts — still addresses the source site; the copy warns per page rather than rewriting the markup.
  • Version history of a page is not restored (REST cannot write it), and a target page that is checked out by someone else cannot be created or overwritten — the create fails with SharePoint's own check-out error and the page is listed under PageCopyReport.failed rather than aborting the run.

These points were exercised live: a run over a source web with three pages copied two — one modern page renamed around an existing target page, and one classic wiki page whose WikiField body (162 characters, braces and an internal link included) read back byte-identical and published to 1.0 — and reported the third as failed because its target name was held by another user's check-out. That same run left the target's own home page alone and said so, because the source's home page (SitePages/CollabHome.aspx) was the page the check-out blocked; a second run with a copyable home page moved the target web's WelcomePage onto the copy and verify_site confirmed it.

Monitoring a run

A session is long and headless: without a progress hook it prints nothing between "Migrating…" and the summary. office365.migration.logging supplies the two pieces an operator needs, and nothing that ties this library to a monitoring product.

Events. Every boundary emits one structured event on the office365.migration logger — run.started/run.completed, plan.started/plan.completed, job.started/job.completed/job.failed, progress — through log_event(...). JsonLogFormatter renders each as one JSON object per line with a stable shape, so a filter never has to parse prose:

{"ts":"2026-02-11T09:12:03.114Z","level":"info","logger":"office365.migration",
 "event":"job.completed","message":"library:/sites/t/Documents: 168 copied, 2 skipped, 0 failed",
 "source":"library:/sites/s/Documents","target":"library:/sites/t/Documents",
 "phase":"completed","success":168,"skipped":2,"errors":0,"bytes_transferred":734003200,
 "duration_ms":184233}

There is deliberately no per-item event: a five-hundred-thousand item library must not produce five-hundred-thousand lines, so items are counted and only failures get a line of their own (job.failed carries the error text). Notes a resolution step collects — a list that lost its field links, a list setting the target refused — go to the caller's warning sink (session.warnings()), which is what a console and a report read; they are also logged at DEBUG for provenance, so a message is never printed twice (once by the logger, once by the caller). A caller that ships logs rather than prints them can emit each of them as a run.warning event (count included) — the example does, under --log-json.

Progress. ProgressReporter is a plain ProgressCallback that reduces the runner's per-item snapshots into a throttled heartbeat with a rate and an ETA — it drops into session.start(progress=...), job.run(...) or any long-running runtime operation:

from office365.migration.logging import ProgressReporter, configure_json_logging

configure_json_logging()  # JSON lines on stderr
reporter = ProgressReporter(interval=15)  # one heartbeat per 15s
session.start(progress=reporter)
print(reporter.summary())  # "1,234/5,000 (24.7%) 41.2/s ETA 1m31s"

The window restarts when a new container (a different total) begins and when the stage changes — a rate measured while planning says nothing about the copy that follows it, which is what used to print an ETA of several hours on a run that was seconds into copying. The rate stays None until there is at least half a second to measure, and close() emits a last heartbeat only when the interval swallowed the final count.

Shipping the log. Emit JSON lines and let the machine's own collector ship them: an OpenTelemetry Collector's filelog/stdout receiver into Loki or Azure Monitor, Promtail, a sidecar, kubectl logs -f. No exporter, SDK or vendor client is imported here — a library must not choose a monitoring product for its caller. Alert on the gaps rather than the totals: a heartbeat that stops (a stalled run), job.failed, a rising error count, or a run.completed whose phase is not completed.

The example wires this up for you: --stats 30 prints a heartbeat per active list, --log-json (or --log-json run.jsonl) writes the events, --log-level filters them.

Throughput. MigrationOptions.concurrency (default 1) makes the runner drive a list's writes through parallel workers, and batch_size (default 100) sets how many items a $batch carries and how often progress/checkpoints flush. The example exposes both (--concurrency 4 --batch-size 50) and prints the measured rate per list — ✓ [ 5/24] Documents 168 copied 1m42s 1.7 items/s 6.1 MB/s, from the duration_ms the session status carries — so a tuning change can be judged on a number instead of a feeling. Concurrency buys the most on many small files and buys nothing on a few large ones, where the upload session is already the bottleneck; it also multiplies in-flight requests, so raise it against a throttling tenant with care (the runtime retries a 429/503 with Retry-After, but the retries are time you do not get back).

Throttling as a measured number

Retrying a 429/503 is transparent by design, which is exactly what makes it misleading: a run throttled on every batch reports the throttled rate, so "is concurrency 8 faster than 4?" has no honest answer. ThrottleMonitor supplies the denominator — it wraps a context's transport (sync and async) and counts responses, 429/503s and the Retry-After seconds the service asked for, without changing retry, backoff or pacing:

from office365.migration import MigrationSession, ThrottleMonitor

throttle = ThrottleMonitor().attach(target_ctx).attach(source_ctx)
session = MigrationSession(throttle=throttle).register(context=target_ctx)
statuses = session.start()

print(throttle.summary())  # "throttled 3/171 response(s) (429: 2, 503: 1), 42s of Retry-After"
print(throttle.snapshot())  # {'responses': 171, 'throttled': 3, 'retry_after_s': 42.0, 'by_status': {...}}

The session logs a throttled event (level warning) whenever the counter grows, carrying the cumulative responses/throttled/retry_after_s/by_status, and adds the same counters to run.completed — so a collector can chart throttling over time, and retry_after_s is the additive cost throttling put on the run. Attaching is cheap and inert: contexts cloned from a monitored one share the wrapper (the whole-site resolver's every web included), and re-attaching replaces rather than stacks. A monitor is the thing to add before changing --concurrency, or the before/after numbers mean nothing.

The proactive lever is pacing rather than retrying: RateLimiter(min_interval=...) puts a floor between requests across all workers of a run, so the tenant's limit is approached instead of discovered:

from office365.runtime.http.throttling import RateLimiter

RateLimiter(min_interval=0.25).bind(target_ctx)  # at most 4 requests/second, fleet-wide

migration_client is the one call that does both for a run: it installs pacing and, on a Graph client, sends x-ms-throttle-priority: low, so the tenant defers the run instead of deferring the interactive users it competes with:

from office365.migration import migration_client

client = migration_client(GraphClient(tenant=tenant).with_client_secret(client_id, secret))
# paced; every request carries x-ms-throttle-priority: low

Configure the client before the run starts and pass it in — the call changes the client it is given, as every with_... does. The header is Graph-only: a SharePoint ClientContext gets pacing and no header, because it signals load with X-SharePointHealthScore, which the same limiter reads.

Resume from the checkpoint and manifest files, never from a log: those are the authoritative record of what has been copied, and a log is best-effort.

Fidelity

Flag Client-side runner Server-side package
preserve_timestamps best-effort (Created/Modified on files, folders and list items) ✓ carried per manifest object (TimeCreated/TimeLastModified)
preserve_authorship best-effort (Author/Editor via EnsureUser) best-effort — resolved PUID users only
preserve_permissions best-effort (same-tenant ACLs: items, a list's own ACLs via apply_site_list_security, web permission levels/groups/role assignments) best-effort — resolves users by PUID; groups / unresolved principals skipped
preserve_versions ✗ (raises) ✓ the runner reads history into the package's <Versions>

The server-side package target is the only one that preserves version history: the runner reads each file's older versions (source.read_version_history) and passes them to PackageBuilder.add_file, which the Migration API promotes into a real version history. That column describes a document library: a package carries files and folders, and a plain list falls back to the client-side column (with a warning) because Manifest.xml has no <List> object. Timestamps ride the same package; authorship (UserGroup.xml principal ids) and resolved-user ACLs (DeploymentRoles + DeploymentRoleAssignments) are applied by the service (see Client-side vs server-side). REST cannot restore version history, so preserve_versions always requires a Migration API package and fails fast on the client-side target. Timestamps and permissions are applied

The version history itself is content-accurate: labels and bytes come back exactly as read (1.0/v1, 2.0/v2, current v3). Per-version dates are not honorable — the service stamps every <Versions> entry with the file's TimeLastModified. Putting TimeCreated/TimeLastModified on individual version entries is accepted and then ignored: asked for 2019-01-01 / 2019-06-01 / 2020-01-01 on one file, a live ingestion job returned 2020-01-01 for every version, the same file-level date a second file carried unpatched. So expect history with the right contents and labels under a single (file-level) date. client-side when the adapters support them — the runner fails fast otherwise, rather than silently migrating without the requested fidelity:

MigrationJob(source, target, MigrationOptions(preserve_timestamps=True, preserve_permissions=True))
  • Timestamps — the client-side targets restore Created/Modified via ValidateUpdateListItem (datesInUTC=True; the same path as ListItem.system_update). This covers files, folder list items and record list items alike.
  • Authorship — the client-side targets restore Author/Editor by resolving each source principal through web.ensure_user and writing a FieldUserValue. The values are resolved and cached, then written in the same system update as the timestamps (or in one final write when timestamps are not preserved): a separate authorship write cost a second request per item and re-approved the document the approval step had just restored. Principals EnsureUser cannot resolve are skipped (the verification reports the resulting mismatch); the server-side package emits UserGroup.xml ids instead.
  • Permissions — the source reads the item's role_assignments; the target breaks inheritance and recreates them, resolving principals by login name and roles by name. Only items with an unique ACL are copied — the REST surface reports inherited role assignments too, and copying those would break inheritance on every item and duplicate the parent ACL. That flag is not part of a plain entity read, so an item loaded before its ACL changed keeps a stale value; the target therefore re-reads it explicitly rather than trusting the cached copy, which would otherwise break inheritance on an item that is already unique (wiping the assignments it was meant to keep). Every principal and role is resolved before anything is written, and inheritance is only broken when at least one assignment resolved: an item whose principals are all unresolvable (cross-tenant accounts, an app principal) would otherwise end up with an empty ACL — inheritance broken, nobody granted — so it is left inheriting and reported instead. A re-run adds only what is missing and never removes an assignment the source does not grant. On the server-side package target, preserve_permissions=True emits DeploymentRoles/DeploymentRoleAssignments for resolved users (matched by PUID) and for SharePoint groups (matched by title); a distribution list, a security group or an unresolved principal is skipped with a warning. Both assume the source logins already resolve on the target — inside one tenant they do. When they don't (another tenant, or normalized logins), hand the run an identity map.
  • Site security (web scope) — with preserve_permissions, the SharePoint resolver also creates each web's custom permission levels, re-creates its custom site groups (flags + membership) and its web-level role assignments. A permission level is what an assignment names, so a target that lacks it cannot restore that grant at all: capture_permission_levels(web) reads each level's BasePermissions as a decimal mask (plus description and order) and a level the site defined itself (RoleTypeKind 0) is created when missing — same mask, same name. Two things are deliberately never written: a built-in level (every site has one; it is matched by RoleTypeKind as well as by name, so a localized target still lines up, and it is reported if genuinely absent rather than created), and an existing level whose mask differs — rewriting what a target site's "Contribute" means would silently change everyone's access, so it is reported and left as-is. Hidden levels ("Limited Access") are skipped: the service grants those itself whenever a scope is broken. The three associated groups (Owners/Members/Visitors) are mapped by name onto the target web's, so a renamed association still lines up; their membership is left to the Microsoft 365 group behind the team site and is not copied. Custom groups are created only when missing, and an assignment already present is left alone — so re-running is idempotent. Same-tenant, matched by login (EnsureUser for users, group name for groups); anything unresolvable is reported via session.warnings() and by verify_site_security, never silently dropped.
  • List security (list scope) — a library whose permissions are unique (the source broke inheritance to restrict it) is a scope neither of the other two reaches: item ACLs say nothing about the container, and the web scope is the web. apply_site_list_security(source_web, target_web) replays those ACLs onto the target list: it breaks the target list's inheritance once (only if it still inherits), adds the source's assignments, and leaves everything else alone, so it is idempotent and a re-run writes nothing. It is called after the content — not by the resolver — because a freshly broken list is briefly unreadable to everyone but the writer, and because a list ACL is only meaningful once the list exists; migrate_site.py calls it right after the run whenever --preserve-permissions is set and prints Permissions: N list(s) carry unique permissions, M applied. capture_list_security(list) is the read-only half (None when a list simply inherits — which costs one extra GET, so it only runs inside a permissions pass). Verified live: a list that granted only the Visitors group Read, with inheritance broken, arrived on the target with exactly that assignment — verify_list_security clean — and the target's list ACLs were not the web's.

Adapters opt in by implementing optional hooks: DataSource.read_permissions(item), DataTarget.apply_timestamps(item), DataTarget.apply_authorship(item), and DataTarget.apply_permissions(item, permissions). To verify that the requested fidelity actually landed, the read counterparts are used too: read_metadata(item) (comparable created/modified/size_bytes) and read_authorship(item) on both sides, and read_permissions(item) on the target. Both the document-library and record-list REST adapters implement all of them.

An item's author/editor are Principals — login_name, system_id (the PUID UserGroup.xml writes), display_name, email, and the site-local numeric user_id the source used to refer to that user (AuthorId/EditorId). The id is a convenience for reading a value back (the ItemReport's author_id column is the only consumer) and means nothing in another site, which is why it lives on the principal rather than as a separate field on the item. An unresolved author is None — the report then leaves the column empty instead of printing an id that no principal carries.

To see what a pair will actually honor before a run, ask for a report — MigrationJob.fidelity(), or MigrationSession.fidelity() for every task:

report = MigrationJob(source, target, MigrationOptions(preserve_timestamps=True)).fidelity()
print(report.summary())  # "not requested: permissions, versions | UNSUPPORTED: timestamps"
print(report.matrix_summary())  # "content=delivered, versions=unsupported, ..."
print(report.to_dict())  # JSON-serializable, for reports/status

fidelity() is informational and never raises. plan() does not stay silent, though: it states the forecast before it enumerates — one plan.fidelity event carrying the matrix, the requested flags and the reason for every dimension that is not delivered — and then fails fast on a requested flag the pair cannot honor, before the enumeration rather than at the first write. Planning a 500 k-item library only to be rejected on item one is the expensive way to learn it. The exception is the runner's own check, so the message is identical either way. MigrationSettings.preserve_timestamps / .preserve_permissions feed the matching options into every session task by default.

Mapping identities (IdentityMap)

Authorship and ACLs are principals, and a principal is a tenant-local fact: on another tenant i:0#.f|membership|anna@contoso.com is nobody, and the source PUID is meaningless. IdentityMap translates them. The engine applies it to the item's author/editor before the write (the server-side package consumes them while building UserGroup.xml) and to every ACL entry as it is applied — so it works the same for the REST and the package target, and no adapter knows about it.

from office365.migration import IdentityMap, MigrationOptions, UnmappedPolicy

identity = IdentityMap.from_csv(
    "users.csv",  # source,target[,system_id]
    domain_rewrites={"contoso.com": "fabrikam.com"},  # the "same people, new domain" case
    unmapped_policy=UnmappedPolicy.FALLBACK_OWNER,
    fallback_owner="migration-admin@fabrikam.com",
)
job = MigrationJob(
    source, target, MigrationOptions(preserve_authorship=True, preserve_permissions=True, identity_map=identity)
)
job.plan()
job.run()
print(identity.unmapped)  # ['contractor@partner.example', ...]

Resolution is three rules, cheapest first:

  1. Exact — the source login, its claims tail (…|anna@contoso.com) or the CSV's own key, compared case-insensitively. A CSV row therefore matches anna@contoso.com and i:0#.f|membership|anna@contoso.com.
  2. Domain rewrite — swap the e-mail domain, keeping the rest of the login and the claims prefix. Exact domains only (contoso.com, not *.contoso.com), so the rule never guesses at a subdomain hierarchy.
  3. Unmapped policy — skip (default: authorship is cleared, the ACL entry is not applied), fail (raise UnmappedPrincipalError at the first miss), or fallback_owner (substitute one known account).

Every miss is kept in identity.unmapped and counted in the run report (identity_unmapped, identity_unmapped_principals, identity_policy), so a skip is a reported loss rather than a silent one. Three honest limits:

  • There is no directory lookup. The map never guesses from display names or e-mail equality on the target; a name it was not given is a name it does not know. MigrationSettings.user_mapping_file is the only source of identities (automatic_user_mapping=False turns mapping off).
  • Groups are not mapped. Only user principals are; a group assignment arrives unresolved, exactly as before.
  • The source PUID is dropped when a principal is remapped (a PUID identifies a user in its own tenant, and a package that carried the old one would emit an unresolvable SystemId). Supply the third CSV column when the target PUID is known; otherwise the REST target resolves by login via EnsureUser.

Verification compares the mapped identity, not the source login: the source's read_authorship reports what the run intended to write, so a target that failed to apply the mapped principal is still caught — and a run with no map is unchanged.

Fidelity matrix

report.dimensions reports every dimension the engine models — each delivered, best-effort, unsupported, or handoff — so a gap is never implicit. The matrix is read from the port's capability flags (DataTarget.supports_versions/supports_authorship/supports_attachments, DataSource.supports_attachments, all False by default) and from the optional hooks (apply_timestamps, read_permissions/apply_permissions); a non-delivered dimension always carries a reason in report.dimension_notes.

That probe only knows SharePoint's shapes, so an adapter declares its own matrix instead when the product is not SharePoint: a mapping of dimension -> (status, reason) on the fidelity_dimensions attribute of either adapter (the source is asked first, because the source names the product). A declaration replaces the probe rather than being merged with it — a mailbox has no views or navigation, and reporting them would be noise — and an unknown status name raises instead of being reported, so a typo surfaces at the first forecast. The Outlook tables live in office365/migration/outlook/fidelity.py.

The table below is the shipped SharePoint pairs' expected output.

Dimension Library → package Library → REST List → REST
Content delivered delivered delivered
Versions best-effort (history content, labels and bytes; every version carries the file's TimeLastModified because the service ignores per-version dates) unsupported unsupported
Created/Modified delivered delivered delivered
Author/Editor best-effort (resolved PUID users only) best-effort (resolved users only) best-effort (resolved users only)
Permissions best-effort (same-tenant, resolved users and SharePoint groups) best-effort (same-tenant) best-effort (same-tenant, unique ACLs only)
Site groups / web role assignments n/a (whole-site resolver only) n/a best-effort (same-tenant; custom groups + membership, associated-group membership excluded)
Attachments n/a n/a delivered (content only; system metadata not settable)
Columns / content types / views / list settings unsupported by the adapters — replay them with provision_list_structure; the whole-site resolver registers web-scope site columns first (provision_web_scope_columns), while a web-scope content type stays reported (verify_site(..., web_scope_structure=True)) same same
Lookup / Person / MMD values unsupported (values copied verbatim) unsupported (values copied verbatim) best-effort (single- and multi-value Lookup remapped across migrated lists; Person by login; MMD carried by term id; multi-choice by value)
Check-out / draft / approval state unsupported best-effort (approval status + comments carried for files and list items when moderation is enabled; a draft file's Modified cannot be restored with it; a checked-out file is copied from its last checked-in version and a file on an un-published (minor) version is copied as its draft — both reported as warnings, since the readable version is all REST exposes) best-effort (approval status + comments carried when the list enables moderation; check-out and in-progress draft state are not)
Navigation / pages / web parts navigation and pages are web concerns, not adapter concerns: the whole-site resolver copies quick launch/top nav (migrate_navigation) and both kinds of page (migrate_pages) — a modern site page's canvas, title, description, layout, banner and promoted state, a classic wiki page's WikiField body — then publishes the ones the source had published; publishing pages stay a handoff, wiki markup is not rewritten, and a serialized web part keeps the source list/library ids it binds to same same
Site settings (web scope) each web's Title/Description are copied onto the target web when they differ (migrate_site_settings), as is its branding (master page, logo, alternate CSS, theme folder) — rebased onto the target's path and only where the target has the asset; the site-collection/regional settings and activated features are not carried over REST, only compared and reported (compare_site_scope, verify_site(..., site_scope=True)) same same
Workflows / InfoPath forms / BCS external lists not migrated (the resolver reports every association, InfoPath form and external list as a handoff) same same
Sharing links unsupported (following tenant policy; not recreated per item) same same

A declared matrix names the dimensions that product actually has, so it is shorter:

Dimension Mail export Mail import Calendar export Calendar import Contacts export Contacts import
Content delivered (MIME bytes + sidecar) delivered delivered delivered delivered (vCard 3.0 bytes + sidecar) delivered
Folder / series structure delivered (folder path + display chain) delivered (folder chain recreated) best-effort (one item per series master; occurrences and cancellations are opt-in) delivered (seriesMaster with Graph's own recurrence) delivered (folder path + display chain) delivered (folder chain recreated)
Created/Modified delivered (payload file mtime; the sidecar keeps both dates) unsupported (receivedDateTime is not settable — Exchange stamps the import time) delivered (DTSTAMP is the event's lastModifiedDateTime) unsupported (Exchange assigns created/modified) delivered (payload file mtime; the sidecar keeps both dates) unsupported (Exchange stamps the import time)
Item state delivered (read state, categories, importance, draft flag) best-effort (one PATCH replays read state, categories and importance; the follow-up flag is not) delivered (show-as, sensitivity, importance, reminder, categories) delivered delivered (categories and every office field) delivered (categories via restore_categories)
Attendees n/a n/a delivered (ATTENDEE/ORGANIZER lines) best-effort (dropped unless restore_attendees=True: adding them makes Exchange invite every attendee) n/a n/a
Attachments delivered (inside the MIME) delivered unsupported (only hasAttachments is recorded) unsupported unsupported (a photo would cost a GET /photo per contact; the entity has no hasPhoto column) unsupported
Permissions unsupported unsupported unsupported unsupported unsupported (delegation is mailbox policy, not a contact property) unsupported (same)
Versions unsupported unsupported unsupported unsupported unsupported (Graph exposes no contact history) unsupported (same)

Columns, content types, views and container settings are deliberately not carried by the item adapters: they are schema/policy, and the SharePoint resolver replays them over REST with provision_list_structure (whole-site and single-list resolution do this by default). The matrix reports the adapter-level truth. Replay is per list, with one web-level exception: a site column (its Scope names the web, not the list) is first registered at web scope by provision_web_scope_columns, so a list that carries it reuses that definition instead of a list-scoped copy; a custom content type used by the list is recreated on the target list. A content type that no migrated list uses is not created, and REST would not let a fresh site column be linked to a content type anyway (Column '<name>' does not exist) — see Provisioning list structure.

Site groups and web-level role assignments are likewise not an adapter dimension: describe_fidelity models a source/target pair, whereas site security is a web concern applied by the resolver (resolve_site_task(..., preserve_permissions=True) → apply_site_security). It is therefore listed here for completeness but is not part of report.dimensions; verify_site_security(source_web, target_web) reconciles it.

Navigation is also a web concern applied by the resolver: with migrate_navigation on (the default), apply_navigation copies each web's Quick Launch and top navigation bar additively (source links rewritten to the target; dynamic nodes such as Recent are skipped), and verify_navigation(source_web, target_web) reconciles the copyable links. Site pages follow the same pattern (migrate_pages, on by default): the resolver calls copy_site_pages(source_web, target_web) and verify_page_copy(source_web, target_web) reconciles the page bodies (canvases and wiki bodies). The navigation dimension is best_effort for that reason: navigation, modern pages and classic wiki pages are carried, publishing pages stay a handoff, wiki markup is not rewritten, and a modern page's client-side web parts keep the source identifiers they serialize — their data bindings are not remapped. The resolver makes the remaining gap visible: detect_page_handoff(source_lists) finds the page libraries and classifies each page, then page_handoff_note(handoff, modern_copied=...) appends a warning naming the pages that were not copied and the client-side web-part count, so a run never silently drops them (detect_page_handoff / page_handoff_note are also exported for reuse).

The three other handoff categories are detected the same way, by detect_handoff(web, lists):

  • Workflows — the web's workflow_associations plus each visible list's WorkflowAssociations (expanded on the same projected read). A workflow definition, its association and its running instances are all server-side state; nothing about them is copyable over REST.
  • InfoPath forms — a list whose declared new/edit/display form URL points at FormServer.aspx, carries an XsnLocation= parameter, or names a .xsn template. The stock platform forms (Forms/Upload.aspx, Lists/X/NewForm.aspx, _layouts/15/aclinv.aspx, the _catalogs pages) are matched out, so a normal library is never flagged. The .xsn template itself lives in the site's form templates and is not carried either.
  • BCS external lists — a list whose BaseTemplate is 600 (EXTERNAL_LIST_TEMPLATE). It is a view over a BDC model, so the resolver skips it (it does not even create a hollow list) and reports it; a BusinessData column inside a migrated list is likewise skipped by the structure replay with its own warning — replaying its schema would create a column that resolves nothing on the target.

All notes are aggregated per web, sample the first five names and end with and N more, and name the remediation (remediate out of band): the migration reports the gap rather than pretending a copy is complete. Like site security and navigation, these are web concerns applied by the resolver, so they are listed here for completeness but are not part of report.dimensions.

Server-side migration

For version history, content is packaged and ingested server-side. The package layer builds the Migration API manifest XML (Manifest.xml / ExportSettings.xml / SystemData.xml / UserGroup.xml) from a document library's files and folders:

from office365.migration import MigrationJob
from office365.migration.adapters.filesystem import FileSystemSource
from office365.migration.sharepoint.package_target import SharePointPackageTarget

# Provision SharePoint-owned Azure containers (SAS URIs + encryption key)
containers = ctx.site.provision_migration_containers().execute_query().value

target = SharePointPackageTarget(
    ctx.site,
    web_id,
    content_uri=containers.DataContainerUri,
    manifest_uri=containers.MetadataContainerUri,
    encryption_key=containers.EncryptionKey,
)
job = MigrationJob(FileSystemSource("src"), target)
job.plan()
job.run()  # stages the package and submits the ingestion job
target.monitor()  # polls GetMigrationJobProgress

MigrationServerJob can also be driven directly (submit / submit_encrypted / progress / status_fn / monitor / monitor_async). The wait itself is job.job_wait(job_id) — a MigrationJobWait, which is the library's shared long-running-operation wait specialised to "one poll = read the job's counters". monitor() is that wait with raise_on_failure=False: a job that ends failed returns "failed", because a monitor reports what happened rather than judging it, while a job that never ends raises OperationTimeoutError (a TimeoutError). Holding the wait instead of calling monitor() buys the stream, the cancel and concurrency:

waits = [job.job_wait(job_id, async_status_fn=job.status_fn_async()) for job_id in job_ids]
for wait in waits:
    for status in wait.stream():  # or: async for status in wait.astream()
        print(wait.job_id, status.status, status.percentage_complete)

status_fn / status_fn_async each carry their own paging token, so a wait owns one job's append-only event log. Supplying a sync status_fn to an async wait bridges it with asyncio.to_thread — the read is a blocking GET — while an async reader cannot be used from wait(); the wait says so rather than blocking the loop quietly.

With encryption_key set, the staging AES-256-CBC encrypts every content and manifest blob (unique random IV, stored as the base64 IV blob property) — which SharePoint-provided containers require. For your own (BYO) containers, omit it and use Site.create_migration_job instead of the encrypted variant.

The builder covers the document-library subset — the library, folders, files, and file versions. Version history is carried through MigrationJob whenever the source exposes read_version_history (the SharePoint library source does); the runner reads it into each item and the package emits <Versions>. It emits no columns, content types, or views: provision those client-side first (see Provisioning list structure). The source also skips the library's own Forms/ scaffolding, which SharePoint recreates on the target. The end-to-end import is validated against a live tenant for file/folder content, version history, authorship (Author/ModifiedBy), and — with preserve_permissions=True — same-tenant ACLs for resolved users and for SharePoint groups. Each UserGroup.xml user carries its PUID (SystemId) in its canonical 16-digit hex form: normalize_puid strips a 0x prefix or stray whitespace rather than carrying it into an attribute the service parses as a number. Each granted level is emitted as a DeploymentRoles <Role> with a fresh non-system id and a decimal PermMask, and is matched by Title to the target's existing permission level, so no duplicate is created.

SharePoint groups are grantable, and need an owner. A role assignment whose member is a PrincipalType.SharePointGroup is captured with its title (the normalized default:Members name would match nothing on the target) and emitted as a <Group> plus a PrincipalId grant, so it applies to the target's group of the same name. Live-verified end-to-end: a custom group and a default Visitors grant both survived an ingestion job. One requirement is not optional — a <Group> must name an Owner, and a group emitted without one fails the entire job at schema validation, not just that grant (The required attribute 'Owner' is missing). A group's owner may itself be a group (a team site's Visitors group is owned by its Owners group, which owns itself), so the owner chain is read from the source and emitted; when no owner can be read at all, the group is written self-owned rather than dropped. Two honest limits: the import does not rewrite the owner of a group that already exists on the target (measured), and whether a group the import creates takes the manifest owner was not tested. A distribution list, a security group or an unresolved principal is not a site group, is site- or tenant-scoped differently, and is skipped with a warning — as before.

Storage & vendor neutrality

Server-side ingestion is the only leg that needs Azure — and that is Microsoft's requirement, not ours. The Migration API reads content/manifest blobs from Azure Blob Storage; it cannot read from local disk or S3.

Move Path Azure? Fidelity
M365 → filesystem / S3 / other client-side adapters no content + best-effort timestamps/ACLs
filesystem / other → M365 client-side adapters no content + best-effort
filesystem / other → M365 server-side Migration API (document libraries) yes versions + authorship (ACL best-effort)
M365 → M365 either only for server-side —

You don't need an Azure account

Site.provision_migration_containers() provisions SharePoint-owned containers — no extra cost, and without the need to manually set up in the Azure admin console. It returns the container URIs (with SAS tokens) and the encryption key. The [azure] extra is only the client library used to push blobs into whatever container you were handed — it is not an Azure subscription:

pip install office365-rest-python-client[azure]

Step by step

from office365.migration import MigrationJob
from office365.migration.adapters.filesystem import FileSystemSource
from office365.migration.sharepoint.package_target import SharePointPackageTarget

# 1. Provision SharePoint-owned containers (no Azure account needed)
containers = ctx.site.provision_migration_containers().execute_query().value
#    containers.DataContainerUri     -> content container (SAS)
#    containers.MetadataContainerUri -> manifest container (SAS)
#    containers.EncryptionKey        -> AES256CBC key

# 2. Point the target at the containers (staging is swappable — see below)
target = SharePointPackageTarget(
    ctx.site,
    web_id,
    content_uri=containers.DataContainerUri,
    manifest_uri=containers.MetadataContainerUri,
    encryption_key=containers.EncryptionKey,
)

# 3. Migrate: items -> manifest XML -> staged blobs -> submitted job
job = MigrationJob(FileSystemSource("src"), target)
job.plan()
job.run()
print(target.job_id)

# 4. Poll GetMigrationJobProgress until the job is terminal
target.monitor()

The Staging seam

The package (documented XML + content blobs) is vendor-neutral bytes; where they land is a strategy. Pass staging= to SharePointPackageTarget:

  • FileSystemStaging — writes manifest/ + content/ to disk (no Azure; tests, inspection, or handing the package to another uploader);
  • BlobStaging — Azure Blob (what create_staging(...) returns today);
  • create_staging(content_url, manifest_url) — the factory that picks a backend by container host. It is the extension point: a future S3 / Azure Files staging slots in here without changing callers.
from office365.migration.package import FileSystemStaging

# Build + write the package to disk (no Azure, no submit)
target = SharePointPackageTarget(ctx.site, web_id, staging=FileSystemStaging("pkg/content", "pkg/manifest"))
package = target.stage()

When you don't need Azure at all

If you don't need version history or ACLs, skip the package entirely and use the client-side adapters — they move content over REST in either direction, including to the filesystem (see Fidelity).

Verification & reports

job.verify() returns a VerificationReport and job.export_reports(dir) writes Summary/Item/Failure/Fidelity reports as CSV + JSON. The last one is the run's promise recorded next to its results: one row per modeled dimension with its status and, when it is not delivered, the reason — so the hand-off pack states what was not carried instead of leaving the client to discover it. Verification is layered and explicit about what it could not confirm:

Check When Finding bucket
Presence (expected vs found paths) always missing / extra
Content checksums always, on a spot_checks sample mismatches (or unverifiable if a side exposes no comparable hash); a checked-out source file's pending edits, and the published version an un-published draft supersedes, are invisible to both sides and are reported by the source instead
Timestamps (Created/Modified) when preserve_timestamps was requested mismatches / unverifiable (attachments: content-only, reported once; folders: compared on the folder resource, applied last)
Authorship (Author/Editor) when preserve_authorship was requested mismatches / unverifiable
Permissions when preserve_permissions was requested mismatches / unverifiable
Versions when preserve_versions was requested always unverifiable over REST

For SharePoint list items the source and target are paired by the MigrationSourceId marker (see Migrating list items); attachment presence and content (md5) are checked, and each record's scalar content columns are hashed on both sides, so a field-value difference is a real mismatch rather than an unverifiable caveat.

Record timestamps and authorship are written as a system update — CSOM ListItem.UpdateOverwriteVersion, exposed over REST as ValidateUpdateListItem(..., bNewDocumentUpdate=True). That writes Created/Modified/Author/Editor without creating a new version, so a migrated record keeps the single version it arrived with instead of gaining one version per fidelity write. (Passing bNewDocumentUpdate=False would create a new version for every write.) Both property groups travel in that one call — authorship is resolved first and folded in, rather than paying a system update of its own that the timestamp write would then repeat. Because SharePoint has no client-side path to set an attachment's own system metadata (an attachment has no backing list item), attachments are migrated content-only: they are presence- and md5-checked, and the timestamp/authorship/permission diffs exclude them with a single unverifiable note rather than one caveat per attachment.

Office Open XML documents (.docx/.xlsx/.pptx/…) that md5-differ are compared canonically before being called a mismatch: SharePoint re-serializes them on upload (document property promotion/demotion rewrites docProps/, customXml/ and adds [trash]/ parts), so a byte hash of a client-side REST round-trip is not stable. If every part SharePoint does not own matches, the file is reported as an unverifiable caveat rather than a false mismatch; a difference in any other part is still a real mismatch.

Presence ignores each library's SharePoint-owned scaffolding on both sides — Forms/, desktop.ini, and on Site Assets the __siteIcon__.png site icon and the auto-provisioned <web title> Notebook folder — so it is neither migrated nor reported as missing/extra.

A requested check that could not run lands in report.unverifiable — report.ok is then still True when nothing contradicted it, but report.complete is False. This is deliberate: a partial verification is never silently reported as a pass.

report = job.verify()
print(report.summary())  # "OK | ..." / "PARTIAL | ..." / "MISMATCH | ..."
print(report.to_dict())  # JSON-serializable evidence
assert report.complete

What a verification compares

A migrated item is never read back byte-for-byte: Exchange and Graph re-spell what they store — an address-only attendee comes back named after its address, a dateTime fraction goes from seven digits to six, a label list keeps no order. So a verification compares meaning, and what counts as the same meaning is declared on the field it is about, as a Compare rule:

Declaration Says
Compare(casefold=True) the service does not distinguish Bob@x from bob@x
Compare(fallback="address") a name the sender left out reads back as the address
Compare(default="required") an absent type is required
Compare(ignore=True) a field the service owns, not the migration
Compare(unordered=True) a list whose order means nothing
Compare(format="date") / "wall-clock" / "text" the reduction that makes two spellings one value
Compare(key=True) a value with this field empty is nobody: it drops out of the list

to_json(COMPARABLE) applies those rules and the one further reduction every field shares (unset, empty, zero and false say nothing, so they go), and a digest of the result is a digest of the meaning. The same three operations spell every payload a migration writes or reads, whichever family it is in:

from office365.runtime.odata.comparable import COMPARABLE, digest
from office365.runtime.odata.v4.json_format import V4JsonFormat

body = event.to_json(V4JsonFormat("none"))  # a request: what the sender changed
state = event.to_json()  # a response or a sidecar: as the model holds it
same = event.to_json(COMPARABLE)  # the meaning, for comparing
digest(same)  # ... hashed: the value a verification records

copy = Event(context).hydrate(state)  # and back: a model from what was written

A profile can be narrowed to the fields one run is responsible for, naming Python attributes so that a misspelling raises instead of quietly spelling nothing:

COMPARABLE.scoped(Contact, only=("email_addresses",), without=("categories",))

COMPARABLE is never a request. A Compare rule may synthesize a value the sender never wrote (fallback, default), because a digest is of the meaning; a create body may not. That is the only reason two profiles exist rather than one.

Verifying list structure

Content validation never looks at the container. To confirm that a target list reproduces the source's columns, content types and views (read-only; issue GETs only), compare the two captured snapshots:

from office365.migration.sharepoint import verify_list_structure

report = verify_list_structure(source_list, target_list)
print(report.summary())  # "MISMATCH | Projects | columns: 2, content types: 1, views: 0, field links: 1, ..."
if not report.ok:
    print(report.to_dict())

Only missing structure is reported: a freshly created target list legitimately carries extra built-in columns, content types and views, so "extra" entries are not findings. Field links that could not be attached (the REST limit described above) show up as missing_field_links.

That comparison is limited to the columns the migration can recreate. A content type's links also name built-in and SharePoint-owned columns — Title, FileLeafRef, and the framework columns SharePoint provisions for itself (MediaServiceMetadata, MetaInfo, _VirusStatus, …) — which are hidden or non-deletable and therefore neither copied nor linked: linking one is a guaranteed server error. Those names are dropped at capture time (and logged at debug level), so a library like Site Assets reports field links: 0 instead of a permanent handful of links to columns no migration was ever going to create.

A column that both lists have is compared attribute by attribute, not just by name: the type, Required/Indexed/EnforceUniqueValues flags, length, format, rich text, the choice list, the default value, a calculated or validation formula and its message, and a calculated column's field references are read out of each SchemaXml and must match, otherwise the difference lands in column_mismatches. Only attributes that describe the containing site or are assigned by the server are ignored — the owning list id, a lookup's target list, a taxonomy column's hidden note list, the column id and its storage column/ordinal — because a faithful re-create legitimately changes those. A formula is compared with brackets and whitespace stripped, the form its own read-back takes.

Verifying site settings

A copied web title is a one-line check (read-only, two projected GETs):

from office365.migration.sharepoint import verify_web_settings

for note in verify_web_settings(source_web, target_web):
    print(note)  # "site settings: …/sites/team_out Title is 'team_out', source has 'team'"

Branding is checked the same way — verify_web_branding(source_web, target_web) — comparing the source's paths rebased onto the target site, so a master page that moved from /sites/team_in/… to /sites/team_out/… is not reported as drift while a property that genuinely differs still is.

The example's post-run reconciliation calls it for every migrated web, so a run whose settings copy silently failed (an app principal without web write access, for instance) shows the difference instead of reading as a clean pass. Anything a copy cannot carry — a master page or theme the target lacks, site-collection and regional settings — is named by the site_settings fidelity dimension rather than reported as drift.

Verifying site scope

Two site-level facts decide what the target site can do, and a migration replays neither. They are compared rather than assumed:

from office365.migration.sharepoint import compare_site_scope, site_scope_note

diff = compare_site_scope(source_ctx, target_ctx)  # read-only, both sides
print(site_scope_note(diff, "https://…/sites/team_in"))  # None when nothing is missing

compare_site_scope reads the features activated on the web and on the site collection, plus the comparable regional settings (time zone, LocaleId, work days, first day and week of the year, calendar, AdjustHijriDays, date/time separators, Time24, DateFormat). Only what the source has and the target lacks is a finding: a feature the target activated on its own is carried in to_dict() under extra_web_features / extra_site_features, but it is not a fidelity loss and never turns the report into a mismatch.

Neither is repaired, for two different reasons. Activating a feature has side effects the source never asked for at this point in a run — a publishing feature installs page layouts and swaps the master page — and deactivating one is not something a re-run undoes, so it stays an explicit out-of-band decision: Enable-SPFeature with the GUID the note prints. Regional settings have no working REST write path (setting a time zone id does not change the web's zone), which is also why the settings copy never attempts them.

verify_site(..., site_scope=True) runs this as the site scope dimension, next to branding, and it is on by default — a run that is silent about features is reporting that the target has all of the source's, not that nobody looked.

Both paths were exercised against a live tenant: a migrated pair reports site scope: 0, and two unrelated sites report 22 missing web features and 34 missing site-collection ones. A regional difference is only unit-tested — a tenant cannot be talked into one over REST, which is the whole reason it is reported.

Verifying site pages

Pages are not files with a hash, so the check compares the artifact that carries the content — the canvas of a modern page, the WikiField body of a classic one:

from office365.migration.sharepoint import verify_page_copy

notes = verify_page_copy(source_web, target_web)  # read-only
for note in notes:
    print(note)  # "1 page(s) have no target counterpart: CollabHome.aspx"

A source page counts as delivered when the target has a page of the same name or a page whose canvas is identical — so a deliberately renamed duplicate (Home-1.aspx) is not reported as drift. Everything else lands in one of two findings: pages with no counterpart, and pages whose canvas differs from the source's. Classic pages are not compared at all; they are handoff, and page_handoff_note names them.

Verifying a whole site

A whole-site run touches a web on several axes, and each has its own verifier. verify_site folds them into one report with one verdict — so "did this web migrate?" is a value, not a reading exercise:

from office365.migration.sharepoint import verify_site

report = verify_site(
    source_ctx,
    target_ctx,
    pages=True,
    security=True,
)
print(report.summary())  # "MISMATCH | … | settings: 0, branding: 0, web-scope structure: 0, site scope: 0, pages: 1"
print(report.ok, report.complete)
for note in report.issues:
    print(note)

The checks are opt-in, matching what a run claimed to do: settings, branding, web_scope_structure and site_scope are on by default (they report what a run did not deliver, so silence has to be earned), navigation, pages and security are passed when the corresponding run flags were set, and lists=[(source, target), …] adds per-list structure reconciliation (the same comparison as verify_list_structure). security=True covers both container scopes as two separate dimensions — security (web groups + web role assignments, via verify_site_security) and list security (each list's own ACLs, via verify_list_security) — because a list can be restricted while the web is fine, and the two are fixed by different passes. Everything is read-only.

Two verdicts, deliberately separate: ok is False as soon as any check contradicts the target, while complete is False when a check could not run (access denied on a site groups read, for instance) — a partial verification never reads as a pass. to_dict() is the JSON-serializable form for an evidence pack, and the example's post-run reconciliation prints exactly this report.

See also