Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion medcat-trainer/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export MCTRAINER_PASSWORD=<password>
```

```python
from mctclient import MedCATTrainerSession, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject
from mctclient import MedCATTrainerSession, KeycloakSettings, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject

# Connect to your MedCATTrainer instance
session = MedCATTrainerSession(server="http://localhost:8001")
Expand All @@ -59,6 +59,31 @@ project = session.create_project(
)
```

## Authentication

### OIDC / Keycloak (optional)

You can pass a `KeycloakSettings` object to use OIDC Bearer auth. Any missing
fields fall back to environment variables
(`KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_USERNAME`, `KEYCLOAK_PASSWORD`)
and then the same defaults as `webapp/scripts/load_examples.py`.

```python
from mctclient import MedCATTrainerSession, KeycloakSettings

session = MedCATTrainerSession(
server="http://localhost:8001",
use_oidc=True,
keycloak_settings=KeycloakSettings(
keycloak_url="http://keycloak.cogstack.localhost",
realm="cogstack-realm",
client_id="cogstack-medcattrainer-frontend",
username="admin",
password="admin",
),
)
```

### MedCATTrainerSession Methods

- `create_project(name, description, members, dataset, cuis=[], cuis_file=None, concept_db=None, vocab=None, cdb_search_filter=None, modelpack=None, meta_tasks=[], rel_tasks=[])`
Expand All @@ -81,6 +106,51 @@ Each method returns the corresponding object or a list of objects.

This project is licensed under the Apache 2.0 License.

## Developer Instructions

### Local dev setup (uv)

From the repo root:

```sh
cd medcat-trainer/client
uv venv
source .venv/bin/activate
uv pip install -e .
```

### Run unit tests

```sh
cd medcat-trainer/client
python -m unittest discover -s tests -p "test_*.py" -v
```

### Run integration test (real server)

```sh
cd medcat-trainer/client
export MCTRAINER_SERVER="http://localhost:8000"
export MCTRAINER_USERNAME="<username>"
export MCTRAINER_PASSWORD="<password>"
export MCTRAINER_EXPECTED_USERS="admin,alice,bob" # optional
python -m unittest tests.integration.test_integration_mctclient -v
```

Optional: use OIDC / Keycloak Bearer token auth:

```sh
export MCTRAINER_USE_OIDC=1
export KEYCLOAK_URL="http://keycloak.cogstack.localhost"
export KEYCLOAK_REALM="cogstack"
export KEYCLOAK_CLIENT_ID="cogstack-medcattrainer-frontend"
# If set, the client will use client-credentials grant; otherwise password grant.
export KEYCLOAK_CLIENT_SECRET="<client_secret>" # optional
export KEYCLOAK_USERNAME="<keycloak_username>" # used if no client_secret
export KEYCLOAK_PASSWORD="<keycloak_password>" # used if no client_secret
python -m unittest tests.integration.test_integration_mctclient -v
```

## Contributing

Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
Expand Down
94 changes: 88 additions & 6 deletions medcat-trainer/client/mctclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,65 @@
import json
import os
from abc import ABC
from typing import Any, Dict, List, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple, Union

import requests

import logging

logger = logging.getLogger(__name__)

@dataclass
class KeycloakSettings:
"""
Keycloak settings for OIDC token retrieval.

If a field is not provided, it falls back to environment variables and then
the same defaults used by `webapp/scripts/load_examples.py`.
"""

keycloak_url: Optional[str] = None
realm: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
scope: str = "openid profile email"

def __post_init__(self):
self.keycloak_url = self.keycloak_url or os.environ.get("KEYCLOAK_URL", "http://keycloak.cogstack.localhost")
self.realm = self.realm or os.environ.get("KEYCLOAK_REALM", "cogstack-realm")
self.client_id = self.client_id or os.environ.get("KEYCLOAK_CLIENT_ID", "cogstack-medcattrainer-frontend")
self.client_secret = self.client_secret or os.environ.get("KEYCLOAK_CLIENT_SECRET")
self.username = self.username or os.environ.get("KEYCLOAK_USERNAME", "admin")
self.password = self.password or os.environ.get("KEYCLOAK_PASSWORD", "admin")


def get_keycloak_access_token(settings: KeycloakSettings) -> str:
token_url = f"{settings.keycloak_url}/realms/{settings.realm}/protocol/openid-connect/token"
if settings.client_secret:
data = {
"grant_type": "client_credentials",
"client_id": settings.client_id,
"client_secret": settings.client_secret,
"scope": settings.scope,
}
else:
data = {
"grant_type": "password",
"client_id": settings.client_id,
"username": settings.username,
"password": settings.password,
"scope": settings.scope,
}
try:
logger.debug(f"Getting Keycloak token from {token_url}")
resp = requests.post(token_url, data=data)
resp.raise_for_status()
return resp.json()["access_token"]
except Exception as e:
raise MCTUtilsException("Failed to get Keycloak access token", e)


@dataclass
class MCTObj(ABC):
Expand Down Expand Up @@ -224,7 +275,7 @@ class MedCATTrainerSession:
>>> annotations = session.get_project_annos(projects[0])
"""

def __init__(self, server=None, username=None, password=None):
def __init__(self, server=None, username=None, password=None, keycloak_settings=None, use_oidc: bool = False):
"""Initialize the MedCATTrainerSession.

Args:
Expand All @@ -237,15 +288,31 @@ def __init__(self, server=None, username=None, password=None):
self.password = password or os.getenv("MCTRAINER_PASSWORD")
self.server = server or 'http://localhost:8001'

env_use_oidc = os.getenv("MCTRAINER_USE_OIDC", "")
env_use_oidc_truthy = env_use_oidc.strip() == "1"
effective_use_oidc = bool(use_oidc) or bool(env_use_oidc_truthy)

if effective_use_oidc:
if keycloak_settings is None:
kc_settings = KeycloakSettings()
else:
if not isinstance(keycloak_settings, KeycloakSettings):
raise TypeError("keycloak_settings must be a KeycloakSettings instance")
kc_settings = keycloak_settings

token = get_keycloak_access_token(kc_settings)
self.headers = {"Authorization": f"Bearer {token}"}
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the early return could bite us in the bum later down the road.
I.e I feel like the 2 different auth / header getting methods should be moved outside the __init__ and called in if/else manner.


payload = {"username": self.username, "password": self.password}
resp = requests.post(f"{self.server}/api/api-token-auth/", json=payload)
if 200 <= resp.status_code < 300:
token = json.loads(resp.text)["token"]
self.headers = {
'Authorization': f'Token {token}',
"Authorization": f"Token {token}",
}
else:
raise MCTUtilsException(f'Failed to login to MedCATtrainer instance running at: {self.server}')
raise MCTUtilsException(f"Failed to login to MedCATtrainer instance running at: {self.server}")

def create_project(self, name: str,
description: str,
Expand Down Expand Up @@ -482,8 +549,23 @@ def get_users(self) -> List[MCTUser]:
Returns:
List[MCTUser]: A list of all users in the MedCATTrainer instance
"""
users = json.loads(requests.get(f'{self.server}/api/users/', headers=self.headers).text)['results']
return [MCTUser(id=u['id'], username=u['username']) for u in users]
resp = requests.get(f"{self.server}/api/users/", headers=self.headers)
if not (200 <= resp.status_code < 300):
raise MCTUtilsException(
f"Failed to get users from MedCATTrainer instance running at: {self.server}",
f"HTTP {resp.status_code}: {resp.text[:500]}",
)
try:
payload = resp.json()
if not isinstance(payload, dict):
payload = json.loads(resp.text or "{}")
except Exception as e:
raise MCTUtilsException(
f"Failed to parse users response from MedCATTrainer instance running at: {self.server}",
e,
)
users = payload.get("results", [])
return [MCTUser(id=u.get("id"), username=u.get("username")) for u in users]

def get_models(self) -> Tuple[List[str], List[str]]:
"""Get all MedCAT cdb and vocab models in the MedCATTrainer instance.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os
import unittest

from mctclient import MedCATTrainerSession


class TestMCTClientIntegration(unittest.TestCase):
@unittest.skipUnless(
os.getenv("MCTRAINER_SERVER") and os.getenv("MCTRAINER_USERNAME") and os.getenv("MCTRAINER_PASSWORD"),
"Integration test requires MCTRAINER_SERVER, MCTRAINER_USERNAME, MCTRAINER_PASSWORD",
)
def test_get_users_contains_expected_users(self):
server = os.getenv("MCTRAINER_SERVER").rstrip("/")
username = os.getenv("MCTRAINER_USERNAME")
password = os.getenv("MCTRAINER_PASSWORD")
expected_users_env = os.getenv("MCTRAINER_EXPECTED_USERS", "")

expected_users = {username}
if expected_users_env:
expected_users |= {u.strip() for u in expected_users_env.split(",") if u.strip()}

session = MedCATTrainerSession(server=server, username=username, password=password)
users = session.get_users()

self.assertIsInstance(users, list)
self.assertGreater(len(users), 0, "Expected at least one user from API")

usernames = {u.username for u in users}
missing = expected_users - usernames
self.assertFalse(missing, f"Expected user(s) missing from API: {sorted(missing)}")

53 changes: 52 additions & 1 deletion medcat-trainer/client/tests/test_mctclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,62 @@
import unittest
from unittest.mock import patch, MagicMock
from mctclient import (
MedCATTrainerSession, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject
MedCATTrainerSession, KeycloakSettings,
MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject
)

class TestMCTClient(unittest.TestCase):

@patch('mctclient.requests.post')
def test_session_init_with_oidc_sets_bearer_header(self, mock_post):
def post_side_effect(url, *args, **kwargs):
if url.endswith('/protocol/openid-connect/token'):
return MagicMock(status_code=200, json=lambda: {"access_token": "jwt"})
return MagicMock(status_code=404, text='')

mock_post.side_effect = post_side_effect

session = MedCATTrainerSession(
server='http://localhost',
username='u',
password='p',
use_oidc=True,
keycloak_settings=KeycloakSettings(
keycloak_url="http://keycloak.example",
realm="test-realm",
client_id="client",
username="kc-user",
password="kc-pass",
),
)
self.assertEqual(session.headers, {"Authorization": "Bearer jwt"})

@patch('mctclient.requests.post')
def test_session_init_with_oidc_client_secret_uses_client_credentials_grant(self, mock_post):
def post_side_effect(url, *args, **kwargs):
if url.endswith('/protocol/openid-connect/token'):
self.assertEqual(kwargs.get("data", {}).get("grant_type"), "client_credentials")
self.assertEqual(kwargs.get("data", {}).get("client_id"), "client")
self.assertEqual(kwargs.get("data", {}).get("client_secret"), "secret")
return MagicMock(status_code=200, json=lambda: {"access_token": "jwt"})
return MagicMock(status_code=404, text='')

mock_post.side_effect = post_side_effect

session = MedCATTrainerSession(
server='http://localhost',
username='u',
password='p',
use_oidc=True,
keycloak_settings=KeycloakSettings(
keycloak_url="http://keycloak.example",
realm="test-realm",
client_id="client",
client_secret="secret",
),
)
self.assertEqual(session.headers, {"Authorization": "Bearer jwt"})

@patch('mctclient.requests.post')
@patch('mctclient.requests.get')
def test_session_get_projects(self, mock_get, mock_post):
Expand Down
Loading