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 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))