odatadol#

Read an OData v4 entity set as a Mapping, with the standard library only.

>>> from odatadol import ODataReader, BasicAuth
>>> store = ODataReader('https://example.com/odata', 'Products',
...                     key='ProductID', credentials=BasicAuth('me', 'pw'))
>>> len(store); store[42]; list(store)

Under the facade are plain functions, usable on their own:

Read-only by construction: no function takes an HTTP verb.

Functions

odata_literal(value)

value written as an OData $filter literal.

odata_get(url, *[, credentials, accept, fetch])

GET one OData URL and return the body.

urllib_fetch(url, headers, *[, timeout])

The default transport: one GET with urllib.

redact(url)

url without any user:password@ it carries.

entity_url(service, entity, *[, suffix, query])

The URL of an entity set, optionally with a path suffix and query options.

quote_name(name)

A property name as $select/$orderby accept it.

entity_count(service, entity, *[, filter, ...])

How many records the entity set holds now (or match filter).

entity_records(service, entity, *[, select, ...])

Records of an entity set, lazily, page by page, with OData annotations removed.

entity_columns(service, entity, *, select, key)

Every record with every select field, however many fields that is, as one moment.

entity_metadata(service, entity, *[, ...])

The declared properties of one entity set's type, in declaration order.

parse_entity_metadata(body, entity)

The properties of entity in a CSDL document, inherited ones first.

Classes

ODataReader(service, entity, *, key[, ...])

The records of entity at service, as {key value: record}.

BasicAuth(account, password)

An account and its password, for HTTP Basic.

BearerToken(token)

An OAuth2 or similar bearer token.

Exceptions

ODataError(message, *[, status, code])

The service answered with an error.

UnstableRead(message, *[, status, code])

Reads of one entity set disagreed about which records exist, or their versions.

class odatadol.BasicAuth(account, password)[source]#

Bases: object

An account and its password, for HTTP Basic. The password is never shown.

>>> BasicAuth('reader', 'secret')
BasicAuth(account='reader')
>>> BasicAuth('reader', 'secret').header()
'Basic cmVhZGVyOnNlY3JldA=='
class odatadol.BearerToken(token)[source]#

Bases: object

An OAuth2 or similar bearer token. Never shown.

exception odatadol.ODataError(message, *, status=None, code='')[source]#

Bases: RuntimeError

The service answered with an error. Carries the HTTP status and the OData code.

class odatadol.ODataReader(service, entity, *, key, select=None, filter=None, version=None, credentials=None, fetch=None, max_url=2000, page_size=1000, parse_float=None, literal=None)[source]#

Bases: Mapping

The records of entity at service, as {key value: record}.

Parameters:
  • service (str) – The OData service root URL.

  • entity (str) – The entity set name.

  • key (str) – The property records are addressed, ordered and joined by. It must be unique and never empty within the reader’s filter.

  • select (Iterable[str] | None) – The properties a record holds. None fetches all of them; a wide list is split into column groups under max_url for bulk reads.

  • filter (str | None) – A $filter scoping the reader: keys outside it are not in it.

  • version (str | None) – A property rewritten on every edit, checked per key across column groups so a bulk read is one moment (see odatadol.read.entity_columns()).

  • credentials (Union[None, _HasHeader, Callable[[], Any]]) – None, a BasicAuth or BearerToken, or a callable returning one.

  • fetch (Callable[[str, Mapping[str, str]], tuple[int, bytes]] | None) – The transport, (url, headers) -> (status, bytes).

  • literal (Callable[[Any], str] | None) – Writes a key as a $filter literal, for store[k] and k in store. Defaults to odata_literal(); an Edm.Guid key read back as text by iteration needs literal=str. Raises TypeError for a key it cannot write, which the reader reports as missing.

  • parse_float (Callable[[str], Any] | None) – Applied to fractional JSON numbers (decimal.Decimal keeps every digit).

items()[source]#

Every (key, record), read in bulk.

Return type:

ItemsView

values()[source]#

Every record, read in bulk.

Return type:

ValuesView

exception odatadol.UnstableRead(message, *, status=None, code='')[source]#

Bases: ODataError

Reads of one entity set disagreed about which records exist, or their versions.

odatadol.entity_columns(service, entity, *, select, key, version=None, filter=None, max_url=2000, page_size=1000, credentials=None, fetch=None, parse_float=None)[source]#

Every record with every select field, however many fields that is, as one moment.

Splits select into groups whose URL fits max_url, reads each group in key order, and joins them on key. Records come back in key order, each with exactly the select fields, in select order.

**A join of reads is only one moment if every read saw the same records in the same state.** So version (a property the server rewrites on every edit, such as a modification timestamp) is read in every group and must agree per key; without it, an edit landing between two groups yields a record that never existed: the old name beside the new price. And there are always at least two reads: a single group is followed by a narrow key/version read, because $skip paging over a delete before the cursor and an insert after it skips a live record while the count still balances.

Raises:
  • UnstableRead – If reads disagree about which keys exist, their order, or their versions. The entity set changed during the read; retrying is safe.

  • ODataError – If key is empty or repeats: the join would silently merge different records into one.

  • ValueError – If one field cannot fit under max_url at all.

