Skip to content

Commit 1d9050b

Browse files
committed
Remove reliance on locally stored CITATION.cff file:
- Use `requests` to get the `CITATION.cff` file from GitHub rather than locally in the project directory - If there are network issues display a minimal splash screen with just ASCII art - Update unit tests to ensure all new functionality is covered
1 parent f5c0729 commit 1d9050b

2 files changed

Lines changed: 208 additions & 72 deletions

File tree

CodeEntropy/run.py

Lines changed: 81 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pickle
44

55
import MDAnalysis as mda
6+
import requests
67
import yaml
78
from art import text2art
89
from MDAnalysis.analysis.base import AnalysisFromFunction
@@ -95,71 +96,91 @@ def create_job_folder():
9596
# Return the path of the newly created folder
9697
return new_folder_path
9798

98-
def load_citation_data(self, path="CITATION.cff"):
99-
""""""
100-
with open(path, "r", encoding="utf-8") as file:
101-
return yaml.safe_load(file)
99+
def load_citation_data(self):
100+
"""
101+
Load CITATION.cff from GitHub into memory.
102+
Return empty dict if offline.
103+
"""
104+
url = (
105+
"https://raw.githubusercontent.com/CCPBioSim/"
106+
"CodeEntropy/refs/heads/main/CITATION.cffs"
107+
)
108+
try:
109+
response = requests.get(url, timeout=10)
110+
response.raise_for_status()
111+
return yaml.safe_load(response.text)
112+
except requests.exceptions.RequestException:
113+
return None
102114

103115
def show_splash(self):
104-
""""""
116+
"""Render splash screen with optional citation metadata."""
105117
citation = self.load_citation_data()
106118

107-
# ASCII Title
108-
ascii_title = text2art(citation.get("title", "CodeEntropy"))
109-
ascii_render = Align.center(Text(ascii_title, style="bold white"))
110-
111-
# Metadata
112-
version = citation.get("version", "?")
113-
release_date = citation.get("date-released", "?")
114-
url = citation.get("url", citation.get("repository-code", ""))
115-
116-
version_text = Align.center(
117-
Text(f"Version {version} | Released {release_date}", style="green")
118-
)
119-
url_text = Align.center(Text(url, style="blue underline"))
120-
121-
# Description block
122-
abstract = citation.get("abstract", "No description available.")
123-
description_title = Align.center(
124-
Text("Description", style="bold magenta underline")
125-
)
126-
description_body = Align.center(
127-
Padding(Text(abstract, style="white", justify="left"), (0, 4))
128-
)
129-
130-
# Contributors table
131-
contributors_title = Align.center(
132-
Text("Contributors", style="bold magenta underline")
133-
)
119+
if citation:
120+
# ASCII Title
121+
ascii_title = text2art(citation.get("title", "CodeEntropy"))
122+
ascii_render = Align.center(Text(ascii_title, style="bold white"))
123+
124+
# Metadata
125+
version = citation.get("version", "?")
126+
release_date = citation.get("date-released", "?")
127+
url = citation.get("url", citation.get("repository-code", ""))
128+
129+
version_text = Align.center(
130+
Text(f"Version {version} | Released {release_date}", style="green")
131+
)
132+
url_text = Align.center(Text(url, style="blue underline"))
133+
134+
# Description block
135+
abstract = citation.get("abstract", "No description available.")
136+
description_title = Align.center(
137+
Text("Description", style="bold magenta underline")
138+
)
139+
description_body = Align.center(
140+
Padding(Text(abstract, style="white", justify="left"), (0, 4))
141+
)
142+
143+
# Contributors table
144+
contributors_title = Align.center(
145+
Text("Contributors", style="bold magenta underline")
146+
)
147+
148+
author_table = Table(
149+
show_header=True, header_style="bold yellow", box=None, pad_edge=False
150+
)
151+
author_table.add_column("Name", style="bold", justify="center")
152+
author_table.add_column("Affiliation", justify="center")
153+
154+
for author in citation.get("authors", []):
155+
name = (
156+
f"{author.get('given-names', '')} {author.get('family-names', '')}"
157+
).strip()
158+
affiliation = author.get("affiliation", "")
159+
author_table.add_row(name, affiliation)
160+
161+
contributors_table = Align.center(Padding(author_table, (0, 4)))
162+
163+
# Full layout
164+
splash_content = Group(
165+
ascii_render,
166+
Rule(style="cyan"),
167+
version_text,
168+
url_text,
169+
Text(),
170+
description_title,
171+
description_body,
172+
Text(),
173+
contributors_title,
174+
contributors_table,
175+
)
176+
else:
177+
# ASCII Title
178+
ascii_title = text2art("CodeEntropy")
179+
ascii_render = Align.center(Text(ascii_title, style="bold white"))
134180

