From 39c5073953cb58e45f84afee4bf5fb87617557da Mon Sep 17 00:00:00 2001 From: benbenbang Date: Sat, 3 Oct 2026 22:34:24 +0800 Subject: [PATCH 1/6] docs(fmp-py): add auth, errors and timeout examples Add runnable example scripts demonstrating common fmp-py usage patterns. * Added `examples/auth.py` showing authentication setup. * Added `examples/errors.py` illustrating error handling. * Added `examples/timeouts_and_limits.py` covering timeouts and response-size limits. * Added `examples/_local_server.py` as a supporting helper for the examples. --- crates/fmp-py/examples/_local_server.py | 53 +++++++++ crates/fmp-py/examples/auth.py | 89 +++++++++++++++ crates/fmp-py/examples/errors.py | 106 ++++++++++++++++++ crates/fmp-py/examples/timeouts_and_limits.py | 66 +++++++++++ 4 files changed, 314 insertions(+) create mode 100644 crates/fmp-py/examples/_local_server.py create mode 100644 crates/fmp-py/examples/auth.py create mode 100644 crates/fmp-py/examples/errors.py create mode 100644 crates/fmp-py/examples/timeouts_and_limits.py diff --git a/crates/fmp-py/examples/_local_server.py b/crates/fmp-py/examples/_local_server.py new file mode 100644 index 0000000..5588320 --- /dev/null +++ b/crates/fmp-py/examples/_local_server.py @@ -0,0 +1,53 @@ +"""A tiny loopback HTTP server the offline examples point ``FmpClient`` at. + +It answers every request with one canned response, so ``errors.py`` and +``timeouts_and_limits.py`` can show each failure mode without a key, without +network access, and without depending on what the real provider sends today. +""" + +# standard library +import threading +import time +from collections.abc import Iterator +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + + +@contextmanager +def serve( + body: bytes, *, status: int = 200, content_type: str = "application/json", delay: float = 0.0 +) -> Iterator[str]: + """Serves ``body`` for every GET until the block exits; yields the origin URL.""" + + class Handler(BaseHTTPRequestHandler): + """Answers every GET with the canned response.""" + + def do_GET(self) -> None: + """Waits ``delay`` seconds, then sends the canned response. + + A client that gave up (a timeout or a size limit) has already closed + the socket, so a failed write is expected and ignored. + """ + time.sleep(delay) + try: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def log_message(self, format: str, *args: Any) -> None: + """Silences the default stderr access log.""" + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/crates/fmp-py/examples/auth.py b/crates/fmp-py/examples/auth.py new file mode 100644 index 0000000..bbf2c03 --- /dev/null +++ b/crates/fmp-py/examples/auth.py @@ -0,0 +1,89 @@ +"""Authentication: where the credential comes from and where it goes on the wire. + +Precedence: an explicit ``token=`` always wins, otherwise ``FMP_API_KEY`` is read +(unset, empty, or whitespace-only counts as absent), and ``auth_mode="none"`` +ignores the variable. Run with ``uv run auth.py``; the live calls need +``FMP_API_KEY``. Every other client here uses a dummy token and sends nothing. +""" + +# standard library +import os + +# fmp library +from fmp import FmpClient, FmpConfigError + +# local +from _local_server import serve # isort: skip + +QUOTE_BODY = b'[{"symbol": "AAPL", "price": 1.0, "change": 0.0, "volume": 1.0}]' + + +def live_modes() -> None: + """Calls FMP with the key in the header (the default) and in the query string.""" + from_env = FmpClient() + explicit = FmpClient(token=os.environ["FMP_API_KEY"]) + # fmp_query appends ?apikey=... to the URL; error bodies redact it as [REDACTED]. + in_query = FmpClient(auth_mode="fmp_query") + for name, client in [("env", from_env), ("token=", explicit), ("fmp_query", in_query)]: + print(f"{name:>10}: {client.quote.short('AAPL')[0].price}") + + +def every_mode() -> None: + """Builds one client per ``auth_mode`` value against a proxy; nothing is sent.""" + proxy = "https://proxy.example" + dummy = "dummy-token" + FmpClient(base_url=proxy, auth_mode="none") + FmpClient(base_url=proxy, auth_mode="fmp_header", token=dummy) + FmpClient(base_url=proxy, auth_mode="fmp_query", token=dummy) + FmpClient(base_url=proxy, auth_mode="bearer", token=dummy) + # The custom modes need auth_name: the header or query parameter to fill. + FmpClient(base_url=proxy, auth_mode="custom_header", auth_name="X-Proxy-Token", auth_prefix="Bearer ", token=dummy) + FmpClient(base_url=proxy, auth_mode="custom_query", auth_name="key", token=dummy) + print("built one client per auth_mode") + + +def no_auth_router() -> None: + """Talks to a credential-free router (a loopback server here) with no auth at all.""" + with serve(QUOTE_BODY) as base_url: + # auth_mode="none" matters: without it an exported FMP_API_KEY would be sent + # to this router. path_prefix="" drops the default "stable/" path segment. + client = FmpClient(base_url=base_url, path_prefix="", auth_mode="none") + print(f"router quote: {client.quote.short('AAPL')[0].price}") + + +def insecure_http() -> None: + """Shows why plain-HTTP authentication needs an explicit, conspicuous opt-in.""" + try: + FmpClient(base_url="http://proxy.example", token="dummy-token") + except FmpConfigError as error: + print(f"refused: {error}") + # Anyone on the path can read a credential sent over plain HTTP. Opt in only for a + # trusted network hop, such as a sidecar on a private interface. Loopback HTTP + # (127.0.0.1, localhost) never needs the flag. + FmpClient(base_url="http://proxy.example", token="dummy-token", danger_allow_insecure_authentication=True) + print("built with danger_allow_insecure_authentication=True") + + +def missing_key() -> None: + """Without a token or FMP_API_KEY, the default host refuses to build a client.""" + saved = os.environ.pop("FMP_API_KEY", None) + try: + FmpClient() + except FmpConfigError as error: + print(f"missing key: {error}") + finally: + if saved is not None: + os.environ["FMP_API_KEY"] = saved + + +def main() -> None: + """Runs every auth scenario.""" + live_modes() + every_mode() + no_auth_router() + insecure_http() + missing_key() + + +if __name__ == "__main__": + main() diff --git a/crates/fmp-py/examples/errors.py b/crates/fmp-py/examples/errors.py new file mode 100644 index 0000000..ff08bf5 --- /dev/null +++ b/crates/fmp-py/examples/errors.py @@ -0,0 +1,106 @@ +"""Errors: one exception class per failure stage, each with structured attributes. + +Every class derives from ``fmp.FmpError`` and carries ``category``, ``endpoint``, +``status``, ``body``, ``body_truncated``, ``decode_path`` and ``decode_kind``. +The failures are staged against a loopback server so each one is reproducible; +run with ``uv run errors.py`` (only the bad-key case touches the network). +""" + +# fmp library +from fmp import ( + FmpClient, + FmpConfigError, + FmpDecodeError, + FmpError, + FmpStatusError, + FmpTransportError, + FmpValidationError, +) + +# local +from _local_server import serve # isort: skip + + +def local_client(base_url: str) -> FmpClient: + """Builds a credential-free client for the loopback server.""" + return FmpClient(base_url=base_url, path_prefix="", auth_mode="none", timeout=5.0) + + +def show(error: FmpError) -> None: + """Prints the structured attributes every fmp error carries.""" + print(f" {type(error).__name__}: {error}") + print(f" category={error.category} endpoint={error.endpoint} status={error.status}") + if error.body is not None: + print(f" body={error.body!r} truncated={error.body_truncated}") + if error.decode_path is not None: + print(f" decode_path={error.decode_path} decode_kind={error.decode_kind}") + + +def main() -> None: + """Triggers each failure stage in order.""" + print("config: the client cannot be built") + try: + FmpClient(base_url="https://proxy.example", timeout=0.0) + except FmpConfigError as error: + show(error) + + print("validation: an argument is rejected locally, nothing is sent") + try: + FmpClient(base_url="https://proxy.example", auth_mode="none").quote.short("") + except FmpValidationError as error: + show(error) + + print("transport: no response at all (nothing listens on port 1)") + try: + FmpClient(base_url="http://127.0.0.1:1", path_prefix="", auth_mode="none", connect_timeout=1.0).quote.short( + "AAPL" + ) + except FmpTransportError as error: + show(error) + + print("status: a non-success HTTP status") + with serve(b'{"Error Message": "Invalid API KEY."}', status=401) as base_url: + try: + local_client(base_url).quote.short("AAPL") + except FmpStatusError as error: + show(error) + + print("status: the real provider rejecting a bad key (needs network)") + try: + FmpClient(token="not-a-real-key").quote.short("AAPL") + except FmpStatusError as error: + show(error) + + # FMP sometimes answers 200 OK with its own error message instead of data. The + # client recognizes that shape and raises FmpStatusError, not a decode error. + print("status: a provider error message inside a 200 body") + with serve(b'{"Error Message": "Limit Reach."}') as base_url: + try: + local_client(base_url).quote.short("AAPL") + except FmpStatusError as error: + show(error) + + # decode_path names the failing member ([1].price: second row, price field) and + # decode_kind the reason. Neither carries the offending value, so both are safe + # to log. body is a size-bounded excerpt of the raw response (an echoed apikey is + # redacted), so it can contain response values. + print("decode: the body does not match the documented shape") + body = ( + b'[{"symbol": "AAPL", "price": 1.0, "change": 0.0, "volume": 1.0},' + b' {"symbol": "MSFT", "price": "n/a", "change": 0.0, "volume": 1.0}]' + ) + with serve(body) as base_url: + try: + local_client(base_url).quote.short("AAPL") + except FmpDecodeError as error: + show(error) + + print("catch-all: FmpError is the base of every class above") + try: + FmpClient(base_url="https://proxy.example", auth_mode="none").quote.short("") + except FmpError as error: + print(f" caught {type(error).__name__} via FmpError") + + +if __name__ == "__main__": + main() diff --git a/crates/fmp-py/examples/timeouts_and_limits.py b/crates/fmp-py/examples/timeouts_and_limits.py new file mode 100644 index 0000000..47461a3 --- /dev/null +++ b/crates/fmp-py/examples/timeouts_and_limits.py @@ -0,0 +1,66 @@ +"""Timeouts and response-size limits, including the separate bulk defaults. + +Defaults when the argument is omitted: + +| Setting | ``client.bulk`` methods | every other method | +| --------------------------- | ----------------------- | ------------------ | +| ``timeout`` | 600 s | 30 s | +| ``max_response_body_bytes`` | 256 MiB | 64 MiB | + +An explicit value replaces BOTH defaults: ``timeout=10`` also caps bulk downloads +at 10 s, which is far too short for a multi-megabyte bulk body. Prefer separate +clients for bulk and for regular calls when you tune these. A timeout and an +oversized body both raise ``FmpTransportError``. Staged against a +loopback server; run with ``uv run timeouts_and_limits.py`` (no key needed). +""" + +# fmp library +from fmp import FmpClient, FmpError + +# local +from _local_server import serve # isort: skip + +QUOTE_BODY = b'[{"symbol": "AAPL", "price": 1.0, "change": 0.0, "volume": 1.0}]' +CSV_BODY = b"symbol\n" + + +def attempt(label: str, client: FmpClient, *, bulk: bool = False) -> None: + """Makes one call and reports whether it succeeded or which error it raised.""" + try: + rows = client.bulk.company_profiles("0") if bulk else client.quote.short("AAPL") + print(f"{label}: ok, {len(rows)} rows") + except FmpError as error: + print(f"{label}: {type(error).__name__}: {error}") + + +def main() -> None: + """Shows each timeout and size-limit rule against a slow or large response.""" + # timeout bounds the whole request; connect_timeout only the TCP/TLS connect. + # Both are positive finite seconds; 0, negative, NaN or inf raise FmpConfigError. + FmpClient(timeout=10.0, connect_timeout=2.0) + + with serve(QUOTE_BODY, delay=1.0) as base_url: + slow = FmpClient(base_url=base_url, path_prefix="", auth_mode="none", timeout=0.3) + attempt("regular call, timeout=0.3, server takes 1 s", slow) + + with serve(CSV_BODY, content_type="text/csv", delay=1.0) as base_url: + default = FmpClient(base_url=base_url, path_prefix="", auth_mode="none") + attempt("bulk call, default timeout (600 s)", default, bulk=True) + tuned = FmpClient(base_url=base_url, path_prefix="", auth_mode="none", timeout=0.3) + attempt("bulk call, timeout=0.3 also applies to bulk", tuned, bulk=True) + + large = b"[" + b", ".join([QUOTE_BODY[1:-1]] * 64) + b"]" + with serve(large) as base_url: + default = FmpClient(base_url=base_url, path_prefix="", auth_mode="none") + attempt(f"{len(large)}-byte body, default limit (64 MiB)", default) + capped = FmpClient(base_url=base_url, path_prefix="", auth_mode="none", max_response_body_bytes=1024) + attempt(f"{len(large)}-byte body, max_response_body_bytes=1024", capped) + + big_csv = CSV_BODY * 1024 + with serve(big_csv, content_type="text/csv") as base_url: + capped = FmpClient(base_url=base_url, path_prefix="", auth_mode="none", max_response_body_bytes=1024) + attempt(f"{len(big_csv)}-byte bulk body, max_response_body_bytes=1024 applies to bulk too", capped, bulk=True) + + +if __name__ == "__main__": + main() From 0987aeffbc6d985389d68564ed9a003f1a977dad Mon Sep 17 00:00:00 2001 From: benbenbang Date: Sat, 3 Oct 2026 22:34:41 +0800 Subject: [PATCH 2/6] docs(fmp-py): add quickstart, bulk, threading and pagination examples Add runnable example scripts demonstrating the main client workflows: * quickstart: minimal setup and first requests to get started quickly. * nullable_fields: handling optional and null-valued response fields. * lifecycle_and_threads: client lifecycle management and thread fan-out for concurrent requests. * bulk: fetching bulk data as CSV. * binary_and_dicts: working with XLSX binary payloads and converting responses via to_dict. * pagination: iterating through paginated endpoints. --- crates/fmp-py/examples/binary_and_dicts.py | 56 ++++++++++++++++++ crates/fmp-py/examples/bulk.py | 39 ++++++++++++ .../fmp-py/examples/lifecycle_and_threads.py | 59 +++++++++++++++++++ crates/fmp-py/examples/nullable_fields.py | 47 +++++++++++++++ crates/fmp-py/examples/pagination.py | 43 ++++++++++++++ crates/fmp-py/examples/quickstart.py | 29 +++++++++ 6 files changed, 273 insertions(+) create mode 100644 crates/fmp-py/examples/binary_and_dicts.py create mode 100644 crates/fmp-py/examples/bulk.py create mode 100644 crates/fmp-py/examples/lifecycle_and_threads.py create mode 100644 crates/fmp-py/examples/nullable_fields.py create mode 100644 crates/fmp-py/examples/pagination.py create mode 100644 crates/fmp-py/examples/quickstart.py diff --git a/crates/fmp-py/examples/binary_and_dicts.py b/crates/fmp-py/examples/binary_and_dicts.py new file mode 100644 index 0000000..ffa94b5 --- /dev/null +++ b/crates/fmp-py/examples/binary_and_dicts.py @@ -0,0 +1,56 @@ +"""Binary bodies and plain dicts: save an XLSX report, hand rows to other tools. + +``statements.reports.xlsx`` returns one ``fmp.BinaryPayload`` (not a list): raw +``bytes`` plus the validated ``content_type``. Every typed row converts to a +plain ``dict`` with ``to_dict()``. Run with ``uv run binary_and_dicts.py``; +needs ``FMP_API_KEY``. The workbook goes to a temp dir. +""" + +# standard library +import json +import tempfile +from pathlib import Path + +# fmp library +from fmp import FmpClient, FmpDecodeError + + +def save_xlsx(client: FmpClient) -> None: + """Downloads one annual report workbook and writes the bytes to disk.""" + try: + payload = client.statements.reports.xlsx("AAPL", 2024, "FY") + except FmpDecodeError as error: + # A body whose Content-Type is not an XLSX (or generic binary) type is + # rejected as a decode error. For a binary endpoint error.body is raw bytes + # rendered as text, so log the structured attributes, not the body. + print(f"xlsx rejected: category={error.category} status={error.status} endpoint={error.endpoint}") + return + print(f"{payload!r}: content_type={payload.content_type} bytes={payload.byte_len}") + # content_disposition is the provider's filename hint, when it sent one. It is + # untrusted text: never use it as a path without sanitizing it. + print(f"content_disposition={payload.content_disposition}") + path = Path(tempfile.mkdtemp(prefix="fmp-xlsx-")) / "AAPL-2024-FY.xlsx" + path.write_bytes(payload.data) + print(f"wrote {path}") + + +def rows_as_dicts(client: FmpClient) -> None: + """Turns typed rows into dicts for JSON, a DataFrame, or any other consumer.""" + rows = client.statements.income.statement("AAPL", period="quarter", limit=4) + records = [row.to_dict() for row in rows] + print(f"{len(records)} records, {len(records[0])} columns, e.g. {list(records[0])[:5]}") + # Dates stay datetime.date objects; default=str renders them for JSON. + print(json.dumps(records[0], default=str)[:120] + "...") + # pandas is not a dependency of these examples; with it installed: + # frame = pandas.DataFrame.from_records(records, index="date") + + +def main() -> None: + """Runs the binary download and the dict hand-off.""" + with FmpClient() as client: + save_xlsx(client) + rows_as_dicts(client) + + +if __name__ == "__main__": + main() diff --git a/crates/fmp-py/examples/bulk.py b/crates/fmp-py/examples/bulk.py new file mode 100644 index 0000000..b68ac7a --- /dev/null +++ b/crates/fmp-py/examples/bulk.py @@ -0,0 +1,39 @@ +"""Bulk download: one call returns every row, then write it out with the csv module. + +Bulk endpoints answer with a whole market in one body (often tens of MiB), so they +get longer defaults: 600 s and 256 MiB (see ``timeouts_and_limits.py``). This uses +``price_target_summaries``, one of the smallest. Run with ``uv run bulk.py``; +needs ``FMP_API_KEY`` and a plan with bulk access. The CSV goes to a temp dir. +""" + +# standard library +import csv +import tempfile +from pathlib import Path + +# fmp library +from fmp import FmpClient + + +def main() -> None: + """Downloads the bulk snapshot and writes it as CSV.""" + # No timeout= here on purpose: an explicit value would also replace the 600 s + # bulk default, and a large bulk body can take minutes on a slow link. + with FmpClient() as client: + rows = client.bulk.price_target_summaries() + print(f"{len(rows)} rows") + if not rows: + return + + # to_dict() keys are the snake_case attribute names, in constructor order. + records = [row.to_dict() for row in rows] + path = Path(tempfile.mkdtemp(prefix="fmp-bulk-")) / "price_target_summaries.csv" + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(records[0])) + writer.writeheader() + writer.writerows(records) + print(f"wrote {path} ({path.stat().st_size:,} bytes, columns: {', '.join(records[0])})") + + +if __name__ == "__main__": + main() diff --git a/crates/fmp-py/examples/lifecycle_and_threads.py b/crates/fmp-py/examples/lifecycle_and_threads.py new file mode 100644 index 0000000..3e4fd38 --- /dev/null +++ b/crates/fmp-py/examples/lifecycle_and_threads.py @@ -0,0 +1,59 @@ +"""Client lifecycle and thread fan-out. + +``FmpClient`` is synchronous, but every call releases the GIL while the Rust +transport waits, so one shared client can serve a thread pool and the requests +overlap. Run with ``uv run lifecycle_and_threads.py``; needs ``FMP_API_KEY``. +""" + +# standard library +import time +from concurrent.futures import ThreadPoolExecutor + +# fmp library +from fmp import FmpClient, FmpConfigError + +SYMBOLS = ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META"] + + +def fan_out(client: FmpClient) -> None: + """Fetches one profile per symbol, sequentially and then on a thread pool.""" + started = time.perf_counter() + for symbol in SYMBOLS: + client.company.profile(symbol) + sequential = time.perf_counter() - started + + started = time.perf_counter() + # Share one client across threads: it pools connections internally. Keep + # max_workers modest so a burst stays inside your FMP plan's rate limit. + with ThreadPoolExecutor(max_workers=4) as pool: + profiles = list(pool.map(lambda symbol: client.company.profile(symbol)[0], SYMBOLS)) + threaded = time.perf_counter() - started + + for profile in profiles: + print(f" {profile.symbol}: {profile.company_name}") + print(f" sequential {sequential:.2f}s, 4 threads {threaded:.2f}s") + + +def close_semantics() -> None: + """Shows that a closed client, and every namespace taken from it, rejects calls.""" + client = FmpClient() + quote = client.quote + client.close() + client.close() + try: + quote.short("AAPL") + except FmpConfigError as error: + print(f" after close(), even via a saved namespace: {error}") + + +def main() -> None: + """Uses the client as a context manager, then demonstrates close().""" + # The with block closes the client on exit, releasing pooled connections; it + # never swallows an exception raised inside the block. + with FmpClient() as client: + fan_out(client) + close_semantics() + + +if __name__ == "__main__": + main() diff --git a/crates/fmp-py/examples/nullable_fields.py b/crates/fmp-py/examples/nullable_fields.py new file mode 100644 index 0000000..a891a31 --- /dev/null +++ b/crates/fmp-py/examples/nullable_fields.py @@ -0,0 +1,47 @@ +"""Nullable fields: members the provider may omit or send as null are ``Optional``. + +Since 1.2.0 a member that FMP really leaves out for some rows (for example +``CompanyProfile.isin`` or ``country``) is typed ``X | None`` instead of failing +the whole decode, so guard it before use. Open-ended extra members land in a +plain ``dict``. Run with ``uv run nullable_fields.py``; needs ``FMP_API_KEY``. +""" + +# fmp library +from fmp import FmpClient + + +def company_profile(client: FmpClient) -> None: + """Reads optional profile members with an explicit ``None`` check.""" + for symbol in ["AAPL", "SPY"]: + profile = client.company.profile(symbol)[0] + isin = profile.isin if profile.isin is not None else "n/a" + country = profile.country or "unknown" + employees = profile.full_time_employees + print(f"{profile.symbol}: isin={isin} country={country} employees={employees} ipo={profile.ipo_date}") + + +def congressional_aggregate(client: FmpClient) -> None: + """Sums optional net-worth columns, then lists the open-ended extra columns.""" + member = client.congressional.profiles(active=True, limit=1)[0] + print(f"member {member.first_name} {member.last_name} ({member.latest_position})") + for row in client.congressional.net_worth_aggregated(member.member_id)[:3]: + # cash_and_cash_equivalents and mutual_funds_and_etfs are Optional since 1.3.0: + # a None means "not disclosed", not zero, so do not coerce it silently. + liquid = [value for value in (row.cash_and_cash_equivalents, row.mutual_funds_and_etfs) if value is not None] + cash = row.cash_and_cash_equivalents + print(f" {row.year}: total={row.total:,.0f} cash={cash} liquid_known={sum(liquid):,.0f}") + # additional_columns keeps any member the model does not name, keyed by + # the raw provider name; it is always a dict (possibly empty). + if row.additional_columns: + print(f" extra columns: {sorted(row.additional_columns)}") + + +def main() -> None: + """Runs both nullable-field walkthroughs.""" + client = FmpClient() + company_profile(client) + congressional_aggregate(client) + + +if __name__ == "__main__": + main() diff --git a/crates/fmp-py/examples/pagination.py b/crates/fmp-py/examples/pagination.py new file mode 100644 index 0000000..f8147cc --- /dev/null +++ b/crates/fmp-py/examples/pagination.py @@ -0,0 +1,43 @@ +"""Pagination: walk a paged endpoint with the keyword-only ``page`` and ``limit``. + +Paged endpoints take ``page`` (0-based) and ``limit`` (rows per page) as +keyword-only arguments. Stop when a page comes back shorter than ``limit``, and +cap the walk so a large history cannot run away with your rate limit. Run with +``uv run pagination.py``; needs ``FMP_API_KEY``. +""" + +# standard library +from collections import Counter +from collections.abc import Iterator + +# fmp library +from fmp import FmpClient +from fmp.congressional import CongressionalTrade + +PAGE_SIZE = 25 +MAX_PAGES = 3 + + +def senate_disclosures(client: FmpClient) -> Iterator[CongressionalTrade]: + """Yields the latest Senate disclosures, one page at a time.""" + for page in range(MAX_PAGES): + rows = client.congressional.latest_senate_disclosures(page=page, limit=PAGE_SIZE) + print(f"page {page}: {len(rows)} rows") + yield from rows + if len(rows) < PAGE_SIZE: + return + + +def main() -> None: + """Collects a few pages and summarizes them.""" + with FmpClient() as client: + trades = list(senate_disclosures(client)) + if not trades: + return + print(f"{len(trades)} trades, newest disclosure {max(trade.disclosure_date for trade in trades)}") + for kind, count in Counter(trade.transaction_type for trade in trades).most_common(): + print(f" {kind}: {count}") + + +if __name__ == "__main__": + main() diff --git a/crates/fmp-py/examples/quickstart.py b/crates/fmp-py/examples/quickstart.py new file mode 100644 index 0000000..2ba1521 --- /dev/null +++ b/crates/fmp-py/examples/quickstart.py @@ -0,0 +1,29 @@ +"""Quickstart: a quote, an income statement, and a DCF valuation for one symbol. + +Run with ``uv run quickstart.py``; ``FMP_API_KEY`` must be set in the environment. +""" + +# fmp library +from fmp import FmpClient + +SYMBOL = "AAPL" + + +def main() -> None: + """Fetches and prints a few typed rows.""" + # No token= argument: the client reads FMP_API_KEY from the environment. + client = FmpClient() + + quote = client.quote.short(SYMBOL)[0] + print(f"{quote.symbol}: price={quote.price} change={quote.change} volume={quote.volume}") + + # Optional arguments are keyword-only; period is a typing.Literal in the stubs. + for row in client.statements.income.statement(SYMBOL, period="annual", limit=3): + print(f"{row.fiscal_year} {row.period}: revenue={row.revenue:,.0f} net_income={row.net_income:,.0f}") + + valuation = client.dcf.standard(SYMBOL)[0] + print(f"DCF {valuation.date}: dcf={valuation.dcf:.2f} vs stock_price={valuation.stock_price:.2f}") + + +if __name__ == "__main__": + main() From dbebe3fbe8ad2ce921b24dfa630964a3c0997b2c Mon Sep 17 00:00:00 2001 From: benbenbang Date: Sat, 3 Oct 2026 22:34:49 +0800 Subject: [PATCH 3/6] docs(fmp-py): document and link the examples folder Add an Examples section to the README pointing to the new examples directory, and include a README and pyproject.toml for the examples so the runnable scripts cover auth modes, timeouts and bulk limits, error handling, threads, bulk CSV, binary downloads, and pagination. --- crates/fmp-py/README.md | 8 +++++ crates/fmp-py/examples/README.md | 43 +++++++++++++++++++++++++++ crates/fmp-py/examples/pyproject.toml | 7 +++++ 3 files changed, 58 insertions(+) create mode 100644 crates/fmp-py/examples/README.md create mode 100644 crates/fmp-py/examples/pyproject.toml diff --git a/crates/fmp-py/README.md b/crates/fmp-py/README.md index 958eae6..b82e946 100644 --- a/crates/fmp-py/README.md +++ b/crates/fmp-py/README.md @@ -29,6 +29,14 @@ naming `FMP_API_KEY`, while a custom `base_url` selects no auth. Rust transport waits, so threads keep running. A Python async facade is not part of this release. +## Examples + +Runnable scripts for common tasks and the trickier configuration (auth modes, +timeouts and bulk limits, error handling, threads, bulk CSV, binary downloads, +pagination) live in +[`crates/fmp-py/examples`](https://github.com/bitbrew-dev/libfmp/blob/main/crates/fmp-py/examples/README.md). +Run any of them with `uv run