Skip to content

Commit 5aa192d

Browse files
authored
Merge pull request #87 from NetherlandsForensicInstitute/feature/api-use-postgresql-database
Let FastAPI app use postgresql database
2 parents 6ceed65 + 674b53a commit 5aa192d

7 files changed

Lines changed: 123 additions & 19 deletions

File tree

citatio/citatio/api.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import os
21
from contextlib import asynccontextmanager
32
from typing import Annotated
43

4+
import confidence
55
from asmtransformers.models.embedder import ASMEmbedder
66
from fastapi import FastAPI, Request
77
from fastapi.params import Body
88

9-
from citatio.db import SQLiteDatabase
9+
from citatio.db import PostgreSQLDatabase
1010
from citatio.models import ControlFlowGraph
1111

1212

@@ -15,26 +15,22 @@
1515

1616
@asynccontextmanager
1717
async def lifespan(app: FastAPI):
18-
model = os.environ.get('CITATIO_MODEL', DEFAULT_MODEL)
19-
database = os.environ.get('CITATIO_SQLITE_DATABASE', ':memory:')
18+
config = confidence.load_name('citatio')
19+
if not config.database:
20+
raise ValueError(f'missing database configuration, available: {config}')
2021

21-
if database == ':memory:':
22-
# TODO: log warning that we're running in in-memory database
23-
pass
22+
app.state.model = ASMEmbedder.from_pretrained(config.model or DEFAULT_MODEL)
2423

25-
app.state.model = ASMEmbedder.from_pretrained(model)
26-
27-
with SQLiteDatabase.from_name(database) as database:
24+
async with await PostgreSQLDatabase.connect(**config.database) as database:
2825
app.state.database = database
29-
3026
yield
3127

3228

3329
app = FastAPI(lifespan=lifespan)
3430

3531

