Skip to content

Commit febd86b

Browse files
committed
feat: load login infos from configuration file
1 parent 6822ec3 commit febd86b

File tree

7 files changed

+575
-27
lines changed

7 files changed

+575
-27
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
11
# DataSHIELD Interface Python
22

33
This DataSHIELD Client Interface is a Python port of the original DataSHIELD Client Interface written in R ([DSI](https://github.com/datashield/DSI)). The provided interface can be implemented for accessing a data repository supporting the DataSHIELD infrastructure: controlled R commands to be executed on the server side are garanteeing that non disclosive information is returned to client side.
4+
5+
## Configuration
6+
7+
The search path for the DataSHIELD configuration file is the following:
8+
9+
1. Current project specific location: `./.datashield/config.yaml`
10+
2. User general location: `~/.datashield/config.yaml`
11+
12+
The format of the DataSHIELD configuration file is:
13+
14+
```yaml
15+
servers:
16+
- name: server1
17+
url: https://opal-demo.obiba.org
18+
user: dsuser
19+
password: P@ssw0rd
20+
- name: server2
21+
url: https://opal.example.org
22+
token: your-access-token-here
23+
profile: default
24+
- name: server3
25+
url: https://study.example.org/opal
26+
user: dsuser
27+
password: P@ssw0rd
28+
profile: custom
29+
driver: datashield_opal.OpalDriver
30+
```
31+
32+
Each server entry in the list must have:
33+
- `name`: Unique identifier for the server
34+
- `url`: The server URL
35+
- Authentication: Either `user` and `password`, or `token` (recommended)
36+
- `profile`: DataSHIELD profile name (optional, defaults to "default")
37+
- `driver`: Connection driver class name (optional, defaults to "datashield_opal.OpalDriver")

datashield/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datashield.interface import (
22
DSConnection as DSConnection,
3+
DSConfig as DSConfig,
34
DSLoginInfo as DSLoginInfo,
45
DSDriver as DSDriver,
56
DSError as DSError,

datashield/api.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,51 @@
33
"""
44

55
import logging
6-
from datashield.interface import DSLoginInfo, DSConnection, DSDriver, DSError
6+
import os
7+
from datashield.interface import DSConfig, DSLoginInfo, DSConnection, DSDriver, DSError
78
import time
89

10+
# Default configuration file paths to look for DataSHIELD login information, in order of precedence
11+
CONFIG_FILES = ["./.datashield/config.yaml", "~/.datashield/config.yaml"]
12+
913

1014
class DSLoginBuilder:
1115
"""
1216
Helper class to formalize DataSHIELD login arguments for a set of servers.
1317
"""
1418

15-
def __init__(self):
19+
def __init__(self, names: list[str] = None):
20+
"""Create a builder, optionally loading login information from configuration files
21+
for the specified server names.
22+
23+
:param names: The list of server names to load from configuration files, if any. If not defined,
24+
no login information will be loaded from configuration files.
25+
"""
1626
self.items: list[DSLoginInfo] = []
27+
# load login information from configuration files, in order of precedence
28+
if names is not None and len(names) > 0:
29+
# load configuration only once, do not merge results from multiple config files
30+
loaded = False
31+
for config_file in CONFIG_FILES:
32+
if loaded:
33+
break
34+
try:
35+
# check file exists and is readable, if not, silently ignore
36+
if not os.path.exists(config_file):
37+
continue
38+
if not os.access(config_file, os.R_OK):
39+
continue
40+
config = DSConfig.load_from_file(config_file)
41+
loaded = True
42+
if config.servers:
43+
items = [x for x in config.servers if x.name in names]
44+
if len(items) == 0:
45+
logging.warning(f"No matching server names found in {config_file} for: {', '.join(names)}")
46+
else:
47+
self.items.extend(items)
48+
except Exception as e:
49+
# silently ignore errors, e.g. file not found or invalid format
50+
logging.error(f"Failed to load login information from {config_file}: {e}")
1751

1852
def add(
1953
self,
@@ -46,7 +80,9 @@ def add(
4680
raise ValueError(f"Server name must be unique: {name}")
4781
if user is None and token is None:
4882
raise ValueError("Either user or token must be provided")
49-
self.items.append(DSLoginInfo(name, url, user, password, token, profile, driver))
83+
self.items.append(
84+
DSLoginInfo(name=name, url=url, user=user, password=password, token=token, profile=profile, driver=driver)
85+
)
5086
return self
5187

5288
def remove(self, name: str):
@@ -109,7 +145,7 @@ def open(self, restore: str = None, failSafe: bool = False) -> None:
109145
raise e
110146
if self.has_errors():
111147
for name in self.errors:
112-
print(f"Connection to {name} has failed")
148+
logging.error(f"Connection to {name} has failed")
113149

114150
def close(self, save: str = None) -> None:
115151
"""

datashield/interface.py

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,50 @@
33
"""
44

55
import importlib
6+
import yaml
7+
from pydantic import BaseModel, Field
68

79

8-
class DSLoginInfo:
10+
class DSLoginInfo(BaseModel):
911
"""
1012
Helper class with DataSHIELD login details.
1113
"""
1214

13-
def __init__(
14-
self,
15-
name: str,
16-
url: str,
17-
user: str = None,
18-
password: str = None,
19-
token: str = None,
20-
profile: str = "default",
21-
driver: str = "datashield_opal.OpalDriver",
22-
):
23-
self.items = []
24-
self.name = name
25-
self.url = url
26-
self.user = user
27-
self.password = password
28-
self.token = token
29-
self.profile = profile if profile is not None else "default"
30-
self.driver = driver if driver is not None else "datashield_opal.OpalDriver"
15+
name: str
16+
url: str
17+
user: str | None = None
18+
password: str | None = None
19+
token: str | None = None
20+
profile: str = "default"
21+
driver: str = "datashield_opal.OpalDriver"
22+
23+
model_config = {"extra": "forbid"}
24+
25+
26+
class DSConfig(BaseModel):
27+
"""
28+
Helper class with DataSHIELD configuration details.
29+
"""
30+
31+
servers: list[DSLoginInfo] = Field(default_factory=list)
32+
33+
model_config = {"extra": "forbid"}
34+
35+
@classmethod
36+
def load_from_file(cls, file: str) -> "DSConfig":
37+
"""
38+
Load the DataSHIELD configuration from a YAML file. The file must contain a list of servers with their login details.
39+
40+
:param file: The path to the YAML file containing the DataSHIELD configuration
41+
:return: The DataSHIELD configuration object
42+
"""
43+
with open(file) as f:
44+
config_data = yaml.safe_load(f)
45+
46+
if config_data is None:
47+
config_data = {}
48+
49+
return cls.model_validate(config_data)
3150

3251

3352
class DSResult:
@@ -409,7 +428,7 @@ def new_connection(cls, args: DSLoginInfo, restore: str = None) -> DSConnection:
409428
raise NotImplementedError("DSConnection function not available")
410429

411430
@classmethod
412-
def load_class(cls, name: str) -> any:
431+
def load_class(cls, name: str) -> type["DSDriver"]:
413432
"""
414433
Load a class from its fully qualified name (dot separated).
415434

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "datashield"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
description = "DataSHIELD Client Interface in Python."
55
authors = [
66
{name = "Yannick Marcon", email = "yannick.marcon@obiba.org"}
@@ -22,7 +22,10 @@ classifiers = [
2222
"Programming Language :: Python :: 3.12",
2323
"Programming Language :: Python :: 3.13",
2424
]
25-
dependencies = []
25+
dependencies = [
26+
"pydantic>=2.0",
27+
"PyYAML>=6.0",
28+
]
2629

2730
[project.optional-dependencies]
2831
test = [

0 commit comments

Comments
 (0)