SQLitey 0.1.0__tar.gz → 0.2.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.
@@ -0,0 +1,26 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ permissions:
4
+ contents: read
5
+ jobs:
6
+ build:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+ - name: Set up uv with Python ${{ matrix.python-version }}
14
+ uses: astral-sh/setup-uv@v7
15
+ with:
16
+ python-version: ${{ matrix.python-version }}
17
+ - name: Install dependencies
18
+ run: uv sync
19
+ - name: Lint with Ruff
20
+ run: uv run ruff check sqlitey tests
21
+ - name: Check formatting
22
+ run: uv run ruff format --check sqlitey tests
23
+ - name: Run Mypy
24
+ run: uv run mypy sqlitey
25
+ - name: Test suite
26
+ run: uv run pytest --cov=sqlitey tests
@@ -4,3 +4,6 @@
4
4
  build/
5
5
  dist/
6
6
  .mypy_cache/
7
+ __pycache__/
8
+ .pytest_cache/
9
+ .ruff_cache/
sqlitey-0.2.0/Makefile ADDED
@@ -0,0 +1,34 @@
1
+ SRC = sqlitey tests
2
+
3
+ .PHONY: help
4
+ help: ## Show this help menu
5
+ @echo "Usage: make [TARGET ...]"
6
+ @echo ""
7
+ @grep --no-filename -E '^[a-zA-Z_%-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
8
+ awk 'BEGIN {FS = ":.*?## "}; {printf "%-25s %s\n", $$1, $$2}'
9
+
10
+ .PHONY: deps
11
+ deps: ## Install requirements in virtual environment
12
+ uv sync
13
+
14
+ .PHONY: tests
15
+ tests: ## Run unit tests
16
+ uv run pytest --cov=sqlitey --cov-report term-missing tests
17
+
18
+ .PHONY: mypy
19
+ mypy: ## Run mypy
20
+ uv run mypy sqlitey
21
+
22
+ .PHONY: ruff
23
+ ruff: ## Run ruff linter
24
+ uv run ruff check $(SRC)
25
+
26
+ .PHONY: fmt
27
+ fmt: ## Format code with ruff
28
+ uv run ruff format $(SRC)
29
+
30
+ .PHONY: release
31
+ release: ## Release to PyPI
32
+ rm -rf dist
33
+ uv build
34
+ uv publish
sqlitey-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,355 @@
1
+ Metadata-Version: 2.5
2
+ Name: SQLitey
3
+ Version: 0.2.0
4
+ Summary: Useful wrapper around SQLite
5
+ Project-URL: Repository, https://github.com/smallwat3r/sqlitey
6
+ Project-URL: Issues, https://github.com/smallwat3r/sqlitey/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Database
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+
21
+ # SQLitey
22
+
23
+ A small, typed wrapper around Python's built-in `sqlite3`. It keeps you writing plain SQL, and takes care of the repetitive parts: connections, transactions, row shapes, SQL files and migrations.
24
+
25
+ - **No dependencies**, standard library only
26
+ - **Plain SQL**, inline or loaded from `.sql` files
27
+ - **Rows the way you want them**: tuples, dicts, namedtuples or your own dataclasses
28
+ - **Safe transactions**: commit on success, roll back on error, nestable atomic blocks
29
+ - **Migrations built in**, from numbered `.sql` files
30
+ - **Fully typed**, ships with type hints
31
+
32
+ Requires Python 3.11 or newer.
33
+
34
+ ## Contents
35
+
36
+ - [Installation](#installation)
37
+ - [Quick start](#quick-start)
38
+ - [Guide](#guide)
39
+ - [Writing queries](#writing-queries)
40
+ - [Reading results](#reading-results)
41
+ - [Row factories](#row-factories)
42
+ - [SQL files](#sql-files)
43
+ - [Transactions](#transactions)
44
+ - [Bulk inserts and scripts](#bulk-inserts-and-scripts)
45
+ - [Pragmas](#pragmas)
46
+ - [Migrations](#migrations)
47
+ - [Things to know](#things-to-know)
48
+ - [API reference](#api-reference)
49
+ - [Development](#development)
50
+
51
+ ## Installation
52
+
53
+ ```
54
+ pip install sqlitey
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ ```python
60
+ from sqlitey import Db, Sql
61
+
62
+ with Db("app.db") as db:
63
+ db.execute(Sql.raw("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"))
64
+ db.execute(Sql.raw("INSERT INTO users (name) VALUES (?)"), ("Alice",))
65
+
66
+ user = db.fetchone(Sql.raw("SELECT id, name FROM users WHERE name = ?"), ("Alice",))
67
+ print(user) # (1, 'Alice')
68
+ ```
69
+
70
+ That is all there is to it. When the `with` block ends, your changes are committed and the connection is closed. If an exception is raised inside the block, the changes are rolled back instead.
71
+
72
+ ## Guide
73
+
74
+ ### Writing queries
75
+
76
+ Every query is wrapped in a `Sql` object. Use `Sql.raw()` for inline SQL:
77
+
78
+ ```python
79
+ get_user = Sql.raw("SELECT id, name FROM users WHERE id = ?")
80
+ ```
81
+
82
+ Pass values separately, as parameters. Both positional (`?`) and named (`:name`) placeholders work:
83
+
84
+ ```python
85
+ db.fetchone(Sql.raw("SELECT * FROM users WHERE id = ?"), (1,))
86
+ db.fetchone(Sql.raw("SELECT * FROM users WHERE id = :id"), {"id": 1})
87
+ ```
88
+
89
+ Never build a query by formatting values into the string, that is how SQL injection happens:
90
+
91
+ ```python
92
+ # Don't do this
93
+ db.fetchone(Sql.raw(f"SELECT * FROM users WHERE name = '{name}'"))
94
+
95
+ # Do this
96
+ db.fetchone(Sql.raw("SELECT * FROM users WHERE name = ?"), (name,))
97
+ ```
98
+
99
+ To help with that, `Sql.raw()` is typed as `LiteralString`. Type checkers that enforce it, such as pyright, report the first example above as an error. Note this is a static check only, nothing is verified at runtime.
100
+
101
+ ### Reading results
102
+
103
+ | You want | Use | Returns |
104
+ |----------|-----|---------|
105
+ | One row | `db.fetchone(sql, params)` | A row, or `None` |
106
+ | All rows | `db.fetchall(sql, params)` | A list of rows |
107
+ | A single value | `db.fetchval(sql, params)` | The first column of the first row, or `None` |
108
+ | To loop over many rows | `db.execute(sql, params)` | A cursor you can iterate |
109
+
110
+ ```python
111
+ total = db.fetchval(Sql.raw("SELECT COUNT(*) FROM users")) # 2
112
+
113
+ for user_id, name in db.execute(Sql.raw("SELECT id, name FROM users")):
114
+ print(user_id, name)
115
+ ```
116
+
117
+ ### Row factories
118
+
119
+ By default rows are tuples. Pass a `row_factory` to get something friendlier.
120
+
121
+ As dictionaries:
122
+
123
+ ```python
124
+ from sqlitey import Db, Sql, dict_factory
125
+
126
+ with Db("app.db", row_factory=dict_factory) as db:
127
+ user = db.fetchone(Sql.raw("SELECT id, name FROM users WHERE id = ?"), (1,))
128
+ print(user["name"]) # 'Alice'
129
+ ```
130
+
131
+ As namedtuples:
132
+
133
+ ```python
134
+ from sqlitey import Db, Sql, namedtuple_factory
135
+
136
+ with Db("app.db", row_factory=namedtuple_factory) as db:
137
+ user = db.fetchone(Sql.raw("SELECT id, name FROM users WHERE id = ?"), (1,))
138
+ print(user.name) # 'Alice'
139
+ ```
140
+
141
+ As your own classes, the column names must match the field names:
142
+
143
+ ```python
144
+ from dataclasses import dataclass
145
+ from sqlitey import Db, Sql, dataclass_factory
146
+
147
+ @dataclass
148
+ class User:
149
+ id: int
150
+ name: str
151
+
152
+ with Db("app.db", row_factory=dataclass_factory(User)) as db:
153
+ user = db.fetchone(Sql.raw("SELECT id, name FROM users WHERE id = ?"), (1,))
154
+ print(user) # User(id=1, name='Alice')
155
+ ```
156
+
157
+ > **Tip:** with `namedtuple_factory`, a column such as `COUNT(*)` is not a valid attribute name, so it is renamed to its position (`_0`, `_1`, ...). Give it an alias to get a proper name: `SELECT COUNT(*) AS total`.
158
+
159
+ ### SQL files
160
+
161
+ Longer queries are easier to read, and to syntax-highlight, in their own `.sql` files. Point `Db` at the directory and refer to queries by filename:
162
+
163
+ ```
164
+ sql/
165
+ get_user_by_id.sql
166
+ list_users.sql
167
+ ```
168
+
169
+ ```python
170
+ from pathlib import Path
171
+ from sqlitey import Db, Sql
172
+
173
+ with Db("app.db", sql_templates_dir=Path("sql")) as db:
174
+ user = db.fetchone(Sql.template("get_user_by_id.sql"), (1,))
175
+ ```
176
+
177
+ A single query can also carry its own directory:
178
+
179
+ ```python
180
+ sql = Sql.template("get_user_by_id.sql", path=Path("sql"))
181
+ ```
182
+
183
+ If you pass the database and the SQL directory around together, `DbPathConfig` bundles them:
184
+
185
+ ```python
186
+ from sqlitey import Db, DbPathConfig
187
+
188
+ config = DbPathConfig(database=Path("app.db"), sql_templates_dir=Path("sql"))
189
+
190
+ with Db.from_config(config) as db:
191
+ ...
192
+ ```
193
+
194
+ `Sql` objects are immutable, so you can define them once at the top of a module and reuse them everywhere.
195
+
196
+ ### Transactions
197
+
198
+ Inside a `with Db(...)` block, your changes form one transaction:
199
+
200
+ - the block ends normally, everything is **committed**
201
+ - the block raises an exception, everything is **rolled back**
202
+
203
+ For finer control, wrap a group of statements in `db.transaction()`. They succeed or fail together, without affecting the rest of your work:
204
+
205
+ ```python
206
+ with Db("app.db") as db:
207
+ db.execute(Sql.raw("INSERT INTO users (name) VALUES (?)"), ("Alice",))
208
+
209
+ try:
210
+ with db.transaction():
211
+ db.execute(Sql.raw("INSERT INTO users (name) VALUES (?)"), ("Bob",))
212
+ raise RuntimeError("something went wrong")
213
+ except RuntimeError:
214
+ pass # Bob is rolled back, Alice is kept
215
+ ```
216
+
217
+ `db.transaction()` blocks can be nested. When no transaction is open yet, the outermost block commits as soon as it succeeds.
218
+
219
+ Other options:
220
+
221
+ - `db.commit(sql, params)` runs a statement and commits straight away
222
+ - `Db("app.db", autocommit=True)` commits every statement as it runs
223
+ - without a `with` block, call `db.close()` yourself when done. Uncommitted changes are discarded, so commit first
224
+
225
+ ### Bulk inserts and scripts
226
+
227
+ Run one statement with many sets of parameters:
228
+
229
+ ```python
230
+ users = [("Alice",), ("Bob",), ("Charlie",)]
231
+ db.executemany(Sql.raw("INSERT INTO users (name) VALUES (?)"), users)
232
+ ```
233
+
234
+ Run several statements at once:
235
+
236
+ ```python
237
+ db.executescript(Sql.raw("""
238
+ DELETE FROM users WHERE id = 1;
239
+ UPDATE users SET name = 'Robert' WHERE name = 'Bob';
240
+ """))
241
+ ```
242
+
243
+ ### Pragmas
244
+
245
+ SQLite's defaults are conservative, most notably **foreign keys are not enforced** unless you ask. `RECOMMENDED_PRAGMAS` turns on the settings most applications want: foreign keys, WAL journalling (readers do not block the writer) and `synchronous = normal`.
246
+
247
+ ```python
248
+ from sqlitey import Db, RECOMMENDED_PRAGMAS
249
+
250
+ with Db("app.db", pragmas=RECOMMENDED_PRAGMAS) as db:
251
+ ...
252
+ ```
253
+
254
+ Or choose your own:
255
+
256
+ ```python
257
+ with Db("app.db", pragmas={"foreign_keys": True, "cache_size": -8000}) as db:
258
+ ...
259
+ ```
260
+
261
+ ### Migrations
262
+
263
+ Keep your schema changes as numbered `.sql` files, named `<version>_<description>.sql`:
264
+
265
+ ```
266
+ migrations/
267
+ 001_create_users.sql
268
+ 002_add_email.sql
269
+ ```
270
+
271
+ Then call `migrate` when your application starts:
272
+
273
+ ```python
274
+ from pathlib import Path
275
+ from sqlitey import Db
276
+
277
+ with Db("app.db") as db:
278
+ applied = db.migrate(Path("migrations"))
279
+ print(f"{applied} migration(s) applied")
280
+ ```
281
+
282
+ - Only files newer than the database's current version are run, in order. The version is stored in the database itself (`PRAGMA user_version`), so no extra table is needed.
283
+ - Each file is all-or-nothing. If one fails, the database stays at the previous version and the error is raised.
284
+ - It is safe to call from several processes at once, each file is applied only once.
285
+
286
+ To add a change later, add a new file with the next number. Never edit a migration that has already been applied.
287
+
288
+ ## Things to know
289
+
290
+ - **Do not commit inside a `db.transaction()` block.** `db.commit()` and `db.executescript()` both end the transaction early, and the block then fails on exit.
291
+ - **`db.migrate()` needs a clean slate.** It raises `RuntimeError` if you have uncommitted changes, call it first.
292
+ - **Migration files must not contain `BEGIN` or `COMMIT`.** Each file already runs in its own transaction.
293
+ - **SQL files are cached.** A file is read once per process, restart to pick up edits.
294
+ - **Need something not covered here?** The underlying `sqlite3` connection is available as `db.conn`.
295
+
296
+ ## API reference
297
+
298
+ ### Sql
299
+
300
+ | Method | Description |
301
+ |--------|-------------|
302
+ | `Sql.raw(query)` | A query from an inline string |
303
+ | `Sql.template(filename, path=None)` | A query loaded from a `.sql` file |
304
+
305
+ ### Db
306
+
307
+ | Method | Description |
308
+ |--------|-------------|
309
+ | `Db(database, row_factory=None, sql_templates_dir=None, autocommit=False, pragmas=None)` | Open a database. Extra arguments are passed to `sqlite3.connect()` |
310
+ | `Db.from_config(config, **kwargs)` | Open a database from a `DbPathConfig` |
311
+ | `execute(sql, params=())` | Run a query and return a cursor |
312
+ | `executemany(sql, seq_of_params)` | Run a query once per set of parameters |
313
+ | `executescript(sql)` | Run several statements as a script |
314
+ | `fetchone(sql, params=())` | Run a query and return the first row, or `None` |
315
+ | `fetchall(sql, params=())` | Run a query and return all rows |
316
+ | `fetchval(sql, params=())` | Run a query and return the first column of the first row, or `None` |
317
+ | `commit(sql, params=())` | Run a query and commit |
318
+ | `transaction()` | Context manager, runs a block atomically, nestable |
319
+ | `migrate(directory)` | Apply pending migration files, return how many ran |
320
+ | `close()` | Close the connection, discarding uncommitted changes |
321
+ | `conn` | The underlying `sqlite3.Connection` |
322
+
323
+ ### Row factories
324
+
325
+ | Factory | Rows are returned as |
326
+ |---------|----------------------|
327
+ | *(none)* | Tuples |
328
+ | `dict_factory` | Dictionaries |
329
+ | `namedtuple_factory` | Namedtuples |
330
+ | `dataclass_factory(cls)` | Instances of `cls` |
331
+
332
+ ### Other
333
+
334
+ | Name | Description |
335
+ |------|-------------|
336
+ | `DbPathConfig(database, sql_templates_dir=None)` | Bundles a database path and a SQL directory |
337
+ | `RECOMMENDED_PRAGMAS` | Foreign keys on, WAL journalling, `synchronous = normal` |
338
+
339
+ ## Development
340
+
341
+ The project uses [uv](https://docs.astral.sh/uv/).
342
+
343
+ ```
344
+ make deps # install dependencies
345
+ make tests # run the test suite
346
+ make ruff # lint
347
+ make fmt # format
348
+ make mypy # type check
349
+ ```
350
+
351
+ The [test suite](https://github.com/smallwat3r/sqlitey/blob/main/tests/test_sqlitey.py) doubles as a set of runnable examples.
352
+
353
+ ## Licence
354
+
355
+ MIT, see [LICENSE](LICENSE).