This release is a pre-release and may not be stable for production use.
Sppyte
A tiny, explicit Python helper for working with a SharePoint site through the legacy SharePoint REST endpoints with NTLM authentication. Sppyte1 keeps a very thin abstraction so you can reason about the underlying HTTP calls without surprises.
⚠️ This client uses NTLM (
requests-ntlm) and is for SharePoint on-premises (2013, 2016, 2019, Subscription Edition). SharePoint Online does not accept NTLM.
Table of Contents
Features
Siteconnection with NTLM auth, lazy session creation and a managed form digest (fetched, cached, refreshed before expiry, and retried once if SharePoint rejects it).Listhelper to:- Add / update / delete items
- Use OData params to control item responses
- Add attachments to items
Libraryhelper to:- Add / delete folders
- Upload / download / delete documents
- List folder contents and control output with OData params
- Exceptions that carry SharePoint's own error message, status code and
Retry-After. - Access other SharePoint REST service endpoints as needed.
- Fully typed (
py.typed).
Installation
pip install sppyte
Quickstart
from sppyte import Site
HOST = "https://sharepoint.example.com"
SITE = "/sites/parrots" # server-relative URL of the site
USER = "norwegian"
PASS = "••••••••"
with Site(HOST, SITE, USER, PASS, domain="CONTOSO") as site:
# ---------------- Lists ----------------
pets = site.list("Pets")
# Add an item (entity type metadata is added for you)
created = pets.add_item({"Title": "Norwegian Blue"})
new_id = created["Id"]
# Update the item (uses MERGE)
pets.update_item(new_id, {"Title": "Polly"})
# Attach a file
with open("notes.txt", "rb") as file_handle:
pets.add_attachment(new_id, "notes.txt", file_handle)
# Fetch one item
item = pets.get_item(new_id)
# Query contents (use any OData params you need)
items = pets.get_contents({
"$select": "Id,Title,Created",
"$top": 5,
"$orderby": "Created desc",
})
# Delete the item
pets.delete_item(new_id)
# --------------- Libraries -------------
shared_docs = site.library("Shared Documents")
# Ensure a nested folder path exists
shared_docs.add_folder("Napping", "2025")
# Upload a document
with open("report.pdf", "rb") as file_handle:
unique_id = shared_docs.add_document("report.pdf", file_handle, "Napping", "2025")
# List files in the library root, and in a subfolder
files = shared_docs.list_contents(params={"$select": "Name,TimeCreated"})
folder_files = shared_docs.list_contents("Napping", "2025", params={"$select": "Name"})
# Download a document (bytes)
report = shared_docs.get_document("report.pdf", "Napping", "2025")
# Delete a document
shared_docs.delete_document("report.pdf", "Napping", "2025")
Usage
Connecting
Site(host, site_relative_url, username, password, *, domain=None, timeout=30.0, verify=None, cert=None, session=None)
hostis scheme and host only:"https://sharepoint.example.com".site_relative_urlis the server-relative URL of the site (web):"/sites/parrots", or""/"/"for the root site.domainis the NTLM (NetBIOS) domain, e.g."CONTOSO". Leave it out for a local account.
Creating a Site performs no network I/O; the HTTP session is created on the first request. Malformed options (a path inside host, a site_relative_url without a leading /, a full URL where a path was expected) raise ConfigurationError immediately rather than surfacing later as an opaque 404.
A Site owns the HTTP session. Use it as a context manager to connect eagerly (which also verifies the credentials) and close the session afterwards, or call connect() / close() yourself:
site = Site(HOST, SITE, USER, PASS)
try:
site.connect() # optional: NTLM handshake + first form digest now, not on first use
...
finally:
site.close()
site.list(name) and site.library(name) return lightweight helpers bound to the site. They hold no resources of their own.
Write operations and the request digest
SharePoint requires a form digest (X-RequestDigest) on every write. Sppyte manages it for you:
- it is fetched from
/_api/contextinfoon the first write (or onconnect()), - cached until a minute before SharePoint says it expires (30 minutes by default),
- shared between threads using the same
Site(one fetch at a time), - and, if SharePoint still rejects it with 403, refreshed and the write retried once, provided the body can be re-sent (bytes, JSON, or a seekable file; a non-seekable stream is not retried).
To take control yourself, pass digest= to any write method; the value is sent as-is with no retry:
digest = site.get_form_digest() # always fetches a fresh one
pets.add_item({"Title": "x"}, digest=digest)
site.digest() returns the cached digest (refreshing if needed) and site.invalidate_digest() drops it.
Errors
SppyteError
├── ConfigurationError (also a ValueError) — bad Site options
├── ResponseFormatError — a 2xx response did not contain the expected field
├── SessionError — kept for compatibility; no longer raised
└── SharePointError (also a requests.HTTPError) — any 4xx/5xx
└── AuthError — 401 or 403
SharePointError parses the SharePoint error body, so the message reads HTTP 400: Column 'Foo' does not exist. ... instead of 400 Client Error: Bad Request for url: .... It exposes:
| attribute | |
|---|---|
status |
HTTP status code |
code |
SharePoint error code, e.g. -2130575338, Microsoft.SharePoint.SPException |
message |
SharePoint's message |
retry_after |
seconds from a Retry-After header (429/503), or None |
response |
the underlying requests.Response |
from sppyte import AuthError, SharePointError
try:
pets.add_item({"Title": "x"})
except AuthError as e:
print("check credentials / permissions:", e)
except SharePointError as e:
if e.status == 429 and e.retry_after:
time.sleep(e.retry_after)
else:
raise
Because SharePointError subclasses requests.HTTPError, existing except HTTPError handlers keep working. Transport failures (DNS, connection refused, timeouts) are still raised by requests as requests.ConnectionError / requests.Timeout.
Timeouts, TLS and proxies
timeout(default30.0seconds) applies to every request and can be a(connect, read)tuple. Passtimeout=Noneto disable (not recommended). Individual calls tosite.request(...)can override it.verifyis forwarded torequests:Falseto skip TLS verification, or a path to a CA bundle for an internal certificate authority — no moreREQUESTS_CA_BUNDLEhacks.certis forwarded torequestsfor client certificates.sessionlets you supply your ownrequests.Session(proxies, adapters, retries). Sppyte adds NTLM auth to it only if it has noauth, and never closes it.
site = Site(HOST, SITE, USER, PASS, verify="/etc/pki/corp-root-ca.pem", timeout=(5, 120))
Special characters
List titles, folder names and file names are escaped for OData string literals automatically, so names containing spaces, ', #, %, ? or & work as-is. The helpers are available as sppyte.odata.odata_literal and sppyte.odata.odata_path if you build your own paths.
Extension requests
Endpoints not wrapped by sppyte are reachable through site.request(method, path, **kwargs). It uses the site's session, raises SharePointError / AuthError for non-2xx, applies the default timeout, and takes a site-relative path instead of a full URL. Pass digest=True for writes to get the managed digest (with the 403 refresh-and-retry), or digest="..." to send a specific one:
r = site.request("get", "_api/web/lists", headers={"Accept": "application/json;odata=nometadata"})
lists = r.json()["value"]
site.request(
"post",
"_api/web/lists/GetByTitle('Pets')/recycle",
headers={"Accept": "application/json;odata=nometadata"},
digest=True,
)
List.item_type gives the ListItemEntityTypeFullName needed in __metadata.type when you build list-item payloads yourself.
API
Site
Site(host, site_relative_url, username, password, *, domain=None, timeout=30.0, verify=None, cert=None, session=None)
See Connecting and Timeouts, TLS and proxies. Raises ConfigurationError for malformed options.
| member | |
|---|---|
connect() |
Create the session and fetch a form digest now. |
close() |
Forget the digest and close the session (if sppyte created it). |
list(name) |
A List bound to this site. No I/O. |
library(name) |
A Library bound to this site. No I/O. |
request(method, path, *, digest=None, **kwargs) |
Low-level request; see Extension requests. |
digest(*, refresh=False) |
The managed form digest, fetching/refreshing as needed. |
get_form_digest() |
Always fetch a fresh digest (and cache it). |
invalidate_digest() |
Drop the cached digest. |
form_digest |
The cached digest, or None. |
session |
The requests.Session, or None before first use. |
site_path, host, relative_url, principal |
Normalised connection details (principal is DOMAIN\user). |
List
Obtain with site.list(name) (or List(name, site)). name is the list title as shown in SharePoint.
| member | returns | |
|---|---|---|
get_contents(params=None) |
list[dict] |
Items; at most 100 unless $top is given. |
get_item(sp_id) |
dict |
One item by ID. |
add_item(item, *, digest=None) |
dict |
The created item as SharePoint returned it, including Id. __metadata.type is added unless present; item is not modified. |
update_item(sp_id, patch, *, digest=None) |
int |
MERGE-update; omitted fields are untouched. Returns sp_id. |
delete_item(sp_id, *, digest=None) |
bool |
True; failures raise. |
add_attachment(sp_id, file_name, attachment, *, digest=None) |
int |
attachment is bytes or a binary file object. Returns sp_id. |
item_type |
str |
ListItemEntityTypeFullName, fetched once and cached. |
get_item_type() |
str |
Fetch it again and refresh the cache. |
params is a mapping (or sequence of pairs) of OData options, e.g. {"$select": "Id,Title", "$filter": "Status eq 'Open'", "$top": 500}.
Library
Obtain with site.library(name) (or Library(name, site)). Folder arguments are path segments beneath the library: ("Reports", "2025") addresses Shared Documents/Reports/2025.
| member | returns | |
|---|---|---|
list_contents(*subfolders, params=None) |
list[dict] |
Files directly in the folder (SP.File objects: Name, ServerRelativeUrl, Length, …). |
get_document(file_name, *subfolders) |
bytes |
Download into memory. |
folder_exists(folder, *subfolders) |
bool |
Missing folder is False, not an error. |
add_folder(folder, *subfolders, digest=None) |
bool |
Create a folder (parent must exist). |
add_document(file_name, document, *subfolders, digest=None) |
str |
Upload (overwrites); returns UniqueId. document is bytes or a binary file object (streamed). |
delete_document(file_name, *subfolders, digest=None) |
bool |
|
delete_folder(folder, *subfolders, digest=None) |
bool |
|
server_relative_url(*parts) |
str |
e.g. /sites/parrots/Shared Documents/Reports. |
Migrating to 1.0
List.add_itemreturns the created item (dict) instead of the ID. Replacenew_id = pets.add_item(...)withnew_id = pets.add_item(...)["Id"].Library.list_contents(params, *subfolders)→list_contents(*subfolders, params=None).paramsis keyword-only:docs.list_contents("A", "B", params={...}).ListandLibraryno longer own the session.with site.list("X") as l:andList.connect()/close()are gone — they closed the sharedSitesession. Usewith Site(...) as site:(orsite.connect()/site.close()).- HTTP errors are
SharePointError/AuthError. Both still subclassrequests.HTTPError, soexcept HTTPErrorkeeps working;e.responseis still available. - Requests time out after 30 s by default. Pass
timeout=Noneto restore the old (no timeout) behaviour. site.list()/site.library()/Site(...)no longer connect eagerly. Callsite.connect()or usewithif you relied on the constructor to validate credentials.add_attachmentnow sendsContent-Type: application/octet-stream.sppyte.modelsstill re-exportsSite,List,Librarybut is deprecated; import fromsppyte.sppyte.utils.build_path/odata_literal/odata_pathmoved tosppyte.odata(re-exported fromutils).
Notes
- OData parameters:
get_contentsandlist_contentsaccept any OData query options viaparams($select,$filter,$orderby,$top,$expand). See the docs. - SharePoint caps a list response at 100 items unless
$topis given. - One
Siteper thread is simplest; sharing one across threads is supported for the digest cache butrequests.Sessionitself is not guaranteed thread-safe. - Permissions: reads need read access; writes need Contribute or higher. Insufficient permissions raise
AuthError.
Contributing
This is a pet project to save me time at work and not open for contribution.
Versioning
We use SemVer for versioning. For the versions available, see the tags on this repository and the CHANGELOG.
Authors
- B Jones RFD - Package Noob - B-Jones-RFD
License
-
What's the deal with this name? (S)hare(P)oint (Py)thon Si(te) ↩
Metadata
Release files for sppyte 1.0.0a1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| sppyte-1.0.0a1.tar.gz | 27.2 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sppyte-1.0.0a1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 48.3 kB
Release files / sppyte-1.0.0a1.tar.gz
| Download URL | sppyte-1.0.0a1.tar.gz |
|---|---|
| Size | 27.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1c8a5e9b49c025fd536efb62f142a9f39ec52276765b443dc4c608cd8a995164
|
|
BLAKE2b-256 checksum How to use checksums |
279859b8b050a4e1aec669a52a4d1b986658ee7eb998c7485bc2ac294e906700
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logRelease files / sppyte-1.0.0a1-py3-none-any.whl
| Download URL | sppyte-1.0.0a1-py3-none-any.whl |
|---|---|
| Size | 21.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8fe6265395f9e3b38abd8af1a5e75c61c7686cde999457dd4411c32021009890
|
|
BLAKE2b-256 checksum How to use checksums |
8f42950d1c538338d8f145e9be01f78877f6249daa3215fb9acc5176915b40b1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logSecurity for sppyte 1.0.0a1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for sppyte-1.0.0a1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.