Skip to content

Commit 0b3b04e

Browse files
committed
feat: ship py.typed so the package is checked as typed
Without the PEP 561 marker, mypy skips the installed package and reports import-untyped, and strict users must set ignore_missing_imports, which turns every massive type into Any. - Add massive/py.typed. The existing packages entry in pyproject.toml already includes it in the wheel and sdist. - Re-export RESTClient and WebSocketClient explicitly ("X as X"), so "from massive import RESTClient" passes --strict, which does not treat plain imports in a typed package as re-exports. This adds no __all__, so "from massive import *" exports exactly the names it did before. - Add test_rest/test_exports.py, which fails if any package stops exporting, through "from <package> import *", a name it exported before this change. - Add test_rest/typing_consumer.py, a small strict user of the public API that is type-checked but never run. It iterates list_* results, indexes get_* lists, checks that raw=True gives HTTPResponse, builds models with keyword arguments, pins each corrected hint with assert_type, and keeps a sample of code written for earlier releases (isinstance narrowing, an int CUSIP) to show it still type-checks. - make static checks it with mypy --strict from inside test_rest/, so massive resolves to the installed package as it does for users. It fails if py.typed goes missing or any of the above regresses.
1 parent df19646 commit 0b3b04e

5 files changed

Lines changed: 261 additions & 2 deletions

File tree

‎Makefile‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ style:
2727
## Check static types
2828
static:
2929
poetry run mypy massive test_* examples
30+
@# From test_rest/, massive resolves to the installed package, as it does
31+
@# for users, so this also fails if the package stops shipping py.typed.
32+
cd test_rest && poetry run mypy --strict typing_consumer.py
3033

3134
## Check code style and static types
3235
lint: style static

‎massive/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
from .rest import RESTClient
1+
from .rest import RESTClient as RESTClient
22
from .rest.base import version
3-
from .websocket import WebSocketClient
3+
from .websocket import WebSocketClient as WebSocketClient
44
from .exceptions import *
55

66
__version__ = version

‎massive/py.typed‎

Whitespace-only changes.

