Skip to content

Commit 446e70e

Browse files
tdobrowolski1claude
andcommitted
v0.3.6: URL-escape path segments + remove tests/ from sdist
Security/correctness fixes from external review: - URL-escape ticker/symbol path parameters (urllib.parse.quote with safe="") to handle inputs like BF/B, BRK.B, X%Y correctly. Previously these would produce malformed URLs. - Remove /tests from sdist allowlist; ship only src/, README, LICENSE, pyproject. Hatchling still bundles .gitignore (built-in behaviour). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c541ec1 commit 446e70e

2 files changed

Lines changed: 32 additions & 23 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "flashalpha"
7-
version = "0.3.5"
7+
version = "0.3.6"
88
description = "Python SDK for the FlashAlpha options analytics API — live options screener, gamma exposure (GEX), VRP, delta, vanna, charm, greeks, 0DTE analytics, volatility surfaces, and more."
99
readme = "README.md"
1010
license = "MIT"
@@ -73,10 +73,11 @@ packages = ["src/flashalpha"]
7373

7474
[tool.hatch.build.targets.sdist]
7575
# Explicit allowlist — only ship source, README, LICENSE, pyproject. Anything
76-
# else in the working dir (e.g. .claude/, CLAUDE.md, .env*, dist/) is excluded.
76+
# else in the working dir (e.g. .claude/, CLAUDE.md, .env*, dist/, tests/)
77+
# is excluded so the sdist stays minimal.
78+
support-legacy = false
7779
include = [
7880
"/src/flashalpha",
79-
"/tests",
8081
"/README.md",
8182
"/LICENSE",
8283
"/pyproject.toml",
@@ -86,6 +87,8 @@ exclude = [
8687
"CLAUDE.md",
8788
".env",
8889
".env.*",
90+
".gitignore",
91+
"**/.gitignore",
8992
".vscode",
9093
".idea",
9194
"*.local",

src/flashalpha/client.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from typing import TYPE_CHECKING, Any
6+
from urllib.parse import quote
67

78
import requests
89

@@ -21,6 +22,11 @@
2122
BASE_URL = "https://lab.flashalpha.com"
2223

2324

25+
def _seg(s: str) -> str:
26+
"""URL-escape a single path segment (e.g. a ticker) — escapes / ? % etc."""
27+
return quote(s, safe="")
28+
29+
2430
class FlashAlpha:
2531
"""Thin wrapper around the FlashAlpha REST API.
2632
@@ -92,7 +98,7 @@ def _handle(self, resp: requests.Response) -> dict:
9298

9399
def stock_quote(self, ticker: str) -> dict:
94100
"""Live stock quote (bid/ask/mid/last)."""
95-
return self._get(f"/stockquote/{ticker}")
101+
return self._get(f"/stockquote/{_seg(ticker)}")
96102

97103
def option_quote(
98104
self,
@@ -110,15 +116,15 @@ def option_quote(
110116
params["strike"] = strike
111117
if type:
112118
params["type"] = type
113-
return self._get(f"/optionquote/{ticker}", params or None)
119+
return self._get(f"/optionquote/{_seg(ticker)}", params or None)
114120

115121
def surface(self, symbol: str) -> dict:
116122
"""Volatility surface grid (public, no auth required)."""
117-
return self._get(f"/v1/surface/{symbol}")
123+
return self._get(f"/v1/surface/{_seg(symbol)}")
118124

119125
def stock_summary(self, symbol: str) -> dict:
120126
"""Comprehensive stock summary (price, vol, exposure, macro)."""
121-
return self._get(f"/v1/stock/{symbol}/summary")
127+
return self._get(f"/v1/stock/{_seg(symbol)}/summary")
122128

123129
# ── Historical ──────────────────────────────────────────────────
124130

@@ -127,7 +133,7 @@ def historical_stock_quote(self, ticker: str, *, date: str, time: str | None = N
127133
params: dict[str, Any] = {"date": date}
128134
if time:
129135
params["time"] = time
130-
return self._get(f"/historical/stockquote/{ticker}", params)
136+
return self._get(f"/historical/stockquote/{_seg(ticker)}", params)
131137

132138
def historical_option_quote(
133139
self,
@@ -149,7 +155,7 @@ def historical_option_quote(
149155
params["strike"] = strike
150156
if type:
151157
params["type"] = type
152-
return self._get(f"/historical/optionquote/{ticker}", params)
158+
return self._get(f"/historical/optionquote/{_seg(ticker)}", params)
153159

154160
# ── Exposure Analytics ──────────────────────────────────────────
155161

@@ -160,40 +166,40 @@ def gex(self, symbol: str, *, expiration: str | None = None, min_oi: int | None
160166
params["expiration"] = expiration
161167
if min_oi is not None:
162168
params["min_oi"] = min_oi
163-
return self._get(f"/v1/exposure/gex/{symbol}", params or None)
169+
return self._get(f"/v1/exposure/gex/{_seg(symbol)}", params or None)
164170

165171
def dex(self, symbol: str, *, expiration: str | None = None) -> dict:
166172
"""Delta exposure by strike."""
167173
params: dict[str, Any] = {}
168174
if expiration:
169175
params["expiration"] = expiration
170-
return self._get(f"/v1/exposure/dex/{symbol}", params or None)
176+
return self._get(f"/v1/exposure/dex/{_seg(symbol)}", params or None)
171177

172178
def vex(self, symbol: str, *, expiration: str | None = None) -> dict:
173179
"""Vanna exposure by strike."""
174180
params: dict[str, Any] = {}
175181
if expiration:
176182
params["expiration"] = expiration
177-
return self._get(f"/v1/exposure/vex/{symbol}", params or None)
183+
return self._get(f"/v1/exposure/vex/{_seg(symbol)}", params or None)
178184

179185
def chex(self, symbol: str, *, expiration: str | None = None) -> dict:
180186
"""Charm exposure by strike."""
181187
params: dict[str, Any] = {}
182188
if expiration:
183189
params["expiration"] = expiration
184-
return self._get(f"/v1/exposure/chex/{symbol}", params or None)
190+
return self._get(f"/v1/exposure/chex/{_seg(symbol)}", params or None)
185191

186192
def exposure_summary(self, symbol: str) -> dict:
187193
"""Full exposure summary (GEX/DEX/VEX/CHEX + hedging). Requires Growth+."""
188-
return self._get(f"/v1/exposure/summary/{symbol}")
194+
return self._get(f"/v1/exposure/summary/{_seg(symbol)}")
189195

190196
def exposure_levels(self, symbol: str) -> dict:
191197
"""Key support/resistance levels from options exposure."""
192-
return self._get(f"/v1/exposure/levels/{symbol}")
198+
return self._get(f"/v1/exposure/levels/{_seg(symbol)}")
193199

194200
def narrative(self, symbol: str) -> dict:
195201
"""Verbal narrative analysis of exposure. Requires Growth+."""
196-
return self._get(f"/v1/exposure/narrative/{symbol}")
202+
return self._get(f"/v1/exposure/narrative/{_seg(symbol)}")
197203

198204
def zero_dte(self, symbol: str, *, strike_range: float | None = None) -> "ZeroDteResponse":
199205
"""Real-time 0DTE analytics: regime, expected move, pin risk, hedging, decay. Requires Growth+.
@@ -205,14 +211,14 @@ def zero_dte(self, symbol: str, *, strike_range: float | None = None) -> "ZeroDt
205211
params: dict[str, Any] = {}
206212
if strike_range is not None:
207213
params["strike_range"] = strike_range
208-
return self._get(f"/v1/exposure/zero-dte/{symbol}", params or None)
214+
return self._get(f"/v1/exposure/zero-dte/{_seg(symbol)}", params or None)
209215

210216
def exposure_history(self, symbol: str, *, days: int | None = None) -> dict:
211217
"""Daily exposure snapshots for trend analysis. Requires Growth+."""
212218
params: dict[str, Any] = {}
213219
if days is not None:
214220
params["days"] = days
215-
return self._get(f"/v1/exposure/history/{symbol}", params or None)
221+
return self._get(f"/v1/exposure/history/{_seg(symbol)}", params or None)
216222

217223
# ── Pricing & Sizing ────────────────────────────────────────────
218224

@@ -287,11 +293,11 @@ def kelly(
287293

288294
def volatility(self, symbol: str) -> dict:
289295
"""Comprehensive volatility analysis. Requires Growth+."""
290-
return self._get(f"/v1/volatility/{symbol}")
296+
return self._get(f"/v1/volatility/{_seg(symbol)}")
291297

292298
def adv_volatility(self, symbol: str) -> dict:
293299
"""Advanced volatility analytics: SVI parameters, variance surface, arbitrage detection, greeks surfaces, variance swap. Requires Alpha+."""
294-
return self._get(f"/v1/adv_volatility/{symbol}")
300+
return self._get(f"/v1/adv_volatility/{_seg(symbol)}")
295301

296302
# ── Reference Data ──────────────────────────────────────────────
297303

@@ -301,7 +307,7 @@ def tickers(self) -> dict:
301307

302308
def options(self, ticker: str) -> dict:
303309
"""Option chain metadata (expirations + strikes)."""
304-
return self._get(f"/v1/options/{ticker}")
310+
return self._get(f"/v1/options/{_seg(ticker)}")
305311

306312
def symbols(self) -> dict:
307313
"""Currently queried symbols with live data."""
@@ -332,7 +338,7 @@ def vrp(self, symbol: str) -> dict:
332338
333339
Requires Alpha+.
334340
"""
335-
return self._get(f"/v1/vrp/{symbol}")
341+
return self._get(f"/v1/vrp/{_seg(symbol)}")
336342

337343
# ── Max Pain ────────────────────────────────────────────────────
338344

@@ -351,7 +357,7 @@ def max_pain(self, symbol: str, *, expiration: str | None = None) -> dict:
351357
params: dict[str, Any] = {}
352358
if expiration:
353359
params["expiration"] = expiration
354-
return self._get(f"/v1/maxpain/{symbol}", params or None)
360+
return self._get(f"/v1/maxpain/{_seg(symbol)}", params or None)
355361

356362
# ── Screener ────────────────────────────────────────────────────
357363

0 commit comments

Comments
 (0)