odatadol 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odatadol/__init__.py +64 -0
- odatadol/metadata.py +167 -0
- odatadol/read.py +365 -0
- odatadol/store.py +253 -0
- odatadol/testing.py +232 -0
- odatadol/transport.py +231 -0
- odatadol-0.0.2.dist-info/METADATA +103 -0
- odatadol-0.0.2.dist-info/RECORD +10 -0
- odatadol-0.0.2.dist-info/WHEEL +4 -0
- odatadol-0.0.2.dist-info/licenses/LICENSE +201 -0
odatadol/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Read an OData v4 entity set as a Mapping, with the standard library only.
|
|
2
|
+
|
|
3
|
+
>>> from odatadol import ODataReader, BasicAuth # doctest: +SKIP
|
|
4
|
+
>>> store = ODataReader('https://example.com/odata', 'Products', # doctest: +SKIP
|
|
5
|
+
... key='ProductID', credentials=BasicAuth('me', 'pw'))
|
|
6
|
+
>>> len(store); store[42]; list(store) # doctest: +SKIP
|
|
7
|
+
|
|
8
|
+
Under the facade are plain functions, usable on their own:
|
|
9
|
+
|
|
10
|
+
- :func:`odata_get`: the one (``GET``) request, through a ``fetch`` seam.
|
|
11
|
+
- :func:`entity_count`, :func:`entity_records`: ``$count`` and paged records, with
|
|
12
|
+
``$select``/``$filter``/``$orderby``/``$top`` passed through.
|
|
13
|
+
- :func:`entity_columns`: a ``$select`` too wide for one URL, read as column groups
|
|
14
|
+
joined on a key and checked to be one moment (:class:`UnstableRead` otherwise).
|
|
15
|
+
- :func:`entity_metadata`: ``$metadata`` as ``{name: {type, nullable, annotations}}``.
|
|
16
|
+
|
|
17
|
+
Read-only by construction: no function takes an HTTP verb.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from odatadol.metadata import entity_metadata, parse_entity_metadata
|
|
21
|
+
from odatadol.read import (
|
|
22
|
+
DEFAULT_PAGE_SIZE,
|
|
23
|
+
MAX_URL_CHARS,
|
|
24
|
+
UnstableRead,
|
|
25
|
+
entity_columns,
|
|
26
|
+
entity_count,
|
|
27
|
+
entity_records,
|
|
28
|
+
entity_url,
|
|
29
|
+
quote_name,
|
|
30
|
+
)
|
|
31
|
+
from odatadol.store import ODataReader, odata_literal
|
|
32
|
+
from odatadol.transport import (
|
|
33
|
+
BasicAuth,
|
|
34
|
+
BearerToken,
|
|
35
|
+
Credentials,
|
|
36
|
+
Fetch,
|
|
37
|
+
ODataError,
|
|
38
|
+
odata_get,
|
|
39
|
+
redact,
|
|
40
|
+
urllib_fetch,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"ODataReader",
|
|
45
|
+
"odata_literal",
|
|
46
|
+
"BasicAuth",
|
|
47
|
+
"BearerToken",
|
|
48
|
+
"Credentials",
|
|
49
|
+
"Fetch",
|
|
50
|
+
"ODataError",
|
|
51
|
+
"UnstableRead",
|
|
52
|
+
"odata_get",
|
|
53
|
+
"urllib_fetch",
|
|
54
|
+
"redact",
|
|
55
|
+
"entity_url",
|
|
56
|
+
"quote_name",
|
|
57
|
+
"entity_count",
|
|
58
|
+
"entity_records",
|
|
59
|
+
"entity_columns",
|
|
60
|
+
"entity_metadata",
|
|
61
|
+
"parse_entity_metadata",
|
|
62
|
+
"DEFAULT_PAGE_SIZE",
|
|
63
|
+
"MAX_URL_CHARS",
|
|
64
|
+
]
|
odatadol/metadata.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""An entity type's declared properties, read from the service's CSDL ``$metadata``.
|
|
2
|
+
|
|
3
|
+
:func:`entity_metadata` returns ``{name: {'type': ..., 'nullable': ...,
|
|
4
|
+
'annotations': {...}}}`` in declaration order, with Edm type names intact
|
|
5
|
+
(``Edm.Decimal``, ``Edm.Binary``, ``Collection(Edm.String)``), so a caller can decide how
|
|
6
|
+
to render a value or which properties are not worth fetching (a binary, a stream, a
|
|
7
|
+
server-side global). ``annotations`` is present only when the property has any.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from odatadol.transport import Credentials, Fetch, ODataError, odata_get
|
|
16
|
+
|
|
17
|
+
__all__ = ["EDM_NAMESPACE", "entity_metadata", "parse_entity_metadata"]
|
|
18
|
+
|
|
19
|
+
#: The CSDL namespace of OData v4.
|
|
20
|
+
EDM_NAMESPACE = "http://docs.oasis-open.org/odata/ns/edm"
|
|
21
|
+
#: The attributes an annotation's constant value may sit in, in the order looked for.
|
|
22
|
+
_ANNOTATION_VALUE_ATTRS = (
|
|
23
|
+
"Bool",
|
|
24
|
+
"String",
|
|
25
|
+
"Int",
|
|
26
|
+
"Decimal",
|
|
27
|
+
"Float",
|
|
28
|
+
"Date",
|
|
29
|
+
"DateTimeOffset",
|
|
30
|
+
"TimeOfDay",
|
|
31
|
+
"Duration",
|
|
32
|
+
"Guid",
|
|
33
|
+
"EnumMember",
|
|
34
|
+
"Path",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _edm(tag: str) -> str:
|
|
39
|
+
return f"{{{EDM_NAMESPACE}}}{tag}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _annotation_value(annotation: ET.Element) -> str:
|
|
43
|
+
"""The annotation's constant as written; ``''`` when it has none (a bare tag term,
|
|
44
|
+
which CSDL reads as ``true`` for a Boolean term)."""
|
|
45
|
+
return next(
|
|
46
|
+
(v for a in _ANNOTATION_VALUE_ATTRS if (v := annotation.get(a)) is not None), ""
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _types_by_qualified_name(root: ET.Element) -> dict[str, ET.Element]:
|
|
51
|
+
"""Every entity type under each name it can be referred by: ``Namespace.Name`` and,
|
|
52
|
+
when its schema declares one, ``Alias.Name``."""
|
|
53
|
+
types: dict[str, ET.Element] = {}
|
|
54
|
+
for schema in root.iter(_edm("Schema")):
|
|
55
|
+
prefixes = [p for p in (schema.get("Namespace"), schema.get("Alias")) if p]
|
|
56
|
+
for entity_type in schema.findall(_edm("EntityType")):
|
|
57
|
+
for prefix in prefixes:
|
|
58
|
+
types[f"{prefix}.{entity_type.get('Name')}"] = entity_type
|
|
59
|
+
return types
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entity_type_of(
|
|
63
|
+
root: ET.Element, entity: str, types: dict[str, ET.Element]
|
|
64
|
+
) -> ET.Element:
|
|
65
|
+
sets = {s.get("Name"): s.get("EntityType") for s in root.iter(_edm("EntitySet"))}
|
|
66
|
+
wanted = sets.get(entity) or entity
|
|
67
|
+
if wanted in types:
|
|
68
|
+
return types[wanted]
|
|
69
|
+
by_short_name = {
|
|
70
|
+
id(t): t for q, t in types.items() if q.rsplit(".", 1)[-1] == wanted
|
|
71
|
+
}
|
|
72
|
+
if len(by_short_name) == 1:
|
|
73
|
+
return next(iter(by_short_name.values()))
|
|
74
|
+
if by_short_name:
|
|
75
|
+
raise ODataError(
|
|
76
|
+
f"several schemas declare an entity type {wanted!r}; name it qualified: "
|
|
77
|
+
+ ", ".join(sorted(q for q in types if q.rsplit(".", 1)[-1] == wanted))
|
|
78
|
+
)
|
|
79
|
+
raise ODataError(
|
|
80
|
+
f"$metadata declares no entity {entity!r}; its entity sets are: "
|
|
81
|
+
+ (", ".join(sorted(n for n in sets if n)) or "none")
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _type_chain(
|
|
86
|
+
entity_type: ET.Element, types: dict[str, ET.Element]
|
|
87
|
+
) -> list[ET.Element]:
|
|
88
|
+
"""``entity_type`` and its ``BaseType`` ancestors, the root ancestor first."""
|
|
89
|
+
chain = [entity_type]
|
|
90
|
+
while (base := chain[-1].get("BaseType")) is not None:
|
|
91
|
+
if base not in types or types[base] in chain:
|
|
92
|
+
raise ODataError(
|
|
93
|
+
f"entity type {chain[-1].get('Name')!r} derives from {base!r}, which "
|
|
94
|
+
"$metadata "
|
|
95
|
+
+ ("does not declare" if base not in types else "makes a cycle")
|
|
96
|
+
)
|
|
97
|
+
chain.append(types[base])
|
|
98
|
+
return chain[::-1]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _external_annotations(
|
|
102
|
+
root: ET.Element, types: dict[str, ET.Element]
|
|
103
|
+
) -> dict[tuple[int, str], dict[str, str]]:
|
|
104
|
+
"""Out-of-line ``<Annotations Target="NS.Type/Property">``, keyed by
|
|
105
|
+
``(id(type element), property)``: how many services publish annotations."""
|
|
106
|
+
found: dict[tuple[int, str], dict[str, str]] = {}
|
|
107
|
+
for block in root.iter(_edm("Annotations")):
|
|
108
|
+
target, _, prop = (block.get("Target") or "").partition("/")
|
|
109
|
+
if prop and target in types:
|
|
110
|
+
found.setdefault((id(types[target]), prop), {}).update(
|
|
111
|
+
(a.get("Term", ""), _annotation_value(a))
|
|
112
|
+
for a in block.findall(_edm("Annotation"))
|
|
113
|
+
)
|
|
114
|
+
return found
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_entity_metadata(body: bytes | str, entity: str) -> dict[str, dict[str, Any]]:
|
|
118
|
+
"""The properties of ``entity`` in a CSDL document, inherited ones first.
|
|
119
|
+
|
|
120
|
+
``entity`` may be an entity set, a qualified type (``Namespace.Name`` or
|
|
121
|
+
``Alias.Name``) or, when only one schema declares it, a type's short name.
|
|
122
|
+
Annotations are merged from inline ``<Annotation>`` and out-of-line
|
|
123
|
+
``<Annotations Target=...>`` elements.
|
|
124
|
+
"""
|
|
125
|
+
root = ET.fromstring(body)
|
|
126
|
+
types = _types_by_qualified_name(root)
|
|
127
|
+
external = _external_annotations(root, types)
|
|
128
|
+
properties: dict[str, dict[str, Any]] = {}
|
|
129
|
+
for entity_type in _type_chain(_entity_type_of(root, entity, types), types):
|
|
130
|
+
for prop in entity_type.findall(_edm("Property")):
|
|
131
|
+
name = prop.get("Name")
|
|
132
|
+
annotations = {
|
|
133
|
+
**external.get((id(entity_type), name), {}),
|
|
134
|
+
**{
|
|
135
|
+
a.get("Term", ""): _annotation_value(a)
|
|
136
|
+
for a in prop.findall(_edm("Annotation"))
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
properties[name] = {
|
|
140
|
+
"type": prop.get("Type"),
|
|
141
|
+
"nullable": prop.get("Nullable", "true") != "false",
|
|
142
|
+
**({"annotations": annotations} if annotations else {}),
|
|
143
|
+
}
|
|
144
|
+
return properties
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def entity_metadata(
|
|
148
|
+
service: str,
|
|
149
|
+
entity: str,
|
|
150
|
+
*,
|
|
151
|
+
credentials: Credentials = None,
|
|
152
|
+
fetch: Fetch | None = None,
|
|
153
|
+
) -> dict[str, dict[str, Any]]:
|
|
154
|
+
"""The declared properties of one entity set's type, in declaration order.
|
|
155
|
+
|
|
156
|
+
``entity`` may name the entity set (resolved through the container) or the type.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ODataError: If the service declares no such entity, naming the ones it has.
|
|
160
|
+
"""
|
|
161
|
+
body = odata_get(
|
|
162
|
+
f"{service.rstrip('/')}/$metadata",
|
|
163
|
+
credentials=credentials,
|
|
164
|
+
accept="application/xml",
|
|
165
|
+
fetch=fetch,
|
|
166
|
+
)
|
|
167
|
+
return parse_entity_metadata(body, entity)
|
odatadol/read.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Reading an entity set: its URL, its count, its records page by page, and wide reads.
|
|
2
|
+
|
|
3
|
+
The functions take the service root and the entity set name first, then keyword-only
|
|
4
|
+
options, and share three seams: ``credentials=``, ``fetch=`` and, for anything returning
|
|
5
|
+
records, ``parse_float=``.
|
|
6
|
+
|
|
7
|
+
Server quirks met in practice, each with a default that copes:
|
|
8
|
+
|
|
9
|
+
- ``$count`` is asked for with ``Accept: */*``: some servers refuse a bare
|
|
10
|
+
``text/plain``.
|
|
11
|
+
- A name that is not an OData identifier (``Unit Price``) is double-quoted in
|
|
12
|
+
``$select``/``$orderby`` (:func:`quote_name`).
|
|
13
|
+
- ``:`` travels bare in the query string: a percent-encoded ``:`` inside a ``$filter``
|
|
14
|
+
timestamp is a syntax error on some servers.
|
|
15
|
+
- JSON is parsed with ``strict=False``: raw control characters (a bare CR line break)
|
|
16
|
+
inside strings are kept verbatim instead of failing the page.
|
|
17
|
+
- A front end may refuse URLs past ~2k characters, so :func:`entity_columns` splits a
|
|
18
|
+
wide ``$select`` into column groups under ``max_url`` and joins them on a key.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import urllib.parse
|
|
25
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from odatadol.transport import (
|
|
29
|
+
Credentials,
|
|
30
|
+
Fetch,
|
|
31
|
+
ODataError,
|
|
32
|
+
odata_get,
|
|
33
|
+
redact,
|
|
34
|
+
resolve_credentials,
|
|
35
|
+
same_origin,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DEFAULT_PAGE_SIZE",
|
|
40
|
+
"MAX_URL_CHARS",
|
|
41
|
+
"UnstableRead",
|
|
42
|
+
"entity_url",
|
|
43
|
+
"quote_name",
|
|
44
|
+
"entity_count",
|
|
45
|
+
"entity_records",
|
|
46
|
+
"entity_columns",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
#: Records per request: small enough that one slow page is not a timeout, large enough
|
|
50
|
+
#: that tens of thousands of records are a few dozen requests.
|
|
51
|
+
DEFAULT_PAGE_SIZE = 1000
|
|
52
|
+
#: The longest URL :func:`entity_columns` sends. IIS's default query-string ceiling is
|
|
53
|
+
#: 2,048 characters, and past it IIS answers its own 404 as if the entity did not exist.
|
|
54
|
+
MAX_URL_CHARS = 2000
|
|
55
|
+
#: Characters left unescaped in the query string, as RFC 3986 allows.
|
|
56
|
+
_QUERY_SAFE = "$,'():"
|
|
57
|
+
#: A ``$skip`` wider than any real one, to size the longest URL a page can have.
|
|
58
|
+
_WIDEST_SKIP = 10**9
|
|
59
|
+
|
|
60
|
+
ParseFloat = Callable[[str], Any]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnstableRead(ODataError):
|
|
64
|
+
"""Reads of one entity set disagreed about which records exist, or their versions."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def entity_url(
|
|
68
|
+
service: str,
|
|
69
|
+
entity: str,
|
|
70
|
+
*,
|
|
71
|
+
suffix: str = "",
|
|
72
|
+
query: Mapping[str, Any] | None = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""The URL of an entity set, optionally with a path suffix and query options.
|
|
75
|
+
|
|
76
|
+
``None``-valued options are dropped, and ``:`` is not percent-encoded.
|
|
77
|
+
|
|
78
|
+
>>> entity_url('https://example.com/odata/', 'Products', suffix='/$count',
|
|
79
|
+
... query={'$filter': 'ModifiedAt gt 2026-01-01T00:00:00Z', '$top': None})
|
|
80
|
+
'https://example.com/odata/Products/$count?$filter=ModifiedAt%20gt%202026-01-01T00:00:00Z'
|
|
81
|
+
"""
|
|
82
|
+
root, _, service_query = service.partition("?") # a service may need its own
|
|
83
|
+
url = f"{root.rstrip('/')}/{urllib.parse.quote(entity)}{suffix}"
|
|
84
|
+
options = {k: v for k, v in (query or {}).items() if v is not None}
|
|
85
|
+
encoded = urllib.parse.urlencode(
|
|
86
|
+
options, quote_via=urllib.parse.quote, safe=_QUERY_SAFE
|
|
87
|
+
)
|
|
88
|
+
if joined := "&".join(q for q in (service_query, encoded) if q):
|
|
89
|
+
url += "?" + joined
|
|
90
|
+
return url
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_plain(name: str) -> bool:
|
|
94
|
+
"""An OData path as written: ``*``, identifiers (Unicode ones too), qualified
|
|
95
|
+
names and ``/`` navigation, so ``Category/Name`` and ``NS.Action`` stay bare."""
|
|
96
|
+
return all(
|
|
97
|
+
segment == "*" or all(part.isidentifier() for part in segment.split("."))
|
|
98
|
+
for segment in name.split("/")
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def quote_name(name: str) -> str:
|
|
103
|
+
"""A property name as ``$select``/``$orderby`` accept it.
|
|
104
|
+
|
|
105
|
+
>>> quote_name('Status'), quote_name('Unit Price'), quote_name('Category/Name')
|
|
106
|
+
('Status', '"Unit Price"', 'Category/Name')
|
|
107
|
+
|
|
108
|
+
A bare ``Unit Price`` is a syntax error at the space; servers that allow such names
|
|
109
|
+
(FileMaker's OData, for one) take them double-quoted. A standard OData name is never
|
|
110
|
+
quoted.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: For a name holding a ``"``, which no quoting can carry.
|
|
114
|
+
"""
|
|
115
|
+
if _is_plain(name):
|
|
116
|
+
return name
|
|
117
|
+
if '"' in name:
|
|
118
|
+
raise ValueError(f"cannot quote the name {name!r}: it holds a double quote")
|
|
119
|
+
return f'"{name}"'
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _encoded_width(name: str) -> int:
|
|
123
|
+
"""Characters ``name`` adds to a ``$select`` list, its comma included."""
|
|
124
|
+
return len(urllib.parse.quote(quote_name(name), safe=_QUERY_SAFE)) + 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def entity_count(
|
|
128
|
+
service: str,
|
|
129
|
+
entity: str,
|
|
130
|
+
*,
|
|
131
|
+
filter: str | None = None,
|
|
132
|
+
credentials: Credentials = None,
|
|
133
|
+
fetch: Fetch | None = None,
|
|
134
|
+
) -> int:
|
|
135
|
+
"""How many records the entity set holds now (or match ``filter``). One request."""
|
|
136
|
+
url = entity_url(service, entity, suffix="/$count", query={"$filter": filter})
|
|
137
|
+
body = odata_get(url, credentials=credentials, accept="*/*", fetch=fetch)
|
|
138
|
+
return int(body.decode("utf-8-sig").strip())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_annotation(name: str) -> bool:
|
|
142
|
+
"""``@odata.etag``, ``Price@odata.type``, ``Name@Core.Description``: an OData
|
|
143
|
+
property name never holds ``@`` otherwise."""
|
|
144
|
+
return "@" in name
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse(body: bytes, parse_float: ParseFloat | None) -> dict:
|
|
148
|
+
return json.loads(body, strict=False, parse_float=parse_float)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def entity_records(
|
|
152
|
+
service: str,
|
|
153
|
+
entity: str,
|
|
154
|
+
*,
|
|
155
|
+
select: Iterable[str] | None = None,
|
|
156
|
+
filter: str | None = None,
|
|
157
|
+
orderby: str | None = None,
|
|
158
|
+
top: int | None = None,
|
|
159
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
160
|
+
credentials: Credentials = None,
|
|
161
|
+
fetch: Fetch | None = None,
|
|
162
|
+
parse_float: ParseFloat | None = None,
|
|
163
|
+
) -> Iterator[dict[str, Any]]:
|
|
164
|
+
"""Records of an entity set, lazily, page by page, with OData annotations removed.
|
|
165
|
+
|
|
166
|
+
Follows ``@odata.nextLink`` when the server sends one, and otherwise pages with
|
|
167
|
+
``$top``/``$skip`` until an empty page (a short page may be a server capping its
|
|
168
|
+
pages without a nextLink, so it is not taken as the end). ``orderby`` should name a unique key: paging
|
|
169
|
+
without a stable order can skip or repeat a record that moves between pages.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
select: Properties to fetch (quoted as needed). ``None`` fetches all of them.
|
|
173
|
+
filter: A ``$filter`` expression, sent as written.
|
|
174
|
+
orderby: A ``$orderby`` expression, sent as written (quote spaced names
|
|
175
|
+
yourself with :func:`quote_name`).
|
|
176
|
+
top: The most records to yield in all; ``None`` for every one.
|
|
177
|
+
page_size: Records asked for per request.
|
|
178
|
+
parse_float: Called on every JSON number with a fraction; ``decimal.Decimal``
|
|
179
|
+
keeps every digit. Defaults to ``float``.
|
|
180
|
+
"""
|
|
181
|
+
if page_size < 1:
|
|
182
|
+
raise ValueError(f"page_size must be at least 1, not {page_size}")
|
|
183
|
+
select = None if select is None else list(select)
|
|
184
|
+
if select == []:
|
|
185
|
+
raise ValueError("select is empty: pass None to fetch every property")
|
|
186
|
+
query: dict[str, Any] = {
|
|
187
|
+
"$select": ",".join(map(quote_name, select)) if select is not None else None,
|
|
188
|
+
"$filter": filter,
|
|
189
|
+
"$orderby": orderby,
|
|
190
|
+
}
|
|
191
|
+
credentials = resolve_credentials(credentials) # once per read, not per page
|
|
192
|
+
|
|
193
|
+
def page_url(skip: int, size: int) -> str:
|
|
194
|
+
return entity_url(service, entity, query={**query, "$top": size, "$skip": skip})
|
|
195
|
+
|
|
196
|
+
def next_size(yielded: int) -> int:
|
|
197
|
+
return page_size if top is None else min(page_size, top - yielded)
|
|
198
|
+
|
|
199
|
+
yielded, size, server_paged = 0, next_size(0), False
|
|
200
|
+
url: str | None = page_url(0, size) if size > 0 else None
|
|
201
|
+
asked: set[str] = set()
|
|
202
|
+
previous = None
|
|
203
|
+
while url:
|
|
204
|
+
if url in asked:
|
|
205
|
+
raise ODataError(
|
|
206
|
+
f"the server sent {redact(url)} twice: paging would not end"
|
|
207
|
+
)
|
|
208
|
+
asked.add(url)
|
|
209
|
+
page = _parse(odata_get(url, credentials=credentials, fetch=fetch), parse_float)
|
|
210
|
+
rows = page.get("value", [])
|
|
211
|
+
if rows and rows == previous:
|
|
212
|
+
raise ODataError(
|
|
213
|
+
f"the server answered {redact(url)} with the page before it: it ignores "
|
|
214
|
+
"$skip, so paging would not end"
|
|
215
|
+
)
|
|
216
|
+
previous = rows
|
|
217
|
+
for row in rows:
|
|
218
|
+
yield {k: v for k, v in row.items() if not _is_annotation(k)}
|
|
219
|
+
yielded += 1
|
|
220
|
+
if top is not None and yielded >= top: # a server page may outrun $top
|
|
221
|
+
return
|
|
222
|
+
if next_link := page.get("@odata.nextLink"):
|
|
223
|
+
following = urllib.parse.urljoin(url, next_link)
|
|
224
|
+
if not same_origin(url, following):
|
|
225
|
+
raise ODataError(
|
|
226
|
+
"refusing to follow a nextLink to another origin, "
|
|
227
|
+
f"{redact(following)}: the credentials would go with it"
|
|
228
|
+
)
|
|
229
|
+
url, server_paged = following, True
|
|
230
|
+
elif server_paged or not rows:
|
|
231
|
+
url = None # a server paging by nextLink ends by omitting it
|
|
232
|
+
else:
|
|
233
|
+
# A short page may be the end, or a server capping pages without saying
|
|
234
|
+
# so; only an empty page tells them apart, at the cost of one request.
|
|
235
|
+
size = next_size(yielded)
|
|
236
|
+
url = page_url(yielded, size)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _select_groups(select: Iterable[str], *, key: str, budget: int) -> list[list[str]]:
|
|
240
|
+
"""``select`` cut into groups, each led by ``key``, whose names fit ``budget``."""
|
|
241
|
+
groups: list[list[str]] = []
|
|
242
|
+
current, width = [key], _encoded_width(key)
|
|
243
|
+
if width > budget:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
"the key, version and filter alone exceed max_url: shorten the filter or "
|
|
246
|
+
"raise max_url"
|
|
247
|
+
)
|
|
248
|
+
for name in select:
|
|
249
|
+
if name == key:
|
|
250
|
+
continue
|
|
251
|
+
name_width = _encoded_width(name)
|
|
252
|
+
if width + name_width > budget:
|
|
253
|
+
if current == [key]:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"{name!r} alone does not fit a URL of max_url characters beside the "
|
|
256
|
+
"key; raise max_url"
|
|
257
|
+
)
|
|
258
|
+
groups.append(current)
|
|
259
|
+
current, width = [key], _encoded_width(key)
|
|
260
|
+
current.append(name)
|
|
261
|
+
width += name_width
|
|
262
|
+
groups.append(current)
|
|
263
|
+
return groups
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def entity_columns(
|
|
267
|
+
service: str,
|
|
268
|
+
entity: str,
|
|
269
|
+
*,
|
|
270
|
+
select: Iterable[str],
|
|
271
|
+
key: str,
|
|
272
|
+
version: str | None = None,
|
|
273
|
+
filter: str | None = None,
|
|
274
|
+
max_url: int = MAX_URL_CHARS,
|
|
275
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
276
|
+
credentials: Credentials = None,
|
|
277
|
+
fetch: Fetch | None = None,
|
|
278
|
+
parse_float: ParseFloat | None = None,
|
|
279
|
+
) -> list[dict[str, Any]]:
|
|
280
|
+
"""Every record with every ``select`` field, however many fields that is, as one moment.
|
|
281
|
+
|
|
282
|
+
Splits ``select`` into groups whose URL fits ``max_url``, reads each group in
|
|
283
|
+
``key`` order, and joins them on ``key``. Records come back in key order, each with
|
|
284
|
+
exactly the ``select`` fields, in ``select`` order.
|
|
285
|
+
|
|
286
|
+
**A join of reads is only one moment if every read saw the same records in the same
|
|
287
|
+
state.** So ``version`` (a property the server rewrites on every edit, such as a
|
|
288
|
+
modification timestamp) is read in every group and must agree per key; without it,
|
|
289
|
+
an edit landing between two groups yields a record that never existed: the old
|
|
290
|
+
name beside the new price. And there are always at least two reads: a single group
|
|
291
|
+
is followed by a narrow ``key``/``version`` read, because ``$skip`` paging over a
|
|
292
|
+
delete before the cursor and an insert after it skips a live record while the
|
|
293
|
+
count still balances.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
UnstableRead: If reads disagree about which keys exist, their order, or their
|
|
297
|
+
versions. The entity set changed during the read; retrying is safe.
|
|
298
|
+
ODataError: If ``key`` is empty or repeats: the join would silently merge
|
|
299
|
+
different records into one.
|
|
300
|
+
ValueError: If one field cannot fit under ``max_url`` at all.
|
|
301
|
+
"""
|
|
302
|
+
select = list(select)
|
|
303
|
+
credentials = resolve_credentials(credentials) # once for every group
|
|
304
|
+
probe = len(
|
|
305
|
+
entity_url(
|
|
306
|
+
service,
|
|
307
|
+
entity,
|
|
308
|
+
query={
|
|
309
|
+
"$select": "",
|
|
310
|
+
"$filter": filter,
|
|
311
|
+
"$orderby": quote_name(key),
|
|
312
|
+
"$top": page_size,
|
|
313
|
+
"$skip": _WIDEST_SKIP,
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
budget = max_url - probe - (_encoded_width(version) if version else 0)
|
|
318
|
+
groups = [
|
|
319
|
+
group + ([version] if version and version not in group else [])
|
|
320
|
+
for group in _select_groups(select, key=key, budget=budget)
|
|
321
|
+
]
|
|
322
|
+
if len(groups) == 1:
|
|
323
|
+
groups.append([key] + ([version] if version and version != key else []))
|
|
324
|
+
wanted = set(select)
|
|
325
|
+
joined: dict[Any, dict[str, Any]] = {}
|
|
326
|
+
first: list[tuple[Any, Any]] = []
|
|
327
|
+
for i, group in enumerate(groups):
|
|
328
|
+
rows = entity_records(
|
|
329
|
+
service,
|
|
330
|
+
entity,
|
|
331
|
+
select=group,
|
|
332
|
+
filter=filter,
|
|
333
|
+
orderby=quote_name(key),
|
|
334
|
+
page_size=page_size,
|
|
335
|
+
credentials=credentials,
|
|
336
|
+
fetch=fetch,
|
|
337
|
+
parse_float=parse_float,
|
|
338
|
+
)
|
|
339
|
+
seen: list[tuple[Any, Any]] = []
|
|
340
|
+
for row in rows:
|
|
341
|
+
k = row.get(key)
|
|
342
|
+
if k is None or (i == 0 and k in joined):
|
|
343
|
+
raise ODataError(
|
|
344
|
+
f"{entity!r} has {'an empty' if k is None else 'a repeated'} {key!r} "
|
|
345
|
+
f"({k!r}): it cannot be joined on that key without merging different "
|
|
346
|
+
"records into one."
|
|
347
|
+
)
|
|
348
|
+
seen.append((k, row.get(version) if version else None))
|
|
349
|
+
if i == 0:
|
|
350
|
+
joined[k] = row
|
|
351
|
+
elif k in joined:
|
|
352
|
+
joined[k].update({n: v for n, v in row.items() if n in wanted})
|
|
353
|
+
if i == 0:
|
|
354
|
+
first = seen
|
|
355
|
+
elif seen != first:
|
|
356
|
+
keys, others = {k for k, _ in first}, {k for k, _ in seen}
|
|
357
|
+
edited = sum(
|
|
358
|
+
1 for a, b in zip(first, seen) if a[0] == b[0] and a[1] != b[1]
|
|
359
|
+
)
|
|
360
|
+
raise UnstableRead(
|
|
361
|
+
f"read {i + 1} of {len(groups)} disagrees with the first: "
|
|
362
|
+
f"{len(others - keys)} new, {len(keys - others)} gone, {edited} edited, "
|
|
363
|
+
"or the order moved. The entity set changed during the read. Retry."
|
|
364
|
+
)
|
|
365
|
+
return [{name: joined[k].get(name) for name in select} for k, _ in first]
|