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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: s3rds
3
- Version: 0.3.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,>=6.1; extra == "django"
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
- One install, one dependency (sqlglot, used only by `s3rds.pymysql` and `s3rds.psycopg2`). Each driver is a submodule **named after the standard
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 # s3rds.sqlite, s3rds.pymysql, s3rds.psycopg2, sqlite+s3rds:// for SQLAlchemy, ENGINE s3rds.sqlite for Django
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 | Status |
92
- |---|---|---|---|
93
- | `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
94
- | `s3rds.pymysql` | MySQL, translated on the client | pymysql | available |
95
- | `s3rds.psycopg2` | PostgreSQL, translated on the client | psycopg2 (`extras`, `errors`, `extensions`) | available |
96
- | `sqlite+s3rds://` | SQLite, written by SQLAlchemy | SQLAlchemy Core / ORM | available — the URL is the only change |
97
- | `ENGINE: "s3rds.sqlite"` | SQLite, written by Django | Django's SQLite backend | available — the database settings are the only change |
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 – 3.13: regression suite on 3.9.6, full comparison on 3.13.7 | `requires-python = ">=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 | dependency `sqlglot>=30.24,<31` |
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 | 6.1, tested on 6.1.2 (Python ≥ 3.12) | extra `pip install "s3rds[django]"` = `Django>=6.1,<6.2` |
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
- One install, one dependency (sqlglot, used only by `s3rds.pymysql` and `s3rds.psycopg2`). Each driver is a submodule **named after the standard
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 # s3rds.sqlite, s3rds.pymysql, s3rds.psycopg2, sqlite+s3rds:// for SQLAlchemy, ENGINE s3rds.sqlite for Django
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 | Status |
59
- |---|---|---|---|
60
- | `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
61
- | `s3rds.pymysql` | MySQL, translated on the client | pymysql | available |
62
- | `s3rds.psycopg2` | PostgreSQL, translated on the client | psycopg2 (`extras`, `errors`, `extensions`) | available |
63
- | `sqlite+s3rds://` | SQLite, written by SQLAlchemy | SQLAlchemy Core / ORM | available — the URL is the only change |
64
- | `ENGINE: "s3rds.sqlite"` | SQLite, written by Django | Django's SQLite backend | available — the database settings are the only change |
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 – 3.13: regression suite on 3.9.6, full comparison on 3.13.7 | `requires-python = ">=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 | dependency `sqlglot>=30.24,<31` |
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 | 6.1, tested on 6.1.2 (Python ≥ 3.12) | extra `pip install "s3rds[django]"` = `Django>=6.1,<6.2` |
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.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
- # one install for every driver. sqlglot: s3rds.pymysql's / s3rds.psycopg2's MySQL / PostgreSQL -> SQLite translation, imported only when a MySQL
26
- # statement is translated; pinned to the release the compatibility report was verified with (translations
27
- # change between releases). SQLAlchemy is not a dependency: the applications that use sqlite+s3rds:// have it.
28
- dependencies = ["sqlglot>=30.24,<31"]
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 = [] # kept so that pip install "s3rds[mysql]" from 0.1.x keeps working
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>=6.1,<6.2"] # the same for Django (ENGINE "s3rds.sqlite"); Django 6.1 needs Python >= 3.12
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.6", "3.13.7"] # the client: regression suite on 3.9.6, the full comparison on 3.13.7
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 = "6.1.2"
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: # the error surfaces on first query, with install instructions
30
- sqlglot = None
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 # sqlglot (the dialect translation) comes with it
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: # the error surfaces on first query, with install instructions
46
- sqlglot = None
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. No DB_USER configured = no auth (do not do that)."""
123
- if not DB_USER:
124
- return True
125
- auth = (event.get("headers") or {}).get("authorization", "")
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
- return _json(status, data)
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 authorizer(action, arg1, arg2, _dbname, _source): # signature fixed by sqlite3
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
- return sqlite3.SQLITE_OK
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
- raise
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
@@ -49,7 +49,7 @@ import socket
49
49
  import urllib.parse
50
50
  import warnings
51
51
 
52
- __version__ = "0.3.3"
52
+ __version__ = "0.3.4"
53
53
 
54
54
  apilevel = "2.0"
55
55
  threadsafety = 1
@@ -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.init_commands:
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
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,>=6.1; extra == "django"
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
- One install, one dependency (sqlglot, used only by `s3rds.pymysql` and `s3rds.psycopg2`). Each driver is a submodule **named after the standard
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 # s3rds.sqlite, s3rds.pymysql, s3rds.psycopg2, sqlite+s3rds:// for SQLAlchemy, ENGINE s3rds.sqlite for Django
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 | Status |
92
- |---|---|---|---|
93
- | `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
94
- | `s3rds.pymysql` | MySQL, translated on the client | pymysql | available |
95
- | `s3rds.psycopg2` | PostgreSQL, translated on the client | psycopg2 (`extras`, `errors`, `extensions`) | available |
96
- | `sqlite+s3rds://` | SQLite, written by SQLAlchemy | SQLAlchemy Core / ORM | available — the URL is the only change |
97
- | `ENGINE: "s3rds.sqlite"` | SQLite, written by Django | Django's SQLite backend | available — the database settings are the only change |
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 – 3.13: regression suite on 3.9.6, full comparison on 3.13.7 | `requires-python = ">=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 | dependency `sqlglot>=30.24,<31` |
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 | 6.1, tested on 6.1.2 (Python ≥ 3.12) | extra `pip install "s3rds[django]"` = `Django>=6.1,<6.2` |
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
 
@@ -1,9 +1,12 @@
1
- sqlglot<31,>=30.24
2
1
 
3
2
  [django]
4
- Django<6.2,>=6.1
3
+ Django<6.2,>=4.2
5
4
 
6
5
  [mysql]
6
+ sqlglot<31,>=30.24
7
+
8
+ [postgres]
9
+ sqlglot<31,>=30.24
7
10
 
8
11
  [server]
9
12
  sqlean.py==3.50.4.5
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes