diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7b65453..6a67332 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,7 +5,7 @@ on: tag: description: Reviewed GitHub release tag required: true - default: v1.0.0 + default: v1.1.0 type: string permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index a0923c4..3709aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.1.0 — 2026-10-27 + +- Rename reader, writer and session area_code parameters and attributes to area. +- Return area metadata through ParsedMessage.area. +- Close database connections after create, add_area and areas operations. +- Require golded-ftn 1.5.0 or newer. + ## 1.0.0 — 2026-10-25 - Add schema version 1 for single- and multi-area SQLite message bases. diff --git a/README.md b/README.md index d2c4c52..2db801f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # golded-ftn-sqlite Repository: [`golded-ftn-sqlite-python`](https://github.com/golded-dev/golded-ftn-sqlite-python). -Install version 1.0.0 with Python 3.12 or newer: +Install version 1.1.0 with Python 3.12 or newer: ```sh -python -m pip install golded-ftn-sqlite==1.0.0 +python -m pip install golded-ftn-sqlite==1.1.0 ``` `golded-ftn-sqlite` stores FTN netmail and echomail in SQLite through the @@ -20,7 +20,7 @@ and recovery local, and limits the blast radius when a disk has opinions. `ftnt` treats export envelopes as write input for every format, including SQLite: ```sh -ftnt create ./general.sqlite3 --format sqlite --area-code FIDO.GENERAL +ftnt create ./general.sqlite3 --format sqlite --area FIDO.GENERAL ftnt export ./general --format jam | ftnt write ./general.sqlite3 --format sqlite --jsonl ``` diff --git a/RELEASE-SHA256.txt b/RELEASE-SHA256.txt index 386d356..5c488ab 100644 --- a/RELEASE-SHA256.txt +++ b/RELEASE-SHA256.txt @@ -1,2 +1,2 @@ -4f9dc10e12dd24f19855a3791980c5d7b358bf5dfc05d70c284a90fa802d6557 dist/golded_ftn_sqlite-1.0.0-py3-none-any.whl -7e974ec3f4236249b0bfac8c96bfb57ebf8ab852c209808023f42ef8a7ca0b01 dist/golded_ftn_sqlite-1.0.0.tar.gz +16dd03e87560802640f2c4e880f73f3a27947ba67379896df3dad2c5ebed7c0a dist/golded_ftn_sqlite-1.1.0-py3-none-any.whl +ce84f158139f7fa5340d0d840a519749ebf8fb007aaa17f2f4c1e2e8c8de69f5 dist/golded_ftn_sqlite-1.1.0.tar.gz diff --git a/pyproject.toml b/pyproject.toml index 3b0919d..5903154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "hatchling.build" [project] name = "golded-ftn-sqlite" -version = "1.0.0" +version = "1.1.0" description = "Transactional SQLite message bases for FTN netmail and echomail" readme = "README.md" requires-python = ">=3.12" license = "MIT" license-files = ["LICENSE"] authors = [{name = "GoldED.dev contributors"}] -dependencies = ["golded-ftn>=1.4.0,<2"] +dependencies = ["golded-ftn>=1.5.0,<2"] classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py index 862bbbd..191f741 100644 --- a/scripts/verify_distribution.py +++ b/scripts/verify_distribution.py @@ -7,6 +7,7 @@ import sys import tarfile import tempfile +import tomllib import zipfile from pathlib import Path @@ -18,6 +19,7 @@ def run(arguments: list[str], directory: Path) -> None: def main() -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] (wheel,) = ROOT.joinpath("dist").glob("*.whl") (sdist,) = ROOT.joinpath("dist").glob("*.tar.gz") with zipfile.ZipFile(wheel) as archive: @@ -25,7 +27,7 @@ def main() -> None: metadata_name = next(name for name in names if name.endswith("/METADATA")) metadata = email.message_from_bytes(archive.read(metadata_name)) assert metadata["Name"] == "golded-ftn-sqlite" - assert metadata["Version"] == "1.0.0" + assert metadata["Version"] == project["version"] assert metadata["License-Expression"] == "MIT" assert any(name.endswith("/py.typed") for name in names) assert any(name.endswith("/licenses/LICENSE") for name in names) diff --git a/src/golded_ftn_sqlite/database.py b/src/golded_ftn_sqlite/database.py index 122ae13..6702bec 100644 --- a/src/golded_ftn_sqlite/database.py +++ b/src/golded_ftn_sqlite/database.py @@ -153,8 +153,9 @@ def create( database_path.parent.mkdir(parents=True, exist_ok=True) descriptor = os.open(database_path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600) os.close(descriptor) + connection = sqlite3.connect(database_path) try: - with sqlite3.connect(database_path) as connection: + with connection: connection.execute("PRAGMA journal_mode = WAL") connection.execute("PRAGMA synchronous = NORMAL") connection.executescript(_SCHEMA) @@ -176,35 +177,45 @@ def create( except BaseException: database_path.unlink(missing_ok=True) raise + finally: + connection.close() return cls(database_path) def add_area(self, area: AreaDefinition) -> None: - with sqlite3.connect(self.path) as connection: - connection.execute( - """ - INSERT INTO areas ( - code, name, kind, sort_order, meta_key, origin_address - ) VALUES (?, ?, ?, ?, ?, ?) - """, - ( - area.code, - area.name, - area.kind.value, - area.sort_order, - area.meta_key, - format_address(area.default_origin), - ), - ) + connection = sqlite3.connect(self.path) + try: + with connection: + connection.execute( + """ + INSERT INTO areas ( + code, name, kind, sort_order, meta_key, origin_address + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + area.code, + area.name, + area.kind.value, + area.sort_order, + area.meta_key, + format_address(area.default_origin), + ), + ) + finally: + connection.close() def areas(self) -> tuple[AreaDefinition, ...]: - with sqlite3.connect(self.path) as connection: - rows = connection.execute( - """ - SELECT code, name, kind, sort_order, meta_key, origin_address - FROM areas - ORDER BY id - """ - ).fetchall() + connection = sqlite3.connect(self.path) + try: + with connection: + rows = connection.execute( + """ + SELECT code, name, kind, sort_order, meta_key, origin_address + FROM areas + ORDER BY id + """ + ).fetchall() + finally: + connection.close() return tuple( AreaDefinition( code=row[0], @@ -243,15 +254,15 @@ def connect_database( return connection -def select_area(connection: sqlite3.Connection, area_code: str | None) -> sqlite3.Row: - if area_code is not None: +def select_area(connection: sqlite3.Connection, area: str | None) -> sqlite3.Row: + if area is not None: row = connection.execute( - "SELECT * FROM areas WHERE code = ?", (area_code,) + "SELECT * FROM areas WHERE code = ?", (area,) ).fetchone() if row is None: - raise KeyError(f"unknown area [{area_code}]") + raise KeyError(f"unknown area [{area}]") return cast(sqlite3.Row, row) rows = connection.execute("SELECT * FROM areas ORDER BY id LIMIT 2").fetchall() if len(rows) != 1: - raise ValueError("area_code is required unless the database has one area") + raise ValueError("area is required unless the database has one area") return cast(sqlite3.Row, rows[0]) diff --git a/src/golded_ftn_sqlite/reader.py b/src/golded_ftn_sqlite/reader.py index 7bbeca4..063aa14 100644 --- a/src/golded_ftn_sqlite/reader.py +++ b/src/golded_ftn_sqlite/reader.py @@ -83,7 +83,7 @@ def _message_from_row( reply_to_msgno=row["reply_to_msgno"], reply1st_msgno=row["reply1st_msgno"], reply_next_msgno=row["reply_next_msgno"], - area_code=area["code"], + area=area["code"], area_name=area["name"], area_sort_order=area["sort_order"], area_meta_key=area["meta_key"], @@ -109,8 +109,8 @@ class SqliteReader: ``ReaderOptions(eager=True)`` to load every message before returning. """ - def __init__(self, area_code: str | None = None) -> None: - self.area_code = area_code + def __init__(self, area: str | None = None) -> None: + self.area = area def read( self, @@ -122,7 +122,7 @@ def read( connection = connect_database(database_path, read_only=True) try: connection.execute("BEGIN") - area = select_area(connection, self.area_code) + area = select_area(connection, self.area) rows = connection.execute( """ SELECT * FROM messages diff --git a/src/golded_ftn_sqlite/writer.py b/src/golded_ftn_sqlite/writer.py index 13cc6f3..b0fa91e 100644 --- a/src/golded_ftn_sqlite/writer.py +++ b/src/golded_ftn_sqlite/writer.py @@ -26,10 +26,10 @@ from .reader import _message_from_row -def _identity(path: Path, area_code: str, msgno: int) -> MessageIdentity: +def _identity(path: Path, area: str, msgno: int) -> MessageIdentity: return MessageIdentity( format="sqlite", - base=f"{path}#{area_code}", + base=f"{path}#{area}", msgno=msgno, ) @@ -86,8 +86,8 @@ def _revision( class SqliteWriter: - def __init__(self, area_code: str | None = None) -> None: - self.area_code = area_code + def __init__(self, area: str | None = None) -> None: + self.area = area def open( self, @@ -95,16 +95,14 @@ def open( options: WriterOptions | None = None, ) -> SqliteSession: return SqliteSession( - Path(path).resolve(), self.area_code, options or WriterOptions() + Path(path).resolve(), self.area, options or WriterOptions() ) class SqliteSession: - def __init__( - self, path: Path, area_code: str | None, options: WriterOptions - ) -> None: + def __init__(self, path: Path, area: str | None, options: WriterOptions) -> None: self.path = path - self.area_code = area_code + self.area = area self._connection = connect_database( path, read_only=False, timeout=options.lock_timeout ) @@ -132,7 +130,7 @@ def append(self, message: OutgoingMessage) -> WriteResult: connection = self._connection try: connection.execute("BEGIN IMMEDIATE") - area = select_area(connection, self.area_code) + area = select_area(connection, self.area) default_origin = parse_address(area["origin_address"]) from_address = message.from_address or default_origin if area["kind"] == "netmail": @@ -242,7 +240,7 @@ def read(self, msgno: int) -> SessionMessage: connection = self._connection try: connection.execute("BEGIN") - area = select_area(connection, self.area_code) + area = select_area(connection, self.area) row = connection.execute( """ SELECT * FROM messages @@ -275,7 +273,7 @@ def update( connection = self._connection try: connection.execute("BEGIN IMMEDIATE") - area = select_area(connection, self.area_code) + area = select_area(connection, self.area) row = connection.execute( """ SELECT * FROM messages @@ -477,7 +475,7 @@ def delete( connection = self._connection try: connection.execute("BEGIN IMMEDIATE") - area = select_area(connection, self.area_code) + area = select_area(connection, self.area) row = connection.execute( """ SELECT * FROM messages diff --git a/tests/test_messages.py b/tests/test_messages.py index cc83a26..058e0f1 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -58,7 +58,7 @@ def test_append_echomail_and_read_it_through_public_api(tmp_path: Path) -> None: written.identity.format, written.identity.msgno, message.msgno, - message.area_code, + message.area, message.from_name, message.to_name, message.subject, @@ -601,7 +601,7 @@ def test_multi_area_database_requires_selection_and_numbers_per_area( ) ) - with SqliteWriter("FIDO.SQLITE").open(path) as session: + with SqliteWriter(area="FIDO.SQLITE").open(path) as session: echo = session.append( OutgoingMessage( from_name="Alice", @@ -610,7 +610,7 @@ def test_multi_area_database_requires_selection_and_numbers_per_area( body_text="Area one.", ) ) - with SqliteWriter("NETMAIL").open(path) as session: + with SqliteWriter(area="NETMAIL").open(path) as session: netmail = session.append( OutgoingMessage( from_name="Alice", @@ -621,13 +621,15 @@ def test_multi_area_database_requires_selection_and_numbers_per_area( ) ) - with pytest.raises(ValueError, match="area_code is required"): + with pytest.raises(ValueError, match="area is required"): tuple(SqliteReader().read(path)) assert ( echo.identity.msgno, netmail.identity.msgno, - tuple(message.subject for message in SqliteReader("FIDO.SQLITE").read(path)), - tuple(message.subject for message in SqliteReader("netmail").read(path)), + tuple( + message.subject for message in SqliteReader(area="FIDO.SQLITE").read(path) + ), + tuple(message.subject for message in SqliteReader(area="netmail").read(path)), ) == (1, 1, ("Echo",), ("Netmail",))