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.
- bmlib/__init__.py +19 -0
- bmlib/agents/__init__.py +21 -0
- bmlib/agents/base.py +269 -0
- bmlib/db/__init__.py +57 -0
- bmlib/db/connection.py +100 -0
- bmlib/db/migrations.py +141 -0
- bmlib/db/operations.py +213 -0
- bmlib/db/transactions.py +84 -0
- bmlib/fulltext/__init__.py +60 -0
- bmlib/fulltext/cache.py +172 -0
- bmlib/fulltext/jats_parser.py +1029 -0
- bmlib/fulltext/models.py +198 -0
- bmlib/fulltext/pdf_converter.py +288 -0
- bmlib/fulltext/service.py +390 -0
- bmlib/llm/__init__.py +79 -0
- bmlib/llm/client.py +393 -0
- bmlib/llm/data_types.py +198 -0
- bmlib/llm/json_repair.py +548 -0
- bmlib/llm/providers/__init__.py +139 -0
- bmlib/llm/providers/anthropic.py +467 -0
- bmlib/llm/providers/base.py +182 -0
- bmlib/llm/providers/deepseek.py +56 -0
- bmlib/llm/providers/gemini.py +78 -0
- bmlib/llm/providers/mistral.py +71 -0
- bmlib/llm/providers/ollama.py +511 -0
- bmlib/llm/providers/openai_compat.py +417 -0
- bmlib/llm/providers/openai_provider.py +81 -0
- bmlib/llm/text_utils.py +361 -0
- bmlib/llm/token_tracker.py +167 -0
- bmlib/llm/utils.py +84 -0
- bmlib/publications/__init__.py +67 -0
- bmlib/publications/fetchers/__init__.py +37 -0
- bmlib/publications/fetchers/biorxiv.py +202 -0
- bmlib/publications/fetchers/openalex.py +280 -0
- bmlib/publications/fetchers/pubmed.py +400 -0
- bmlib/publications/fetchers/registry.py +187 -0
- bmlib/publications/models.py +321 -0
- bmlib/publications/schema.py +84 -0
- bmlib/publications/storage.py +384 -0
- bmlib/publications/sync.py +442 -0
- bmlib/py.typed +0 -0
- bmlib/quality/__init__.py +81 -0
- bmlib/quality/cochrane_formatter.py +380 -0
- bmlib/quality/cochrane_models.py +599 -0
- bmlib/quality/data_models.py +361 -0
- bmlib/quality/extractors.py +487 -0
- bmlib/quality/manager.py +175 -0
- bmlib/quality/metadata_filter.py +161 -0
- bmlib/quality/quality_agent.py +168 -0
- bmlib/quality/scoring_models.py +140 -0
- bmlib/quality/study_classifier.py +123 -0
- bmlib/templates/__init__.py +36 -0
- bmlib/templates/engine.py +119 -0
- bmlib/transparency/__init__.py +38 -0
- bmlib/transparency/analyzer.py +766 -0
- bmlib/transparency/models.py +187 -0
- bmlib-0.4.0.dist-info/METADATA +263 -0
- bmlib-0.4.0.dist-info/RECORD +61 -0
- bmlib-0.4.0.dist-info/WHEEL +5 -0
- bmlib-0.4.0.dist-info/licenses/LICENSE +17 -0
- 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"
|
bmlib/agents/__init__.py
ADDED
|
@@ -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
|