bmlib 0.4.0__py3-none-any.whl

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.
Files changed (61) hide show
  1. bmlib/__init__.py +19 -0
  2. bmlib/agents/__init__.py +21 -0
  3. bmlib/agents/base.py +269 -0
  4. bmlib/db/__init__.py +57 -0
  5. bmlib/db/connection.py +100 -0
  6. bmlib/db/migrations.py +141 -0
  7. bmlib/db/operations.py +213 -0
  8. bmlib/db/transactions.py +84 -0
  9. bmlib/fulltext/__init__.py +60 -0
  10. bmlib/fulltext/cache.py +172 -0
  11. bmlib/fulltext/jats_parser.py +1029 -0
  12. bmlib/fulltext/models.py +198 -0
  13. bmlib/fulltext/pdf_converter.py +288 -0
  14. bmlib/fulltext/service.py +390 -0
  15. bmlib/llm/__init__.py +79 -0
  16. bmlib/llm/client.py +393 -0
  17. bmlib/llm/data_types.py +198 -0
  18. bmlib/llm/json_repair.py +548 -0
  19. bmlib/llm/providers/__init__.py +139 -0
  20. bmlib/llm/providers/anthropic.py +467 -0
  21. bmlib/llm/providers/base.py +182 -0
  22. bmlib/llm/providers/deepseek.py +56 -0
  23. bmlib/llm/providers/gemini.py +78 -0
  24. bmlib/llm/providers/mistral.py +71 -0
  25. bmlib/llm/providers/ollama.py +511 -0
  26. bmlib/llm/providers/openai_compat.py +417 -0
  27. bmlib/llm/providers/openai_provider.py +81 -0
  28. bmlib/llm/text_utils.py +361 -0
  29. bmlib/llm/token_tracker.py +167 -0
  30. bmlib/llm/utils.py +84 -0
  31. bmlib/publications/__init__.py +67 -0
  32. bmlib/publications/fetchers/__init__.py +37 -0
  33. bmlib/publications/fetchers/biorxiv.py +202 -0
  34. bmlib/publications/fetchers/openalex.py +280 -0
  35. bmlib/publications/fetchers/pubmed.py +400 -0
  36. bmlib/publications/fetchers/registry.py +187 -0
  37. bmlib/publications/models.py +321 -0
  38. bmlib/publications/schema.py +84 -0
  39. bmlib/publications/storage.py +384 -0
  40. bmlib/publications/sync.py +442 -0
  41. bmlib/py.typed +0 -0
  42. bmlib/quality/__init__.py +81 -0
  43. bmlib/quality/cochrane_formatter.py +380 -0
  44. bmlib/quality/cochrane_models.py +599 -0
  45. bmlib/quality/data_models.py +361 -0
  46. bmlib/quality/extractors.py +487 -0
  47. bmlib/quality/manager.py +175 -0
  48. bmlib/quality/metadata_filter.py +161 -0
  49. bmlib/quality/quality_agent.py +168 -0
  50. bmlib/quality/scoring_models.py +140 -0
  51. bmlib/quality/study_classifier.py +123 -0
  52. bmlib/templates/__init__.py +36 -0
  53. bmlib/templates/engine.py +119 -0
  54. bmlib/transparency/__init__.py +38 -0
  55. bmlib/transparency/analyzer.py +766 -0
  56. bmlib/transparency/models.py +187 -0
  57. bmlib-0.4.0.dist-info/METADATA +263 -0
  58. bmlib-0.4.0.dist-info/RECORD +61 -0
  59. bmlib-0.4.0.dist-info/WHEEL +5 -0
  60. bmlib-0.4.0.dist-info/licenses/LICENSE +17 -0
  61. bmlib-0.4.0.dist-info/top_level.txt +1 -0
