initial commit
Python Code Quality / python-code-quality (push) Successful in 15s
Test Python Package / test (push) Failing after 16s

This commit is contained in:
Brian Bjarke Jensen
2025-10-30 00:00:02 +01:00
parent 2fd2e949bb
commit 1676fd10c9
20 changed files with 2385 additions and 12 deletions
+13
View File
@@ -0,0 +1,13 @@
name: Python Code Quality
on:
push:
branches:
- main
pull_request:
jobs:
python-code-quality:
uses: brian/CI-templates/.gitea/workflows/python/[email protected]
with:
python-version: ${{ vars.PYTHON_VERSION }}
+47
View File
@@ -0,0 +1,47 @@
name: Test Python Package
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Install uv
run: pip install uv
- name: Install dependencies
env:
UV_LINK_MODE: copy
run: uv sync --all-extras
- name: Run pytest
env:
PYTHONPATH: .
run: uv run pytest --cov=python_repositories --cov-report=term-missing > coverage.txt
- name: Post coverage summary to PR
env:
API_URL: ${{ vars.API_URL }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }}
run: |
COVERAGE=$(cat coverage.txt)
COMMENT_BODY="**Test Coverage Report:**\n\`\`\`\n$COVERAGE\n\`\`\`"
curl -s -X POST "$API_URL/repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments" \
-H "Authorization: token $CI_RUNNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"body\": \"$COMMENT_BODY\"}"
-1
View File
@@ -187,4 +187,3 @@ ipython_config.py
# Remove previous ipynb_checkpoints # Remove previous ipynb_checkpoints
# git rm -r .ipynb_checkpoints/ # git rm -r .ipynb_checkpoints/
+40
View File
@@ -0,0 +1,40 @@
repos:
# General repository hygiene hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: debug-statements
- id: name-tests-test
- id: check-merge-conflict
# Python linting and formatting with Ruff
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.8
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
# Static type checking with mypy
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
# Python syntax modernization with pyupgrade
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
hooks:
- id: pyupgrade
args: ["--py312-plus"]
# Formatting for Markdown, JSON, and YAML with Prettier
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
hooks:
- id: prettier
files: "\\.(md|json|yaml|yml)$"
+9 -9
View File
@@ -2,17 +2,17 @@ MIT License
Copyright (c) 2025 brian Copyright (c) 2025 brian
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions: following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software. portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE. USE OR OTHER DEALINGS IN THE SOFTWARE.
+26
View File
@@ -0,0 +1,26 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "258945b9",
"metadata": {
"vscode": {
"languageId": "plaintext"
}
},
"outputs": [],
"source": [
"import requests\n",
"\n"
]
}
],
"metadata": {
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+272 -2
View File
@@ -1,3 +1,273 @@
# elprisenligenu # ElprisenLigeNu Python API Client
Python module to interface with ElPrisenLigeNu's API. A Python library for accessing Danish electricity spot prices from the [ElprisenLigeNu.dk](https://www.elprisenligenu.dk) API.
[![Python Version](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://python.org)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
## Overview
ElprisenLigeNu provides real-time and historical Danish electricity spot prices for the two Danish price zones:
- **DK1** (West of Great Belt - Jutland and Funen)
- **DK2** (East of Great Belt - Zealand and Bornholm)
This library provides a simple Python interface to fetch hourly electricity prices with automatic timezone handling and type-safe data models.
## Features
- 🔌 Simple API for fetching electricity prices
- 🇩🇰 Support for both Danish price zones (DK1/DK2)
- ⏰ Automatic timezone handling (Europe/Copenhagen)
- 📊 Type-safe data models using Pydantic
- 💰 Prices in both DKK and EUR with exchange rates
- 🕐 Hourly granularity with start/end timestamps
## Installation
```bash
pip install elprisenligenu
```
Or using uv:
```bash
uv add elprisenligenu
```
## Quick Start
```python
from datetime import datetime
from elprisenligenu import API
# Initialize the API client
api = API()
# Get today's prices for Copenhagen area (DK2)
prices = api.get_daily_prices(zone="DK2")
# Get prices for Aarhus area (DK1)
prices = api.get_daily_prices(zone="DK1")
# Get prices for a specific date
from datetime import datetime
from zoneinfo import ZoneInfo
date = datetime(2024, 10, 29, tzinfo=ZoneInfo("Europe/Copenhagen"))
prices = api.get_daily_prices(zone="DK2", date=date)
# Work with the price data
for hour_data in prices:
print(f"Time: {hour_data.time_start} - {hour_data.time_end}")
print(f"Price: {hour_data.DKK_per_kWh:.4f} DKK/kWh")
print(f"Price: {hour_data.EUR_per_kWh:.4f} EUR/kWh")
print(f"Exchange rate: {hour_data.EXR:.4f}")
print("---")
```
## API Reference
### API Class
```python
from elprisenligenu import API
api = API()
```
#### Methods
##### `get_daily_prices(zone="DK1", date=datetime.now())`
Fetch hourly electricity prices for a specific day and zone.
**Parameters:**
- `zone` (str): Price zone, either "DK1" or "DK2". Default: "DK1"
- `date` (datetime): Date to fetch prices for. Must be timezone-aware. Default: current date
**Returns:**
- `list[HourData]`: List of hourly price data objects
**Raises:**
- `ValueError`: If zone is not "DK1" or "DK2", or if date is not timezone-aware
- `TypeError`: If date is not a datetime object
- `requests.HTTPError`: If the API request fails
### HourData Model
The `HourData` class represents electricity price data for a single hour:
```python
from elprisenligenu.dto import HourData
class HourData:
DKK_per_kWh: float # Price in Danish Kroner per kWh
EUR_per_kWh: float # Price in Euros per kWh
EXR: float # Exchange rate EUR to DKK
time_start: datetime # Hour start time
time_end: datetime # Hour end time
```
## Examples
### Find the cheapest and most expensive hours
```python
from elprisenligenu import API
api = API()
prices = api.get_daily_prices(zone="DK2")
# Find cheapest hour
cheapest = min(prices, key=lambda x: x.DKK_per_kWh)
print(f"Cheapest: {cheapest.DKK_per_kWh:.4f} DKK/kWh at {cheapest.time_start.hour}:00")
# Find most expensive hour
expensive = max(prices, key=lambda x: x.DKK_per_kWh)
print(f"Most expensive: {expensive.DKK_per_kWh:.4f} DKK/kWh at {expensive.time_start.hour}:00")
```
### Calculate daily average price
```python
from elprisenligenu import API
api = API()
prices = api.get_daily_prices(zone="DK1")
average_price = sum(hour.DKK_per_kWh for hour in prices) / len(prices)
print(f"Average price today: {average_price:.4f} DKK/kWh")
```
### Get price data for multiple days
```python
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from elprisenligenu import API
api = API()
timezone = ZoneInfo("Europe/Copenhagen")
# Get prices for the last 7 days
for i in range(7):
date = datetime.now(timezone) - timedelta(days=i)
try:
prices = api.get_daily_prices(zone="DK2", date=date)
avg_price = sum(hour.DKK_per_kWh for hour in prices) / len(prices)
print(f"{date.strftime('%Y-%m-%d')}: {avg_price:.4f} DKK/kWh")
except Exception as e:
print(f"Failed to get data for {date.strftime('%Y-%m-%d')}: {e}")
```
## Price Zones
Denmark is divided into two electricity price zones:
- **DK1 (West)**: Jutland and Funen (west of the Great Belt)
- Major cities: Aarhus, Aalborg, Odense, Esbjerg
- **DK2 (East)**: Zealand, Lolland, Falster, and Bornholm (east of the Great Belt)
- Major cities: Copenhagen, Roskilde, Helsingør
## Data Source
This library uses the free API provided by [ElprisenLigeNu.dk](https://www.elprisenligenu.dk). The electricity prices are:
- Spot prices from the Nord Pool electricity market
- Provided without VAT or additional fees
- Updated daily (tomorrow's prices available from 13:00 the day before)
- Historical data available from November 1, 2022
- Originally sourced from [ENTSO-E](https://transparency.entsoe.eu/) in EUR, converted to DKK
## Development
### Requirements
- Python 3.12+
- Dependencies: `pydantic>=2.12.3`, `requests>=2.32.5`
### Setup
```bash
# Clone the repository
git clone https://github.com/yourusername/elprisenligenu.git
cd elprisenligenu
# Install with development dependencies
uv sync --dev
# Run tests (unit tests only)
uv run pytest tests/unit/
# Run all tests including integration tests (requires internet)
uv run pytest
# Run tests with coverage
uv run pytest --cov=src --cov-report=html
# Type checking
uv run mypy .
# Linting
uv run ruff check .
# Formatting
uv run ruff format .
```
### Test Structure
The test suite is organized into:
- **Unit Tests** (`tests/unit/`): Fast tests that mock external dependencies
- `test_hour_data.py`: Tests for the HourData model
- `test_api.py`: Tests for the API class with mocked HTTP requests
- **Integration Tests** (`tests/test_integration.py`): Tests that make real API calls
- Marked with `@pytest.mark.integration`
- Can be skipped with `pytest -m "not integration"`
### Running Specific Tests
```bash
# Run only unit tests
uv run pytest tests/unit/
# Run only integration tests (requires internet)
uv run pytest -m integration
# Run tests matching a pattern
uv run pytest -k "test_hour_data"
# Run with verbose output
uv run pytest -v
# Run with coverage report
uv run pytest --cov=src --cov-report=term-missing
```
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Attribution
When using this library in public projects, please consider attributing the data source:
> Electricity prices provided by [ElprisenLigeNu.dk](https://www.elprisenligenu.dk)
## Support
- 🐛 [Report issues](https://github.com/yourusername/elprisenligenu/issues)
- 📧 Contact: [[email protected]](mailto:[email protected])
- 🌐 ElprisenLigeNu.dk: [[email protected]](mailto:[email protected])
## Related Projects
- [ElprisenLigeNu.dk](https://www.elprisenligenu.dk) - The source website
- [API Documentation](https://www.elprisenligenu.dk/elpris-api) - Official API docs
+51
View File
@@ -0,0 +1,51 @@
[project]
name = "elprisenligenu"
version = "0.1.0"
authors = [
{ name = "Brian Bjarke Jensen", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.12.3",
"requests>=2.32.5",
]
[dependency-groups]
dev = [
"ipykernel>=7.1.0",
"mypy>=1.18.2",
"pre-commit>=4.3.0",
"pytest>=8.4.2",
"pytest-cov>=7.0.0",
"python-dotenv>=1.2.1",
"pyupgrade>=3.21.0",
"ruff>=0.14.2",
"types-requests>=2.32.4.20250913",
]
[[tool.uv.index]]
name = "threadripper-proxpi-cache"
url = "http://10.0.0.2:5001/index/"
default = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.mypy]
python_version = "3.12"
mypy_path = "src"
warn_return_any = true # nudge to use stricter types
warn_unused_configs = true # nudge to remove unused configs
disallow_untyped_defs = true # disallow untyped function definitions
disallow_incomplete_defs = true # ensure all parts of a function has type annotation
check_untyped_defs = true # dont skip untyped functions
disallow_untyped_decorators = false # allow untyped decorators to keep code more readable
no_implicit_optional = true # disallow implicit optional types leading to None-mess
warn_redundant_casts = true # nudge to use explicit type casts
warn_unused_ignores = true # nudge to remove unused ignores
warn_no_return = true # catch missing return statements
warn_unreachable = true # catch unreachable code
show_error_codes = true # show error codes in output
explicit_package_bases = true # reduce risk of import confusion
namespace_packages = true # enable namespace packages
+3
View File
@@ -0,0 +1,3 @@
from .api import API
__all__ = ["API"]
+60
View File
@@ -0,0 +1,60 @@
"""Definition of the API class for interacting with the ElprisenLigeNu service."""
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from typing import Literal
import requests
from elprisenligenu.dto.hour_data import HourData
class API:
"""Class for interacting with the ElprisenLigeNu API."""
BASE_URL = "https://www.elprisenligenu.dk/api/v1/prices"
TIMEZONE = ZoneInfo("Europe/Copenhagen")
def __init__(self) -> None:
"""Initialize the API class."""
self.session = requests.Session()
self.logger = logging.getLogger(__name__)
@classmethod
def _endpoint(cls, date: datetime, zone: str) -> str:
"""Construct the API endpoint URL for the given date and zone."""
year_str = date.strftime("%Y")
date_str = date.strftime("%m-%d")
return f"{cls.BASE_URL}/{year_str}/{date_str}_{zone}.json"
def get_daily_prices(
self,
zone: Literal["DK1", "DK2"] = "DK1",
date: datetime = datetime.now(tz=TIMEZONE),
) -> list[HourData]:
"""Fetch the daily electricity prices.
Returns:
list[HourData]: A list of HourData objects containing the daily electricity price data.
"""
# Check inputs
if zone not in ["DK1", "DK2"]:
raise ValueError("zone must be either 'DK1' or 'DK2'")
if not isinstance(date, datetime):
raise TypeError("date must be a datetime object")
if date.tzinfo is None:
raise ValueError("date must be timezone-aware")
if date.tzinfo != self.TIMEZONE:
self.logger.info("Converting date to %s timezone", self.TIMEZONE)
date = date.astimezone(self.TIMEZONE)
# Make request
endpoint = self._endpoint(date, zone)
response = self.session.get(endpoint)
response.raise_for_status()
# Convert response to list of HourData
data = [HourData(**entry) for entry in response.json()]
return data
+3
View File
@@ -0,0 +1,3 @@
from .hour_data import HourData
__all__ = ["HourData"]
+14
View File
@@ -0,0 +1,14 @@
"""Definition of HourData DTO for ElprisenLigeNu service."""
from datetime import datetime
from pydantic import BaseModel
class HourData(BaseModel):
"""Data Transfer Object for hourly electricity price data."""
DKK_per_kWh: float
EUR_per_kWh: float
EXR: float
time_start: datetime
time_end: datetime
View File
+1
View File
@@ -0,0 +1 @@
"""Test package for elprisenligenu."""
+48
View File
@@ -0,0 +1,48 @@
"""Test configuration and fixtures for elprisenligenu tests."""
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
def pytest_configure(config: pytest.Config) -> None:
"""Configure pytest with custom markers."""
config.addinivalue_line(
"markers",
"integration: marks tests as integration tests (may require internet)",
)
@pytest.fixture
def mock_api_response() -> list[dict]:
"""Fixture providing a mock API response."""
return [
{
"DKK_per_kWh": 1.3929,
"EUR_per_kWh": 0.18729,
"EXR": 7.437118,
"time_start": "2024-10-29T00:00:00+01:00",
"time_end": "2024-10-29T01:00:00+01:00",
},
{
"DKK_per_kWh": 1.30291,
"EUR_per_kWh": 0.17519,
"EXR": 7.437118,
"time_start": "2024-10-29T01:00:00+01:00",
"time_end": "2024-10-29T02:00:00+01:00",
},
]
@pytest.fixture
def sample_hour_data() -> dict:
"""Fixture providing sample HourData for testing."""
tz = ZoneInfo("Europe/Copenhagen")
return {
"DKK_per_kWh": 1.2345,
"EUR_per_kWh": 0.1659,
"EXR": 7.4371,
"time_start": datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz),
"time_end": datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz),
}
+152
View File
@@ -0,0 +1,152 @@
"""Integration tests for the elprisenligenu module."""
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from elprisenligenu.api import API
from elprisenligenu.dto.hour_data import HourData
class TestElprisenLigenIntegration:
"""Integration tests that make real API calls."""
def setup_method(self) -> None:
"""Set up test fixtures before each test method."""
self.api = API()
self.tz = ZoneInfo("Europe/Copenhagen")
@pytest.mark.integration
def test_get_current_prices_dk1(self) -> None:
"""Test fetching current day prices for DK1 (integration test)."""
# Act
result = self.api.get_daily_prices(zone="DK1")
# Assert
assert isinstance(result, list)
assert all(isinstance(item, HourData) for item in result)
if result: # If data is available
first_hour = result[0]
assert isinstance(first_hour.DKK_per_kWh, float)
assert isinstance(first_hour.EUR_per_kWh, float)
assert isinstance(first_hour.EXR, float)
assert isinstance(first_hour.time_start, datetime)
assert isinstance(first_hour.time_end, datetime)
# Basic sanity checks
assert first_hour.EXR > 0 # Exchange rate should be positive
assert first_hour.time_start < first_hour.time_end
@pytest.mark.integration
def test_get_current_prices_dk2(self) -> None:
"""Test fetching current day prices for DK2 (integration test)."""
# Act
result = self.api.get_daily_prices(zone="DK2")
# Assert
assert isinstance(result, list)
assert all(isinstance(item, HourData) for item in result)
if result: # If data is available
first_hour = result[0]
assert isinstance(first_hour.DKK_per_kWh, float)
assert isinstance(first_hour.EUR_per_kWh, float)
assert isinstance(first_hour.EXR, float)
assert isinstance(first_hour.time_start, datetime)
assert isinstance(first_hour.time_end, datetime)
@pytest.mark.integration
def test_get_historical_prices(self) -> None:
"""Test fetching historical prices (integration test)."""
# Arrange - Use a date that should have data (not too far back)
historical_date = datetime(2024, 10, 1, tzinfo=self.tz)
# Act
result = self.api.get_daily_prices(zone="DK1", date=historical_date)
# Assert
assert isinstance(result, list)
if result: # If historical data is available
assert len(result) <= 24 # Should be max 24 hours
assert all(isinstance(item, HourData) for item in result)
# Check that all times are from the requested date
for hour_data in result:
assert hour_data.time_start.date() == historical_date.date()
@pytest.mark.integration
def test_price_consistency(self) -> None:
"""Test that EUR and DKK prices are consistent with exchange rate."""
# Act
result = self.api.get_daily_prices(zone="DK1")
# Assert
if result:
for hour_data in result:
# Calculate expected DKK price from EUR price and exchange rate
expected_dkk = hour_data.EUR_per_kWh * hour_data.EXR
# Allow for small floating point differences
difference = abs(hour_data.DKK_per_kWh - expected_dkk)
assert difference < 0.001, (
f"Price inconsistency: DKK={hour_data.DKK_per_kWh}, "
f"EUR={hour_data.EUR_per_kWh}, EXR={hour_data.EXR}, "
f"Expected DKK={expected_dkk}"
)
@pytest.mark.integration
def test_time_ordering(self) -> None:
"""Test that hours are returned in chronological order."""
# Act
result = self.api.get_daily_prices(zone="DK1")
# Assert
if len(result) > 1:
for i in range(1, len(result)):
assert result[i - 1].time_start <= result[i].time_start, (
f"Hours not in chronological order: "
f"{result[i - 1].time_start} > {result[i].time_start}"
)
@pytest.mark.integration
def test_hour_duration(self) -> None:
"""Test that each hour period is exactly 1 hour long."""
# Act
result = self.api.get_daily_prices(zone="DK1")
# Assert
if result:
for hour_data in result:
duration = hour_data.time_end - hour_data.time_start
assert duration.total_seconds() == 3600, (
f"Hour duration is not 1 hour: {duration}"
)
@pytest.mark.integration
def test_both_zones_same_day(self) -> None:
"""Test that both zones return data for the same day."""
# Arrange
test_date = datetime(2024, 10, 15, tzinfo=self.tz)
# Act
dk1_result = self.api.get_daily_prices(zone="DK1", date=test_date)
dk2_result = self.api.get_daily_prices(zone="DK2", date=test_date)
# Assert
# Both zones should have data for the same day
if dk1_result and dk2_result:
assert len(dk1_result) == len(dk2_result)
# Times should be the same
for dk1_hour, dk2_hour in zip(dk1_result, dk2_result):
assert dk1_hour.time_start == dk2_hour.time_start
assert dk1_hour.time_end == dk2_hour.time_end
# Exchange rates should be the same
assert dk1_hour.EXR == dk2_hour.EXR
# But prices can be different between zones
# (That's the whole point of having two zones!)
+1
View File
@@ -0,0 +1 @@
"""Unit tests package for elprisenligenu."""
+321
View File
@@ -0,0 +1,321 @@
"""Unit tests for the API class."""
import json
from datetime import datetime
import unittest
from unittest.mock import Mock, patch
from zoneinfo import ZoneInfo
import pytest
import requests
from elprisenligenu.api import API
from elprisenligenu.dto.hour_data import HourData
class TestAPI(unittest.TestCase):
"""Test cases for the API class."""
api: API
tz: ZoneInfo
@classmethod
def setUpClass(cls) -> None:
"""Set up test fixtures before any tests are run."""
cls.api = API()
cls.tz = ZoneInfo("Europe/Copenhagen")
def test_api_initialization(self) -> None:
"""Test API class initialization."""
# Act
api = API()
# Assert
self.assertIsNotNone(api.session)
self.assertIsInstance(api.session, requests.Session)
self.assertIsNotNone(api.logger)
self.assertEqual(api.BASE_URL, "https://www.elprisenligenu.dk/api/v1/prices")
self.assertEqual(api.TIMEZONE, ZoneInfo("Europe/Copenhagen"))
def test_endpoint_construction_dk1(self) -> None:
"""Test endpoint URL construction for DK1."""
# Arrange
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act
endpoint = API._endpoint(date, "DK1")
# Assert
expected = "https://www.elprisenligenu.dk/api/v1/prices/2024/10-29_DK1.json"
self.assertEqual(endpoint, expected)
def test_endpoint_construction_dk2(self) -> None:
"""Test endpoint URL construction for DK2."""
# Arrange
date = datetime(2024, 1, 5, 12, 0, 0, tzinfo=self.tz)
# Act
endpoint = API._endpoint(date, "DK2")
# Assert
expected = "https://www.elprisenligenu.dk/api/v1/prices/2024/01-05_DK2.json"
self.assertEqual(endpoint, expected)
def test_endpoint_construction_edge_dates(self) -> None:
"""Test endpoint URL construction for edge case dates."""
# Test December 31st
date = datetime(2023, 12, 31, 12, 0, 0, tzinfo=self.tz)
endpoint = API._endpoint(date, "DK1")
expected = "https://www.elprisenligenu.dk/api/v1/prices/2023/12-31_DK1.json"
self.assertEqual(endpoint, expected)
# Test January 1st
date = datetime(2024, 1, 1, 12, 0, 0, tzinfo=self.tz)
endpoint = API._endpoint(date, "DK2")
expected = "https://www.elprisenligenu.dk/api/v1/prices/2024/01-01_DK2.json"
self.assertEqual(endpoint, expected)
@patch("elprisenligenu.api.requests.Session.get")
def test_get_daily_prices_success_dk1(self, mock_get: Mock) -> None:
"""Test successful API call for DK1."""
# Arrange
mock_response_data = [
{
"DKK_per_kWh": 1.3929,
"EUR_per_kWh": 0.18729,
"EXR": 7.437118,
"time_start": "2024-10-29T00:00:00+01:00",
"time_end": "2024-10-29T01:00:00+01:00",
},
{
"DKK_per_kWh": 1.30291,
"EUR_per_kWh": 0.17519,
"EXR": 7.437118,
"time_start": "2024-10-29T01:00:00+01:00",
"time_end": "2024-10-29T02:00:00+01:00",
},
]
mock_response = Mock()
mock_response.json.return_value = mock_response_data
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act
result = self.api.get_daily_prices(zone="DK1", date=date)
# Assert
self.assertEqual(len(result), 2)
self.assertTrue(all(isinstance(item, HourData) for item in result))
self.assertAlmostEqual(result[0].DKK_per_kWh, 1.3929, places=4)
self.assertAlmostEqual(result[1].DKK_per_kWh, 1.30291, places=4)
# Verify API was called correctly
expected_url = "https://www.elprisenligenu.dk/api/v1/prices/2024/10-29_DK1.json"
mock_get.assert_called_once_with(expected_url)
@patch("elprisenligenu.api.requests.Session.get")
def test_get_daily_prices_success_dk2(self, mock_get: Mock) -> None:
"""Test successful API call for DK2."""
# Arrange
mock_response_data = [
{
"DKK_per_kWh": 1.5000,
"EUR_per_kWh": 0.2000,
"EXR": 7.5,
"time_start": "2024-10-29T00:00:00+01:00",
"time_end": "2024-10-29T01:00:00+01:00",
}
]
mock_response = Mock()
mock_response.json.return_value = mock_response_data
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act
result = self.api.get_daily_prices(zone="DK2", date=date)
# Assert
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0].DKK_per_kWh, 1.5000, places=4)
# Verify API was called correctly
expected_url = "https://www.elprisenligenu.dk/api/v1/prices/2024/10-29_DK2.json"
mock_get.assert_called_once_with(expected_url)
def test_get_daily_prices_invalid_zone(self) -> None:
"""Test validation error for invalid zone."""
# Arrange
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act & Assert
with pytest.raises(ValueError, match="zone must be either 'DK1' or 'DK2'"):
self.api.get_daily_prices(zone="DK3", date=date) # type: ignore
def test_get_daily_prices_invalid_date_type(self) -> None:
"""Test validation error for invalid date type."""
# Act & Assert
with pytest.raises(TypeError, match="date must be a datetime object"):
self.api.get_daily_prices(zone="DK1", date="2024-10-29") # type: ignore
def test_get_daily_prices_naive_datetime(self) -> None:
"""Test validation error for naive datetime."""
# Arrange
naive_date = datetime(2024, 10, 29, 12, 0, 0) # No timezone
# Act & Assert
with pytest.raises(ValueError, match="date must be timezone-aware"):
self.api.get_daily_prices(zone="DK1", date=naive_date)
@patch("elprisenligenu.api.requests.Session.get")
def test_get_daily_prices_timezone_conversion(self, mock_get: Mock) -> None:
"""Test automatic timezone conversion."""
# Arrange
mock_response_data = [
{
"DKK_per_kWh": 1.0,
"EUR_per_kWh": 0.13,
"EXR": 7.5,
"time_start": "2024-10-29T00:00:00+01:00",
"time_end": "2024-10-29T01:00:00+01:00",
}
]
mock_response = Mock()
mock_response.json.return_value = mock_response_data
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
# Use UTC timezone instead of Copenhagen
utc_date = datetime(2024, 10, 29, 11, 0, 0, tzinfo=ZoneInfo("UTC"))
# Act
result = self.api.get_daily_prices(zone="DK1", date=utc_date)
# Assert
self.assertEqual(len(result), 1)
# Should still work, date gets converted internally
@patch("elprisenligenu.api.requests.Session.get")
def test_get_daily_prices_http_error(self, mock_get: Mock) -> None:
"""Test handling of HTTP errors."""
# Arrange
mock_response = Mock()
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
mock_get.return_value = mock_response
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act & Assert
with pytest.raises(requests.HTTPError):
self.api.get_daily_prices(zone="DK1", date=date)
@patch("elprisenligenu.api.requests.Session.get")
def test_get_daily_prices_invalid_json(self, mock_get: Mock) -> None:
"""Test handling of invalid JSON response."""
# Arrange
mock_response = Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0)
mock_get.return_value = mock_response
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act & Assert
with pytest.raises(json.JSONDecodeError):
self.api.get_daily_prices(zone="DK1", date=date)
@patch("elprisenligenu.api.requests.Session.get")
def test_get_daily_prices_empty_response(self, mock_get: Mock) -> None:
"""Test handling of empty response."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = []
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act
result = self.api.get_daily_prices(zone="DK1", date=date)
# Assert
self.assertEqual(result, [])
@patch("elprisenligenu.api.requests.Session.get")
def test_get_daily_prices_malformed_data(self, mock_get: Mock) -> None:
"""Test handling of malformed response data."""
# Arrange
mock_response_data = [
{
"DKK_per_kWh": "not_a_number", # Invalid type
"EUR_per_kWh": 0.18729,
"EXR": 7.437118,
"time_start": "2024-10-29T00:00:00+01:00",
"time_end": "2024-10-29T01:00:00+01:00",
}
]
mock_response = Mock()
mock_response.json.return_value = mock_response_data
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act & Assert
with pytest.raises(Exception): # Pydantic validation error
self.api.get_daily_prices(zone="DK1", date=date)
def test_get_daily_prices_default_parameters(self) -> None:
"""Test that default parameters work correctly."""
# This test verifies the method signature but doesn't make real API calls
# We just check that the method can be called without parameters
# Act & Assert - should not raise an error for the method signature
try:
# We expect this to fail with a network error in tests, but the
# parameter validation should pass
self.api.get_daily_prices()
except (requests.RequestException, requests.ConnectionError):
# Expected in test environment without internet
pass
@patch("elprisenligenu.api.requests.Session.get")
def test_get_daily_prices_full_day_data(self, mock_get: Mock) -> None:
"""Test processing a full day of 24 hours."""
# Arrange - Create 24 hours of mock data
mock_response_data = []
for hour in range(24):
mock_response_data.append(
{
"DKK_per_kWh": 1.0 + hour * 0.1,
"EUR_per_kWh": 0.13 + hour * 0.01,
"EXR": 7.5,
"time_start": f"2024-10-29T{hour:02d}:00:00+01:00",
"time_end": f"2024-10-29T{(hour + 1) % 24:02d}:00:00+01:00",
}
)
mock_response = Mock()
mock_response.json.return_value = mock_response_data
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
# Act
result = self.api.get_daily_prices(zone="DK1", date=date)
# Assert
self.assertEqual(len(result), 24)
self.assertTrue(all(isinstance(item, HourData) for item in result))
# Check that prices increase as expected
self.assertEqual(result[0].DKK_per_kWh, 1.0)
self.assertAlmostEqual(result[23].DKK_per_kWh, 3.3, 3) # 1.0 + 23 * 0.1
+229
View File
@@ -0,0 +1,229 @@
"""Unit tests for the HourData DTO model."""
from datetime import datetime
from zoneinfo import ZoneInfo
import unittest
import pytest
from pydantic import ValidationError
from elprisenligenu.dto.hour_data import HourData
class TestHourData(unittest.TestCase):
"""Test cases for the HourData model."""
def test_valid_hour_data_creation(self) -> None:
"""Test creating a valid HourData instance."""
# Arrange
tz = ZoneInfo("Europe/Copenhagen")
start_time = datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz)
end_time = datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz)
# Act
hour_data = HourData(
DKK_per_kWh=1.2345,
EUR_per_kWh=0.1659,
EXR=7.4371,
time_start=start_time,
time_end=end_time,
)
# Assert
self.assertEqual(hour_data.DKK_per_kWh, 1.2345)
self.assertEqual(hour_data.EUR_per_kWh, 0.1659)
self.assertEqual(hour_data.EXR, 7.4371)
self.assertEqual(hour_data.time_start, start_time)
self.assertEqual(hour_data.time_end, end_time)
def test_hour_data_from_api_response(self) -> None:
"""Test creating HourData from typical API response data."""
# Act - Pydantic can parse ISO datetime strings
hour_data = HourData(
DKK_per_kWh=1.3929,
EUR_per_kWh=0.18729,
EXR=7.437118,
time_start=datetime.fromisoformat("2022-11-25T00:00:00+01:00"),
time_end=datetime.fromisoformat("2022-11-25T01:00:00+01:00"),
)
# Assert
self.assertEqual(hour_data.DKK_per_kWh, 1.3929)
self.assertEqual(hour_data.EUR_per_kWh, 0.18729)
self.assertEqual(hour_data.EXR, 7.437118)
self.assertEqual(hour_data.time_start.year, 2022)
self.assertEqual(hour_data.time_start.month, 11)
self.assertEqual(hour_data.time_start.day, 25)
self.assertEqual(hour_data.time_start.hour, 0)
self.assertEqual(hour_data.time_end.hour, 1)
def test_hour_data_validation_missing_fields(self) -> None:
"""Test validation fails when required fields are missing."""
# Arrange
incomplete_data = {
"DKK_per_kWh": 1.2345,
"EUR_per_kWh": 0.1659,
# Missing EXR, time_start, time_end
}
# Act & Assert
with pytest.raises(ValidationError) as exc_info:
HourData(**incomplete_data) # type: ignore[arg-type]
errors = exc_info.value.errors()
missing_fields = {error["loc"][0] for error in errors}
self.assertIn("EXR", missing_fields)
self.assertIn("time_start", missing_fields)
self.assertIn("time_end", missing_fields)
def test_hour_data_validation_invalid_types(self) -> None:
"""Test validation fails with invalid data types."""
# Arrange
invalid_data = {
"DKK_per_kWh": "not_a_number", # Should be float
"EUR_per_kWh": 0.1659,
"EXR": 7.4371,
"time_start": "2024-10-29T12:00:00+01:00",
"time_end": "2024-10-29T13:00:00+01:00",
}
# Act & Assert
with pytest.raises(ValidationError) as exc_info:
HourData(**invalid_data) # type: ignore[arg-type]
errors = exc_info.value.errors()
self.assertTrue(any(error["loc"][0] == "DKK_per_kWh" for error in errors))
def test_hour_data_negative_prices(self) -> None:
"""Test HourData accepts negative prices (real markets)."""
# Arrange
tz = ZoneInfo("Europe/Copenhagen")
# Act
hour_data = HourData(
DKK_per_kWh=-0.5, # Negative price
EUR_per_kWh=-0.067,
EXR=7.4371,
time_start=datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz),
time_end=datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz),
)
# Assert
self.assertEqual(hour_data.DKK_per_kWh, -0.5)
self.assertEqual(hour_data.EUR_per_kWh, -0.067)
def test_hour_data_zero_prices(self) -> None:
"""Test HourData accepts zero prices."""
# Arrange
tz = ZoneInfo("Europe/Copenhagen")
# Act
hour_data = HourData(
DKK_per_kWh=0.0,
EUR_per_kWh=0.0,
EXR=7.4371,
time_start=datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz),
time_end=datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz),
)
# Assert
self.assertEqual(hour_data.DKK_per_kWh, 0.0)
self.assertEqual(hour_data.EUR_per_kWh, 0.0)
def test_hour_data_serialization(self) -> None:
"""Test HourData can be serialized to JSON."""
# Arrange
tz = ZoneInfo("Europe/Copenhagen")
start_time = datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz)
end_time = datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz)
hour_data = HourData(
DKK_per_kWh=1.2345,
EUR_per_kWh=0.1659,
EXR=7.4371,
time_start=start_time,
time_end=end_time,
)
# Act
json_data = hour_data.model_dump()
# Assert
self.assertEqual(json_data["DKK_per_kWh"], 1.2345)
self.assertEqual(json_data["EUR_per_kWh"], 0.1659)
self.assertEqual(json_data["EXR"], 7.4371)
self.assertEqual(json_data["time_start"], start_time)
self.assertEqual(json_data["time_end"], end_time)
def test_hour_data_string_representation(self) -> None:
"""Test HourData string representation is readable."""
# Arrange
tz = ZoneInfo("Europe/Copenhagen")
hour_data = HourData(
DKK_per_kWh=1.2345,
EUR_per_kWh=0.1659,
EXR=7.4371,
time_start=datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz),
time_end=datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz),
)
# Act
str_repr = str(hour_data)
# Assert
self.assertIn("DKK_per_kWh=1.2345", str_repr)
self.assertIn("EUR_per_kWh=0.1659", str_repr)
self.assertIn("EXR=7.4371", str_repr)
def test_hour_data_equality(self) -> None:
"""Test HourData equality comparison."""
# Arrange
tz = ZoneInfo("Europe/Copenhagen")
start_time = datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz)
end_time = datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz)
hour_data1 = HourData(
DKK_per_kWh=1.2345,
EUR_per_kWh=0.1659,
EXR=7.4371,
time_start=start_time,
time_end=end_time,
)
hour_data2 = HourData(
DKK_per_kWh=1.2345,
EUR_per_kWh=0.1659,
EXR=7.4371,
time_start=start_time,
time_end=end_time,
)
# Act & Assert
self.assertEqual(hour_data1, hour_data2)
def test_hour_data_inequality(self) -> None:
"""Test HourData inequality comparison."""
# Arrange
tz = ZoneInfo("Europe/Copenhagen")
start_time = datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz)
end_time = datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz)
hour_data1 = HourData(
DKK_per_kWh=1.2345,
EUR_per_kWh=0.1659,
EXR=7.4371,
time_start=start_time,
time_end=end_time,
)
hour_data2 = HourData(
DKK_per_kWh=2.0000, # Different price
EUR_per_kWh=0.1659,
EXR=7.4371,
time_start=start_time,
time_end=end_time,
)
# Act & Assert
self.assertNotEqual(hour_data1, hour_data2)
Generated
+1095
View File
File diff suppressed because it is too large Load Diff