From a04b5571a8acc7408906ca409207b8f7e77760ed Mon Sep 17 00:00:00 2001 From: MarketDataDev02 Date: Fri, 25 Sep 2026 11:48:36 -0300 Subject: [PATCH 1/3] refactor: read the US/Eastern zone from one constant DataFrames from both handlers, INTERNAL dates, the rate-limit reset time and exception timestamps all render in US/Eastern, but the zone was built from a literal in four places. DEFAULT_TIMEZONE moves from utils to internal_settings, which exceptions can import (utils imports exceptions), and every site reads it; the polars handler takes its name with .zone. pytz returns one cached object per zone name, so each site gets the same object it built before, and polars the same string. --- src/marketdata/exceptions.py | 14 ++++- src/marketdata/internal_settings.py | 4 ++ src/marketdata/output_handlers/pandas.py | 18 +++++- src/marketdata/output_handlers/polars.py | 18 +++++- src/marketdata/types.py | 2 +- src/marketdata/utils.py | 8 +-- src/tests/test_default_timezone.py | 72 ++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 src/tests/test_default_timezone.py diff --git a/src/marketdata/exceptions.py b/src/marketdata/exceptions.py index 40fa336..3590652 100644 --- a/src/marketdata/exceptions.py +++ b/src/marketdata/exceptions.py @@ -28,9 +28,9 @@ from datetime import datetime from httpx import Request, Response -from pytz import timezone from marketdata.internal_settings import ( + DEFAULT_TIMEZONE, HEADER_AUTHORIZED_IP, HEADER_REQUEST_ID, read_header, @@ -78,8 +78,18 @@ def format_timestamp(cls, timestamp: datetime) -> str: @classmethod def _coerce_timestamp(cls, timestamp: datetime | str | None) -> str: + """The exception's timestamp as text. + + Args: + timestamp: A datetime, rendered by ``format_timestamp``; text, kept + as it is; or ``None`` for the current time in + ``DEFAULT_TIMEZONE``. + + Returns: + The timestamp as ``YYYY-MM-DD HH:MM:SS``, or the text given. + """ if timestamp is None: - return cls.format_timestamp(datetime.now(timezone("US/Eastern"))) + return cls.format_timestamp(datetime.now(DEFAULT_TIMEZONE)) if isinstance(timestamp, datetime): return cls.format_timestamp(timestamp) return timestamp diff --git a/src/marketdata/internal_settings.py b/src/marketdata/internal_settings.py index bb76f6b..4ce887f 100644 --- a/src/marketdata/internal_settings.py +++ b/src/marketdata/internal_settings.py @@ -1,6 +1,7 @@ import datetime import httpx +import pytz from httpx import Response @@ -32,6 +33,9 @@ class NoTokenValueType: # than that is a value the SDK could not read rather than a window that closed # (#42). MAX_CREDIT_WINDOW_SECONDS = 24 * 60 * 60 +# The zone of every timestamp the SDK renders. It is kept here, not in utils, +# because utils imports exceptions, which needs it too. +DEFAULT_TIMEZONE = pytz.timezone("US/Eastern") # The API's IP headers (#44). httpx matches header names case-insensitively, # so the lowercase spelling reads them whatever case the API sends. The legacy diff --git a/src/marketdata/output_handlers/pandas.py b/src/marketdata/output_handlers/pandas.py index 4409c42..29c387e 100644 --- a/src/marketdata/output_handlers/pandas.py +++ b/src/marketdata/output_handlers/pandas.py @@ -1,8 +1,8 @@ import numpy as np import pandas as pd -import pytz from marketdata.input_types.base import DateFormat +from marketdata.internal_settings import DEFAULT_TIMEZONE from marketdata.output_handlers.base import BaseOutputHandler _DTYPES = {float: "float64", int: "int64", bool: "bool", str: "object"} @@ -50,12 +50,24 @@ def _convert_timestamp_columns( date_columns: list[str], date_format: DateFormat | None, ) -> pd.DataFrame: - """Convert date/time columns to timezone-aware datetime objects.""" + """Turn the date columns into datetimes in ``DEFAULT_TIMEZONE``. + + Args: + df: The result, with its dates as the API sent them. + date_columns: The columns that hold dates. + date_format: The ``dateformat`` of the request. ``UNIX`` keeps the + columns as numbers, and ``None`` reads them as Unix seconds, + the API's default. + + Returns: + ``df`` with its date columns converted. A column that does not + read in that format is left as it is. + """ if date_format == DateFormat.UNIX: return df format_to_use = date_format or DateFormat.UNIX - default_tz = pytz.timezone("US/Eastern") + default_tz = DEFAULT_TIMEZONE for col in df.columns: if col not in date_columns: diff --git a/src/marketdata/output_handlers/polars.py b/src/marketdata/output_handlers/polars.py index 5fb466e..2c54ac3 100644 --- a/src/marketdata/output_handlers/polars.py +++ b/src/marketdata/output_handlers/polars.py @@ -1,7 +1,7 @@ import polars as pl -import pytz from marketdata.input_types.base import DateFormat +from marketdata.internal_settings import DEFAULT_TIMEZONE from marketdata.output_handlers.base import BaseOutputHandler _DTYPES = {float: pl.Float64, int: pl.Int64, bool: pl.Boolean, str: pl.String} @@ -40,12 +40,24 @@ def _convert_timestamp_columns( date_columns: list[str], date_format: DateFormat | None, ) -> pl.DataFrame: - """Convert date/time columns to timezone-aware datetime objects.""" + """Turn the date columns into datetimes in ``DEFAULT_TIMEZONE``. + + Args: + df: The result, with its dates as the API sent them. + date_columns: The columns that hold dates. + date_format: The ``dateformat`` of the request. ``UNIX`` keeps the + columns as numbers, and ``None`` reads them as Unix seconds, + the API's default. + + Returns: + ``df`` with its date columns converted. A column that does not + read in that format is left as it is. + """ if date_format == DateFormat.UNIX: return df format_to_use = date_format or DateFormat.UNIX - default_tz = pytz.timezone("US/Eastern").zone + default_tz = DEFAULT_TIMEZONE.zone for col in df.columns: if col not in date_columns: diff --git a/src/marketdata/types.py b/src/marketdata/types.py index b2565ea..fc7b541 100644 --- a/src/marketdata/types.py +++ b/src/marketdata/types.py @@ -3,7 +3,7 @@ from pytz.exceptions import InvalidTimeError -from marketdata.utils import DEFAULT_TIMEZONE +from marketdata.internal_settings import DEFAULT_TIMEZONE @dataclass diff --git a/src/marketdata/utils.py b/src/marketdata/utils.py index 180f209..5a20f18 100644 --- a/src/marketdata/utils.py +++ b/src/marketdata/utils.py @@ -7,16 +7,10 @@ from typing import Any, Iterator, NoReturn from urllib.parse import quote -import pytz from httpx import Response from marketdata.exceptions import ParseError -from marketdata.internal_settings import VALID_STATUS_CODES - -# Every timestamp the SDK renders is US/Eastern. One constant, so the zone a -# naive value is read in and the zone it is rendered in agree by construction -# rather than because two literals happen to match. -DEFAULT_TIMEZONE = pytz.timezone("US/Eastern") +from marketdata.internal_settings import DEFAULT_TIMEZONE, VALID_STATUS_CODES def _exact_number(text: str) -> Decimal: diff --git a/src/tests/test_default_timezone.py b/src/tests/test_default_timezone.py new file mode 100644 index 0000000..0c7cf72 --- /dev/null +++ b/src/tests/test_default_timezone.py @@ -0,0 +1,72 @@ +"""Every timestamp the SDK renders takes its zone from one constant, +`internal_settings.DEFAULT_TIMEZONE`.""" + +import ast +import datetime +from dataclasses import dataclass +from pathlib import Path + +import polars as pl +import pytest +import pytz + +from marketdata import exceptions, internal_settings +from marketdata.exceptions import BaseMarketdataException +from marketdata.input_types.base import UserUniversalAPIParams +from marketdata.output_handlers import pandas as pandas_handler +from marketdata.output_handlers import polars as polars_handler + +PACKAGE = Path(internal_settings.__file__).parent +TOKYO = pytz.timezone("Asia/Tokyo") +STAMP = "%Y-%m-%d %H:%M:%S" + + +@dataclass +class Updated: + updated: datetime.datetime + + +@pytest.fixture +def tokyo(monkeypatch): + """Point every module that renders a timestamp at Asia/Tokyo, a zone with + no daylight saving time and 13 or 14 hours away from US/Eastern. + + Returns: + The Asia/Tokyo zone. + """ + for module in (exceptions, pandas_handler, polars_handler): + monkeypatch.setattr(module, "DEFAULT_TIMEZONE", TOKYO) + return TOKYO + + +def test_the_zone_name_is_written_only_where_the_constant_is_defined(): + """No module of the package builds the zone from a literal of its own.""" + literals = [ + f"{path.relative_to(PACKAGE).as_posix()}:{node.lineno}" + for path in sorted(PACKAGE.rglob("*.py")) + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, ast.Constant) and node.value == "US/Eastern" + ] + + assert len(literals) == 1, literals + assert literals[0].startswith("internal_settings.py:"), literals + + +def test_dataframes_and_exception_timestamps_render_in_the_constant_zone(tokyo): + """A DataFrame from either handler and an exception timestamp all follow + the zone the constant holds.""" + handler = { + "data": {"updated": [1765552906]}, + "output_schema": Updated, + "user_universal_params": UserUniversalAPIParams(), + } + + pandas_frame = pandas_handler.PandasOutputHandler(**handler).get_result() + polars_frame = polars_handler.PolarsOutputHandler(**handler).get_result() + before = datetime.datetime.now(tokyo).strftime(STAMP) + error = BaseMarketdataException("boom") + after = datetime.datetime.now(tokyo).strftime(STAMP) + + assert str(pandas_frame["updated"].dt.tz) == "Asia/Tokyo" + assert polars_frame["updated"].dtype == pl.Datetime("us", "Asia/Tokyo") + assert before <= error.timestamp <= after From 841c880483aea1b6755a5507a2b595ea35d5bd4b Mon Sep 17 00:00:00 2001 From: MarketDataDev02 Date: Fri, 25 Sep 2026 11:54:14 -0300 Subject: [PATCH 2/3] test: name the modules the zone fixture patches The fixture's docstring said it pointed every module that renders a timestamp at Asia/Tokyo. It patches the exception module and the two DataFrame handlers, the sites the test checks; utils and types also read the constant and keep it. --- src/tests/test_default_timezone.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/test_default_timezone.py b/src/tests/test_default_timezone.py index 0c7cf72..dd5ea1b 100644 --- a/src/tests/test_default_timezone.py +++ b/src/tests/test_default_timezone.py @@ -28,8 +28,8 @@ class Updated: @pytest.fixture def tokyo(monkeypatch): - """Point every module that renders a timestamp at Asia/Tokyo, a zone with - no daylight saving time and 13 or 14 hours away from US/Eastern. + """Point the exception module and both DataFrame handlers at Asia/Tokyo, a + zone with no daylight saving time and 13 or 14 hours away from US/Eastern. Returns: The Asia/Tokyo zone. From 0e0f1c273f1c0e0376a43bf9034fa0560242a50e Mon Sep 17 00:00:00 2001 From: MarketDataDev02 Date: Mon, 28 Sep 2026 11:27:57 -0300 Subject: [PATCH 3/3] refactor: keep utils.DEFAULT_TIMEZONE public and test the zone through the client The shared zone becomes internal_settings._DEFAULT_TIMEZONE, and utils assigns DEFAULT_TIMEZONE from it rather than importing it. An imported name is not part of a module's public surface, so the surface stays the one on main and nothing changes for a caller. The AST scan for the "US/Eastern" literal and the render test that built the handlers and an exception by hand give way to respx tests through client.stocks.quotes. With the zone set to Asia/Tokyo in the five modules that read it, the INTERNAL dates, the credit reset, the pandas and polars DataFrames and the timestamp of a 400 answer all come out in Asia/Tokyo. Another test pins utils.DEFAULT_TIMEZONE to US/Eastern. The pandas docstring of _convert_timestamp_columns takes the text the same docstring has on fix/null-date-float-overflow, so the two branches merge cleanly. --- src/marketdata/exceptions.py | 7 +- src/marketdata/internal_settings.py | 2 +- src/marketdata/output_handlers/pandas.py | 18 ++-- src/marketdata/output_handlers/polars.py | 19 ++-- src/marketdata/types.py | 6 +- src/marketdata/utils.py | 5 +- src/tests/test_default_timezone.py | 125 +++++++++++++++-------- 7 files changed, 110 insertions(+), 72 deletions(-) diff --git a/src/marketdata/exceptions.py b/src/marketdata/exceptions.py index 3590652..c2225ec 100644 --- a/src/marketdata/exceptions.py +++ b/src/marketdata/exceptions.py @@ -30,7 +30,7 @@ from httpx import Request, Response from marketdata.internal_settings import ( - DEFAULT_TIMEZONE, + _DEFAULT_TIMEZONE, HEADER_AUTHORIZED_IP, HEADER_REQUEST_ID, read_header, @@ -82,14 +82,13 @@ def _coerce_timestamp(cls, timestamp: datetime | str | None) -> str: Args: timestamp: A datetime, rendered by ``format_timestamp``; text, kept - as it is; or ``None`` for the current time in - ``DEFAULT_TIMEZONE``. + as it is; or ``None`` for the current time in US/Eastern. Returns: The timestamp as ``YYYY-MM-DD HH:MM:SS``, or the text given. """ if timestamp is None: - return cls.format_timestamp(datetime.now(DEFAULT_TIMEZONE)) + return cls.format_timestamp(datetime.now(_DEFAULT_TIMEZONE)) if isinstance(timestamp, datetime): return cls.format_timestamp(timestamp) return timestamp diff --git a/src/marketdata/internal_settings.py b/src/marketdata/internal_settings.py index 4ce887f..7fc3945 100644 --- a/src/marketdata/internal_settings.py +++ b/src/marketdata/internal_settings.py @@ -35,7 +35,7 @@ class NoTokenValueType: MAX_CREDIT_WINDOW_SECONDS = 24 * 60 * 60 # The zone of every timestamp the SDK renders. It is kept here, not in utils, # because utils imports exceptions, which needs it too. -DEFAULT_TIMEZONE = pytz.timezone("US/Eastern") +_DEFAULT_TIMEZONE = pytz.timezone("US/Eastern") # The API's IP headers (#44). httpx matches header names case-insensitively, # so the lowercase spelling reads them whatever case the API sends. The legacy diff --git a/src/marketdata/output_handlers/pandas.py b/src/marketdata/output_handlers/pandas.py index 29c387e..d8cd8c7 100644 --- a/src/marketdata/output_handlers/pandas.py +++ b/src/marketdata/output_handlers/pandas.py @@ -2,7 +2,7 @@ import pandas as pd from marketdata.input_types.base import DateFormat -from marketdata.internal_settings import DEFAULT_TIMEZONE +from marketdata.internal_settings import _DEFAULT_TIMEZONE from marketdata.output_handlers.base import BaseOutputHandler _DTYPES = {float: "float64", int: "int64", bool: "bool", str: "object"} @@ -50,24 +50,22 @@ def _convert_timestamp_columns( date_columns: list[str], date_format: DateFormat | None, ) -> pd.DataFrame: - """Turn the date columns into datetimes in ``DEFAULT_TIMEZONE``. + """Convert the date columns to US/Eastern datetimes. Args: - df: The result, with its dates as the API sent them. - date_columns: The columns that hold dates. - date_format: The ``dateformat`` of the request. ``UNIX`` keeps the - columns as numbers, and ``None`` reads them as Unix seconds, - the API's default. + df: The result, with its columns cast. + date_columns: The columns to convert. + date_format: The request's date format. ``None`` reads the values + as Unix seconds, and ``DateFormat.UNIX`` leaves them as numbers. Returns: - ``df`` with its date columns converted. A column that does not - read in that format is left as it is. + The same frame. A column that cannot be converted keeps its values. """ if date_format == DateFormat.UNIX: return df format_to_use = date_format or DateFormat.UNIX - default_tz = DEFAULT_TIMEZONE + default_tz = _DEFAULT_TIMEZONE for col in df.columns: if col not in date_columns: diff --git a/src/marketdata/output_handlers/polars.py b/src/marketdata/output_handlers/polars.py index 2c54ac3..5778eaf 100644 --- a/src/marketdata/output_handlers/polars.py +++ b/src/marketdata/output_handlers/polars.py @@ -1,7 +1,7 @@ import polars as pl from marketdata.input_types.base import DateFormat -from marketdata.internal_settings import DEFAULT_TIMEZONE +from marketdata.internal_settings import _DEFAULT_TIMEZONE from marketdata.output_handlers.base import BaseOutputHandler _DTYPES = {float: pl.Float64, int: pl.Int64, bool: pl.Boolean, str: pl.String} @@ -40,24 +40,23 @@ def _convert_timestamp_columns( date_columns: list[str], date_format: DateFormat | None, ) -> pl.DataFrame: - """Turn the date columns into datetimes in ``DEFAULT_TIMEZONE``. + """Convert the date columns to US/Eastern datetimes. Args: - df: The result, with its dates as the API sent them. - date_columns: The columns that hold dates. - date_format: The ``dateformat`` of the request. ``UNIX`` keeps the - columns as numbers, and ``None`` reads them as Unix seconds, - the API's default. + df: The result, with its columns cast. + date_columns: The columns to convert. + date_format: The request's date format. ``None`` reads the values + as Unix seconds, and ``DateFormat.UNIX`` leaves them as numbers. Returns: - ``df`` with its date columns converted. A column that does not - read in that format is left as it is. + The frame with its date columns converted. A column that cannot be + converted keeps its values. """ if date_format == DateFormat.UNIX: return df format_to_use = date_format or DateFormat.UNIX - default_tz = DEFAULT_TIMEZONE.zone + default_tz = _DEFAULT_TIMEZONE.zone for col in df.columns: if col not in date_columns: diff --git a/src/marketdata/types.py b/src/marketdata/types.py index fc7b541..74ea8fd 100644 --- a/src/marketdata/types.py +++ b/src/marketdata/types.py @@ -3,7 +3,7 @@ from pytz.exceptions import InvalidTimeError -from marketdata.internal_settings import DEFAULT_TIMEZONE +from marketdata.internal_settings import _DEFAULT_TIMEZONE @dataclass @@ -36,7 +36,7 @@ def __post_init__(self): raise refused try: reset_time = datetime.datetime.fromtimestamp( - float(reset_time), tz=DEFAULT_TIMEZONE + float(reset_time), tz=_DEFAULT_TIMEZONE ) except (TypeError, ValueError, OverflowError, OSError): raise refused from None @@ -44,7 +44,7 @@ def __post_init__(self): try: # `is_dst=None`: a repeated or skipped wall time names no single # instant, and picking one would move the reset by an hour. - reset_time = DEFAULT_TIMEZONE.localize(reset_time, is_dst=None) + reset_time = _DEFAULT_TIMEZONE.localize(reset_time, is_dst=None) except InvalidTimeError as exc: raise ValueError( f"{reset_time.isoformat()} is not one US/Eastern time: a " diff --git a/src/marketdata/utils.py b/src/marketdata/utils.py index 5a20f18..151441f 100644 --- a/src/marketdata/utils.py +++ b/src/marketdata/utils.py @@ -10,7 +10,10 @@ from httpx import Response from marketdata.exceptions import ParseError -from marketdata.internal_settings import DEFAULT_TIMEZONE, VALID_STATUS_CODES +from marketdata.internal_settings import _DEFAULT_TIMEZONE, VALID_STATUS_CODES + +# Assigned, not imported: an imported name is not part of the public surface. +DEFAULT_TIMEZONE = _DEFAULT_TIMEZONE def _exact_number(text: str) -> Decimal: diff --git a/src/tests/test_default_timezone.py b/src/tests/test_default_timezone.py index dd5ea1b..ec83775 100644 --- a/src/tests/test_default_timezone.py +++ b/src/tests/test_default_timezone.py @@ -1,72 +1,111 @@ -"""Every timestamp the SDK renders takes its zone from one constant, -`internal_settings.DEFAULT_TIMEZONE`.""" +"""Every timestamp the SDK renders takes its zone from one constant.""" -import ast import datetime -from dataclasses import dataclass -from pathlib import Path +import time +from unittest.mock import patch -import polars as pl import pytest import pytz -from marketdata import exceptions, internal_settings -from marketdata.exceptions import BaseMarketdataException -from marketdata.input_types.base import UserUniversalAPIParams +from marketdata import exceptions, get_meta, types, utils +from marketdata.exceptions import BadRequestError +from marketdata.input_types.base import OutputFormat from marketdata.output_handlers import pandas as pandas_handler from marketdata.output_handlers import polars as polars_handler +from src.tests.conftest import use_real_header_extraction -PACKAGE = Path(internal_settings.__file__).parent +QUOTES_URL = "https://api.marketdata.app/v1/stocks/quotes/" +UPDATED = 1765552906 TOKYO = pytz.timezone("Asia/Tokyo") STAMP = "%Y-%m-%d %H:%M:%S" -@dataclass -class Updated: - updated: datetime.datetime - - @pytest.fixture def tokyo(monkeypatch): - """Point the exception module and both DataFrame handlers at Asia/Tokyo, a - zone with no daylight saving time and 13 or 14 hours away from US/Eastern. + """Point every module that renders a timestamp at Asia/Tokyo, a zone with + no daylight saving time and 13 or 14 hours away from US/Eastern. Returns: The Asia/Tokyo zone. """ - for module in (exceptions, pandas_handler, polars_handler): - monkeypatch.setattr(module, "DEFAULT_TIMEZONE", TOKYO) + for module, name in ( + (utils, "DEFAULT_TIMEZONE"), + (types, "_DEFAULT_TIMEZONE"), + (exceptions, "_DEFAULT_TIMEZONE"), + (pandas_handler, "_DEFAULT_TIMEZONE"), + (polars_handler, "_DEFAULT_TIMEZONE"), + ): + monkeypatch.setattr(module, name, TOKYO) return TOKYO -def test_the_zone_name_is_written_only_where_the_constant_is_defined(): - """No module of the package builds the zone from a literal of its own.""" - literals = [ - f"{path.relative_to(PACKAGE).as_posix()}:{node.lineno}" - for path in sorted(PACKAGE.rglob("*.py")) - for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) - if isinstance(node, ast.Constant) and node.value == "US/Eastern" - ] +def test_utils_default_timezone_is_us_eastern(): + """`marketdata.utils.DEFAULT_TIMEZONE` is the US/Eastern zone.""" + assert utils.DEFAULT_TIMEZONE is pytz.timezone("US/Eastern") + + +def test_internal_dates_and_the_credit_reset_follow_the_constant( + tokyo, load_json, respx_mock, client +): + """An INTERNAL result's dates and the credit reset read from its headers + are in the zone the constant holds.""" + use_real_header_extraction(client) + reset = int(time.time()) + 60 + respx_mock.get(QUOTES_URL).respond( + json=load_json("stocks_quotes_response_200"), + headers={ + "x-api-ratelimit-limit": "100", + "x-api-ratelimit-remaining": "99", + "x-api-ratelimit-reset": str(reset), + "x-api-ratelimit-consumed": "1", + }, + status_code=200, + ) + + quotes = client.stocks.quotes( + symbols=["AAPL", "MSFT"], output_format=OutputFormat.INTERNAL + ) + reset_time = get_meta(quotes).rate_limits.reset_time + + assert quotes[0].updated == datetime.datetime.fromtimestamp(UPDATED, tz=tokyo) + assert str(quotes[0].updated.tzinfo) == "Asia/Tokyo" + assert reset_time.timestamp() == reset + assert str(reset_time.tzinfo) == "Asia/Tokyo" + + +@pytest.mark.parametrize("library", ["pandas", "polars"]) +def test_dataframe_dates_follow_the_constant( + tokyo, load_json, respx_mock, client, library +): + """A DataFrame's date column is in the zone the constant holds. + + Args: + library: The DataFrame library the client builds the result with. + """ + respx_mock.get(QUOTES_URL).respond( + json=load_json("stocks_quotes_response_200"), status_code=200 + ) + + with patch("marketdata.output_handlers.DATAFRAME_HANDLERS_PRIORITY", [library]): + frame = client.stocks.quotes( + symbols=["AAPL", "MSFT"], output_format=OutputFormat.DATAFRAME + ) + updated = frame["updated"].to_list()[0] - assert len(literals) == 1, literals - assert literals[0].startswith("internal_settings.py:"), literals + assert updated == datetime.datetime.fromtimestamp(UPDATED, tz=tokyo) + assert str(updated.tzinfo) == "Asia/Tokyo" -def test_dataframes_and_exception_timestamps_render_in_the_constant_zone(tokyo): - """A DataFrame from either handler and an exception timestamp all follow - the zone the constant holds.""" - handler = { - "data": {"updated": [1765552906]}, - "output_schema": Updated, - "user_universal_params": UserUniversalAPIParams(), - } +def test_an_error_timestamp_follows_the_constant(tokyo, respx_mock, client): + """An error the API answered is stamped with the time in the zone the + constant holds.""" + respx_mock.get(QUOTES_URL).respond( + json={"errmsg": "Invalid symbol"}, status_code=400 + ) - pandas_frame = pandas_handler.PandasOutputHandler(**handler).get_result() - polars_frame = polars_handler.PolarsOutputHandler(**handler).get_result() before = datetime.datetime.now(tokyo).strftime(STAMP) - error = BaseMarketdataException("boom") + with pytest.raises(BadRequestError) as caught: + client.stocks.quotes(symbols=["AAPL"], output_format=OutputFormat.INTERNAL) after = datetime.datetime.now(tokyo).strftime(STAMP) - assert str(pandas_frame["updated"].dt.tz) == "Asia/Tokyo" - assert polars_frame["updated"].dtype == pl.Datetime("us", "Asia/Tokyo") - assert before <= error.timestamp <= after + assert before <= caught.value.timestamp <= after