Skip to content
i2mintPublic

About

Read an OData v4 entity set as a Python Mapping, standard library only

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

odatadol

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 bulk

It is read-only by construction: every request is a GET, and no function takes an HTTP verb.

The functions under the facade

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
))

Seams

Every function takes the same keyword arguments for what a host may need to swap:

  • fetch=(url, headers) -> (status, body bytes): the transport. Defaults to urllib_fetch (use functools.partial(urllib_fetch, timeout=...) to change its timeout). Tests pass odatadol.testing.FakeService, a scripted server that records every URL it was asked for.
  • credentials=: None (anonymous), BasicAuth(account, password), BearerToken(token), anything with a header() -> str method, 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 the Authorization header ever appears in an error, and credentials never follow a redirect or a @odata.nextLink to another origin (a cross-origin nextLink is refused). A server that would page forever (ignoring $skip, or repeating its nextLink) is stopped with an ODataError.
  • max_url= (default 2000) and page_size= (default 1000).
  • parse_float=: applied to every fractional JSON number; decimal.Decimal keeps every digit (default float).
  • literal= (on ODataReader): writes a key as a $filter literal for store[k] and k in store; defaults to odata_literal (str, numbers, bool, uuid.UUID, datetime objects). For an Edm.Guid key that iteration returns as text, pass literal=str.

Wide reads are one moment, or they raise

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 $skip paging 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 (an ODataError), saying how many records were new, gone or edited. The entity set changed during the read; retrying is safe.

Server quirks it copes with

Each was met against a real server and has a test:

  • $count with Accept: text/plain refused; 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 $filter timestamp 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.

Writes

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.

About

Read an OData v4 entity set as a Python Mapping, standard library only

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages