jongo 0.2.0__tar.gz → 0.2.2__tar.gz

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.
Files changed (47) hide show
  1. {jongo-0.2.0 → jongo-0.2.2}/PKG-INFO +3 -3
  2. {jongo-0.2.0 → jongo-0.2.2}/README.md +2 -2
  3. {jongo-0.2.0 → jongo-0.2.2}/jongo/__init__.py +1 -1
  4. {jongo-0.2.0 → jongo-0.2.2}/jongo/app.py +36 -22
  5. {jongo-0.2.0 → jongo-0.2.2}/jongo/cli.py +1 -1
  6. {jongo-0.2.0 → jongo-0.2.2}/jongo/compiler/dom.js +0 -0
  7. {jongo-0.2.0 → jongo-0.2.2}/jongo/compiler/pyrt.js +124 -42
  8. {jongo-0.2.0 → jongo-0.2.2}/jongo/compiler/transpile.py +6 -5
  9. {jongo-0.2.0 → jongo-0.2.2}/jongo/db/connection.py +42 -3
  10. {jongo-0.2.0 → jongo-0.2.2}/jongo/db/fields.py +11 -7
  11. {jongo-0.2.0 → jongo-0.2.2}/jongo/db/models.py +28 -17
  12. {jongo-0.2.0 → jongo-0.2.2}/jongo/db/query.py +32 -3
  13. {jongo-0.2.0 → jongo-0.2.2}/jongo/http.py +30 -7
  14. {jongo-0.2.0 → jongo-0.2.2}/jongo/rpc.py +16 -7
  15. {jongo-0.2.0 → jongo-0.2.2}/jongo/server.py +4 -9
  16. {jongo-0.2.0 → jongo-0.2.2}/jongo/vdom.py +33 -11
  17. {jongo-0.2.0 → jongo-0.2.2}/jongo.egg-info/PKG-INFO +3 -3
  18. {jongo-0.2.0 → jongo-0.2.2}/jongo.egg-info/SOURCES.txt +2 -1
  19. {jongo-0.2.0 → jongo-0.2.2}/pyproject.toml +1 -1
  20. jongo-0.2.2/tests/test_stress_fixes_022.py +222 -0
  21. {jongo-0.2.0 → jongo-0.2.2}/LICENSE +0 -0
  22. {jongo-0.2.0 → jongo-0.2.2}/jongo/__main__.py +0 -0
  23. {jongo-0.2.0 → jongo-0.2.2}/jongo/admin.py +0 -0
  24. {jongo-0.2.0 → jongo-0.2.2}/jongo/auth.py +0 -0
  25. {jongo-0.2.0 → jongo-0.2.2}/jongo/compiler/__init__.py +0 -0
  26. {jongo-0.2.0 → jongo-0.2.2}/jongo/compiler/bundle.py +0 -0
  27. {jongo-0.2.0 → jongo-0.2.2}/jongo/compiler/scope.py +0 -0
  28. {jongo-0.2.0 → jongo-0.2.2}/jongo/db/__init__.py +0 -0
  29. {jongo-0.2.0 → jongo-0.2.2}/jongo/db/migrate.py +0 -0
  30. {jongo-0.2.0 → jongo-0.2.2}/jongo/errors.py +0 -0
  31. {jongo-0.2.0 → jongo-0.2.2}/jongo/errors_page.py +0 -0
  32. {jongo-0.2.0 → jongo-0.2.2}/jongo/html.py +0 -0
  33. {jongo-0.2.0 → jongo-0.2.2}/jongo/log.py +0 -0
  34. {jongo-0.2.0 → jongo-0.2.2}/jongo/routing.py +0 -0
  35. {jongo-0.2.0 → jongo-0.2.2}/jongo/scaffold/app.py.tmpl +0 -0
  36. {jongo-0.2.0 → jongo-0.2.2}/jongo/scaffold/dot-gitignore.tmpl +0 -0
  37. {jongo-0.2.0 → jongo-0.2.2}/jongo/styles.py +0 -0
  38. {jongo-0.2.0 → jongo-0.2.2}/jongo/testing.py +0 -0
  39. {jongo-0.2.0 → jongo-0.2.2}/jongo.egg-info/dependency_links.txt +0 -0
  40. {jongo-0.2.0 → jongo-0.2.2}/jongo.egg-info/entry_points.txt +0 -0
  41. {jongo-0.2.0 → jongo-0.2.2}/jongo.egg-info/top_level.txt +0 -0
  42. {jongo-0.2.0 → jongo-0.2.2}/setup.cfg +0 -0
  43. {jongo-0.2.0 → jongo-0.2.2}/tests/test_admin.py +0 -0
  44. {jongo-0.2.0 → jongo-0.2.2}/tests/test_app.py +0 -0
  45. {jongo-0.2.0 → jongo-0.2.2}/tests/test_compiler.py +0 -0
  46. {jongo-0.2.0 → jongo-0.2.2}/tests/test_db.py +0 -0
  47. {jongo-0.2.0 → jongo-0.2.2}/tests/test_stress_fixes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jongo
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A full-stack Python web framework: routes, server code and reactive UI in one language.
5
5
  Author: Joshua Harty