bmlib/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ # bmlib — shared library for biomedical literature tools
2
+ # Copyright (C) 2024-2026 Dr Horst Herb
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """bmlib — shared library for biomedical literature tools."""
18
+
19
+ __version__ = "0.4.0"
@@ -0,0 +1,21 @@
1
+ # bmlib — shared library for biomedical literature tools
2
+ # Copyright (C) 2024-2026 Dr Horst Herb
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Agent base class for LLM-powered tasks."""
18
+
19
+ from bmlib.agents.base import BaseAgent
20
+
21
+ __all__ = ["BaseAgent"]
bmlib/agents/base.py ADDED
@@ -0,0 +1,269 @@
1
+ # bmlib — shared library for biomedical literature tools
2
+ # Copyright (C) 2024-2026 Dr Horst Herb
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Base agent class for LLM-powered tasks.
18
+
19
+ Provides shared infrastructure for agents that call LLMs:
20
+ - Model/provider resolution from externally-supplied configuration
21
+ - Helper methods for building messages
22
+ - JSON response parsing
23
+
24
+ Unlike the bmlibrarian_lite ``LiteBaseAgent``, this class does **not**
25
+ read config from a hardcoded path. The calling application passes in
26
+ the model string and LLM client explicitly.
27
+
28
+ Usage::
29
+
30
+ class ScoringAgent(BaseAgent):
31
+ def score(self, title: str, abstract: str, interests: list[str]) -> dict:
32
+ prompt = self.render_template("scoring.txt", ...)
33
+ response = self.chat(
34
+ [self.system_msg("You are ..."), self.user_msg(prompt)],
35
+ json_mode=True,
36
+ )
37
+ return self.parse_json(response.content)
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import logging
44
+ import time
45
+ from typing import Any
46
+
47
+ from bmlib.llm import LLMClient, LLMMessage, LLMResponse
48
+ from bmlib.llm.json_repair import extract_and_repair_json
49
+ from bmlib.llm.utils import extract_json
50
+ from bmlib.templates import TemplateEngine
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ # Provider stop_reason values that mean the output hit the max_tokens ceiling:
55
+ # Anthropic reports "max_tokens", OpenAI-compatible providers "length".
56
+ _TRUNCATION_STOP_REASONS = ("max_tokens", "length")
57
+
58
+
59
+ class BaseAgent:
60
+ """Base class for LLM-powered agents.
61
+
62
+ Args:
63
+ llm: The LLM client to use.
64
+ model: Full model string (``"provider:model_name"``).
65
+ template_engine: Template engine for loading prompt files.
66
+ temperature: Default sampling temperature.
67
+ max_tokens: Default max tokens.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ llm: LLMClient,
73
+ model: str,
74
+ template_engine: TemplateEngine | None = None,
75
+ temperature: float = 0.3,
76
+ max_tokens: int = 4096,
77
+ ) -> None:
78
+ self.llm = llm
79
+ self.model = model
80
+ self.templates = template_engine
81
+ self.temperature = temperature
82
+ self.max_tokens = max_tokens
83
+
84
+ # --- Message helpers ---
85
+
86
+ @staticmethod
87
+ def system_msg(content: str) -> LLMMessage:
88
+ return LLMMessage(role="system", content=content)
89
+
90
+ @staticmethod
91
+ def user_msg(content: str) -> LLMMessage:
92
+ return LLMMessage(role="user", content=content)
93
+
94
+ @staticmethod
95
+ def assistant_msg(content: str) -> LLMMessage:
96
+ return LLMMessage(role="assistant", content=content)
97
+
98
+ # --- LLM interaction ---
99
+
100
+ def chat(
101
+ self,
102
+ messages: list[LLMMessage],
103
+ *,
104
+ json_mode: bool = False,
105
+ temperature: float | None = None,
106
+ max_tokens: int | None = None,
107
+ **kwargs: object,
108
+ ) -> LLMResponse:
109
+ """Send a chat request through the LLM client."""
110
+ return self.llm.chat(
111
+ messages=messages,
112
+ model=self.model,
113
+ temperature=temperature if temperature is not None else self.temperature,
114
+ max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
115
+ json_mode=json_mode,
116
+ **kwargs,
117
+ )
118
+
119
+ def chat_json(
120
+ self,
121
+ messages: list[LLMMessage],
122
+ *,
123
+ temperature: float | None = None,
124
+ max_tokens: int | None = None,
125
+ max_retries: int = 3,
126
+ **kwargs: object,
127
+ ) -> dict:
128
+ """Chat with JSON mode, retry on empty/unparseable responses.
129
+
130
+ Combines :meth:`chat` with :meth:`parse_json` and exponential
131
+ backoff retry. Empty responses are treated as transport/model
132
+ errors (WARNING). Unparseable responses are logged at ERROR
133
+ with the full model output for diagnosis.
134
+
135
+ A response that stopped because it hit the ``max_tokens``
136
+ ceiling is reported as truncation, not "unparseable response".
137
+ At temperature 0 the retry is provably futile (greedy sampling
138
+ reproduces the identical truncation), so it raises immediately;
139
+ at temperature > 0 a retry may sample a shorter completion that
140
+ fits, so truncation still gets the normal retries, but the
141
+ final error names the real cause.
142
+
143
+ Returns the parsed dict. Raises :class:`ValueError` on
144
+ truncation or after all retries are exhausted.
145
+ """
146
+ last_error: str | None = None
147
+ for attempt in range(max_retries):
148
+ if attempt > 0:
149
+ delay = 2 ** (attempt - 1) # 1s, 2s, 4s …
150
+ logger.warning(
151
+ "Retry %d/%d after %.0fs (previous: %s)",
152
+ attempt + 1,
153
+ max_retries,
154
+ delay,
155
+ last_error,
156
+ )
157
+ time.sleep(delay)
158
+
159
+ response = self.chat(
160
+ messages,
161
+ json_mode=True,
162
+ temperature=temperature,
163
+ max_tokens=max_tokens,
164
+ **kwargs,
165
+ )
166
+
167
+ content = response.content.strip()
168
+
169
+ if response.stop_reason in _TRUNCATION_STOP_REASONS:
170
+ parsed = self._try_parse(content)
171
+ if parsed is not None:
172
+ # The JSON happens to be complete despite hitting the
173
+ # ceiling — usable as-is.
174
+ return parsed
175
+ budget = max_tokens if max_tokens is not None else self.max_tokens
176
+ truncated = (
177
+ f"response truncated at max_tokens={budget} "
178
+ f"(stop_reason={response.stop_reason!r}) — raise max_tokens "
179
+ "or request less output"
180
+ )
181
+ logger.error(
182
+ "LLM response truncated (attempt %d/%d), full response: %s",
183
+ attempt + 1,
184
+ max_retries,
185
+ content,
186
+ )
187
+ effective_temperature = temperature if temperature is not None else self.temperature
188
+ if effective_temperature == 0.0:
189
+ # Greedy sampling reproduces the identical truncation;
190
+ # retrying only pays for it again.
191
+ raise ValueError(truncated) from None
192
+ # A retry at temperature > 0 may sample a shorter completion
193
+ # that fits.
194
+ last_error = truncated
195
+ continue
196
+
197
+ if not content:
198
+ last_error = "empty response from model"
199
+ logger.warning(
200
+ "LLM returned empty response (attempt %d/%d)",
201
+ attempt + 1,
202
+ max_retries,
203
+ )
204
+ continue
205
+
206
+ try:
207
+ return self.parse_json(content)
208
+ except ValueError:
209
+ last_error = "unparseable response"
210
+ logger.error(
211
+ "LLM returned unparseable response (attempt %d/%d), full response: %s",
212
+ attempt + 1,
213
+ max_retries,
214
+ content,
215
+ )
216
+ continue
217
+
218
+ raise ValueError(f"Failed after {max_retries} attempts: {last_error}")
219
+
220
+ # --- Template rendering ---
221
+
222
+ def render_template(self, template_name: str, **variables: Any) -> str:
223
+ """Render a prompt template. Raises if no template engine configured."""
224
+ if self.templates is None:
225
+ raise RuntimeError(f"No template engine configured — cannot render {template_name!r}")
226
+ return self.templates.render(template_name, **variables)
227
+
228
+ # --- JSON parsing ---
229
+
230
+ @classmethod
231
+ def _try_parse(cls, text: str) -> dict | None:
232
+ """:meth:`parse_json`, but ``None`` instead of ``ValueError`` on failure."""
233
+ if not text:
234
+ return None
235
+ try:
236
+ return cls.parse_json(text)
237
+ except ValueError:
238
+ return None
239
+
240
+ @staticmethod
241
+ def parse_json(text: str) -> dict:
242
+ """Extract and parse JSON from LLM response text.
243
+
244
+ Handles responses wrapped in markdown code blocks.
245
+ """
246
+ # Try direct parse
247
+ try:
248
+ return json.loads(text)
249
+ except json.JSONDecodeError:
250
+ pass
251
+
252
+ # Fall back to the shared extractor (code-block aware + balanced-brace
253
+ # scanning that picks the first parseable object).
254
+ candidate = extract_json(text)
255
+ if candidate != text:
256
+ try:
257
+ return json.loads(candidate)
258
+ except json.JSONDecodeError:
259
+ pass
260
+
261
+ # Last resort: repair common LLM JSON defects (single quotes, trailing
262
+ # commas, truncation, unquoted keys) after extracting the JSON span.
263
+ try:
264
+ repaired, _ = extract_and_repair_json(text)
265
+ return json.loads(repaired)
266
+ except (ValueError, json.JSONDecodeError):
267
+ pass
268
+
269
+ raise ValueError(f"Could not parse JSON from LLM response: {text[:200]!r}")
bmlib/db/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ # bmlib — shared library for biomedical literature tools
2
+ # Copyright (C) 2024-2026 Dr Horst Herb
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Thin database abstraction — pure functions over DB-API connections.
18
+
19
+ Supports SQLite (built-in) and PostgreSQL (optional, via psycopg2).
20
+
21
+ Usage::
22
+
23
+ from bmlib.db import connect_sqlite, fetch_all, execute, transaction
24
+
25
+ conn = connect_sqlite("~/.myapp/data.db")
26
+ with transaction(conn):
27
+ execute(conn, "INSERT INTO papers (doi, title) VALUES (?, ?)", ("10.1101/x", "A paper"))
28
+ rows = fetch_all(conn, "SELECT * FROM papers")
29
+ """
30
+
31
+ from bmlib.db.connection import connect_postgresql, connect_sqlite
32
+ from bmlib.db.migrations import Migration, run_migrations
33
+ from bmlib.db.operations import (
34
+ create_tables,
35
+ execute,
36
+ executemany,
37
+ fetch_all,
38
+ fetch_one,
39
+ fetch_scalar,
40
+ table_exists,
41
+ )
42
+ from bmlib.db.transactions import transaction
43
+
44
+ __all__ = [
45
+ "connect_sqlite",
46
+ "connect_postgresql",
47
+ "execute",
48
+ "executemany",
49
+ "fetch_one",
50
+ "fetch_all",
51
+ "fetch_scalar",
52
+ "table_exists",
53
+ "create_tables",
54
+ "transaction",
55
+ "Migration",
56
+ "run_migrations",
57
+ ]
bmlib/db/connection.py ADDED
@@ -0,0 +1,100 @@
1
+ # bmlib — shared library for biomedical literature tools
2
+ # Copyright (C) 2024-2026 Dr Horst Herb
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Database connection factories.
18
+
19
+ Each function returns a standard DB-API 2.0 connection. SQLite uses the
20
+ built-in ``sqlite3`` module; PostgreSQL uses ``psycopg2`` (optional
21
+ dependency).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import sqlite3
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def connect_sqlite(
35
+ path: str | Path,
36
+ *,
37
+ wal_mode: bool = True,
38
+ foreign_keys: bool = True,
39
+ ) -> sqlite3.Connection:
40
+ """Open (or create) a SQLite database and return a connection.
41
+
42
+ Args:
43
+ path: File path (``":memory:"`` for in-memory).
44
+ wal_mode: Enable WAL journal mode for better concurrent access.
45
+ foreign_keys: Enforce foreign key constraints.
46
+ """
47
+ path = str(Path(path).expanduser()) if path != ":memory:" else ":memory:"
48
+
49
+ if path != ":memory:":
50
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
51
+
52
+ conn = sqlite3.connect(path, check_same_thread=False)
53
+ conn.row_factory = sqlite3.Row
54
+
55
+ if wal_mode and path != ":memory:":
56
+ conn.execute("PRAGMA journal_mode=WAL")
57
+ if foreign_keys:
58
+ conn.execute("PRAGMA foreign_keys=ON")
59
+
60
+ logger.debug("SQLite connection opened: %s", path)
61
+ return conn
62
+
63
+
64
+ def connect_postgresql(
65
+ dsn: str | None = None,
66
+ *,
67
+ host: str = "localhost",
68
+ port: int = 5432,
69
+ database: str = "bmlib",
70
+ user: str = "bmlib",
71
+ password: str = "",
72
+ ) -> Any:
73
+ """Open a PostgreSQL connection via psycopg2.
74
+
75
+ Either provide a full *dsn* string, or individual parameters.
76
+
77
+ Returns:
78
+ A ``psycopg2`` connection with ``RealDictCursor`` as the default
79
+ cursor factory.
80
+ """
81
+ try:
82
+ import psycopg2
83
+ import psycopg2.extras
84
+ except ImportError:
85
+ raise ImportError("psycopg2 not installed. Install with: pip install bmlib[postgresql]")
86
+
87
+ if dsn:
88
+ conn = psycopg2.connect(dsn, cursor_factory=psycopg2.extras.RealDictCursor)
89
+ else:
90
+ conn = psycopg2.connect(
91
+ host=host,
92
+ port=port,
93
+ database=database,
94
+ user=user,
95
+ password=password,
96
+ cursor_factory=psycopg2.extras.RealDictCursor,
97
+ )
98
+
99
+ logger.debug("PostgreSQL connection opened: %s:%s/%s", host, port, database)
100
+ return conn
bmlib/db/migrations.py ADDED
@@ -0,0 +1,141 @@
1
+ # bmlib — shared library for biomedical literature tools
2
+ # Copyright (C) 2024-2026 Dr Horst Herb
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Idempotent database migration runner.
18
+
19
+ Provides a simple, sequential migration system that tracks applied
20
+ migrations in a ``schema_version`` table. Each migration is a Python
21
+ function that receives a DB-API connection.
22
+
23
+ Usage::
24
+
25
+ from bmlib.db.migrations import Migration, run_migrations
26
+
27
+ def _m001_create_users(conn):
28
+ create_tables(conn, "CREATE TABLE IF NOT EXISTS users (...);")
29
+
30
+ MIGRATIONS = [Migration(1, "create_users", _m001_create_users)]
31
+
32
+ run_migrations(conn, MIGRATIONS)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+ from collections.abc import Callable
39
+ from dataclasses import dataclass
40
+ from typing import Any
41
+
42
+ from bmlib.db.operations import create_tables, execute, fetch_all, table_exists
43
+ from bmlib.db.transactions import transaction
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ @dataclass
49
+ class Migration:
50
+ """A single database migration.
51
+
52
+ Attributes:
53
+ version: Sequential integer (1, 2, 3, ...). Must be unique.
54
+ name: Short descriptive name (e.g. ``"initial_schema"``).
55
+ up: Callable that takes a DB-API connection and applies the DDL.
56
+ """
57
+
58
+ version: int
59
+ name: str
60
+ up: Callable[[Any], None]
61
+
62
+
63
+ def _is_sqlite(conn: Any) -> bool:
64
+ """Return True if the connection is SQLite."""
65
+ return "sqlite3" in type(conn).__module__
66
+
67
+
68
+ def _placeholder(conn: Any) -> str:
69
+ """Return the correct parameter placeholder for this connection."""
70
+ return "?" if _is_sqlite(conn) else "%s"
71
+
72
+
73
+ def _ensure_version_table(conn: Any) -> None:
74
+ """Create the ``schema_version`` table if it does not exist."""
75
+ if table_exists(conn, "schema_version"):
76
+ return
77
+
78
+ if _is_sqlite(conn):
79
+ ddl = """\
80
+ CREATE TABLE schema_version (
81
+ version INTEGER PRIMARY KEY,
82
+ name TEXT NOT NULL,
83
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
84
+ );
85
+ """
86
+ else:
87
+ ddl = """\
88
+ CREATE TABLE schema_version (
89
+ version INTEGER PRIMARY KEY,
90
+ name TEXT NOT NULL,
91
+ applied_at TIMESTAMP NOT NULL DEFAULT NOW()
92
+ );
93
+ """
94
+ create_tables(conn, ddl)
95
+ logger.info("Created schema_version table")
96
+
97
+
98
+ def get_applied_versions(conn: Any) -> set[int]:
99
+ """Return the set of migration version numbers already applied.
100
+
101
+ Returns an empty set if the ``schema_version`` table does not exist.
102
+ """
103
+ if not table_exists(conn, "schema_version"):
104
+ return set()
105
+
106
+ rows = fetch_all(conn, "SELECT version FROM schema_version")
107
+ return {r[0] if not isinstance(r, dict) else r["version"] for r in rows}
108
+
109
+
110
+ def run_migrations(conn: Any, migrations: list[Migration]) -> int:
111
+ """Apply all pending migrations in version order.
112
+
113
+ Args:
114
+ conn: A DB-API connection (sqlite3 or psycopg2).
115
+ migrations: Ordered list of ``Migration`` objects.
116
+
117
+ Returns:
118
+ Number of migrations applied.
119
+ """
120
+ _ensure_version_table(conn)
121
+ applied = get_applied_versions(conn)
122
+ ph = _placeholder(conn)
123
+
124
+ count = 0
125
+ for migration in sorted(migrations, key=lambda m: m.version):
126
+ if migration.version in applied:
127
+ continue
128
+
129
+ logger.info("Applying migration %d: %s", migration.version, migration.name)
130
+ with transaction(conn):
131
+ migration.up(conn)
132
+ execute(
133
+ conn,
134
+ f"INSERT INTO schema_version (version, name) VALUES ({ph}, {ph})",
135
+ (migration.version, migration.name),
136
+ )
137
+ count += 1
138
+
139
+ if count:
140
+ logger.info("Applied %d migration(s)", count)
141
+ return count