Release v2.7.0
🎉 Release v2.7.0
Released from dev. A security-and-contracts minor in the same shape as
2.6.0: credential config fields are now pydantic.SecretStr, so
model_dump() can no longer leak an API key, and a large #252 / #273
hardening pass closes the remaining redaction, publish-isolation, and
CI-enforcement holes that 2.6.0 left open.
This cut also aligns the client with the 2026 FMP /stable surface
(diluted P/E, screener pagination, Senate/House senateID, rating-bulk
scores, delisted companies), finishes the Endpoint[T] / _unwrap_list
typing migration, types period / interval / timeframe as closed
vocabularies (Period, Interval, TechnicalInterval, Timeframe),
and adds a local VCR-backed client-method sweep.
Read before upgrading. Three public-type breaks, all narrow:
| Change | Who it affects | Migration |
|---|---|---|
Credential config fields are SecretStr (#252 FMP-SEC-007) |
anyone reading ClientConfig.api_key, LangChainConfig.embedding_api_key, EmbeddingConfig.api_key, or CacheConfig.redis_url as a str |
call .get_secret_value() |
Senate/House trade dates are date, not datetime (#338) |
anyone comparing SenateTrade / HouseDisclosure disclosure_date, transaction_date, or SenateTrade.date_received to datetime.now() |
compare with date.today() |
SenateNetWorthValueRange dropped .min / .max (#336) |
anyone reading those attributes | use .minimum / .maximum (wire aliases min / max unchanged) |
Construction is unchanged — passing a plain str still works. repr() /
str() were already redacted. What breaks is code that reads the field
as a string (comparisons, slicing, or passing it into a str parameter).
Passing a SecretStr where a string is expected often fails silently
(httpx sends apikey=********** → 401). If you see unexplained auth
failures after upgrading, look for a missed .get_secret_value().
Logger names no longer double fmp_data. (#238). If you filtered
fmp_data.fmp_data.*, switch to fmp_data.*.
3.0 is still the removal cut. The Endpoint.arg_model /
EndpointParam.required deletions and the removal of withdrawn endpoints
announced in 2.6.0 stay deprecated, not deleted. In-tree tripwires refuse
a ## [3.x] changelog heading until that work lands. Withdrawn tools
still register and return [].
FMP API surface (scan this first)
Source: FMP public changelog
(docs/changelog)
plus a live /stable probe on 2026-08-12. Marketing-only items (dashboard,
localization, Insights Hub, plan add-ons) and FMP's hosted MCP product are
out of scope.
New (now first-class in this client)
| Surface | What you get | Wire key / param | Client |
|---|---|---|---|
| Financial ratios | Diluted P/E | priceToEarningsDilutedRatio |
FinancialRatios.price_to_earnings_diluted_ratio |
| Financial ratios | Diluted PEG | priceToEarningsDilutedGrowthRatio |
FinancialRatios.price_to_earnings_diluted_growth_ratio |
Ratios TTM (+ ratios-ttm-bulk) |
Diluted P/E TTM | priceToEarningsDilutedRatioTTM |
FinancialRatiosTTM.price_to_earnings_diluted_ratio_ttm |
Ratios TTM (+ ratios-ttm-bulk) |
Diluted PEG TTM | priceToEarningsDilutedGrowthRatioTTM |
FinancialRatiosTTM.price_to_earnings_diluted_growth_ratio_ttm |
| Company screener | Pagination | page (optional, omitted when unset) |
market.get_company_screener(..., page=None) |
| Senate / House trades | Entity id | senateID (same key on House rows) |
SenateTrade.senate_id / HouseDisclosure.senate_id |
| Ratings bulk | Score columns | discountedCashFlowScore, returnOnEquityScore, returnOnAssetsScore, debtToEquityScore, priceToEarningsScore, priceToBookScore |
CompanyRating.*_score |
| Delisted companies | Slim delist list | /stable/delisted-companies (page, limit) |
company.get_delisted_companies → DelistedCompany |
Updated (path or contract still matches; no caller change)
| FMP note | Path probed | Result |
|---|---|---|
| Exchange directory taxonomy (2025-06) | /stable/available-exchanges |
200. Keys exchange, name, countryName, countryCode, delay, symbolSuffix — already on ExchangeSymbol. |
| Exchange variants (2025-05/06) | /stable/search-exchange-variants?query=Apple |
200. Profile-shaped rows still parse as CompanySearchResult. |
| DCF valuations bulk naming (2025-06) | /stable/dcf-bulk |
200. Headers symbol,date,dcf,Stock Price. Client still remaps Stock Price → stockPrice. |
| Historical S&P 500 symbol naming (2025-06) | /stable/historical-sp500-constituent |
200. symbol present; HistoricalIndexConstituent still parses. |
| Stock ratings bulk field standardization (2025-06) | /stable/rating-bulk |
200. Score columns now typed on CompanyRating (see New). |
| CUSIP on fund disclosure holders (2025-10) | /stable/funds/disclosure-holders-latest |
Only securityCusip. Already modeled; no second key. |
ETF isActivelyTrading (2025-12) |
/stable/etf/info |
Present. Already modeled. |
Splits calendar splitType (2025-11) |
/stable/splits-calendar |
Present (may be null). Already modeled. |
Earnings includeReportTimes (2026-06) |
/stable/earnings-calendar?includeReportTimes=true |
Extra fields confirmed, fiscalPeriod, fiscalYear, periodEnding, time, lastUpdated already modeled. |
Profile bulk part=0..3 (2024-10) |
/stable/profile-bulk?part=0 |
200. Multi-part scheme unchanged. |
| Legacy route auth-gate (2025-08) | /api/v3/profile/AAPL |
403. No remaining live client path uses APIVersion.V3. The one leftover V4 declaration is the already-withdrawn stock-news-sentiments. |
Deprecated / withdrawn in this pass
None. No FMP path we ship was newly retired by this changelog window.
Added
-
Senate and House trades by member id (#323).
get_senate_trades_by_id/get_house_trades_by_id(sync + async)
call/stable/senate-trades-by-idand/stable/house-trades-by-id
with query paramsenateID. ReusesSenateTrade/HouseDisclosure.
The docs exampleS000033currently returns an empty list; Whitehouse
W000802and PelosiP000197return rows (probed 2026-08-15). -
Senate member profiles and term history (#324).
get_senate_profile/get_senate_positions(sync + async) call
/stable/senate-profileand/stable/senate-positions. New models
SenateProfileandSenatePosition. Unset optional filters are
omitted from the query string. Sample rows include House members;
FMP's path names are kept. -
Senate net-worth disclosures (#325).
get_senate_net_worth/get_senate_net_worth_aggregated(sync +
async) call/stable/senate-net-worthand
/stable/senate-net-worth-aggregated. NestedvalueRange/
debtDetailsstay nested.totalsColis optional: a live probe on
2026-08-15 returned 200 with 12 yearly rows when it was omitted,
sent empty, or set tototal. MissingsenateIDis 400. -
Public re-export of
Structure(#352). Annotate revenue
segmentationstructure=againstfrom fmp_data import Structure.
It isLiteral["flat", "nested"]. Live/stablereturns the same
list-of-objects for both (probed 2026-08-17).StructureTypeEnum
stays leftover / deprecated. -
Closed request vocabularies for period, interval, and timeframe
(#306, #308). Client methods now takePeriod/PeriodFiscal/
PeriodAnnualQuarter,Interval, andTimeframe(Literalaliases
infmp_data.schema) instead of a nakedstr. Three period types on
purpose: financial reports and batch bulk accept onlyFY/Q1–Q4.
Endpointvalid_valuesare derived from the same aliases. Plain
strings still work at runtime. Re-exported fromfmp_data(__all__):
from fmp_data import Period, PeriodFiscal, PeriodAnnualQuarter, Interval, Timeframe. The e2e harness samples required closed params
from the method annotation, not a global"annual"table. -
Public re-export of
TechnicalInterval(#311). Annotate
TechnicalClient.interval=against
from fmp_data import TechnicalInterval. It isIntervalplus
"daily"/"hourly", notTimeframe(1day). Client-only leftover
aliases mapped by_normalize_timeframe; endpointvalid_valuesstay
onTimeframe/Interval. -
Local VCR-backed client-method e2e sweep. Maintainer script
scripts/e2e_endpoints.py(make e2e-record/make e2e-replay) calls
every public syncFMPDataClientmethod, records gitignored cassettes
undertests/e2e/vcr_cassettes/, and prints a per-method report. Opt-in
(-m not e2ein addopts); not run bymake test. First live pass
covered 265 methods: 239 ok, 26 deprecated skips. -
FMP hosted MCP vs
fmp-mcppositioning (#230). Docs-only: we are not
wrapping FMP's remote MCP URL. README +docs/mcp/index.mdpoint at
docs/mcp/hosted.md, which records the 2026-08-12 decision (A+D: position- coverage matrix), a live
tools/listof 28 dataset tools (changelog
said 27;tipranksis a paid add-on), and when to use hosted MCP vs this
package. No runtime helper, no FastMCP dependency, no CI smoke.
- coverage matrix), a live
-
FMP changelog alignment (#229). Three confirmed 2026 wire gaps, the
leftover 2025-10 delisted-companies wiring (#233), plus the older-note
re-probe above. Live/stablechecks used the current API key on
2026-08-12.- Diluted P/E on ratios.
FinancialRatiosandFinancialRatiosTTMnow
declare the diluted PE / diluted PEG pair FMP added on 2026-07-30
(priceToEarningsDilutedRatio,priceToEarningsDilutedGrowthRatio, and
the*TTMnames on TTM +ratios-ttm-bulk). They previously landed only
in__pydantic_extra__and could warn underFMP_VALIDATION_MODE=warn. - Screener
page.market.get_company_screener/ async accept optional
page: int | None = None. Unset is omitted from the request (existing
callers keep the same wire).page=0is sent. Live:limit=2&page=0
andlimit=2&page=1return distinct first rows. senateIDon Senate and House trades.SenateTrade.senate_idand
HouseDisclosure.senate_idaliassenateID. The wire name is kept on
House rows (Pelosi →P000197); we do not inventhouse_id.CompanyRatingscore columns.rating-bulkheaders
discountedCashFlowScore/returnOnEquityScore/returnOnAssetsScore
/debtToEquityScore/priceToEarningsScore/priceToBookScoreare
now typed attributes. Fractional CSV cells such as"3.0"coerce to
intsoparse_csv_modelsdoes not skip the whole company row.company.get_delisted_companies. The 2025-10 delisted-companies
architecture sync left a live/stable/delisted-companiespath whose
declaration usedCompanyProfileand had no client method. Sync and
async clients now exposeget_delisted_companies(page=0, limit=100)
returningDelistedCompany(symbol,companyName,exchange,
ipoDate,delistedDate). MCP:company.delisted_companies(in
DEFAULT_TOOLS). LangChain indexes the same semantics key.
- Diluted P/E on ratios.
Changed
-
One module now owns credential display redaction (#316).
redact_credential_patternsis the shared query/assignment/encoded
path (CodeQL-safe for stdout / logging sinks; never takes the live
secret).redact_held_secretis exact-replace for prompts /
getpassonly. The setup wizard also runsredact_key_shaped_tokens
(sk-/32-char heuristics); those stay off HTTP error bodies so request
ids are not blanked. -
redact_credential_patternsredactsFMP_API_KEY=assignments
(#330). The #316 lookbehind skipped every*_API_KEY=assignment
so wizard copyexport FMP_API_KEY="[YOUR_API_KEY]"survived — and
so did a real token in an HTTP error body. Prefixed names now match;
only the wizard placeholder[YOUR_API_KEY]is left alone.
Wizard 32-char / hex-40 heuristics stay off this path. -
SenateIdbranded type on everysenate_idfield (#338). Shared
Annotatedtype (likeCIK) onSenateTrade,HouseDisclosure,
SenateProfile,SenatePosition, and the net-worth rows. Strings
pass through untouched. -
Senate/House trade dates are
date, notdatetime(#338).
SenateTrade/HouseDisclosuredisclosure_dateand
transaction_date(andSenateTrade.date_received) now match the
profile / net-worth family. Breaking for callers that compared those
fields todatetime.now(). -
SENATE_LATEST/HOUSE_LATESTpagination is optional (#338).
page/limitmove tooptional_params, same rule as trades-by-id. -
RSS pagination leftovers are optional (#345).
SENATE_TRADING_RSS.page,CROWDFUNDING_RSSpage/limit, and
adjacentEQUITY_OFFERING_RSSpage/limitmove to
optional_paramssovalidate_params({})applies the documented
defaults. NotPaginationArg(1-based, different defaults). -
Company segmentation/report leftover defaults are optional (#349).
structure/periodon product and geographic revenue
segmentation, andperiodon financial-reports JSON/XLSX, move to
optional_paramssovalidate_paramsapplies the documented
defaults (flat/annual/FY). Segmentation methods now take
structure: Structure = "flat"(Literal["flat", "nested"]).
Stable currently returns the same list-of-objects for both
(probed 2026-08-17). No remaining mandatory-plus-default params in
the catalogue. -
SenateNetWorthValueRangeno longer shadows builtins (#336).
Fields areminimum/maximum(wire aliasesmin/max).
Inverted and non-finite ranges are rejected. Callers using.min/
.maxmust switch. -
redact_credential_patternsredacts hyphenated assignment names
(#339).X-API-KEY=/FMP-API-KEY=now match. Wizard placeholder
[YOUR_API_KEY]and 32-char request ids are still left alone. -
BREAKING: credential config fields are
pydantic.SecretStr(#252
FMP-SEC-007).ClientConfig.api_key,LangChainConfig.embedding_api_key,
EmbeddingConfig.api_keyandCacheConfig.redis_urlchanged type.repr()andstr()were already redacted, butmodel_dump()and
model_dump_json()returned every one of them in cleartext — so anything
serializing a config (a structured log, a JSON dump, an error report) leaked
the key.SecretStrcloses that at the type level, including for
third-party callers who invokemodel_dump()themselves.Migration: read the value with
.get_secret_value().# before client = FMPDataClient(api_key=config.api_key) # after client = FMPDataClient(api_key=config.api_key.get_secret_value())
Construction is unchanged — passing a plain
strstill works, pydantic
coerces it. Truthiness checks (if not config.api_key) still work. What
breaks is code that reads the field expecting astr: comparisons
against a string, slicing, or passing it into astr-typed parameter.Note that passing a
SecretStrwhere a string is expected often fails
silently rather than loudly —httpxaccepts it and sends
apikey=**********, producing 401s with no local error. If you see
unexplained auth failures after upgrading, look for a missed
.get_secret_value().ClientConfig.validate_api_keynow also rejects a key consisting only of
asterisks, which is what rebuilding a config from a masked dump produces. -
List-returning requests are
list[T]without collapsingrequest()(#235).
BaseClient.requeststaysEndpoint[T] -> T | list[T]. Company list
methods stay onrequest()(so existing mocks still work) and normalize
throughEndpointGroup._unwrap_list.request_list/request_async_list
are the typedEndpoint[T] -> list[T]façade for callers that do not
need to mockrequest. A lone object becomes[row]; an empty list
stays empty. -
Remaining list endpoints and methods now go through
_unwrap_list/
Endpoint[T](#242). Market, fundamental, investment, intelligence,
institutional, alternative, economics, and technical list surfaces bind
the row type (Endpoint[T], notEndpoint[list[T]]) and normalize
request()/request_async()with_unwrap_list. Company historical
EOD helpers unwrap rows then wrapHistoricalData.request()stays
T | list[T]. Methods are not switched torequest_list.
get_mutual_fund_dates/get_fund_disclosure_datesare annotated
list[PortfolioDate](runtime already returnedPortfolioDaterows). -
Leftover index, SEC, transcripts, and batch quote lists go through
_unwrap_list/Endpoint[T](#245). Index constituents, SEC list
surfaces (notSEC_PROFILE), transcripts, and batch JSON quote lists
bind the row type and normalizerequest()/request_async().
get_profilestays_unwrap_single. Bulk-bytes / CSV paths stay on
_request_csv.request()staysT | list[T]. -
Batch bulk-bytes / CSV endpoints are
Endpoint[bytes](#247).
All 18*_BULKCSV downloads bindbytes(not a row type). In batch,
_request_csvis the only bytes helper. Quote lists stay on
_unwrap_list(#246).request()staysT | list[T].
FINANCIAL_REPORTS_XLSXis unchanged: company XLSX download, still a
bareEndpoint, not_request_csv. -
request(Endpoint[bytes])isbytes;request_listrefuses bytes
(#249).request()/request_async()overload tobytesfor file
downloads and stayT | list[T]for every otherT.
request_list/request_async_listoverload toNoReturnand raise
TypeErroronresponse_model is bytesinstead of wrapping the file
as[bytes]. Quote lists still unwrap tolist[T]. -
Company financial-report endpoints are
Endpoint[T](#250).
FINANCIAL_REPORTS_JSONisEndpoint[FinancialReportJSON];
FINANCIAL_REPORTS_XLSXisEndpoint[bytes]. Client methods keep
their existing type checks and still returndict/bytes. XLSX
stays off batch_request_csv. -
Leftover period/interval enums track the Literal contracts (#307,
#309).ReportingPeriodEnum,IntervalEnum, and
IntradayTimeIntervalunpack member values from the Literal tuples
so they cannot drift; member names are unchanged. Deprecated
technical.schema.TimeIntervalis now an alias ofTechnicalInterval
(daily/hourlyincluded). Deprecated
alternative.schema.BaseIntradayArgsno longer keeps a second
hardcoded interval list; the field type isInterval. Live
TechnicalClientsignatures are unchanged. The arg-model types
themselves stay until 3.0. -
CI and local uv are 0.12.3+.
setup-uvv10 installs uv0.12.3.
[tool.uv] required-version = ">=0.12.3"rejects older local
installs. The action pin remains the v10.0.0 SHA. -
Separate extras coverage gate for
lc/mcp/ Redis (#273).
The core 80% report still omits those trees.nox -s coverage_extras
(CI jobextras-coverage) installs the extras, measures only those
files, and fails under 65% (measured baseline 66.78% on 2026-08-13).
Test-MatrixExpectedrequires that job. Themcp-serversession
now runs everytests/unit/test_mcp*.pyfile. -
Extras coverage gate raised to 80% and made a required check (#273, #282).
Dedicated tests forcache/redis_backend.py,lc/validation.py,
lc/__init__.py,mcp/utils.pyandmcp/setup.pytook the measured extras
number from 66.78% to 86.99%, sonox -s coverage_extrasnow fails under 80
with ~7 points of headroom.Extras Coverage,coverage,Secret Scan,
Actions shell checksandTest-MatrixExpectedare now required status
checks on Protect Dev and Protect Main; previously only
tests (3.10–3.14)were required, so a red extras or secret scan could not
block a merge. Also fixed theProtocolexclusion inextras.coveragerc,
which had been copied from TOML with\\(and so never matched. -
uv.lockstays uncommitted, by decision (#273). Recorded in
CONTRIBUTING.mdunder "Lock file policy": this is a library, so the
compatibility contract is the floors inpyproject.toml; drift is caught by
nox -s securityauditing a liveuv exportwithpip-audit --strict
rather than frozen behind stale pins. The old.gitignorerationale cited a
non-existent "GitHub 500KB warning" (that threshold is pre-commit's
check-added-large-filesdefault) and has been replaced. -
fmp-mcp generatewrites JSON / YAML / TOML from the output suffix (#256).
.jsonis preferred ({"tools": [...]})..yaml/.ymland.tomluse
the sametoolsobject. A path with no suffix becomes<name>.json.
.pystill writes the restrictedTOOLS = ["..."]form so existing
scripts keep working. Unknown suffixes are refused.
Fixed
-
Remaining
httpx.RequestErrorleftovers no longer leakapikey=
(#354).ProtocolError,ProxyError,DecodingError,
TooManyRedirects,UnsupportedProtocol, and any future
RequestErrornow raiseFMPNetworkErrorwithfrom None. Same
secret-absent pin as #97 / #350 (str/repr/__cause__/
traceback / error log).ProtocolError/ProxyErrorstay
retryable; the others setFMPNetworkError.retryable = False.
Timeouts stayFMPTimeoutError.validate_api_keyclassifies
FMPNetworkErrorand raw non-timeouthttpx.RequestError(fakes /
adapters) asunavailable. -
Timeout and network errors no longer leak
apikey=(#350).
httpx.TimeoutException/httpx.NetworkErrornow raise
FMPTimeoutError/FMPNetworkErrorwithfrom None. The request
URL is not logged (str(httpx_exc)andexc_info=Trueare off that
path). Status errors were already mapped in #97; this is the leftover
transport path. Callers that caught raw httpx timeouts should catch
the new types (they remainFMPErrorsubclasses and stay retryable).
validate_api_keyclassifiesFMPTimeoutErrorastimeoutand
FMPNetworkErrorasunavailableso a transport failure cannot
report a valid key. -
MCP setup helpers no longer echo subprocess stderr (#319).
validate_api_keyandtest_mcp_serverreturn classified reasons
(valid/invalid/timeout/unavailableand
passed/started/failed/unavailable).fmp-mcp status
andfmp-mcp testprint sink-local literals so a key quoted in
child stderr cannot reach the terminal. -
validate_api_keynow probes FMP (#317).
A typed junk key no longer reports success. The helper constructs a
client and callscompany.get_quote("AAPL"); 401 and 403 map to
invalid. Rate-limit / other HTTP errors still count asvalid. -
validate_api_keyfirst classified a status-lessInvalid API KEY
body as invalid (#329). That wizard-side copy matcher is superseded
by #340 in this same 2.7.0 section. Live/stable/quotereturns
HTTP 401 for a junk key (probed 2026-08-15). -
Setup wizard offers the example MCP profiles again (#320).
get_manifest_choiceslooks inexamples/mcp/configurations/(the
real directory). The example Claude Desktop script, JSON profiles,
and README / troubleshooting copies use the same path. -
E2E sweep no longer treats Senate/House trades-by-id as allowed
empty (#337). Those methods pin known-nonempty ids (W000802/
P000197); a 200/[]is the 404-as-empty class, not entity
emptiness. Name-based lookups stay onALLOW_EMPTY. -
2xx
Invalid API KEYbodies raiseAuthenticationError(#340).
_check_error_responsetypes the known invalid-key copy instead of
a status-lessFMPError. Unrelated 2xx bodies stayFMPError.
validate_api_key's copy matcher is gone — the
except AuthenticationErrorpath covers it. -
Net-worth integration tests lock nested objects, aggregated
aliases, andmodel_extra(#336). Itemized rows must have empty
model_extraand at least one populatedvalueRangeand
debtDetails. Aggregated pages must populate bothtotaland
stock. -
Committed cassette-contract snapshot fails CI without YAML (#336).
tests/integration/cassette_contracts.jsonlists required wire
aliases for government-trading models. The YAML payload check still
skips when cassettes are gitignored. -
List-shaped 2xx
Invalid API KEYbodies raise
AuthenticationError(#342). A one-element list whose only keys
are error keys is routed through_raise_response_error. Multi-row
lists are not walked. Unrelated singleton error lists stay
FMPError. -
Residual list-shaped 2xx error bodies type as
AuthenticationError(#344). A one-element list with decorator
keys (Error Messageplus data fields), a nested error value
({"message": "Invalid API KEY"}), or a scalar
["Invalid API KEY"]now raises instead of falling through
validation. Mixed-key rows whose copy is not the junk-key body stay
on the validation path. Multi-row lists are still not walked. -
TestPyPI / Dev-Release / Release sdist version-assert no longer
SIGPIPEs.set -euo pipefailplustar -xOf … | awk … exitmade
GNU tar exit 2 after a successful build (awk closed the pipe on the
firstVersion:line). The sdist was fine; the job still went red,
so the 2.7.0 release PR never published a TestPyPI comment. PKG-INFO
is now read in full before the version line is parsed. -
MCP setup wizard no longer prints validator stderr or the key
(#315). Failed validation, the MCP smoke-test, and unexpected
crashes print static copy. Console output is pattern-redacted and
never interpolates the getpass-held key or exception text (CodeQL
py/clear-text-logging-sensitive-data). Persistence hints use
[YOUR_API_KEY], not a prefix/suffix of the secret. -
Release-PR review follow-ups (#304). Loopback HTTP now requires a
real loopback address (127.evil.exampleis rejected). Log extras
redact string leaves andAuthorizationmapping keys. MCP Python
manifests acceptTOOLS: list[str] = [...]. RedisSCAN MATCH
escapes glob metacharacters in the key prefix. The release remote tag
is created only after the sdist version check. Makefile.env
loading strips dotenv-style inline comments, and quoted values keep
escaped quotes / backslashes. URL redaction covers?api-key=.
LangChain tool validation envelopes reuse the already-redacted
message so a reflectedapikey=cannot reach
details.validation_errors. Claude Desktop troubleshooting
samples that users copy into a config file are valid JSON. -
HolderIndustryBreakdown.industry_titleacceptsnull. A live
13F industry-breakdown row sendsindustryTitle: null; the field was
requiredstrand the whole call raisedValidationError. -
CryptoNewsArticle.symbolacceptsnull. General crypto news
often has no pair; the requiredstrfield rejected those rows. -
FMPLogger.get_logger(__name__)no longer doublesfmp_data.(#238).
The root logger is alreadyfmp_data;getChild(__name__)was emitting
fmp_data.fmp_data.base, socaplogand log aggregators filtering
fmp_data.basemissed extras / HTTP warnings. Qualifiedfmp_data.*
names are now used as-is. If you already filtered the doubled name
(fmp_data.fmp_data.*), switch tofmp_data.*. Handler and filter
attachment is unchanged. -
Remaining logging entry points go through
FMPLogger(#241).
Productionlogging.getLogger(__name__)call sites (CSV extras, secure
log-file chmod, cache backends, rate limiter, investment async) now use
FMPLogger().get_logger(__name__). LangChain class-name loggers use
__name__+.getChild(class)soValidationRulelands on
fmp_data.lc.validation.ValidationRulerather thanfmp_data.ValidationRule.
Extras tests listen onfmp_data.baseviacaplog. Handler and filter
attachment is unchanged. -
CSV bulk parsing now honors
FMP_VALIDATION_MODE(#232).parse_csv_models
usedmodel.model_validatedirectly, so unknown bulk headers (overallScore
onrating-bulk, a misspelled diluted-PE column) landed in
__pydantic_extra__with no warn and no failure. They now share the JSON
extras policy (warn/strict/lenient), keyed per bulk endpoint +
field set. Invalid cells still retry URL fields, then skip the row in
lenient/warn(logged) or fail the request instrict. Default mode
is unchanged (warn).rating-bulkscores coerce only whole values
("3.0"→3);"3.5"is no longer truncated to3. -
CI: post-release main→dev sync auto-merges and no longer needs admin bypass.
Sync-Main-to-Dev enablesgh pr merge --auto --mergeon the history-reachability
PR so green Test-Matrix lands it without a human. Squash is never requested
(it would re-break ancestry). Protect Dev droppedrequired_signatures, which
had made every unsigned automation commit need--admindespite green checks;
required Test-Matrix jobs, no force-push, and no branch deletion remain. -
Async requests work again (#252 FMP-SEC-004).
httpx.AsyncClientawaits
every response hook (await hook(response)), whilehttpx.Clientcalls it
plainly. The cross-origin redirect guard added for FMP-SEC-004 was a plain
defregistered on both clients, so httpx awaitedNoneand every
successful async response raised
TypeError: object NoneType can't be used in 'await' expression— not only
redirects._areject_cross_origin_redirectnow wraps the check for the async
client. Unreleased regression: it never shipped in a tagged version. The async
tests replaced the client withAsyncMockand asserted only hook membership,
so nothing exercised the real send path; they now drive
httpx.MockTransportand assert the hook is a coroutine function. -
_unwrap_list_resultrefuses a bytes file (#253).request_listalready
raisedTypeErrorforEndpoint[bytes], but the shared unwrap helper still
treatedisinstance(payload, bytes)as a lone row and returned[bytes].
Quote-list unwrap is unchanged.
Security
-
LogHandlerConfig.handler_kwargsis redacted on the dump path too
(#252 FMP-SEC-007).__str__was swept in the metadata-driven
redaction change, butmodel_dump()/model_dump_json()still emitted
these verbatim — and they are a baredict[str, Any]handed straight to
a logging handler, so aSysLogHandlerpassword or an HTTP handler's
credentials live there.SecretStrcannot reach inside an untyped bag,
so afield_serializercloses it, the same approach used for
EmbeddingConfig.additional_kwargs. Safe here because the only consumer
reads the attribute (config.handler_kwargs.copy()inlogger.py),
so nothing rebuilds the model from its own dump. Operational kwargs such
asmaxBytesandbackupCountare still shown. -
Log redaction is type-complete and happens on the record (#252
FMP-SEC-005). Four gaps sharing one root cause — the filter gated on
isinstance(v, str | int | float)to mask anddict | listto recurse:extra={"api_key": <bytes|tuple|set>}was logged in the clear; only
str/int/floatwere masked. Every container type is now walked,
andbytesis decoded before masking rather than having itsrepr
masked (b'****…'was misleading about both value and length).- A secret reachable only through a tuple was never reached.
- Nested extras were redacted inside
JsonFormatter.formatrather than
on theLogRecord, so a second handler —logging.basicConfig(),
a Sentry or OTel exporter — received the raw value from the same
record. Redaction now happens once on the record, so every handler
inherits it. log_api_callput positional arguments intocall_argsas a raw
tuple. Positional arguments have no names, so nothing downstream can
tell a credential from a ticker symbol; it now logs their types.
call_kwargsis named, and is masked as before.
-
The LangChain tool error envelope redacts API keys (#252
FMP-SEC-005).fmp_data/lc/vector_store.pyreturnedstr(e)inside
the tool's result dict, which goes into the agent scratchpad, the chat
history and any tracing backend.str()of anhttpx.HTTPStatusError
stringifies the request URL, which carriesapikey=— the exact leak
base.pysuppresses on its own paths, while nothing underlc/was
redacting. Now redacted at the point of capture, so every branch of the
envelope inherits it. -
Publish workflows serialize (#252).
release.yml,
dev-release.ymlandpublish-testpypi.ymlhad noconcurrencygroup,
so two pushes to the same PR could both build and publish and the
slower run's comment could overwrite the newer one — advertising a
stale version, the exact invariant #204 exists to protect.
cancel-in-progressis deliberatelyfalse: making the second run
wait is strictly better than aborting one mid-upload. -
Ruff's bandit twins are no longer globally ignored (#252
FMP-SEC-008). #278 removedB603/B607from[tool.bandit] skips,
but their flake8-bandit equivalentsS603/S607stayed in
[tool.ruff.lint] ignore— so the narrowing looked done while the rule
was enforced by neither tool. The global ignore is now justS101;
the five real call sites carry a targeted# noqawith a reason
alongside the existing# nosec.scripts/was also exempt from both
ruff'sSrules and bandit'sexclude_dirs; both exemptions are gone
and both tools pass. A test pins the ruff ignore list next to the
existing bandit one, since splitting them is what let this drift. -
ClientConfig.__str__redaction is metadata-driven, not a name
allowlist (#252 FMP-SEC-007). It dumped the whole model and masked the
three field names it knew about, so everything else rendered verbatim —
including a secret insideLogHandlerConfig.handler_kwargs, a bare
dict[str, Any]that needs no subclass to reach, and any credential
field added by a subclass. Redaction now runs in three passes: fields
markedrepr=False(pydantic's own not-for-display signal, which every
credential field already carries, so a subclass gets correct behaviour
from the standard idiom); a recursive sweep for secret-shaped keys,
which is the only handle available on untyped bags; then the two values
worth rendering richer than***(the API key's leading characters and
the Redis host). The shape rules moved tofmp_data/_redaction.pyand
are shared withEmbeddingConfig.__repr__, which previously recursed
while the config side did not. Non-secret values such asmaxBytesare
still shown. -
Checkouts no longer leave a repo-write token in the workspace
(#252 FMP-SEC-002). Only 4 of 17actions/checkoutsteps set
persist-credentials: false, so the rest wrote the job's token into
.git/configwhere every later step could read it — dependency
installs, build backends, third-party actions. Ten more now drop it.
The two that keep it are documented in place:sync-main-to-dev.yml
really pushes (and persists a PAT, broader thanGITHUB_TOKEN), and
claude.ymlruns an action whose defaultuse_commit_signing: false
means it commits with standard git commands. A test asserts the
exception list by name so a third one has to be deliberate. -
CODECOV_TOKENis no longer present in a job that runs PR code
(#252).nox -s coverage_localexecutes the PR branch's own noxfile
and test suite; the upload token sat in that same job. Coverage now
uploadscoverage.xmlas an artifact and a separatecoverage-upload
job — no secrets in the first, no repository code in the second —
performs the upload. It is deliberately not a required check: the
coverage threshold is still enforced in thecoveragejob, and a
telemetry upload should not gate merges. -
Release notes use a random
$GITHUB_OUTPUTheredoc delimiter (#252).
Not a live hole:--pretty=format:"- %s"prefixes every commit line, so
a commit subject ofEOFemits- EOFand cannot close the heredoc
(verified both ways). But that protection is a side effect of a format
string — dropping the-prefix would make every following line a
workflow command. The delimiter is now random per run, so the guard is
deliberate. -
The release build job no longer carries a persisted git credential
(#252 FMP-SEC-002).release.yml's build job legitimately needs
contents: write— it pushes the release tag and creates the GitHub
Release — but it checked out withtoken: ${{ secrets.GITHUB_TOKEN }},
leaving repo-write credentials in.git/configfor the whole job,
including while it installs a PEP 517 frontend and backend and runs a
build. Exactly one step needed remote write; it now creates the tag
through the API with an explicitly scopedGH_TOKEN, so the checkout
usespersist-credentials: false. The tag is still created locally
first (hatch-vcs derives the version from the local tag, and the build
runs after) and the remote tag is created as an annotated tag object
rather than a bare ref, so it matches. -
get_company_logo_urlescapes the symbol (#252 FMP-SEC-010). That
builder assembles its URL with a raw f-string and never calls
Endpoint.build_url, so it did not inherit the path sanitizing added
when FMP-SEC-010 was closed — the sweep behind that fix only covered
build_urlcallers.symbolreaches this method from an LLM through
thecompany_logo_urlMCP tool, and
get_company_logo_url("../../stable/profile")returned a URL pointing
at a different path on the origin. Now percent-encoded and rejected
for separators and dot-segments, matching every other path parameter.
Dotted tickers such asBRK.Bstill work. -
The TestPyPI publish job no longer holds repo-write (#252 FMP-SEC-002).
test-release-publishcarriedpull-requests: writeand
issues: writealongsideid-token: writepurely so it could post
the "test release published" PR comment. Anything running in that job —
a compromised action, a malicious build dependency — could reach both
the TestPyPI OIDC token and the repository. The comment now runs in a
separatetest-release-commentjob with no OIDC token and no access
to the artifact; the publishing job is down toid-token: writeplus
contents: read. A new test walks every workflow and fails on any
job combiningid-token: writewith another write scope (the
actions/deploy-pagesjob is exempted narrowly, scoped to the
github-pagesenvironment, since that pairing is mandatory there). -
Authorization: Bearerno longer crashes the log filter, and
tracebacks are actually redacted (#252 FMP-SEC-005).
SensitiveDataFilterread capture group 3 for every pattern, but
authorizationdefines only two groups. Any log line containing
Authorization: Bearer <token>raisedIndexError: no such group
from insidelogging, so the Bearer rule never redacted and the
filter's own exception replaced the caller's — reachable through
FMPDataClient.__exit__, which logs withexc_info=True.
Separately, filters run before formatting, sorecord.exc_textwas
stillNonewhen the filter looked at it: theexc_textbranch
never fired on a liveexc_info=Truecall and tracebacks reached
handlers unredacted. The filter now renders and masks the traceback
itself, andJsonFormattermasks the exception message and
traceback it derives fromrecord.exc_info. -
Non-JSON and scalar error bodies redact the API key (#252 FMP-SEC-005).
handle_response'sjson.JSONDecodeErrorbranch built
FMPError.responsefrom the raw bytes with no redaction, while the
5xx sibling ran the same bytes through_redact_api_keys— so a 2xx
carrying a non-JSON body leaked the live key into the exception. This
is routine, not exotic: WAF and CDN block pages echo the full request
URL, and ours carriesapikey=. A bare JSON string body took the
same unredacted route through_parse_json_response. Both now
redact, and the body is decoded witherrors="replace"so a
non-UTF-8 payload cannot raiseUnicodeDecodeErrorfrom inside the
JSON error handler and mask the real failure. -
pip-audit --strictaudits a live extras export (#252 FMP-SEC-008).
nox -s securityrunsuv exportfor the published extras
(langchain, mcp, cache-redis) and audits that pin set. There is no
committed hashed lock — apyproject.tomlfloor bump is enough to
pick up newer deps. Nox installs thedevgroup from pyproject
instead of a second, stale pin list. -
Secret scan covers the committed tree, including tests (#252 FMP-SEC-008).
CI no longer skips when VCR cassettes are absent.detect-secrets
compares the whole tree to.secrets.baseline; dummy keys in tests
are baselined. Cassettes stay zero-tolerance when present. -
CodeQL analyzes the Python tree on every PR (#252 FMP-SEC-008).
Findings upload to GitHub code scanning. The workflow is SHA-pinned
and usesbuild-mode: none. -
Isolated PyPI publishing and immutable Actions pins (#252). Release,
Dev-Release, and Publish-to-TestPyPI now build in a job with no OIDC token
and publish from a second job that only downloads hashed artifacts. Publish
jobs use thepypi/testpypiGitHub environments. All external
uses:entries are pinned to full commit SHAs. The PEP 517 frontend
(build) and backend (hatchling,hatch-vcs) are installed from
version floors in.github/requirements-build.txt(not a hashed lock),
and builds run--no-isolationso isolation cannot fetch a different
backend than the one just installed.
workflow_dispatchon Dev-Release is bound torefs/heads/dev. The
TestPyPI PR path requireshead.repo.full_name == github.repository.
Tag-based TestPyPI publishes no longerskip-existing. Before the next
release, set the PyPI and TestPyPI Trusted Publisher environment names
topypiandtestpypito match the workflows. -
MCP manifests are data, not executed code (#252).
load_manifest_tools
andfmp-mcp validateno longerexecuser-supplied Python. Legacy
TOOLS = ["..."]files are parsed with a restricted AST (docstring + that
assignment only). Imports, calls, and any other statement are rejected.
JSON and YAML accept a top-level list or{"tools": ["..."]}. TOML
acceptstools = ["..."]only (no top-level array). On Python 3.10
TOML usestomlifrom themcpextra; 3.11+ uses stdlibtomllib.
Existing generated and example.pymanifests keep working. A file
that previously ran arbitrary code as "validation" now fails closed. -
TestPyPI tag guard cannot be shadowed by a tag (#252 FMP-SEC-003). The
ancestry check compared against the unqualified revorigin/main. Git
resolvesrefs/tags/<name>beforerefs/remotes/<name>, and
actions/checkoutwithfetch-depth: 0mirrors every tag verbatim, so a tag
literally namedorigin/mainshadowed the remote-tracking branch and the
guard compared HEAD against an attacker-chosen commit — passing with only a
"refname is ambiguous" warning, whichset -euo pipefaildoes not trap. Both
comparisons now userefs/remotes/origin/{main,dev}and the guard fetches
with--no-tagsand an explicit refspec. -
Bandit actually runs in CI (#273). #278 replaced the blanket
B404/
B603/B607/B608skips with narrow file-local# nosecnotes, but
bandit was only wired into.pre-commit-config.yamland there is no
pre-commit CI job — so the narrowing was unenforced and a newsubprocess
call could land unreviewed.nox -s securitynow runs bandit over the
library before the dependency audit. Current tree: 0 findings.
examples/mcp/claude_desktop/setup_claude_desktop.pyalso picked up the
same file-local notes: #278 dropped the global skips without annotating it,
which leftpre-commit run --all-filesfailing on the example. -
Embedding
additional_kwargsredaction is recursive (#273). The
top-level scan copied nested containers by reference, so a credential under a
real provider kwarg —default_headers={"Authorization": ...}or
model_kwargs={"api_key": ...}, both accepted byOpenAIEmbeddingsand
splatted straight through — was printed verbatim byrepr. Nested dicts,
lists, tuples and sets are now walked (depth-bounded); the live kwargs are
left unmutated. -
Embedding
additional_kwargsno longer leak secrets inrepr(#273).
Secret-shaped keys are replaced with***;api_keystays off the
default field repr. -
Makefile
.envloader drops process-hijack keys (#273).
PATH,LD_PRELOAD,PYTHONPATH,DYLD_*, and similar
identifiers are ignored so a trusted local file cannot rewrite the
maintainer shell. -
Bandit skips are limited to
assertused in the library (#273).
GlobalB404/B603/B607/B608skips are gone. MCP subprocess
calls keep file-localnosecnotes; there is no SQL to justifyB608. -
API key stays on origin (#252 FMP-SEC-004).
base_urlmust be HTTPS
except loopback HTTP. The key is sent only as theapikeyquery parameter
(no client-wide header that a 302 would forward). Cross-origin redirects
are refused by inspecting theLocationheader: httpx only sets
next_requestwhenfollow_redirectsis false, so a next-request-only
check was a no-op in production. An unparsableLocationis refused
rather than left for httpx to handle after the hook returns. -
Log redaction applies to child loggers and
api_key=%s(#252 FMP-SEC-005).
The filter formats the message before masking, so a%svalue is not
treated as the secret (which previously crashed logging and printed the
raw key). The filter is attached to each handler and each child logger.
JSON extras and exception text are redacted. -
MCP Claude config is written
0600and the API key is prompted with
echo off (#252 FMP-SEC-006). Directory0700; backups0600;
atomic replace. -
Secondary secrets no longer survive
str/repr(#252 FMP-SEC-007).
embedding_api_keyand embeddingapi_keyarerepr=False.
CacheConfig.redis_urluserinfo is redacted. Nested cache URLs on
ClientConfigare redacted the same way. -
VCR sanitization no longer logs the raw API key (#252 FMP-SEC-009).
DEBUG logs method/host/path only. Cassette leak assertions name
file/line/credential type, not the captured value. -
Company intraday
intervalis whitelisted and path params are encoded
(#252 FMP-SEC-010).../../stable/profileis rejected; remaining path
segments are percent-encoded so httpx cannot normalize them into another
endpoint. -
Claude workflows no longer mint an OIDC token (#252 FMP-SEC-012).
Review iscontents: readplus PR/issue write. Interactive Claude keeps
contents: writeso it can land commits; both use the OAuth secret, not
id-token: write. -
Added
SECURITY.md(#252 FMP-SEC-013). Private disclosure via GitHub
advisories; supported versions and target response times are documented. -
Tag-based TestPyPI builds require the tag to sit on
mainordev
(#252 FMP-SEC-003). A tag of an unrelated commit no longer publishes. -
Makefile loads
.envas data, not as shell (#252 FMP-SEC-011).
test/ integration targets callscripts/export_dotenv.pyinstead of
set -a; . ./.env.
📦 Installation
pip install fmp-data==2.7.0