Return type:

list[dict[str, Any]]

odatadol.entity_count(service, entity, *, filter=None, credentials=None, fetch=None)[source]#

How many records the entity set holds now (or match filter). One request.

Return type:

int

odatadol.entity_metadata(service, entity, *, credentials=None, fetch=None)[source]#

The declared properties of one entity set’s type, in declaration order.

entity may name the entity set (resolved through the container) or the type.

Raises:

ODataError – If the service declares no such entity, naming the ones it has.

Return type:

dict[str, dict[str, Any]]

odatadol.entity_records(service, entity, *, select=None, filter=None, orderby=None, top=None, page_size=1000, credentials=None, fetch=None, parse_float=None)[source]#

Records of an entity set, lazily, page by page, with OData annotations removed.

Follows @odata.nextLink when the server sends one, and otherwise pages with $top/$skip until an empty page (a short page may be a server capping its pages without a nextLink, so it is not taken as the end). orderby should name a unique key: paging without a stable order can skip or repeat a record that moves between pages.

Parameters:
  • select (Iterable[str] | None) – Properties to fetch (quoted as needed). None fetches all of them.

  • filter (str | None) – A $filter expression, sent as written.

  • orderby (str | None) – A $orderby expression, sent as written (quote spaced names yourself with quote_name()).

  • top (int | None) – The most records to yield in all; None for every one.

  • page_size (int) – Records asked for per request.

  • parse_float (Callable[[str], Any] | None) – Called on every JSON number with a fraction; decimal.Decimal keeps every digit. Defaults to float.

Return type:

Iterator[dict[str, Any]]

odatadol.entity_url(service, entity, *, suffix='', query=None)[source]#

The URL of an entity set, optionally with a path suffix and query options.

None-valued options are dropped, and : is not percent-encoded.

>>> entity_url('https://example.com/odata/', 'Products', suffix='/$count',
...            query={'$filter': 'ModifiedAt gt 2026-01-01T00:00:00Z', '$top': None})
'https://example.com/odata/Products/$count?$filter=ModifiedAt%20gt%202026-01-01T00:00:00Z'
Return type:

str

odatadol.odata_get(url, *, credentials=None, accept='application/json', fetch=None)[source]#

GET one OData URL and return the body. The only request odatadol makes.

Raises:

ODataError – On any non-2xx status, with the service’s error code and message. The message names the URL (without userinfo) and never the Authorization header.

Return type:

bytes

odatadol.odata_literal(value)[source]#

value written as an OData $filter literal.

>>> odata_literal("O'Brien"), odata_literal(42), odata_literal(True)
("'O''Brien'", '42', 'true')
>>> odata_literal(float('nan')), odata_literal(Decimal('1E+2'))
('NaN', '100')
>>> odata_literal(uuid.UUID(int=1)), odata_literal(datetime.date(2026, 1, 2))
('00000000-0000-0000-0000-000000000001', '2026-01-02')

A str is always a quoted string literal: pass a uuid.UUID for an Edm.Guid and a datetime object for a date or time.

Return type:

str

odatadol.parse_entity_metadata(body, entity)[source]#

The properties of entity in a CSDL document, inherited ones first.

entity may be an entity set, a qualified type (Namespace.Name or Alias.Name) or, when only one schema declares it, a type’s short name. Annotations are merged from inline <Annotation> and out-of-line <Annotations Target=...> elements.

Return type:

dict[str, dict[str, Any]]

odatadol.quote_name(name)[source]#

A property name as $select/$orderby accept it.

>>> quote_name('Status'), quote_name('Unit Price'), quote_name('Category/Name')
('Status', '"Unit Price"', 'Category/Name')

A bare Unit Price is a syntax error at the space; servers that allow such names (FileMaker’s OData, for one) take them double-quoted. A standard OData name is never quoted.

Raises:

ValueError – For a name holding a ", which no quoting can carry.

Return type:

str

odatadol.redact(url)[source]#

url without any user:password@ it carries.

>>> redact('https://user:pw@example.com:8443/odata/Products?$top=1')
'https://example.com:8443/odata/Products?$top=1'
>>> redact('https://u:p@[::1]:8443/x')
'https://[::1]:8443/x'
Return type:

str

odatadol.urllib_fetch(url, headers, *, timeout=120.0)[source]#

The default transport: one GET with urllib. An HTTP error is returned, not raised, so odata_get() reads every status the same way.

A redirect to another origin is followed without the Authorization header. Use functools.partial() to change timeout.

Raises:

ODataError – If no HTTP answer came back (DNS, refused connection, timeout, a malformed URL), naming the URL without any userinfo it carries.

Return type:

tuple[int, bytes]

Modules

metadata

An entity type's declared properties, read from the service's CSDL $metadata.

read

Reading an entity set: its URL, its count, its records page by page, and wide reads.

store

A read-only Mapping over one OData entity set, keyed on a declared key.

testing

A scripted OData service for tests: no network, no password.

transport

The one request odatadol makes: an authenticated GET, through a swappable fetch.