Read an OData v4 entity set as a Python Mapping, with the standard library only (urllib, json, xml.etree): zero runtime dependencies.
pip install odatadol
from odatadol import ODataReader, BasicAuth
store = ODataReader(
'https://example.com/odata/v4/Shop', 'Products',
key='ProductID', credentials=BasicAuth('reader', password),
)
len(store) # one $count request
store['P0042'] # one filtered request
list(store) # the key column only, in key order
dict(store.items()) # every record, read in bulkIt is read-only by construction: every request is a GET, and no function takes an HTTP verb.
ODataReader is a thin layer over plain functions, each usable on its own. They take the service root and the entity set name, then keyword-only options.
| function | does |
|---|---|
entity_count(service, entity, filter=) |
$count, sent with Accept: */* |
entity_records(service, entity, select=, filter=, orderby=, top=, page_size=) |
records, lazily, paged by $top/$skip or @odata.nextLink, annotations dropped; a read ends on an empty page, so a server that silently caps its pages loses nothing |
entity_columns(service, entity, select=, key=, version=, filter=, max_url=) |
a $select too wide for one URL, read as column groups and joined on key |
entity_metadata(service, entity) |
$metadata as {name: {'type': 'Edm.…', 'nullable': bool, 'annotations': {...}}} |
odata_get(url) |
the one request everything goes through |
from decimal import Decimal
from odatadol import entity_records, entity_metadata
entity_metadata(service, 'Products')['Price']
# {'type': 'Edm.Decimal', 'nullable': True}
newest = next(entity_records(
service, 'Products', select=['ProductID', 'ModifiedAt'],
filter='ModifiedAt ne null', orderby='ModifiedAt desc', top=1,
parse_float=Decimal, # keep every digit of a fractional number
))Every function takes the same keyword arguments for what a host may need to swap:
fetch=(url, headers) -> (status, body bytes): the transport. Defaults tourllib_fetch(usefunctools.partial(urllib_fetch, timeout=...)to change its timeout). Tests passodatadol.testing.FakeService, a scripted server that records every URL it was asked for.credentials=:None(anonymous),BasicAuth(account, password),BearerToken(token), anything with aheader() -> strmethod, or a zero-argument callable returning one of those, so a keychain or file lookup happens only when a request is made. Neither the password nor theAuthorizationheader ever appears in an error, and credentials never follow a redirect or a@odata.nextLinkto another origin (a cross-origin nextLink is refused). A server that would page forever (ignoring$skip, or repeating its nextLink) is stopped with anODataError.max_url=(default 2000) andpage_size=(default 1000).parse_float=: applied to every fractional JSON number;decimal.Decimalkeeps every digit (defaultfloat).literal=(onODataReader): writes a key as a$filterliteral forstore[k]andk in store; defaults toodata_literal(str, numbers, bool,uuid.UUID,datetimeobjects). For anEdm.Guidkey that iteration returns as text, passliteral=str.
A front end such as IIS refuses URLs past about 2,048 characters, answering its own 404 as if the entity did not exist. A $select of a hundred or more fields is past that, so entity_columns (and ODataReader with a wide select=) splits it into groups that fit max_url, reads each in key order, and joins them on the key.
A join of separate reads is only a true record if every read saw the same records in the same state, so:
version=names a property the server rewrites on every edit (a modification timestamp). It is read in every group and must agree per key; otherwise an edit landing between two reads would yield a record that never existed.- With a single group, a narrow key/version read follows it, because
$skippaging over a delete before the cursor and an insert after it skips a live record while the count still balances. - An empty or repeated key is refused (
ODataError): joining on it would merge different records. - Reads that disagree raise
UnstableRead(anODataError), saying how many records were new, gone or edited. The entity set changed during the read; retrying is safe.
Each was met against a real server and has a test:
$countwithAccept: text/plainrefused; odatadol sends*/*.- Property names with spaces must be double-quoted in
$select/$orderby(quote_name), as FileMaker's OData requires. - A percent-encoded
:inside a$filtertimestamp is a syntax error;:travels bare, as RFC 3986 allows. - Raw control characters (a bare CR) inside JSON strings; parsed with
json.loads(strict=False)and kept verbatim.
Not here, and not behind a flag on the reader. If writing (POST/PATCH) is ever added it will be a separate, opt-in class with its own transport seam (method, body and response headers, for ETags), so code holding a reader cannot reach a write by passing the wrong argument, and the reader's fetch=(url, headers) -> (status, bytes) stays as it is. A fetch may do its own retries (on 429 or 503, say); the reader needs nothing more from it.