From 1bab7eab7b4623fbe21d2dabbe276a678910452a Mon Sep 17 00:00:00 2001 From: Jakub Vins Date: Thu, 25 Jun 2026 15:07:25 +0200 Subject: [PATCH] fix: make datetimes tz-aware to avoid naive/aware mix errors Replace naive datetime.utcnow() with datetime.now(timezone.utc) in FetchResult.time and the NftOffer.start_time fallback, add tz=utc to unisat's fromtimestamp (which also fixes a local-offset shift), and make parse_dt always return a tz-aware datetime (assume UTC for strings without a timezone) so it is consistent with its numeric paths. This prevents "can't subtract offset-naive and offset-aware datetimes" downstream when these values are compared/subtracted. Updates 3 tests that asserted the old naive behaviour and documents the two spots (DebankProtocolCache, v1 RateLimit) that stay naive on purpose. Co-Authored-By: Claude Opus 4.8 (1M context) --- blockapi/services.py | 3 +++ blockapi/test/v2/api/debank/test_debank_usage_parser.py | 4 ++-- blockapi/test/v2/api/nft/test_opensea.py | 4 +++- blockapi/test/v2/api/nft/test_simple_hash.py | 4 +++- blockapi/utils/datetime.py | 7 ++++++- blockapi/v2/api/debank.py | 3 +++ blockapi/v2/api/nft/unisat.py | 4 ++-- blockapi/v2/base.py | 8 ++++---- blockapi/v2/models.py | 6 ++++-- 9 files changed, 30 insertions(+), 13 deletions(-) diff --git a/blockapi/services.py b/blockapi/services.py index 5703a581..281a124b 100644 --- a/blockapi/services.py +++ b/blockapi/services.py @@ -56,6 +56,9 @@ def request( response = reqobj.get(request_url, headers=headers) self.last_response = response + # Naive local time on purpose: only used to measure elapsed time against + # datetime.now() in wait_for_next_request (rate limiting), never compared + # with tz-aware datetimes. self.last_response_time = datetime.now() if response.status_code != 200: diff --git a/blockapi/test/v2/api/debank/test_debank_usage_parser.py b/blockapi/test/v2/api/debank/test_debank_usage_parser.py index 832f2d94..b56121a3 100644 --- a/blockapi/test/v2/api/debank/test_debank_usage_parser.py +++ b/blockapi/test/v2/api/debank/test_debank_usage_parser.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal @@ -10,4 +10,4 @@ def test_can_parse_usage(usage_parser, debank_usage_response): stats = parsed.stats[0] assert stats.remains == Decimal('351978') assert stats.usage == Decimal('13162') - assert stats.date == datetime(2023, 3, 20) + assert stats.date == datetime(2023, 3, 20, tzinfo=timezone.utc) diff --git a/blockapi/test/v2/api/nft/test_opensea.py b/blockapi/test/v2/api/nft/test_opensea.py index f5182579..44308b0c 100644 --- a/blockapi/test/v2/api/nft/test_opensea.py +++ b/blockapi/test/v2/api/nft/test_opensea.py @@ -162,7 +162,9 @@ def test_parse_nfts(requests_mock, api, nfts_response, nfts_next_response): == 'data:application/json;base64,eyJuYW1lIjoiVW5pc3dhcCAtIDElIC0gUFJJTUUvV0VUSCAtIDMzMC4yMDw+NzgwLjI5In0=' ) assert not data.metadata - assert data.updated_time == datetime.datetime(2023, 8, 15, 13, 56, 39, 759414) + assert data.updated_time == datetime.datetime( + 2023, 8, 15, 13, 56, 39, 759414, tzinfo=datetime.timezone.utc + ) assert not data.is_disabled assert not data.is_nsfw assert data.blockchain == Blockchain.ETHEREUM diff --git a/blockapi/test/v2/api/nft/test_simple_hash.py b/blockapi/test/v2/api/nft/test_simple_hash.py index dd02757e..dfde94d4 100644 --- a/blockapi/test/v2/api/nft/test_simple_hash.py +++ b/blockapi/test/v2/api/nft/test_simple_hash.py @@ -55,7 +55,9 @@ def test_parse_nfts(requests_mock, api, nfts_response): ) assert not data.metadata_url assert not data.metadata - assert data.updated_time == datetime.datetime(2023, 3, 11, 3, 46, 15) + assert data.updated_time == datetime.datetime( + 2023, 3, 11, 3, 46, 15, tzinfo=datetime.timezone.utc + ) assert not data.is_disabled assert not data.is_nsfw assert data.blockchain == Blockchain.BITCOIN diff --git a/blockapi/utils/datetime.py b/blockapi/utils/datetime.py index 1ff5fcc4..579157d0 100644 --- a/blockapi/utils/datetime.py +++ b/blockapi/utils/datetime.py @@ -12,7 +12,12 @@ def parse_dt(dt: Union[str, int, float]) -> datetime: try: return datetime.fromtimestamp(int(dt), tz=timezone.utc) except ValueError: - return parse_date(dt) + parsed = parse_date(dt) + # Assume UTC for strings that carry no timezone, so parse_dt always + # returns a tz-aware datetime (consistent with the numeric paths). + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed elif isinstance(dt, int) or isinstance(dt, float): return datetime.fromtimestamp(dt, tz=timezone.utc) else: diff --git a/blockapi/v2/api/debank.py b/blockapi/v2/api/debank.py index 63a8e1d6..982f0edb 100644 --- a/blockapi/v2/api/debank.py +++ b/blockapi/v2/api/debank.py @@ -243,6 +243,9 @@ class DebankProtocolCache: def __init__(self, timeout: int = 3600): self._timeout: int = timeout self._data: Dict[str, Protocol] = {} + # Naive local time on purpose: _timelimit is only ever compared against + # other datetime.now() values here (cache TTL), never mixed with the + # tz-aware datetimes used elsewhere, so there is no naive/aware hazard. self._timelimit = datetime.now() def invalidate(self): diff --git a/blockapi/v2/api/nft/unisat.py b/blockapi/v2/api/nft/unisat.py index a24c4802..ae91911a 100644 --- a/blockapi/v2/api/nft/unisat.py +++ b/blockapi/v2/api/nft/unisat.py @@ -1,7 +1,7 @@ import logging from typing import Optional, Dict, Generator, Tuple from enum import Enum -from datetime import datetime +from datetime import datetime, timezone from blockapi.v2.base import BlockchainApi, INftParser, INftProvider, ISleepProvider from blockapi.v2.coins import COIN_BTC @@ -670,7 +670,7 @@ def _yield_parsed_offers( try: timestamp_seconds = timestamp / 1000 formatted_time = datetime.fromtimestamp( - timestamp_seconds + timestamp_seconds, tz=timezone.utc ).isoformat() except (ValueError, TypeError, OverflowError): logger.warning( diff --git a/blockapi/v2/base.py b/blockapi/v2/base.py index 27ff6c54..3e659a36 100644 --- a/blockapi/v2/base.py +++ b/blockapi/v2/base.py @@ -1,7 +1,7 @@ import logging import time from abc import ABC -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional from urllib.parse import urljoin @@ -107,7 +107,7 @@ def get_data( return FetchResult( status_code=1, errors=[str(connection_error)], - time=datetime.utcnow(), + time=datetime.now(timezone.utc), ) self.sleep_provider.sleep(self.base_url, seconds=sleep_seconds) @@ -155,7 +155,7 @@ def get_data( headers=dict(), errors=[f'{type(ex).__name__}: {str(ex)}'], extra=extra, - time=datetime.utcnow(), + time=datetime.now(timezone.utc), ) time = self._get_response_time(response.headers) @@ -236,7 +236,7 @@ def _get_response_time(headers) -> Optional[datetime]: return parse_dt(date_str) if age_str := headers.get('age'): - return datetime.utcnow() - timedelta(seconds=int(age_str)) + return datetime.now(timezone.utc) - timedelta(seconds=int(age_str)) return None diff --git a/blockapi/v2/models.py b/blockapi/v2/models.py index 8308d377..bcbc4c84 100644 --- a/blockapi/v2/models.py +++ b/blockapi/v2/models.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal from enum import Enum from typing import Dict, List, Literal, Optional, Union @@ -863,7 +863,9 @@ def from_api( contract=contract, blockchain=blockchain, offerer=offerer, - start_time=parse_dt(start_time) if start_time else datetime.utcnow(), + start_time=( + parse_dt(start_time) if start_time else datetime.now(timezone.utc) + ), end_time=parse_dt(end_time) if end_time else None, offer_coin=offer_coin, offer_contract=offer_contract.lower() if offer_contract else None,