# odatadol.read

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

The functions take the service root and the entity set name first, then keyword-only
options, and share three seams: `credentials=`, `fetch=` and, for anything returning
records, `parse_float=`.

Server quirks met in practice, each with a default that copes:

- `$count` is asked for with `Accept: */*`: some servers refuse a bare
  `text/plain`.
- A name that is not an OData identifier (`Unit Price`) is double-quoted in
  `$select`/`$orderby` ([`quote_name()`](#odatadol.read.quote_name)).
- `:` travels bare in the query string: a percent-encoded `:` inside a `$filter`
  timestamp is a syntax error on some servers.
- JSON is parsed with `strict=False`: raw control characters (a bare CR line break)
  inside strings are kept verbatim instead of failing the page.
- A front end may refuse URLs past ~2k characters, so [`entity_columns()`](#odatadol.read.entity_columns) splits a
  wide `$select` into column groups under `max_url` and joins them on a key.

### Module Attributes

| [`DEFAULT_PAGE_SIZE`](#odatadol.read.DEFAULT_PAGE_SIZE)   | small enough that one slow page is not a timeout, large enough that tens of thousands of records are a few dozen requests.   |
|----------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| [`MAX_URL_CHARS`](#odatadol.read.MAX_URL_CHARS)       | The longest URL [`entity_columns()`](#odatadol.read.entity_columns) sends.                                     |

### Functions

| [`entity_url`](#odatadol.read.entity_url)(service, entity, \*[, suffix, query])   | The URL of an entity set, optionally with a path suffix and query options.          |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
| [`quote_name`](#odatadol.read.quote_name)(name)                                   | A property name as `$select`/`$orderby` accept it.                                  |
| [`entity_count`](#odatadol.read.entity_count)(service, entity, \*[, filter, ...])   | How many records the entity set holds now (or match `filter`).                      |
| [`entity_records`](#odatadol.read.entity_records)(service, entity, \*[, select, ...]) | Records of an entity set, lazily, page by page, with OData annotations removed.     |
| [`entity_columns`](#odatadol.read.entity_columns)(service, entity, \*, select, key)   | Every record with every `select` field, however many fields that is, as one moment. |

### Exceptions

| [`UnstableRead`](#odatadol.read.UnstableRead)(message, \*[, status, code])   | Reads of one entity set disagreed about which records exist, or their versions.   |
|----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|

### odatadol.read.DEFAULT_PAGE_SIZE *= 1000*

small enough that one slow page is not a timeout, large enough
that tens of thousands of records are a few dozen requests.

* **Type:**
  Records per request

### odatadol.read.MAX_URL_CHARS *= 2000*

The longest URL [`entity_columns()`](#odatadol.read.entity_columns) sends. IIS’s default query-string ceiling is
2,048 characters, and past it IIS answers its own 404 as if the entity did not exist.

### *exception* odatadol.read.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.read.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.read.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.html.md#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.read.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.read.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.read.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.read.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.read.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)
