pgtern 0.3.0__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.
- pgtern/__init__.py +46 -0
- pgtern/_binary.py +226 -0
- pgtern/_composite.py +103 -0
- pgtern/_conn.py +907 -0
- pgtern/_copy.py +186 -0
- pgtern/_cursor.py +261 -0
- pgtern/_dsn.py +182 -0
- pgtern/_protocol.py +258 -0
- pgtern/_query.py +69 -0
- pgtern/_scram.py +101 -0
- pgtern/_types.py +442 -0
- pgtern/adapters.py +85 -0
- pgtern/aio.py +1287 -0
- pgtern/errors.py +248 -0
- pgtern/hooks.py +53 -0
- pgtern/pipeline.py +202 -0
- pgtern/pool.py +309 -0
- pgtern/py.typed +0 -0
- pgtern/rows.py +31 -0
- pgtern/sql.py +200 -0
- pgtern/types/__init__.py +30 -0
- pgtern/types/json.py +5 -0
- pgtern-0.3.0.dist-info/METADATA +265 -0
- pgtern-0.3.0.dist-info/RECORD +27 -0
- pgtern-0.3.0.dist-info/WHEEL +5 -0
- pgtern-0.3.0.dist-info/licenses/LICENSE +202 -0
- pgtern-0.3.0.dist-info/top_level.txt +1 -0
pgtern/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""pgtern: a zero-dependency, pure-Python PostgreSQL client with a familiar DB-API style interface.
|
|
3
|
+
|
|
4
|
+
import pgtern
|
|
5
|
+
from pgtern.rows import dict_row
|
|
6
|
+
|
|
7
|
+
with pgtern.connect("postgresql://user:pw@host/db", row_factory=dict_row) as conn:
|
|
8
|
+
for row in conn.execute("SELECT id, name FROM users WHERE id = %s", (42,)):
|
|
9
|
+
...
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from . import adapters, aio, errors, hooks, rows, sql, types
|
|
16
|
+
from ._conn import Connection, ConnectionInfo, Transaction, TransactionStatus
|
|
17
|
+
from ._cursor import Cursor, ServerCursor
|
|
18
|
+
from ._protocol import Notify
|
|
19
|
+
from .errors import (
|
|
20
|
+
DatabaseError, DataError, Error, IntegrityError, InterfaceError, InternalError,
|
|
21
|
+
NotSupportedError, OperationalError, ProgrammingError, Warning,
|
|
22
|
+
)
|
|
23
|
+
from .hooks import QueryInfo, add_query_hook
|
|
24
|
+
from .pool import ConnectionPool, PoolClosed, PoolTimeout
|
|
25
|
+
|
|
26
|
+
__version__ = "0.3.0"
|
|
27
|
+
|
|
28
|
+
# DB-API 2.0 module globals
|
|
29
|
+
apilevel = "2.0"
|
|
30
|
+
threadsafety = 2 # connections can be shared between threads
|
|
31
|
+
paramstyle = "pyformat"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def connect(conninfo: str = "", **kwargs: Any) -> Connection:
|
|
35
|
+
"""Open a connection. ``conninfo`` is a ``postgresql://`` URL or ``key=value`` string;
|
|
36
|
+
keyword arguments (``autocommit``, ``row_factory``, ``connect_timeout``, ``sslmode``, ...) override it."""
|
|
37
|
+
return Connection.connect(conninfo, **kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"connect", "Connection", "ConnectionInfo", "ConnectionPool", "Cursor", "ServerCursor", "Notify", "Transaction",
|
|
42
|
+
"TransactionStatus", "PoolClosed", "PoolTimeout", "QueryInfo", "add_query_hook",
|
|
43
|
+
"Warning", "Error", "InterfaceError", "DatabaseError", "DataError", "OperationalError",
|
|
44
|
+
"IntegrityError", "InternalError", "ProgrammingError", "NotSupportedError",
|
|
45
|
+
"adapters", "aio", "errors", "hooks", "rows", "sql", "types", "apilevel", "threadsafety", "paramstyle", "__version__",
|
|
46
|
+
]
|
pgtern/_binary.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Binary wire format for the common types (opt-in with ``binary=True``).
|
|
3
|
+
|
|
4
|
+
Decoders must agree with the text decoders in ``_types`` for every value both support:
|
|
5
|
+
tests check this against the server's own text output. Types not listed here keep using text.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
import json
|
|
11
|
+
import struct
|
|
12
|
+
import uuid
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from . import errors
|
|
17
|
+
from ._types import (
|
|
18
|
+
BOOL, BPCHAR, BYTEA, CHAR, DATE, ELEMENT_OF, FLOAT4, FLOAT8, INT2, INT4, INT8, INTERVAL, JSON,
|
|
19
|
+
JSONB, NAME, NUMERIC, OID, TEXT, TIME, TIMESTAMP, TIMESTAMPTZ, UUID, VARCHAR, ARRAY_OF,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_EPOCH = _dt.datetime(2000, 1, 1)
|
|
23
|
+
_EPOCH_DATE = _dt.date(2000, 1, 1)
|
|
24
|
+
_EPOCH_UTC = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc)
|
|
25
|
+
_I64_MAX = 2**63 - 1
|
|
26
|
+
_I64_MIN = -(2**63)
|
|
27
|
+
|
|
28
|
+
_s_i16 = struct.Struct("!h")
|
|
29
|
+
_s_u16 = struct.Struct("!H")
|
|
30
|
+
_s_i32 = struct.Struct("!i")
|
|
31
|
+
_s_u32 = struct.Struct("!I")
|
|
32
|
+
_s_i64 = struct.Struct("!q")
|
|
33
|
+
_s_f32 = struct.Struct("!f")
|
|
34
|
+
_s_f64 = struct.Struct("!d")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- decoders (bytes -> Python)
|
|
38
|
+
def _dec_numeric(b: bytes) -> Decimal:
|
|
39
|
+
ndigits, weight, sign, dscale = struct.unpack_from("!hhHh", b, 0)
|
|
40
|
+
if sign == 0xC000:
|
|
41
|
+
return Decimal("NaN")
|
|
42
|
+
if sign in (0xD000, 0xF000):
|
|
43
|
+
return Decimal("Infinity") if sign == 0xD000 else Decimal("-Infinity")
|
|
44
|
+
digits = [_s_i16.unpack_from(b, 8 + 2 * i)[0] for i in range(ndigits)]
|
|
45
|
+
n = 0
|
|
46
|
+
lowest = weight - (ndigits - 1) if ndigits else 0
|
|
47
|
+
for i, d in enumerate(digits):
|
|
48
|
+
n += d * (10000 ** (weight - i - lowest)) # base-10000 groups; digit i is worth 10000**(weight-i)
|
|
49
|
+
# value = n * 10**(4*lowest); exact decimal with exactly dscale fraction digits (no context limits)
|
|
50
|
+
shift = 4 * lowest + dscale
|
|
51
|
+
coefficient = n * 10 ** shift if shift >= 0 else n // 10 ** (-shift)
|
|
52
|
+
text = str(coefficient)
|
|
53
|
+
return Decimal((1 if sign == 0x4000 else 0, tuple(int(c) for c in text), -dscale))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dec_interval(b: bytes) -> _dt.timedelta:
|
|
57
|
+
usec, days, months = struct.unpack("!qii", b)
|
|
58
|
+
years = int(months / 12) # truncate toward zero, as PostgreSQL prints years and months
|
|
59
|
+
rest = months - years * 12
|
|
60
|
+
return _dt.timedelta(days=years * 365 + rest * 30 + days, microseconds=usec)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dec_timestamp_bin(b: bytes) -> _dt.datetime:
|
|
64
|
+
(usec,) = _s_i64.unpack(b)
|
|
65
|
+
_check_finite(usec)
|
|
66
|
+
return _EPOCH + _dt.timedelta(microseconds=usec)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _dec_timestamptz_bin(b: bytes) -> _dt.datetime:
|
|
70
|
+
(usec,) = _s_i64.unpack(b)
|
|
71
|
+
_check_finite(usec)
|
|
72
|
+
return (_EPOCH_UTC + _dt.timedelta(microseconds=usec)).astimezone(_dt.timezone.utc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_finite(usec: int) -> None:
|
|
76
|
+
if usec in (_I64_MAX, _I64_MIN):
|
|
77
|
+
raise errors.DataError("can't decode infinite timestamp in binary format")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _dec_date_bin(b: bytes) -> _dt.date:
|
|
81
|
+
(days,) = _s_i32.unpack(b)
|
|
82
|
+
if days in (2147483647, -2147483648):
|
|
83
|
+
raise errors.DataError("can't decode infinite date in binary format")
|
|
84
|
+
return _EPOCH_DATE + _dt.timedelta(days=days)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dec_time_bin(b: bytes) -> _dt.time:
|
|
88
|
+
(usec,) = _s_i64.unpack(b)
|
|
89
|
+
return (_dt.datetime(2000, 1, 1) + _dt.timedelta(microseconds=usec)).time()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dec_text_bin(b: bytes) -> str:
|
|
93
|
+
return b.decode("utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _dec_jsonb_bin(b: bytes) -> Any:
|
|
97
|
+
if not b or b[0] != 1:
|
|
98
|
+
raise errors.NotSupportedError("unsupported jsonb binary version")
|
|
99
|
+
return json.loads(b[1:].decode("utf-8"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _dec_float4(b: bytes) -> float:
|
|
103
|
+
return _s_f32.unpack(b)[0]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _dec_float8(b: bytes) -> float:
|
|
107
|
+
return _s_f64.unpack(b)[0]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_SCALAR_BIN: dict[int, Callable[[bytes], Any]] = {
|
|
111
|
+
BOOL: lambda b: b != b"\x00",
|
|
112
|
+
BYTEA: bytes,
|
|
113
|
+
INT2: lambda b: _s_i16.unpack(b)[0],
|
|
114
|
+
INT4: lambda b: _s_i32.unpack(b)[0],
|
|
115
|
+
INT8: lambda b: _s_i64.unpack(b)[0],
|
|
116
|
+
OID: lambda b: _s_u32.unpack(b)[0],
|
|
117
|
+
FLOAT4: _dec_float4,
|
|
118
|
+
FLOAT8: _dec_float8,
|
|
119
|
+
NUMERIC: _dec_numeric,
|
|
120
|
+
UUID: lambda b: uuid.UUID(bytes=b),
|
|
121
|
+
JSON: lambda b: json.loads(b.decode("utf-8")),
|
|
122
|
+
JSONB: _dec_jsonb_bin,
|
|
123
|
+
DATE: _dec_date_bin,
|
|
124
|
+
TIME: _dec_time_bin,
|
|
125
|
+
TIMESTAMP: _dec_timestamp_bin,
|
|
126
|
+
TIMESTAMPTZ: _dec_timestamptz_bin,
|
|
127
|
+
INTERVAL: _dec_interval,
|
|
128
|
+
TEXT: _dec_text_bin,
|
|
129
|
+
VARCHAR: _dec_text_bin,
|
|
130
|
+
BPCHAR: _dec_text_bin,
|
|
131
|
+
NAME: _dec_text_bin,
|
|
132
|
+
CHAR: _dec_text_bin,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def decode_array(b: bytes, element: Callable[[bytes], Any]) -> list:
|
|
137
|
+
ndim, _flags, _elem = struct.unpack_from("!iii", b, 0)
|
|
138
|
+
pos = 12
|
|
139
|
+
dims = []
|
|
140
|
+
for _ in range(ndim):
|
|
141
|
+
length, _lb = struct.unpack_from("!ii", b, pos)
|
|
142
|
+
dims.append(length)
|
|
143
|
+
pos += 8
|
|
144
|
+
if ndim == 0:
|
|
145
|
+
return []
|
|
146
|
+
items: list = []
|
|
147
|
+
total = 1
|
|
148
|
+
for d in dims:
|
|
149
|
+
total *= d
|
|
150
|
+
for _ in range(total):
|
|
151
|
+
(length,) = _s_i32.unpack_from(b, pos)
|
|
152
|
+
pos += 4
|
|
153
|
+
if length < 0:
|
|
154
|
+
items.append(None)
|
|
155
|
+
else:
|
|
156
|
+
items.append(element(b[pos:pos + length]))
|
|
157
|
+
pos += length
|
|
158
|
+
# fold the flat list into nested lists, outermost dimension first
|
|
159
|
+
for d in reversed(dims[1:]):
|
|
160
|
+
items = [items[i:i + d] for i in range(0, len(items), d)]
|
|
161
|
+
return items
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def scalar_decoder(oid: int) -> Callable[[bytes], Any] | None:
|
|
165
|
+
"""Binary decoder for a type (arrays included), or ``None`` if the type has no binary form here."""
|
|
166
|
+
if oid in _SCALAR_BIN:
|
|
167
|
+
return _SCALAR_BIN[oid]
|
|
168
|
+
elem = ELEMENT_OF.get(oid)
|
|
169
|
+
if elem is not None and elem in _SCALAR_BIN:
|
|
170
|
+
inner = _SCALAR_BIN[elem]
|
|
171
|
+
return lambda b: decode_array(b, inner)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------- encoders (Python -> bytes)
|
|
176
|
+
def encode_scalar(value: Any, oid: int) -> bytes | None:
|
|
177
|
+
"""Binary form of ``value`` for a parameter the server typed as ``oid``; ``None`` means
|
|
178
|
+
"send it as text instead" (always safe: the text form is what the server parses anyway)."""
|
|
179
|
+
if oid == BOOL and isinstance(value, bool):
|
|
180
|
+
return b"\x01" if value else b"\x00"
|
|
181
|
+
if oid in (INT2, INT4, INT8, OID) and isinstance(value, int) and not isinstance(value, bool):
|
|
182
|
+
fmt, lo, hi = {INT2: ("!h", -2**15, 2**15 - 1), INT4: ("!i", -2**31, 2**31 - 1),
|
|
183
|
+
INT8: ("!q", -2**63, 2**63 - 1), OID: ("!I", 0, 2**32 - 1)}[oid]
|
|
184
|
+
return struct.pack(fmt, value) if lo <= value <= hi else None
|
|
185
|
+
if oid in (FLOAT4, FLOAT8) and isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
186
|
+
return _s_f32.pack(value) if oid == FLOAT4 else _s_f64.pack(float(value))
|
|
187
|
+
if oid in (TEXT, VARCHAR, BPCHAR, NAME) and isinstance(value, str) and "\x00" not in value:
|
|
188
|
+
return value.encode("utf-8")
|
|
189
|
+
if oid == BYTEA and isinstance(value, (bytes, bytearray, memoryview)):
|
|
190
|
+
return bytes(value)
|
|
191
|
+
if oid == UUID and isinstance(value, uuid.UUID):
|
|
192
|
+
return value.bytes
|
|
193
|
+
if oid == DATE and isinstance(value, _dt.date) and not isinstance(value, _dt.datetime):
|
|
194
|
+
return _s_i32.pack((value - _EPOCH_DATE).days)
|
|
195
|
+
if oid == TIMESTAMP and isinstance(value, _dt.datetime) and value.tzinfo is None:
|
|
196
|
+
return _s_i64.pack(_usec(value - _EPOCH))
|
|
197
|
+
if oid == TIMESTAMPTZ and isinstance(value, _dt.datetime) and value.tzinfo is not None:
|
|
198
|
+
return _s_i64.pack(_usec(value.astimezone(_dt.timezone.utc) - _EPOCH_UTC))
|
|
199
|
+
if oid == TIME and isinstance(value, _dt.time) and value.tzinfo is None:
|
|
200
|
+
t = _dt.timedelta(hours=value.hour, minutes=value.minute, seconds=value.second, microseconds=value.microsecond)
|
|
201
|
+
return _s_i64.pack(_usec(t))
|
|
202
|
+
if oid == INTERVAL and isinstance(value, _dt.timedelta):
|
|
203
|
+
return struct.pack("!qii", _usec(value), 0, 0)
|
|
204
|
+
if oid in ARRAY_OF.values() and isinstance(value, list): # a one-dimensional array of scalars
|
|
205
|
+
elem = ELEMENT_OF.get(oid)
|
|
206
|
+
return _encode_array(value, elem) if elem is not None else None
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _usec(delta: _dt.timedelta) -> int:
|
|
211
|
+
return (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _encode_array(items: list, elem: int) -> bytes | None:
|
|
215
|
+
if any(isinstance(v, list) for v in items):
|
|
216
|
+
return None # multi-dimensional: text
|
|
217
|
+
parts = [struct.pack("!iii", 1, 0, elem), struct.pack("!ii", len(items), 1)]
|
|
218
|
+
for v in items:
|
|
219
|
+
if v is None:
|
|
220
|
+
parts.append(_s_i32.pack(-1))
|
|
221
|
+
continue
|
|
222
|
+
data = encode_scalar(v, elem)
|
|
223
|
+
if data is None:
|
|
224
|
+
return None
|
|
225
|
+
parts.append(_s_i32.pack(len(data)) + data)
|
|
226
|
+
return b"".join(parts)
|
pgtern/_composite.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Text-format parsing and formatting for PostgreSQL records (composite types) and ranges."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, Sequence
|
|
7
|
+
|
|
8
|
+
from . import errors
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_record(text: str) -> list[str | None]:
|
|
12
|
+
"""``(a,"b c",,"d""e")`` -> ``["a", "b c", None, 'd"e']``. An unquoted empty field is NULL."""
|
|
13
|
+
if len(text) < 2 or text[0] != "(" or text[-1] != ")":
|
|
14
|
+
raise errors.DataError(f"malformed record literal: {text!r}")
|
|
15
|
+
s = text[1:-1]
|
|
16
|
+
n = len(s)
|
|
17
|
+
if n == 0:
|
|
18
|
+
return [None]
|
|
19
|
+
out: list[str | None] = []
|
|
20
|
+
i = 0
|
|
21
|
+
while True:
|
|
22
|
+
buf: list[str] = []
|
|
23
|
+
quoted = False
|
|
24
|
+
if i < n and s[i] == '"':
|
|
25
|
+
quoted = True
|
|
26
|
+
i += 1
|
|
27
|
+
while i < n:
|
|
28
|
+
c = s[i]
|
|
29
|
+
if c == "\\":
|
|
30
|
+
if i + 1 >= n:
|
|
31
|
+
raise errors.DataError(f"malformed record literal: {text!r}")
|
|
32
|
+
buf.append(s[i + 1])
|
|
33
|
+
i += 2
|
|
34
|
+
elif c == '"':
|
|
35
|
+
if i + 1 < n and s[i + 1] == '"':
|
|
36
|
+
buf.append('"')
|
|
37
|
+
i += 2
|
|
38
|
+
else:
|
|
39
|
+
i += 1
|
|
40
|
+
break
|
|
41
|
+
else:
|
|
42
|
+
buf.append(c)
|
|
43
|
+
i += 1
|
|
44
|
+
else:
|
|
45
|
+
while i < n and s[i] != ",":
|
|
46
|
+
if s[i] == "\\" and i + 1 < n:
|
|
47
|
+
buf.append(s[i + 1])
|
|
48
|
+
i += 2
|
|
49
|
+
else:
|
|
50
|
+
buf.append(s[i])
|
|
51
|
+
i += 1
|
|
52
|
+
out.append("".join(buf) if (quoted or buf) else None)
|
|
53
|
+
if i >= n:
|
|
54
|
+
break
|
|
55
|
+
if s[i] != ",":
|
|
56
|
+
raise errors.DataError(f"malformed record literal: {text!r}")
|
|
57
|
+
i += 1
|
|
58
|
+
if i == n: # trailing comma: one more, empty, field
|
|
59
|
+
out.append(None)
|
|
60
|
+
break
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _quote_field(value: str) -> str:
|
|
65
|
+
if value == "" or any(c in value for c in ',()"\\') or value.isspace() or value != value.strip():
|
|
66
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '""') + '"'
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_record(values: Sequence[str | None]) -> str:
|
|
71
|
+
"""Inverse of :func:`parse_record`: values are already text (``None`` is NULL)."""
|
|
72
|
+
if len(values) == 0:
|
|
73
|
+
return "()"
|
|
74
|
+
return "(" + ",".join("" if v is None else _quote_field(v) for v in values) + ")"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Range:
|
|
79
|
+
"""A PostgreSQL range. ``lower``/``upper`` are ``None`` when unbounded (or when ``empty``)."""
|
|
80
|
+
|
|
81
|
+
lower: Any = None
|
|
82
|
+
upper: Any = None
|
|
83
|
+
lower_inc: bool = True
|
|
84
|
+
upper_inc: bool = False
|
|
85
|
+
empty: bool = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_range(text: str, element: Callable[[str], Any] | None = None) -> Range:
|
|
89
|
+
if text == "empty":
|
|
90
|
+
return Range(empty=True, lower_inc=False, upper_inc=False)
|
|
91
|
+
if len(text) < 2 or text[0] not in "[(" or text[-1] not in "])":
|
|
92
|
+
raise errors.DataError(f"malformed range literal: {text!r}")
|
|
93
|
+
lo, hi = parse_record("(" + text[1:-1] + ")")[:2] if text[1:-1] else (None, None)
|
|
94
|
+
conv = (lambda v: v if v is None or element is None else element(v))
|
|
95
|
+
return Range(conv(lo), conv(hi), text[0] == "[", text[-1] == "]")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def format_range(r: Range, to_text: Callable[[Any], str] = str) -> str:
|
|
99
|
+
if r.empty:
|
|
100
|
+
return "empty"
|
|
101
|
+
lo = "" if r.lower is None else _quote_field(to_text(r.lower))
|
|
102
|
+
hi = "" if r.upper is None else _quote_field(to_text(r.upper))
|
|
103
|
+
return ("[" if r.lower_inc else "(") + lo + "," + hi + ("]" if r.upper_inc else ")")
|