6
6
  License: MIT
@@ -331,12 +331,12 @@ cd examples/todo && ../../.venv/bin/jongo dev
331
331
 
332
332
  ## Status
333
333
 
334
- Version 0.2 — 0.1 hardened by a stress audit (see `CHANGELOG.md`). Known limits:
334
+ Version 0.2 — 0.1 hardened over two stress-audit passes (see `CHANGELOG.md`). Known limits:
335
335
  - SQLite only.
336
336
  - No WebSockets yet.
337
337
  - No classes in browser code.
338
338
  - Components re-render their subtree without memoisation.
339
- - Integers beyond 2⁵³ lose precision in browser code (JavaScript numbers are float64); do big-integer work in a `@server` function.
340
339
  - `x__ne=v` / `.exclude(field=v)` also match rows where the column is `NULL` (matching Python's `None != v`, not SQL's three-valued logic).
340
+ - Browser (post-hydration) code inherits JavaScript's value model, so a few things differ from CPython (the server is always correct): integers past 2⁵³ lose precision and `str(2.0)` shows `"2"` (one number type — format explicitly, e.g. `f"{x:.2f}"`); a dict keyed by ints iterates string keys; `len("😀")` counts UTF-16 units; `(1,2) == [1,2]` is true. Do exact numeric/big-integer work in a `@server` function.
341
341
 
342
342
  Bug reports and ideas are welcome.
@@ -301,12 +301,12 @@ cd examples/todo && ../../.venv/bin/jongo dev
301
301
 
302
302
  ## Status
303
303
 
304
- Version 0.2 — 0.1 hardened by a stress audit (see `CHANGELOG.md`). Known limits:
304
+ Version 0.2 — 0.1 hardened over two stress-audit passes (see `CHANGELOG.md`). Known limits:
305
305
  - SQLite only.
306
306
  - No WebSockets yet.
307
307
  - No classes in browser code.
308
308
  - Components re-render their subtree without memoisation.
309
- - Integers beyond 2⁵³ lose precision in browser code (JavaScript numbers are float64); do big-integer work in a `@server` function.
310
309
  - `x__ne=v` / `.exclude(field=v)` also match rows where the column is `NULL` (matching Python's `None != v`, not SQL's three-valued logic).
310
+ - Browser (post-hydration) code inherits JavaScript's value model, so a few things differ from CPython (the server is always correct): integers past 2⁵³ lose precision and `str(2.0)` shows `"2"` (one number type — format explicitly, e.g. `f"{x:.2f}"`); a dict keyed by ints iterates string keys; `len("😀")` counts UTF-16 units; `(1,2) == [1,2]` is true. Do exact numeric/big-integer work in a `@server` function.
311
311
 
312
312
  Bug reports and ideas are welcome.
@@ -23,7 +23,7 @@ from .vdom import (
23
23
  state,
24
24
  )
25
25
 
26
- __version__ = "0.2.0"
26
+ __version__ = "0.2.2"
27
27
 
28
28
  __all__ = [
29
29
  "Jongo", "Page", "Request", "Response", "redirect", "json_response",
@@ -18,6 +18,7 @@ import urllib.parse
18
18
  from pathlib import Path
19
19
 
20
20
  from . import vdom
21
+ from . import log as jlog
21
22
  from .errors import CompileError, HTTPError, NotFound
22
23
  from .http import (
23
24
  CSRF_COOKIE,
@@ -63,6 +64,7 @@ class Jongo:
63
64
  login_url: str = "/login",
64
65
  session_max_age: int = 14 * 24 * 3600,
65
66
  static_url: str = "/static",
67
+ trust_proxy: bool = False,
66
68
  ):
67
69
  self.name = name or "app"
68
70
  module = sys.modules.get(name) if name else None
@@ -79,6 +81,7 @@ class Jongo:
79
81
  self.login_url = login_url
80
82
  self.session_max_age = session_max_age
81
83
  self.static_url = static_url.rstrip("/")
84
+ self.trust_proxy = trust_proxy # trust X-Forwarded-Proto only when behind a known proxy
82
85
  self.router = Router()
83
86
  self.boot_id = secrets.token_hex(8)
84
87
  self.before_request_hooks: list = []
@@ -168,27 +171,38 @@ class Jongo:
168
171
 
169
172
  def handle(self, request: Request) -> Response:
170
173
  started = time.perf_counter()
171
- try:
172
- self._load_session(request)
173
- response = None
174
- for hook in self.before_request_hooks:
175
- result = hook(request)
176
- if result is not None:
177
- response = self.to_response(result, request)
178
- break
179
- if response is None:
180
- response = self._dispatch(request)
181
- except HTTPError as exc:
182
- response = self.http_error(request, exc)
183
- except Exception as exc:
184
- response = self.server_error(request, exc)
185
- for hook in self.after_request_hooks:
186
- response = hook(request, response) or response
187
- self._finish(request, response)
188
- if self.dev and not request.path.startswith("/_jongo/"):
189
- elapsed = (time.perf_counter() - started) * 1000
190
- log.info("%s %s -> %s (%.1f ms)", request.method, request.full_path, response.status, elapsed)
191
- return response
174
+ with jlog.request_scope():
175
+ try:
176
+ self._load_session(request)
177
+ response = None
178
+ for hook in self.before_request_hooks:
179
+ result = hook(request)
180
+ if result is not None:
181
+ response = self.to_response(result, request)
182
+ break
183
+ if response is None:
184
+ response = self._dispatch(request)
185
+ except HTTPError as exc:
186
+ response = self.http_error(request, exc)
187
+ except Exception as exc:
188
+ response = self.server_error(request, exc)
189
+ try:
190
+ for hook in self.after_request_hooks:
191
+ response = hook(request, response) or response
192
+ self._finish(request, response)
193
+ except Exception as exc:
194
+ # A failing after_request hook or _finish (e.g. a non-serialisable session)
195
+ # must still yield a proper 500 instead of escaping to the WSGI server.
196
+ response = self.server_error(request, exc)
197
+ try:
198
+ self._finish(request, response)
199
+ except Exception:
200
+ jlog.get_logger("http").error("failed to finalise error response", exc_info=True)
201
+ if self.dev and not (request.path.startswith("/_jongo/app") or request.path == "/_jongo/live"):
202
+ elapsed = (time.perf_counter() - started) * 1000
203
+ route_name = getattr(getattr(request, "route", None), "name", None)
204
+ jlog.request_done(request.method, request.full_path, response.status, elapsed, route=route_name)
205
+ return response
192
206
 
193
207
  def _dispatch(self, request: Request) -> Response:
194
208
  route, params = self.router.match(request.method, request.path)
@@ -381,7 +395,7 @@ class Jongo:
381
395
  return Response(http_error_page(exc), exc.status, headers)
382
396
 
383
397
  def server_error(self, request: Request, exc: Exception) -> Response:
384
- log.error("error handling %s %s\n%s", request.method, request.full_path, "".join(traceback.format_exception(exc)))
398
+ jlog.get_logger("http").error("error handling %s %s", request.method, request.full_path, exc_info=exc)
385
399
  if request.path.startswith("/_jongo/rpc/"):
386
400
  error = {"type": type(exc).__name__, "message": str(exc) if self.dev else "Server error"}
387
401
  if self.dev:
@@ -177,7 +177,7 @@ def cmd_build(args):
177
177
 
178
178
  def main(argv=None):
179
179
  parser = argparse.ArgumentParser(prog="jongo", description="Full-stack Python web framework")
180
- parser.add_argument("--version", action="version", version="jongo 0.2.0")
180
+ parser.add_argument("--version", action="version", version="jongo 0.2.2")
181
181
  sub = parser.add_subparsers(dest="command", metavar="command")
182
182
 
183
183
  def add(name, fn, help_text, app_arg=True):
@@ -121,12 +121,23 @@ function $cmp(a, b) {
121
121
  return a < b ? -1 : a > b ? 1 : 0;
122
122
  }
123
123
 
124
+ // Python hashes True as 1 and False as 0, so a bool used as a set element or dict key
125
+ // is the same as the int. JS keeps them distinct (Set) / stringifies to "true" (object),
126
+ // so normalise bools to numbers wherever a value is used as a key/member.
127
+ function $key(x) {
128
+ return typeof x === "boolean" ? +x : x;
129
+ }
130
+
131
+ function $set(items) {
132
+ return new Set(items.map($key));
133
+ }
134
+
124
135
  function $in(x, c) {
125
136
  if (typeof c === "string") return c.includes(x);
126
137
  if (Array.isArray(c)) return c.some((y) => $eq(x, y));
127
- if (c instanceof Set || c instanceof Map) return c.has(x);
128
- if ($isPlain(c)) return $hasOwn(c, x);
129
- if (c !== null && typeof c === "object") return x in c;
138
+ if (c instanceof Set || c instanceof Map) return c.has($key(x));
139
+ if ($isPlain(c)) return $hasOwn(c, $key(x));
140
+ if (c !== null && typeof c === "object") return $key(x) in c;
130
141
  throw $b.TypeError(`argument of type '${$typename(c)}' is not iterable`);
131
142
  }
132
143
 
@@ -144,6 +155,8 @@ function $iadd(a, b) {
144
155
  }
145
156
 
146
157
  function $mul(a, b) {
158
+ if (typeof a === "boolean") a = +a; // Python: True*[1]==[1], False*[1]==[]
159
+ if (typeof b === "boolean") b = +b;
147
160
  if (typeof a === "number" && typeof b !== "number") [a, b] = [b, a];
148
161
  if (typeof a === "string") return b > 0 ? a.repeat(b) : "";
149
162
  if (Array.isArray(a)) {
@@ -165,10 +178,18 @@ function $mod(a, b) {
165
178
  return ((a % b) + b) % b;
166
179
  }
167
180
 
181
+ // JS bitwise operators are 32-bit signed; use BigInt so ints up to the float64 range
182
+ // stay correct (1<<40, 0xFFFFFFFF & …) instead of wrapping/going negative.
183
+ function $bigint(x, op) {
184
+ if (typeof x === "boolean") return x ? 1n : 0n;
185
+ if (typeof x === "number" && Number.isInteger(x)) return BigInt(x);
186
+ throw $b.TypeError(`unsupported operand type(s) for ${op}: '${$typename(x)}'`);
187
+ }
188
+
168
189
  function $bor(a, b) {
169
190
  if ($isPlain(a) && $isPlain(b)) return { ...a, ...b };
170
191
  if (a instanceof Set && b instanceof Set) return new Set([...a, ...b]);
171
- return a | b;
192
+ return Number($bigint(a, "|") | $bigint(b, "|"));
172
193
  }
173
194
 
174
195
  function $sub(a, b) {
@@ -178,14 +199,22 @@ function $sub(a, b) {
178
199
 
179
200
  function $band(a, b) {
180
201
  if (a instanceof Set && b instanceof Set) return new Set([...a].filter((x) => b.has(x)));
181
- return a & b;
202
+ return Number($bigint(a, "&") & $bigint(b, "&"));
182
203
  }
183
204
 
184
205
  function $bxor(a, b) {
185
206
  if (a instanceof Set && b instanceof Set) {
186
207
  return new Set([...a].filter((x) => !b.has(x)).concat([...b].filter((x) => !a.has(x))));
187
208
  }
188
- return a ^ b;
209
+ return Number($bigint(a, "^") ^ $bigint(b, "^"));
210
+ }
211
+
212
+ function $lshift(a, b) {
213
+ return Number($bigint(a, "<<") << $bigint(b, "<<"));
214
+ }
215
+
216
+ function $rshift(a, b) {
217
+ return Number($bigint(a, ">>") >> $bigint(b, ">>"));
189
218
  }
190
219
 
191
220
  // ---- iteration, indexing, attributes ------------------------------------------
@@ -219,8 +248,9 @@ function $gi(o, k) {
219
248
  return o[i];
220
249
  }
221
250
  } else if ($isPlain(o)) {
222
- if (!$hasOwn(o, k)) throw $b.KeyError($repr(k));
223
- return o[k];
251
+ const nk = $key(k);
252
+ if (!$hasOwn(o, nk)) throw $b.KeyError($repr(k));
253
+ return o[nk];
224
254
  } else if (o instanceof Map) {
225
255
  if (!o.has(k)) throw $b.KeyError($repr(k));
226
256
  return o.get(k);
@@ -382,6 +412,7 @@ function $fmt(x, spec) {
382
412
  const p = prec === undefined ? undefined : +prec;
383
413
  let body;
384
414
  let signStr = "";
415
+ let prefix = "";
385
416
  if (typeof x === "number") {
386
417
  const n = Math.abs(x);
387
418
  switch (type) {
@@ -393,36 +424,55 @@ function $fmt(x, spec) {
393
424
  case "X": body = Math.trunc(n).toString(16).toUpperCase(); break;
394
425
  case "b": body = Math.trunc(n).toString(2); break;
395
426
  case "o": body = Math.trunc(n).toString(8); break;
396
- case "g": case "G": body = String(p === undefined ? n : +n.toPrecision(p || 1)); break;
397
- default: body = p === undefined ? String(n) : String(+n.toPrecision(p || 1));
398
- }
399
- if (type === "E") body = body.toUpperCase();
400
- if (alt) {
401
- const prefix = { x: "0x", X: "0X", o: "0o", b: "0b" }[type];
402
- if (prefix) body = prefix + body;
427
+ case "c": body = String.fromCodePoint(Math.trunc(x)); break;
428
+ case "g": case "G": body = $gformat(n, p === undefined ? 6 : p || 1); break;
429
+ default: body = p === undefined ? String(n) : $gformat(n, p || 1);
403
430
  }
431
+ if (type === "E" || type === "G") body = body.toUpperCase();
432
+ if (alt) prefix = { x: "0x", X: "0X", o: "0o", b: "0b" }[type] || "";
404
433
  if (group) body = body.replace(/^(\d+)/, (d) => d.replace(/\B(?=(\d{3})+(?!\d))/g, group));
405
434
  if (type === "%") body += "%";
406
- signStr = x < 0 ? "-" : sign === "+" ? "+" : sign === " " ? " " : "";
407
- if (zero && !align) {
408
- fill = "0";
409
- align = "=";
435
+ if (type === "c") {
436
+ align = align || "<"; // a char is aligned like a string and carries no sign
437
+ } else {
438
+ signStr = x < 0 ? "-" : sign === "+" ? "+" : sign === " " ? " " : "";
439
+ if (zero && !align) {
440
+ fill = "0";
441
+ align = "=";
442
+ }
443
+ align = align || ">";
410
444
  }
411
- align = align || ">";
412
445
  } else {
413
446
  body = $str(x);
414
447
  if (p !== undefined) body = body.slice(0, p);
415
448
  align = align || "<";
416
449
  }
417
450
  const w = width === undefined ? 0 : +width;
418
- const pad = Math.max(0, w - body.length - signStr.length);
419
- if (align === "=") return signStr + fill.repeat(pad) + body;
420
- const text = signStr + body;
451
+ const pad = Math.max(0, w - body.length - signStr.length - prefix.length);
452
+ // "=" fills between the sign/prefix and the digits, so f"{255:#06x}" -> "0x00ff".
453
+ if (align === "=") return signStr + prefix + fill.repeat(pad) + body;
454
+ const text = signStr + prefix + body;
421
455
  if (align === "<") return text + fill.repeat(pad);
422
456
  if (align === "^") return fill.repeat(Math.floor(pad / 2)) + text + fill.repeat(Math.ceil(pad / 2));
423
457
  return fill.repeat(pad) + text;
424
458
  }
425
459
 
460
+ // Python's general ("g") float format: shortest of fixed/exponential at `pr` significant
461
+ // digits, trailing zeros stripped, exponent shown when < -4 or >= pr.
462
+ function $gformat(n, pr) {
463
+ if (n === 0) return "0";
464
+ const exp = parseInt(n.toExponential().split("e")[1], 10);
465
+ if (exp < -4 || exp >= pr) {
466
+ let [mant, e] = n.toExponential(pr - 1).split("e");
467
+ if (mant.indexOf(".") >= 0) mant = mant.replace(/0+$/, "").replace(/\.$/, "");
468
+ const en = parseInt(e, 10);
469
+ return mant + "e" + (en < 0 ? "-" : "+") + String(Math.abs(en)).padStart(2, "0");
470
+ }
471
+ let s = n.toFixed(Math.max(0, pr - 1 - exp));
472
+ if (s.indexOf(".") >= 0) s = s.replace(/0+$/, "").replace(/\.$/, "");
473
+ return s;
474
+ }
475
+
426
476
  function $percentFormat(template, values) {
427
477
  const positional = Array.isArray(values) ? values : [values];
428
478
  let index = 0;
@@ -513,17 +563,41 @@ function $minmax(which) {
513
563
  }
514
564
 
515
565
  function $toInt(x, base) {
516
- if (typeof x === "string") {
517
- const text = x.trim().replace(/_/g, "");
518
- const n = parseInt(text, base || 10);
519
- if (Number.isNaN(n) || !/^[-+]?[0-9a-zA-Z]+$/.test(text)) {
520
- throw $b.ValueError(`invalid literal for int() with base ${base || 10}: ${$repr(x)}`);
521
- }
522
- return n;
523
- }
524
566
  if (typeof x === "boolean") return x ? 1 : 0;
525
567
  if (x === undefined) return 0;
526
- return Math.trunc(x);
568
+ if (typeof x === "number") return Math.trunc(x);
569
+ if (typeof x !== "string") {
570
+ throw $b.TypeError(`int() argument must be a string or a number, not '${$typename(x)}'`);
571
+ }
572
+ const b = base === undefined ? 10 : base;
573
+ const fail = () => {
574
+ throw $b.ValueError(`invalid literal for int() with base ${b}: ${$repr(x)}`);
575
+ };
576
+ let s = x.trim();
577
+ let sign = 1;
578
+ if (s[0] === "+" || s[0] === "-") {
579
+ if (s[0] === "-") sign = -1;
580
+ s = s.slice(1);
581
+ }
582
+ let radix = b;
583
+ const low = s.toLowerCase();
584
+ if (b === 0) {
585
+ if (low.startsWith("0x")) (radix = 16), (s = s.slice(2));
586
+ else if (low.startsWith("0o")) (radix = 8), (s = s.slice(2));
587
+ else if (low.startsWith("0b")) (radix = 2), (s = s.slice(2));
588
+ else radix = 10;
589
+ } else if (b === 16 && low.startsWith("0x")) s = s.slice(2);
590
+ else if (b === 8 && low.startsWith("0o")) s = s.slice(2);
591
+ else if (b === 2 && low.startsWith("0b")) s = s.slice(2);
592
+ if (radix < 2 || radix > 36) throw $b.ValueError("int() base must be >= 2 and <= 36, or 0");
593
+ if (/^_|_$|__/.test(s)) fail(); // underscores only as single separators between digits
594
+ s = s.replace(/_/g, "");
595
+ if (s === "") fail();
596
+ const digits = "0123456789abcdefghijklmnopqrstuvwxyz".slice(0, radix);
597
+ if (!new RegExp(`^[${digits}]+$`, "i").test(s)) fail(); // reject trailing garbage like "1a"/"42px"
598
+ const n = parseInt(s, radix);
599
+ if (Number.isNaN(n)) fail();
600
+ return sign * n;
527
601
  }
528
602
 
529
603
  function $round(x, n) {
@@ -567,10 +641,16 @@ Object.assign($b, {
567
641
  str: (x = "") => $str(x),
568
642
  int: $toInt,
569
643
  float(x = 0) {
570
- const n = typeof x === "string" ? Number(x.trim()) : Number(x);
571
- if (Number.isNaN(n) && !/^\s*nan\s*$/i.test(String(x))) {
572
- throw $b.ValueError(`could not convert string to float: ${$repr(x)}`);
573
- }
644
+ if (typeof x === "boolean") return x ? 1 : 0;
645
+ if (typeof x === "number") return x;
646
+ const s = String(x).trim();
647
+ const body = s.replace(/^[+-]/, "");
648
+ const low = body.toLowerCase();
649
+ if (low === "inf" || low === "infinity") return s[0] === "-" ? -Infinity : Infinity;
650
+ if (low === "nan") return NaN;
651
+ if (/^_|_$|__/.test(body)) throw $b.ValueError(`could not convert string to float: ${$repr(x)}`);
652
+ const n = Number(s.replace(/_/g, "")); // Python allows single "_" digit separators
653
+ if (Number.isNaN(n)) throw $b.ValueError(`could not convert string to float: ${$repr(x)}`);
574
654
  return n;
575
655
  },
576
656
  bool: (x) => $t(x),
@@ -625,7 +705,8 @@ Object.assign($b, {
625
705
  if (Array.isArray(T)) return T.some((t) => $b.isinstance(x, t));
626
706
  switch (T) {
627
707
  case $b.str: return typeof x === "string";
628
- case $b.int: return Number.isInteger(x);
708
+ // bool is a subclass of int in Python (isinstance(True, int) is True).
709
+ case $b.int: return typeof x === "boolean" || (typeof x === "number" && Number.isInteger(x));
629
710
  case $b.float: return typeof x === "number";
630
711
  case $b.bool: return typeof x === "boolean";
631
712
  case $b.list: case $b.tuple: return Array.isArray(x);
@@ -795,19 +876,19 @@ const $LIST = {
795
876
  };
796
877
 
797
878
  const $DICT = {
798
- get: (d, [k, dflt = null]) => ($hasOwn(d, k) ? d[k] : dflt),
879
+ get: (d, [k, dflt = null]) => ($hasOwn(d, $key(k)) ? d[$key(k)] : dflt),
799
880
  keys: (d) => Object.keys(d),
800
881
  values: (d) => Object.values(d),
801
882
  items: (d) => Object.entries(d),
802
883
  pop(d, args) {
803
- const k = args[0];
884
+ const k = $key(args[0]);
804
885
  if ($hasOwn(d, k)) {
805
886
  const v = d[k];
806
887
  delete d[k];
807
888
  return v;
808
889
  }
809
890
  if (args.length > 1) return args[1];
810
- throw $b.KeyError($repr(k));
891
+ throw $b.KeyError($repr(args[0]));
811
892
  },
812
893
  update(d, [o], kw) {
813
894
  if (o !== undefined && o !== null) {
@@ -817,8 +898,9 @@ const $DICT = {
817
898
  Object.assign(d, kw);
818
899
  },
819
900
  setdefault(d, [k, v = null]) {
820
- if (!$hasOwn(d, k)) d[k] = v;
821
- return d[k];
901
+ const nk = $key(k);
902
+ if (!$hasOwn(d, nk)) d[nk] = v;
903
+ return d[nk];
822
904
  },
823
905
  copy: (d) => ({ ...d }),
824
906
  clear(d) { for (const k of Object.keys(d)) delete d[k]; },
@@ -574,13 +574,14 @@ class FunctionTranspiler:
574
574
  return f"({left} - {right})"
575
575
  return f"$sub({left}, {right})" # set difference when both are sets
576
576
  simple = {
577
- ast.Div: "/", ast.Pow: "**", ast.LShift: "<<", ast.RShift: ">>",
577
+ ast.Div: "/", ast.Pow: "**",
578
578
  }
579
579
  if kind in simple:
580
580
  return f"({left} {simple[kind]} {right})"
581
581
  helpers = {
582
582
  ast.FloorDiv: "$floordiv", ast.Mod: "$mod", ast.BitOr: "$bor",
583
583
  ast.BitAnd: "$band", ast.BitXor: "$bxor", # set intersection / symmetric difference
584
+ ast.LShift: "$lshift", ast.RShift: "$rshift", # BigInt-backed, not 32-bit JS shifts
584
585
  }
585
586
  if kind in helpers:
586
587
  return f"{helpers[kind]}({left}, {right})"
@@ -646,7 +647,7 @@ class FunctionTranspiler:
646
647
  x_Tuple = x_List
647
648
 
648
649
  def x_Set(self, node):
649
- return f"new Set([{self._elements(node.elts)}])"
650
+ return f"$set([{self._elements(node.elts)}])" # normalises bool/int members (True == 1)
650
651
 
651
652
  def x_Dict(self, node):
652
653
  items = []
@@ -656,7 +657,7 @@ class FunctionTranspiler:
656
657
  elif isinstance(key, ast.Constant) and isinstance(key.value, str):
657
658
  items.append(f"{js_string(key.value)}: {self.expr(value)}")
658
659
  else:
659
- items.append(f"[{self.expr(key)}]: {self.expr(value)}")
660
+ items.append(f"[$key({self.expr(key)})]: {self.expr(value)}") # True/False key == 1/0
660
661
  return "({" + ", ".join(items) + "})"
661
662
 
662
663
  def comprehension(self, node, kind) -> str:
@@ -679,9 +680,9 @@ class FunctionTranspiler:
679
680
  for cond in gen.ifs:
680
681
  code.append(f"if (!{self.test(cond)}) continue;")
681
682
  if kind == "dict":
682
- code.append(f"$r[{self.expr(node.key)}] = {self.expr(node.value)};")
683
+ code.append(f"$r[$key({self.expr(node.key)})] = {self.expr(node.value)};")
683
684
  elif kind == "set":
684
- code.append(f"$r.add({self.expr(node.elt)});")
685
+ code.append(f"$r.add($key({self.expr(node.elt)}));")
685
686
  else:
686
687
  code.append(f"$r.push({self.expr(node.elt)});")
687
688
  code.append("}" * len(generators))
@@ -5,11 +5,14 @@ from __future__ import annotations
5
5
  import itertools
6
6
  import os
7
7
  import sqlite3
8
+ import time
8
9
  import threading
9
10
  import weakref
10
11
  from contextlib import ContextDecorator, contextmanager
11
12
  from typing import Any, Callable, Iterator, Sequence
12
13
 
14
+ from .. import log as _jlog
15
+
13
16
  MEMORY = ":memory:"
14
17
  DEFAULT_DATABASE = "db.sqlite3"
15
18
  ENV_VAR = "JONGO_DATABASE"
@@ -24,8 +27,34 @@ _memory_connection: _Connection | None = None
24
27
  _savepoint_ids = itertools.count(1)
25
28
 
26
29
 
30
+ _WRITE = frozenset(("INSERT", "UPDATE", "DELETE", "REPLACE"))
31
+
32
+
27
33
  class _Connection(sqlite3.Connection):
28
- """A sqlite3 connection that can be weakly referenced (so we can track it)."""
34
+ """A sqlite3 connection that can be weakly referenced (so we can track it).
35
+
36
+ Its ``execute`` is the single choke point for data-modifying statements: it
37
+ times and logs INSERT/UPDATE/DELETE via ``jongo.log`` — including the
38
+ row-level writes ``Model.save`` issues straight on the connection, which
39
+ would otherwise bypass the module-level ``execute``. Reads are logged in
40
+ ``fetch`` (which knows the row count); transaction-control and PRAGMA
41
+ statements are left silent. When SQL logging is off (production) this adds
42
+ only one ``isEnabledFor`` check per statement.
43
+ """
44
+
45
+ def execute(self, sql, parameters=()): # type: ignore[override]
46
+ if not _jlog.sql_enabled():
47
+ return super().execute(sql, parameters)
48
+ head = sql.lstrip()[:12].split(None, 1)[0].upper() if sql.strip() else ""
49
+ if head not in _WRITE:
50
+ return super().execute(sql, parameters) # reads -> fetch(); control -> silent
51
+ t0 = time.perf_counter()
52
+ cursor = super().execute(sql, parameters)
53
+ ms = (time.perf_counter() - t0) * 1000
54
+ rows = cursor.rowcount if cursor.rowcount is not None and cursor.rowcount >= 0 else None
55
+ _jlog.sql(sql, ms, rows)
56
+ _jlog.record_sql(ms)
57
+ return cursor
29
58
 
30
59
 
31
60
  def _parse_url(url: str | os.PathLike) -> str:
@@ -140,7 +169,10 @@ def locked() -> Iterator[sqlite3.Connection]:
140
169
 
141
170
 
142
171
  def execute(sql: str, params: Sequence[Any] | dict = ()) -> sqlite3.Cursor:
143
- """Execute one SQL statement and return the cursor."""
172
+ """Execute one SQL statement and return the cursor.
173
+
174
+ Data-modifying statements are timed and logged by ``_Connection.execute``.
175
+ """
144
176
  with locked() as conn:
145
177
  return conn.execute(sql, params)
146
178
 
@@ -148,7 +180,14 @@ def execute(sql: str, params: Sequence[Any] | dict = ()) -> sqlite3.Cursor:
148
180
  def fetch(sql: str, params: Sequence[Any] | dict = ()) -> list[sqlite3.Row]:
149
181
  """Execute a query and return all rows (fetched while holding the lock)."""
150
182
  with locked() as conn:
151
- return conn.execute(sql, params).fetchall()
183
+ if not _jlog.sql_enabled():
184
+ return conn.execute(sql, params).fetchall()
185
+ t0 = time.perf_counter()
186
+ rows = conn.execute(sql, params).fetchall()
187
+ ms = (time.perf_counter() - t0) * 1000
188
+ _jlog.sql(sql, ms, len(rows))
189
+ _jlog.record_sql(ms)
190
+ return rows
152
191
 
153
192
 
154
193
  def query(sql: str, params: Sequence[Any] | dict = ()) -> list[dict[str, Any]]:
@@ -356,12 +356,15 @@ class DateTime(Field):
356
356
  return super().required and not (self.auto_now or self.auto_now_add)
357
357
 
358
358
  def to_db(self, value: Any) -> Any:
359
- # Normalise to UTC before storing so the ISO text sorts and range-compares
360
- # chronologically (mixed offsets would otherwise sort lexicographically).
359
+ # Normalise aware datetimes to UTC (so mixed offsets sort/range-compare chronologically)
360
+ # while leaving naive datetimes naive, so each round-trips to an equal value of the same
361
+ # kind. (Mixing naive and aware values in one column is the only case left unsorted.)
361
362
  if isinstance(value, datetime):
362
- return _to_utc(value).isoformat(timespec="microseconds")
363
+ if value.tzinfo is not None:
364
+ value = value.astimezone(timezone.utc)
365
+ return value.isoformat(timespec="microseconds")
363
366
  if isinstance(value, date):
364
- return datetime.combine(value, time(), tzinfo=timezone.utc).isoformat(timespec="microseconds")
367
+ return datetime.combine(value, time()).isoformat(timespec="microseconds")
365
368
  return value
366
369
 
367
370
  def to_python(self, value: Any) -> Any:
@@ -397,7 +400,7 @@ class Date(Field):
397
400
 
398
401
  def to_db(self, value: Any) -> Any:
399
402
  if isinstance(value, datetime):
400
- return value.date().isoformat()
403
+ return _to_utc(value).date().isoformat() # UTC date, consistent with DateTime
401
404
  if isinstance(value, date):
402
405
  return value.isoformat()
403
406
  return value
@@ -416,14 +419,15 @@ class Date(Field):
416
419
  if value is None or _is_blank_string(value):
417
420
  return None
418
421
  if isinstance(value, datetime):
419
- return value.date()
422
+ return _to_utc(value).date() if value.tzinfo is not None else value.date()
420
423
  if isinstance(value, date):
421
424
  return value
422
425
  if isinstance(value, str):
423
426
  text = value.strip()
424
427
  try:
425
428
  if len(text) > 10:
426
- return _parse_datetime_string(text).date()
429
+ parsed = _parse_datetime_string(text)
430
+ return (_to_utc(parsed).date() if parsed.tzinfo is not None else parsed.date())
427
431
  return date.fromisoformat(text)
428
432
  except ValueError:
429
433
  pass