Skip to content

Commit fb5b1e1

Browse files
committed
feat: added config load tests and code cleaning
1 parent ba562dd commit fb5b1e1

File tree

3 files changed

+261
-16
lines changed

3 files changed

+261
-16
lines changed

datashield/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ def __init__(self, names: list[str] = None):
2323
# load login information from configuration files, in order of precedence
2424
if names is not None and len(names) > 0:
2525
config = DSConfig.load()
26+
name_set = set(names)
2627
if config.servers:
27-
items = [x for x in config.servers if x.name in names]
28+
items = [x for x in config.servers if x.name in name_set]
2829
if len(items) == 0:
2930
logging.warning(f"No matching server names found in configuration for: {', '.join(names)}")
3031
else:

datashield/interface.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
import os
88
import yaml
9-
from pydantic import BaseModel, Field
9+
from pydantic import BaseModel, Field, model_validator
1010

1111
# Default configuration file paths to look for DataSHIELD login information, in order of precedence
1212
CONFIG_FILES = ["~/.config/datashield/config.yaml", "./.datashield/config.yaml"]
@@ -27,6 +27,12 @@ class DSLoginInfo(BaseModel):
2727

2828
model_config = {"extra": "forbid"}
2929

30+
@model_validator(mode="after")
31+
def validate_credentials(self) -> "DSLoginInfo":
32+
if self.user is None and self.token is None:
33+
raise ValueError("Either user or token must be provided")
34+
return self
35+
3036

3137
class DSConfig(BaseModel):
3238
"""
@@ -40,22 +46,26 @@ class DSConfig(BaseModel):
4046
@classmethod
4147
def load(cls) -> "DSConfig":
4248
"""
43-
Load the DataSHIELD configuration from default configuration files. The file must contain
44-
a list of servers with their login details. The configuration from the first file found will be loaded,
45-
in order of precedence. If multiple files are found, the configurations will be merged, with new server
46-
details replacing existing ones by name.
49+
Load the DataSHIELD configuration from the default configuration files.
50+
Each file must contain a list of servers with their login details.
51+
All readable configuration files listed in ``CONFIG_FILES`` are processed in
52+
order. Their configurations are merged, with servers identified by their
53+
``name`` field. If the same server name appears in multiple files, the
54+
definition from the later file in the list takes precedence and replaces
55+
the earlier one. Servers that are only present in earlier files are kept.
4756
4857
:return: The DataSHIELD configuration object
4958
"""
5059
merged_config = None
5160
for config_file in CONFIG_FILES:
5261
try:
5362
# check file exists and is readable, if not, silently ignore
54-
if not os.path.exists(config_file):
63+
path = os.path.expanduser(config_file)
64+
if not os.path.exists(path):
5565
continue
56-
if not os.access(config_file, os.R_OK):
66+
if not os.access(path, os.R_OK):
5767
continue
58-
config = cls.load_from_file(config_file)
68+
config = cls.load_from_file(path)
5969
if merged_config is None:
6070
merged_config = config
6171
else:
@@ -64,9 +74,9 @@ def load(cls) -> "DSConfig":
6474
for server in config.servers:
6575
existing_servers[server.name] = server
6676
merged_config.servers = list(existing_servers.values())
67-
except Exception as e:
68-
# silently ignore errors, e.g. file not found or invalid format
69-
logging.error(f"Failed to load login information from {config_file}: {e}")
77+
except Exception:
78+
# log and ignore errors, e.g. file not found or invalid format
79+
logging.error(f"Failed to load login information from {config_file}")
7080
return merged_config if merged_config else cls()
7181

7282
@classmethod

tests/test_config.py

Lines changed: 238 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ def test_load_config_from_yaml():
1616
- name: server1
1717
url: https://opal-demo.obiba.org
1818
user: dsuser
19-
password: P@ssw0rd
19+
password: your-password-here
2020
- name: server2
2121
url: https://opal.example.org
2222
token: your-access-token-here
2323
profile: default
2424
- name: server3
2525
url: https://study.example.org/opal
2626
user: dsuser
27-
password: P@ssw0rd
27+
password: your-password-here
2828
profile: custom
2929
driver: datashield_opal.OpalDriver
3030
"""
@@ -46,7 +46,7 @@ def test_load_config_from_yaml():
4646
assert config.servers[0].name == "server1"
4747
assert config.servers[0].url == "https://opal-demo.obiba.org"
4848
assert config.servers[0].user == "dsuser"
49-
assert config.servers[0].password == "P@ssw0rd"
49+
assert config.servers[0].password == "your-password-here"
5050
assert config.servers[0].token is None
5151
assert config.servers[0].profile == "default"
5252
assert config.servers[0].driver == "datashield_opal.OpalDriver"
@@ -64,7 +64,7 @@ def test_load_config_from_yaml():
6464
assert config.servers[2].name == "server3"
6565
assert config.servers[2].url == "https://study.example.org/opal"
6666
assert config.servers[2].user == "dsuser"
67-
assert config.servers[2].password == "P@ssw0rd"
67+
assert config.servers[2].password == "your-password-here"
6868
assert config.servers[2].token is None
6969
assert config.servers[2].profile == "custom"
7070
assert config.servers[2].driver == "datashield_opal.OpalDriver"
@@ -179,6 +179,14 @@ def test_create_dslogininfo_directly():
179179
assert login.driver == "datashield_opal.OpalDriver"
180180

