Two stories: model types now declare the OData metadata they read — every getter names the member behind it, and a type's schema derives its projections from those declarations instead of a hand-kept list — and the migration layer became a set of portable trees, so an export of mail, calendars, contacts or a whole bundle can be inspected, resumed and imported into another tenant.
✨ Highlights
- The model declares what it reads. 10,924 getters name the OData member behind them — 4,013 in
office365.sharepoint, 6,911 across 1,057 Graph modules — andTypeSchemaderives its projections from that index.TypeSchema.of(Web).columnsgoes 39 → 118 names (.navigations2 → 50),List21 → 98,User21 → 81,Group13 → 53. Request bodies are unchanged: a declaration says what a response carries, never what a request must send. - The same index answers a type's identity, its versions and its roles.
@key(...),@version(...),@odata(navigation=True),@odata(system=True),@odata(compare=Compare(format="date"))and afacet=by role (columns_of_facet("permissions")), read from_schemarather than re-scanned. - A value declares itself the way an object already did.
ClientValue.__init_subclass__appliesdataclass(cls, repr=False)once and reads the schema from those fields, with@dataclass_transform()telling a type checker what the decorator used to. - A payload carries only what was set. 117 SharePoint columns are declared
readonlyfrom a live write-and-read-back probe — all 37 ofFile, 43 more onListItem, 13 onFolder— andList(41 of its 98 columns) andWeb(62) answer the same question, soto_json(profile)no longer echoes a server-owned value.hydrate(payload)is the read verb onClientObjectandClientValue, andJsonProfile.scoped(Model, only=..., without=...)narrows any profile to one model. - Mail, calendars and contacts are portable trees.
MailFolderSource/MailExportTargetwithEmlTreeSource/MailboxTarget,CalendarSource/IcsExportTargetwithIcsTreeSource/CalendarTarget,ContactSource/VcfExportTargetwithVcfTreeSource/ContactTarget. A sidecar is the model's own JSON plus provenance, mail is verified bymime_digest(Exchange re-serialises every message it stores), and a resumed run skips what is already on disk. - A bundle is one directory.
BundleTarget/BundleSourceinoffice365.migration.adapterswritecontent/<sha256[:2]>/<sha256>, an append-onlymanifest.ndjsonand abundle.jsonderived from it — content-addressed, idempotent, byte-identical on a re-seal — and the same bundle reads back as a source for another target. A fidelity the bundle cannot carry is refused atplan(), not lost atrun(). - Permissions are declared, and a token can be asked what it proves.
office365.permissionsexportsrequire_permission,declared,Requirement,ResourceNameandindex; a declaration names the API resource its names belong to; 413 tenant-administration operations, 54 migration operations and the SharePoint everyday objects declare a rung (READ→WRITE→MANAGE→FULL_CONTROL).TokenClaimsreads a JWT's audience,scpandroleswithout verifying it, andcatalog(resource)answers what an API actually publishes. - Limits and long-running work are data. One
quotasmodule per product, registered at import,@limitcomposing over a class andlimits_of(cls);Operation.stream()/wait()withRetry-Afterpacing, jitter, progress and cancel, and apartiallySucceededoutcome that is terminal-but-neither. - Faster where it is measured.
to_json()62% less time (#1069),parse_datetime15.6× (#1067),ODataPathBuilder17.8% (#1068),Progresssnapshots 7.7% (#1066).
⚠️ Breaking changes
- A projection built from the old hand-kept lists asked for navigation properties, which
$selectdrops silently. Any code that readselect_all()and friends by hand now readsTypeSchema.of(T).columns;expand_all()is.expansions,select_facet(f)is.columns_of_facet(f),odata_name(a)is.name_of(a)— on aClientValuetoo. - A
ClientValuesubclass no longer takes@dataclass: the base applies it once in__init_subclass__, and a second pass over fields the first consumed makes a different class. Drop the decorator. - Every exported sidecar and archive is the model's own JSON —
team.jsonisteam.to_json(), a mailbox sidecar isMessage.to_json()plus three provenance keys.TeamRecord,ChannelRecord,MemberRecord,TabRecord,AppRecord,PagePayloadandNavNode/WebNavigationare gone,IcsProfileisIcsDialect, and a tree written by a pre-release build is refused by name instead of importing as empty fields. - The permission machinery lives in
office365.permissions;office365.directory.permissionsis gone.declared(method, flow=...)raises rather than answering an empty requirement when the method names nothing under that flow. SP.Userloses the 58 profile getters that duplicatedSP.Directory.User, plusWeb.site_idandoffice365.sharepoint.workflow.task;MigrationItemno longer carriesauthor_id/editor_id(they areitem.author.user_id), and the old keywords raiseTypeError.- Approval columns answer an
SPModerationStatusTypemember, not the raw integer, so a comparison against a literal reads false;SitePage.moderation_statusisreadonlyas well. - The lockfile and the dev tooling need Python 3.11 (the wheel still installs on 3.8): every patched release of
filelockandtornadodeclaresrequires-python >=3.11.
🐛 Notable fixes
order_bynever reached the service:QueryOptionsemitted its Python attribute names, so a sorted read asked for$order_by, which Graph answers200to and ignores. Standard options are spelled as the specification spells them (#1063).- A small file was never attached to a message that already exists: the <3 MB inline path only registered a child on the collection, and that child rides the message create.
- A
$selectread narrowed every later read of the object it was made on:ReadEntityQueryappended its properties to the object's ownquery_options. - A page payload no longer carries an emptied nested value:
SitePageFieldsData(Title="x")serialises as{"Title": "x"}, not{"Title": "x", "CoAuthState": {}}, so a farm that never had that property does not refuse the create (#1060). List.export()exported files: the loop compared the wire's number againstFileSystemObjectType.Fileand0 == FileisFalse. A multi-value column can be updated ({"results": [...]}, the only shape either write accepts), a team site is no longer returned before it exists, and a page's web parts ask for the cast that names the type.- A throttled request waits the interval the service asked for —
execute_query_retryretried on its own backoff and ignoredRetry-After.to_json()no longer emits__-prefixed bookkeeping (__etag,__odata_type), andDateTimeTimeZone.parse()reads a Graphdict, soEventCollection.add(start={...})works. - The term store no longer refuses the type casts the payload carries, activating a directory role no longer announces the base type name, and
SignIn.locationreads the location rather than the status. - The 3.0.0
serverRelativeUrl→server_relative_urlrename is documented as breaking, with the migration shown (#1037).
📄 Full changelog: CHANGELOG.md · 📦 pip install --upgrade office365-rest-python-client