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
37 changes: 36 additions & 1 deletion crates/fmp-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ print(rows[0].symbol, rows[0].price)
omitted (unset, empty, or whitespace-only counts as absent); an explicit
`token=...` always wins, and `auth_mode="none"` ignores the variable. With
neither a token nor the variable, the default host raises `FmpConfigError`
naming `FMP_API_KEY`, while a custom `base_url` selects no auth.
naming `FMP_API_KEY`, while a custom `base_url` selects no auth. When
`base_url` is omitted, `FMP_BASE_URL` is read the same way (see
[Using a proxy](#using-a-proxy)).

`FmpClient` is synchronous: each call releases the Python GIL while the async
Rust transport waits, so threads keep running. A Python async facade is not
Expand Down Expand Up @@ -193,6 +195,39 @@ A client that is never closed keeps its pooled connections until the next
request made through any client after it and its namespaces are
garbage-collected, or until the process exits.

## Using a proxy

A proxy that issues its own keys and forwards to FMP, such as
[valet](https://valet.bitbrew.app), needs two environment variables and no
code change:

```sh
export FMP_API_KEY=vk_...
export FMP_BASE_URL=https://valet.bitbrew.app/fmp
```

```python
from fmp import FmpClient

client = FmpClient()
rows = client.quote.short("AAPL")
```

The key goes in the `apikey` header and the base URL keeps its path, so the
quote endpoint resolves to `https://valet.bitbrew.app/fmp/stable/quote`.
The rules:

| Setting | Result |
| --- | --- |
| `base_url=...` given | used as is; `FMP_BASE_URL` is ignored |
| `base_url` omitted, `FMP_BASE_URL` set | the variable (trimmed) is the base URL |
| neither (or a blank variable) | `https://financialmodelingprep.com` |
| `auth_mode="none"` | no credential is sent, whichever base URL applies |
| plaintext `http://` to a non-loopback host with a key | `FmpConfigError` unless `danger_allow_insecure_authentication=True` |

`FMP_BASE_URL` only replaces the base URL; the `token` and `FMP_API_KEY`
rules above are unchanged.

## Custom router or proxy

```python
Expand Down
8 changes: 8 additions & 0 deletions crates/fmp-py/python/fmp/_native/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ class FmpClient:
both selects no auth, which is valid only with a custom base URL: against
the default host the constructor raises `FmpConfigError` naming
`FMP_API_KEY`.
When `base_url` is omitted, the `FMP_BASE_URL` environment variable is
read the same way, so a proxy such as valet needs only `FMP_API_KEY=vk_...`
and `FMP_BASE_URL=https://valet.bitbrew.app/fmp`: the key goes in the
`apikey` header and the quote endpoint resolves to
`https://valet.bitbrew.app/fmp/stable/quote`. An explicit `base_url`
always wins, and without either the client targets
`https://financialmodelingprep.com`. The variable only replaces the base
URL; it never selects or relaxes authentication.
`timeout` and `connect_timeout` are positive finite numbers of seconds.
When `timeout` is omitted, `client.bulk` methods may take up to 600
seconds and every other method up to 30; a given `timeout` applies to
Expand Down
18 changes: 16 additions & 2 deletions crates/fmp-py/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{sync::Arc, time::Duration};

use libfmp::{
ClientBuilder,
config::{Authentication, RedirectPolicy, fmp_api_key_from_env},
config::{Authentication, RedirectPolicy, fmp_api_key_from_env, fmp_base_url_from_env},
error::ConfigurationErrorKind,
};
use pyo3::{
Expand Down Expand Up @@ -39,6 +39,12 @@ fn resolve_token(mode: Option<&str>, token: Option<String>) -> Option<String> {
}
}

/// Resolves the base URL: an explicit `base_url` always wins, otherwise
/// `FMP_BASE_URL` is used when set, and `None` keeps the default FMP origin.
fn resolve_base_url(base_url: Option<String>) -> Option<String> {
base_url.or_else(fmp_base_url_from_env)
}

/// Names `FMP_API_KEY` when the default host was left without a credential
/// and the caller did not select `auth_mode="none"` explicitly.
fn build_error(auth_mode: Option<&str>, error: libfmp::Error) -> PyErr {
Expand Down Expand Up @@ -170,6 +176,14 @@ fn positive_duration(value: f64, field: &'static str) -> PyResult<Duration> {
/// both selects no auth, which is valid only with a custom base URL: against
/// the default host the constructor raises `FmpConfigError` naming
/// `FMP_API_KEY`.
/// When `base_url` is omitted, the `FMP_BASE_URL` environment variable is
/// read the same way, so a proxy such as valet needs only `FMP_API_KEY=vk_...`
/// and `FMP_BASE_URL=https://valet.bitbrew.app/fmp`: the key goes in the
/// `apikey` header and the quote endpoint resolves to
/// `https://valet.bitbrew.app/fmp/stable/quote`. An explicit `base_url`
/// always wins, and without either the client targets
/// `https://financialmodelingprep.com`. The variable only replaces the base
/// URL; it never selects or relaxes authentication.
/// `timeout` and `connect_timeout` are positive finite numbers of seconds.
/// When `timeout` is omitted, `client.bulk` methods may take up to 600
/// seconds and every other method up to 30; a given `timeout` applies to
Expand Down Expand Up @@ -216,7 +230,7 @@ impl FmpClient {
let auth = authentication(auth_mode, token, auth_name, auth_prefix)?;
let mut builder = ClientBuilder::default().authentication(auth);

if let Some(base_url) = base_url {
if let Some(base_url) = resolve_base_url(base_url) {
builder = builder.base_url(base_url);
}
if let Some(path_prefix) = path_prefix {
Expand Down
10 changes: 6 additions & 4 deletions crates/fmp-py/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ def test_profile_decodes_documented_fixture(client, fixture_server):
server with ``path_prefix=""`` and ``auth_mode="none"``; build your own
``FmpClient(base_url=fixture_server.base_url, ...)`` for other configurations.

``FMP_API_KEY`` is removed from the environment for every test (the autouse
``isolated_api_key_env`` fixture), so a key exported in the developer's shell
cannot change which precedence row a constructor call lands on. Tests that
``FMP_API_KEY`` and ``FMP_BASE_URL`` are removed from the environment for
every test (the autouse ``isolated_api_key_env`` fixture), so values exported
in the developer's shell cannot change which precedence row a constructor call
lands on. Tests that
exercise the pickup call ``monkeypatch.setenv`` themselves.
"""

Expand Down Expand Up @@ -217,8 +218,9 @@ def log_message(self, _format: str, *_args: Any) -> None:

@pytest.fixture(autouse=True)
def isolated_api_key_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Unset ``FMP_API_KEY`` so the developer's shell cannot leak into the suite."""
"""Unset ``FMP_API_KEY`` and ``FMP_BASE_URL`` so the developer's shell cannot leak into the suite."""
monkeypatch.delenv("FMP_API_KEY", raising=False)
monkeypatch.delenv("FMP_BASE_URL", raising=False)


@pytest.fixture
Expand Down
93 changes: 93 additions & 0 deletions crates/fmp-py/tests/test_base_url_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""``FMP_BASE_URL`` pickup when ``FmpClient`` is built without ``base_url``.

Precedence under test: an explicit ``base_url`` always wins; otherwise the
variable replaces the default host and keeps its path, so a proxy such as
valet needs only ``FMP_API_KEY`` and ``FMP_BASE_URL``. The variable never
changes authentication: the key rules of ``test_auth_env.py`` and the
plaintext-HTTP guard still apply.
"""

from types import SimpleNamespace

import pytest
from conftest import FixtureServer, load_fixture
from fmp import FmpClient

MISSING_TOKEN_MESSAGE = "no token given and FMP_API_KEY is not set"
INSECURE_MESSAGE = "authenticated plaintext HTTP requires an explicit dangerous opt-in"


def test_env_base_and_env_key_reach_the_proxy_path_with_the_apikey_header(
fixture_server: FixtureServer, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A valet-shaped base keeps its path before ``stable`` and the key rides in ``apikey``."""
fixture_server.route("/fmp/stable/quote-short", load_fixture("quote_short.json"))
monkeypatch.setenv("FMP_API_KEY", "vk_example")
monkeypatch.setenv("FMP_BASE_URL", f" {fixture_server.base_url}/fmp\n")
assert len(FmpClient().quote.short("AAPL")) == 1
request = fixture_server.requests[0]
assert request.path == "/fmp/stable/quote-short"
assert request.headers["apikey"] == "vk_example"
assert "apikey" not in request.query


def test_explicit_base_url_beats_the_env_base(fixture_server: FixtureServer, monkeypatch: pytest.MonkeyPatch) -> None:
"""A ``base_url`` argument is used even when the variable points elsewhere."""
fixture_server.route("/quote-short", load_fixture("quote_short.json"))
monkeypatch.setenv("FMP_BASE_URL", "https://unused.example/fmp")
client = FmpClient(base_url=fixture_server.base_url, path_prefix="", auth_mode="none")
assert len(client.quote.short("AAPL")) == 1
assert fixture_server.requests[0].path == "/quote-short"


def test_auth_mode_none_with_the_env_base_sends_no_key(
fixture_server: FixtureServer, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``auth_mode="none"`` still ignores ``FMP_API_KEY`` when the base comes from the variable."""
fixture_server.route("/stable/quote-short", load_fixture("quote_short.json"))
monkeypatch.setenv("FMP_API_KEY", "vk_example")
monkeypatch.setenv("FMP_BASE_URL", fixture_server.base_url)
assert len(FmpClient(auth_mode="none").quote.short("AAPL")) == 1
request = fixture_server.requests[0]
assert "apikey" not in request.headers
assert "apikey" not in request.query


@pytest.mark.parametrize("value", [None, "", " \t\n"], ids=["unset", "empty", "blank"])
def test_unset_or_blank_env_base_keeps_the_default_host(
errors: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, value: str | None
) -> None:
"""Without a usable variable the client targets the default host, which needs a key."""
if value is not None:
monkeypatch.setenv("FMP_BASE_URL", value)
with pytest.raises(errors.FmpConfigError) as raised:
FmpClient()
assert str(raised.value) == MISSING_TOKEN_MESSAGE


def test_plaintext_env_base_with_a_key_needs_the_danger_flag(
errors: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A non-loopback ``http://`` base from the variable keeps the insecure-auth guard."""
monkeypatch.setenv("FMP_API_KEY", "vk_example")
monkeypatch.setenv("FMP_BASE_URL", "http://example.test")
with pytest.raises(errors.FmpConfigError) as raised:
FmpClient()
assert str(raised.value) == INSECURE_MESSAGE
assert "vk_example" not in repr(raised.value)
FmpClient(danger_allow_insecure_authentication=True)


@pytest.mark.parametrize(
"value", ["valet.bitbrew.app/fmp", "ftp://valet.bitbrew.app/fmp", "https://user:pw@valet.bitbrew.app/fmp"]
)
def test_invalid_env_base_raises_a_config_error(
errors: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, value: str
) -> None:
"""An unusable variable fails construction without echoing the value."""
monkeypatch.setenv("FMP_API_KEY", "vk_example")
monkeypatch.setenv("FMP_BASE_URL", value)
with pytest.raises(errors.FmpConfigError) as raised:
FmpClient()
assert raised.value.category == "configuration"
assert value not in str(raised.value)
32 changes: 32 additions & 0 deletions crates/libfmp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,38 @@ empty, or whitespace-only), so the `env::var` line above is the explicit form.

The documented bare JSON array remains a `Vec<QuoteShort>`; empty and multi-row responses keep their original shape.

## Using a proxy

A proxy that issues its own keys and forwards to FMP, such as
[valet](https://valet.bitbrew.app), needs two environment variables and no
code change:

```sh
export FMP_API_KEY=vk_...
export FMP_BASE_URL=https://valet.bitbrew.app/fmp
```

```rust
use libfmp::{Client, types::Ticker};

# async fn example() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_env()?;
let rows = client.quote_short(Ticker::new("AAPL")?).await?;
# let _ = rows;
# Ok(())
# }
```

`Client::from_env()` (or `ClientBuilder::from_env()?` to keep configuring)
sends the key in the `apikey` header and keeps the base URL's path, so the
quote endpoint goes to `https://valet.bitbrew.app/fmp/stable/quote`. Without
`FMP_BASE_URL` it targets `https://financialmodelingprep.com`, and without
`FMP_API_KEY` it returns a `MissingCredential` configuration error. Both values
are trimmed and a blank value counts as unset. A plaintext `http://` base URL
to a non-loopback host is still refused unless the builder opts in with
`danger_allow_insecure_authentication(true)`. `Client::builder()` itself never
reads either variable.

## Custom router or proxy

Transport choices do not change endpoint code. A caller can select a custom base URL and path prefix, no auth, FMP header or query auth, bearer auth, custom secret header or query auth, and arbitrary default headers.
Expand Down
Loading
Loading