Skip to content

Commit 373ba4e

Browse files
authored
Improve Coverage & Fix Some Bugs (#133)
This PR started as a coverage improvement. However, we found that config did not need to be async and simplified that flow. There was also a bug with `make run` (the sample config) introduced in #130 with the `DataFrame` to `TypedDataFrame` switch. This resolves all of that while bringing coverage to 96% (also ignoring main).
1 parent 945e327 commit 373ba4e

8 files changed

Lines changed: 190 additions & 98 deletions

File tree

.coveragerc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[run]
2+
omit =
3+
src/main.py

.github/workflows/coverage.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
- name: Install Requirements
4141
run: POETRY_VIRTUALENVS_CREATE=false python -m poetry install
4242
- name: Run tests with coverage
43-
run: pytest --cov=src --cov-report=html --cov-fail-under=90
43+
run: pytest --cov=src --cov-report=html --cov-fail-under=96
4444
# Environment variables used by the `pg_client.py`
4545
env:
4646
DB_URL: postgresql://postgres:postgres@localhost:5432/postgres

src/config.py

Lines changed: 25 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,16 @@
22

33
from __future__ import annotations
44

5-
import asyncio
65
import os
76
from dataclasses import dataclass
87
from io import StringIO
98
from pathlib import Path
109
from string import Template
1110
from typing import Any, TextIO
12-
from urllib.parse import urlsplit, urlunsplit
11+
from urllib.parse import urlsplit
1312

13+
import requests
1414
import yaml
15-
from aiohttp import ClientError, ClientResponseError
16-
from aiohttp.client import ClientSession
1715
from dotenv import load_dotenv
1816
from dune_client.query import QueryBase
1917

@@ -153,51 +151,38 @@ def _is_url(cls, path: str) -> bool:
153151
"""
154152
try:
155153
result = urlsplit(path)
156-
urlunsplit(result)
157-
if result.scheme and result.netloc:
158-
return True
159-
except (
160-
ValueError,
161-
TypeError,
162-
): # raised when not enough parts were given to unsplit -> not a URL probably
154+
return bool(result.scheme and result.netloc)
155+
except (TypeError, AttributeError): # raised when path isn't str-like
163156
return False
164157

165-
return False
166-
167158
@classmethod
168159
def _load_config_file(cls, file_path: Path | str) -> Any:
169160
with open(file_path, encoding="utf-8") as _handle:
170161
return cls.read_yaml(_handle)
171162

172-
@classmethod
173-
async def _download_config(cls, url: str) -> str | None:
174-
try:
175-
async with ClientSession() as session:
176-
async with session.get(url) as response:
177-
try:
178-
response.raise_for_status()
179-
except ClientResponseError as e:
180-
log.error(
181-
"Error fetching config from URL: %s",
182-
e,
183-
)
184-
return None
185-
186-
return await response.text()
187-
188-
except ClientError as e:
189-
log.error("Request failed: %s", e)
190-
return None
191-
192163
@classmethod
193164
def _load_config_url(cls, url: str) -> Any:
194-
loop = asyncio.get_event_loop()
195-
config_data = loop.run_until_complete(cls._download_config(url))
196-
if not config_data:
197-
raise SystemExit("Could not download config")
165+
"""Load configuration from a URL.
166+
167+
Args:
168+
url: The URL to fetch the configuration from
169+
170+
Returns:
171+
The parsed YAML configuration
172+
173+
Raises:
174+
SystemExit: If the configuration cannot be downloaded
175+
176+
"""
177+
try:
178+
response = requests.get(url, timeout=10)
179+
response.raise_for_status()
180+
config_data = response.text
181+
except requests.RequestException as e:
182+
log.error("Error fetching config from URL: %s", e)
183+
raise SystemExit("Could not download config") from e
198184

199-
pseudofile = StringIO(config_data)
200-
return cls.read_yaml(pseudofile)
185+
return cls.read_yaml(StringIO(config_data))
201186

202187
@classmethod
203188
def read_yaml(cls, file_handle: TextIO) -> Any:
@@ -323,7 +308,7 @@ def _build_destination(
323308
match dest.type:
324309
case Database.DUNE:
325310
try:
326-
request_timeout = dest_config["timeout"]
311+
request_timeout = dest_config["request_timeout"]
327312
request_timeout = int(request_timeout)
328313
except KeyError:
329314
log.debug("Dune request timeout not set: defaulting to 10")

src/destinations/dune.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
from dune_client.client import DuneClient
44
from dune_client.models import DuneError
5-
from pandas import DataFrame
65

7-
from src.interfaces import Destination
6+
from src.interfaces import Destination, TypedDataFrame
87
from src.logger import log
98

109

11-
class DuneDestination(Destination[DataFrame]):
10+
class DuneDestination(Destination[TypedDataFrame]):
1211
"""A class representing as Dune as a destination.
1312
1413
Uses the Dune API to upload CSV data to a table.
@@ -39,7 +38,7 @@ def validate(self) -> bool:
3938
"""
4039
return True
4140

42-
def save(self, data: DataFrame) -> int:
41+
def save(self, data: TypedDataFrame) -> int:
4342
"""Upload a DataFrame to Dune as a CSV.
4443
4544
Returns size of dataframe (i.e. number of "affected" rows).
@@ -61,7 +60,9 @@ def save(self, data: DataFrame) -> int:
6160
"""
6261
try:
6362
log.debug("Uploading DF to Dune...")
64-
result = self.client.upload_csv(self.table_name, data.to_csv(index=False))
63+
result = self.client.upload_csv(
64+
self.table_name, data.dataframe.to_csv(index=False)
65+
)
6566
if not result:
6667
raise RuntimeError("Dune Upload Failed")
6768
except DuneError as dune_e:
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
data_sources:
3+
- name: dune
4+
type: dune
5+
key: fake-key
6+
- name: postgres
7+
type: postgres
8+
key: postgresql://postgres:postgres@localhost:5432/postgres
9+
10+
11+
jobs:
12+
- name: Download simple test query to local postgres
13+
source:
14+
ref: dune
15+
query_id: 4238114
16+
query_engine: medium
17+
poll_frequency: 5
18+
destination:
19+
ref: dune
20+
table_name: test_table
21+
if_exists: replace
22+
request_timeout: word

tests/unit/config_test.py

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import os
22
import unittest
33
from datetime import datetime
4-
from unittest.mock import AsyncMock, MagicMock, patch
4+
from io import StringIO
5+
from unittest.mock import MagicMock, patch
56

67
import pytest
7-
from aiohttp import ClientError, ClientResponseError
8+
import requests
89
from dune_client.types import QueryParameter
910

1011
from src.config import Env, RuntimeConfig
@@ -64,6 +65,32 @@ def setUpClass(cls):
6465
def tearDownClass(cls):
6566
cls.env_patcher.stop()
6667

68+
def test_is_url(self):
69+
# Valid URLs
70+
assert RuntimeConfig._is_url("https://example.com") is True
71+
assert RuntimeConfig._is_url("http://localhost:8080") is True
72+
assert RuntimeConfig._is_url("ftp://files.example.com") is True
73+
assert RuntimeConfig._is_url("https://api.github.com/path?query=123") is True
74+
assert RuntimeConfig._is_url("sftp://user:pass@server.com:22") is True
75+
76+
# Invalid URLs
77+
assert RuntimeConfig._is_url("not-a-url") is False
78+
assert RuntimeConfig._is_url("") is False
79+
assert RuntimeConfig._is_url("file.txt") is False
80+
assert RuntimeConfig._is_url("/path/to/file") is False
81+
assert RuntimeConfig._is_url("C:\\Windows\\Path") is False
82+
assert RuntimeConfig._is_url("://missing-scheme.com") is False
83+
assert RuntimeConfig._is_url("http://") is False # Missing netloc
84+
85+
# Edge cases
86+
assert RuntimeConfig._is_url(None) is False # type: ignore
87+
assert RuntimeConfig._is_url("http:/example.com") is False # Missing slash
88+
assert RuntimeConfig._is_url("https:example.com") is False # Missing slashes
89+
90+
# Cases that actually trigger exceptions
91+
assert RuntimeConfig._is_url([1, 2, 3]) is False # TypeError: list is not str
92+
assert RuntimeConfig._is_url(123) is False # TypeError: int is not str
93+
6794
def test_load_basic_conf(self):
6895
config_file = config_root / "basic.yaml"
6996
conf = RuntimeConfig.load(config_file.absolute())
@@ -124,58 +151,63 @@ def test_load_buggy_conf(self):
124151
with self.assertRaises(SystemExit):
125152
RuntimeConfig.load(config_root / "no_data_sources.yaml")
126153

127-
@pytest.mark.asyncio
128-
async def test_successful_download(self):
129-
mock_response = AsyncMock(name="Mock GET Response")
130-
mock_response.text = AsyncMock(return_value="test_config_content")
131-
mock_response.raise_for_status.return_value = True
132-
mock_get = AsyncMock()
133-
mock_get.__aenter__.return_value = mock_response
134-
135-
with patch("src.config.ClientSession.get", return_value=mock_get):
136-
result = await RuntimeConfig._download_config("http://test.xyz")
137-
138-
self.assertEqual("test_config_content", result)
139-
mock_response.raise_for_status.assert_called_once()
140-
mock_response.text.assert_called_once()
141-
142-
@pytest.mark.asyncio
143-
async def test_http_error_response(self):
144-
error_response = ClientResponseError(
145-
request_info=None, history=None, status=404, message="Not Found"
146-
)
147-
mock_response = AsyncMock(name="Mock GET Response")
148-
mock_response.raise_for_status = MagicMock(
149-
side_effect=error_response, name="mock raise for status"
150-
)
151-
mock_get = AsyncMock()
152-
mock_get.__aenter__.return_value = mock_response
153-
154+
with self.assertRaises(ValueError):
155+
RuntimeConfig.load(config_root / "invalid_request_timeout.yaml")
156+
157+
def test_load_config_url(self):
158+
# Mock response for successful case
159+
mock_yaml_content = """
160+
data_sources:
161+
- name: test
162+
type: dune
163+
key: test_key
164+
jobs:
165+
- name: job1
166+
source:
167+
ref: test
168+
"""
169+
170+
# Test successful download
154171
with (
155-
patch("src.config.log") as mock_logger,
156-
patch("src.config.ClientSession.get", return_value=mock_get),
172+
patch("requests.get") as mock_get,
173+
patch("src.config.RuntimeConfig.read_yaml") as mock_read_yaml,
157174
):
158-
result = await RuntimeConfig._download_config(
159-
"http://test.thistldbetternotexist"
160-
)
175+
# Setup mock response
176+
mock_response = MagicMock()
177+
mock_response.text = mock_yaml_content
178+
mock_response.raise_for_status.return_value = None
179+
mock_get.return_value = mock_response
161180

162-
self.assertIsNone(result)
163-
mock_logger.error.assert_called_once_with(
164-
"Error fetching config from URL: %s", error_response
165-
)
181+
mock_read_yaml.return_value = {"test": "data"}
166182

167-
@pytest.mark.asyncio
168-
async def test_client_connection_error(self):
169-
with (
170-
patch("aiohttp.ClientSession", side_effect=ClientError("Connection error")),
171-
patch("src.config.log") as mock_logger,
172-
):
173-
result = await RuntimeConfig._download_config(
174-
"http://test.thistldbetternotexist"
183+
result = RuntimeConfig._load_config_url("https://example.com/config.yaml")
184+
185+
# Verify the URL was called with timeout
186+
mock_get.assert_called_once_with(
187+
"https://example.com/config.yaml", timeout=10
175188
)
189+
# Verify read_yaml was called with StringIO containing our mock content
190+
mock_read_yaml.assert_called_once()
191+
assert isinstance(mock_read_yaml.call_args[0][0], StringIO)
192+
assert result == {"test": "data"}
193+
194+
# Test HTTP error
195+
with patch("requests.get") as mock_get:
196+
mock_response = MagicMock()
197+
mock_response.raise_for_status.side_effect = requests.HTTPError(
198+
"404 Not Found"
199+
)
200+
mock_get.return_value = mock_response
201+
202+
with pytest.raises(SystemExit, match="Could not download config"):
203+
RuntimeConfig._load_config_url("https://example.com/config.yaml")
204+
205+
# Test network error
206+
with patch("requests.get") as mock_get:
207+
mock_get.side_effect = requests.RequestException("Network error")
176208

177-
assert result is None
178-
mock_logger.error.assert_called_once()
209+
with pytest.raises(SystemExit, match="Could not download config"):
210+
RuntimeConfig._load_config_url("https://example.com/config.yaml")
179211

180212

181213
class TestParseQueryParameters(unittest.TestCase):

tests/unit/destinations_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_ensure_index_disabled_when_uploading(self, mock_to_csv, *_):
3939
table_name="foo",
4040
request_timeout=10,
4141
)
42-
destination.save(dummy_df)
42+
destination.save(TypedDataFrame(dummy_df, {}))
4343
mock_to_csv.assert_called_once_with(index=False)
4444

4545
@patch("pandas.core.generic.NDFrame.to_csv", name="Fake csv writer")
@@ -55,7 +55,7 @@ def test_duneclient_sets_timeout(self, mock_to_csv, *_):
5555
@patch("dune_client.api.table.TableAPI.upload_csv", name="Fake CSV uploader")
5656
def test_dune_error_handling(self, mock_dune_upload_csv):
5757
dest = DuneDestination(api_key="f00b4r", table_name="foo", request_timeout=10)
58-
df = pd.DataFrame([{"foo": "bar"}])
58+
df = TypedDataFrame(pd.DataFrame([{"foo": "bar"}]), {})
5959

6060
dune_err = DuneError(
6161
data={"error": "bad stuff"},

0 commit comments

Comments
 (0)