# odatadol

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

```pycon
>>> 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:

- [`odata_get()`](#odatadol.odata_get): the one (`GET`) request, through a `fetch` seam.
- [`entity_count()`](#odatadol.entity_count), [`entity_records()`](#odatadol.entity_records): `$count` and paged records, with
  `$select`/`$filter`/`$orderby`/`$top` passed through.
- [`entity_columns()`](#odatadol.entity_columns): a `$select` too wide for one URL, read as column groups
  joined on a key and checked to be one moment ([`UnstableRead`](#odatadol.UnstableRead) otherwise).
- [`entity_metadata()`](#odatadol.entity_metadata): `$metadata` as `{name: {type, nullable, annotations}}`.

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

### Functions

| [`odata_literal`](#odatadol.odata_literal)(value)                               | `value` written as an OData `$filter` literal.                                                                 |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
| [`odata_get`](#odatadol.odata_get)(url, \*[, credentials, accept, fetch])   | `GET` one OData URL and return the body.                                                                       |
| [`urllib_fetch`](#odatadol.urllib_fetch)(url, headers, \*[, timeout])          | The default transport: one `GET` with [`urllib`](https://docs.python.org/3/library/urllib.html#module-urllib). |
| [`redact`](#odatadol.redact)(url)                                        | `url` without any `user:password@` it carries.                                                                 |
| [`entity_url`](#odatadol.entity_url)(service, entity, \*[, suffix, query])   | The URL of an entity set, optionally with a path suffix and query options.                                     |
| [`quote_name`](#odatadol.quote_name)(name)                                   | A property name as `$select`/`$orderby` accept it.                                                             |
| [`entity_count`](#odatadol.entity_count)(service, entity, \*[, filter, ...])   | How many records the entity set holds now (or match `filter`).                                                 |
| [`entity_records`](#odatadol.entity_records)(service, entity, \*[, select, ...]) | Records of an entity set, lazily, page by page, with OData annotations removed.                                |
| [`entity_columns`](#odatadol.entity_columns)(service, entity, \*, select, key)   | Every record with every `select` field, however many fields that is, as one moment.                            |
| [`entity_metadata`](#odatadol.entity_metadata)(service, entity, \*[, ...])        | The declared properties of one entity set's type, in declaration order.                                        |
| [`parse_entity_metadata`](#odatadol.parse_entity_metadata)(body, entity)                | The properties of `entity` in a CSDL document, inherited ones first.                                           |

### Classes

| [`ODataReader`](#odatadol.ODataReader)(service, entity, \*, key[, ...])   | The records of `entity` at `service`, as `{key value: record}`.   |
|-------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|
| [`BasicAuth`](#odatadol.BasicAuth)(account, password)                   | An account and its password, for HTTP Basic.                      |
| [`BearerToken`](#odatadol.BearerToken)(token)                             | An OAuth2 or similar bearer token.                                |

### Exceptions

| [`ODataError`](#odatadol.ODataError)(message, \*[, status, code])   | The service answered with an error.                                             |
|--------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| [`UnstableRead`](#odatadol.UnstableRead)(message, \*[, status, code]) | Reads of one entity set disagreed about which records exist, or their versions. |

### *class* odatadol.BasicAuth(account, password)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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

```pycon
>>> BasicAuth('reader', 'secret')
BasicAuth(account='reader')
>>> BasicAuth('reader', 'secret').header()
'Basic cmVhZGVyOnNlY3JldA=='
```

### *class* odatadol.BearerToken(token)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

An OAuth2 or similar bearer token. Never shown.

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

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#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)

Bases: [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)

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

* **Parameters:**
  * **service** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The OData service root URL.
  * **entity** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The entity set name.
  * **key** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The property records are addressed, ordered and joined by. It must be
    unique and never empty within the reader’s `filter`.
  * **select** ([`Iterable`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)] | [`None`](https://docs.python.org/3/builtins/constants.html#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`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – A `$filter` scoping the reader: keys outside it are not in it.
  * **version** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#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()`](odatadol.read.html.md#odatadol.read.entity_columns)).
  * **credentials** ([`Union`](https://docs.python.org/3/library/typing.html#typing.Union)[[`None`](https://docs.python.org/3/builtins/constants.html#None), `_HasHeader`, [`Callable`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)[[], [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]) – `None`, a [`BasicAuth`](odatadol.transport.html.md#odatadol.transport.BasicAuth) or
    [`BearerToken`](odatadol.transport.html.md#odatadol.transport.BearerToken), or a callable returning one.
  * **fetch** ([`Callable`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]], [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`bytes`](https://docs.python.org/3/builtins/stdtypes.html#bytes)]] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The transport, `(url, headers) -> (status, bytes)`.
  * **literal** ([`Callable`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)[[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any)], [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Writes a key as a `$filter` literal, for `store[k]` and
    `k in store`. Defaults to [`odata_literal()`](#odatadol.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`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Applied to fractional JSON numbers (`decimal.Decimal` keeps
    every digit).

#### items()

Every `(key, record)`, read in bulk.

* **Return type:**
  [`ItemsView`](https://docs.python.org/3/library/collections.abc.html#collections.abc.ItemsView)

#### values()

Every record, read in bulk.

* **Return type:**
  [`ValuesView`](https://docs.python.org/3/library/collections.abc.html#collections.abc.ValuesView)

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

Bases: [`ODataError`](odatadol.transport.html.md#odatadol.transport.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)

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**](#odatadol.UnstableRead) – If reads disagree about which keys exist, their order, or their
        versions. The entity set changed during the read; retrying is safe.
  * [**ODataError**](#odatadol.ODataError) – If `key` is empty or repeats: the join would silently merge
        different records into one.
  * [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – If one field cannot fit under `max_url` at all.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### odatadol.entity_count(service, entity, , filter=None, credentials=None, fetch=None)

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

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

### odatadol.entity_metadata(service, entity, , credentials=None, fetch=None)

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**](#odatadol.ODataError) – If the service declares no such entity, naming the ones it has.
* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

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

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`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Iterable)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Properties to fetch (quoted as needed). `None` fetches all of them.
  * **filter** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – A `$filter` expression, sent as written.
  * **orderby** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – A `$orderby` expression, sent as written (quote spaced names
    yourself with [`quote_name()`](#odatadol.quote_name)).
  * **top** ([`int`](https://docs.python.org/3/builtins/functions.html#int) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The most records to yield in all; `None` for every one.
  * **page_size** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Records asked for per request.
  * **parse_float** ([`Callable`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Callable)[[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Called on every JSON number with a fraction; `decimal.Decimal`
    keeps every digit. Defaults to `float`.
* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Iterator)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### odatadol.entity_url(service, entity, , suffix='', query=None)

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

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

```pycon
>>> 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`](https://docs.python.org/3/builtins/stdtypes.html#str)

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

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

* **Raises:**
  [**ODataError**](#odatadol.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`](https://docs.python.org/3/builtins/stdtypes.html#bytes)

### odatadol.odata_literal(value)

`value` written as an OData `$filter` literal.

```pycon
>>> 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`](https://docs.python.org/3/library/uuid.html#uuid.UUID) for an
`Edm.Guid` and a [`datetime`](https://docs.python.org/3/library/datetime.html#module-datetime) object for a date or time.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### odatadol.parse_entity_metadata(body, entity)

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`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### odatadol.quote_name(name)

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

```pycon
>>> 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**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – For a name holding a `"`, which no quoting can carry.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### odatadol.redact(url)

`url` without any `user:password@` it carries.

```pycon
>>> 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`](https://docs.python.org/3/builtins/stdtypes.html#str)

### odatadol.urllib_fetch(url, headers, , timeout=120.0)

The default transport: one `GET` with [`urllib`](https://docs.python.org/3/library/urllib.html#module-urllib). An HTTP error is returned,
not raised, so [`odata_get()`](#odatadol.odata_get) reads every status the same way.

A redirect to another origin is followed without the `Authorization` header.
Use [`functools.partial()`](https://docs.python.org/3/library/functools.html#functools.partial) to change `timeout`.

* **Raises:**
  [**ODataError**](#odatadol.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`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`bytes`](https://docs.python.org/3/builtins/stdtypes.html#bytes)]

### Modules

| [`metadata`](odatadol.metadata.html.md#module-odatadol.metadata)   | An entity type's declared properties, read from the service's CSDL `$metadata`.      |
|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| [`read`](odatadol.read.html.md#module-odatadol.read)           | Reading an entity set: its URL, its count, its records page by page, and wide reads. |
| [`store`](odatadol.store.html.md#module-odatadol.store)         | A read-only `Mapping` over one OData entity set, keyed on a declared key.            |
| [`testing`](odatadol.testing.html.md#module-odatadol.testing)     | A scripted OData service for tests: no network, no password.                         |
| [`transport`](odatadol.transport.html.md#module-odatadol.transport) | The one request odatadol makes: an authenticated `GET`, through a swappable `fetch`. |
