From 2b851699e6f3e45b35d3fbe9d77242f5bb080d81 Mon Sep 17 00:00:00 2001 From: Kevin Kenyon Date: Sun, 28 Dec 2025 15:24:07 -0600 Subject: [PATCH 1/6] Add coverage reporting and expand ERCOT tests Raise repo coverage above 90% by adding focused ERCOT client, historical archive, timezone, and date utility tests and ensure coverage.xml is generated. Update CI and docs to publish coverage to Codecov and reflect the workflow badge. --- .github/workflows/ci.yml | 7 + README.md | 1 + justfile | 2 +- tests/test_base.py | 10 + tests/test_constants.py | 238 ++++++++++ tests/test_dates_utils.py | 9 + tests/test_decorators.py | 309 +++++++++++++ tests/test_ercot_auth.py | 615 ++++++++++++++++++++++++++ tests/test_ercot_endpoint_coverage.py | 140 ++++++ tests/test_ercot_helpers.py | 441 ++++++++++++++++++ tests/test_historical_archive.py | 222 ++++++++++ tests/test_tz.py | 422 ++++++++++++++++++ 12 files changed, 2415 insertions(+), 1 deletion(-) create mode 100644 tests/test_constants.py create mode 100644 tests/test_dates_utils.py create mode 100644 tests/test_decorators.py create mode 100644 tests/test_ercot_auth.py create mode 100644 tests/test_ercot_endpoint_coverage.py create mode 100644 tests/test_ercot_helpers.py create mode 100644 tests/test_historical_archive.py create mode 100644 tests/test_tz.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30f2b38..3d4027f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,10 @@ jobs: - name: Run tests with coverage run: just test-coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index dc4a909..ee66a35 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Tiny Grid [![CI](https://github.com/kvkenyon/tinygrid/actions/workflows/ci.yml/badge.svg)](https://github.com/kvkenyon/tinygrid/actions) +[![codecov](https://codecov.io/gh/kvkenyon/tinygrid/branch/main/graph/badge.svg)](https://codecov.io/gh/kvkenyon/tinygrid) A Python SDK for accessing electricity grid data from US Independent System Operators (ISOs). diff --git a/justfile b/justfile index 58a6f56..2941dfe 100644 --- a/justfile +++ b/justfile @@ -18,7 +18,7 @@ test-func func: # Run tests with coverage report test-coverage: - uv run pytest --cov=tinygrid + uv run pytest --cov=tinygrid --cov-report=xml --cov-report=term-missing # Lint code lint: diff --git a/tests/test_base.py b/tests/test_base.py index 7e57d82..b18897a 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -106,3 +106,13 @@ def test_handle_error_preserves_grid_error(self): client._handle_error(original_error, endpoint="/test") assert exc_info.value.status_code == 500 + + def test_handle_error_timeout_error(self): + """Test that TimeoutError is converted to GridTimeoutError.""" + from tinygrid.errors import GridTimeoutError + + client = ConcreteISOClient(base_url="https://api.test.com", timeout=30.0) + timeout_error = TimeoutError("Connection timed out") + + with pytest.raises(GridTimeoutError, match="Request timed out"): + client._handle_error(timeout_error, endpoint="/test-endpoint") diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..ce8576b --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,238 @@ +"""Tests for ERCOT constants and enums.""" + +from tinygrid.constants.ercot import ( + COLUMN_MAPPINGS, + EMIL_IDS, + ENDPOINT_MAPPINGS, + ERCOT_TIMEZONE, + HISTORICAL_THRESHOLD_DAYS, + LIVE_API_RETENTION, + LOAD_ZONES, + PUBLIC_API_BASE_URL, + TRADING_HUBS, + LocationType, + Market, + SettlementPointType, +) + + +class TestMarketEnum: + """Test Market enum.""" + + def test_market_enum_values(self): + """Test that Market enum has expected values.""" + assert Market.REAL_TIME_SCED == "REAL_TIME_SCED" + assert Market.REAL_TIME_15_MIN == "REAL_TIME_15_MIN" + assert Market.DAY_AHEAD_HOURLY == "DAY_AHEAD_HOURLY" + + def test_market_enum_is_string(self): + """Test that Market enum values are strings.""" + assert isinstance(Market.REAL_TIME_SCED, str) + assert isinstance(Market.REAL_TIME_15_MIN, str) + assert isinstance(Market.DAY_AHEAD_HOURLY, str) + + def test_market_enum_str_representation(self): + """Test string representation of Market enum.""" + assert str(Market.REAL_TIME_SCED) == "REAL_TIME_SCED" + + +class TestLocationTypeEnum: + """Test LocationType enum.""" + + def test_location_type_values(self): + """Test that LocationType enum has expected values.""" + assert LocationType.LOAD_ZONE == "Load Zone" + assert LocationType.TRADING_HUB == "Trading Hub" + assert LocationType.RESOURCE_NODE == "Resource Node" + assert LocationType.ELECTRICAL_BUS == "Electrical Bus" + + def test_location_type_is_string(self): + """Test that LocationType enum values are strings.""" + assert isinstance(LocationType.LOAD_ZONE, str) + assert isinstance(LocationType.TRADING_HUB, str) + + def test_location_type_str_representation(self): + """Test string representation of LocationType enum.""" + assert str(LocationType.LOAD_ZONE) == "Load Zone" + + +class TestSettlementPointTypeEnum: + """Test SettlementPointType enum.""" + + def test_settlement_point_type_values(self): + """Test that SettlementPointType enum has expected values.""" + assert SettlementPointType.LZ == "LZ" + assert SettlementPointType.HB == "HB" + assert SettlementPointType.RN == "RN" + + def test_settlement_point_type_is_string(self): + """Test that SettlementPointType values are strings.""" + assert isinstance(SettlementPointType.LZ, str) + assert isinstance(SettlementPointType.HB, str) + + +class TestConstants: + """Test ERCOT constants.""" + + def test_ercot_timezone(self): + """Test ERCOT timezone constant.""" + assert ERCOT_TIMEZONE == "US/Central" + + def test_historical_threshold_days(self): + """Test historical threshold constant.""" + assert HISTORICAL_THRESHOLD_DAYS == 90 + assert isinstance(HISTORICAL_THRESHOLD_DAYS, int) + + def test_public_api_base_url(self): + """Test public API base URL.""" + assert PUBLIC_API_BASE_URL == "https://api.ercot.com/api/public-reports" + assert PUBLIC_API_BASE_URL.startswith("https://") + + def test_load_zones_list(self): + """Test LOAD_ZONES list.""" + assert isinstance(LOAD_ZONES, list) + assert len(LOAD_ZONES) == 8 + assert "LZ_HOUSTON" in LOAD_ZONES + assert "LZ_NORTH" in LOAD_ZONES + assert "LZ_SOUTH" in LOAD_ZONES + assert "LZ_WEST" in LOAD_ZONES + + def test_trading_hubs_list(self): + """Test TRADING_HUBS list.""" + assert isinstance(TRADING_HUBS, list) + assert len(TRADING_HUBS) == 7 + assert "HB_HOUSTON" in TRADING_HUBS + assert "HB_NORTH" in TRADING_HUBS + assert "HB_SOUTH" in TRADING_HUBS + assert "HB_WEST" in TRADING_HUBS + + +class TestEndpointMappings: + """Test ENDPOINT_MAPPINGS dictionary.""" + + def test_endpoint_mappings_has_spp(self): + """Test SPP endpoint mappings.""" + assert "spp" in ENDPOINT_MAPPINGS + assert Market.REAL_TIME_15_MIN in ENDPOINT_MAPPINGS["spp"] + assert Market.DAY_AHEAD_HOURLY in ENDPOINT_MAPPINGS["spp"] + + def test_endpoint_mappings_has_lmp(self): + """Test LMP endpoint mappings.""" + assert "lmp" in ENDPOINT_MAPPINGS + assert Market.REAL_TIME_SCED in ENDPOINT_MAPPINGS["lmp"] + assert Market.DAY_AHEAD_HOURLY in ENDPOINT_MAPPINGS["lmp"] + + def test_endpoint_mappings_has_as_prices(self): + """Test AS prices endpoint mapping.""" + assert "as_prices" in ENDPOINT_MAPPINGS + assert isinstance(ENDPOINT_MAPPINGS["as_prices"], str) + + def test_endpoint_mappings_has_as_plan(self): + """Test AS plan endpoint mapping.""" + assert "as_plan" in ENDPOINT_MAPPINGS + assert isinstance(ENDPOINT_MAPPINGS["as_plan"], str) + + def test_endpoint_mappings_has_wind_solar(self): + """Test wind and solar endpoint mappings.""" + assert "wind_forecast" in ENDPOINT_MAPPINGS + assert "wind_forecast_geo" in ENDPOINT_MAPPINGS + assert "solar_forecast" in ENDPOINT_MAPPINGS + assert "solar_forecast_geo" in ENDPOINT_MAPPINGS + + +class TestLiveAPIRetention: + """Test LIVE_API_RETENTION dictionary.""" + + def test_live_api_retention_has_expected_keys(self): + """Test that LIVE_API_RETENTION has expected categories.""" + assert "real_time" in LIVE_API_RETENTION + assert "day_ahead" in LIVE_API_RETENTION + assert "forecast" in LIVE_API_RETENTION + assert "load" in LIVE_API_RETENTION + assert "default" in LIVE_API_RETENTION + + def test_live_api_retention_values(self): + """Test LIVE_API_RETENTION values.""" + assert LIVE_API_RETENTION["real_time"] == 1 + assert LIVE_API_RETENTION["day_ahead"] == 2 + assert LIVE_API_RETENTION["forecast"] == 3 + assert LIVE_API_RETENTION["load"] == 3 + assert LIVE_API_RETENTION["default"] == 1 + + def test_live_api_retention_all_positive(self): + """Test that all retention values are positive.""" + for value in LIVE_API_RETENTION.values(): + assert value > 0 + + +class TestEmilIDs: + """Test EMIL_IDs dictionary.""" + + def test_emil_ids_has_disclosure_reports(self): + """Test EMIL IDs for disclosure reports.""" + assert "as_reports_dam" in EMIL_IDS + assert "as_reports_sced" in EMIL_IDS + assert "dam_disclosure" in EMIL_IDS + assert "sced_disclosure" in EMIL_IDS + + def test_emil_ids_has_real_time_endpoints(self): + """Test EMIL IDs for real-time endpoints.""" + assert "np6-905-cd" in EMIL_IDS + assert "np6-788-cd" in EMIL_IDS + assert "np6-787-cd" in EMIL_IDS + + def test_emil_ids_has_dam_endpoints(self): + """Test EMIL IDs for DAM endpoints.""" + assert "np4-190-cd" in EMIL_IDS + assert "np4-183-cd" in EMIL_IDS + assert "np4-188-cd" in EMIL_IDS + assert "np4-33-cd" in EMIL_IDS + + def test_emil_ids_has_forecast_endpoints(self): + """Test EMIL IDs for forecast endpoints.""" + assert "np4-732-cd" in EMIL_IDS + assert "np4-742-cd" in EMIL_IDS + assert "np4-737-cd" in EMIL_IDS + assert "np4-745-cd" in EMIL_IDS + + def test_emil_ids_values_are_strings(self): + """Test that all EMIL ID values are strings.""" + for value in EMIL_IDS.values(): + assert isinstance(value, str) + + +class TestColumnMappings: + """Test COLUMN_MAPPINGS dictionary.""" + + def test_column_mappings_has_location_columns(self): + """Test column mappings for location fields.""" + assert "ElectricalBus" in COLUMN_MAPPINGS + assert "SettlementPoint" in COLUMN_MAPPINGS + assert "SettlementPointName" in COLUMN_MAPPINGS + assert COLUMN_MAPPINGS["SettlementPoint"] == "Location" + + def test_column_mappings_has_price_columns(self): + """Test column mappings for price fields.""" + assert "SettlementPointPrice" in COLUMN_MAPPINGS + assert "LMP" in COLUMN_MAPPINGS + assert "ShadowPrice" in COLUMN_MAPPINGS + assert COLUMN_MAPPINGS["LMP"] == "Price" + + def test_column_mappings_has_time_columns(self): + """Test column mappings for time fields.""" + assert "SCEDTimestamp" in COLUMN_MAPPINGS + assert "DeliveryDate" in COLUMN_MAPPINGS + assert "DeliveryHour" in COLUMN_MAPPINGS + assert "PostedDatetime" in COLUMN_MAPPINGS + assert COLUMN_MAPPINGS["SCEDTimestamp"] == "Timestamp" + + def test_column_mappings_has_flag_columns(self): + """Test column mappings for flag fields.""" + assert "DSTFlag" in COLUMN_MAPPINGS + assert "RepeatedHourFlag" in COLUMN_MAPPINGS + assert COLUMN_MAPPINGS["DSTFlag"] == "DST" + + def test_column_mappings_values_are_strings(self): + """Test that all column mapping values are strings.""" + for value in COLUMN_MAPPINGS.values(): + assert isinstance(value, str) diff --git a/tests/test_dates_utils.py b/tests/test_dates_utils.py new file mode 100644 index 0000000..0730f7c --- /dev/null +++ b/tests/test_dates_utils.py @@ -0,0 +1,9 @@ +import pandas as pd + +from tinygrid.utils.dates import parse_date + + +def test_parse_date_defaults_when_none(): + ts = parse_date(None) + assert isinstance(ts, pd.Timestamp) + assert ts.tz is not None diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 0000000..4c9db08 --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,309 @@ +"""Tests for decorators module.""" + +import logging + +import pandas as pd + +from tinygrid.utils.decorators import support_date_range + + +class MockClient: + """Mock client for testing decorators.""" + + def __init__(self): + self.call_count = 0 + self.calls = [] + + @support_date_range(freq=None) + def no_chunking( + self, + start: str | pd.Timestamp = "today", + end: str | pd.Timestamp | None = None, + **kwargs, + ) -> pd.DataFrame: + """Method with no chunking.""" + self.call_count += 1 + self.calls.append((start, end, kwargs)) + return pd.DataFrame({"value": [1, 2, 3]}) + + @support_date_range(freq="7D") + def with_chunking_7days( + self, + start: str | pd.Timestamp = "today", + end: str | pd.Timestamp | None = None, + **kwargs, + ) -> pd.DataFrame: + """Method with 7-day chunking.""" + self.call_count += 1 + self.calls.append((start, end, kwargs)) + return pd.DataFrame({"date": [start], "value": [self.call_count]}) + + @support_date_range(freq="1D") + def with_chunking_1day( + self, + start: str | pd.Timestamp = "today", + end: str | pd.Timestamp | None = None, + **kwargs, + ) -> pd.DataFrame: + """Method with 1-day chunking.""" + self.call_count += 1 + self.calls.append((start, end, kwargs)) + return pd.DataFrame({"date": [start], "value": [self.call_count]}) + + @support_date_range(freq="7D") + def with_empty_results( + self, + start: str | pd.Timestamp = "today", + end: str | pd.Timestamp | None = None, + **kwargs, + ) -> pd.DataFrame: + """Method that returns empty DataFrames.""" + self.call_count += 1 + self.calls.append((start, end, kwargs)) + return pd.DataFrame() + + @support_date_range(freq="7D") + def with_errors( + self, + start: str | pd.Timestamp = "today", + end: str | pd.Timestamp | None = None, + **kwargs, + ) -> pd.DataFrame: + """Method that raises errors for certain chunks.""" + self.call_count += 1 + self.calls.append((start, end, kwargs)) + if self.call_count == 2: + raise RuntimeError("Simulated error for chunk 2") + return pd.DataFrame({"date": [start], "value": [self.call_count]}) + + +class TestSupportDateRangeNoChunking: + """Test support_date_range decorator without chunking.""" + + def test_no_chunking_single_call(self): + """Test that no chunking mode calls function once.""" + client = MockClient() + + result = client.no_chunking(start="2024-01-01", end="2024-01-31") + + assert client.call_count == 1 + assert len(result) == 3 + assert list(result["value"]) == [1, 2, 3] + + def test_no_chunking_default_end(self): + """Test no chunking with default end parameter.""" + client = MockClient() + + result = client.no_chunking(start="2024-01-01") + + assert client.call_count == 1 + assert len(result) == 3 + + def test_no_chunking_preserves_kwargs(self): + """Test that kwargs are passed through correctly.""" + client = MockClient() + + result = client.no_chunking( + start="2024-01-01", end="2024-01-31", extra_param="value" + ) + + assert client.call_count == 1 + assert client.calls[0][2]["extra_param"] == "value" + + +class TestSupportDateRangeWithChunking: + """Test support_date_range decorator with chunking.""" + + def test_chunking_small_range_no_split(self): + """Test that small date ranges don't get chunked.""" + client = MockClient() + + # 3 days should not be chunked with 7D freq + result = client.with_chunking_7days(start="2024-01-01", end="2024-01-03") + + assert client.call_count == 1 + + def test_chunking_large_range_splits(self): + """Test that large date ranges get chunked.""" + client = MockClient() + + # 20 days with 7D freq should create 3 chunks + result = client.with_chunking_7days(start="2024-01-01", end="2024-01-21") + + assert client.call_count == 3 + assert len(result) == 3 # One row per chunk + + def test_chunking_1day_freq(self): + """Test chunking with 1-day frequency.""" + client = MockClient() + + # 3 days with 1D freq should create 3 chunks + result = client.with_chunking_1day(start="2024-01-01", end="2024-01-04") + + assert client.call_count == 3 + assert len(result) == 3 + + def test_chunking_concatenates_results(self): + """Test that chunked results are concatenated correctly.""" + client = MockClient() + + result = client.with_chunking_7days(start="2024-01-01", end="2024-01-21") + + # Should have 3 chunks with sequential values + assert len(result) == 3 + assert list(result["value"]) == [1, 2, 3] + + def test_chunking_preserves_kwargs(self): + """Test that kwargs are passed to each chunk.""" + client = MockClient() + + result = client.with_chunking_7days( + start="2024-01-01", end="2024-01-21", extra_param="test_value" + ) + + assert client.call_count == 3 + # Check that each chunk received the kwargs + for call in client.calls: + assert call[2]["extra_param"] == "test_value" + + def test_chunking_empty_results(self): + """Test handling of empty DataFrames in chunks.""" + client = MockClient() + + result = client.with_empty_results(start="2024-01-01", end="2024-01-21") + + # Should call multiple times but return empty DataFrame + assert client.call_count == 3 + assert len(result) == 0 + assert isinstance(result, pd.DataFrame) + + +class TestSupportDateRangeErrorHandling: + """Test error handling in support_date_range decorator.""" + + def test_chunking_continues_on_error(self, caplog): + """Test that errors in one chunk don't stop other chunks.""" + client = MockClient() + + with caplog.at_level(logging.WARNING): + result = client.with_errors(start="2024-01-01", end="2024-01-21") + + # Should have attempted 3 chunks + assert client.call_count == 3 + # Should only have 2 results (chunk 2 failed) + assert len(result) == 2 + assert list(result["value"]) == [1, 3] + # Should have logged the error + assert "Failed to fetch chunk" in caplog.text + + def test_chunking_all_errors_returns_empty(self): + """Test that if all chunks error, returns empty DataFrame.""" + + class AlwaysErrorClient: + @support_date_range(freq="7D") + def always_errors( + self, + start: str | pd.Timestamp = "today", + end: str | pd.Timestamp | None = None, + **kwargs, + ) -> pd.DataFrame: + raise RuntimeError("Always fails") + + client = AlwaysErrorClient() + result = client.always_errors(start="2024-01-01", end="2024-01-21") + + assert len(result) == 0 + assert isinstance(result, pd.DataFrame) + + +class TestSupportDateRangeDateParsing: + """Test date parsing in support_date_range decorator.""" + + def test_parses_string_dates(self): + """Test that string dates are parsed correctly.""" + client = MockClient() + + result = client.no_chunking(start="2024-01-01", end="2024-01-31") + + # Check that dates were converted to Timestamps + assert isinstance(client.calls[0][0], pd.Timestamp) + assert isinstance(client.calls[0][1], pd.Timestamp) + + def test_parses_timestamp_dates(self): + """Test that Timestamp dates are handled correctly.""" + client = MockClient() + start_ts = pd.Timestamp("2024-01-01") + end_ts = pd.Timestamp("2024-01-31") + + result = client.no_chunking(start=start_ts, end=end_ts) + + assert client.call_count == 1 + + def test_parses_today_keyword(self): + """Test that 'today' keyword is parsed.""" + client = MockClient() + + result = client.no_chunking(start="today") + + assert client.call_count == 1 + assert isinstance(client.calls[0][0], pd.Timestamp) + + def test_default_end_is_parsed(self): + """Test that default end parameter is handled correctly.""" + client = MockClient() + + result = client.no_chunking(start="2024-01-01", end=None) + + assert client.call_count == 1 + # End should be defaulted to start + 1 day + assert isinstance(client.calls[0][1], pd.Timestamp) + + +class TestSupportDateRangeEdgeCases: + """Test edge cases for support_date_range decorator.""" + + def test_exact_chunk_boundary(self): + """Test date range that's exactly on chunk boundary.""" + client = MockClient() + + # Exactly 7 days with 7D freq should create 1 chunk (<=) + result = client.with_chunking_7days(start="2024-01-01", end="2024-01-08") + + assert client.call_count == 1 + + def test_one_day_over_boundary(self): + """Test date range that's one day over chunk boundary.""" + client = MockClient() + + # 8 days with 7D freq should create 2 chunks + result = client.with_chunking_7days(start="2024-01-01", end="2024-01-09") + + assert client.call_count == 2 + + def test_preserves_function_metadata(self): + """Test that decorator preserves function metadata.""" + client = MockClient() + + # Check that functools.wraps preserved the metadata + assert client.no_chunking.__name__ == "no_chunking" + assert "Method with no chunking" in client.no_chunking.__doc__ # pyright: ignore[reportOperatorIssue] + + def test_with_args(self): + """Test that positional args are passed through.""" + + class ClientWithArgs: + @support_date_range(freq=None) + def method_with_args( + self, + start: str | pd.Timestamp = "today", + end: str | pd.Timestamp | None = None, + *args, + **kwargs, + ) -> pd.DataFrame: + return pd.DataFrame({"args": list(args)}) + + client = ClientWithArgs() + result = client.method_with_args("2024-01-01", "2024-01-31", "extra_arg") + + # Extra positional arg should be passed through + assert "extra_arg" in result["args"].tolist() diff --git a/tests/test_ercot_auth.py b/tests/test_ercot_auth.py new file mode 100644 index 0000000..cbde16a --- /dev/null +++ b/tests/test_ercot_auth.py @@ -0,0 +1,615 @@ +"""Tests for ERCOT authentication module.""" + +import time + +import httpx +import pytest +import respx + +from tinygrid.auth import ERCOTAuth, ERCOTAuthConfig +from tinygrid.errors import GridAuthenticationError + + +class TestERCOTAuthConfig: + """Test ERCOTAuthConfig configuration.""" + + def test_auth_config_defaults(self): + """Test that ERCOTAuthConfig has correct default values.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + + assert config.username == "test@example.com" + assert config.password == "password123" + assert config.subscription_key == "key123" + assert ( + config.auth_url + == "https://ercotb2c.b2clogin.com/ercotb2c.onmicrosoft.com/B2C_1_PUBAPI-ROPC-FLOW/oauth2/v2.0/token" + ) + assert config.client_id == "fec253ea-0d06-4272-a5e6-b478baeecd70" + assert config.token_cache_ttl == 3300 + + def test_auth_config_custom_values(self): + """Test ERCOTAuthConfig with custom values.""" + config = ERCOTAuthConfig( + username="custom@example.com", + password="custompass", + subscription_key="customkey", + auth_url="https://custom.auth.url", + client_id="custom-client-id", + token_cache_ttl=1800, + ) + + assert config.username == "custom@example.com" + assert config.password == "custompass" + assert config.subscription_key == "customkey" + assert config.auth_url == "https://custom.auth.url" + assert config.client_id == "custom-client-id" + assert config.token_cache_ttl == 1800 + + +class TestERCOTAuthTokenCaching: + """Test ERCOT authentication token caching logic.""" + + def test_is_token_valid_no_token(self): + """Test that _is_token_valid returns False when no token is cached.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + assert auth._is_token_valid() is False + + def test_is_token_valid_expired_token(self): + """Test that _is_token_valid returns False when token is expired.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + auth._cached_token = "expired-token" + auth._token_expires_at = time.time() - 100 # Expired 100 seconds ago + + assert auth._is_token_valid() is False + + def test_is_token_valid_current_token(self): + """Test that _is_token_valid returns True when token is still valid.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + auth._cached_token = "valid-token" + auth._token_expires_at = time.time() + 1000 # Expires in 1000 seconds + + assert auth._is_token_valid() is True + + def test_clear_token_cache(self): + """Test that clear_token_cache clears the cached token.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + auth._cached_token = "valid-token" + auth._token_expires_at = time.time() + 1000 + + auth.clear_token_cache() + + assert auth._cached_token is None + assert auth._token_expires_at is None + + def test_get_subscription_key(self): + """Test that get_subscription_key returns the subscription key.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="my-sub-key", + ) + auth = ERCOTAuth(config) + + assert auth.get_subscription_key() == "my-sub-key" + + +class TestERCOTAuthSyncTokenFetch: + """Test ERCOT authentication synchronous token fetching.""" + + @respx.mock + def test_fetch_token_sync_success_access_token(self): + """Test successful token fetch with access_token in response.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock the authentication endpoint + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, json={"access_token": "test-access-token"}) + ) + + token = auth._fetch_token_sync() + + assert token == "test-access-token" + + @respx.mock + def test_fetch_token_sync_success_id_token(self): + """Test successful token fetch with id_token in response.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock the authentication endpoint + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, json={"id_token": "test-id-token"}) + ) + + token = auth._fetch_token_sync() + + assert token == "test-id-token" + + @respx.mock + def test_fetch_token_sync_http_error(self): + """Test token fetch with HTTP error response.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a 401 unauthorized response + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response( + 401, + json={ + "error": "invalid_credentials", + "error_description": "Invalid username or password", + }, + ) + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + auth._fetch_token_sync() + + assert exc_info.value.status_code == 401 + assert "ERCOT authentication failed with status 401" in str(exc_info.value) + + @respx.mock + def test_fetch_token_sync_invalid_json(self): + """Test token fetch with invalid JSON response.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a response with invalid JSON + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, text="invalid json response") + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + auth._fetch_token_sync() + + assert "Failed to parse authentication response as JSON" in str(exc_info.value) + + @respx.mock + def test_fetch_token_sync_no_token_in_response(self): + """Test token fetch when response has no access_token or id_token.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a response without token + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, json={"some_field": "some_value"}) + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + auth._fetch_token_sync() + + assert "No access token in ERCOT authentication response" in str(exc_info.value) + + @respx.mock + def test_fetch_token_sync_timeout(self): + """Test token fetch with timeout.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a timeout + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + side_effect=httpx.TimeoutException("Timeout") + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + auth._fetch_token_sync() + + assert "ERCOT authentication request timed out" in str(exc_info.value) + + @respx.mock + def test_fetch_token_sync_request_error(self): + """Test token fetch with request error.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a request error + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + side_effect=httpx.ConnectError("Connection failed") + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + auth._fetch_token_sync() + + assert "ERCOT authentication request failed" in str(exc_info.value) + + @respx.mock + def test_fetch_token_sync_unexpected_error(self): + """Test token fetch with unexpected error.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock an unexpected error + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + side_effect=RuntimeError("Unexpected error") + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + auth._fetch_token_sync() + + assert "Unexpected error during ERCOT authentication" in str(exc_info.value) + + +class TestERCOTAuthAsyncTokenFetch: + """Test ERCOT authentication asynchronous token fetching.""" + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_token_async_success_access_token(self): + """Test successful async token fetch with access_token in response.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock the authentication endpoint + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response( + 200, json={"access_token": "test-access-token-async"} + ) + ) + + token = await auth._fetch_token_async() + + assert token == "test-access-token-async" + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_token_async_success_id_token(self): + """Test successful async token fetch with id_token in response.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock the authentication endpoint + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, json={"id_token": "test-id-token-async"}) + ) + + token = await auth._fetch_token_async() + + assert token == "test-id-token-async" + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_token_async_http_error(self): + """Test async token fetch with HTTP error response.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a 401 unauthorized response + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response( + 401, + json={ + "error": "invalid_credentials", + "error_description": "Invalid username or password", + }, + ) + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + await auth._fetch_token_async() + + assert exc_info.value.status_code == 401 + assert "ERCOT authentication failed with status 401" in str(exc_info.value) + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_token_async_invalid_json(self): + """Test async token fetch with invalid JSON response.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a response with invalid JSON + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, text="invalid json response") + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + await auth._fetch_token_async() + + assert "Failed to parse authentication response as JSON" in str(exc_info.value) + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_token_async_no_token_in_response(self): + """Test async token fetch when response has no access_token or id_token.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a response without token + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, json={"some_field": "some_value"}) + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + await auth._fetch_token_async() + + assert "No access token in ERCOT authentication response" in str(exc_info.value) + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_token_async_timeout(self): + """Test async token fetch with timeout.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a timeout + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + side_effect=httpx.TimeoutException("Timeout") + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + await auth._fetch_token_async() + + assert "ERCOT authentication request timed out" in str(exc_info.value) + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_token_async_request_error(self): + """Test async token fetch with request error.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock a request error + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + side_effect=httpx.ConnectError("Connection failed") + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + await auth._fetch_token_async() + + assert "ERCOT authentication request failed" in str(exc_info.value) + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_token_async_unexpected_error(self): + """Test async token fetch with unexpected error.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock an unexpected error + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + side_effect=RuntimeError("Unexpected error") + ) + + with pytest.raises(GridAuthenticationError) as exc_info: + await auth._fetch_token_async() + + assert "Unexpected error during ERCOT authentication" in str(exc_info.value) + + +class TestERCOTAuthGetToken: + """Test ERCOT authentication get_token methods.""" + + @respx.mock + def test_get_token_fetches_new_token(self): + """Test that get_token fetches a new token when cache is empty.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock the authentication endpoint + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, json={"access_token": "new-token"}) + ) + + token = auth.get_token() + + assert token == "new-token" + assert auth._cached_token == "new-token" + assert auth._token_expires_at is not None + + @respx.mock + def test_get_token_uses_cached_token(self): + """Test that get_token uses cached token when valid.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Set a valid cached token + auth._cached_token = "cached-token" + auth._token_expires_at = time.time() + 1000 + + # Mock should not be called + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response( + 200, json={"access_token": "should-not-be-called"} + ) + ) + + token = auth.get_token() + + assert token == "cached-token" + + @respx.mock + def test_get_token_refreshes_expired_token(self): + """Test that get_token fetches a new token when cached token is expired.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Set an expired cached token + auth._cached_token = "expired-token" + auth._token_expires_at = time.time() - 100 + + # Mock the authentication endpoint + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, json={"access_token": "refreshed-token"}) + ) + + token = auth.get_token() + + assert token == "refreshed-token" + assert auth._cached_token == "refreshed-token" + + @respx.mock + @pytest.mark.asyncio + async def test_get_token_async_fetches_new_token(self): + """Test that get_token_async fetches a new token when cache is empty.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Mock the authentication endpoint + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response(200, json={"access_token": "new-async-token"}) + ) + + token = await auth.get_token_async() + + assert token == "new-async-token" + assert auth._cached_token == "new-async-token" + assert auth._token_expires_at is not None + + @respx.mock + @pytest.mark.asyncio + async def test_get_token_async_uses_cached_token(self): + """Test that get_token_async uses cached token when valid.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Set a valid cached token + auth._cached_token = "cached-async-token" + auth._token_expires_at = time.time() + 1000 + + # Mock should not be called + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response( + 200, json={"access_token": "should-not-be-called"} + ) + ) + + token = await auth.get_token_async() + + assert token == "cached-async-token" + + @respx.mock + @pytest.mark.asyncio + async def test_get_token_async_refreshes_expired_token(self): + """Test that get_token_async fetches a new token when cached token is expired.""" + config = ERCOTAuthConfig( + username="test@example.com", + password="password123", + subscription_key="key123", + ) + auth = ERCOTAuth(config) + + # Set an expired cached token + auth._cached_token = "expired-async-token" + auth._token_expires_at = time.time() - 100 + + # Mock the authentication endpoint + respx.post(url__regex=r".*ercotb2c\.b2clogin\.com.*").mock( + return_value=httpx.Response( + 200, json={"access_token": "refreshed-async-token"} + ) + ) + + token = await auth.get_token_async() + + assert token == "refreshed-async-token" + assert auth._cached_token == "refreshed-async-token" diff --git a/tests/test_ercot_endpoint_coverage.py b/tests/test_ercot_endpoint_coverage.py new file mode 100644 index 0000000..57f7a11 --- /dev/null +++ b/tests/test_ercot_endpoint_coverage.py @@ -0,0 +1,140 @@ +"""Parametrized tests to cover ERCOT endpoint wrapper methods.""" + +from unittest.mock import patch + +import pandas as pd +import pytest + +from tinygrid import ERCOT + +# List of simple endpoint wrapper methods that just call _call_endpoint +SIMPLE_ENDPOINT_METHODS = [ + # Aggregated endpoints + "get_aggregated_dsr_loads", + "get_aggregated_generation_summary", + "get_aggregated_generation_summary_houston", + "get_aggregated_generation_summary_north", + "get_aggregated_generation_summary_south", + "get_aggregated_generation_summary_west", + "get_aggregated_load_summary", + "get_aggregated_load_summary_houston", + "get_aggregated_load_summary_north", + "get_aggregated_load_summary_south", + "get_aggregated_load_summary_west", + "get_aggregated_outage_schedule", + "get_aggregated_outage_schedule_houston", + "get_aggregated_outage_schedule_north", + "get_aggregated_outage_schedule_south", + "get_aggregated_outage_schedule_west", + # AS Offers + "get_aggregated_as_offers_ecrsm", + "get_aggregated_as_offers_ecrss", + "get_aggregated_as_offers_offns", + "get_aggregated_as_offers_onns", + "get_aggregated_as_offers_regdn", + "get_aggregated_as_offers_regup", + "get_aggregated_as_offers_rrsffr", + "get_aggregated_as_offers_rrspfr", + "get_aggregated_as_offers_rrsufr", + # SCED Disclosure + "get_sced_dsr_load_data", + "get_sced_gen_res_data", + "get_sced_smne_gen_res", + "get_load_res_data_in_sced", + "get_hdl_ldl_manual_override", + "get_sced_qse_self_arranged_as", + # SASM + "get_sasm_gen_res_as_offers", + "get_sasm_gen_res_as_offer_awards", + "get_sasm_load_res_as_offers", + "get_sasm_load_res_as_offer_awards", + # Price Corrections + "get_rtm_price_corrections_eblmp", + "get_rtm_price_corrections_spp", + "get_rtm_price_corrections_shadow", + "get_rtm_price_corrections_soglmp", + "get_rtm_price_corrections_sogprice", + "get_rtm_price_corrections_splmp", + "get_dam_price_corrections_eblmp", + "get_dam_price_corrections_mcpc", + "get_dam_price_corrections_spp", + # Cleared AS + "get_cleared_dam_as_ecrsm", + "get_cleared_dam_as_ecrss", + "get_cleared_dam_as_nspin", + "get_cleared_dam_as_regdn", + "get_cleared_dam_as_regup", + "get_cleared_dam_as_rrsffr", + "get_cleared_dam_as_rrspfr", + "get_cleared_dam_as_rrsufr", + # Self-arranged AS + "get_self_arranged_as_ecrsm", + "get_self_arranged_as_ecrss", + "get_self_arranged_as_nspin", + "get_self_arranged_as_nspnm", + "get_self_arranged_as_regdn", + "get_self_arranged_as_regup", + "get_self_arranged_as_rrsffr", + "get_self_arranged_as_rrspfr", + "get_self_arranged_as_rrsufr", + # DAM Disclosure + "get_dam_energy_bid_awards", + "get_dam_energy_bids", + "get_dam_energy_only_offer_awards", + "get_dam_energy_only_offers", + "get_dam_gen_res_as_offers", + "get_dam_gen_res_data", + "get_dam_load_res_as_offers", + "get_dam_load_res_data", + "get_dam_ptp_obl_bid_awards", + "get_dam_ptp_obl_bids", + "get_dam_ptp_obl_opt", + "get_dam_ptp_obl_opt_awards", + "get_dam_qse_self_as", + # Load and forecasting + "get_actual_system_load_by_weather_zone", + "get_actual_system_load_by_forecast_zone", + "get_wpp_hourly_average_actual_forecast", + "get_wpp_actual_5min_avg_values", + "get_wpp_hourly_actual_forecast_geo", + "get_wpp_actual_5min_avg_values_geo", + "get_spp_hourly_average_actual_forecast", + "get_spp_actual_5min_avg_values", + "get_spp_hourly_actual_forecast_geo", + "get_spp_actual_5min_avg_values_geo", + # Pricing + "get_dam_settlement_point_prices", + "get_dam_shadow_prices", + "get_dam_clear_price_for_cap", + "get_dam_system_lambda", + "get_sced_system_lambda", + "get_lmp_electrical_bus", + "get_lmp_node_zone_hub", + "get_spp_node_zone_hub", + "get_rtd_lmp_node_zone_hub", + "get_shadow_prices_bound_transmission_constraint", + # Other + "get_hourly_res_outage_cap", + "get_total_as_service_offers", + "get_load_distribution_factors", +] + + +class TestSimpleEndpointWrappers: + """Test simple endpoint wrapper methods using parametrization.""" + + @pytest.mark.parametrize("method_name", SIMPLE_ENDPOINT_METHODS) + @patch.object(ERCOT, "_call_endpoint") + def test_endpoint_wrapper(self, mock_call_endpoint, method_name): + """Test that endpoint wrapper methods call _call_endpoint and return DataFrame.""" + # Setup mock + mock_call_endpoint.return_value = pd.DataFrame({"test": [1, 2, 3]}) + + # Create client and call method + ercot = ERCOT() + method = getattr(ercot, method_name) + result = method() + + # Verify + assert isinstance(result, pd.DataFrame) + mock_call_endpoint.assert_called_once() diff --git a/tests/test_ercot_helpers.py b/tests/test_ercot_helpers.py new file mode 100644 index 0000000..8f29582 --- /dev/null +++ b/tests/test_ercot_helpers.py @@ -0,0 +1,441 @@ +from unittest.mock import MagicMock + +import pandas as pd +import pytest + +from pyercot.errors import UnexpectedStatus + +from tinygrid.constants.ercot import LOAD_ZONES, LocationType, Market, TRADING_HUBS +from tinygrid.errors import GridAPIError, GridAuthenticationError, GridTimeoutError +from tinygrid.ercot import ERCOT + + +def make_ercot(**kwargs: object) -> ERCOT: + """Create an ERCOT instance with sensible defaults for unit tests.""" + return ERCOT(auth=kwargs.get("auth")) # type: ignore[arg-type] + + +def test_get_client_wraps_unexpected_auth_error() -> None: + auth = MagicMock() + auth.get_token.side_effect = ValueError("boom") + auth.get_subscription_key.return_value = "sub" + + client = make_ercot(auth=auth) + + with pytest.raises(GridAuthenticationError): + client._get_client() + + auth.get_subscription_key.assert_not_called() + + +def test_get_client_creates_authenticated_client(monkeypatch: pytest.MonkeyPatch) -> None: + created: dict[str, object] = {} + + class DummyAuthenticatedClient: + def __init__(self, base_url, token, timeout, verify_ssl, raise_on_unexpected_status): # type: ignore[no-untyped-def] + created["token"] = token + created["base_url"] = base_url + created["headers"] = {} + self.token = token + + def with_headers(self, headers): + created["headers"] = headers + return self + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + auth = MagicMock() + auth.get_token.return_value = "token123" + auth.get_subscription_key.return_value = "subkey" + + monkeypatch.setattr("tinygrid.ercot.AuthenticatedClient", DummyAuthenticatedClient) + + client = make_ercot(auth=auth) + result = client._get_client() + + assert isinstance(result, DummyAuthenticatedClient) + assert created["headers"] == {"Ocp-Apim-Subscription-Key": "subkey"} + + +def test_get_client_refreshes_token_and_closes_old(monkeypatch: pytest.MonkeyPatch) -> None: + closed = {"value": False} + + class DummyAuthenticatedClient: + def __init__(self, base_url=None, token=None, timeout=None, verify_ssl=None, raise_on_unexpected_status=None): # type: ignore[no-untyped-def] + self.token = token + + def with_headers(self, headers): + return self + + def __exit__(self, exc_type, exc, tb): + closed["value"] = True + return False + + auth = MagicMock() + auth.get_token.return_value = "new" + auth.get_subscription_key.return_value = "sub" + + monkeypatch.setattr("tinygrid.ercot.AuthenticatedClient", DummyAuthenticatedClient) + + client = make_ercot(auth=auth) + client._client = DummyAuthenticatedClient(token="old") + + result = client._get_client() + + assert isinstance(result, DummyAuthenticatedClient) + assert closed["value"] is True + assert result.token == "new" + +def test_handle_api_error_wraps_exceptions() -> None: + client = make_ercot() + unexpected = UnexpectedStatus(status_code=500, content=b"err") + + with pytest.raises(GridAPIError): + client._handle_api_error(unexpected, endpoint="/test") + + with pytest.raises(GridTimeoutError): + client._handle_api_error(TimeoutError(), endpoint="/test") + + +def test_flatten_dict_handles_nested_lists_and_nulls() -> None: + client = make_ercot() + data = { + "meta": {"id": 1, "name": "x"}, + "values": [{"foo": "bar"}, {"foo": "baz"}], + "tags": ["a", "b", "c"], + "_links": {"self": "skip"}, + "none_val": None, + } + + flattened = client._flatten_dict_for_dataframe(data) + + assert flattened["meta.id"] == 1 + assert flattened["values_count"] == 2 + assert "values" in flattened and "skip" not in str(flattened) + assert flattened["tags"] == "a, b, c" + assert "none_val" in flattened and flattened["none_val"] is None + + +def test_products_to_dataframe_handles_additional_properties() -> None: + client = make_ercot() + response = { + "additional_properties": { + "_embedded": { + "products": [ + { + "emilId": "np1", + "name": "test", + "details": {"nested": 1}, + } + ] + } + } + } + + df = client._products_to_dataframe(response) + + assert not df.empty + assert list(df["emilId"])[0] == "np1" + assert df.filter(like="details.nested").iloc[0, 0] == 1 + + +def test_product_history_to_dataframe_expands_archives() -> None: + client = make_ercot() + response = { + "emilId": "np1", + "archives": [ + {"version": 1, "size": 10}, + {"version": 2, "size": 20}, + ], + } + + df = client._product_history_to_dataframe(response) + + assert len(df) == 2 + assert set(df["version"].tolist()) == {1, 2} + assert df["emilId"].iloc[0] == "np1" + + +def test_filter_by_location_excludes_zones_for_resource_nodes() -> None: + client = make_ercot() + df = pd.DataFrame( + { + "Settlement Point": ["HB_HOUSTON", "CUSTOM1", "LZ_NORTH", "CUSTOM2"], + "val": [1, 2, 3, 4], + } + ) + + filtered = client._filter_by_location( + df, + location_type=LocationType.RESOURCE_NODE, + location_column="Settlement Point", + ) + + assert set(filtered["Settlement Point"]) == {"CUSTOM1", "CUSTOM2"} + assert all(pt not in LOAD_ZONES + TRADING_HUBS for pt in filtered["Settlement Point"]) + + +def test_filter_by_location_matches_allowed_types() -> None: + client = make_ercot() + df = pd.DataFrame( + { + "Settlement Point": ["HB_HOUSTON", "LZ_SOUTH", "CUSTOM"], + "val": [1, 2, 3], + } + ) + + filtered = client._filter_by_location( + df, + location_type=[LocationType.TRADING_HUB, LocationType.LOAD_ZONE], + location_column="Settlement Point", + ) + + assert set(filtered["Settlement Point"]) == {"HB_HOUSTON", "LZ_SOUTH"} + + +def test_filter_by_date_handles_alternate_column_names() -> None: + client = make_ercot() + df = pd.DataFrame({"DeliveryDate": ["2024-01-01", "2024-01-03"]}) + + start = pd.Timestamp("2024-01-01") + end = pd.Timestamp("2024-01-02") + + filtered = client._filter_by_date(df, start, end, date_column="Delivery Date") + + assert len(filtered) == 1 + assert filtered.iloc[0]["DeliveryDate"] == "2024-01-01" + + +def test_add_time_columns_from_interval_fields() -> None: + client = make_ercot() + df = pd.DataFrame({"Date": ["2024-01-01"], "Hour": [1], "Interval": [2]}) + + result = client._add_time_columns(df.copy()) + + assert "Time" in result and "End Time" in result + assert result["Time"].dt.tz is not None + assert (result["End Time"] - result["Time"]).dt.total_seconds().iloc[0] == 900 + + +def test_add_time_columns_from_hour_ending_strings() -> None: + client = make_ercot() + df = pd.DataFrame({"Date": ["2024-01-01"], "Hour Ending": ["01:00"]}) + + result = client._add_time_columns(df.copy()) + + assert "Time" in result and "End Time" in result + assert result["Time"].dt.hour.iloc[0] == 0 + assert result["End Time"].dt.hour.iloc[0] == 1 + + +def test_get_shadow_prices_routes_to_archive_for_day_ahead(monkeypatch: pytest.MonkeyPatch) -> None: + class HistoricalERCOT(ERCOT): + def _needs_historical(self, start: pd.Timestamp, market: str) -> bool: # type: ignore[override] + return True + + def _filter_by_date(self, df: pd.DataFrame, *args, **kwargs) -> pd.DataFrame: # type: ignore[override] + return df + + def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] + return df + + client = HistoricalERCOT() + calls: dict[str, object] = {} + + archive = MagicMock() + archive.fetch_historical.return_value = pd.DataFrame({"Delivery Date": ["2024-01-01"]}) + monkeypatch.setattr(client, "_get_archive", lambda: archive) + monkeypatch.setattr( + client, + "get_dam_shadow_prices", + lambda **kwargs: calls.setdefault("live", kwargs) or pd.DataFrame(), + ) + + df = client.get_shadow_prices( + start="2024-01-01", end="2024-01-02", market=Market.DAY_AHEAD_HOURLY + ) + + assert not df.empty + archive.fetch_historical.assert_called_once() + assert "live" not in calls + + +def test_get_load_uses_historical_weather_zone(monkeypatch: pytest.MonkeyPatch) -> None: + class HistoricalERCOT(ERCOT): + def _needs_historical(self, start: pd.Timestamp, market: str) -> bool: # type: ignore[override] + return True + + def _filter_by_date(self, df: pd.DataFrame, *args, **kwargs) -> pd.DataFrame: # type: ignore[override] + return df + + def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] + return df + + client = HistoricalERCOT() + + archive = MagicMock() + archive.fetch_historical.return_value = pd.DataFrame({"Oper Day": ["2024-01-01"], "value": [1]}) + monkeypatch.setattr(client, "_get_archive", lambda: archive) + monkeypatch.setattr( + client, + "get_actual_system_load_by_weather_zone", + lambda **kwargs: pd.DataFrame(), + ) + + df = client.get_load(start="2024-01-01", end="2024-01-02", by="weather_zone") + + assert df["value"].iloc[0] == 1 + archive.fetch_historical.assert_called_once() + + +def test_get_shadow_prices_real_time_live_path(monkeypatch: pytest.MonkeyPatch) -> None: + class LiveERCOT(ERCOT): + def _needs_historical(self, start: pd.Timestamp, market: str) -> bool: # type: ignore[override] + return False + + def _filter_by_date(self, df: pd.DataFrame, *args, **kwargs) -> pd.DataFrame: # type: ignore[override] + return df + + def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] + return df + + client = LiveERCOT() + monkeypatch.setattr( + client, + "get_shadow_prices_bound_transmission_constraint", + lambda **kwargs: pd.DataFrame({"Delivery Date": ["2024-01-01"]}), + ) + + df = client.get_shadow_prices(start="2024-01-01", end="2024-01-02", market=Market.REAL_TIME_SCED) + + assert not df.empty + + +def test_get_wind_forecast_mixes_historical_and_live(monkeypatch: pytest.MonkeyPatch) -> None: + class MixedERCOT(ERCOT): + def _needs_historical(self, start: pd.Timestamp, market: str) -> bool: # type: ignore[override] + return market == "forecast" + + def _filter_by_date(self, df: pd.DataFrame, *args, **kwargs) -> pd.DataFrame: # type: ignore[override] + return df + + def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] + return df + + client = MixedERCOT() + + archive = MagicMock() + archive.fetch_historical.return_value = pd.DataFrame({"Posted Datetime": ["2024-01-01"]}) + monkeypatch.setattr(client, "_get_archive", lambda: archive) + monkeypatch.setattr( + client, + "get_wpp_hourly_actual_forecast_geo", + lambda **kwargs: pd.DataFrame({"Posted Datetime": ["2024-01-02"]}), + ) + + df_region = client.get_wind_forecast(start="2024-01-01", end="2024-01-02", by_region=True) + df_global = client.get_wind_forecast(start="2024-01-01", end="2024-01-02", by_region=False) + + assert not df_region.empty and not df_global.empty + assert archive.fetch_historical.call_count == 2 + + +def test_get_solar_forecast_live_region(monkeypatch: pytest.MonkeyPatch) -> None: + class LiveERCOT(ERCOT): + def _needs_historical(self, start: pd.Timestamp, market: str) -> bool: # type: ignore[override] + return False + + def _filter_by_date(self, df: pd.DataFrame, *args, **kwargs) -> pd.DataFrame: # type: ignore[override] + return df + + def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] + return df + + client = LiveERCOT() + monkeypatch.setattr( + client, + "get_spp_hourly_actual_forecast_geo", + lambda **kwargs: pd.DataFrame({"Posted Datetime": ["2024-01-01"]}), + ) + + df = client.get_solar_forecast(start="2024-01-01", end="2024-01-02", by_region=True) + + assert not df.empty + + +def test_get_solar_forecast_historical(monkeypatch: pytest.MonkeyPatch) -> None: + class HistoricalERCOT(ERCOT): + def _needs_historical(self, start: pd.Timestamp, market: str) -> bool: # type: ignore[override] + return True + + def _filter_by_date(self, df: pd.DataFrame, *args, **kwargs) -> pd.DataFrame: # type: ignore[override] + return df + + def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] + return df + + client = HistoricalERCOT() + archive = MagicMock() + archive.fetch_historical.return_value = pd.DataFrame({"Posted Datetime": ["2024-01-01"]}) + monkeypatch.setattr(client, "_get_archive", lambda: archive) + + df = client.get_solar_forecast(start="2024-01-01", end="2024-01-02", by_region=False) + + assert not df.empty + archive.fetch_historical.assert_called_once() + + +def test_get_60_day_dam_disclosure_uses_archive(monkeypatch: pytest.MonkeyPatch) -> None: + class DisclosureERCOT(ERCOT): + def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] + return df + + client = DisclosureERCOT() + + archive = MagicMock() + archive.fetch_historical.return_value = pd.DataFrame({"DeliveryDate": ["2024-01-01"]}) + monkeypatch.setattr(client, "_get_archive", lambda: archive) + + for method_name in [ + "get_dam_gen_res_as_offers", + "get_dam_load_res_data", + "get_dam_load_res_as_offers", + "get_dam_energy_only_offers", + "get_dam_energy_only_offer_awards", + "get_dam_energy_bids", + "get_dam_energy_bid_awards", + "get_dam_ptp_obl_bids", + "get_dam_ptp_obl_bid_awards", + "get_dam_ptp_obl_opt", + "get_dam_ptp_obl_opt_awards", + ]: + monkeypatch.setattr(client, method_name, lambda **kwargs: pd.DataFrame({"dummy": [1]})) + + reports = client.get_60_day_dam_disclosure("today") + + assert "dam_gen_resource" in reports + archive.fetch_historical.assert_called_once() + + +def test_get_60_day_sced_disclosure(monkeypatch: pytest.MonkeyPatch) -> None: + class DisclosureERCOT(ERCOT): + def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] + return df + + client = DisclosureERCOT() + + archive = MagicMock() + archive.fetch_historical.return_value = pd.DataFrame({"DeliveryDate": ["2024-01-01"]}) + monkeypatch.setattr(client, "_get_archive", lambda: archive) + + monkeypatch.setattr(client, "get_sced_gen_res_data", lambda **kwargs: pd.DataFrame({"a": [1]})) + monkeypatch.setattr(client, "get_load_res_data_in_sced", lambda **kwargs: pd.DataFrame({"b": [2]})) + + reports = client.get_60_day_sced_disclosure("today") + + assert "sced_smne" in reports + archive.fetch_historical.assert_called_once() diff --git a/tests/test_historical_archive.py b/tests/test_historical_archive.py new file mode 100644 index 0000000..c63c74f --- /dev/null +++ b/tests/test_historical_archive.py @@ -0,0 +1,222 @@ +import io +from zipfile import ZipFile + +import pandas as pd +import pytest +import httpx + +from tinygrid.errors import GridAPIError +from tinygrid.historical.ercot import ArchiveLink, ERCOTArchive + + +class DummyClient: + def __init__(self, auth=None) -> None: + self.auth = auth + + +def make_zip_bytes(csv: str) -> io.BytesIO: + """Create a zip file in memory containing a CSV payload.""" + buf = io.BytesIO() + with ZipFile(buf, "w") as zf: + zf.writestr("data.csv", csv) + buf.seek(0) + return buf + + +def test_fetch_historical_returns_empty_when_no_links() -> None: + class NoLinksArchive(ERCOTArchive): + def get_archive_links(self, emil_id, start, end): # type: ignore[override] + return [] + + archive = NoLinksArchive(client=DummyClient()) + + df = archive.fetch_historical( + "/np6-905-cd/spp_node_zone_hub", + pd.Timestamp("2024-01-01"), + pd.Timestamp("2024-01-02"), + ) + + assert df.empty + + +def test_fetch_historical_adds_post_datetime() -> None: + class DownloadArchive(ERCOTArchive): + def get_archive_links(self, emil_id, start, end): # type: ignore[override] + return [ + ArchiveLink( + doc_id="123", + url="https://example.com/doc?id=123", + post_datetime="2024-01-01T00:00:00Z", + ) + ] + + def bulk_download(self, doc_ids, emil_id): # type: ignore[override] + csv_bytes = make_zip_bytes("col1,col2\n1,2\n") + return [(csv_bytes, "123.zip")] + + archive = DownloadArchive(client=DummyClient()) + + df = archive.fetch_historical( + "/np6-905-cd/spp_node_zone_hub", + pd.Timestamp("2024-01-01"), + pd.Timestamp("2024-01-02"), + add_post_datetime=True, + ) + + assert not df.empty + assert df["postDatetime"].iloc[0] == "2024-01-01T00:00:00Z" + + +def test_fetch_historical_parallel_handles_partial_failures() -> None: + class ParallelArchive(ERCOTArchive): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.links = [ + ArchiveLink(doc_id="ok", url="http://example.com/ok", post_datetime="2024-01-01"), + ArchiveLink(doc_id="fail", url="http://example.com/fail", post_datetime="2024-01-02"), + ] + + def get_archive_links(self, emil_id, start, end): # type: ignore[override] + return self.links + + def _download_single(self, link: ArchiveLink) -> pd.DataFrame: # type: ignore[override] + if link.doc_id == "fail": + raise ValueError("boom") + return pd.DataFrame({"DeliveryDate": ["2024-01-01"], "val": [1]}) + + archive = ParallelArchive(client=DummyClient(), max_concurrent=2) + + df = archive.fetch_historical_parallel( + "/np6-905-cd/spp_node_zone_hub", + pd.Timestamp("2024-01-01"), + pd.Timestamp("2024-01-02"), + add_post_datetime=True, + ) + + assert len(df) == 1 + assert df["postDatetime"].iloc[0] == "2024-01-01" + + +def test_fetch_historical_parallel_returns_empty_when_no_links() -> None: + class NoLinksArchive(ERCOTArchive): + def get_archive_links(self, emil_id, start, end): # type: ignore[override] + return [] + + archive = NoLinksArchive(client=DummyClient()) + df = archive.fetch_historical_parallel( + "/np6-905-cd/spp_node_zone_hub", + pd.Timestamp("2024-01-01"), + pd.Timestamp("2024-01-02"), + ) + + assert df.empty + + +def test_fetch_historical_handles_parse_failure() -> None: + class BadDownloadArchive(ERCOTArchive): + def get_archive_links(self, emil_id, start, end): # type: ignore[override] + return [ + ArchiveLink(doc_id="bad", url="https://example.com/bad", post_datetime="2024-01-01"), + ] + + def bulk_download(self, doc_ids, emil_id): # type: ignore[override] + # Return invalid zip content to trigger parse failure + return [(io.BytesIO(b"not-a-zip"), "bad.zip")] + + archive = BadDownloadArchive(client=DummyClient()) + + df = archive.fetch_historical( + "/np6-905-cd/spp_node_zone_hub", + pd.Timestamp("2024-01-01"), + pd.Timestamp("2024-01-02"), + add_post_datetime=True, + ) + + assert df.empty + + +def test_fetch_historical_parallel_all_failures_returns_empty() -> None: + class AllFailArchive(ERCOTArchive): + def get_archive_links(self, emil_id, start, end): # type: ignore[override] + return [ + ArchiveLink(doc_id="bad", url="http://example.com/bad", post_datetime="2024-01-01"), + ] + + def _download_single(self, link: ArchiveLink) -> pd.DataFrame: # type: ignore[override] + raise RuntimeError("fail") + + archive = AllFailArchive(client=DummyClient(), max_concurrent=1) + + df = archive.fetch_historical_parallel( + "/np6-905-cd/spp_node_zone_hub", + pd.Timestamp("2024-01-01"), + pd.Timestamp("2024-01-02"), + ) + + assert df.empty + + +def test_download_single_reads_zip(monkeypatch: pytest.MonkeyPatch) -> None: + class DownloadArchive(ERCOTArchive): + def _make_request(self, url, parse_json=True): # type: ignore[override] + return make_zip_bytes("col1\n1\n").getvalue() + + archive = DownloadArchive(client=DummyClient()) + link = ArchiveLink(doc_id="1", url="http://example.com/1", post_datetime="2024-01-01") + + df = archive._download_single(link) + + assert df.iloc[0]["col1"] == 1 + + +def test_make_request_handles_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + class TimeoutClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + raise httpx.TimeoutException("timeout") + + def __exit__(self, exc_type, exc, tb): + return False + + archive = ERCOTArchive(client=DummyClient()) + monkeypatch.setattr(httpx, "Client", TimeoutClient) + + with pytest.raises(GridAPIError): + archive._make_request("http://example.com") + + +def test_make_request_handles_request_error(monkeypatch: pytest.MonkeyPatch) -> None: + class ErrorClient: + def __init__(self, *args, **kwargs): + self + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, *args, **kwargs): + raise httpx.RequestError("fail", request=None) + + archive = ERCOTArchive(client=DummyClient()) + monkeypatch.setattr(httpx, "Client", ErrorClient) + + with pytest.raises(GridAPIError): + archive._make_request("http://example.com") + + +def test_get_auth_headers_uses_client_auth(monkeypatch: pytest.MonkeyPatch) -> None: + auth = type( + "Auth", + (), + {"get_token": lambda self: "token", "get_subscription_key": lambda self: "sub"}, + )() + archive = ERCOTArchive(client=DummyClient(auth=auth)) + + headers = archive._get_auth_headers() + + assert headers["Authorization"] == "Bearer token" + assert headers["Ocp-Apim-Subscription-Key"] == "sub" diff --git a/tests/test_tz.py b/tests/test_tz.py new file mode 100644 index 0000000..4d4c7c7 --- /dev/null +++ b/tests/test_tz.py @@ -0,0 +1,422 @@ +"""Tests for timezone utilities.""" + +import pandas as pd +import pytest +import pytz + +from tinygrid.constants.ercot import ERCOT_TIMEZONE +from tinygrid.utils.tz import ( + _localize_single, + dst_flag_to_ambiguous, + get_utc_offset, + is_dst_transition_date, + localize_with_dst, + resolve_ambiguous_dst, +) + + +class TestResolveAmbiguousDST: + """Test resolve_ambiguous_dst function.""" + + def test_resolve_ambiguous_dst_with_dst_flags(self): + """Test resolving ambiguous DST timestamps with DSTFlag.""" + # During fall back, 1:30 AM occurs twice + timestamps = pd.Series( + [ + "2023-11-05 01:30:00", + "2023-11-05 01:30:00", + ] + ) + dst_flags = pd.Series([True, False]) # First is DST, second is Standard + + result = resolve_ambiguous_dst(timestamps, dst_flags) + + assert result.dt.tz is not None + assert len(result) == 2 + # Both timestamps should be timezone-aware + assert result[0].tz is not None + assert result[1].tz is not None + + def test_resolve_ambiguous_dst_without_dst_flags(self): + """Test resolving ambiguous DST timestamps without DSTFlag (defaults to DST).""" + timestamps = pd.Series(["2023-11-05 01:30:00"]) + + result = resolve_ambiguous_dst(timestamps) + + assert result.dt.tz is not None + assert len(result) == 1 + + def test_resolve_ambiguous_dst_with_null_dst_flags(self): + """Test resolving with null DSTFlag values (should default to True).""" + timestamps = pd.Series(["2023-11-05 01:30:00", "2023-11-05 02:30:00"]) + dst_flags = pd.Series([None, True]) + + result = resolve_ambiguous_dst(timestamps, dst_flags) + + assert result.dt.tz is not None + assert len(result) == 2 + + def test_resolve_ambiguous_dst_non_ambiguous_times(self): + """Test resolving non-ambiguous timestamps.""" + timestamps = pd.Series(["2023-06-15 12:00:00", "2023-06-15 13:00:00"]) + + result = resolve_ambiguous_dst(timestamps) + + assert result.dt.tz is not None + assert len(result) == 2 + + def test_resolve_ambiguous_dst_custom_timezone(self): + """Test resolving with a custom timezone.""" + timestamps = pd.Series(["2023-11-05 01:30:00"]) + + result = resolve_ambiguous_dst(timestamps, tz="US/Eastern") + + assert result.dt.tz is not None + assert str(result[0].tz) == "US/Eastern" + + +class TestLocalizeWithDST: + """Test localize_with_dst function.""" + + def test_localize_naive_timestamp(self): + """Test localizing a naive timestamp.""" + dt = pd.Timestamp("2023-06-15 12:00:00") + + result = localize_with_dst(dt) + + assert result.tz is not None + assert str(result.tz) == ERCOT_TIMEZONE + + def test_localize_string_timestamp(self): + """Test localizing a datetime string.""" + dt = "2023-06-15 12:00:00" + + result = localize_with_dst(dt) + + assert result.tz is not None + assert str(result.tz) == ERCOT_TIMEZONE + + def test_localize_already_aware_timestamp(self): + """Test localizing an already timezone-aware timestamp (should convert).""" + dt = pd.Timestamp("2023-06-15 12:00:00", tz="UTC") + + result = localize_with_dst(dt) + + assert result.tz is not None + assert str(result.tz) == ERCOT_TIMEZONE + + def test_localize_ambiguous_time_dst(self): + """Test localizing ambiguous time with DST=True.""" + # During fall back, 1:30 AM occurs twice + dt = pd.Timestamp("2023-11-05 01:30:00") + + result = localize_with_dst(dt, ambiguous=True) + + assert result.tz is not None + + def test_localize_ambiguous_time_standard(self): + """Test localizing ambiguous time with DST=False.""" + # During fall back, 1:30 AM occurs twice + dt = pd.Timestamp("2023-11-05 01:30:00") + + result = localize_with_dst(dt, ambiguous=False) + + assert result.tz is not None + + def test_localize_custom_timezone(self): + """Test localizing to a custom timezone.""" + dt = pd.Timestamp("2023-06-15 12:00:00") + + result = localize_with_dst(dt, tz="US/Eastern") + + assert result.tz is not None + assert str(result.tz) == "US/Eastern" + + +class TestDSTFlagToAmbiguous: + """Test dst_flag_to_ambiguous function.""" + + def test_dst_flag_true_values(self): + """Test converting True DST flags.""" + dst_flag = pd.Series([True, True, True]) + + result = dst_flag_to_ambiguous(dst_flag) + + assert all(result == [True, True, True]) + + def test_dst_flag_false_values(self): + """Test converting False DST flags.""" + dst_flag = pd.Series([False, False, False]) + + result = dst_flag_to_ambiguous(dst_flag) + + assert all(result == [False, False, False]) + + def test_dst_flag_mixed_values(self): + """Test converting mixed DST flags.""" + dst_flag = pd.Series([True, False, True]) + + result = dst_flag_to_ambiguous(dst_flag) + + assert result[0] + assert not result[1] + assert result[2] + + def test_dst_flag_with_nulls(self): + """Test converting DST flags with null values (should default to True).""" + dst_flag = pd.Series([True, None, False]) + + result = dst_flag_to_ambiguous(dst_flag) + + assert result[0] + assert result[1] # Null should become True + assert not result[2] + + def test_dst_flag_all_nulls(self): + """Test converting all null DST flags.""" + dst_flag = pd.Series([None, None, None]) + + result = dst_flag_to_ambiguous(dst_flag) + + assert all(result == [True, True, True]) + + +class TestIsDSTTransitionDate: + """Test is_dst_transition_date function.""" + + def test_dst_transition_spring_forward(self): + """Test detecting spring forward DST transition.""" + # March 2023 spring forward (2nd Sunday of March) + date = pd.Timestamp("2023-03-12", tz=ERCOT_TIMEZONE) + + result = is_dst_transition_date(date) + + # This date should be a DST transition date + assert isinstance(result, bool) + + def test_dst_transition_fall_back(self): + """Test detecting fall back DST transition.""" + # November 2023 fall back (1st Sunday of November) + date = pd.Timestamp("2023-11-05", tz=ERCOT_TIMEZONE) + + result = is_dst_transition_date(date) + + # This date should be a DST transition date + assert isinstance(result, bool) + + def test_non_dst_transition_date(self): + """Test detecting non-DST transition date.""" + date = pd.Timestamp("2023-06-15", tz=ERCOT_TIMEZONE) + + result = is_dst_transition_date(date) + + # Regular date should not be a DST transition + assert result is False + + def test_dst_transition_custom_timezone(self): + """Test detecting DST transition in custom timezone.""" + date = pd.Timestamp("2023-03-12", tz="US/Eastern") + + result = is_dst_transition_date(date, tz="US/Eastern") + + assert isinstance(result, bool) + + +class TestGetUTCOffset: + """Test get_utc_offset function.""" + + def test_get_utc_offset_cdt(self): + """Test getting UTC offset for CDT (Central Daylight Time).""" + # Summer date in Central Daylight Time (UTC-5) + dt = pd.Timestamp("2023-06-15 12:00:00", tz=ERCOT_TIMEZONE) + + offset = get_utc_offset(dt) + + assert offset == -5 # CDT is UTC-5 + + def test_get_utc_offset_cst(self): + """Test getting UTC offset for CST (Central Standard Time).""" + # Winter date in Central Standard Time (UTC-6) + dt = pd.Timestamp("2023-01-15 12:00:00", tz=ERCOT_TIMEZONE) + + offset = get_utc_offset(dt) + + assert offset == -6 # CST is UTC-6 + + def test_get_utc_offset_utc(self): + """Test getting UTC offset for UTC timezone.""" + dt = pd.Timestamp("2023-06-15 12:00:00", tz="UTC") + + offset = get_utc_offset(dt) + + assert offset == 0 # UTC is offset 0 + + def test_get_utc_offset_eastern(self): + """Test getting UTC offset for Eastern timezone.""" + # Summer date in Eastern Daylight Time (UTC-4) + dt = pd.Timestamp("2023-06-15 12:00:00", tz="US/Eastern") + + offset = get_utc_offset(dt) + + assert offset == -4 # EDT is UTC-4 + + def test_get_utc_offset_naive_timestamp_raises(self): + """Test that naive timestamp raises ValueError.""" + dt = pd.Timestamp("2023-06-15 12:00:00") + + with pytest.raises(ValueError, match="Timestamp must be timezone-aware"): + get_utc_offset(dt) + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_resolve_ambiguous_dst_empty_series(self): + """Test resolving empty series.""" + timestamps = pd.Series([], dtype="datetime64[ns]") + + result = resolve_ambiguous_dst(timestamps) + + assert len(result) == 0 + assert result.dt.tz is not None + + def test_localize_with_dst_already_in_target_tz(self): + """Test localizing timestamp already in target timezone.""" + dt = pd.Timestamp("2023-06-15 12:00:00", tz=ERCOT_TIMEZONE) + + result = localize_with_dst(dt, tz=ERCOT_TIMEZONE) + + assert result.tz is not None + assert str(result.tz) == ERCOT_TIMEZONE + + def test_dst_flag_to_ambiguous_empty_series(self): + """Test converting empty DST flag series.""" + dst_flag = pd.Series([], dtype=bool) + + result = dst_flag_to_ambiguous(dst_flag) + + assert len(result) == 0 + + def test_localize_with_dst_nonexistent_shift_forward(self): + """Test localizing nonexistent time with shift_forward.""" + # During spring forward, 2:30 AM doesn't exist + dt = pd.Timestamp("2023-03-12 02:30:00") + + result = localize_with_dst(dt, tz=ERCOT_TIMEZONE, nonexistent="shift_forward") + + assert result.tz is not None + # Should be shifted forward to next valid time + + def test_localize_with_dst_nonexistent_shift_backward(self): + """Test localizing nonexistent time with shift_backward.""" + # During spring forward, 2:30 AM doesn't exist + dt = pd.Timestamp("2023-03-12 02:30:00") + + result = localize_with_dst(dt, tz=ERCOT_TIMEZONE, nonexistent="shift_backward") + + assert result.tz is not None + # Should be shifted backward to previous valid time + assert result.hour in {1, 3} + + +class TestAdditionalDSTPaths: + def test_resolve_ambiguous_dst_fallback(self, monkeypatch: pytest.MonkeyPatch): + ts = pd.Series([pd.Timestamp("2021-11-07 01:30")]) + + from pandas.core.indexes.accessors import DatetimeProperties + + def raise_ambiguous(self, tz=None, ambiguous=None): # type: ignore[override] + raise pytz.exceptions.AmbiguousTimeError() + + monkeypatch.setattr(DatetimeProperties, "tz_localize", raise_ambiguous, raising=False) + + result = resolve_ambiguous_dst(ts) + + assert not result.isna().all() + + def test_localize_with_dst_nonexistent_explicit_backward(self): + ts = "2024-03-10 02:15" + result = localize_with_dst(ts, nonexistent="shift_backward") + assert result.hour in {1, 3} + + def test_localize_with_dst_nonexistent_shift_forward_fallback(self, monkeypatch: pytest.MonkeyPatch): + orig = pd.Timestamp.tz_localize + calls = {"count": 0} + + def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[override] + calls["count"] += 1 + if calls["count"] == 1: + raise pytz.exceptions.NonExistentTimeError() + return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) + + monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) + + ts = pd.Timestamp("2024-03-10 02:15") + result = localize_with_dst(ts, nonexistent="shift_forward") + assert result.tz is not None + + def test_localize_with_dst_nonexistent_shift_backward_fallback(self, monkeypatch: pytest.MonkeyPatch): + orig = pd.Timestamp.tz_localize + calls = {"count": 0} + + def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[override] + calls["count"] += 1 + if calls["count"] == 1: + raise pytz.exceptions.NonExistentTimeError() + return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) + + monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) + + ts = pd.Timestamp("2024-03-10 02:15") + result = localize_with_dst(ts, nonexistent="shift_backward") + assert result.tz is not None + + def test_localize_with_dst_ambiguous_fallback(self, monkeypatch: pytest.MonkeyPatch): + orig = pd.Timestamp.tz_localize + calls = {"count": 0} + + def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[override] + calls["count"] += 1 + if calls["count"] == 1: + raise pytz.exceptions.AmbiguousTimeError() + return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) + + monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) + + ts = pd.Timestamp("2021-11-07 01:30") + result = localize_with_dst(ts) + assert result.tz is not None + + def test_localize_with_dst_nonexistent_invalid_mode(self): + ts = "2024-03-10 02:15" + with pytest.raises(ValueError): + localize_with_dst(ts, nonexistent="raise") + + def test_localize_with_dst_nonexistent_raise_branch(self, monkeypatch: pytest.MonkeyPatch): + ts = pd.Timestamp("2024-03-10 02:15") + + def raise_nonexistent(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[override] + raise pytz.exceptions.NonExistentTimeError() + + monkeypatch.setattr(pd.Timestamp, "tz_localize", raise_nonexistent, raising=False) + + with pytest.raises(ValueError): + localize_with_dst(ts, nonexistent="raise") + + def test_localize_single_returns_nat(self): + assert pd.isna(_localize_single(pd.NaT, ERCOT_TIMEZONE)) + + def test_localize_single_success(self): + ts = pd.Timestamp("2024-01-01 00:00:00") + result = _localize_single(ts, ERCOT_TIMEZONE) + assert result.tz is not None + + def test_get_utc_offset_with_missing_offset(self): + class Dummy: + tz = "UTC" + + def utcoffset(self): + return None + + with pytest.raises(ValueError): + get_utc_offset(Dummy()) # type: ignore[arg-type] \ No newline at end of file From cbb6531aea990ecdefbf077cfcdcdcfa755e6995 Mon Sep 17 00:00:00 2001 From: Kevin Kenyon Date: Sun, 28 Dec 2025 15:27:22 -0600 Subject: [PATCH 2/6] Fix formatting/linter issues --- tests/test_ercot_helpers.py | 97 ++++++++++++++++++++++++-------- tests/test_historical_archive.py | 28 +++++++-- tests/test_tz.py | 26 ++++++--- 3 files changed, 113 insertions(+), 38 deletions(-) diff --git a/tests/test_ercot_helpers.py b/tests/test_ercot_helpers.py index 8f29582..6c9b55a 100644 --- a/tests/test_ercot_helpers.py +++ b/tests/test_ercot_helpers.py @@ -2,12 +2,11 @@ import pandas as pd import pytest - from pyercot.errors import UnexpectedStatus -from tinygrid.constants.ercot import LOAD_ZONES, LocationType, Market, TRADING_HUBS -from tinygrid.errors import GridAPIError, GridAuthenticationError, GridTimeoutError +from tinygrid.constants.ercot import LOAD_ZONES, TRADING_HUBS, LocationType, Market from tinygrid.ercot import ERCOT +from tinygrid.errors import GridAPIError, GridAuthenticationError, GridTimeoutError def make_ercot(**kwargs: object) -> ERCOT: @@ -28,11 +27,15 @@ def test_get_client_wraps_unexpected_auth_error() -> None: auth.get_subscription_key.assert_not_called() -def test_get_client_creates_authenticated_client(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_client_creates_authenticated_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: created: dict[str, object] = {} class DummyAuthenticatedClient: - def __init__(self, base_url, token, timeout, verify_ssl, raise_on_unexpected_status): # type: ignore[no-untyped-def] + def __init__( + self, base_url, token, timeout, verify_ssl, raise_on_unexpected_status + ): # type: ignore[no-untyped-def] created["token"] = token created["base_url"] = base_url created["headers"] = {} @@ -61,11 +64,20 @@ def __exit__(self, exc_type, exc, tb): assert created["headers"] == {"Ocp-Apim-Subscription-Key": "subkey"} -def test_get_client_refreshes_token_and_closes_old(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_client_refreshes_token_and_closes_old( + monkeypatch: pytest.MonkeyPatch, +) -> None: closed = {"value": False} class DummyAuthenticatedClient: - def __init__(self, base_url=None, token=None, timeout=None, verify_ssl=None, raise_on_unexpected_status=None): # type: ignore[no-untyped-def] + def __init__( + self, + base_url=None, + token=None, + timeout=None, + verify_ssl=None, + raise_on_unexpected_status=None, + ): # type: ignore[no-untyped-def] self.token = token def with_headers(self, headers): @@ -90,6 +102,7 @@ def __exit__(self, exc_type, exc, tb): assert closed["value"] is True assert result.token == "new" + def test_handle_api_error_wraps_exceptions() -> None: client = make_ercot() unexpected = UnexpectedStatus(status_code=500, content=b"err") @@ -139,7 +152,7 @@ def test_products_to_dataframe_handles_additional_properties() -> None: df = client._products_to_dataframe(response) assert not df.empty - assert list(df["emilId"])[0] == "np1" + assert next(iter(df["emilId"])) == "np1" assert df.filter(like="details.nested").iloc[0, 0] == 1 @@ -176,7 +189,9 @@ def test_filter_by_location_excludes_zones_for_resource_nodes() -> None: ) assert set(filtered["Settlement Point"]) == {"CUSTOM1", "CUSTOM2"} - assert all(pt not in LOAD_ZONES + TRADING_HUBS for pt in filtered["Settlement Point"]) + assert all( + pt not in LOAD_ZONES + TRADING_HUBS for pt in filtered["Settlement Point"] + ) def test_filter_by_location_matches_allowed_types() -> None: @@ -232,7 +247,9 @@ def test_add_time_columns_from_hour_ending_strings() -> None: assert result["End Time"].dt.hour.iloc[0] == 1 -def test_get_shadow_prices_routes_to_archive_for_day_ahead(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_shadow_prices_routes_to_archive_for_day_ahead( + monkeypatch: pytest.MonkeyPatch, +) -> None: class HistoricalERCOT(ERCOT): def _needs_historical(self, start: pd.Timestamp, market: str) -> bool: # type: ignore[override] return True @@ -247,7 +264,9 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor calls: dict[str, object] = {} archive = MagicMock() - archive.fetch_historical.return_value = pd.DataFrame({"Delivery Date": ["2024-01-01"]}) + archive.fetch_historical.return_value = pd.DataFrame( + {"Delivery Date": ["2024-01-01"]} + ) monkeypatch.setattr(client, "_get_archive", lambda: archive) monkeypatch.setattr( client, @@ -278,7 +297,9 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor client = HistoricalERCOT() archive = MagicMock() - archive.fetch_historical.return_value = pd.DataFrame({"Oper Day": ["2024-01-01"], "value": [1]}) + archive.fetch_historical.return_value = pd.DataFrame( + {"Oper Day": ["2024-01-01"], "value": [1]} + ) monkeypatch.setattr(client, "_get_archive", lambda: archive) monkeypatch.setattr( client, @@ -310,12 +331,16 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor lambda **kwargs: pd.DataFrame({"Delivery Date": ["2024-01-01"]}), ) - df = client.get_shadow_prices(start="2024-01-01", end="2024-01-02", market=Market.REAL_TIME_SCED) + df = client.get_shadow_prices( + start="2024-01-01", end="2024-01-02", market=Market.REAL_TIME_SCED + ) assert not df.empty -def test_get_wind_forecast_mixes_historical_and_live(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_wind_forecast_mixes_historical_and_live( + monkeypatch: pytest.MonkeyPatch, +) -> None: class MixedERCOT(ERCOT): def _needs_historical(self, start: pd.Timestamp, market: str) -> bool: # type: ignore[override] return market == "forecast" @@ -329,7 +354,9 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor client = MixedERCOT() archive = MagicMock() - archive.fetch_historical.return_value = pd.DataFrame({"Posted Datetime": ["2024-01-01"]}) + archive.fetch_historical.return_value = pd.DataFrame( + {"Posted Datetime": ["2024-01-01"]} + ) monkeypatch.setattr(client, "_get_archive", lambda: archive) monkeypatch.setattr( client, @@ -337,8 +364,12 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor lambda **kwargs: pd.DataFrame({"Posted Datetime": ["2024-01-02"]}), ) - df_region = client.get_wind_forecast(start="2024-01-01", end="2024-01-02", by_region=True) - df_global = client.get_wind_forecast(start="2024-01-01", end="2024-01-02", by_region=False) + df_region = client.get_wind_forecast( + start="2024-01-01", end="2024-01-02", by_region=True + ) + df_global = client.get_wind_forecast( + start="2024-01-01", end="2024-01-02", by_region=False + ) assert not df_region.empty and not df_global.empty assert archive.fetch_historical.call_count == 2 @@ -380,16 +411,22 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor client = HistoricalERCOT() archive = MagicMock() - archive.fetch_historical.return_value = pd.DataFrame({"Posted Datetime": ["2024-01-01"]}) + archive.fetch_historical.return_value = pd.DataFrame( + {"Posted Datetime": ["2024-01-01"]} + ) monkeypatch.setattr(client, "_get_archive", lambda: archive) - df = client.get_solar_forecast(start="2024-01-01", end="2024-01-02", by_region=False) + df = client.get_solar_forecast( + start="2024-01-01", end="2024-01-02", by_region=False + ) assert not df.empty archive.fetch_historical.assert_called_once() -def test_get_60_day_dam_disclosure_uses_archive(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_60_day_dam_disclosure_uses_archive( + monkeypatch: pytest.MonkeyPatch, +) -> None: class DisclosureERCOT(ERCOT): def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignore[override] return df @@ -397,7 +434,9 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor client = DisclosureERCOT() archive = MagicMock() - archive.fetch_historical.return_value = pd.DataFrame({"DeliveryDate": ["2024-01-01"]}) + archive.fetch_historical.return_value = pd.DataFrame( + {"DeliveryDate": ["2024-01-01"]} + ) monkeypatch.setattr(client, "_get_archive", lambda: archive) for method_name in [ @@ -413,7 +452,9 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor "get_dam_ptp_obl_opt", "get_dam_ptp_obl_opt_awards", ]: - monkeypatch.setattr(client, method_name, lambda **kwargs: pd.DataFrame({"dummy": [1]})) + monkeypatch.setattr( + client, method_name, lambda **kwargs: pd.DataFrame({"dummy": [1]}) + ) reports = client.get_60_day_dam_disclosure("today") @@ -429,11 +470,17 @@ def _standardize_columns(self, df: pd.DataFrame) -> pd.DataFrame: # type: ignor client = DisclosureERCOT() archive = MagicMock() - archive.fetch_historical.return_value = pd.DataFrame({"DeliveryDate": ["2024-01-01"]}) + archive.fetch_historical.return_value = pd.DataFrame( + {"DeliveryDate": ["2024-01-01"]} + ) monkeypatch.setattr(client, "_get_archive", lambda: archive) - monkeypatch.setattr(client, "get_sced_gen_res_data", lambda **kwargs: pd.DataFrame({"a": [1]})) - monkeypatch.setattr(client, "get_load_res_data_in_sced", lambda **kwargs: pd.DataFrame({"b": [2]})) + monkeypatch.setattr( + client, "get_sced_gen_res_data", lambda **kwargs: pd.DataFrame({"a": [1]}) + ) + monkeypatch.setattr( + client, "get_load_res_data_in_sced", lambda **kwargs: pd.DataFrame({"b": [2]}) + ) reports = client.get_60_day_sced_disclosure("today") diff --git a/tests/test_historical_archive.py b/tests/test_historical_archive.py index c63c74f..a8c36c3 100644 --- a/tests/test_historical_archive.py +++ b/tests/test_historical_archive.py @@ -1,9 +1,9 @@ import io from zipfile import ZipFile +import httpx import pandas as pd import pytest -import httpx from tinygrid.errors import GridAPIError from tinygrid.historical.ercot import ArchiveLink, ERCOTArchive @@ -72,8 +72,14 @@ class ParallelArchive(ERCOTArchive): def __init__(self, **kwargs): super().__init__(**kwargs) self.links = [ - ArchiveLink(doc_id="ok", url="http://example.com/ok", post_datetime="2024-01-01"), - ArchiveLink(doc_id="fail", url="http://example.com/fail", post_datetime="2024-01-02"), + ArchiveLink( + doc_id="ok", url="http://example.com/ok", post_datetime="2024-01-01" + ), + ArchiveLink( + doc_id="fail", + url="http://example.com/fail", + post_datetime="2024-01-02", + ), ] def get_archive_links(self, emil_id, start, end): # type: ignore[override] @@ -116,7 +122,11 @@ def test_fetch_historical_handles_parse_failure() -> None: class BadDownloadArchive(ERCOTArchive): def get_archive_links(self, emil_id, start, end): # type: ignore[override] return [ - ArchiveLink(doc_id="bad", url="https://example.com/bad", post_datetime="2024-01-01"), + ArchiveLink( + doc_id="bad", + url="https://example.com/bad", + post_datetime="2024-01-01", + ), ] def bulk_download(self, doc_ids, emil_id): # type: ignore[override] @@ -139,7 +149,11 @@ def test_fetch_historical_parallel_all_failures_returns_empty() -> None: class AllFailArchive(ERCOTArchive): def get_archive_links(self, emil_id, start, end): # type: ignore[override] return [ - ArchiveLink(doc_id="bad", url="http://example.com/bad", post_datetime="2024-01-01"), + ArchiveLink( + doc_id="bad", + url="http://example.com/bad", + post_datetime="2024-01-01", + ), ] def _download_single(self, link: ArchiveLink) -> pd.DataFrame: # type: ignore[override] @@ -162,7 +176,9 @@ def _make_request(self, url, parse_json=True): # type: ignore[override] return make_zip_bytes("col1\n1\n").getvalue() archive = DownloadArchive(client=DummyClient()) - link = ArchiveLink(doc_id="1", url="http://example.com/1", post_datetime="2024-01-01") + link = ArchiveLink( + doc_id="1", url="http://example.com/1", post_datetime="2024-01-01" + ) df = archive._download_single(link) diff --git a/tests/test_tz.py b/tests/test_tz.py index 4d4c7c7..3e56310 100644 --- a/tests/test_tz.py +++ b/tests/test_tz.py @@ -328,7 +328,9 @@ def test_resolve_ambiguous_dst_fallback(self, monkeypatch: pytest.MonkeyPatch): def raise_ambiguous(self, tz=None, ambiguous=None): # type: ignore[override] raise pytz.exceptions.AmbiguousTimeError() - monkeypatch.setattr(DatetimeProperties, "tz_localize", raise_ambiguous, raising=False) + monkeypatch.setattr( + DatetimeProperties, "tz_localize", raise_ambiguous, raising=False + ) result = resolve_ambiguous_dst(ts) @@ -339,7 +341,9 @@ def test_localize_with_dst_nonexistent_explicit_backward(self): result = localize_with_dst(ts, nonexistent="shift_backward") assert result.hour in {1, 3} - def test_localize_with_dst_nonexistent_shift_forward_fallback(self, monkeypatch: pytest.MonkeyPatch): + def test_localize_with_dst_nonexistent_shift_forward_fallback( + self, monkeypatch: pytest.MonkeyPatch + ): orig = pd.Timestamp.tz_localize calls = {"count": 0} @@ -355,7 +359,9 @@ def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[ov result = localize_with_dst(ts, nonexistent="shift_forward") assert result.tz is not None - def test_localize_with_dst_nonexistent_shift_backward_fallback(self, monkeypatch: pytest.MonkeyPatch): + def test_localize_with_dst_nonexistent_shift_backward_fallback( + self, monkeypatch: pytest.MonkeyPatch + ): orig = pd.Timestamp.tz_localize calls = {"count": 0} @@ -371,7 +377,9 @@ def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[ov result = localize_with_dst(ts, nonexistent="shift_backward") assert result.tz is not None - def test_localize_with_dst_ambiguous_fallback(self, monkeypatch: pytest.MonkeyPatch): + def test_localize_with_dst_ambiguous_fallback( + self, monkeypatch: pytest.MonkeyPatch + ): orig = pd.Timestamp.tz_localize calls = {"count": 0} @@ -392,13 +400,17 @@ def test_localize_with_dst_nonexistent_invalid_mode(self): with pytest.raises(ValueError): localize_with_dst(ts, nonexistent="raise") - def test_localize_with_dst_nonexistent_raise_branch(self, monkeypatch: pytest.MonkeyPatch): + def test_localize_with_dst_nonexistent_raise_branch( + self, monkeypatch: pytest.MonkeyPatch + ): ts = pd.Timestamp("2024-03-10 02:15") def raise_nonexistent(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[override] raise pytz.exceptions.NonExistentTimeError() - monkeypatch.setattr(pd.Timestamp, "tz_localize", raise_nonexistent, raising=False) + monkeypatch.setattr( + pd.Timestamp, "tz_localize", raise_nonexistent, raising=False + ) with pytest.raises(ValueError): localize_with_dst(ts, nonexistent="raise") @@ -419,4 +431,4 @@ def utcoffset(self): return None with pytest.raises(ValueError): - get_utc_offset(Dummy()) # type: ignore[arg-type] \ No newline at end of file + get_utc_offset(Dummy()) # type: ignore[arg-type] From 3d878c4d36566c4f3ae5da255c1510a9c2348611 Mon Sep 17 00:00:00 2001 From: Kevin Kenyon Date: Sun, 28 Dec 2025 15:41:24 -0600 Subject: [PATCH 3/6] Enhance timezone utility tests for better handling of DST flags and edge cases. --- examples/ercot_demo.ipynb | 159 ++++++++++++++++++++------------------ tests/test_tz.py | 48 ++++++++---- tinygrid/utils/tz.py | 11 ++- 3 files changed, 125 insertions(+), 93 deletions(-) diff --git a/examples/ercot_demo.ipynb b/examples/ercot_demo.ipynb index 743e7b0..d1d87ff 100644 --- a/examples/ercot_demo.ipynb +++ b/examples/ercot_demo.ipynb @@ -2,9 +2,18 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 29, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], "source": [ "%load_ext autoreload\n", "%autoreload 2" @@ -44,7 +53,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 30, "metadata": { "execution": { "iopub.execute_input": "2025-12-28T16:14:56.927060Z", @@ -78,7 +87,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 31, "metadata": { "execution": { "iopub.execute_input": "2025-12-28T16:14:57.854493Z", @@ -122,7 +131,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 32, "metadata": { "execution": { "iopub.execute_input": "2025-12-28T16:14:58.838540Z", @@ -171,7 +180,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 34, "metadata": { "execution": { "iopub.execute_input": "2025-12-28T16:15:00.615946Z", @@ -186,7 +195,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Real-Time SPP: 91,390 records\n" + "Real-Time SPP: 84,930 records\n" ] }, { @@ -221,46 +230,46 @@ " \n", " \n", " 0\n", - " 2024-12-12 23:30:00-06:00\n", - " 2024-12-12 23:45:00-06:00\n", + " 2023-12-12 23:30:00-06:00\n", + " 2023-12-12 23:45:00-06:00\n", " 7RNCHSLR_ALL\n", - " 18.81\n", + " 11.65\n", " REAL_TIME_15_MIN\n", " RN\n", " \n", " \n", " 1\n", - " 2024-12-12 23:30:00-06:00\n", - " 2024-12-12 23:45:00-06:00\n", - " ADL_RN\n", - " 20.58\n", + " 2023-12-12 23:30:00-06:00\n", + " 2023-12-12 23:45:00-06:00\n", + " AEEC\n", + " 12.47\n", " REAL_TIME_15_MIN\n", " RN\n", " \n", " \n", " 2\n", - " 2024-12-12 23:30:00-06:00\n", - " 2024-12-12 23:45:00-06:00\n", - " AEEC\n", - " 8.81\n", + " 2023-12-12 23:30:00-06:00\n", + " 2023-12-12 23:45:00-06:00\n", + " AGUAYO_UNIT1\n", + " 12.30\n", " REAL_TIME_15_MIN\n", " RN\n", " \n", " \n", " 3\n", - " 2024-12-12 23:30:00-06:00\n", - " 2024-12-12 23:45:00-06:00\n", - " AE_RN\n", - " 18.83\n", + " 2023-12-12 23:30:00-06:00\n", + " 2023-12-12 23:45:00-06:00\n", + " AJAXWIND_RN\n", + " 12.37\n", " REAL_TIME_15_MIN\n", " RN\n", " \n", " \n", " 4\n", - " 2024-12-12 23:30:00-06:00\n", - " 2024-12-12 23:45:00-06:00\n", - " AGUAYO_UNIT1\n", - " 8.88\n", + " 2023-12-12 23:30:00-06:00\n", + " 2023-12-12 23:45:00-06:00\n", + " ALGOD_ALL_RN\n", + " 7.64\n", " REAL_TIME_15_MIN\n", " RN\n", " \n", @@ -270,14 +279,14 @@ ], "text/plain": [ " Time End Time Location Price Market Location Type\n", - "0 2024-12-12 23:30:00-06:00 2024-12-12 23:45:00-06:00 7RNCHSLR_ALL 18.81 REAL_TIME_15_MIN RN\n", - "1 2024-12-12 23:30:00-06:00 2024-12-12 23:45:00-06:00 ADL_RN 20.58 REAL_TIME_15_MIN RN\n", - "2 2024-12-12 23:30:00-06:00 2024-12-12 23:45:00-06:00 AEEC 8.81 REAL_TIME_15_MIN RN\n", - "3 2024-12-12 23:30:00-06:00 2024-12-12 23:45:00-06:00 AE_RN 18.83 REAL_TIME_15_MIN RN\n", - "4 2024-12-12 23:30:00-06:00 2024-12-12 23:45:00-06:00 AGUAYO_UNIT1 8.88 REAL_TIME_15_MIN RN" + "0 2023-12-12 23:30:00-06:00 2023-12-12 23:45:00-06:00 7RNCHSLR_ALL 11.65 REAL_TIME_15_MIN RN\n", + "1 2023-12-12 23:30:00-06:00 2023-12-12 23:45:00-06:00 AEEC 12.47 REAL_TIME_15_MIN RN\n", + "2 2023-12-12 23:30:00-06:00 2023-12-12 23:45:00-06:00 AGUAYO_UNIT1 12.30 REAL_TIME_15_MIN RN\n", + "3 2023-12-12 23:30:00-06:00 2023-12-12 23:45:00-06:00 AJAXWIND_RN 12.37 REAL_TIME_15_MIN RN\n", + "4 2023-12-12 23:30:00-06:00 2023-12-12 23:45:00-06:00 ALGOD_ALL_RN 7.64 REAL_TIME_15_MIN RN" ] }, - "execution_count": 5, + "execution_count": 34, "metadata": {}, "output_type": "execute_result" } @@ -285,7 +294,7 @@ "source": [ "# Real-time 15-minute SPP\n", "df = ercot.get_spp(\n", - " start=\"2024-12-12\",\n", + " start=\"2023-12-12\",\n", " market=Market.REAL_TIME_15_MIN,\n", ")\n", "\n", @@ -295,7 +304,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 35, "metadata": { "execution": { "iopub.execute_input": "2025-12-28T16:15:12.766812Z", @@ -310,7 +319,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Load Zone SPP: 1,520 records\n" + "Load Zone SPP: 103,455 records\n" ] }, { @@ -347,61 +356,61 @@ " 0\n", " 2025-12-27 23:30:00-06:00\n", " 2025-12-27 23:45:00-06:00\n", - " LZ_AEN\n", - " 8.10\n", + " 7RNCHSLR_ALL\n", + " 8.51\n", " REAL_TIME_15_MIN\n", - " LZ\n", + " RN\n", " \n", " \n", " 1\n", " 2025-12-27 23:30:00-06:00\n", " 2025-12-27 23:45:00-06:00\n", - " LZ_AEN\n", - " 8.10\n", + " A4_DGR1_RN\n", + " 7.73\n", " REAL_TIME_15_MIN\n", - " LZEW\n", + " RN\n", " \n", " \n", " 2\n", " 2025-12-27 23:30:00-06:00\n", " 2025-12-27 23:45:00-06:00\n", - " LZ_CPS\n", + " A4_DGR2_RN\n", " 7.73\n", " REAL_TIME_15_MIN\n", - " LZEW\n", + " RN\n", " \n", " \n", " 3\n", " 2025-12-27 23:30:00-06:00\n", " 2025-12-27 23:45:00-06:00\n", - " LZ_CPS\n", - " 7.73\n", + " ABINDUST_RN\n", + " 1.21\n", " REAL_TIME_15_MIN\n", - " LZ\n", + " RN\n", " \n", " \n", " 4\n", " 2025-12-27 23:30:00-06:00\n", " 2025-12-27 23:45:00-06:00\n", - " LZ_HOUSTON\n", - " 9.89\n", + " ADL_RN\n", + " 10.00\n", " REAL_TIME_15_MIN\n", - " LZEW\n", + " RN\n", " \n", " \n", "\n", "" ], "text/plain": [ - " Time End Time Location Price Market Location Type\n", - "0 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 LZ_AEN 8.10 REAL_TIME_15_MIN LZ\n", - "1 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 LZ_AEN 8.10 REAL_TIME_15_MIN LZEW\n", - "2 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 LZ_CPS 7.73 REAL_TIME_15_MIN LZEW\n", - "3 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 LZ_CPS 7.73 REAL_TIME_15_MIN LZ\n", - "4 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 LZ_HOUSTON 9.89 REAL_TIME_15_MIN LZEW" + " Time End Time Location Price Market Location Type\n", + "0 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 7RNCHSLR_ALL 8.51 REAL_TIME_15_MIN RN\n", + "1 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 A4_DGR1_RN 7.73 REAL_TIME_15_MIN RN\n", + "2 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 A4_DGR2_RN 7.73 REAL_TIME_15_MIN RN\n", + "3 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 ABINDUST_RN 1.21 REAL_TIME_15_MIN RN\n", + "4 2025-12-27 23:30:00-06:00 2025-12-27 23:45:00-06:00 ADL_RN 10.00 REAL_TIME_15_MIN RN" ] }, - "execution_count": 6, + "execution_count": 35, "metadata": {}, "output_type": "execute_result" } @@ -2646,7 +2655,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 36, "metadata": { "execution": { "iopub.execute_input": "2025-12-27T23:03:14.961791Z", @@ -2661,7 +2670,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Week of DAM SPP: 1,152 records\n" + "Week of DAM SPP: 136,944 records\n" ] }, { @@ -2697,40 +2706,40 @@ " 0\n", " 2024-12-26 00:00:00-06:00\n", " 2024-12-26 01:00:00-06:00\n", - " LZ_AEN\n", - " 16.64\n", + " 7RNCHSLR_ALL\n", + " 16.72\n", " DAY_AHEAD_HOURLY\n", " \n", " \n", " 1\n", " 2024-12-26 00:00:00-06:00\n", " 2024-12-26 01:00:00-06:00\n", - " LZ_CPS\n", - " 16.76\n", + " ADL_RN\n", + " 16.72\n", " DAY_AHEAD_HOURLY\n", " \n", " \n", " 2\n", " 2024-12-26 00:00:00-06:00\n", " 2024-12-26 01:00:00-06:00\n", - " LZ_HOUSTON\n", - " 16.72\n", + " AEEC\n", + " 17.89\n", " DAY_AHEAD_HOURLY\n", " \n", " \n", " 3\n", " 2024-12-26 00:00:00-06:00\n", " 2024-12-26 01:00:00-06:00\n", - " LZ_LCRA\n", - " 16.78\n", + " AE_RN\n", + " 16.72\n", " DAY_AHEAD_HOURLY\n", " \n", " \n", " 4\n", " 2024-12-26 00:00:00-06:00\n", " 2024-12-26 01:00:00-06:00\n", - " LZ_NORTH\n", - " 16.75\n", + " AGUAYO_UNIT1\n", + " 16.20\n", " DAY_AHEAD_HOURLY\n", " \n", " \n", @@ -2738,15 +2747,15 @@ "" ], "text/plain": [ - " Time End Time Location Price Market\n", - "0 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 LZ_AEN 16.64 DAY_AHEAD_HOURLY\n", - "1 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 LZ_CPS 16.76 DAY_AHEAD_HOURLY\n", - "2 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 LZ_HOUSTON 16.72 DAY_AHEAD_HOURLY\n", - "3 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 LZ_LCRA 16.78 DAY_AHEAD_HOURLY\n", - "4 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 LZ_NORTH 16.75 DAY_AHEAD_HOURLY" + " Time End Time Location Price Market\n", + "0 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 7RNCHSLR_ALL 16.72 DAY_AHEAD_HOURLY\n", + "1 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 ADL_RN 16.72 DAY_AHEAD_HOURLY\n", + "2 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 AEEC 17.89 DAY_AHEAD_HOURLY\n", + "3 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 AE_RN 16.72 DAY_AHEAD_HOURLY\n", + "4 2024-12-26 00:00:00-06:00 2024-12-26 01:00:00-06:00 AGUAYO_UNIT1 16.20 DAY_AHEAD_HOURLY" ] }, - "execution_count": 4, + "execution_count": 36, "metadata": {}, "output_type": "execute_result" } diff --git a/tests/test_tz.py b/tests/test_tz.py index 3e56310..8def6f8 100644 --- a/tests/test_tz.py +++ b/tests/test_tz.py @@ -158,9 +158,9 @@ def test_dst_flag_mixed_values(self): result = dst_flag_to_ambiguous(dst_flag) - assert result[0] - assert not result[1] - assert result[2] + assert result[0] # pyright: ignore[reportGeneralTypeIssues] + assert not result[1] # pyright: ignore[reportGeneralTypeIssues] + assert result[2] # pyright: ignore[reportGeneralTypeIssues] def test_dst_flag_with_nulls(self): """Test converting DST flags with null values (should default to True).""" @@ -168,9 +168,9 @@ def test_dst_flag_with_nulls(self): result = dst_flag_to_ambiguous(dst_flag) - assert result[0] - assert result[1] # Null should become True - assert not result[2] + assert result[0] # pyright: ignore[reportGeneralTypeIssues] + assert result[1] # pyright: ignore[reportGeneralTypeIssues] # Null should become True + assert not result[2] # pyright: ignore[reportGeneralTypeIssues] def test_dst_flag_all_nulls(self): """Test converting all null DST flags.""" @@ -264,7 +264,9 @@ def test_get_utc_offset_naive_timestamp_raises(self): """Test that naive timestamp raises ValueError.""" dt = pd.Timestamp("2023-06-15 12:00:00") - with pytest.raises(ValueError, match="Timestamp must be timezone-aware"): + with pytest.raises( + ValueError, match="Timestamp must be timezone-aware" + ): get_utc_offset(dt) @@ -302,7 +304,9 @@ def test_localize_with_dst_nonexistent_shift_forward(self): # During spring forward, 2:30 AM doesn't exist dt = pd.Timestamp("2023-03-12 02:30:00") - result = localize_with_dst(dt, tz=ERCOT_TIMEZONE, nonexistent="shift_forward") + result = localize_with_dst( + dt, tz=ERCOT_TIMEZONE, nonexistent="shift_forward" + ) assert result.tz is not None # Should be shifted forward to next valid time @@ -312,15 +316,19 @@ def test_localize_with_dst_nonexistent_shift_backward(self): # During spring forward, 2:30 AM doesn't exist dt = pd.Timestamp("2023-03-12 02:30:00") - result = localize_with_dst(dt, tz=ERCOT_TIMEZONE, nonexistent="shift_backward") + result = localize_with_dst( + dt, tz=ERCOT_TIMEZONE, nonexistent="shift_backward" + ) assert result.tz is not None # Should be shifted backward to previous valid time - assert result.hour in {1, 3} + assert result.hour == 1 class TestAdditionalDSTPaths: - def test_resolve_ambiguous_dst_fallback(self, monkeypatch: pytest.MonkeyPatch): + def test_resolve_ambiguous_dst_fallback( + self, monkeypatch: pytest.MonkeyPatch + ): ts = pd.Series([pd.Timestamp("2021-11-07 01:30")]) from pandas.core.indexes.accessors import DatetimeProperties @@ -339,7 +347,7 @@ def raise_ambiguous(self, tz=None, ambiguous=None): # type: ignore[override] def test_localize_with_dst_nonexistent_explicit_backward(self): ts = "2024-03-10 02:15" result = localize_with_dst(ts, nonexistent="shift_backward") - assert result.hour in {1, 3} + assert result.hour == 1 def test_localize_with_dst_nonexistent_shift_forward_fallback( self, monkeypatch: pytest.MonkeyPatch @@ -351,7 +359,9 @@ def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[ov calls["count"] += 1 if calls["count"] == 1: raise pytz.exceptions.NonExistentTimeError() - return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) + return orig( + self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent + ) monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) @@ -369,7 +379,9 @@ def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[ov calls["count"] += 1 if calls["count"] == 1: raise pytz.exceptions.NonExistentTimeError() - return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) + return orig( + self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent + ) monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) @@ -387,7 +399,9 @@ def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[ov calls["count"] += 1 if calls["count"] == 1: raise pytz.exceptions.AmbiguousTimeError() - return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) + return orig( + self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent + ) monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) @@ -405,7 +419,9 @@ def test_localize_with_dst_nonexistent_raise_branch( ): ts = pd.Timestamp("2024-03-10 02:15") - def raise_nonexistent(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[override] + def raise_nonexistent( + self, tz=None, ambiguous=True, nonexistent="raise" + ): # type: ignore[override] raise pytz.exceptions.NonExistentTimeError() monkeypatch.setattr( diff --git a/tinygrid/utils/tz.py b/tinygrid/utils/tz.py index 7f50c30..bfe8c3b 100644 --- a/tinygrid/utils/tz.py +++ b/tinygrid/utils/tz.py @@ -35,7 +35,13 @@ def resolve_ambiguous_dst( dt_series = pd.to_datetime(timestamps) # Use DSTFlag to resolve ambiguous times (DST=True, Standard=False) - ambiguous = dst_flags.fillna(True).astype(bool) if dst_flags is not None else True + if dst_flags is None: + ambiguous = True + else: + # Normalize to pandas nullable boolean to avoid downcast warnings, then + # fill missing values as True (DST). + normalized_flags = dst_flags.astype("boolean").fillna(True) + ambiguous = normalized_flags.astype(bool) try: localized = dt_series.dt.tz_localize(tz, ambiguous=ambiguous) @@ -133,7 +139,8 @@ def dst_flag_to_ambiguous(dst_flag: pd.Series) -> pd.Series: Returns: Boolean series for use with tz_localize(ambiguous=...) """ - return dst_flag.fillna(True).astype(bool) + normalized_flags = dst_flag.astype("boolean").fillna(True) + return normalized_flags.astype(bool) def is_dst_transition_date(date: pd.Timestamp, tz: str = ERCOT_TIMEZONE) -> bool: From a0bf444e985e786eabecccd6595dfe95f35194cc Mon Sep 17 00:00:00 2001 From: Kevin Kenyon Date: Sun, 28 Dec 2025 15:46:38 -0600 Subject: [PATCH 4/6] Add pre-commit hooks for linting and formatting - Introduced `hooks-install` to set up pre-commit hooks after cloning. - Added `hooks-run` to manually execute pre-commit hooks on all files. - Updated README to include instructions for using pre-commit hooks and their benefits. - Added `pre-commit` as a development dependency in `pyproject.toml`. --- .pre-commit-config.yaml | 23 +++++++++++++++++++++++ README.md | 26 ++++++++++++++++++++++---- justfile | 8 ++++++++ pyproject.toml | 1 + 4 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a3bf0b9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +# Pre-commit hooks for tinygrid +# Install: uv run pre-commit install +# Run manually: uv run pre-commit run --all-files + +repos: + # Ruff linter and formatter + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff + args: [--fix, --unsafe-fixes] + - id: ruff-format + + # Type checking with pyright + - repo: https://github.com/RobertCraiworthy/pyright-python + rev: v1.1.391 + hooks: + - id: pyright + additional_dependencies: + - pandas>=2.0.0 + - tenacity>=8.0.0 + - python-dotenv>=1.0.0 + - pytz diff --git a/README.md b/README.md index ee66a35..5fdfef8 100644 --- a/README.md +++ b/README.md @@ -196,19 +196,37 @@ just test # Run tests with coverage just test-coverage -# Lint +# Lint and format just lint - -# Format just format +just lint-fix # Auto-fix lint issues # Type check just type-check -# Run all checks +# Run all checks (lint, format, type-check, test) just check ``` +### Pre-commit Hooks + +Pre-commit hooks automatically run linting and formatting before each commit to catch issues early. + +```bash +# Install hooks (one-time setup after cloning) +just hooks-install + +# Run hooks manually on all files +just hooks-run +``` + +The hooks run: +- **ruff** - Linting with auto-fix +- **ruff-format** - Code formatting +- **pyright** - Type checking + +If a hook fails, fix the issues and re-commit. Ruff auto-fixes will be staged automatically. + ## License MIT diff --git a/justfile b/justfile index 2941dfe..6315611 100644 --- a/justfile +++ b/justfile @@ -44,6 +44,14 @@ type-check: pre-commit: lint-fix format type-check @echo "Pre-commit checks completed!" +# Install pre-commit hooks (run once after cloning) +hooks-install: + uv run pre-commit install + +# Run pre-commit hooks on all files +hooks-run: + uv run pre-commit run --all-files + # Run all checks: lint, format, type check, and tests check: lint format type-check test @echo "All checks passed!" diff --git a/pyproject.toml b/pyproject.toml index 65b810b..2f81d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dev = [ "pyright>=1.1.0", "pandas>=2.3.3", "respx>=0.21.0", + "pre-commit>=3.6.0", ] [tool.pytest.ini_options] From ca5392fb892aacfe910ef0bbd5aa10c495087c5f Mon Sep 17 00:00:00 2001 From: Kevin Kenyon Date: Sun, 28 Dec 2025 15:49:16 -0600 Subject: [PATCH 5/6] Refactor pre-commit configuration and update dependencies - Changed pyright hook to a local configuration using `uv run`. - Added new dependencies in `uv.lock` for `cfgv`, `distlib`, `filelock`, `identify`, `pre-commit`, and `virtualenv`. - Cleaned up import statements in test files for consistency and clarity. --- .pre-commit-config.yaml | 15 ++++---- tests/conftest.py | 4 +-- tests/test_ercot_helpers.py | 2 +- tests/test_tz.py | 32 +++++------------ tinygrid/ercot.py | 20 +++++------ uv.lock | 69 +++++++++++++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 45 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a3bf0b9..04efc68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,13 +11,12 @@ repos: args: [--fix, --unsafe-fixes] - id: ruff-format - # Type checking with pyright - - repo: https://github.com/RobertCraiworthy/pyright-python - rev: v1.1.391 + # Type checking with pyright (local hook using uv) + - repo: local hooks: - id: pyright - additional_dependencies: - - pandas>=2.0.0 - - tenacity>=8.0.0 - - python-dotenv>=1.0.0 - - pytz + name: pyright + entry: uv run pyright + language: system + types: [python] + pass_filenames: false diff --git a/tests/conftest.py b/tests/conftest.py index 9c01bfd..3148808 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,10 @@ import pandas as pd import pytest import respx -from pyercot.models.report import Report -from pyercot.models.report_data import ReportData from pyercot import Client as ERCOTClient +from pyercot.models.report import Report +from pyercot.models.report_data import ReportData @pytest.fixture diff --git a/tests/test_ercot_helpers.py b/tests/test_ercot_helpers.py index 6c9b55a..14b7605 100644 --- a/tests/test_ercot_helpers.py +++ b/tests/test_ercot_helpers.py @@ -2,8 +2,8 @@ import pandas as pd import pytest -from pyercot.errors import UnexpectedStatus +from pyercot.errors import UnexpectedStatus from tinygrid.constants.ercot import LOAD_ZONES, TRADING_HUBS, LocationType, Market from tinygrid.ercot import ERCOT from tinygrid.errors import GridAPIError, GridAuthenticationError, GridTimeoutError diff --git a/tests/test_tz.py b/tests/test_tz.py index 8def6f8..e87f3cb 100644 --- a/tests/test_tz.py +++ b/tests/test_tz.py @@ -264,9 +264,7 @@ def test_get_utc_offset_naive_timestamp_raises(self): """Test that naive timestamp raises ValueError.""" dt = pd.Timestamp("2023-06-15 12:00:00") - with pytest.raises( - ValueError, match="Timestamp must be timezone-aware" - ): + with pytest.raises(ValueError, match="Timestamp must be timezone-aware"): get_utc_offset(dt) @@ -304,9 +302,7 @@ def test_localize_with_dst_nonexistent_shift_forward(self): # During spring forward, 2:30 AM doesn't exist dt = pd.Timestamp("2023-03-12 02:30:00") - result = localize_with_dst( - dt, tz=ERCOT_TIMEZONE, nonexistent="shift_forward" - ) + result = localize_with_dst(dt, tz=ERCOT_TIMEZONE, nonexistent="shift_forward") assert result.tz is not None # Should be shifted forward to next valid time @@ -316,9 +312,7 @@ def test_localize_with_dst_nonexistent_shift_backward(self): # During spring forward, 2:30 AM doesn't exist dt = pd.Timestamp("2023-03-12 02:30:00") - result = localize_with_dst( - dt, tz=ERCOT_TIMEZONE, nonexistent="shift_backward" - ) + result = localize_with_dst(dt, tz=ERCOT_TIMEZONE, nonexistent="shift_backward") assert result.tz is not None # Should be shifted backward to previous valid time @@ -326,9 +320,7 @@ def test_localize_with_dst_nonexistent_shift_backward(self): class TestAdditionalDSTPaths: - def test_resolve_ambiguous_dst_fallback( - self, monkeypatch: pytest.MonkeyPatch - ): + def test_resolve_ambiguous_dst_fallback(self, monkeypatch: pytest.MonkeyPatch): ts = pd.Series([pd.Timestamp("2021-11-07 01:30")]) from pandas.core.indexes.accessors import DatetimeProperties @@ -359,9 +351,7 @@ def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[ov calls["count"] += 1 if calls["count"] == 1: raise pytz.exceptions.NonExistentTimeError() - return orig( - self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent - ) + return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) @@ -379,9 +369,7 @@ def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[ov calls["count"] += 1 if calls["count"] == 1: raise pytz.exceptions.NonExistentTimeError() - return orig( - self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent - ) + return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) @@ -399,9 +387,7 @@ def fake(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[ov calls["count"] += 1 if calls["count"] == 1: raise pytz.exceptions.AmbiguousTimeError() - return orig( - self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent - ) + return orig(self, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent) monkeypatch.setattr(pd.Timestamp, "tz_localize", fake, raising=False) @@ -419,9 +405,7 @@ def test_localize_with_dst_nonexistent_raise_branch( ): ts = pd.Timestamp("2024-03-10 02:15") - def raise_nonexistent( - self, tz=None, ambiguous=True, nonexistent="raise" - ): # type: ignore[override] + def raise_nonexistent(self, tz=None, ambiguous=True, nonexistent="raise"): # type: ignore[override] raise pytz.exceptions.NonExistentTimeError() monkeypatch.setattr( diff --git a/tinygrid/ercot.py b/tinygrid/ercot.py index fc4a36b..c3cffa1 100644 --- a/tinygrid/ercot.py +++ b/tinygrid/ercot.py @@ -25,6 +25,16 @@ from .historical.ercot import ERCOTArchive # Import endpoint modules (they have .sync() methods) +from tenacity import ( + RetryError, + retry, + retry_if_exception, + stop_after_attempt, + wait_exponential, +) + +from pyercot import AuthenticatedClient +from pyercot import Client as ERCOTClient from pyercot.api.emil_products import ( get_list_for_products, get_product, @@ -148,16 +158,6 @@ from pyercot.api.np6_970_cd import rtd_lmp_node_zone_hub from pyercot.api.versioning import get_version from pyercot.errors import UnexpectedStatus -from tenacity import ( - RetryError, - retry, - retry_if_exception, - stop_after_attempt, - wait_exponential, -) - -from pyercot import AuthenticatedClient -from pyercot import Client as ERCOTClient from .auth import ERCOTAuth from .base import BaseISOClient diff --git a/uv.lock b/uv.lock index f1d3be4..1d66b6c 100644 --- a/uv.lock +++ b/uv.lock @@ -261,6 +261,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -532,6 +541,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -562,6 +580,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, ] +[[package]] +name = "filelock" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, +] + [[package]] name = "fqdn" version = "1.5.1" @@ -608,6 +635,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1572,6 +1608,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + [[package]] name = "prometheus-client" version = "0.23.1" @@ -2318,6 +2370,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "pandas" }, + { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2339,6 +2392,7 @@ dev = [ requires-dist = [ { name = "pandas", specifier = ">=2.0.0" }, { name = "pandas", marker = "extra == 'dev'", specifier = ">=2.3.3" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.6.0" }, { name = "pyercot", editable = "pyercot" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, @@ -2473,6 +2527,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, ] +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" From 528e1e1e6e17817a379d3a2f06932abd595ebd5f Mon Sep 17 00:00:00 2001 From: Kevin Kenyon Date: Sun, 28 Dec 2025 15:54:33 -0600 Subject: [PATCH 6/6] Refactor pre-commit configuration and clean up imports - Updated pre-commit hooks to use local configurations for ruff and ruff-format. - Cleaned up import statements in `conftest.py` and `test_ercot_helpers.py` for consistency. - Rearranged imports in `ercot.py` for better organization. --- .pre-commit-config.yaml | 18 +++++++++++------- tests/conftest.py | 4 ++-- tests/test_ercot_helpers.py | 2 +- tinygrid/ercot.py | 20 ++++++++++---------- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04efc68..948295f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,17 +3,21 @@ # Run manually: uv run pre-commit run --all-files repos: - # Ruff linter and formatter - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + # Use local hooks to respect project configs (ruff.toml, pyrightconfig.json) + - repo: local hooks: - id: ruff - args: [--fix, --unsafe-fixes] + name: ruff + entry: uv run ruff check --fix --unsafe-fixes + language: system + types: [python] + - id: ruff-format + name: ruff-format + entry: uv run ruff format + language: system + types: [python] - # Type checking with pyright (local hook using uv) - - repo: local - hooks: - id: pyright name: pyright entry: uv run pyright diff --git a/tests/conftest.py b/tests/conftest.py index 3148808..9c01bfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,11 +5,11 @@ import pandas as pd import pytest import respx - -from pyercot import Client as ERCOTClient from pyercot.models.report import Report from pyercot.models.report_data import ReportData +from pyercot import Client as ERCOTClient + @pytest.fixture def mock_ercot_client(): diff --git a/tests/test_ercot_helpers.py b/tests/test_ercot_helpers.py index 14b7605..6c9b55a 100644 --- a/tests/test_ercot_helpers.py +++ b/tests/test_ercot_helpers.py @@ -2,8 +2,8 @@ import pandas as pd import pytest - from pyercot.errors import UnexpectedStatus + from tinygrid.constants.ercot import LOAD_ZONES, TRADING_HUBS, LocationType, Market from tinygrid.ercot import ERCOT from tinygrid.errors import GridAPIError, GridAuthenticationError, GridTimeoutError diff --git a/tinygrid/ercot.py b/tinygrid/ercot.py index c3cffa1..fc4a36b 100644 --- a/tinygrid/ercot.py +++ b/tinygrid/ercot.py @@ -25,16 +25,6 @@ from .historical.ercot import ERCOTArchive # Import endpoint modules (they have .sync() methods) -from tenacity import ( - RetryError, - retry, - retry_if_exception, - stop_after_attempt, - wait_exponential, -) - -from pyercot import AuthenticatedClient -from pyercot import Client as ERCOTClient from pyercot.api.emil_products import ( get_list_for_products, get_product, @@ -158,6 +148,16 @@ from pyercot.api.np6_970_cd import rtd_lmp_node_zone_hub from pyercot.api.versioning import get_version from pyercot.errors import UnexpectedStatus +from tenacity import ( + RetryError, + retry, + retry_if_exception, + stop_after_attempt, + wait_exponential, +) + +from pyercot import AuthenticatedClient +from pyercot import Client as ERCOTClient from .auth import ERCOTAuth from .base import BaseISOClient