181181

182+
def test_create_dslogininfo_requires_user_or_token():
183+
"""Test that DSLoginInfo requires at least one credential method."""
184+
with pytest.raises(ValidationError) as exc_info:
185+
DSLoginInfo(name="test", url="https://example.org")
186+
187+
assert "either user or token must be provided" in str(exc_info.value).lower()
188+
189+
182190
def test_create_dsconfig_directly():
183191
"""Test creating DSConfig objects directly with Pydantic."""
184192
login1 = DSLoginInfo(name="server1", url="https://example1.org", user="user1", password="pass1")
@@ -229,3 +237,229 @@ def test_model_serialization():
229237
assert data["password"] == "testpass"
230238
assert data["profile"] == "default"
231239
assert data["driver"] == "datashield_opal.OpalDriver"
240+
241+
242+
def test_load_no_config_files(monkeypatch):
243+
"""Test loading configuration when no config files exist."""
244+
# Mock the CONFIG_FILES to point to non-existent paths
245+
monkeypatch.setattr(
246+
"datashield.interface.CONFIG_FILES", ["/nonexistent/path/config.yaml", "/another/nonexistent/path/config.yaml"]
247+
)
248+
249+
config = DSConfig.load()
250+
251+
assert config is not None
252+
assert len(config.servers) == 0
253+
254+
255+
def test_load_from_first_config_file(monkeypatch):
256+
"""Test loading configuration from the first config file found."""
257+
yaml_content = """
258+
servers:
259+
- name: server1
260+
url: https://example1.org
261+
user: user1
262+
password: pass1
263+
"""
264+
265+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
266+
f.write(yaml_content)
267+
first_config_file = f.name
268+
269+
try:
270+
# Mock CONFIG_FILES to use our temporary file
271+
monkeypatch.setattr("datashield.interface.CONFIG_FILES", [first_config_file, "/nonexistent/second/config.yaml"])
272+
273+
config = DSConfig.load()
274+
275+
assert config is not None
276+
assert len(config.servers) == 1
277+
assert config.servers[0].name == "server1"
278+
assert config.servers[0].url == "https://example1.org"
279+
assert config.servers[0].user == "user1"
280+
281+
finally:
282+
os.unlink(first_config_file)
283+
284+
285+
def test_load_from_second_config_file(monkeypatch):
286+
"""Test loading configuration from the second config file if first doesn't exist."""
287+
yaml_content = """
288+
servers:
289+
- name: server2
290+
url: https://example2.org
291+
user: user2
292+
token: token123
293+
"""
294+
295+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
296+
f.write(yaml_content)
297+
second_config_file = f.name
298+
299+
try:
300+
# Mock CONFIG_FILES to use our temporary file as second option
301+
monkeypatch.setattr("datashield.interface.CONFIG_FILES", ["/nonexistent/first/config.yaml", second_config_file])
302+
303+
config = DSConfig.load()
304+
305+
assert config is not None
306+
assert len(config.servers) == 1
307+
assert config.servers[0].name == "server2"
308+
assert config.servers[0].url == "https://example2.org"
309+
assert config.servers[0].token == "token123"
310+
311+
finally:
312+
os.unlink(second_config_file)
313+
314+
315+
def test_load_merge_multiple_config_files(monkeypatch):
316+
"""Test merging configurations from multiple config files."""
317+
yaml_content1 = """
318+
servers:
319+
- name: server1
320+
url: https://example1.org
321+
user: user1
322+
password: pass1
323+
- name: server2
324+
url: https://example2.org
325+
user: user2
326+
password: pass2
327+
"""
328+
329+
yaml_content2 = """
330+
servers:
331+
- name: server2
332+
url: https://example2-updated.org
333+
user: user2_updated
334+
password: pass2_updated
335+
- name: server3
336+
url: https://example3.org
337+
token: token123
338+
"""
339+
340+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f1:
341+
f1.write(yaml_content1)
342+
first_config_file = f1.name
343+
344+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f2:
345+
f2.write(yaml_content2)
346+
second_config_file = f2.name
347+
348+
try:
349+
# Mock CONFIG_FILES to use both our temporary files
350+
monkeypatch.setattr("datashield.interface.CONFIG_FILES", [first_config_file, second_config_file])
351+
352+
config = DSConfig.load()
353+
354+
assert config is not None
355+
assert len(config.servers) == 3
356+
357+
# Check server1 (from first file, unchanged)
358+
server1 = next(s for s in config.servers if s.name == "server1")
359+
assert server1.url == "https://example1.org"
360+
assert server1.user == "user1"
361+
362+
# Check server2 (from first file, but updated by second file)
363+
server2 = next(s for s in config.servers if s.name == "server2")
364+
assert server2.url == "https://example2-updated.org"
365+
assert server2.user == "user2_updated"
366+
assert server2.password == "pass2_updated"
367+
368+
# Check server3 (from second file only)
369+
server3 = next(s for s in config.servers if s.name == "server3")
370+
assert server3.url == "https://example3.org"
371+
assert server3.token == "token123"
372+
373+
finally:
374+
os.unlink(first_config_file)
375+
os.unlink(second_config_file)
376+
377+
378+
def test_load_handles_invalid_yaml_silently(monkeypatch):
379+
"""Test that load() silently handles invalid YAML files."""
380+
invalid_yaml_content = """
381+
servers:
382+
- name: server1
383+
url: https://example.org
384+
invalid: yaml: content:
385+
"""
386+
387+
valid_yaml_content = """
388+
servers:
389+
- name: server2
390+
url: https://example2.org
391+
user: user2
392+
password: pass2
393+
"""
394+
395+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f1:
396+
f1.write(invalid_yaml_content)
397+
invalid_config_file = f1.name
398+
399+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f2:
400+
f2.write(valid_yaml_content)
401+
valid_config_file = f2.name
402+
403+
try:
404+
# Mock CONFIG_FILES with invalid file first, then valid file
405+
monkeypatch.setattr("datashield.interface.CONFIG_FILES", [invalid_config_file, valid_config_file])
406+
407+
# Should not raise an exception, but load from the valid file
408+
config = DSConfig.load()
409+
410+
assert config is not None
411+
assert len(config.servers) == 1
412+
assert config.servers[0].name == "server2"
413+
assert config.servers[0].url == "https://example2.org"
414+
415+
finally:
416+
os.unlink(invalid_config_file)
417+
os.unlink(valid_config_file)
418+
419+
420+
def test_load_handles_permission_denied_silently(monkeypatch):
421+
"""Test that load() silently handles files without read permissions."""
422+
yaml_content1 = """
423+
servers:
424+
- name: server1
425+
url: https://example1.org
426+
user: user1
427+
password: pass1
428+
"""
429+
430+
yaml_content2 = """
431+
servers:
432+
- name: server2
433+
url: https://example2.org
434+
user: user2
435+
password: pass2
436+
"""
437+
438+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f1:
439+
f1.write(yaml_content1)
440+
no_read_file = f1.name
441+
442+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f2:
443+
f2.write(yaml_content2)
444+
readable_file = f2.name
445+
446+
try:
447+
# Remove read permissions from first file
448+
os.chmod(no_read_file, 0o000)
449+
450+
# Mock CONFIG_FILES with unreadable file first, then readable file
451+
monkeypatch.setattr("datashield.interface.CONFIG_FILES", [no_read_file, readable_file])
452+
453+
# Should not raise an exception, but load from the readable file
454+
config = DSConfig.load()
455+
456+
assert config is not None
457+
assert len(config.servers) == 1
458+
assert config.servers[0].name == "server2"
459+
assert config.servers[0].url == "https://example2.org"
460+
461+
finally:
462+
# Restore permissions to allow cleanup
463+
os.chmod(no_read_file, 0o644)
464+
os.unlink(no_read_file)
465+
os.unlink(readable_file)

0 commit comments

Comments
 (0)