vastgrid 0.2.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.
- vastgrid/__init__.py +6 -0
- vastgrid/_block.py +147 -0
- vastgrid/_calendar.py +212 -0
- vastgrid/_check.py +860 -0
- vastgrid/_columns.py +133 -0
- vastgrid/_engine.py +1468 -0
- vastgrid/_export.py +324 -0
- vastgrid/_hash.py +54 -0
- vastgrid/_json.py +80 -0
- vastgrid/_messages.py +340 -0
- vastgrid/_plan.py +151 -0
- vastgrid/_sql.py +602 -0
- vastgrid/_values.py +531 -0
- vastgrid/_widget.py +274 -0
- vastgrid/static/widget.css +1 -0
- vastgrid/static/widget.js +18 -0
- vastgrid-0.2.0.dist-info/METADATA +43 -0
- vastgrid-0.2.0.dist-info/RECORD +19 -0
- vastgrid-0.2.0.dist-info/WHEEL +4 -0
vastgrid/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""vastgrid in notebooks: tables of any size in vastgrid's grid, with the engine in the kernel (docs/notebook.md)."""
|
|
2
|
+
|
|
3
|
+
from ._engine import Engine, EngineError, Source
|
|
4
|
+
from ._widget import Grid, show
|
|
5
|
+
|
|
6
|
+
__all__ = ["Engine", "EngineError", "Grid", "Source", "show"]
|
vastgrid/_block.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Blocks (docs/spec.md §8.4): rows of typed JSON (§2.2) as an Apache Arrow IPC stream with one schema and one
|
|
2
|
+
record batch, written with the Arrow types of §2.1, as vastgrid-spec's `encodeBlock` writes them."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pyarrow as pa
|
|
12
|
+
|
|
13
|
+
from ._calendar import parse_date_text, parse_interval_text, parse_time_text, parse_timestamp_text
|
|
14
|
+
from ._values import is_value_of, type_name
|
|
15
|
+
|
|
16
|
+
UUID_EXTENSION = (b"ARROW:extension:name", b"arrow.uuid")
|
|
17
|
+
|
|
18
|
+
_FLOAT_TOKENS = {"NaN": float("nan"), "Infinity": float("inf"), "-Infinity": float("-inf")}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BlockValueError(TypeError):
|
|
22
|
+
"""A value that cannot be written without loss; the path is relative to the block's rows."""
|
|
23
|
+
|
|
24
|
+
reason = "type"
|
|
25
|
+
|
|
26
|
+
def __init__(self, path: str, column: dict[str, Any]):
|
|
27
|
+
super().__init__(f"The value at {path} is not a {type_name(column)} value within the ranges of §2.2.")
|
|
28
|
+
self.path = path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def arrow_type(column: dict[str, Any]) -> pa.DataType:
|
|
32
|
+
"""The Arrow type of a logical type (§8.4)."""
|
|
33
|
+
kind = column["type"]
|
|
34
|
+
if kind == "boolean":
|
|
35
|
+
return pa.bool_()
|
|
36
|
+
if kind == "integer":
|
|
37
|
+
return pa.int32()
|
|
38
|
+
if kind == "bigint":
|
|
39
|
+
return pa.int64()
|
|
40
|
+
if kind == "decimal":
|
|
41
|
+
return pa.decimal128(column.get("precision", 38), column.get("scale", 0))
|
|
42
|
+
if kind == "float":
|
|
43
|
+
return pa.float32() if column.get("bits") == 32 else pa.float64()
|
|
44
|
+
if kind == "string":
|
|
45
|
+
return pa.string()
|
|
46
|
+
if kind == "date":
|
|
47
|
+
return pa.date32()
|
|
48
|
+
if kind == "time":
|
|
49
|
+
return pa.time64("us")
|
|
50
|
+
if kind == "timestamp":
|
|
51
|
+
return pa.timestamp(column.get("unit", "us"))
|
|
52
|
+
if kind == "timestamptz":
|
|
53
|
+
return pa.timestamp("us", tz="UTC")
|
|
54
|
+
if kind == "interval":
|
|
55
|
+
return pa.month_day_nano_interval()
|
|
56
|
+
if kind == "uuid":
|
|
57
|
+
return pa.binary(16)
|
|
58
|
+
if kind == "binary":
|
|
59
|
+
return pa.binary()
|
|
60
|
+
raise ValueError(f"{kind} is not a logical type.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decimal(value: str) -> Decimal:
|
|
64
|
+
return Decimal(value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _float(value: Any) -> float:
|
|
68
|
+
return _FLOAT_TOKENS[value] if isinstance(value, str) else float(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _count(column: dict[str, Any], text: str) -> int:
|
|
72
|
+
kind = column["type"]
|
|
73
|
+
if kind == "date":
|
|
74
|
+
return parse_date_text(text)
|
|
75
|
+
if kind == "time":
|
|
76
|
+
return parse_time_text(text)
|
|
77
|
+
if kind == "timestamptz":
|
|
78
|
+
return parse_timestamp_text(text[:-3], "us")
|
|
79
|
+
return parse_timestamp_text(text, column.get("unit", "us"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _interval(text: str) -> pa.MonthDayNano:
|
|
83
|
+
parts = parse_interval_text(text)
|
|
84
|
+
return pa.MonthDayNano([parts.months, parts.days, parts.nanos])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_CONVERT = {
|
|
88
|
+
"boolean": None,
|
|
89
|
+
"integer": None,
|
|
90
|
+
"bigint": int,
|
|
91
|
+
"decimal": _decimal,
|
|
92
|
+
"float": _float,
|
|
93
|
+
"string": None,
|
|
94
|
+
"date": None,
|
|
95
|
+
"time": None,
|
|
96
|
+
"timestamp": None,
|
|
97
|
+
"timestamptz": None,
|
|
98
|
+
"interval": _interval,
|
|
99
|
+
"uuid": lambda value: bytes.fromhex(value.replace("-", "")),
|
|
100
|
+
"binary": base64.b64decode,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _array(column: dict[str, Any], values: list[Any]) -> pa.Array:
|
|
105
|
+
kind = column["type"]
|
|
106
|
+
if kind in ("date", "time", "timestamp", "timestamptz"):
|
|
107
|
+
counts = [None if value is None else _count(column, value) for value in values]
|
|
108
|
+
storage = pa.array(counts, pa.int32() if kind == "date" else pa.int64())
|
|
109
|
+
return storage.view(arrow_type(column))
|
|
110
|
+
convert = _CONVERT[kind]
|
|
111
|
+
if convert is not None:
|
|
112
|
+
values = [None if value is None else convert(value) for value in values]
|
|
113
|
+
return pa.array(values, arrow_type(column))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def schema_of(columns: Sequence[dict[str, Any]]) -> pa.Schema:
|
|
117
|
+
return pa.schema(
|
|
118
|
+
[
|
|
119
|
+
pa.field(
|
|
120
|
+
column["name"],
|
|
121
|
+
arrow_type(column),
|
|
122
|
+
nullable=True,
|
|
123
|
+
metadata=dict([UUID_EXTENSION]) if column["type"] == "uuid" else None,
|
|
124
|
+
)
|
|
125
|
+
for column in columns
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def encode_block(columns: Sequence[dict[str, Any]], rows: Sequence[Sequence[Any]]) -> bytes:
|
|
131
|
+
"""Writes rows (typed JSON, §2.2) of columns as a block (§8.4); a value outside §2.2 raises `BlockValueError`."""
|
|
132
|
+
arrays = []
|
|
133
|
+
for index, column in enumerate(columns):
|
|
134
|
+
values = []
|
|
135
|
+
for at, row in enumerate(rows):
|
|
136
|
+
value = row[index]
|
|
137
|
+
if not is_value_of(value, column):
|
|
138
|
+
raise BlockValueError(f"/rows/{at}/{index}", column)
|
|
139
|
+
values.append(value)
|
|
140
|
+
arrays.append(_array(column, values))
|
|
141
|
+
schema = schema_of(columns)
|
|
142
|
+
sink = pa.BufferOutputStream()
|
|
143
|
+
with pa.ipc.new_stream(sink, schema) as writer:
|
|
144
|
+
# A block without columns has its schema only: the answer gives its positions (§8.4).
|
|
145
|
+
if columns:
|
|
146
|
+
writer.write_batch(pa.record_batch(arrays, schema=schema))
|
|
147
|
+
return sink.getvalue().to_pybytes()
|
vastgrid/_calendar.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Dates and times as integers and as text (docs/spec.md §2.2): days since 1970-01-01 in the proleptic Gregorian
|
|
2
|
+
calendar, and counts of seconds, milliseconds, microseconds or nanoseconds. Years are from 1 to 9999, the years that
|
|
3
|
+
the text of §2.2 writes. Python ints stand in for JavaScript's bigints."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
UNIT_PER_SECOND: dict[str, int] = {"s": 1, "ms": 1000, "us": 1_000_000, "ns": 1_000_000_000}
|
|
11
|
+
"""Counts of a unit in a second."""
|
|
12
|
+
|
|
13
|
+
UNIT_DIGITS: dict[str, int] = {"s": 0, "ms": 3, "us": 6, "ns": 9}
|
|
14
|
+
"""Digits after the point that each unit keeps."""
|
|
15
|
+
|
|
16
|
+
_DAY_SECONDS = 86_400
|
|
17
|
+
_INT64_MIN = -(2**63)
|
|
18
|
+
_INT64_MAX = 2**63 - 1
|
|
19
|
+
_NANOS_PER_DAY = 86_400_000_000_000
|
|
20
|
+
|
|
21
|
+
_DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})")
|
|
22
|
+
_CLOCK = re.compile(r"([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?")
|
|
23
|
+
_INTERVAL = re.compile(
|
|
24
|
+
r"(?:(-?[0-9]+) years?(?: |\Z))?(?:(-?[0-9]+) months?(?: |\Z))?(?:(-?[0-9]+) days?(?: |\Z))?"
|
|
25
|
+
r"(?:(-?)([0-9]{2,}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,9}))?)?"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _trunc_div(a: int, b: int) -> int:
|
|
30
|
+
"""`a / b` of bigints: rounded toward zero."""
|
|
31
|
+
quotient = abs(a) // abs(b)
|
|
32
|
+
return quotient if (a < 0) == (b < 0) else -quotient
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _trunc_rem(a: int, b: int) -> int:
|
|
36
|
+
"""`a % b` of bigints: the sign of `a`."""
|
|
37
|
+
return a - b * _trunc_div(a, b)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def days_from_civil(year: int, month: int, day: int) -> int:
|
|
41
|
+
"""Days from 1970-01-01 to a date (Howard Hinnant's `days_from_civil`)."""
|
|
42
|
+
y = year - 1 if month <= 2 else year
|
|
43
|
+
era = y // 400
|
|
44
|
+
yoe = y - era * 400
|
|
45
|
+
doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
|
|
46
|
+
doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
|
|
47
|
+
return era * 146097 + doe - 719468
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def civil_from_days(days: int) -> tuple[int, int, int]:
|
|
51
|
+
"""The year, month and day of a count of days from 1970-01-01 (Howard Hinnant's `civil_from_days`)."""
|
|
52
|
+
z = days + 719468
|
|
53
|
+
era = z // 146097
|
|
54
|
+
doe = z - era * 146097
|
|
55
|
+
yoe = (doe - doe // 1460 + doe // 36524 - doe // 146096) // 365
|
|
56
|
+
doy = doe - (365 * yoe + yoe // 4 - yoe // 100)
|
|
57
|
+
mp = (5 * doy + 2) // 153
|
|
58
|
+
day = doy - (153 * mp + 2) // 5 + 1
|
|
59
|
+
month = mp + (3 if mp < 10 else -9)
|
|
60
|
+
return (yoe + era * 400 + (1 if month <= 2 else 0), month, day)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _pad(value: int, length: int) -> str:
|
|
64
|
+
return str(value).rjust(length, "0")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def date_text(days: int) -> str:
|
|
68
|
+
"""`YYYY-MM-DD` of a count of days. Raises for years outside 1 to 9999."""
|
|
69
|
+
year, month, day = civil_from_days(days)
|
|
70
|
+
if year < 1 or year > 9999:
|
|
71
|
+
raise ValueError(f"The year {year} is outside 1 to 9999.")
|
|
72
|
+
return f"{_pad(year, 4)}-{_pad(month, 2)}-{_pad(day, 2)}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_date_text(text: str) -> int | None:
|
|
76
|
+
"""The count of days of `YYYY-MM-DD`, or None when the text is not a real day of the years 1 to 9999."""
|
|
77
|
+
match = _DATE.fullmatch(text)
|
|
78
|
+
if not match:
|
|
79
|
+
return None
|
|
80
|
+
year, month, day = int(match[1]), int(match[2]), int(match[3])
|
|
81
|
+
if year < 1 or month < 1 or month > 12 or day < 1:
|
|
82
|
+
return None
|
|
83
|
+
days = days_from_civil(year, month, day)
|
|
84
|
+
return days if civil_from_days(days) == (year, month, day) else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _clock(count: int, unit: str) -> str:
|
|
88
|
+
"""`HH:MM:SS`, then the fraction of a second without trailing zeros, of a count of a unit within a day."""
|
|
89
|
+
per_second = UNIT_PER_SECOND[unit]
|
|
90
|
+
seconds = _trunc_div(count, per_second)
|
|
91
|
+
fraction = _trunc_rem(count, per_second)
|
|
92
|
+
hours = _trunc_div(seconds, 3600)
|
|
93
|
+
minutes = _trunc_rem(_trunc_div(seconds, 60), 60)
|
|
94
|
+
rest = _trunc_rem(seconds, 60)
|
|
95
|
+
text = f"{_pad(hours, 2)}:{_pad(minutes, 2)}:{_pad(rest, 2)}"
|
|
96
|
+
if fraction != 0:
|
|
97
|
+
text += "." + _pad(fraction, UNIT_DIGITS[unit]).rstrip("0")
|
|
98
|
+
return text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def time_text(micros: int) -> str:
|
|
102
|
+
"""The text of a time of day (§2.2) from microseconds since midnight."""
|
|
103
|
+
if micros < 0 or micros >= _DAY_SECONDS * 1_000_000:
|
|
104
|
+
raise ValueError("A time is within one day.")
|
|
105
|
+
return _clock(micros, "us")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_clock(text: str, unit: str) -> int | None:
|
|
109
|
+
"""Reads `HH:MM:SS` with up to the unit's fraction digits as a count of the unit since midnight, or None."""
|
|
110
|
+
match = _CLOCK.fullmatch(text)
|
|
111
|
+
if not match:
|
|
112
|
+
return None
|
|
113
|
+
hours, minutes, seconds = int(match[1]), int(match[2]), int(match[3])
|
|
114
|
+
fraction = match[4] or ""
|
|
115
|
+
if hours > 23 or minutes > 59 or seconds > 59 or len(fraction) > UNIT_DIGITS[unit]:
|
|
116
|
+
return None
|
|
117
|
+
whole = (hours * 3600 + minutes * 60 + seconds) * UNIT_PER_SECOND[unit]
|
|
118
|
+
return whole + (int(fraction.ljust(UNIT_DIGITS[unit], "0")) if fraction else 0)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_time_text(text: str) -> int | None:
|
|
122
|
+
"""Microseconds since midnight of the text of a time (§2.2), or None."""
|
|
123
|
+
return _parse_clock(text, "us")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def timestamp_text(count: int, unit: str = "us") -> str:
|
|
127
|
+
"""The text of a timestamp (§2.2) from a count of its unit since 1970-01-01 00:00:00."""
|
|
128
|
+
per_day = _DAY_SECONDS * UNIT_PER_SECOND[unit]
|
|
129
|
+
days = count // per_day
|
|
130
|
+
return f"{date_text(days)} {_clock(count - days * per_day, unit)}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_timestamp_text(text: str, unit: str = "us") -> int | None:
|
|
134
|
+
"""The count of the unit since 1970-01-01 00:00:00 of the text of a timestamp (§2.2), or None."""
|
|
135
|
+
if text.find(" ") != 10:
|
|
136
|
+
return None
|
|
137
|
+
days = parse_date_text(text[:10])
|
|
138
|
+
time = _parse_clock(text[11:], unit)
|
|
139
|
+
if days is None or time is None:
|
|
140
|
+
return None
|
|
141
|
+
count = days * _DAY_SECONDS * UNIT_PER_SECOND[unit] + time
|
|
142
|
+
return count if _INT64_MIN <= count <= _INT64_MAX else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class IntervalParts:
|
|
147
|
+
"""An interval (§2.1): months, days and nanoseconds."""
|
|
148
|
+
|
|
149
|
+
months: int
|
|
150
|
+
days: int
|
|
151
|
+
nanos: int
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _count(value: int, unit: str) -> str:
|
|
155
|
+
return f"{value} {unit}{'' if value in (1, -1) else 's'}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def interval_text(parts: IntervalParts) -> str:
|
|
159
|
+
"""DuckDB's text of an interval (§2.4): years and months, days, then the time as `HH:MM:SS` with the fraction
|
|
160
|
+
without trailing zeros, each left out when it is 0 (`00:00:00` when everything is), as
|
|
161
|
+
`1 year 2 months 3 days 04:05:06.789` or `-01:30:00`. Nanoseconds beyond DuckDB's microseconds add digits."""
|
|
162
|
+
out: list[str] = []
|
|
163
|
+
years = _trunc_div(parts.months, 12)
|
|
164
|
+
rest = parts.months - years * 12
|
|
165
|
+
if years != 0:
|
|
166
|
+
out.append(_count(years, "year"))
|
|
167
|
+
if rest != 0:
|
|
168
|
+
out.append(_count(rest, "month"))
|
|
169
|
+
if parts.days != 0:
|
|
170
|
+
out.append(_count(parts.days, "day"))
|
|
171
|
+
if parts.nanos != 0:
|
|
172
|
+
magnitude = abs(parts.nanos)
|
|
173
|
+
seconds, fraction = divmod(magnitude, 1_000_000_000)
|
|
174
|
+
time = f"{_pad(seconds // 3600, 2)}:{_pad(seconds // 60 % 60, 2)}:{_pad(seconds % 60, 2)}"
|
|
175
|
+
if fraction != 0:
|
|
176
|
+
time += "." + _pad(fraction, 9).rstrip("0")
|
|
177
|
+
out.append(("-" if parts.nanos < 0 else "") + time)
|
|
178
|
+
return " ".join(out) if out else "00:00:00"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse_interval_text(text: str) -> IntervalParts | None:
|
|
182
|
+
"""The parts of DuckDB's text of an interval, as `interval_text` writes it, or None."""
|
|
183
|
+
match = _INTERVAL.fullmatch(text)
|
|
184
|
+
if not match or text == "" or text.endswith(" "):
|
|
185
|
+
return None
|
|
186
|
+
years, months, days = (int(part or 0) for part in (match[1], match[2], match[3]))
|
|
187
|
+
if int(match[6] or 0) > 59 or int(match[7] or 0) > 59:
|
|
188
|
+
return None
|
|
189
|
+
nanos = 0
|
|
190
|
+
if match[5] is not None:
|
|
191
|
+
seconds = int(match[5]) * 3600 + int(match[6]) * 60 + int(match[7])
|
|
192
|
+
nanos = seconds * 1_000_000_000 + int((match[8] or "").ljust(9, "0"))
|
|
193
|
+
if match[4] == "-":
|
|
194
|
+
nanos = -nanos
|
|
195
|
+
total = years * 12 + months
|
|
196
|
+
if not (-2147483648 <= total <= 2147483647 and -2147483648 <= days <= 2147483647):
|
|
197
|
+
return None
|
|
198
|
+
if not _INT64_MIN <= nanos <= _INT64_MAX:
|
|
199
|
+
return None
|
|
200
|
+
return IntervalParts(total, days, nanos)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def normalize_interval(text: str) -> tuple[int, int, int] | None:
|
|
204
|
+
"""The parts of an interval as DuckDB 2.0 compares them (§3.1): its length in nanoseconds, with months of 30 days
|
|
205
|
+
and days of 24 hours, as whole months, then the days (0 to 29) and the nanoseconds (less than a day) of the rest.
|
|
206
|
+
Intervals compare by these months, then days, then nanoseconds; `1 month` equals `30 days`, and `1 month -1 day`
|
|
207
|
+
equals `29 days`. None when the text is not an interval."""
|
|
208
|
+
parts = parse_interval_text(text)
|
|
209
|
+
if parts is None:
|
|
210
|
+
return None
|
|
211
|
+
months, rest = divmod((parts.months * 30 + parts.days) * _NANOS_PER_DAY + parts.nanos, 30 * _NANOS_PER_DAY)
|
|
212
|
+
return (months, *divmod(rest, _NANOS_PER_DAY))
|