sqlite-dateutil 0.1a0__py3-none-win_amd64.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.
@@ -0,0 +1,90 @@
1
+ """Python helpers for the sqlite-dateutil SQLite extension."""
2
+ import os.path
3
+ import sys
4
+
5
+ try:
6
+ import sqlite3
7
+ except ModuleNotFoundError as e:
8
+ if sys.platform == "emscripten":
9
+ raise ModuleNotFoundError(
10
+ "sqlite-dateutil needs Pyodide's sqlite3 package: run "
11
+ "`await micropip.install('sqlite3')` (or pyodide.loadPackage('sqlite3') "
12
+ "from JavaScript) before importing sqlite_dateutil"
13
+ ) from e
14
+ raise
15
+
16
+ __version__ = "0.1a0"
17
+ __version_info__ = tuple(__version__.split("."))
18
+
19
+
20
+ def loadable_path() -> str:
21
+ """Path to the bundled extension, without its file suffix (as SQLite expects)."""
22
+ return os.path.normpath(os.path.join(os.path.dirname(__file__), "dateutil_parse"))
23
+
24
+
25
+ if sys.platform == "emscripten":
26
+ # Pyodide's sqlite3 module is built without load_extension(). Its SQLite
27
+ # is still reachable with ctypes, so register the extension with
28
+ # sqlite3_auto_extension(): every connection opened after this import
29
+ # gets the functions. Pyodide 314+ links SQLite into the main module;
30
+ # older releases ship it inside the separate _sqlite3 shared library.
31
+ #
32
+ # SQLite passes the init function its table of API functions, but a
33
+ # SQLite built without load-extension support (Pyodide 0.28/0.29) passes
34
+ # NULL. So hand the extension the address of every function it uses
35
+ # first; tools/pyodide_shim.c falls back to those when the table is NULL.
36
+ import ctypes
37
+
38
+ def _find_sqlite():
39
+ import _sqlite3
40
+
41
+ candidates = [None]
42
+ if getattr(_sqlite3, "__file__", None):
43
+ candidates.append(_sqlite3.__file__)
44
+ for candidate in candidates:
45
+ lib = ctypes.CDLL(candidate)
46
+ if hasattr(lib, "sqlite3_auto_extension"):
47
+ return lib
48
+ raise ImportError("sqlite-dateutil could not find SQLite's C API in this Pyodide")
49
+
50
+ def _address(func):
51
+ return ctypes.cast(func, ctypes.c_void_p).value
52
+
53
+ _sqlite = _find_sqlite()
54
+ _lib = ctypes.CDLL(loadable_path() + ".dll")
55
+ _lib.dateutil_pyodide_set_api.argtypes = [ctypes.c_char_p, ctypes.c_void_p]
56
+ _lib.dateutil_pyodide_set_api.restype = ctypes.c_int
57
+ _names = ctypes.string_at( # a C char array, so take its address
58
+ ctypes.addressof(ctypes.c_char.in_dll(_lib, "dateutil_pyodide_api_names")))
59
+ for _name in _names.decode().strip(",").split(","):
60
+ _fn = getattr(_sqlite, "sqlite3_" + _name, None)
61
+ if _fn is None or _lib.dateutil_pyodide_set_api(_name.encode(), _address(_fn)) != 0:
62
+ raise ImportError("sqlite-dateutil could not find sqlite3_" + _name)
63
+ _sqlite.sqlite3_auto_extension.argtypes = [ctypes.c_void_p]
64
+ _sqlite.sqlite3_auto_extension.restype = ctypes.c_int
65
+ _rc = _sqlite.sqlite3_auto_extension(_address(_lib.dateutil_pyodide_init))
66
+ if _rc != 0:
67
+ raise ImportError("sqlite3_auto_extension() failed with code %d" % _rc)
68
+
69
+
70
+ def load(conn: sqlite3.Connection) -> None:
71
+ """Load the extension into conn.
72
+
73
+ Extension loading is switched on for the call and back off afterwards.
74
+ In Pyodide the extension is registered when this module is imported, so
75
+ connections opened after the import already have the functions.
76
+ """
77
+ if sys.platform == "emscripten":
78
+ try:
79
+ conn.execute("select dateutil_parse('')")
80
+ except sqlite3.OperationalError:
81
+ raise RuntimeError(
82
+ "In Pyodide, sqlite_dateutil must be imported before the "
83
+ "connection is opened: this connection predates the import"
84
+ ) from None
85
+ return
86
+ conn.enable_load_extension(True)
87
+ try:
88
+ conn.load_extension(loadable_path())
89
+ finally:
90
+ conn.enable_load_extension(False)
Binary file
@@ -0,0 +1,260 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite-dateutil
3
+ Version: 0.1a0
4
+ Summary: A SQLite extension porting dateutil.parser.parse() to C
5
+ Author: Simon Willison
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Project-URL: Homepage, https://github.com/simonw/sqlite-dateutil
9
+ Project-URL: Issues, https://github.com/simonw/sqlite-dateutil/issues
10
+ Project-URL: Changelog, https://github.com/simonw/sqlite-dateutil/releases
11
+ Project-URL: CI, https://github.com/simonw/sqlite-dateutil/actions
12
+ Description-Content-Type: text/markdown
13
+
14
+ # sqlite-dateutil
15
+
16
+ A SQLite extension, written in C, that ports [python-dateutil](https://github.com/dateutil/dateutil)'s
17
+ `dateutil.parser.parse()` to a set of SQL functions.
18
+
19
+ ```sql
20
+ select dateutil_parse('Thu, 25 Sep 2003 10:49:41 -0300');
21
+ -- 2003-09-25T10:49:41-03:00
22
+
23
+ select dateutil_parse_fuzzy('Today is January 1, 2047 at 8:21:00AM');
24
+ -- 2047-01-01T08:21:00
25
+
26
+ select dateutil_parse('10/09/2003'), dateutil_parse_dayfirst('10/09/2003');
27
+ -- 2003-10-09T00:00:00 | 2003-09-10T00:00:00
28
+
29
+ select dateutil_parse('not a date');
30
+ -- NULL
31
+ ```
32
+
33
+ | SQL function | Python equivalent |
34
+ | --- | --- |
35
+ | `dateutil_parse(text)` | `parse(text).isoformat()` |
36
+ | `dateutil_parse_fuzzy(text)` | `parse(text, fuzzy=True).isoformat()` |
37
+ | `dateutil_parse_dayfirst(text)` | `parse(text, dayfirst=True).isoformat()` |
38
+ | `dateutil_parse_fuzzy_dayfirst(text)` | `parse(text, fuzzy=True, dayfirst=True).isoformat()` |
39
+
40
+ The function names match the [datasette-dateutil](https://github.com/simonw/datasette-dateutil) plugin.
41
+ Where dateutil raises an exception, these functions return `NULL`. Missing fields come from
42
+ today's local date at midnight, as in dateutil (so `dateutil_parse('10:30')` is today at 10:30).
43
+ That also means the functions aren't marked deterministic.
44
+
45
+ ## Installation
46
+
47
+ The extension is packaged for Python, with prebuilt wheels for macOS (Apple Silicon and
48
+ Intel), Linux (x86_64 and aarch64) and Windows (x64):
49
+
50
+ ```bash
51
+ pip install sqlite-dateutil
52
+ ```
53
+
54
+ ```python
55
+ import sqlite3
56
+ import sqlite_dateutil
57
+
58
+ db = sqlite3.connect(":memory:")
59
+ sqlite_dateutil.load(db)
60
+ print(db.execute("select dateutil_parse('Sep 25 2003 5pm')").fetchone()[0])
61
+ # 2003-09-25T17:00:00
62
+ ```
63
+
64
+ `load()` turns extension loading on just long enough to load the extension, then turns it
65
+ off again. `sqlite_dateutil.loadable_path()` gives the path to the bundled library for use
66
+ with other tools.
67
+
68
+ ### Pyodide
69
+
70
+ There are also wheels for [Pyodide](https://pyodide.org/):
71
+
72
+ | Pyodide | Wheel tag |
73
+ | --- | --- |
74
+ | 314.x | `pyemscripten_2026_0_wasm32` |
75
+ | 0.28.x, 0.29.x | `pyodide_2025_0_wasm32` |
76
+
77
+ Pyodide's `sqlite3` module can't load extensions, so in Pyodide `import sqlite_dateutil`
78
+ registers the functions with SQLite's `sqlite3_auto_extension()` instead. Every connection
79
+ opened **after the import** has them, and `load(conn)` just checks that:
80
+
81
+ ```python
82
+ import micropip
83
+ await micropip.install("sqlite-dateutil")
84
+ # Pyodide 0.28/0.29 only: sqlite3 is a separate package there
85
+ # await micropip.install("sqlite3")
86
+
87
+ import sqlite3
88
+ import sqlite_dateutil # import before connecting
89
+
90
+ db = sqlite3.connect(":memory:")
91
+ print(db.execute("select dateutil_parse('Sep 25 2003 5pm')").fetchone()[0])
92
+ ```
93
+
94
+ All of these Pyodide releases link in SQLite 3.39.0, older than the headers in
95
+ `vendor/sqlite`, but the extension only uses APIs that SQLite had long before that version.
96
+ Pyodide 0.28/0.29 build SQLite without load-extension support, so SQLite hands extensions no
97
+ table of its API functions. `tools/pyodide_shim.c` gets those function addresses from Python
98
+ instead.
99
+
100
+ There are also plugins that add the functions to every connection:
101
+
102
+ ```bash
103
+ datasette install datasette-sqlite-dateutil
104
+ sqlite-utils install sqlite-utils-sqlite-dateutil
105
+ ```
106
+
107
+ ## Building
108
+
109
+ ```bash
110
+ uv run python tools/build.py # or just: make
111
+ ```
112
+
113
+ This builds `dateutil_parse.dylib` on macOS, `.so` on Linux, or `.dll` on Windows. The
114
+ build script uses only the Python standard library and picks the compiler from `$CC`, or
115
+ falls back to `cc`. On Windows it uses MSVC `cl` from a Native Tools prompt, or MinGW `gcc` /
116
+ `clang`. It compiles against the SQLite headers in `vendor/sqlite/`, never the system
117
+ headers, because some system copies (notably the macOS SDK's `sqlite3ext.h`) turn off the
118
+ loadable-extension API.
119
+
120
+ ```
121
+ sqlite> .load ./dateutil_parse
122
+ sqlite> select dateutil_parse('Sep 25 2003 5pm');
123
+ 2003-09-25T17:00:00
124
+ ```
125
+
126
+ ```python
127
+ import sqlite3
128
+ db = sqlite3.connect(":memory:")
129
+ db.enable_load_extension(True)
130
+ db.load_extension("./dateutil_parse")
131
+ ```
132
+
133
+ The extension is `dateutil_parse.c` plus the generated `dateutil_unicode.h`. It has no
134
+ dependencies beyond the SQLite headers. The entry point is `sqlite3_dateutilparse_init`.
135
+
136
+ ## Tests
137
+
138
+ ```bash
139
+ uv run pytest
140
+ ```
141
+
142
+ That is all you need: uv creates the environment, and the test suite compiles the extension
143
+ first whenever it is missing or older than its sources. The project is set to use only
144
+ uv-managed Pythons, because some system Pythons, such as Apple's `/usr/bin/python3`, are
145
+ built without `enable_load_extension()`. The sanitizer tests are skipped when ASan/UBSan
146
+ isn't available (for example with MSVC).
147
+
148
+ The test suite (`tests/`) is differential: for every input it checks that the SQL functions
149
+ return exactly what `dateutil.parser.parse(...).isoformat()` returns, or `NULL` where dateutil
150
+ raises. It covers:
151
+
152
+ - 190 hand-picked inputs (ISO 8601, RFC 2822, Unix `date`, numeric ambiguity, am/pm,
153
+ `10h36m`, `GMT+3`, `-0300 (BRST)`, fuzzy sentences, NUL bytes, Unicode whitespace and
154
+ punctuation, and many inputs that should fail), across all four functions.
155
+ - 40,000 generated inputs (token soup plus datetime-shaped strings with out-of-range parts).
156
+ - The parser core built with **AddressSanitizer + UBSan** (`tools/du_cli.c`). This runs the
157
+ same corpus against fixed default dates (Jan 31, Feb 29, 9999-12-28, ...) to test month-end
158
+ clamping and weekday arithmetic, plus 20,000 random byte strings with invalid UTF-8.
159
+ - Property-based tests with [Hypothesis](https://hypothesis.readthedocs.io/) (`tests/test_properties.py`):
160
+ arbitrary Unicode, token soup, real datetimes rendered in about 20 `strftime` formats, and fuzzy
161
+ sentences must all match dateutil. Any result must be valid ISO 8601 that parses back to itself,
162
+ and arbitrary BLOBs must never raise an error. Failures shrink to a minimal input. Run them for
163
+ longer with `HYPOTHESIS_PROFILE=thorough uv run pytest tests/test_properties.py`
164
+ (20,000 examples per property).
165
+ - Limits on input size and running time, behavior in SQL queries, and the known differences below.
166
+
167
+ The parser core is also fuzzed with libFuzzer + ASan + UBSan on Linux, for 3 minutes on every
168
+ push, and for 30 minutes daily if `main` has changed in the last 24 hours. See [fuzz/README.md](fuzz/README.md).
169
+
170
+ During development, a further 1.35 million generated comparisons found zero mismatches with
171
+ dateutil 2.9.0.
172
+
173
+ ## Safety and performance
174
+
175
+ - **Bounded memory, no overflow.** Every token lookahead is bounds-checked. Integer parsing
176
+ is strict and saturates at 10^15, so arithmetic can't overflow. Output goes into a
177
+ fixed buffer through `snprintf`. There is no recursion.
178
+ - **Linear time.** One pass tokenizes and one pass parses. Inputs up to 256 bytes need no heap
179
+ allocation. Longer inputs use one allocation, sized to the input.
180
+ - **Size cap.** Inputs over 64 KiB return `NULL` (compile with `-DDATEUTIL_MAX_INPUT=...`
181
+ to change this).
182
+ - **Arbitrary bytes are safe.** Embedded NULs are skipped, as dateutil does. Invalid UTF-8
183
+ is handled.
184
+ - **Exact numbers.** dateutil uses `Decimal` to avoid float rounding (its GH #427). This port
185
+ does the same arithmetic on the decimal digits, so `1.7h` is 1:42, not 1:41.
186
+ - Registered as `SQLITE_INNOCUOUS`.
187
+
188
+ One machine, 100,000 mixed inputs with `fuzzy=True`: **~1.1 million rows/second**,
189
+ about 38x faster than calling dateutil from Python.
190
+
191
+ ## Known differences from dateutil
192
+
193
+ These are deliberate:
194
+
195
+ 1. **No `tzinfos`, and no local time zone names.** As in dateutil without `tzinfos`, a zone
196
+ name it can't resolve (e.g. `EST`) gives a naive result. dateutil also maps names in
197
+ `time.tzname` (the server's own zone) to `tzlocal()`. This port doesn't, so results never
198
+ depend on the server's `TZ` setting. `UTC`, `GMT`, `Z` and numeric offsets work as in dateutil.
199
+ 2. **Only ASCII digits form numbers.** Characters are classified exactly as Python's
200
+ `isalpha()`, `isdigit()` and `isspace()` do (table generated by `tools/gen_unicode.py`).
201
+ Python's `float()`/`int()` also accept other Unicode decimal digits, such as full-width
202
+ `2003`. Those inputs return `NULL` here.
203
+ 3. **Numeric extremes.** dateutil's `Decimal` arithmetic uses 28 significant digits.
204
+ Fractions longer than that may round differently. Absurdly long numbers make dateutil raise
205
+ `decimal.InvalidOperation`; here they return `NULL`.
206
+ 4. **Inputs over 64 KiB** return `NULL`.
207
+
208
+ ## Layout
209
+
210
+ ```
211
+ dateutil_parse.c the extension (port of dateutil/parser/_parser.py)
212
+ dateutil_unicode.h generated character-class table
213
+ tools/gen_unicode.py regenerates the table from Python's Unicode data
214
+ tools/du_cli.c SQLite-free driver used for sanitizer tests
215
+ tools/build.py cross-platform build script (used by the tests)
216
+ tools/build_wheels.py packages the built extension as wheels, plus the plugin wheels
217
+ fuzz/ libFuzzer harness, dictionary and seed generator
218
+ vendor/sqlite/ SQLite 3.53.2 public-domain headers
219
+ tests/ pytest differential test suite, plus wheel packaging tests
220
+ tests/pyodide/ installs each Pyodide wheel with micropip under Node and checks it
221
+ tools/pyodide_shim.c Pyodide entry point that copes with SQLite built without extension support
222
+ ```
223
+
224
+ ## Releasing
225
+
226
+ Bump `version` in `pyproject.toml`, then publish a GitHub release with that version as its
227
+ tag. `.github/workflows/release.yml` runs the tests, builds a wheel on each platform
228
+ (in the `manylinux_2_28` images on Linux), installs and tests each one, then uploads them and
229
+ the two plugin wheels to PyPI through trusted publishing.
230
+
231
+ To build wheels locally:
232
+
233
+ ```bash
234
+ uv run python tools/build_wheels.py # this platform, into dist/
235
+ uv run python tools/build_wheels.py --plugins # datasette- and sqlite-utils- plugins
236
+ ```
237
+
238
+ On macOS the wheel targets macOS 11 (arm64) or 10.13 (x86_64), unless
239
+ `MACOSX_DEPLOYMENT_TARGET` says otherwise. The script refuses to package a binary built for
240
+ a newer macOS than its wheel tag claims.
241
+
242
+ Each Pyodide wheel needs the exact Emscripten version its Pyodide ABI uses (see
243
+ `PYODIDE_TARGETS` in `tools/build_wheels.py`): 4.0.9 for ABI `2025_0` (Pyodide 0.28/0.29),
244
+ 5.0.3 for `2026_0` (Pyodide 314). With that Emscripten active:
245
+
246
+ ```bash
247
+ uv run python tools/build_wheels.py --pyodide 2026_0 # or 2025_0
248
+ cd tests/pyodide && npm ci && node test.mjs ../../dist/sqlite_dateutil-*_2026_0_wasm32.whl
249
+ ```
250
+
251
+ emsdk keeps one active version per checkout, so use a separate emsdk directory for each, or
252
+ point `EMCC` at the right `upstream/emscripten/emcc`.
253
+
254
+ `tests/pyodide/test.mjs` picks the Pyodide release from the wheel's tag, installs the wheel
255
+ with micropip under Node, and checks every hand-picked case against python-dateutil running
256
+ in that Pyodide. On 0.28/0.29 it also checks that importing without `sqlite3` explains what
257
+ to do. CI runs it for every ABI on every push. Adding a Pyodide ABI means a new
258
+ `PYODIDE_TARGETS` entry, a matching entry in the `test.yml` Pyodide matrix (with its emsdk
259
+ commit), a `pyodide-<ABI>` alias in `tests/pyodide/package.json`, and the wheel count and
260
+ publish step in `release.yml`.
@@ -0,0 +1,5 @@
1
+ sqlite_dateutil/__init__.py,sha256=z-TRaMGOH9uksK_VWWVNYCyOXWIA-7V_5hDOy7yoE2Q,3751
2
+ sqlite_dateutil/dateutil_parse.dll,sha256=ghSyGGEafc4CNTAEakkFTTzIDpJj6C01tRPQMO-HHes,173056
3
+ sqlite_dateutil-0.1a0.dist-info/METADATA,sha256=h736pGihN8KjKAzfp-JXeZV3vOwisYp1UVl-Qtx5Sbw,11618
4
+ sqlite_dateutil-0.1a0.dist-info/WHEEL,sha256=2UEFiEpRSYThNyePqsSa2CwkGS2Dkyq3UrS9m4xNu-w,115
5
+ sqlite_dateutil-0.1a0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: sqlite-dateutil tools/build_wheels.py
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64