s3rds 0.1.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.
s3rds-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: s3rds
3
+ Version: 0.1.0
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.
5
+ Author: Frank Zhang
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/cloudformer/s3rds
8
+ Keywords: serverless,rds,sqlite,s3,lambda,database,pymysql,dbapi
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Database
14
+ Classifier: Topic :: Database :: Front-Ends
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Provides-Extra: mysql
18
+ Requires-Dist: sqlglot>=25.0; extra == "mysql"
19
+
20
+ # s3rds
21
+
22
+ **Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
23
+ a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
24
+
25
+ ![s3rds architecture](https://raw.githubusercontent.com/cloudformer/s3rds/main/docs/architecture.svg)
26
+
27
+ Zero dependencies (standard library only). Each driver is a submodule **named after the standard
28
+ client it mirrors**, so moving to a real database is a one-line import change and nothing else:
29
+
30
+ ```bash
31
+ pip install s3rds # s3rds.sqlite: SQLite dialect
32
+ pip install "s3rds[mysql]" # + s3rds.pymysql: write MySQL, translated on the client (sqlglot)
33
+ ```
34
+
35
+ ```python
36
+ from s3rds import pymysql # today -- later: `import pymysql` on a real RDS, rest unchanged
37
+
38
+ conn = pymysql.connect(
39
+ host="xxxx.lambda-url.us-east-2.on.aws",
40
+ user="admin", password="...", database="blog")
41
+
42
+ with conn.cursor() as cur:
43
+ cur.execute("CREATE TABLE posts (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200))")
44
+ cur.execute("INSERT INTO posts (title) VALUES (%s)", ["hello"])
45
+ print(cur.lastrowid)
46
+
47
+ with conn.cursor(pymysql.cursors.DictCursor) as cur:
48
+ cur.execute("SELECT * FROM posts WHERE id = %s", [1])
49
+ print(cur.fetchall()) # [{'id': 1, 'title': 'hello'}]
50
+
51
+ conn.transaction([ # all-or-nothing, single S3 write
52
+ ("UPDATE stock SET qty = qty - 1 WHERE id = %s", [7]),
53
+ ("INSERT INTO orders (item) VALUES (%s)", [7]),
54
+ ])
55
+ ```
56
+
57
+ | Submodule | SQL dialect | Mirrors | Status |
58
+ |---|---|---|---|
59
+ | `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
60
+ | `s3rds.pymysql` | MySQL, translated on the client | pymysql | available — `pip install "s3rds[mysql]"` |
61
+ | `s3rds.psycopg2` | PostgreSQL | psycopg2 | placeholder; `connect()` raises until implemented |
62
+
63
+ ## Semantics
64
+
65
+ - Each `execute()` is its own transaction and is durable in S3 when it returns (`autocommit=True`).
66
+ Use `transaction()` for multi-statement atomicity.
67
+ - Writes **never** duplicate: the driver re-sends only when the request provably never reached the
68
+ server (stale keep-alive connection). Timeouts raise an error with an explicit "result unknown".
69
+ - `s3rds.pymysql` raises pymysql-style exceptions with MySQL error codes (1062, 1146, 1054, 1064, …);
70
+ unsupported MySQL features raise `NotSupportedError` instead of misbehaving silently.
71
+
72
+ ## Server
73
+
74
+ You need an s3rds server in your own AWS account — deploy it once with Terraform or CloudFormation
75
+ from the [main repository](https://github.com/cloudformer/s3rds). Same as any driver needs its database.
s3rds-0.1.0/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # s3rds
2
+
3
+ **Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
4
+ a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
5
+
6
+ ![s3rds architecture](https://raw.githubusercontent.com/cloudformer/s3rds/main/docs/architecture.svg)
7
+
8
+ Zero dependencies (standard library only). Each driver is a submodule **named after the standard
9
+ client it mirrors**, so moving to a real database is a one-line import change and nothing else:
10
+
11
+ ```bash
12
+ pip install s3rds # s3rds.sqlite: SQLite dialect
13
+ pip install "s3rds[mysql]" # + s3rds.pymysql: write MySQL, translated on the client (sqlglot)
14
+ ```
15
+
16
+ ```python
17
+ from s3rds import pymysql # today -- later: `import pymysql` on a real RDS, rest unchanged
18
+
19
+ conn = pymysql.connect(
20
+ host="xxxx.lambda-url.us-east-2.on.aws",
21
+ user="admin", password="...", database="blog")
22
+
23
+ with conn.cursor() as cur:
24
+ cur.execute("CREATE TABLE posts (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200))")
25
+ cur.execute("INSERT INTO posts (title) VALUES (%s)", ["hello"])
26
+ print(cur.lastrowid)
27
+
28
+ with conn.cursor(pymysql.cursors.DictCursor) as cur:
29
+ cur.execute("SELECT * FROM posts WHERE id = %s", [1])
30
+ print(cur.fetchall()) # [{'id': 1, 'title': 'hello'}]
31
+
32
+ conn.transaction([ # all-or-nothing, single S3 write
33
+ ("UPDATE stock SET qty = qty - 1 WHERE id = %s", [7]),
34
+ ("INSERT INTO orders (item) VALUES (%s)", [7]),
35
+ ])
36
+ ```
37
+
38
+ | Submodule | SQL dialect | Mirrors | Status |
39
+ |---|---|---|---|
40
+ | `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
41
+ | `s3rds.pymysql` | MySQL, translated on the client | pymysql | available — `pip install "s3rds[mysql]"` |
42
+ | `s3rds.psycopg2` | PostgreSQL | psycopg2 | placeholder; `connect()` raises until implemented |
43
+
44
+ ## Semantics
45
+
46
+ - Each `execute()` is its own transaction and is durable in S3 when it returns (`autocommit=True`).
47
+ Use `transaction()` for multi-statement atomicity.
48
+ - Writes **never** duplicate: the driver re-sends only when the request provably never reached the
49
+ server (stale keep-alive connection). Timeouts raise an error with an explicit "result unknown".
50
+ - `s3rds.pymysql` raises pymysql-style exceptions with MySQL error codes (1062, 1146, 1054, 1064, …);
51
+ unsupported MySQL features raise `NotSupportedError` instead of misbehaving silently.
52
+
53
+ ## Server
54
+
55
+ You need an s3rds server in your own AWS account — deploy it once with Terraform or CloudFormation
56
+ from the [main repository](https://github.com/cloudformer/s3rds). Same as any driver needs its database.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "s3rds"
7
+ version = "0.1.0"
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."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Frank Zhang" }]
13
+ keywords = ["serverless", "rds", "sqlite", "s3", "lambda", "database", "pymysql", "dbapi"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Database",
20
+ "Topic :: Database :: Front-Ends",
21
+ ]
22
+ dependencies = [] # standard library only
23
+
24
+ [project.optional-dependencies]
25
+ mysql = ["sqlglot>=25.0"] # s3rds.pymysql: MySQL -> SQLite dialect translation
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/cloudformer/s3rds"
29
+
30
+ [tool.setuptools]
31
+ packages = ["s3rds"]
@@ -0,0 +1,17 @@
1
+ """s3rds — a serverless RDS on your own AWS account (Lambda URL endpoint, SQLite on S3).
2
+
3
+ Each driver is a submodule named after the standard client it mirrors, so the day you move to a
4
+ real database you change one import line and nothing else:
5
+
6
+ from s3rds import pymysql # today: MySQL dialect on the s3rds endpoint
7
+ import pymysql # later: a real RDS MySQL -- pymysql.connect(...), pymysql.cursors, errors all unchanged
8
+
9
+ from s3rds import sqlite # SQLite dialect, no translation, zero dependencies
10
+ from s3rds import psycopg2 # PostgreSQL dialect: placeholder, connect() raises until implemented
11
+ """
12
+ from . import sqlite # noqa: F401
13
+ from . import pymysql # noqa: F401 (sqlglot is only needed when a MySQL statement is actually translated)
14
+ from . import psycopg2 # noqa: F401 (placeholder: connect() raises NotSupportedError until implemented)
15
+
16
+ __all__ = ["sqlite", "pymysql", "psycopg2"]
17
+ __version__ = sqlite.__version__
@@ -0,0 +1,22 @@
1
+ """s3rds.psycopg2 — the PostgreSQL-dialect driver for s3rds. Planned; not implemented yet.
2
+
3
+ It will mirror psycopg2 (connect(dsn or host=/user=/password=/dbname=), cursors, %s placeholders)
4
+ and translate PostgreSQL SQL to SQLite on the client, the same way s3rds.pymysql does for MySQL.
5
+ """
6
+ apilevel = "2.0"
7
+ threadsafety = 1
8
+ paramstyle = "pyformat"
9
+
10
+
11
+ class Error(Exception):
12
+ pass
13
+
14
+
15
+ class NotSupportedError(Error):
16
+ pass
17
+
18
+
19
+ def connect(*args, **kwargs):
20
+ raise NotSupportedError(
21
+ "s3rds.psycopg2 is not implemented yet. Use `from s3rds import pymysql` (MySQL dialect) "
22
+ "or `from s3rds import sqlite` (SQLite dialect) for now.")
@@ -0,0 +1,387 @@
1
+ """s3rds.pymysql — the MySQL-dialect driver for s3rds. Write MySQL; it runs on the s3rds endpoint (SQLite engine).
2
+
3
+ pip install "s3rds[mysql]" # pulls in sqlglot for the dialect translation
4
+
5
+ from s3rds import pymysql # later: `import pymysql` on a real RDS, nothing else changes
6
+ conn = pymysql.connect(host="xxx.lambda-url.us-east-2.on.aws",
7
+ user="admin", password="...", database="blog")
8
+ with conn.cursor() as cur:
9
+ cur.execute("CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL)")
10
+ cur.execute("INSERT INTO users (name) VALUES (%s)", ["alice"])
11
+ cur.execute("INSERT INTO users (name) VALUES (%(n)s)", {"n": "bob"})
12
+ cur.execute("SELECT * FROM users WHERE name = %s LIMIT 0, 10", ["ALICE"]) # case-insensitive, like MySQL
13
+
14
+ What is translated (client side, cached per statement):
15
+ - placeholders %s and %(name)s, %% -> % - backticks, LIMIT offset,count
16
+ - AUTO_INCREMENT -> INTEGER PRIMARY KEY AUTOINCREMENT - ENUM(...) -> TEXT CHECK (col IN (...))
17
+ - text columns get COLLATE NOCASE (MySQL's default case-insensitive comparison)
18
+ - INSERT IGNORE, ON DUPLICATE KEY UPDATE (VALUES(col) -> excluded.col)
19
+ - table options (ENGINE=, CHARSET=), ON UPDATE CURRENT_TIMESTAMP and inline KEY/INDEX are dropped
20
+ - functions sqlglot knows (NOW, IFNULL, CONCAT_WS, DATE_FORMAT, ...), LAST_INSERT_ID()
21
+ - SHOW TABLES / DATABASES / COLUMNS / CREATE TABLE / INDEX, DESCRIBE, SELECT VERSION(), SELECT DATABASE()
22
+ - errors come back as pymysql-style classes with MySQL codes: 1062, 1146, 1054, 1064, 1048, 1452, ...
23
+
24
+ Not supported, raised as NotSupportedError (never silently wrong): stored procedures/functions,
25
+ triggers, FULLTEXT, SELECT ... FOR UPDATE, LOCK TABLES, GRANT/REVOKE/users.
26
+ Values are returned as stored (no DATETIME -> datetime.datetime conversion in this version).
27
+ """
28
+ import functools
29
+ import re
30
+
31
+ from . import sqlite as _sqlite
32
+ from .sqlite import Cursor, DictCursor, cursors # noqa: F401 (same DB-API cursors)
33
+
34
+ try:
35
+ import sqlglot
36
+ from sqlglot import exp
37
+ from sqlglot.errors import ParseError
38
+ except ImportError: # the error surfaces on first query, with install instructions
39
+ sqlglot = None
40
+
41
+ __version__ = _sqlite.__version__
42
+ apilevel = "2.0"
43
+ threadsafety = 1
44
+ paramstyle = "pyformat"
45
+
46
+
47
+ # ---------------- exceptions (pymysql hierarchy; args[0] = MySQL error code) ----------------
48
+
49
+ class Error(Exception):
50
+ def __init__(self, code, message):
51
+ super().__init__(code, message)
52
+ self.code = code
53
+ self.message = message
54
+
55
+ def __str__(self):
56
+ return f"({self.code}, {self.message!r})"
57
+
58
+
59
+ class InterfaceError(Error): pass
60
+ class DatabaseError(Error): pass
61
+ class DataError(DatabaseError): pass
62
+ class OperationalError(DatabaseError): pass
63
+ class IntegrityError(DatabaseError): pass
64
+ class InternalError(DatabaseError): pass
65
+ class ProgrammingError(DatabaseError): pass
66
+ class NotSupportedError(DatabaseError): pass
67
+
68
+
69
+ _SQLITE_TO_MYSQL = [
70
+ (r"UNIQUE constraint failed", IntegrityError, 1062, "Duplicate entry"),
71
+ (r"NOT NULL constraint failed", IntegrityError, 1048, "Column cannot be null"),
72
+ (r"FOREIGN KEY constraint failed", IntegrityError, 1452, "Cannot add or update a child row: a foreign key constraint fails"),
73
+ (r"CHECK constraint failed", IntegrityError, 3819, "Check constraint is violated"),
74
+ (r"no such table", ProgrammingError, 1146, "Table doesn't exist"),
75
+ (r"no such column", ProgrammingError, 1054, "Unknown column"),
76
+ (r"already exists", OperationalError, 1050, "Table already exists"),
77
+ (r"no such function", ProgrammingError, 1305, "FUNCTION does not exist"),
78
+ (r"syntax error|incomplete input", ProgrammingError, 1064, "You have an error in your SQL syntax"),
79
+ (r"readonly|read-only", OperationalError, 1290, "The server is running in read-only mode"),
80
+ (r"cannot store .* value in .* column", DataError, 1366, "Incorrect value for column"),
81
+ ]
82
+
83
+
84
+ def _map_error(e):
85
+ """s3rds.sqlite.Error (HTTP status + server message) -> pymysql-style exception with a MySQL code."""
86
+ msg = e.message or str(e)
87
+ if e.status == 0:
88
+ return OperationalError(2013, f"Lost connection to MySQL server: {msg}")
89
+ if e.status == 401:
90
+ return OperationalError(1045, "Access denied for user (bad credentials)")
91
+ if e.status == 404 and "database" in msg:
92
+ return OperationalError(1049, f"Unknown database ({msg})")
93
+ if e.status == 409:
94
+ return OperationalError(1213, f"Write conflict; try restarting transaction ({msg})")
95
+ if e.status == 403:
96
+ return OperationalError(1290, msg)
97
+ for pattern, cls, code, text in _SQLITE_TO_MYSQL:
98
+ if re.search(pattern, msg, re.I):
99
+ return cls(code, f"{text} ({msg})")
100
+ return OperationalError(1105, msg)
101
+
102
+
103
+ # ---------------- placeholders: %s / %(name)s -> ?, dict params -> list ----------------
104
+
105
+ def _bind(sql, params):
106
+ """Return (sql with ? placeholders, list of params). Quoted text is left alone."""
107
+ names, out, i, n, quote = [], [], 0, len(sql), None
108
+ while i < n:
109
+ ch = sql[i]
110
+ if quote:
111
+ out.append(ch)
112
+ if ch == quote:
113
+ quote = None
114
+ elif ch in ("'", '"', "`"):
115
+ quote = ch
116
+ out.append(ch)
117
+ elif ch == "%" and i + 1 < n:
118
+ nxt = sql[i + 1]
119
+ if nxt == "s":
120
+ out.append("?"); names.append(None); i += 1
121
+ elif nxt == "%":
122
+ out.append("%"); i += 1
123
+ elif nxt == "(":
124
+ end = sql.find(")s", i)
125
+ if end == -1:
126
+ raise ProgrammingError(1064, f"bad placeholder near {sql[i:i+20]!r}")
127
+ out.append("?"); names.append(sql[i + 2:end]); i = end + 1
128
+ else:
129
+ out.append(ch)
130
+ else:
131
+ out.append(ch)
132
+ i += 1
133
+ if isinstance(params, dict):
134
+ if any(k is None for k in names):
135
+ raise ProgrammingError(1064, "dict params need %(name)s placeholders")
136
+ return "".join(out), [params[k] for k in names]
137
+ return "".join(out), list(params or [])
138
+
139
+
140
+ # ---------------- unsupported features: fail loudly ----------------
141
+
142
+ _UNSUPPORTED = [
143
+ (r"^\s*CREATE\s+(OR\s+REPLACE\s+)?(DEFINER\s*=\s*\S+\s+)?(PROCEDURE|FUNCTION|TRIGGER|EVENT)\b", "stored procedures / functions / triggers / events"),
144
+ (r"^\s*(CALL|LOCK\s+TABLES|UNLOCK\s+TABLES|GRANT|REVOKE|FLUSH)\b", "CALL / LOCK TABLES / GRANT / REVOKE / FLUSH"),
145
+ (r"^\s*(CREATE|ALTER|DROP|RENAME)\s+USER\b", "user management"),
146
+ (r"\bFOR\s+UPDATE\s*;?\s*$|\bLOCK\s+IN\s+SHARE\s+MODE\s*;?\s*$", "SELECT ... FOR UPDATE / LOCK IN SHARE MODE"),
147
+ (r"\bFULLTEXT\b", "FULLTEXT indexes"),
148
+ ]
149
+
150
+
151
+ def _check_supported(sql):
152
+ for pattern, what in _UNSUPPORTED:
153
+ if re.search(pattern, sql, re.I | re.S):
154
+ raise NotSupportedError(1235, f"{what} need a real MySQL - migrate to RDS when you do")
155
+
156
+
157
+ # ---------------- metadata commands: answered from sqlite_master / PRAGMA, shaped like MySQL ----------------
158
+
159
+ _META = re.compile(
160
+ r"^\s*(?:"
161
+ r"(?P<tables>SHOW\s+(?:FULL\s+)?TABLES)"
162
+ r"|(?P<dbs>SHOW\s+DATABASES)"
163
+ r"|(?:SHOW\s+(?:FULL\s+)?COLUMNS\s+FROM|DESCRIBE|DESC)\s+`?(?P<cols>\w+)`?"
164
+ r"|SHOW\s+CREATE\s+TABLE\s+`?(?P<create>\w+)`?"
165
+ r"|SHOW\s+INDEX(?:ES)?\s+FROM\s+`?(?P<index>\w+)`?"
166
+ r"|(?P<version>SELECT\s+VERSION\s*\(\s*\))"
167
+ r"|(?P<database>SELECT\s+DATABASE\s*\(\s*\))"
168
+ r")\s*;?\s*$", re.I)
169
+
170
+
171
+ def _result(columns, rows):
172
+ return {"columns": columns, "rows": rows, "rowcount": -1, "lastrowid": None}
173
+
174
+
175
+ # ---------------- dialect translation (sqlglot) + fixes sqlglot does not do ----------------
176
+
177
+ # sqlglot may leave "ON DUPLICATE KEY UPDATE" or render "ON DUPLICATE KEY DO UPDATE SET"
178
+ _ON_DUP = re.compile(r"\bON\s+DUPLICATE\s+KEY(?:\s+DO)?\s+UPDATE(?:\s+SET)?\b", re.I)
179
+ _UPSERT_SPLIT = re.compile(r"\bON\s+CONFLICT\s+DO\s+UPDATE\s+SET\b", re.I)
180
+ _VALUES_REF = re.compile(r"\bVALUES\s*\(\s*(\"?[A-Za-z_]\w*\"?)\s*\)", re.I) # VALUES(col), never VALUES(1)
181
+ # LAST_INSERT_ID(): SQLite's last_insert_rowid() is per connection and the server opens one per
182
+ # request, so the driver substitutes the id it saw on its own last INSERT (MySQL's per-session semantics)
183
+ _LAST_ID_TOKEN = "__S3RDS_LAST_INSERT_ID__"
184
+
185
+ # MySQL functions sqlglot does not map for SQLite. ONE table, one line per function:
186
+ # MySQL name -> SQLite SQL template; {0} = first argument (defaults to 'now' when there is none).
187
+ # A function missing here comes back as error 1305 "FUNCTION does not exist" -- add a line, nothing else.
188
+ _FUNCS = {
189
+ "NOW": "CURRENT_TIMESTAMP",
190
+ "CURDATE": "CURRENT_DATE",
191
+ "CURTIME": "CURRENT_TIME",
192
+ "UNIX_TIMESTAMP": "CAST(strftime('%s', {0}) AS INTEGER)",
193
+ "FROM_UNIXTIME": "datetime({0}, 'unixepoch')",
194
+ "UNIX_TO_TIME": "datetime({0}, 'unixepoch')", # sqlglot's own node name for FROM_UNIXTIME
195
+ "LAST_INSERT_ID": _LAST_ID_TOKEN,
196
+ }
197
+
198
+
199
+ def _rewrite_funcs(node):
200
+ if not isinstance(node, exp.Func):
201
+ return node
202
+ # unknown functions are exp.Anonymous whose real name is node.name; known ones answer to sql_name()
203
+ name = (node.name if isinstance(node, exp.Anonymous) else node.sql_name()).upper()
204
+ template = _FUNCS.get(name)
205
+ if template is None:
206
+ return node
207
+ args = node.expressions if isinstance(node, exp.Anonymous) else \
208
+ [v for v in node.args.values() if isinstance(v, exp.Expression)]
209
+ first = args[0].sql(dialect="sqlite") if args else "'now'"
210
+ return exp.maybe_parse(template.format(first), dialect="sqlite")
211
+
212
+
213
+ def _fix_create_table(tree):
214
+ """MySQL CREATE TABLE -> something SQLite accepts with MySQL-like semantics."""
215
+ if not (isinstance(tree, exp.Create) and (tree.args.get("kind") or "").upper() == "TABLE"):
216
+ return
217
+ schema = tree.this
218
+ if not isinstance(schema, exp.Schema):
219
+ return
220
+ tree.set("properties", None) # ENGINE=, CHARSET=, COMMENT=, ...
221
+
222
+ kept, table_pk_cols = [], set()
223
+ for item in schema.expressions:
224
+ if isinstance(item, exp.PrimaryKey):
225
+ cols = [c.name for c in item.expressions]
226
+ if len(cols) == 1: # single-column PK: moved into the column (needed for AUTOINCREMENT)
227
+ table_pk_cols.add(cols[0])
228
+ continue
229
+ if isinstance(item, (exp.ColumnDef, exp.PrimaryKey, exp.ForeignKey)) or "Unique" in type(item).__name__:
230
+ kept.append(item) # inline KEY/INDEX definitions are dropped (CREATE INDEX separately)
231
+
232
+ for col in kept:
233
+ if not isinstance(col, exp.ColumnDef):
234
+ continue
235
+ kind = col.args.get("kind")
236
+ constraints = [c for c in (col.args.get("constraints") or [])
237
+ if not isinstance(c.kind, exp.OnUpdateColumnConstraint)]
238
+ kinds = [type(c.kind) for c in constraints]
239
+ is_pk = exp.PrimaryKeyColumnConstraint in kinds or col.name in table_pk_cols
240
+ is_auto = exp.AutoIncrementColumnConstraint in kinds
241
+
242
+ if kind is not None and kind.this == exp.DataType.Type.ENUM:
243
+ values = [v.this for v in kind.expressions]
244
+ col.set("kind", exp.DataType.build("TEXT"))
245
+ constraints.append(exp.ColumnConstraint(kind=exp.CheckColumnConstraint(
246
+ this=exp.In(this=exp.column(col.name), expressions=[exp.Literal.string(v) for v in values]))))
247
+ kind = col.args["kind"]
248
+
249
+ if is_auto:
250
+ col.set("kind", exp.DataType.build("INTEGER"))
251
+ is_pk = True
252
+ if kind is not None and kind.this in exp.DataType.TEXT_TYPES \
253
+ and exp.CollateColumnConstraint not in kinds:
254
+ constraints.append(exp.ColumnConstraint(kind=exp.CollateColumnConstraint(this=exp.var("NOCASE"))))
255
+
256
+ others = [c for c in constraints
257
+ if not isinstance(c.kind, (exp.PrimaryKeyColumnConstraint, exp.AutoIncrementColumnConstraint))]
258
+ tail = []
259
+ if is_pk:
260
+ tail.append(exp.ColumnConstraint(kind=exp.PrimaryKeyColumnConstraint()))
261
+ if is_auto:
262
+ tail.append(exp.ColumnConstraint(kind=exp.AutoIncrementColumnConstraint())) # must follow PRIMARY KEY
263
+ col.set("constraints", others + tail)
264
+
265
+ schema.set("expressions", kept)
266
+
267
+
268
+ @functools.lru_cache(maxsize=1024)
269
+ def _translate(sql):
270
+ if sqlglot is None:
271
+ raise NotSupportedError(0, "the MySQL dialect needs sqlglot: pip install 's3rds[mysql]'")
272
+ try:
273
+ tree = sqlglot.parse_one(sql, read="mysql")
274
+ except ParseError as e:
275
+ raise ProgrammingError(1064, f"You have an error in your SQL syntax ({e})") from None
276
+ _fix_create_table(tree)
277
+ tree = tree.transform(_rewrite_funcs)
278
+ out = tree.sql(dialect="sqlite")
279
+ out = _ON_DUP.sub("ON CONFLICT DO UPDATE SET", out) # upsert
280
+ parts = _UPSERT_SPLIT.split(out, maxsplit=1)
281
+ if len(parts) == 2: # VALUES(col) -> excluded.col, only inside the DO UPDATE SET clause
282
+ out = parts[0] + "ON CONFLICT DO UPDATE SET" + _VALUES_REF.sub(r"excluded.\1", parts[1])
283
+ return out
284
+
285
+
286
+ # ---------------- connection ----------------
287
+
288
+ class Connection(_sqlite.Connection):
289
+ _last_insert_id = 0 # MySQL's per-session LAST_INSERT_ID()
290
+
291
+ def _prepare(self, sql):
292
+ sql = sql.strip().rstrip(";")
293
+ _check_supported(sql)
294
+ return sql
295
+
296
+ def _finalize(self, translated):
297
+ return translated.replace(_LAST_ID_TOKEN, str(self._last_insert_id))
298
+
299
+ def _remember(self, result):
300
+ if result.get("lastrowid"):
301
+ self._last_insert_id = result["lastrowid"]
302
+ return result
303
+
304
+ def execute(self, sql, params=()):
305
+ sql = self._prepare(sql)
306
+ m = _META.match(sql)
307
+ if m:
308
+ return self._meta(m)
309
+ qmark, args = _bind(sql, params)
310
+ try:
311
+ return self._remember(super().execute(self._finalize(_translate(qmark)), args))
312
+ except _sqlite.Error as e:
313
+ raise _map_error(e) from None
314
+
315
+ def transaction(self, statements):
316
+ prepared = []
317
+ for s in statements:
318
+ sql, params = (s, []) if isinstance(s, str) else (s[0], s[1] if len(s) > 1 else [])
319
+ qmark, args = _bind(self._prepare(sql), params)
320
+ prepared.append((self._finalize(_translate(qmark)), args))
321
+ try:
322
+ results = super().transaction(prepared)
323
+ except _sqlite.Error as e:
324
+ raise _map_error(e) from None
325
+ for r in results:
326
+ self._remember(r)
327
+ return results
328
+
329
+ def create_database(self):
330
+ try:
331
+ super().create_database()
332
+ except _sqlite.Error as e:
333
+ raise _map_error(e) from None
334
+
335
+ def _raw(self, sql, params=()):
336
+ """SQLite SQL straight to the endpoint (for metadata queries)."""
337
+ try:
338
+ return _sqlite.Connection.execute(self, sql, params)
339
+ except _sqlite.Error as e:
340
+ raise _map_error(e) from None
341
+
342
+ def _meta(self, m):
343
+ if m.group("tables"):
344
+ r = self._raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
345
+ return _result([f"Tables_in_{self.database}"], r["rows"])
346
+ if m.group("dbs"):
347
+ try:
348
+ names = self.list_databases()
349
+ except _sqlite.Error as e:
350
+ raise _map_error(e) from None
351
+ return _result(["Database"], [[n] for n in names])
352
+ if m.group("cols"):
353
+ table = m.group("cols")
354
+ info = self._raw(f'PRAGMA table_info("{table}")')["rows"]
355
+ if not info:
356
+ raise ProgrammingError(1146, f"Table '{table}' doesn't exist")
357
+ rows = []
358
+ for _cid, name, ctype, notnull, default, pk in info:
359
+ auto = "auto_increment" if pk and (ctype or "").upper() == "INTEGER" else ""
360
+ rows.append([name, (ctype or "").lower(), "NO" if notnull else "YES",
361
+ "PRI" if pk else "", default, auto])
362
+ return _result(["Field", "Type", "Null", "Key", "Default", "Extra"], rows)
363
+ if m.group("create"):
364
+ table = m.group("create")
365
+ r = self._raw("SELECT sql FROM sqlite_master WHERE type='table' AND name = ?", [table])
366
+ if not r["rows"]:
367
+ raise ProgrammingError(1146, f"Table '{table}' doesn't exist")
368
+ return _result(["Table", "Create Table"], [[table, r["rows"][0][0]]])
369
+ if m.group("index"):
370
+ table = m.group("index")
371
+ rows = []
372
+ for _seq, name, unique, _origin, _partial in self._raw(f'PRAGMA index_list("{table}")')["rows"]:
373
+ for seqno, _cid, colname in self._raw(f'PRAGMA index_info("{name}")')["rows"]:
374
+ rows.append([table, 0 if unique else 1, name, seqno + 1, colname])
375
+ return _result(["Table", "Non_unique", "Key_name", "Seq_in_index", "Column_name"], rows)
376
+ if m.group("version"):
377
+ return _result(["VERSION()"], [[f"8.0.0-s3rds-{__version__}"]])
378
+ if m.group("database"):
379
+ return _result(["DATABASE()"], [[self.database]])
380
+ raise InternalError(0, "unreachable")
381
+
382
+
383
+ def connect(host, user="", password="", database="", timeout=30,
384
+ cursorclass=Cursor, **_ignored):
385
+ """Same signature as pymysql.connect; extra pymysql arguments are accepted and ignored."""
386
+ url = host if host.startswith("http") else "https://" + host
387
+ return Connection(url, database, user, password, timeout, cursorclass)
@@ -0,0 +1,325 @@
1
+ """s3rds.sqlite — the native (SQLite-dialect) driver for s3rds: Lambda URL endpoint, SQLite on S3.
2
+
3
+ Zero dependencies (standard library only). The API is shaped like pymysql
4
+ (connect(host=, user=, password=, database=), cursors, %s placeholders); the SQL dialect is SQLite.
5
+
6
+ from s3rds import sqlite
7
+ conn = sqlite.connect(host="xxx.lambda-url.us-east-2.on.aws",
8
+ user="admin", password="...", database="blog")
9
+
10
+ with conn.cursor() as cur: # DB-API style
11
+ cur.execute("INSERT INTO items (text) VALUES (%s)", ["hi"])
12
+ new_id = cur.lastrowid
13
+ with conn.cursor(sqlite.cursors.DictCursor) as cur:
14
+ cur.execute("SELECT * FROM items WHERE id = %s", [new_id])
15
+ rows = cur.fetchall() # [{"id": 1, "text": "hi"}]
16
+
17
+ conn.execute("INSERT INTO items (text) VALUES (?)", ["hi"]) # convenience; ? and %s both accepted
18
+ rows = conn.query("SELECT * FROM items") # [dict, ...]
19
+ conn.transaction([("UPDATE ...", [..]), ("INSERT ...", [..])]) # one transaction, one S3 write
20
+ conn.last # {"ms": server time, "s3": "cache-hit"...}
21
+
22
+ Transactions: every execute() is its own transaction and is durable in S3 when it returns
23
+ (= pymysql autocommit=True). Use transaction() for multi-statement atomicity.
24
+ commit()/rollback() exist for compatibility and are no-ops.
25
+
26
+ Retry policy -- fail rather than duplicate a write:
27
+ the request is re-sent exactly once, and only when it provably never reached the server
28
+ (an idle keep-alive connection closed by the peer). Timeouts and anything after the request
29
+ was sent raise Error with "result unknown"; the caller decides.
30
+ (A connection is not thread-safe; use one per thread.)
31
+ """
32
+ import base64
33
+ import http.client
34
+ import json
35
+ import socket
36
+ import urllib.parse
37
+
38
+ __version__ = "0.1.0"
39
+
40
+ apilevel = "2.0"
41
+ threadsafety = 1
42
+ paramstyle = "format" # %s like pymysql; ? is accepted too
43
+
44
+
45
+ # ---------------- exceptions ----------------
46
+
47
+ class Error(Exception):
48
+ def __init__(self, status, message):
49
+ super().__init__(f"HTTP {status}: {message}" if status else message)
50
+ self.status = status # HTTP status; 0 = network layer
51
+ self.message = message
52
+
53
+
54
+ class _NotDelivered(Exception):
55
+ """The request provably never reached the server: sending failed, or no status line came back.
56
+
57
+ Only this case may be retried; any failure while reading the response is not --
58
+ the server may already have executed the statement.
59
+ """
60
+ def __init__(self, cause, reused):
61
+ super().__init__(str(cause))
62
+ self.cause = cause
63
+ self.reused = reused # happened on a reused keep-alive connection (idle connection closed by peer)
64
+
65
+
66
+ # ---------------- placeholders: %s (pymysql style) -> ? (SQLite) ----------------
67
+
68
+ def _to_qmark(sql):
69
+ """Turn %s into ? and %% into % outside quotes; leave quoted text alone. ?-style SQL is returned as is."""
70
+ if "%" not in sql:
71
+ return sql
72
+ out, i, n, quote = [], 0, len(sql), None
73
+ while i < n:
74
+ ch = sql[i]
75
+ if quote:
76
+ out.append(ch)
77
+ if ch == quote:
78
+ quote = None
79
+ elif ch in ("'", '"', "`"):
80
+ quote = ch
81
+ out.append(ch)
82
+ elif ch == "%" and i + 1 < n and sql[i + 1] == "s":
83
+ out.append("?")
84
+ i += 1
85
+ elif ch == "%" and i + 1 < n and sql[i + 1] == "%":
86
+ out.append("%")
87
+ i += 1
88
+ else:
89
+ out.append(ch)
90
+ i += 1
91
+ return "".join(out)
92
+
93
+
94
+ # ---------------- cursors (DB-API 2.0 subset, enough for common pymysql usage) ----------------
95
+
96
+ class Cursor:
97
+ def __init__(self, connection):
98
+ self.connection = connection
99
+ self.description = None
100
+ self.rowcount = -1
101
+ self.lastrowid = None
102
+ self.arraysize = 1
103
+ self._rows = []
104
+ self._pos = 0
105
+
106
+ def _row(self, columns, row):
107
+ return tuple(row)
108
+
109
+ def execute(self, sql, params=None):
110
+ r = self.connection.execute(sql, params or [])
111
+ cols = r.get("columns") or []
112
+ self.description = [(c, None, None, None, None, None, None) for c in cols] or None
113
+ self.rowcount = r.get("rowcount", -1)
114
+ self.lastrowid = r.get("lastrowid")
115
+ self._rows = [self._row(cols, row) for row in r.get("rows") or []]
116
+ self._pos = 0
117
+ return self.rowcount
118
+
119
+ def executemany(self, sql, seq_of_params):
120
+ """Same statement, several parameter sets, one transaction (all or nothing)."""
121
+ self.connection.transaction([(sql, p) for p in seq_of_params])
122
+ self.rowcount = len(seq_of_params)
123
+ return self.rowcount
124
+
125
+ def fetchone(self):
126
+ if self._pos >= len(self._rows):
127
+ return None
128
+ row = self._rows[self._pos]
129
+ self._pos += 1
130
+ return row
131
+
132
+ def fetchmany(self, size=None):
133
+ size = size or self.arraysize
134
+ rows = self._rows[self._pos:self._pos + size]
135
+ self._pos += len(rows)
136
+ return rows
137
+
138
+ def fetchall(self):
139
+ rows = self._rows[self._pos:]
140
+ self._pos = len(self._rows)
141
+ return rows
142
+
143
+ def __iter__(self):
144
+ while True:
145
+ row = self.fetchone()
146
+ if row is None:
147
+ return
148
+ yield row
149
+
150
+ def close(self):
151
+ self._rows = []
152
+
153
+ def __enter__(self):
154
+ return self
155
+
156
+ def __exit__(self, *exc):
157
+ self.close()
158
+
159
+
160
+ class DictCursor(Cursor):
161
+ def _row(self, columns, row):
162
+ return dict(zip(columns, row))
163
+
164
+
165
+ class cursors: # so that sqlite.cursors.DictCursor reads like pymysql.cursors.DictCursor
166
+ Cursor = Cursor
167
+ DictCursor = DictCursor
168
+
169
+
170
+ # ---------------- connection ----------------
171
+
172
+ class Connection:
173
+ def __init__(self, url, database, user="", password="", timeout=30, cursorclass=Cursor):
174
+ p = urllib.parse.urlparse(url.rstrip("/"))
175
+ self._host = p.netloc
176
+ self._base_path = p.path
177
+ self.database = database
178
+ self.cursorclass = cursorclass
179
+ self._auth = base64.b64encode(f"{user}:{password}".encode()).decode()
180
+ self.timeout = timeout
181
+ self._conn = None # reused HTTPS keep-alive connection
182
+ # server-side metrics of the last call: {"ms": ..., "s3": "cache-hit" | "download" | "...+upload"}
183
+ self.last = {}
184
+
185
+ # ---- transport ----
186
+
187
+ def _drop(self):
188
+ try:
189
+ if self._conn:
190
+ self._conn.close()
191
+ finally:
192
+ self._conn = None
193
+
194
+ def _send(self, path, body):
195
+ reused = self._conn is not None
196
+ if self._conn is None:
197
+ self._conn = http.client.HTTPSConnection(self._host, timeout=self.timeout)
198
+ headers = {"content-type": "application/json",
199
+ "authorization": f"Basic {self._auth}"}
200
+ # phase 1: send. A timeout is NOT "not delivered" (the body may have arrived in full)
201
+ try:
202
+ self._conn.request("POST", self._base_path + path, body, headers)
203
+ except (socket.timeout, TimeoutError):
204
+ raise
205
+ except (OSError, http.client.HTTPException) as e:
206
+ raise _NotDelivered(e, reused) from None
207
+ # phase 2: status line. Nothing received at all -> the server did not process it
208
+ try:
209
+ resp = self._conn.getresponse()
210
+ except (http.client.RemoteDisconnected, http.client.BadStatusLine) as e:
211
+ raise _NotDelivered(e, reused) from None
212
+ # phase 3: body. Any error here is never retried -- the server most likely executed it
213
+ raw = resp.read()
214
+ try:
215
+ data = json.loads(raw)
216
+ except ValueError:
217
+ data = {"error": raw[:200].decode(errors="replace")}
218
+ if resp.status >= 400:
219
+ raise Error(resp.status, data.get("error", ""))
220
+ return data
221
+
222
+ def _post(self, path, payload):
223
+ body = json.dumps(payload)
224
+ try:
225
+ return self._send(path, body)
226
+ except _NotDelivered as e:
227
+ self._drop()
228
+ if not e.reused: # a fresh connection failed: not a stale keep-alive, do not guess
229
+ raise Error(0, f"connection failed: {e.cause}") from None
230
+ try:
231
+ return self._send(path, body) # the only safe retry: provably not delivered
232
+ except _NotDelivered as e2:
233
+ self._drop()
234
+ raise Error(0, f"connection failed: {e2.cause}") from None
235
+ except (socket.timeout, TimeoutError):
236
+ self._drop()
237
+ raise Error(0, "timeout: result unknown (the statement may have run); do not blindly retry writes") from None
238
+ except (OSError, http.client.HTTPException) as e:
239
+ self._drop()
240
+ raise Error(0, f"network error (result unknown): {e}") from None
241
+
242
+ def _get(self, path):
243
+ """A GET on a short-lived connection (the keep-alive path is POST-only)."""
244
+ conn = http.client.HTTPSConnection(self._host, timeout=self.timeout)
245
+ try:
246
+ conn.request("GET", self._base_path + path, headers={"authorization": f"Basic {self._auth}"})
247
+ resp = conn.getresponse()
248
+ data = json.loads(resp.read() or b"{}")
249
+ if resp.status >= 400:
250
+ raise Error(resp.status, data.get("error", ""))
251
+ return data
252
+ finally:
253
+ conn.close()
254
+
255
+ # ---- DB-API ----
256
+
257
+ def cursor(self, cursorclass=None):
258
+ return (cursorclass or self.cursorclass)(self)
259
+
260
+ def commit(self):
261
+ """Every statement is already committed and durable; kept for pymysql compatibility."""
262
+
263
+ def rollback(self):
264
+ """Nothing pending to roll back; kept for compatibility. Use transaction() for multi-statement atomicity."""
265
+
266
+ def close(self):
267
+ self._drop()
268
+
269
+ def ping(self, reconnect=True):
270
+ self.execute("SELECT 1")
271
+
272
+ def __enter__(self):
273
+ return self
274
+
275
+ def __exit__(self, *exc):
276
+ self.close()
277
+
278
+ # ---- convenience ----
279
+
280
+ def create_database(self):
281
+ """Create the database; a 409 (already exists) is silently fine, so this is safe on cold start."""
282
+ try:
283
+ self._post("/api/databases", {"name": self.database})
284
+ except Error as e:
285
+ if e.status != 409:
286
+ raise
287
+
288
+ def list_databases(self):
289
+ return self._get("/api/databases").get("databases", [])
290
+
291
+ def execute(self, sql, params=()):
292
+ """Run one statement (? or %s placeholders). Returns {columns, rows, rowcount, lastrowid, truncated, ms, s3}."""
293
+ r = self._post(f"/api/db/{self.database}/query",
294
+ {"sql": _to_qmark(sql), "params": list(params)})
295
+ self.last = {"ms": r.get("ms"), "s3": r.get("s3")}
296
+ return r
297
+
298
+ def query(self, sql, params=()):
299
+ """SELECT helper returning [dict, ...]. Results over 500 rows are truncated (see execute()['truncated'])."""
300
+ r = self.execute(sql, params)
301
+ return [dict(zip(r["columns"], row)) for row in r["rows"]]
302
+
303
+ def transaction(self, statements):
304
+ """Run several statements in one transaction: all commit together or none do, and S3 is written once.
305
+
306
+ Each item is "sql" or (sql, params). Returns one result per statement.
307
+ """
308
+ stmts = [{"sql": _to_qmark(s), "params": []} if isinstance(s, str)
309
+ else {"sql": _to_qmark(s[0]), "params": list(s[1]) if len(s) > 1 else []}
310
+ for s in statements]
311
+ r = self._post(f"/api/db/{self.database}/tx", {"statements": stmts})
312
+ self.last = {"ms": r.get("ms"), "s3": r.get("s3")}
313
+ return r["results"]
314
+
315
+
316
+ # ---------------- module entry point, same signature as pymysql.connect ----------------
317
+
318
+ def connect(host, user="", password="", database="", timeout=30,
319
+ cursorclass=Cursor, **_ignored):
320
+ """Extra pymysql arguments (port, charset, autocommit, ...) are accepted and ignored.
321
+
322
+ host may be given with or without https:// (Lambda Function URLs are https only).
323
+ """
324
+ url = host if host.startswith("http") else "https://" + host
325
+ return Connection(url, database, user, password, timeout, cursorclass)
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: s3rds
3
+ Version: 0.1.0
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.
5
+ Author: Frank Zhang
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/cloudformer/s3rds
8
+ Keywords: serverless,rds,sqlite,s3,lambda,database,pymysql,dbapi
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Database
14
+ Classifier: Topic :: Database :: Front-Ends
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Provides-Extra: mysql
18
+ Requires-Dist: sqlglot>=25.0; extra == "mysql"
19
+
20
+ # s3rds
21
+
22
+ **Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
23
+ a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
24
+
25
+ ![s3rds architecture](https://raw.githubusercontent.com/cloudformer/s3rds/main/docs/architecture.svg)
26
+
27
+ Zero dependencies (standard library only). Each driver is a submodule **named after the standard
28
+ client it mirrors**, so moving to a real database is a one-line import change and nothing else:
29
+
30
+ ```bash
31
+ pip install s3rds # s3rds.sqlite: SQLite dialect
32
+ pip install "s3rds[mysql]" # + s3rds.pymysql: write MySQL, translated on the client (sqlglot)
33
+ ```
34
+
35
+ ```python
36
+ from s3rds import pymysql # today -- later: `import pymysql` on a real RDS, rest unchanged
37
+
38
+ conn = pymysql.connect(
39
+ host="xxxx.lambda-url.us-east-2.on.aws",
40
+ user="admin", password="...", database="blog")
41
+
42
+ with conn.cursor() as cur:
43
+ cur.execute("CREATE TABLE posts (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200))")
44
+ cur.execute("INSERT INTO posts (title) VALUES (%s)", ["hello"])
45
+ print(cur.lastrowid)
46
+
47
+ with conn.cursor(pymysql.cursors.DictCursor) as cur:
48
+ cur.execute("SELECT * FROM posts WHERE id = %s", [1])
49
+ print(cur.fetchall()) # [{'id': 1, 'title': 'hello'}]
50
+
51
+ conn.transaction([ # all-or-nothing, single S3 write
52
+ ("UPDATE stock SET qty = qty - 1 WHERE id = %s", [7]),
53
+ ("INSERT INTO orders (item) VALUES (%s)", [7]),
54
+ ])
55
+ ```
56
+
57
+ | Submodule | SQL dialect | Mirrors | Status |
58
+ |---|---|---|---|
59
+ | `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
60
+ | `s3rds.pymysql` | MySQL, translated on the client | pymysql | available — `pip install "s3rds[mysql]"` |
61
+ | `s3rds.psycopg2` | PostgreSQL | psycopg2 | placeholder; `connect()` raises until implemented |
62
+
63
+ ## Semantics
64
+
65
+ - Each `execute()` is its own transaction and is durable in S3 when it returns (`autocommit=True`).
66
+ Use `transaction()` for multi-statement atomicity.
67
+ - Writes **never** duplicate: the driver re-sends only when the request provably never reached the
68
+ server (stale keep-alive connection). Timeouts raise an error with an explicit "result unknown".
69
+ - `s3rds.pymysql` raises pymysql-style exceptions with MySQL error codes (1062, 1146, 1054, 1064, …);
70
+ unsupported MySQL features raise `NotSupportedError` instead of misbehaving silently.
71
+
72
+ ## Server
73
+
74
+ You need an s3rds server in your own AWS account — deploy it once with Terraform or CloudFormation
75
+ from the [main repository](https://github.com/cloudformer/s3rds). Same as any driver needs its database.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ s3rds/__init__.py
4
+ s3rds/psycopg2.py
5
+ s3rds/pymysql.py
6
+ s3rds/sqlite.py
7
+ s3rds.egg-info/PKG-INFO
8
+ s3rds.egg-info/SOURCES.txt
9
+ s3rds.egg-info/dependency_links.txt
10
+ s3rds.egg-info/requires.txt
11
+ s3rds.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+
2
+ [mysql]
3
+ sqlglot>=25.0
@@ -0,0 +1 @@
1
+ s3rds
s3rds-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+