s3rds 0.3.3__tar.gz → 0.3.4__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.
- {s3rds-0.3.3 → s3rds-0.3.4}/PKG-INFO +28 -16
- {s3rds-0.3.3 → s3rds-0.3.4}/README.md +17 -13
- {s3rds-0.3.3 → s3rds-0.3.4}/pyproject.toml +16 -9
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/__init__.py +10 -3
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/psycopg2/__init__.py +5 -4
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/pymysql.py +4 -5
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/server/lambda_function.py +20 -8
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/server/s3_sqlite.py +55 -4
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/sqlite/__init__.py +1 -1
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/sqlite/base.py +1 -1
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds.egg-info/PKG-INFO +28 -16
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds.egg-info/requires.txt +5 -2
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/psycopg2/errors.py +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/psycopg2/extensions.py +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/psycopg2/extras.py +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/server/__init__.py +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds/server/index.html +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds.egg-info/SOURCES.txt +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds.egg-info/dependency_links.txt +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds.egg-info/entry_points.txt +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/s3rds.egg-info/top_level.txt +0 -0
- {s3rds-0.3.3 → s3rds-0.3.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: s3rds
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: A truly serverless RDS on your own AWS account: Lambda URL endpoint, SQLite on S3, ≈ $0 when idle. Drivers named after the standard clients: s3rds.sqlite, s3rds.pymysql, s3rds.psycopg2; SQLAlchemy via sqlite+s3rds://, Django via ENGINE s3rds.sqlite.
|
|
5
5
|
Author: Frank Zhang
|
|
6
6
|
License: MIT
|
|
@@ -16,20 +16,28 @@ Classifier: Intended Audience :: Developers
|
|
|
16
16
|
Classifier: License :: OSI Approved :: MIT License
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
23
|
Classifier: Framework :: Django
|
|
24
|
+
Classifier: Framework :: Django :: 4.2
|
|
25
|
+
Classifier: Framework :: Django :: 5.2
|
|
26
|
+
Classifier: Framework :: Django :: 6.1
|
|
21
27
|
Classifier: Topic :: Database
|
|
22
28
|
Classifier: Topic :: Database :: Front-Ends
|
|
23
29
|
Requires-Python: >=3.9
|
|
24
30
|
Description-Content-Type: text/markdown
|
|
25
|
-
Requires-Dist: sqlglot<31,>=30.24
|
|
26
31
|
Provides-Extra: mysql
|
|
32
|
+
Requires-Dist: sqlglot<31,>=30.24; extra == "mysql"
|
|
33
|
+
Provides-Extra: postgres
|
|
34
|
+
Requires-Dist: sqlglot<31,>=30.24; extra == "postgres"
|
|
27
35
|
Provides-Extra: server
|
|
28
36
|
Requires-Dist: sqlean.py==3.50.4.5; extra == "server"
|
|
29
37
|
Provides-Extra: sqlalchemy
|
|
30
38
|
Requires-Dist: SQLAlchemy<2.1,>=2.0; extra == "sqlalchemy"
|
|
31
39
|
Provides-Extra: django
|
|
32
|
-
Requires-Dist: Django<6.2,>=
|
|
40
|
+
Requires-Dist: Django<6.2,>=4.2; extra == "django"
|
|
33
41
|
|
|
34
42
|
# s3rds
|
|
35
43
|
|
|
@@ -59,11 +67,13 @@ while nobody uses it. Deploy it once with Terraform from the
|
|
|
59
67
|
**Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
|
|
60
68
|
a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
70
|
+
No dependencies for the native paths; the translating drivers take an extra. Each driver is a submodule **named after
|
|
71
|
+
the standard client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
64
72
|
|
|
65
73
|
```bash
|
|
66
|
-
pip install s3rds
|
|
74
|
+
pip install s3rds # s3rds.sqlite, sqlite+s3rds:// for SQLAlchemy, ENGINE s3rds.sqlite for Django
|
|
75
|
+
pip install "s3rds[mysql]" # + s3rds.pymysql (sqlglot: MySQL -> SQLite)
|
|
76
|
+
pip install "s3rds[postgres]" # + s3rds.psycopg2 (sqlglot: PostgreSQL -> SQLite)
|
|
67
77
|
```
|
|
68
78
|
|
|
69
79
|
```python
|
|
@@ -88,13 +98,15 @@ conn.transaction([ # all-or-nothing, single S3 w
|
|
|
88
98
|
])
|
|
89
99
|
```
|
|
90
100
|
|
|
91
|
-
| Submodule | SQL dialect | Mirrors |
|
|
92
|
-
|
|
93
|
-
| `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API |
|
|
94
|
-
| `s3rds
|
|
95
|
-
| `s3rds.
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
101
|
+
| Submodule | Kind | SQL dialect | Mirrors | Install |
|
|
102
|
+
|---|---|---|---|---|
|
|
103
|
+
| `s3rds.sqlite` | native | SQLite (no translation) | pymysql-shaped API | `s3rds` |
|
|
104
|
+
| `sqlite+s3rds://` | native | SQLite, written by SQLAlchemy | SQLAlchemy 2.0 Core / ORM | `s3rds` (+ SQLAlchemy) |
|
|
105
|
+
| `ENGINE: "s3rds.sqlite"` | native | SQLite, written by Django | Django 4.2, 5.2, 6.1 | `s3rds` (+ Django) |
|
|
106
|
+
| `s3rds.pymysql` | translated | MySQL, translated on the client | pymysql | `"s3rds[mysql]"` |
|
|
107
|
+
| `s3rds.psycopg2` | translated | PostgreSQL, translated on the client | psycopg2 (`extras`, `errors`, `extensions`) | `"s3rds[postgres]"` |
|
|
108
|
+
|
|
109
|
+
Native is the most reliable: the SQL is SQLite's to begin with. For a new project, prefer SQLAlchemy or Django.
|
|
98
110
|
|
|
99
111
|
**SQLAlchemy** (Core and ORM): only the URL changes — SQLAlchemy's own SQLite dialect writes the SQL,
|
|
100
112
|
`s3rds.sqlite` replaces the standard library's `sqlite3` underneath.
|
|
@@ -144,11 +156,11 @@ What the report was verified with; the full report: [REPORT.md](https://github.c
|
|
|
144
156
|
|
|
145
157
|
| | Version | Fixed by |
|
|
146
158
|
|---|---|---|
|
|
147
|
-
| Python (client) | 3.9
|
|
159
|
+
| Python (client) | 3.9, 3.10, 3.11, 3.12, 3.13, each in CI | `requires-python = ">=3.9"` |
|
|
148
160
|
| Server runtime | AWS Lambda `python3.12` | `terraform/main.tf` |
|
|
149
161
|
| SQLite (server engine) | 3.50.4, shipped in the Lambda zip (the runtime's own is 3.40.0) | extra `pip install "s3rds[server]"` = `sqlean.py==3.50.4.5` |
|
|
150
|
-
| sqlglot (MySQL translation) | 30.24 |
|
|
162
|
+
| sqlglot (MySQL / PostgreSQL translation) | 30.24 | extras `"s3rds[mysql]"` / `"s3rds[postgres]"` = `sqlglot>=30.24,<31` |
|
|
151
163
|
| SQLAlchemy | 2.0, tested on 2.0.55 | extra `pip install "s3rds[sqlalchemy]"` = `SQLAlchemy>=2.0,<2.1` |
|
|
152
|
-
| Django |
|
|
164
|
+
| Django | 4.2 (Python 3.9, 3.10), 5.2 (3.11, 3.12), 6.1 (3.12, 3.13), each in CI | extra `pip install "s3rds[django]"` = `Django>=4.2,<6.2` |
|
|
153
165
|
| MySQL (reference) | 8.0.46 in Docker | `tests/compat/docker-compose.yml`: `mysql:8.0.46` |
|
|
154
166
|
|
|
@@ -26,11 +26,13 @@ while nobody uses it. Deploy it once with Terraform from the
|
|
|
26
26
|
**Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
|
|
27
27
|
a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
29
|
+
No dependencies for the native paths; the translating drivers take an extra. Each driver is a submodule **named after
|
|
30
|
+
the standard client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
pip install s3rds
|
|
33
|
+
pip install s3rds # s3rds.sqlite, sqlite+s3rds:// for SQLAlchemy, ENGINE s3rds.sqlite for Django
|
|
34
|
+
pip install "s3rds[mysql]" # + s3rds.pymysql (sqlglot: MySQL -> SQLite)
|
|
35
|
+
pip install "s3rds[postgres]" # + s3rds.psycopg2 (sqlglot: PostgreSQL -> SQLite)
|
|
34
36
|
```
|
|
35
37
|
|
|
36
38
|
```python
|
|
@@ -55,13 +57,15 @@ conn.transaction([ # all-or-nothing, single S3 w
|
|
|
55
57
|
])
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
| Submodule | SQL dialect | Mirrors |
|
|
59
|
-
|
|
60
|
-
| `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API |
|
|
61
|
-
| `s3rds
|
|
62
|
-
| `s3rds.
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
60
|
+
| Submodule | Kind | SQL dialect | Mirrors | Install |
|
|
61
|
+
|---|---|---|---|---|
|
|
62
|
+
| `s3rds.sqlite` | native | SQLite (no translation) | pymysql-shaped API | `s3rds` |
|
|
63
|
+
| `sqlite+s3rds://` | native | SQLite, written by SQLAlchemy | SQLAlchemy 2.0 Core / ORM | `s3rds` (+ SQLAlchemy) |
|
|
64
|
+
| `ENGINE: "s3rds.sqlite"` | native | SQLite, written by Django | Django 4.2, 5.2, 6.1 | `s3rds` (+ Django) |
|
|
65
|
+
| `s3rds.pymysql` | translated | MySQL, translated on the client | pymysql | `"s3rds[mysql]"` |
|
|
66
|
+
| `s3rds.psycopg2` | translated | PostgreSQL, translated on the client | psycopg2 (`extras`, `errors`, `extensions`) | `"s3rds[postgres]"` |
|
|
67
|
+
|
|
68
|
+
Native is the most reliable: the SQL is SQLite's to begin with. For a new project, prefer SQLAlchemy or Django.
|
|
65
69
|
|
|
66
70
|
**SQLAlchemy** (Core and ORM): only the URL changes — SQLAlchemy's own SQLite dialect writes the SQL,
|
|
67
71
|
`s3rds.sqlite` replaces the standard library's `sqlite3` underneath.
|
|
@@ -111,11 +115,11 @@ What the report was verified with; the full report: [REPORT.md](https://github.c
|
|
|
111
115
|
|
|
112
116
|
| | Version | Fixed by |
|
|
113
117
|
|---|---|---|
|
|
114
|
-
| Python (client) | 3.9
|
|
118
|
+
| Python (client) | 3.9, 3.10, 3.11, 3.12, 3.13, each in CI | `requires-python = ">=3.9"` |
|
|
115
119
|
| Server runtime | AWS Lambda `python3.12` | `terraform/main.tf` |
|
|
116
120
|
| SQLite (server engine) | 3.50.4, shipped in the Lambda zip (the runtime's own is 3.40.0) | extra `pip install "s3rds[server]"` = `sqlean.py==3.50.4.5` |
|
|
117
|
-
| sqlglot (MySQL translation) | 30.24 |
|
|
121
|
+
| sqlglot (MySQL / PostgreSQL translation) | 30.24 | extras `"s3rds[mysql]"` / `"s3rds[postgres]"` = `sqlglot>=30.24,<31` |
|
|
118
122
|
| SQLAlchemy | 2.0, tested on 2.0.55 | extra `pip install "s3rds[sqlalchemy]"` = `SQLAlchemy>=2.0,<2.1` |
|
|
119
|
-
| Django |
|
|
123
|
+
| Django | 4.2 (Python 3.9, 3.10), 5.2 (3.11, 3.12), 6.1 (3.12, 3.13), each in CI | extra `pip install "s3rds[django]"` = `Django>=4.2,<6.2` |
|
|
120
124
|
| MySQL (reference) | 8.0.46 in Docker | `tests/compat/docker-compose.yml`: `mysql:8.0.46` |
|
|
121
125
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "s3rds"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "A truly serverless RDS on your own AWS account: Lambda URL endpoint, SQLite on S3, ≈ $0 when idle. Drivers named after the standard clients: s3rds.sqlite, s3rds.pymysql, s3rds.psycopg2; SQLAlchemy via sqlite+s3rds://, Django via ENGINE s3rds.sqlite."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -17,21 +17,28 @@ classifiers = [
|
|
|
17
17
|
"License :: OSI Approved :: MIT License",
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
19
19
|
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
20
23
|
"Programming Language :: Python :: 3.13",
|
|
21
24
|
"Framework :: Django",
|
|
25
|
+
"Framework :: Django :: 4.2",
|
|
26
|
+
"Framework :: Django :: 5.2",
|
|
27
|
+
"Framework :: Django :: 6.1",
|
|
22
28
|
"Topic :: Database",
|
|
23
29
|
"Topic :: Database :: Front-Ends",
|
|
24
30
|
]
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# change between releases)
|
|
28
|
-
dependencies = [
|
|
31
|
+
# no dependencies: s3rds.sqlite (and SQLAlchemy / Django on it) is the standard library only. The translating drivers
|
|
32
|
+
# need sqlglot (MySQL / PostgreSQL -> SQLite), pinned to the series the compatibility report was verified with
|
|
33
|
+
# (translations change between releases): pip install "s3rds[mysql]" / "s3rds[postgres]".
|
|
34
|
+
dependencies = []
|
|
29
35
|
|
|
30
36
|
[project.optional-dependencies]
|
|
31
|
-
mysql = []
|
|
37
|
+
mysql = ["sqlglot>=30.24,<31"] # s3rds.pymysql
|
|
38
|
+
postgres = ["sqlglot>=30.24,<31"] # s3rds.psycopg2
|
|
32
39
|
server = ["sqlean.py==3.50.4.5"] # the Lambda: s3rds.server + SQLite 3.50.4 instead of the runtime's 3.40.0
|
|
33
40
|
sqlalchemy = ["SQLAlchemy>=2.0,<2.1"] # the series the compatibility report verified; pip install "s3rds[sqlalchemy]"
|
|
34
|
-
django = ["Django>=
|
|
41
|
+
django = ["Django>=4.2,<6.2"] # ENGINE "s3rds.sqlite"; CI runs 4.2 (Python 3.9, 3.10), 5.2 (3.11, 3.12), 6.1 (3.12, 3.13)
|
|
35
42
|
|
|
36
43
|
[project.entry-points."sqlalchemy.dialects"]
|
|
37
44
|
"sqlite.s3rds" = "s3rds.sqlite:SQLAlchemyDialect"
|
|
@@ -52,13 +59,13 @@ packages = ["s3rds", "s3rds.sqlite", "s3rds.psycopg2", "s3rds.server"]
|
|
|
52
59
|
|
|
53
60
|
# The versions tests/compat/REPORT.md was verified with; README.md, README.zh.md and client/README.md state the same.
|
|
54
61
|
[tool.s3rds.verified]
|
|
55
|
-
python = ["3.9.
|
|
62
|
+
python = ["3.9", "3.10", "3.11", "3.12", "3.13"] # the client: the full suite in CI (.github/workflows/test.yml)
|
|
56
63
|
lambda_runtime = "python3.12" # the server (terraform/main.tf)
|
|
57
64
|
sqlite = "3.50.4" # the server's engine, shipped in the Lambda zip (the runtime's own is 3.40.0)
|
|
58
65
|
sqlean = "3.50.4.5" # sqlean.py, which carries it (extra "server")
|
|
59
66
|
sqlglot = "30.24.0"
|
|
60
67
|
sqlalchemy = "2.0.55"
|
|
61
|
-
django = "
|
|
68
|
+
django = ["4.2", "5.2", "6.1"] # series; CI installs the latest release of each
|
|
62
69
|
mysql_reference = "8.0.46" # tests/compat/docker-compose.yml
|
|
63
70
|
postgres_reference = "16.4" # the same
|
|
64
71
|
psycopg2 = "2.9.12" # the reference client (psycopg2-binary)
|
|
@@ -14,9 +14,16 @@ uses SQLAlchemy's SQLite dialect with s3rds.sqlite as its driver; on RDS the URL
|
|
|
14
14
|
Django needs no import either: DATABASES ENGINE "s3rds.sqlite" (s3rds/sqlite/base.py) is Django's SQLite
|
|
15
15
|
backend on s3rds.sqlite; on RDS the ENGINE becomes django.db.backends.mysql.
|
|
16
16
|
"""
|
|
17
|
-
from . import sqlite # noqa: F401
|
|
18
|
-
from . import pymysql # noqa: F401 (sqlglot is only needed when a MySQL statement is actually translated)
|
|
19
|
-
from . import psycopg2 # noqa: F401
|
|
17
|
+
from . import sqlite # noqa: F401 (standard library only)
|
|
20
18
|
|
|
21
19
|
__all__ = ["sqlite", "pymysql", "psycopg2"]
|
|
22
20
|
__version__ = sqlite.__version__
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name):
|
|
24
|
+
"""s3rds.pymysql / s3rds.psycopg2 load on first use: they translate with sqlglot (extras "mysql" / "postgres"),
|
|
25
|
+
which s3rds.sqlite, SQLAlchemy, Django and the server (s3rds.server) never need."""
|
|
26
|
+
if name in ("pymysql", "psycopg2"):
|
|
27
|
+
import importlib
|
|
28
|
+
return importlib.import_module(f".{name}", __name__)
|
|
29
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""s3rds.psycopg2 — the PostgreSQL-dialect driver for s3rds. Write PostgreSQL; it runs on the s3rds endpoint (SQLite engine).
|
|
2
2
|
|
|
3
|
+
pip install "s3rds[postgres]" # the extra brings sqlglot, the dialect translation
|
|
4
|
+
|
|
3
5
|
from s3rds import psycopg2 # later: `import psycopg2` on a real RDS, nothing else changes
|
|
4
6
|
from s3rds.psycopg2.extras import RealDictCursor
|
|
5
7
|
conn = psycopg2.connect(host="xxx.lambda-url.us-east-2.on.aws", dbname="blog", user="admin", password="...")
|
|
@@ -26,8 +28,9 @@ try:
|
|
|
26
28
|
import sqlglot
|
|
27
29
|
from sqlglot import exp
|
|
28
30
|
from sqlglot.errors import ParseError
|
|
29
|
-
except ImportError:
|
|
30
|
-
sqlglot
|
|
31
|
+
except ImportError:
|
|
32
|
+
raise ImportError('s3rds.psycopg2 translates PostgreSQL with sqlglot, which is not installed: '
|
|
33
|
+
'pip install "s3rds[postgres]"') from None
|
|
31
34
|
|
|
32
35
|
__version__ = _sqlite.__version__
|
|
33
36
|
apilevel = "2.0"
|
|
@@ -170,8 +173,6 @@ def _translate(sql):
|
|
|
170
173
|
tables = [t.strip().strip('"') for t in m.group(1).split(",")]
|
|
171
174
|
return tuple([f'DELETE FROM "{t}"' for t in tables]
|
|
172
175
|
+ ([f"DELETE FROM sqlite_sequence WHERE name = '{t}'" for t in tables] if m.group(2) else []))
|
|
173
|
-
if sqlglot is None:
|
|
174
|
-
raise NotSupportedError("the PostgreSQL dialect needs sqlglot (an s3rds dependency): pip install s3rds")
|
|
175
176
|
try:
|
|
176
177
|
tree = sqlglot.parse_one(sql, read="postgres")
|
|
177
178
|
except ParseError as e:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""s3rds.pymysql — the MySQL-dialect driver for s3rds. Write MySQL; it runs on the s3rds endpoint (SQLite engine).
|
|
2
2
|
|
|
3
|
-
pip install s3rds
|
|
3
|
+
pip install "s3rds[mysql]" # the extra brings sqlglot, the dialect translation
|
|
4
4
|
|
|
5
5
|
from s3rds import pymysql # later: `import pymysql` on a real RDS, nothing else changes
|
|
6
6
|
conn = pymysql.connect(host="xxx.lambda-url.us-east-2.on.aws",
|
|
@@ -42,8 +42,9 @@ try:
|
|
|
42
42
|
import sqlglot
|
|
43
43
|
from sqlglot import exp
|
|
44
44
|
from sqlglot.errors import ParseError
|
|
45
|
-
except ImportError:
|
|
46
|
-
sqlglot
|
|
45
|
+
except ImportError:
|
|
46
|
+
raise ImportError('s3rds.pymysql translates MySQL with sqlglot, which is not installed: '
|
|
47
|
+
'pip install "s3rds[mysql]"') from None
|
|
47
48
|
|
|
48
49
|
__version__ = _sqlite.__version__
|
|
49
50
|
apilevel = "2.0"
|
|
@@ -557,8 +558,6 @@ def _fix_delete_join(tree):
|
|
|
557
558
|
def _translate_stmts(sql):
|
|
558
559
|
"""MySQL statement -> tuple of SQLite statements (usually one; CREATE TABLE with inline indexes and
|
|
559
560
|
RENAME TABLE give several, a type-only ALTER gives none)."""
|
|
560
|
-
if sqlglot is None:
|
|
561
|
-
raise NotSupportedError(0, "the MySQL dialect needs sqlglot (an s3rds dependency): pip install s3rds")
|
|
562
561
|
m = _RENAME_TABLE.match(sql)
|
|
563
562
|
if m:
|
|
564
563
|
pairs = _RENAME_PAIR.findall(m.group(1))
|
|
@@ -17,7 +17,9 @@ applied before BEGIN on every request (the server keeps no session).
|
|
|
17
17
|
Read / write is decided by the endpoint, never by guessing the SQL:
|
|
18
18
|
write endpoint (default) /query goes through run(): SQLite decides whether data changed, only then S3 is written
|
|
19
19
|
read-only endpoint READONLY=1 everything opens mode=ro; a write is refused by SQLite -> 403
|
|
20
|
-
Auth: HTTP Basic, the same meaning as RDS user / password.
|
|
20
|
+
Auth: HTTP Basic, the same meaning as RDS user / password. No DB_USER / DB_PASSWORD -> every request 503, unless
|
|
21
|
+
ALLOW_NO_AUTH=1 (local experiments only), which answers without auth and says so in x-s3rds-warning: no-auth.
|
|
22
|
+
ATTACH / DETACH / VACUUM INTO -> 403: a request works on its own database file only.
|
|
21
23
|
Every JSON reply carries ms (server time); data calls also carry s3 (cache-hit / download / +upload).
|
|
22
24
|
"""
|
|
23
25
|
import base64
|
|
@@ -27,13 +29,14 @@ import os
|
|
|
27
29
|
import re
|
|
28
30
|
import time
|
|
29
31
|
|
|
30
|
-
from .s3_sqlite import (SESSION_PRAGMAS, DatabaseExists, DatabaseNotFound, SqliteS3, WriteConflict,
|
|
32
|
+
from .s3_sqlite import (SESSION_PRAGMAS, DatabaseExists, DatabaseNotFound, NotAllowed, SqliteS3, WriteConflict,
|
|
31
33
|
create_database, list_databases, sqlite3)
|
|
32
34
|
|
|
33
35
|
BUCKET = os.environ["DB_BUCKET"]
|
|
34
36
|
PREFIX = os.environ.get("DB_PREFIX", "")
|
|
35
37
|
DB_USER = os.environ.get("DB_USER", "")
|
|
36
38
|
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
|
|
39
|
+
ALLOW_NO_AUTH = os.environ.get("ALLOW_NO_AUTH") == "1" # no credentials configured: refuse (503) unless this is set
|
|
37
40
|
READONLY = os.environ.get("READONLY") == "1"
|
|
38
41
|
CONSOLE = os.environ.get("CONSOLE", "1") == "1" # the web console at GET /; unset = on
|
|
39
42
|
|
|
@@ -118,11 +121,15 @@ def _int(qs, key, default, lo, hi):
|
|
|
118
121
|
return max(lo, min(value, hi))
|
|
119
122
|
|
|
120
123
|
|
|
124
|
+
def _no_credentials():
|
|
125
|
+
return not DB_USER or not DB_PASSWORD
|
|
126
|
+
|
|
127
|
+
|
|
121
128
|
def _authed(event):
|
|
122
|
-
"""HTTP Basic, constant-time compare.
|
|
123
|
-
if
|
|
124
|
-
return
|
|
125
|
-
auth =
|
|
129
|
+
"""HTTP Basic, constant-time compare. The handler answers 503 before this when no credentials are configured."""
|
|
130
|
+
if _no_credentials():
|
|
131
|
+
return ALLOW_NO_AUTH
|
|
132
|
+
auth =(event.get("headers") or {}).get("authorization", "")
|
|
126
133
|
if not auth.lower().startswith("basic "):
|
|
127
134
|
return False
|
|
128
135
|
try:
|
|
@@ -388,6 +395,8 @@ def lambda_handler(event, context):
|
|
|
388
395
|
"body": INDEX_HTML,
|
|
389
396
|
}
|
|
390
397
|
|
|
398
|
+
if _no_credentials() and not ALLOW_NO_AUTH:
|
|
399
|
+
return _json(503, {"error": "server has no credentials configured (DB_USER / DB_PASSWORD)"})
|
|
391
400
|
if not _authed(event):
|
|
392
401
|
return _json(401, {"error": "bad credentials"})
|
|
393
402
|
|
|
@@ -402,7 +411,7 @@ def lambda_handler(event, context):
|
|
|
402
411
|
break
|
|
403
412
|
except BadRequest as e:
|
|
404
413
|
status, data = 400, {"error": str(e)}
|
|
405
|
-
except Forbidden as e:
|
|
414
|
+
except (Forbidden, NotAllowed) as e:
|
|
406
415
|
status, data = 403, {"error": str(e)}
|
|
407
416
|
except DatabaseNotFound as e:
|
|
408
417
|
status, data = 404, {"error": f"database not found: {e}"}
|
|
@@ -419,4 +428,7 @@ def lambda_handler(event, context):
|
|
|
419
428
|
status, data = 500, {"error": f"{type(e).__name__}: {e}"}
|
|
420
429
|
|
|
421
430
|
data.setdefault("ms", round((time.perf_counter() - t0) * 1000, 1))
|
|
422
|
-
|
|
431
|
+
resp = _json(status, data)
|
|
432
|
+
if _no_credentials(): # here only with ALLOW_NO_AUTH=1
|
|
433
|
+
resp["headers"]["x-s3rds-warning"] = "no-auth"
|
|
434
|
+
return resp
|
|
@@ -4,6 +4,7 @@ read(fn): opens the local cache mode=ro, SQLite itself refuses writes; never up
|
|
|
4
4
|
run(fn): after fn, SQLite's authorizer says whether data really changed; only then the whole file
|
|
5
5
|
goes back with If-Match (CAS); on 412 re-download, replay, retry; out of retries -> WriteConflict.
|
|
6
6
|
Rule: failing is allowed, duplicating a write or overwriting someone else's is not.
|
|
7
|
+
Every connection: ATTACH / DETACH (and so VACUUM INTO) refused -> NotAllowed; one request touches one file.
|
|
7
8
|
Cache: /tmp keyed by ETag; downloads go through a temp file + os.replace, never a half file.
|
|
8
9
|
One key = one database; the lock is the file, so different databases never conflict.
|
|
9
10
|
|
|
@@ -64,6 +65,45 @@ class WriteConflict(Exception):
|
|
|
64
65
|
pass
|
|
65
66
|
|
|
66
67
|
|
|
68
|
+
class NotAllowed(Exception):
|
|
69
|
+
"""A statement the authorizer refused: it would reach past this request's one database file."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ATTACH opens another file read/write even on a mode=ro connection: another database's /tmp cache (its path is
|
|
73
|
+
# sha1(bucket/key), so it can be guessed) could be changed and later uploaded. VACUUM INTO attaches too, so it is
|
|
74
|
+
# refused with it and cannot write files into /tmp either. Plain VACUUM attaches a temporary database as well and is
|
|
75
|
+
# refused the same way. On the read/write path SQLite stops any VACUUM before the authorizer is even asked (every
|
|
76
|
+
# request runs inside a transaction: "cannot VACUUM from within a transaction"); that error is reported as the same
|
|
77
|
+
# refusal, so both endpoints answer alike.
|
|
78
|
+
_DENIED_ACTIONS = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH}
|
|
79
|
+
_VACUUM_IN_TX = "cannot VACUUM from within a transaction" # SQLite's own message
|
|
80
|
+
NOT_ALLOWED = "ATTACH / DETACH / VACUUM INTO are not allowed: a request works on its own database only"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _Authorizer:
|
|
84
|
+
"""SQLite's authorizer for every connection: ATTACH / DETACH denied; on_allowed(action, arg1, arg2) sees the rest.
|
|
85
|
+
SQLite reports a denial as a plain DatabaseError ("not authorized"); denied says it was ours -> NotAllowed."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, on_allowed=None):
|
|
88
|
+
self.on_allowed = on_allowed
|
|
89
|
+
self.denied = False
|
|
90
|
+
|
|
91
|
+
def __call__(self, action, arg1, arg2, _dbname, _source): # signature fixed by sqlite3
|
|
92
|
+
if action in _DENIED_ACTIONS:
|
|
93
|
+
self.denied = True
|
|
94
|
+
return sqlite3.SQLITE_DENY
|
|
95
|
+
if self.on_allowed:
|
|
96
|
+
self.on_allowed(action, arg1, arg2)
|
|
97
|
+
return sqlite3.SQLITE_OK
|
|
98
|
+
|
|
99
|
+
def translate(self, e):
|
|
100
|
+
"""The exception to raise for e: NotAllowed when the authorizer refused (or SQLite refused a VACUUM inside
|
|
101
|
+
the request's transaction), else e itself."""
|
|
102
|
+
if isinstance(e, sqlite3.DatabaseError) and (self.denied or _VACUUM_IN_TX in str(e)):
|
|
103
|
+
return NotAllowed(NOT_ALLOWED)
|
|
104
|
+
return e
|
|
105
|
+
|
|
106
|
+
|
|
67
107
|
def _local_path(bucket, key):
|
|
68
108
|
digest = hashlib.sha1(f"{bucket}/{key}".encode()).hexdigest()
|
|
69
109
|
return f"/tmp/{digest}.sqlite"
|
|
@@ -208,9 +248,16 @@ class SqliteS3:
|
|
|
208
248
|
self._ensure_fresh()
|
|
209
249
|
conn = sqlite3.connect(f"file:{self.local}?mode=ro", uri=True, detect_types=detect_types)
|
|
210
250
|
conn.row_factory = sqlite3.Row
|
|
251
|
+
auth = _Authorizer()
|
|
211
252
|
try:
|
|
212
253
|
_apply_pragmas(conn, pragmas)
|
|
254
|
+
conn.set_authorizer(auth)
|
|
213
255
|
return fn(conn)
|
|
256
|
+
except sqlite3.DatabaseError as e:
|
|
257
|
+
err = auth.translate(e)
|
|
258
|
+
if err is e:
|
|
259
|
+
raise
|
|
260
|
+
raise err from None
|
|
214
261
|
finally:
|
|
215
262
|
conn.close()
|
|
216
263
|
|
|
@@ -276,7 +323,7 @@ class SqliteS3:
|
|
|
276
323
|
"""
|
|
277
324
|
dirty = False
|
|
278
325
|
|
|
279
|
-
def
|
|
326
|
+
def note_write(action, arg1, arg2):
|
|
280
327
|
nonlocal dirty
|
|
281
328
|
if action == sqlite3.SQLITE_PRAGMA:
|
|
282
329
|
# only PRAGMA x = v is a write; the file fingerprint still catches anything that changed the file
|
|
@@ -284,7 +331,8 @@ class SqliteS3:
|
|
|
284
331
|
dirty = True
|
|
285
332
|
elif action not in _READ_ACTIONS:
|
|
286
333
|
dirty = True
|
|
287
|
-
|
|
334
|
+
|
|
335
|
+
authorizer = _Authorizer(note_write)
|
|
288
336
|
|
|
289
337
|
# isolation_level=None: no implicit transactions from the sqlite3 module; we BEGIN/COMMIT ourselves
|
|
290
338
|
conn = sqlite3.connect(self.local, isolation_level=None, detect_types=detect_types)
|
|
@@ -303,13 +351,16 @@ class SqliteS3:
|
|
|
303
351
|
raise sqlite3.OperationalError("COMMIT / END inside a transaction trial; use the commit request")
|
|
304
352
|
if conn.in_transaction: # fn may have committed itself
|
|
305
353
|
conn.execute("COMMIT" if commit else "ROLLBACK")
|
|
306
|
-
except BaseException:
|
|
354
|
+
except BaseException as e:
|
|
307
355
|
try:
|
|
308
356
|
if conn.in_transaction:
|
|
309
357
|
conn.execute("ROLLBACK")
|
|
310
358
|
except sqlite3.Error:
|
|
311
359
|
self._invalidate() # even ROLLBACK failed: the local state cannot be trusted
|
|
312
|
-
|
|
360
|
+
err = authorizer.translate(e)
|
|
361
|
+
if err is e:
|
|
362
|
+
raise
|
|
363
|
+
raise err from None
|
|
313
364
|
finally:
|
|
314
365
|
conn.close()
|
|
315
366
|
return result, dirty
|
|
@@ -239,7 +239,7 @@ class DatabaseWrapper(base.DatabaseWrapper):
|
|
|
239
239
|
# no register_functions(conn): Python functions would have to run on the server (DatabaseOperations above)
|
|
240
240
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
241
241
|
conn.execute("PRAGMA legacy_alter_table = OFF")
|
|
242
|
-
for init_command in self
|
|
242
|
+
for init_command in getattr(self, "init_commands", ()): # OPTIONS init_command: Django 5.1+ only
|
|
243
243
|
if init_command := init_command.strip():
|
|
244
244
|
conn.execute(init_command)
|
|
245
245
|
return conn
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: s3rds
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: A truly serverless RDS on your own AWS account: Lambda URL endpoint, SQLite on S3, ≈ $0 when idle. Drivers named after the standard clients: s3rds.sqlite, s3rds.pymysql, s3rds.psycopg2; SQLAlchemy via sqlite+s3rds://, Django via ENGINE s3rds.sqlite.
|
|
5
5
|
Author: Frank Zhang
|
|
6
6
|
License: MIT
|
|
@@ -16,20 +16,28 @@ Classifier: Intended Audience :: Developers
|
|
|
16
16
|
Classifier: License :: OSI Approved :: MIT License
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
23
|
Classifier: Framework :: Django
|
|
24
|
+
Classifier: Framework :: Django :: 4.2
|
|
25
|
+
Classifier: Framework :: Django :: 5.2
|
|
26
|
+
Classifier: Framework :: Django :: 6.1
|
|
21
27
|
Classifier: Topic :: Database
|
|
22
28
|
Classifier: Topic :: Database :: Front-Ends
|
|
23
29
|
Requires-Python: >=3.9
|
|
24
30
|
Description-Content-Type: text/markdown
|
|
25
|
-
Requires-Dist: sqlglot<31,>=30.24
|
|
26
31
|
Provides-Extra: mysql
|
|
32
|
+
Requires-Dist: sqlglot<31,>=30.24; extra == "mysql"
|
|
33
|
+
Provides-Extra: postgres
|
|
34
|
+
Requires-Dist: sqlglot<31,>=30.24; extra == "postgres"
|
|
27
35
|
Provides-Extra: server
|
|
28
36
|
Requires-Dist: sqlean.py==3.50.4.5; extra == "server"
|
|
29
37
|
Provides-Extra: sqlalchemy
|
|
30
38
|
Requires-Dist: SQLAlchemy<2.1,>=2.0; extra == "sqlalchemy"
|
|
31
39
|
Provides-Extra: django
|
|
32
|
-
Requires-Dist: Django<6.2,>=
|
|
40
|
+
Requires-Dist: Django<6.2,>=4.2; extra == "django"
|
|
33
41
|
|
|
34
42
|
# s3rds
|
|
35
43
|
|
|
@@ -59,11 +67,13 @@ while nobody uses it. Deploy it once with Terraform from the
|
|
|
59
67
|
**Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
|
|
60
68
|
a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
70
|
+
No dependencies for the native paths; the translating drivers take an extra. Each driver is a submodule **named after
|
|
71
|
+
the standard client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
64
72
|
|
|
65
73
|
```bash
|
|
66
|
-
pip install s3rds
|
|
74
|
+
pip install s3rds # s3rds.sqlite, sqlite+s3rds:// for SQLAlchemy, ENGINE s3rds.sqlite for Django
|
|
75
|
+
pip install "s3rds[mysql]" # + s3rds.pymysql (sqlglot: MySQL -> SQLite)
|
|
76
|
+
pip install "s3rds[postgres]" # + s3rds.psycopg2 (sqlglot: PostgreSQL -> SQLite)
|
|
67
77
|
```
|
|
68
78
|
|
|
69
79
|
```python
|
|
@@ -88,13 +98,15 @@ conn.transaction([ # all-or-nothing, single S3 w
|
|
|
88
98
|
])
|
|
89
99
|
```
|
|
90
100
|
|
|
91
|
-
| Submodule | SQL dialect | Mirrors |
|
|
92
|
-
|
|
93
|
-
| `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API |
|
|
94
|
-
| `s3rds
|
|
95
|
-
| `s3rds.
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
101
|
+
| Submodule | Kind | SQL dialect | Mirrors | Install |
|
|
102
|
+
|---|---|---|---|---|
|
|
103
|
+
| `s3rds.sqlite` | native | SQLite (no translation) | pymysql-shaped API | `s3rds` |
|
|
104
|
+
| `sqlite+s3rds://` | native | SQLite, written by SQLAlchemy | SQLAlchemy 2.0 Core / ORM | `s3rds` (+ SQLAlchemy) |
|
|
105
|
+
| `ENGINE: "s3rds.sqlite"` | native | SQLite, written by Django | Django 4.2, 5.2, 6.1 | `s3rds` (+ Django) |
|
|
106
|
+
| `s3rds.pymysql` | translated | MySQL, translated on the client | pymysql | `"s3rds[mysql]"` |
|
|
107
|
+
| `s3rds.psycopg2` | translated | PostgreSQL, translated on the client | psycopg2 (`extras`, `errors`, `extensions`) | `"s3rds[postgres]"` |
|
|
108
|
+
|
|
109
|
+
Native is the most reliable: the SQL is SQLite's to begin with. For a new project, prefer SQLAlchemy or Django.
|
|
98
110
|
|
|
99
111
|
**SQLAlchemy** (Core and ORM): only the URL changes — SQLAlchemy's own SQLite dialect writes the SQL,
|
|
100
112
|
`s3rds.sqlite` replaces the standard library's `sqlite3` underneath.
|
|
@@ -144,11 +156,11 @@ What the report was verified with; the full report: [REPORT.md](https://github.c
|
|
|
144
156
|
|
|
145
157
|
| | Version | Fixed by |
|
|
146
158
|
|---|---|---|
|
|
147
|
-
| Python (client) | 3.9
|
|
159
|
+
| Python (client) | 3.9, 3.10, 3.11, 3.12, 3.13, each in CI | `requires-python = ">=3.9"` |
|
|
148
160
|
| Server runtime | AWS Lambda `python3.12` | `terraform/main.tf` |
|
|
149
161
|
| SQLite (server engine) | 3.50.4, shipped in the Lambda zip (the runtime's own is 3.40.0) | extra `pip install "s3rds[server]"` = `sqlean.py==3.50.4.5` |
|
|
150
|
-
| sqlglot (MySQL translation) | 30.24 |
|
|
162
|
+
| sqlglot (MySQL / PostgreSQL translation) | 30.24 | extras `"s3rds[mysql]"` / `"s3rds[postgres]"` = `sqlglot>=30.24,<31` |
|
|
151
163
|
| SQLAlchemy | 2.0, tested on 2.0.55 | extra `pip install "s3rds[sqlalchemy]"` = `SQLAlchemy>=2.0,<2.1` |
|
|
152
|
-
| Django |
|
|
164
|
+
| Django | 4.2 (Python 3.9, 3.10), 5.2 (3.11, 3.12), 6.1 (3.12, 3.13), each in CI | extra `pip install "s3rds[django]"` = `Django>=4.2,<6.2` |
|
|
153
165
|
| MySQL (reference) | 8.0.46 in Docker | `tests/compat/docker-compose.yml`: `mysql:8.0.46` |
|
|
154
166
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|