‎test_rest/test_exports.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Guard the names that ``from <package> import *`` exports.
2+
3+
Code that does ``from massive.rest.models import *`` (or the same for any
4+
other package below) depends on every name it got before. This test fails if
5+
a package stops exporting a name it exported before type-checking support was
6+
added (commit e462088, just after v2.9.0). Adding names is fine.
7+
8+
The snapshots are the complete star-export of each package at that commit,
9+
including incidental names such as ``Optional`` or ``logging``: removing one
10+
of those could still break a caller that relied on it. To remove a name on
11+
purpose, delete it from the snapshot in the same change.
12+
"""
13+
14+
import importlib
15+
import unittest
16+
17+
# Names that ``from <package> import *`` exported at commit e462088.
18+
BASELINE_EXPORTS = {
19+
"massive": """
20+
AuthError BadResponse RESTClient WebSocketClient certs exceptions logging
21+
modelclass rest version websocket
22+
""".split(),
23+
"massive.rest": """
24+
AggsClient Any BASE BenzingaClient ConditionsClient ContractsClient Dict
25+
DividendsClient ENV_KEY EconomyClient EtfGlobalClient ExchangesClient
26+
FinancialsClient FuturesClient IndicatorsClient MarketsClient Optional
27+
QuotesClient RESTClient SnapshotClient SplitsClient SummariesClient
28+
TickersClient TmxClient TradesClient VXClient aggs base benzinga
29+
ca_bundle_path economy etf_global financials futures indicators logger
30+
models os quotes reference snapshot summaries tmx trades vX
31+
""".split(),
32+
"massive.rest.models": """
33+
Agg Any AssetClass BalanceSheet Branding CashFlowStatement CompanyAddress
34+
ComprehensiveIncome Condition Consolidated CryptoTrade DailyOpenCloseAgg
35+
DataPoint DataType DayOptionContractSnapshot Dict Direction Disclosure
36+
DisclosureTaxonomy Dividend DividendType EMAIndicatorResults Enum Exchange
37+
ExchangeType Filing13F Filing8K FilingFootnote FilingForm3 FilingForm4
38+
FilingIndex FilingSection FinancialBalanceSheet FinancialCashFlowStatement
39+
FinancialFloat FinancialIncomeStatement FinancialRatio Financials ForexQuote
40+
Frequency FuturesAgg FuturesContract FuturesExchange FuturesMarketStatus
41+
FuturesProduct FuturesQuote FuturesSchedule FuturesSnapshot
42+
FuturesSnapshotDetails FuturesSnapshotMinute FuturesSnapshotQuote
43+
FuturesSnapshotSession FuturesSnapshotTrade FuturesTrade Greeks
44+
GroupedDailyAgg IPOListing IncomeStatement IndicatorUnderlying
45+
IndicatorValue IndicesSession IndicesSnapshot Insight LastForexQuote
46+
LastQuote LastQuoteOptionContractSnapshot LastTrade
47+
LastTradeOptionContractSnapshot LaunchPadOptions List Locale
48+
MACDIndicatorResults MACDIndicatorValue Market MarketCenter MarketCurrencies
49+
MarketExchanges MarketHoliday MarketIndices MarketStatus MinuteSnapshot
50+
OptionContractSnapshot OptionDetails Optional Options OptionsContract Order
51+
OrderBookQuote Precision PreviousCloseAgg Publisher Quote
52+
RSIIndicatorResults RealTimeCurrencyConversion RelatedCompany RiskFactor
53+
RiskFactorTaxonomy SIP SMAIndicatorResults SeriesType Session ShortInterest
54+
ShortVolume SingleIndicatorResults SipMapping SnapshotMarketType
55+
SnapshotTickerFullBook Sort Split StockDividend StockFinancial StockSplit
56+
SummaryResult Ticker TickerChange TickerChangeEvent TickerChangeResults
57+
TickerDetails TickerNews TickerSnapshot TickerTypes Timeframe Trade
58+
Underlying UnderlyingAsset Union UniversalSnapshot UniversalSnapshotDetails
59+
UniversalSnapshotLastMinute UniversalSnapshotLastQuote
60+
UniversalSnapshotLastTrade UniversalSnapshotSession
61+
UniversalSnapshotUnderlyingAsset UpdateRules aggs benzinga common conditions
62+
contracts dataclass dividends economy etf_global exchanges financials
63+
futures indicators markets modelclass quotes request snapshot splits
64+
summaries tickers tmx trades
65+
""".split(),
66+
"massive.websocket": """
67+
Any AuthError Awaitable Callable ClientConnection ConnectionClosedError
68+
ConnectionClosedOK Counter CryptoQuote CryptoTrade CurrencyAgg Dict Enum
69+
EquityAgg EquityQuote EquityTrade EventType FairMarketValue Feed ForexQuote
70+
FromDictProtocol FuturesAgg FuturesQuote FuturesTrade Imbalance IndexValue
71+
LaunchpadValue Level2Book LimitUpLimitDown List MARKET_EVENT_MAP Market
72+
NewType OK_STATUSES Optional Protocol Set Type Union WebSocketClient
73+
WebSocketMessage asyncio ca_bundle_path cast common connect env_key
74+
get_logger json logger logging modelclass models os parse parse_single ssl
75+
time
76+
""".split(),
77+
"massive.websocket.models": """
78+
Any CryptoQuote CryptoTrade CurrencyAgg Dict Enum EquityAgg EquityQuote
79+
EquityTrade EventType FairMarketValue Feed ForexQuote FromDictProtocol
80+
FuturesAgg FuturesQuote FuturesTrade Imbalance IndexValue LaunchpadValue
81+
Level2Book LimitUpLimitDown List MARKET_EVENT_MAP Market NewType Optional
82+
Protocol Type Union WebSocketMessage cast common logging modelclass models
83+
parse parse_single
84+
""".split(),
85+
}
86+
87+
88+
def star_exports(module_name):
89+
"""Return the names ``from module_name import *`` binds."""
90+
module = importlib.import_module(module_name)
91+
names = getattr(module, "__all__", None)
92+
if names is None:
93+
names = [n for n in vars(module) if not n.startswith("_")]
94+
return set(names)
95+
96+
97+
class StarExportsTest(unittest.TestCase):
98+
def test_no_name_removed(self):
99+
for package, names in BASELINE_EXPORTS.items():
100+
with self.subTest(package=package):
101+
missing = sorted(set(names) - star_exports(package))
102+
self.assertEqual(missing, [], f"no longer exported by {package}")
103+
104+
105+
if __name__ == "__main__":
106+
unittest.main()

‎test_rest/typing_consumer.py‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Typical user code, checked with ``mypy --strict`` but never run.
2+
3+
``make static`` checks this file from inside ``test_rest/``, so ``massive``
4+
resolves to the installed package rather than the source tree, exactly as it
5+
does for a user. That check fails if:
6+
7+
- the package stops shipping ``py.typed`` (mypy reports ``import-untyped``);
8+
- a public name stops being re-exported from ``massive``;
9+
- a REST method's default call no longer returns the model type, so
10+
iterating, indexing or reading attributes needs an ``isinstance`` check;
11+
- ``raw=True`` no longer returns ``HTTPResponse``;
12+
- a model constructor stops accepting its fields as keywords;
13+
- a corrected hint regresses. ``assert_type`` checks the exact type, so
14+
``Optional[int]`` does not pass where ``Optional[float]`` is expected;
15+
- code written for earlier releases, which narrows each result with
16+
``isinstance`` and passes an ``int`` CUSIP, stops type-checking.
17+
18+
The file is named so that unittest discovery does not import it.
19+
"""
20+
21+
from typing import Iterator, List, Optional, Union
22+
23+
from typing_extensions import assert_type
24+
from urllib3 import HTTPResponse
25+
26+
from massive import AuthError, BadResponse, RESTClient, WebSocketClient
27+
from massive.rest.models import (
28+
Agg,
29+
IPOListing,
30+
LastTrade,
31+
PreviousCloseAgg,
32+
ShortInterest,
33+
ShortVolume,
34+
Ticker,
35+
)
36+
from massive.rest.models.economy import TreasuryYield
37+
from massive.websocket.models import CryptoQuote, IndexValue, WebSocketMessage
38+
39+
40+
def ticker_symbols(client: RESTClient) -> List[str]:
41+
# list_* yields models; no isinstance check is needed to read them.
42+
return [t.ticker for t in client.list_tickers(market="stocks") if t.ticker]
43+
44+
45+
def apple_tickers(client: RESTClient) -> List[Ticker]:
46+
# A CUSIP is a string; an int is still accepted for backward compatibility.
47+
return list(client.list_tickers(cusip="037833100")) + list(
48+
client.list_tickers(cusip=37833100)
49+
)
50+
51+
52+
def first_close(client: RESTClient) -> Optional[float]:
53+
# get_* list endpoints can be indexed directly.
54+
bars = client.get_aggs("AAPL", 1, "day", "2024-01-02", "2024-01-31")
55+
assert_type(bars, List[Agg])
56+
return bars[0].close if bars else None
57+
58+
59+
def previous_close(client: RESTClient) -> Optional[float]:
60+
aggs = client.get_previous_close_agg("AAPL")
61+
assert_type(aggs, List[PreviousCloseAgg])
62+
return aggs[0].close if aggs else None
63+
64+
65+
def last_price(client: RESTClient) -> Optional[float]:
66+
trade = client.get_last_trade("AAPL")
67+
assert_type(trade, LastTrade)
68+
return trade.price
69+
70+
71+
def paginated(client: RESTClient) -> None:
72+
assert_type(
73+
client.list_aggs("AAPL", 1, "day", "2024-01-02", "2024-01-31"), Iterator[Agg]
74+
)
75+
assert_type(client.list_treasury_yields(), Iterator[TreasuryYield])
76+
assert_type(client.list_short_interest(ticker="AAPL"), Iterator[ShortInterest])
77+
assert_type(client.list_short_volume(ticker="AAPL"), Iterator[ShortVolume])
78+
assert_type(client.vx.list_ipos(), Iterator[IPOListing])
79+
80+
81+
def raw_status(client: RESTClient) -> int:
82+
resp = client.get_aggs("AAPL", 1, "day", "2024-01-02", "2024-01-31", raw=True)
83+
assert_type(resp, HTTPResponse)
84+
assert_type(client.list_tickers(raw=True), HTTPResponse)
85+
assert_type(client.get_last_trade("AAPL", raw=True), HTTPResponse)
86+
return resp.status
87+
88+
89+
def raw_flag(client: RESTClient, raw: bool) -> None:
90+
# A non-literal flag gets the full union, as before.
91+
assert_type(client.get_last_trade("AAPL", raw=raw), Union[LastTrade, HTTPResponse])
92+
assert_type(client.list_tickers(raw=raw), Union[Iterator[Ticker], HTTPResponse])
93+
94+
95+
def ticker_symbols_or_none(client: RESTClient) -> Optional[List[str]]:
96+
try:
97+
return ticker_symbols(client)
98+
except (AuthError, BadResponse):
99+
return None
100+
101+
102+
def spread(q: CryptoQuote) -> Optional[float]:
103+
assert_type(q.bid_price, Optional[float])
104+
assert_type(q.ask_price, Optional[float])
105+
assert_type(q.ask_size, Optional[float])
106+
if q.ask_price is None or q.bid_price is None:
107+
return None
108+
return q.ask_price - q.bid_price
109+
110+
111+
def index_time_ms(v: IndexValue) -> Optional[int]:
112+
assert_type(v.timestamp, Optional[int])
113+
return v.timestamp
114+
115+
116+
def handle(msgs: List[WebSocketMessage]) -> None:
117+
for m in msgs:
118+
if isinstance(m, CryptoQuote):
119+
spread(m)
120+
elif isinstance(m, IndexValue):
121+
index_time_ms(m)
122+
123+
124+
def make_ws_client() -> WebSocketClient:
125+
return WebSocketClient(api_key="key", subscriptions=["XQ.BTC-USD"])
126+
127+
128+
def build_models() -> List[object]:
129+
# Model constructors accept their fields as keywords.
130+
quote = CryptoQuote(pair="BTC-USD", bid_price=33052.79, ask_size=0.601)
131+
value = IndexValue(ticker="I:SPX", value=3988.5, timestamp=1678220098130)
132+
trade = LastTrade(ticker="AAPL", sequence_number=3135876)
133+
return [quote, value, trade]
134+
135+
136+
def written_for_earlier_releases(client: RESTClient, raw: bool) -> None:
137+
# Code written when every REST method returned Union[Model, HTTPResponse]
138+
# narrows the result itself. It must keep type-checking.
139+
trade = client.get_last_trade("AAPL")
140+
if isinstance(trade, LastTrade):
141+
print(trade.price)
142+
for t in client.list_tickers(cusip=37833100):
143+
if isinstance(t, Ticker):
144+
print(t.ticker)
145+
resp = client.get_last_trade("AAPL", raw=True)
146+
if isinstance(resp, HTTPResponse):
147+
print(resp.status)
148+
either: Union[LastTrade, HTTPResponse] = client.get_last_trade("AAPL", raw=raw)
149+
tickers: Union[Iterator[Ticker], HTTPResponse] = client.list_tickers()
150+
print(either, tickers)

0 commit comments

Comments
 (0)