Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/go-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ on:
- 'crates/libfmp/src/responses/**'
- 'crates/libfmp/src/endpoints/**'
- 'rust-toolchain.toml'
- '!crates/fmp-py/examples/**'
- '!sdk/go/examples/**'
- '!sdk/node/examples/**'
push:
branches: [main]
paths:
Expand All @@ -28,6 +31,9 @@ on:
- 'crates/libfmp/src/responses/**'
- 'crates/libfmp/src/endpoints/**'
- 'rust-toolchain.toml'
- '!crates/fmp-py/examples/**'
- '!sdk/go/examples/**'
- '!sdk/node/examples/**'

permissions:
contents: read
Expand Down
8 changes: 8 additions & 0 deletions crates/fmp-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script>.py` from that directory.

## Requirements

- CPython 3.10 or newer (`abi3-py310`: one wheel per platform covers every
Expand Down
1 change: 1 addition & 0 deletions crates/fmp-py/examples/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uv.lock
43 changes: 43 additions & 0 deletions crates/fmp-py/examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# fmp-py-sdk examples

Small, runnable scripts for the `fmp` Python client (`pip install fmp-py-sdk`).
Each one covers a single topic and is commented where the configuration is easy
to get wrong.

## Running

From this directory, with [uv](https://docs.astral.sh/uv/):

```console
export FMP_API_KEY=... # or inject it from your secret manager
uv run quickstart.py
```

- `uv run` creates a local `.venv` with the released `fmp-py-sdk` from PyPI
(Python 3.10 or newer); nothing in this repository is built.
- The client reads `FMP_API_KEY` from the environment. No script prints it,
and no script takes it as an argument.
- Files a script writes (the bulk CSV, the XLSX report) go to a fresh temporary
directory, whose path is printed.
- Scripts marked "offline" stage their scenarios against a loopback server in
`_local_server.py`, so they need neither a key nor network access.

## Scripts

| Script | Shows | Needs |
|--------|-------|-------|
| `quickstart.py` | `FmpClient()` from the environment, `quote.short`, an income statement with `period` and `limit`, a DCF valuation | key |
| `auth.py` | env key vs `token=`, every `auth_mode` value, a credential-free router with `auth_mode="none"`, why plain-HTTP auth needs `danger_allow_insecure_authentication` | key |
| `timeouts_and_limits.py` | `timeout` and `connect_timeout`; bulk defaults (600 s, 256 MiB) vs the rest (30 s, 64 MiB); an explicit value applies to bulk too | offline |
| `errors.py` | config, validation, transport, status (including a provider error inside a 200 body) and decode errors; `decode_path` and `decode_kind` | offline, one call needs network |
| `nullable_fields.py` | `Optional` members such as `CompanyProfile.isin` / `country`; congressional net-worth columns that may be `None` and the `additional_columns` dict | key |
| `lifecycle_and_threads.py` | `with FmpClient() as client:`, `close()` semantics, a `ThreadPoolExecutor` fan-out on one shared client | key |
| `bulk.py` | one small bulk endpoint, its row count, and a CSV written with the `csv` module | key with bulk access |
| `binary_and_dicts.py` | `statements.reports.xlsx` bytes saved to a file; `to_dict()` for JSON or a DataFrame hand-off | key |
| `pagination.py` | a `page` / `limit` loop that stops on a short page | key |

## Type checking

The scripts pass `mypy --strict` against the stubs shipped in the wheel; the
binding's test suite (`tests/test_stubs.py`) checks this whenever `mypy` is
installed.
53 changes: 53 additions & 0 deletions crates/fmp-py/examples/_local_server.py
Original file line number Diff line number Diff line change
@@ -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)
89 changes: 89 additions & 0 deletions crates/fmp-py/examples/auth.py
Original file line number Diff line number Diff line change
@@ -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()
56 changes: 56 additions & 0 deletions crates/fmp-py/examples/binary_and_dicts.py
Original file line number Diff line number Diff line change
@@ -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()
39 changes: 39 additions & 0 deletions crates/fmp-py/examples/bulk.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading