gnomon-postgres 1.0.0__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.
- gnomon_postgres-1.0.0/LICENSE +17 -0
- gnomon_postgres-1.0.0/PKG-INFO +79 -0
- gnomon_postgres-1.0.0/README.md +57 -0
- gnomon_postgres-1.0.0/gnomon/__init__.py +140 -0
- gnomon_postgres-1.0.0/gnomon/_util.py +82 -0
- gnomon_postgres-1.0.0/gnomon/aio.py +360 -0
- gnomon_postgres-1.0.0/gnomon/asyncpg.py +171 -0
- gnomon_postgres-1.0.0/gnomon/client.py +361 -0
- gnomon_postgres-1.0.0/gnomon/django.py +68 -0
- gnomon_postgres-1.0.0/gnomon/errors.py +65 -0
- gnomon_postgres-1.0.0/gnomon/pytest_plugin.py +62 -0
- gnomon_postgres-1.0.0/gnomon/sqlalchemy.py +32 -0
- gnomon_postgres-1.0.0/gnomon/testing.py +129 -0
- gnomon_postgres-1.0.0/gnomon/tools.json +40 -0
- gnomon_postgres-1.0.0/gnomon/tools.py +103 -0
- gnomon_postgres-1.0.0/gnomon_postgres.egg-info/PKG-INFO +79 -0
- gnomon_postgres-1.0.0/gnomon_postgres.egg-info/SOURCES.txt +26 -0
- gnomon_postgres-1.0.0/gnomon_postgres.egg-info/dependency_links.txt +1 -0
- gnomon_postgres-1.0.0/gnomon_postgres.egg-info/entry_points.txt +2 -0
- gnomon_postgres-1.0.0/gnomon_postgres.egg-info/requires.txt +13 -0
- gnomon_postgres-1.0.0/gnomon_postgres.egg-info/top_level.txt +1 -0
- gnomon_postgres-1.0.0/pyproject.toml +33 -0
- gnomon_postgres-1.0.0/setup.cfg +4 -0
- gnomon_postgres-1.0.0/tests/test_django.py +91 -0
- gnomon_postgres-1.0.0/tests/test_fixtures.py +23 -0
- gnomon_postgres-1.0.0/tests/test_helpers.py +87 -0
- gnomon_postgres-1.0.0/tests/test_sdk.py +422 -0
- gnomon_postgres-1.0.0/tests/test_sqlalchemy.py +92 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
PostgreSQL License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, The Gnomon Contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and distribute this software and its documentation for any
|
|
6
|
+
purpose, without fee, and without a written agreement is hereby granted, provided that the
|
|
7
|
+
above copyright notice and this paragraph and the following two paragraphs appear in all copies.
|
|
8
|
+
|
|
9
|
+
IN NO EVENT SHALL THE GNOMON CONTRIBUTORS BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL,
|
|
10
|
+
INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS
|
|
11
|
+
SOFTWARE AND ITS DOCUMENTATION, EVEN IF THE GNOMON CONTRIBUTORS HAVE BEEN ADVISED OF THE
|
|
12
|
+
POSSIBILITY OF SUCH DAMAGE.
|
|
13
|
+
|
|
14
|
+
THE GNOMON CONTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
15
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED
|
|
16
|
+
HEREUNDER IS ON AN "AS IS" BASIS, AND THE GNOMON CONTRIBUTORS HAVE NO OBLIGATIONS TO PROVIDE
|
|
17
|
+
MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gnomon-postgres
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Branches, time travel and merges for PostgreSQL apps: psycopg helpers, SQLAlchemy, Django and pytest fixtures for the gnomon extension
|
|
5
|
+
License-Expression: PostgreSQL
|
|
6
|
+
Project-URL: Homepage, https://gnomon.bitsinfra.com/
|
|
7
|
+
Project-URL: Repository, https://github.com/bitsinfra/gnomon
|
|
8
|
+
Project-URL: Issues, https://github.com/bitsinfra/gnomon/issues
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: psycopg>=3.1
|
|
13
|
+
Provides-Extra: sqlalchemy
|
|
14
|
+
Requires-Dist: sqlalchemy>=2; extra == "sqlalchemy"
|
|
15
|
+
Provides-Extra: django
|
|
16
|
+
Requires-Dist: django>=4.2; extra == "django"
|
|
17
|
+
Provides-Extra: asyncpg
|
|
18
|
+
Requires-Dist: asyncpg>=0.29; extra == "asyncpg"
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# gnomon-postgres (Python)
|
|
24
|
+
|
|
25
|
+
Branches, time travel and merges for PostgreSQL apps on the gnomon extension: the SDK (runs for
|
|
26
|
+
agents and tasks, review, the core), psycopg helpers, SQLAlchemy and Django, and pytest fixtures
|
|
27
|
+
with snapshots. `import gnomon`.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from gnomon import Gnomon
|
|
31
|
+
|
|
32
|
+
gn = Gnomon(app="dbname=shop user=shop_app", runs="dbname=shop user=shop_runs")
|
|
33
|
+
with gn.start_run("Refund order 3", actor="support-bot") as run: # a branch for this task
|
|
34
|
+
run.sql("update orders set status = 'refunded' where id = 3")
|
|
35
|
+
if run.summary()["checks"]["passed"]: run.merge()
|
|
36
|
+
else: run.submit("Refund order 3") # a change request instead
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Leaving the `with` block without `merge()` or `submit()` discards the branch. `run.url(base)` and
|
|
40
|
+
`run.connection(base)` give a tool a connection string, or a psycopg connection, pinned to the run
|
|
41
|
+
(a login of the run's own, confined to its branch, dropped when the run ends). With asyncpg:
|
|
42
|
+
`AsyncGnomon(app=asyncpg_source(pool), runs=asyncpg_source(runs_pool))` (`from gnomon.asyncpg import
|
|
43
|
+
asyncpg_source`; the `asyncpg` extra). `AsyncGnomon` is the
|
|
44
|
+
same API for `psycopg.AsyncConnection` (the sync client is generated from it, so they cannot
|
|
45
|
+
drift). `gn.requests`, `gn.branches`, `history`, `blame`, `revert`, `status()` and `doctor()` are
|
|
46
|
+
the review and core layers. Tools for agent frameworks bound to one run:
|
|
47
|
+
`gnomon.tools.anthropic_tools(run)`, `openai_agents_tools(run)`, `langchain_tools(run)`,
|
|
48
|
+
`mcp_handler(run)`. The two logins, the envelope and the errors are as in the TypeScript SDK
|
|
49
|
+
([sdk/ts/README.md](../../sdk/ts/README.md)); both pass the same conformance scenarios
|
|
50
|
+
([sdk/spec](../../sdk/spec)).
|
|
51
|
+
|
|
52
|
+
## Helpers
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import gnomon, psycopg
|
|
56
|
+
conn = psycopg.connect(gnomon.branch_dsn("dbname=shop", "feature")) # sessions start on a branch
|
|
57
|
+
with gnomon.branch(conn, "feature"): ... # or switch one connection
|
|
58
|
+
opts = gnomon.connection_options(admin_conn, "feature") # what the database says the
|
|
59
|
+
# branch needs (schema versions, masks)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`use`, `begin` (one transaction, for poolers), `create_branch`, `create_scratch`, `drop_branch`,
|
|
63
|
+
`diff`, `merge`. SQLAlchemy: `gnomon.sqlalchemy.branch_engine(url, branch)` and
|
|
64
|
+
`attach_branch(engine, get_branch)` (each transaction on the branch a callable names). Django:
|
|
65
|
+
`gnomon.django.BranchMiddleware` (a branch per request from `X-Gnomon-Branch`).
|
|
66
|
+
|
|
67
|
+
## Tests on branches
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
def test_signup(gnomon_conn, gnomon_snapshot): # a psycopg connection on a fresh scratch branch
|
|
71
|
+
signup(gnomon_conn, "a@x.io")
|
|
72
|
+
gnomon_snapshot.assert_match(gnomon_conn) # the branch's diff against a stored file
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`GNOMON_TEST_DSN=... pytest` (the plugin loads itself when the package is installed;
|
|
76
|
+
`--gnomon-update-snapshots` records, `--gnomon-keep` keeps the branches).
|
|
77
|
+
`gnomon.testing.assert_no_changes` and `assert_changed` check a branch's diff directly.
|
|
78
|
+
|
|
79
|
+
Install: `pip install gnomon-postgres` (extras: `sqlalchemy`, `django`, `test`).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# gnomon-postgres (Python)
|
|
2
|
+
|
|
3
|
+
Branches, time travel and merges for PostgreSQL apps on the gnomon extension: the SDK (runs for
|
|
4
|
+
agents and tasks, review, the core), psycopg helpers, SQLAlchemy and Django, and pytest fixtures
|
|
5
|
+
with snapshots. `import gnomon`.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from gnomon import Gnomon
|
|
9
|
+
|
|
10
|
+
gn = Gnomon(app="dbname=shop user=shop_app", runs="dbname=shop user=shop_runs")
|
|
11
|
+
with gn.start_run("Refund order 3", actor="support-bot") as run: # a branch for this task
|
|
12
|
+
run.sql("update orders set status = 'refunded' where id = 3")
|
|
13
|
+
if run.summary()["checks"]["passed"]: run.merge()
|
|
14
|
+
else: run.submit("Refund order 3") # a change request instead
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Leaving the `with` block without `merge()` or `submit()` discards the branch. `run.url(base)` and
|
|
18
|
+
`run.connection(base)` give a tool a connection string, or a psycopg connection, pinned to the run
|
|
19
|
+
(a login of the run's own, confined to its branch, dropped when the run ends). With asyncpg:
|
|
20
|
+
`AsyncGnomon(app=asyncpg_source(pool), runs=asyncpg_source(runs_pool))` (`from gnomon.asyncpg import
|
|
21
|
+
asyncpg_source`; the `asyncpg` extra). `AsyncGnomon` is the
|
|
22
|
+
same API for `psycopg.AsyncConnection` (the sync client is generated from it, so they cannot
|
|
23
|
+
drift). `gn.requests`, `gn.branches`, `history`, `blame`, `revert`, `status()` and `doctor()` are
|
|
24
|
+
the review and core layers. Tools for agent frameworks bound to one run:
|
|
25
|
+
`gnomon.tools.anthropic_tools(run)`, `openai_agents_tools(run)`, `langchain_tools(run)`,
|
|
26
|
+
`mcp_handler(run)`. The two logins, the envelope and the errors are as in the TypeScript SDK
|
|
27
|
+
([sdk/ts/README.md](../../sdk/ts/README.md)); both pass the same conformance scenarios
|
|
28
|
+
([sdk/spec](../../sdk/spec)).
|
|
29
|
+
|
|
30
|
+
## Helpers
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import gnomon, psycopg
|
|
34
|
+
conn = psycopg.connect(gnomon.branch_dsn("dbname=shop", "feature")) # sessions start on a branch
|
|
35
|
+
with gnomon.branch(conn, "feature"): ... # or switch one connection
|
|
36
|
+
opts = gnomon.connection_options(admin_conn, "feature") # what the database says the
|
|
37
|
+
# branch needs (schema versions, masks)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`use`, `begin` (one transaction, for poolers), `create_branch`, `create_scratch`, `drop_branch`,
|
|
41
|
+
`diff`, `merge`. SQLAlchemy: `gnomon.sqlalchemy.branch_engine(url, branch)` and
|
|
42
|
+
`attach_branch(engine, get_branch)` (each transaction on the branch a callable names). Django:
|
|
43
|
+
`gnomon.django.BranchMiddleware` (a branch per request from `X-Gnomon-Branch`).
|
|
44
|
+
|
|
45
|
+
## Tests on branches
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
def test_signup(gnomon_conn, gnomon_snapshot): # a psycopg connection on a fresh scratch branch
|
|
49
|
+
signup(gnomon_conn, "a@x.io")
|
|
50
|
+
gnomon_snapshot.assert_match(gnomon_conn) # the branch's diff against a stored file
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`GNOMON_TEST_DSN=... pytest` (the plugin loads itself when the package is installed;
|
|
54
|
+
`--gnomon-update-snapshots` records, `--gnomon-keep` keeps the branches).
|
|
55
|
+
`gnomon.testing.assert_no_changes` and `assert_changed` check a branch's diff directly.
|
|
56
|
+
|
|
57
|
+
Install: `pip install gnomon-postgres` (extras: `sqlalchemy`, `django`, `test`).
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Helpers for apps on a PostgreSQL database with the gnomon extension.
|
|
2
|
+
|
|
3
|
+
Choosing a branch is plain SQL. In SQL-only mode it takes the setting *and* the routing
|
|
4
|
+
search_path, which ``gnomon.use()`` (session) and ``gnomon.begin()`` (one transaction, safe behind
|
|
5
|
+
transaction poolers) set together; with the C module loaded, ``SET gnomon.branch`` alone routes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Iterator, Optional
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"routing_schema", "branch_options", "connection_options", "branch_dsn", "use", "begin", "create_branch", "create_scratch",
|
|
17
|
+
"drop_branch", "merge", "diff", "branch", "connect",
|
|
18
|
+
# the SDK
|
|
19
|
+
"Gnomon", "AsyncGnomon", "Run", "AsyncRun", "GnomonError",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def routing_schema(schema: str = "public", *, scratch: bool = False) -> str:
|
|
24
|
+
"""The routing schema of a user schema; scratch branches have their own (``__s``)."""
|
|
25
|
+
return f"gnomon_r_{schema}" + ("__s" if scratch else "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def branch_options(branch: str, *, c_module: bool = False, schemas: tuple[str, ...] = ("public",),
|
|
29
|
+
as_of: Optional[str] = None, scratch: bool = False) -> str:
|
|
30
|
+
"""The libpq ``options`` value that puts a connection on a branch.
|
|
31
|
+
``scratch``: the branch is a scratch branch (it is read through the scratch routing schemas)."""
|
|
32
|
+
opts = [f"-c gnomon.branch={branch}"]
|
|
33
|
+
if as_of:
|
|
34
|
+
opts.append(f"-c gnomon.as_of={as_of}")
|
|
35
|
+
if not c_module:
|
|
36
|
+
path = ",".join(p for s in schemas for p in (routing_schema(s, scratch=scratch), s))
|
|
37
|
+
opts.append(f"-c search_path={path}")
|
|
38
|
+
return " ".join(opts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def connection_options(conn, branch: str, *, c_module: bool = False) -> str:
|
|
42
|
+
"""The ``options`` value the database computes for ``branch`` (``gnomon.connection_options``):
|
|
43
|
+
unlike :func:`branch_options`, it knows the branch's kind, a confined role's mask profile and
|
|
44
|
+
the branch's schema version."""
|
|
45
|
+
return conn.execute("select gnomon.connection_options(%s, %s)", (branch, c_module)).fetchone()[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def branch_dsn(dsn: str, branch: str, *, options: Optional[str] = None, **kw) -> str:
|
|
49
|
+
"""A connection string (URL or key=value) whose sessions start on ``branch``. ``options``: a
|
|
50
|
+
value from :func:`connection_options` instead of the one built here."""
|
|
51
|
+
opts = options or branch_options(branch, **kw)
|
|
52
|
+
if "://" in dsn:
|
|
53
|
+
sep = "&" if "?" in dsn else "?"
|
|
54
|
+
return f"{dsn}{sep}options={quote(opts)}"
|
|
55
|
+
return f"{dsn} options='{opts}'"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def use(conn, branch: str = "main", as_of: Optional[str] = None) -> str:
|
|
59
|
+
"""Put a psycopg connection's session on a branch (``main`` resets it)."""
|
|
60
|
+
with conn.cursor() as cur:
|
|
61
|
+
cur.execute("select gnomon.use(%s, %s)", (branch, as_of))
|
|
62
|
+
return cur.fetchone()[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def begin(conn, branch: str, as_of: Optional[str] = None) -> None:
|
|
66
|
+
"""Put the current transaction on a branch (SET LOCAL semantics, for poolers)."""
|
|
67
|
+
with conn.cursor() as cur:
|
|
68
|
+
cur.execute("select gnomon.begin(%s, %s)", (branch, as_of))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_branch(conn, name: str, parent: str = "main", *, scratch: bool = False,
|
|
72
|
+
at: Optional[str] = None) -> int:
|
|
73
|
+
with conn.cursor() as cur:
|
|
74
|
+
cur.execute("select gnomon.create_branch(%s, %s, %s, %s)", (name, parent, "scratch" if scratch else "logged", at))
|
|
75
|
+
return cur.fetchone()[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_scratch(conn, prefix: str = "test", parent: str = "main") -> str:
|
|
79
|
+
name = f"{prefix}-{os.getpid():x}-{secrets.token_hex(4)}"
|
|
80
|
+
create_branch(conn, name, parent, scratch=True)
|
|
81
|
+
return name
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def drop_branch(conn, name: str, cascade: bool = True) -> None:
|
|
85
|
+
with conn.cursor() as cur:
|
|
86
|
+
cur.execute("select gnomon.drop_branch(%s, %s)", (name, cascade))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def merge(conn, source: str, target: Optional[str] = None) -> dict:
|
|
90
|
+
with conn.cursor() as cur:
|
|
91
|
+
cur.execute("select gnomon.merge(%s, %s)", (source, target))
|
|
92
|
+
mid = cur.fetchone()[0]
|
|
93
|
+
cur.execute("select id, status, stats from gnomon.merge where id = %s", (mid,))
|
|
94
|
+
i, status, stats = cur.fetchone()
|
|
95
|
+
return {"id": i, "status": status, "stats": stats}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def diff(conn, a: str, b: Optional[str] = None) -> list[dict]:
|
|
99
|
+
with conn.cursor() as cur:
|
|
100
|
+
cur.execute("select tbl::text, key, kind, col, old, new from gnomon.diff(%s, %s)", (a, b))
|
|
101
|
+
cols = ["table", "key", "kind", "col", "old", "new"]
|
|
102
|
+
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@contextlib.contextmanager
|
|
106
|
+
def branch(conn, name: str, as_of: Optional[str] = None) -> Iterator[None]:
|
|
107
|
+
"""``with gnomon.branch(conn, "feature"):`` - the session reads and writes the branch inside."""
|
|
108
|
+
use(conn, name, as_of)
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
if not conn.closed:
|
|
113
|
+
if conn.info.transaction_status != 0: # not idle: leave the caller's transaction alone
|
|
114
|
+
conn.rollback()
|
|
115
|
+
use(conn, "main")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def connect(dsn: str, branch_name: Optional[str] = None, **kw):
|
|
119
|
+
"""psycopg.connect, optionally starting on a branch."""
|
|
120
|
+
import psycopg
|
|
121
|
+
|
|
122
|
+
conn = psycopg.connect(dsn, **kw)
|
|
123
|
+
if branch_name:
|
|
124
|
+
use(conn, branch_name)
|
|
125
|
+
return conn
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# The SDK: runs, review, core. Imported lazily so the helpers above need
|
|
129
|
+
# nothing but psycopg.
|
|
130
|
+
def __getattr__(name):
|
|
131
|
+
if name in ("Gnomon", "Run", "Request", "Branches"):
|
|
132
|
+
from . import client
|
|
133
|
+
return getattr(client, name)
|
|
134
|
+
if name in ("AsyncGnomon", "AsyncRun", "AsyncRequest", "AsyncBranches"):
|
|
135
|
+
from . import aio
|
|
136
|
+
return getattr(aio, name)
|
|
137
|
+
if name in ("GnomonError",):
|
|
138
|
+
from . import errors
|
|
139
|
+
return getattr(errors, name)
|
|
140
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Lossless JSON and the statement splitter (shared by the sync and async clients)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_json(text: Any) -> Any:
|
|
11
|
+
"""JSON text to Python: integers stay exact (int), decimals become Decimal (spec: Values)."""
|
|
12
|
+
if text is None:
|
|
13
|
+
return None
|
|
14
|
+
if not isinstance(text, str):
|
|
15
|
+
return text
|
|
16
|
+
return json.loads(text, parse_float=Decimal)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def stable_json(v: Any) -> str:
|
|
20
|
+
"""JSON text with object keys sorted, compact, as the TypeScript SDK writes it (snapshots, digests)."""
|
|
21
|
+
def default(x):
|
|
22
|
+
return str(x)
|
|
23
|
+
return json.dumps(_num(v), sort_keys=True, separators=(",", ":"), default=default, ensure_ascii=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _num(v):
|
|
27
|
+
if isinstance(v, Decimal):
|
|
28
|
+
return int(v) if v == v.to_integral_value() else float(v) if str(float(v)) == str(v) else str(v)
|
|
29
|
+
if isinstance(v, dict):
|
|
30
|
+
return {k: _num(x) for k, x in v.items()}
|
|
31
|
+
if isinstance(v, list):
|
|
32
|
+
return [_num(x) for x in v]
|
|
33
|
+
return v
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_DOLLAR = re.compile(r"\$([A-Za-z_][A-Za-z_0-9]*)?\$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def split_sql(sql: str) -> list[str]:
|
|
40
|
+
"""Top-level statements of a script (the port of tools/sqlsplit.py)."""
|
|
41
|
+
out, buf, i, n = [], [], 0, len(sql)
|
|
42
|
+
while i < n:
|
|
43
|
+
c = sql[i]
|
|
44
|
+
if c == "-" and sql.startswith("--", i):
|
|
45
|
+
j = sql.find("\n", i)
|
|
46
|
+
j = n if j < 0 else j
|
|
47
|
+
buf.append(sql[i:j]); i = j; continue
|
|
48
|
+
if c == "/" and sql.startswith("/*", i):
|
|
49
|
+
j = sql.find("*/", i + 2)
|
|
50
|
+
j = n if j < 0 else j + 2
|
|
51
|
+
buf.append(sql[i:j]); i = j; continue
|
|
52
|
+
if c == "'":
|
|
53
|
+
j = i + 1
|
|
54
|
+
while j < n:
|
|
55
|
+
if sql[j] == "'" and j + 1 < n and sql[j + 1] == "'":
|
|
56
|
+
j += 2; continue
|
|
57
|
+
if sql[j] == "'":
|
|
58
|
+
break
|
|
59
|
+
j += 1
|
|
60
|
+
buf.append(sql[i:j + 1]); i = j + 1; continue
|
|
61
|
+
if c == '"':
|
|
62
|
+
j = sql.find('"', i + 1)
|
|
63
|
+
j = n - 1 if j < 0 else j
|
|
64
|
+
buf.append(sql[i:j + 1]); i = j + 1; continue
|
|
65
|
+
if c == "$":
|
|
66
|
+
m = _DOLLAR.match(sql, i)
|
|
67
|
+
if m:
|
|
68
|
+
tag = m.group(0)
|
|
69
|
+
j = sql.find(tag, i + len(tag))
|
|
70
|
+
j = n if j < 0 else j + len(tag)
|
|
71
|
+
buf.append(sql[i:j]); i = j; continue
|
|
72
|
+
if c == ";":
|
|
73
|
+
buf.append(";"); out.append("".join(buf)); buf = []; i += 1; continue
|
|
74
|
+
buf.append(c); i += 1
|
|
75
|
+
rest = "".join(buf)
|
|
76
|
+
if rest.strip():
|
|
77
|
+
out.append(rest)
|
|
78
|
+
def empty(s):
|
|
79
|
+
s = re.sub(r"--[^\n]*", "", s)
|
|
80
|
+
s = re.sub(r"/\*.*?\*/", "", s, flags=re.S)
|
|
81
|
+
return s.replace(";", "").strip() == ""
|
|
82
|
+
return [s for s in out if not empty(s)]
|