3632
@app.post('/api/v1/add')
37-
async def add_function( # NB: function body isn't actually async, forcing it to run blocking on the event loop
33+
async def add_function(
3834
request: Request,
3935
name: Annotated[str, Body()],
4036
cfg: Annotated[ControlFlowGraph, Body()],
@@ -43,15 +39,15 @@ async def add_function( # NB: function body isn't actually async, forcing it to
4339
binary_sha256: Annotated[str, Body()] = None,
4440
):
4541
embedding = request.app.state.model.encode(str(cfg), architecture=architecture)
46-
request.app.state.database.add_function(name, cfg, embedding, binary_name, binary_sha256)
42+
await request.app.state.database.add_function(name, cfg, embedding, binary_name, binary_sha256)
4743

4844

4945
@app.post('/api/v1/search')
50-
async def search_function( # NB: function body isn't actually async, forcing it to run blocking on the event loop
46+
async def search_function(
5147
request: Request,
5248
cfg: Annotated[ControlFlowGraph, Body()],
5349
architecture: str = 'arm64',
5450
top_n: Annotated[int, Body()] = 25,
5551
):
5652
embedding = request.app.state.model.encode(str(cfg), architecture=architecture)
57-
return request.app.state.database.search_function(embedding, top_n)
53+
return await request.app.state.database.search_function(embedding, top_n)

citatio/citatio/db.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ async def connect(cls, **kwargs):
119119
await connection.execute(resources.read_text('citatio', 'schema-postgresql.sql'))
120120
return cls(connection)
121121

122+
async def __aenter__(self):
123+
return self
124+
125+
async def __aexit__(self, exc_type, exc_val, exc_tb):
126+
await self.connection.close()
127+
122128
async def _insert_or_get_function(self, cfg, embedding):
123129
cfg = str(cfg)
124130
try:
@@ -147,7 +153,7 @@ async def add_function(self, name, cfg, embedding, binary_name, binary_sha256, m
147153
async def _search_near(self, embedding, top_n):
148154
return await self.connection.fetch(
149155
"""
150-
SELECT label, (2 - (embedding <-> $1)) / 2 AS similarity, binary_name, binary_sha256
156+
SELECT label, (2 - (embedding <=> $1)) / 2 AS similarity, binary_name, binary_sha256
151157
FROM labels
152158
JOIN functions ON labels.function_id = functions.id
153159
ORDER BY similarity DESC

citatio/pdm.lock

Lines changed: 26 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

citatio/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ requires-python = ">=3.13,<3.14"
1919
dependencies = [
2020
"asmtransformers @ file:///${PROJECT_ROOT}/../asmtransformers",
2121
"asyncpg>=0.31.0",
22+
"confidence>=0.17.2",
2223
"datasets",
2324
"fastapi[all]>=0.115.12",
2425
"numpy",

citatio/tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,20 @@
33

44
import numpy as np
55
import pytest
6+
from _pytest.monkeypatch import MonkeyPatch
67

78

89
TEST_ROOT = Path(__file__).parent
910

1011

12+
@pytest.fixture(scope='session')
13+
def monkeypatch_session():
14+
# regular monkeypatch fixture is function scope, provide a session-scoped one
15+
mp = MonkeyPatch()
16+
yield mp
17+
mp.undo()
18+
19+
1120
@pytest.fixture(scope='session')
1221
def functions():
1322
return json.loads((TEST_ROOT / 'functions.json').read_text(encoding='utf-8'))

citatio/tests/test_api.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,82 @@
11
import os
2+
from contextlib import contextmanager
3+
from os import environ
24

5+
import asyncpg
36
import pytest
47
from fastapi.testclient import TestClient
8+
from testcontainers.postgres import PostgresContainer
59

610
from citatio.api import app
711

812

13+
pytestmark = pytest.mark.postgresql
14+
15+
16+
@contextmanager
17+
def local_pgvector_container():
18+
with PostgresContainer('pgvector/pgvector:pg18', driver=None) as container:
19+
yield container
20+
21+
22+
def _patch_database_env(monkeypatch, env):
23+
for var, value in env.items():
24+
# use environment variables to communicate configuration
25+
var = f'CITATIO_DATABASE_{var}'.upper()
26+
if value:
27+
monkeypatch.setenv(var, str(value))
28+
else:
29+
# env vars have no concept of 'no value' other than removing the var
30+
monkeypatch.delenv(var)
31+
32+
# return the original env that's still suitable for use with asyncpg.connect()
33+
return env
34+
35+
36+
@pytest.fixture(scope='session')
37+
def connect_pgvector(monkeypatch_session):
38+
# translate PostgreSQL connection details into environment variables that will be picked up by confidence.load_name
39+
# in the app's lifecycle
40+
match environ:
41+
case {'GITHUB_ACTIONS': _}:
42+
# running on GHA, provide connection details to pgvector service defined in .github/workflows/citatio.yml
43+
yield _patch_database_env(
44+
monkeypatch_session,
45+
{
46+
'host': environ.get('POSTGRES_HOST', 'postgres'),
47+
'port': environ.get('POSTGRES_PORT', 5432),
48+
'user': environ.get('POSTGRES_USER', 'postgres'),
49+
'password': environ.get('POSTGRES_PASSWORD'),
50+
'database': environ.get('POSTGRES_DATABASE', 'postgres'),
51+
},
52+
)
53+
case _:
54+
# running locally, provide connection details to local pgvector container
55+
with local_pgvector_container() as container:
56+
yield _patch_database_env(
57+
monkeypatch_session,
58+
{
59+
'host': container.get_container_host_ip(),
60+
'port': container.get_exposed_port(5432),
61+
'user': container.username,
62+
'password': container.password,
63+
'database': container.dbname,
64+
},
65+
)
66+
67+
968
@pytest.fixture
10-
def client():
69+
async def client(connect_pgvector):
1170
with TestClient(app) as client:
1271
yield client
1372

73+
# after the test (post-yield), make sure to empty the tables that we might have inserted into during the test
74+
connection = await asyncpg.connect(**connect_pgvector)
75+
await connection.execute("""
76+
DELETE FROM labels;
77+
DELETE FROM functions;
78+
""")
79+
1480

1581
@pytest.mark.skipif(os.environ.get('CI') == 'true', reason="don't run this test on CI")
1682
def test_add_function(client, functions):

citatio/tests/test_db_postgresql.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ async def connect_pgvector():
4545

4646
@pytest.fixture(scope='session')
4747
async def database(connect_pgvector):
48-
return await PostgreSQLDatabase.connect(**connect_pgvector)
48+
async with await PostgreSQLDatabase.connect(**connect_pgvector) as database:
49+
yield database
4950

5051

5152
@pytest.fixture(scope='function')

0 commit comments

Comments
 (0)