135-
author_table = Table(
136-
show_header=True, header_style="bold yellow", box=None, pad_edge=False
137-
)
138-
author_table.add_column("Name", style="bold", justify="center")
139-
author_table.add_column("Affiliation", justify="center")
140-
141-
for author in citation.get("authors", []):
142-
name = (
143-
f"{author.get('given-names', '')} {author.get('family-names', '')}"
144-
).strip()
145-
affiliation = author.get("affiliation", "")
146-
author_table.add_row(name, affiliation)
147-
148-
contributors_table = Align.center(Padding(author_table, (0, 4)))
149-
150-
# Full layout
151-
splash_content = Group(
152-
ascii_render,
153-
Rule(style="cyan"),
154-
version_text,
155-
url_text,
156-
Text(),
157-
description_title,
158-
description_body,
159-
Text(),
160-
contributors_title,
161-
contributors_table,
162-
)
181+
splash_content = Group(
182+
ascii_render,
183+
)
163184

164185
splash_panel = Panel(
165186
splash_content,

tests/test_CodeEntropy/test_run.py

Lines changed: 127 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
import shutil
33
import tempfile
44
import unittest
5+
from io import StringIO
56
from unittest.mock import MagicMock, mock_open, patch
67

78
import numpy as np
9+
import requests
10+
import yaml
11+
from rich.console import Console
812

913
from CodeEntropy.run import RunManager
1014

@@ -45,8 +49,8 @@ def setup_citation_file(self, mock_file):
4549
"""
4650
citation_content = """\
4751
authors:
48-
- given-names: Name1
49-
family-names: Name2
52+
- given-names: Alice
53+
family-names: Smith
5054
"""
5155

5256
mock_file.return_value = mock_open(read_data=citation_content).return_value
@@ -122,18 +126,129 @@ def test_create_job_folder_with_invalid_job_suffix(
122126
self.assertEqual(new_folder_path, expected_path)
123127
mock_makedirs.assert_called_once_with(expected_path, exist_ok=True)
124128

125-
@patch("builtins.open", new_callable=mock_open)
126-
def test_load_citation_data(self, mock_file):
129+
@patch("requests.get")
130+
def test_load_citation_data_success(self, mock_get):
131+
"""Should return parsed dict when CITATION.cff loads successfully."""
132+
mock_yaml = """
133+
authors:
134+
- given-names: Alice
135+
family-names: Smith
136+
title: TestProject
137+
version: 1.0
138+
date-released: 2025-01-01
127139
"""
128-
Test loading the citation data from CITATION.cff.
129-
"""
130-
self.setup_citation_file(mock_file)
131-
instance = RunManager("dummy") # replace with your class
132-
data = instance.load_citation_data("CITATION.cff")
140+
mock_response = MagicMock()
141+
mock_response.status_code = 200
142+
mock_response.text = mock_yaml
143+
mock_get.return_value = mock_response
144+
145+
instance = RunManager("dummy")
146+
data = instance.load_citation_data()
147+
148+
self.assertIsInstance(data, dict)
149+
self.assertEqual(data["title"], "TestProject")
150+
self.assertEqual(data["authors"][0]["given-names"], "Alice")
151+
152+
@patch("requests.get")
153+
def test_load_citation_data_network_error(self, mock_get):
154+
"""Should return None if network request fails."""
155+
mock_get.side_effect = requests.exceptions.ConnectionError("Network down")
156+
157+
instance = RunManager("dummy")
158+
data = instance.load_citation_data()
159+
160+
self.assertIsNone(data)
161+
162+
@patch("requests.get")
163+
def test_load_citation_data_http_error(self, mock_get):
164+
"""Should return None if HTTP response is non-200."""
165+
mock_response = MagicMock()
166+
mock_response.status_code = 404
167+
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError()
168+
mock_get.return_value = mock_response
169+
170+
instance = RunManager("dummy")
171+
data = instance.load_citation_data()
172+
173+
self.assertIsNone(data)
174+
175+
@patch("requests.get")
176+
def test_load_citation_data_invalid_yaml(self, mock_get):
177+
"""Should raise YAML error if file content is invalid YAML."""
178+
mock_response = MagicMock()
179+
mock_response.status_code = 200
180+
mock_response.text = "invalid: [oops"
181+
mock_get.return_value = mock_response
182+
183+
instance = RunManager("dummy")
184+
185+
with self.assertRaises(yaml.YAMLError):
186+
instance.load_citation_data()
187+
188+
@patch.object(RunManager, "load_citation_data")
189+
def test_show_splash_with_citation(self, mock_load):
190+
"""Should render full splash screen when citation data is present."""
191+
mock_load.return_value = {
192+
"title": "TestProject",
193+
"version": "1.0",
194+
"date-released": "2025-01-01",
195+
"url": "https://example.com",
196+
"abstract": "This is a test abstract.",
197+
"authors": [
198+
{"given-names": "Alice", "family-names": "Smith", "affiliation": "Uni"}
199+
],
200+
}
201+
202+
buf = StringIO()
203+
test_console = Console(file=buf, force_terminal=False, width=80)
204+
205+
instance = RunManager("dummy")
206+
with patch("CodeEntropy.run.console", test_console):
207+
instance.show_splash()
208+
209+
output = buf.getvalue()
210+
211+
self.assertIn("Version 1.0", output)
212+
self.assertIn("2025-01-01", output)
213+
self.assertIn("https://example.com", output)
214+
self.assertIn("This is a test abstract.", output)
215+
self.assertIn("Alice Smith", output)
216+
217+
@patch.object(RunManager, "load_citation_data", return_value=None)
218+
def test_show_splash_without_citation(self, mock_load):
219+
"""Should render minimal splash screen when no citation data."""
220+
buf = StringIO()
221+
test_console = Console(file=buf, force_terminal=False, width=80)
222+
223+
instance = RunManager("dummy")
224+
with patch("CodeEntropy.run.console", test_console):
225+
instance.show_splash()
226+
227+
output = buf.getvalue()
228+
229+
self.assertNotIn("Version", output)
230+
self.assertNotIn("Contributors", output)
231+
self.assertIn("Welcome to CodeEntropy", output)
232+
233+
@patch.object(RunManager, "load_citation_data")
234+
def test_show_splash_missing_fields(self, mock_load):
235+
"""Should gracefully handle missing optional fields in citation data."""
236+
mock_load.return_value = {
237+
"title": "PartialProject",
238+
# no version, no date, no authors, no abstract
239+
}
240+
241+
buf = StringIO()
242+
test_console = Console(file=buf, force_terminal=False, width=80)
243+
244+
instance = RunManager("dummy")
245+
with patch("CodeEntropy.run.console", test_console):
246+
instance.show_splash()
247+
248+
output = buf.getvalue()
133249

134-
self.assertIn("authors", data)
135-
self.assertEqual(data["authors"][0]["given-names"], "Name1")
136-
self.assertEqual(data["authors"][0]["family-names"], "Name2")
250+
self.assertIn("Version ?", output)
251+
self.assertIn("No description available.", output)
137252

138253
def test_run_entropy_workflow(self):
139254
"""

0 commit comments

Comments
 (0)