Extensibility¶
stonepy can be extended in three independent ways, all driven by ClientConfig:
- Out-of-tree resource plugins - ship your own resource group in a separate package, register it with a packaging entry point, and reach it through
client.plugin("name"). allow_overrides- permit a plugin to shadow a built-in resource name.- A custom
status_decoder- change how the response pipeline decides whether a businessStatusfield means "rejected".
Everything below is grounded in the actual loader (src/stonepy/_core/plugins.py), the base class (src/stonepy/_core/resource.py), the status types (src/stonepy/_core/status.py), the client wiring (src/stonepy/client.py), and ClientConfig (src/stonepy/_core/config.py).
1. Writing a plugin resource¶
A plugin resource is any subclass of BaseResource. The base class is intentionally tiny:
# src/stonepy/_core/resource.py
ABI_VERSION: int = 1
class BaseResource:
def __init__(self, ctx: CallContext) -> None:
self._ctx = ctx
The constructor takes a single CallContext and stores it as self._ctx. That context is the only thing your resource needs: it exposes self._ctx.invoke(spec, ...) (sync) and self._ctx.ainvoke(spec, ...) (async), which run the full pipeline (auth refresh, rate limiting, retries, error mapping, response parsing, and the business-status check).
Note
BaseResource is imported from the internal module stonepy._core.resource - that exact import path is the ABI surface plugins depend on. The built-in generated resources import it the same way (for example src/stonepy/resources/cfd/__init__.py does from stonepy._core.resource import BaseResource). It is not re-exported from the top-level stonepy package, so import it from stonepy._core.resource directly.
A minimal plugin resource¶
The methods on your resource are ordinary Python methods. To call the StoneX API you build an EndpointSpec (from stonepy._core.endpoint) and pass it to self._ctx.invoke(...), exactly as the generated endpoint modules do. Here is a read-only resource that lists CFD markets through a hand-written spec:
# my_stonepy_plugin/resource.py
from __future__ import annotations
from stonepy._core.endpoint import AuthPolicy, EndpointSpec, Param
from stonepy._core.resource import BaseResource
from stonepy.models import ListCfdMarketsResponseDTO
_LIST_MARKETS = EndpointSpec(
name="ListCfdMarkets",
method="GET",
path="/cfd/markets",
idempotent=True,
auth_policy=AuthPolicy.SESSION,
rate_limit_bucket="cfd",
response_model=ListCfdMarketsResponseDTO,
params=(
Param(name="ClientAccountId", location="query", python_name="client_account_id"),
Param(name="maxResults", location="query", python_name="max_results"),
),
)
class CfdExtrasResource(BaseResource):
# Optional version-compat contract; see section on requires_stonepy below.
requires_stonepy = ">=0.1.0"
def list_markets(self, client_account_id: int, *, max_results: int = 20) -> ListCfdMarketsResponseDTO:
return self._ctx.invoke(
_LIST_MARKETS,
query={"ClientAccountId": client_account_id, "maxResults": max_results},
)
Note
The query keys you pass to invoke are the wire names (the Param.name values), not the Python names. This mirrors the generated endpoint modules under src/stonepy/_endpoints/.
Registering it via a packaging entry point¶
The loader discovers plugins from the entry-point group stonepy.resources. This is the exact group name read in discover_plugin_resources:
# src/stonepy/_core/plugins.py
discovered = (
entry_points
if entry_points is not None
else metadata_entry_points(group="stonepy.resources")
)
Declare the entry point in your plugin package's pyproject.toml. The entry-point name becomes the key you pass to client.plugin(...), and the value must point at a BaseResource subclass:
# my_stonepy_plugin/pyproject.toml
[project.entry-points."stonepy.resources"]
cfd_extras = "my_stonepy_plugin.resource:CfdExtrasResource"
After installing that package into the same environment as stonepy, the resource is discoverable under the name cfd_extras.
Enabling and accessing the plugin¶
Plugin discovery is off by default. Turn it on with ClientConfig(enable_plugins=True). The client loads and instantiates every discovered plugin at construction time and stores them in an internal dict; client.plugin(name) returns the instance:
from stonepy import ClientConfig, StoneXClient
config = ClientConfig(
base_url="https://ciapi.cityindex.com/TradingAPI",
app_key="your-app-key",
username="your-username",
password="your-password",
enable_plugins=True,
)
with StoneXClient(config) as client:
extras = client.plugin("cfd_extras") # the CfdExtrasResource instance
markets = extras.list_markets(client_account_id=12345, max_results=10)
The same works for AsyncStoneXClient; both clients build their _plugins dict identically in __init__ and both expose def plugin(self, name: str) -> BaseResource.
Note
client.plugin(name) does a plain dict lookup and raises KeyError if the name was never loaded (plugins disabled, package not installed, entry point not declared, or the plugin was skipped during loading). There is no fallback.
Warning
When enable_plugins=False (the default), discover_plugin_resources returns {} immediately and no entry points are read at all. Plugins only load when you opt in.
How loading and validation work (fail-loud vs fail-soft)¶
load_plugin_resources enforces a precise set of rules. Understanding which conditions raise and which are silently skipped matters for debugging:
- Name collides with a built-in - if an entry-point name is one of the 19 built-in resource names and is not listed in
allow_overrides, loading raisesValueError("plugin resource '<name>' collides with a built-in; add it to allow_overrides to override"). This is fail-loud, raised during client construction. - Duplicate plugin name - if two entry points declare the same name, loading raises
ValueError("plugin resource '<name>' was declared more than once"). Fail-loud. ep.load()raises - if importing/loading the entry point throws, the loader logs a warning (logger = logging.getLogger("stonepy.plugins")) and continues, skipping that plugin. Fail-soft.- Loaded object is not a
BaseResourcesubclass - ifep.load()does not return a class that is a subclass ofBaseResource, the loader logs a warning and continues, skipping it. Fail-soft. requires_stonepymismatch or invalid - raisesValueError(see below). Fail-loud.
The built-in names that count as collisions are exactly the known set passed in from client.py:
cfd, client_preference, clientapplication, clientpreference, fixedmargin, margin, market,
message, news, order, order_including_closed, pm, preference, price_alert, session, spread,
tradingadvisor, user_account, watchlist
Tip
Because a failed ep.load() is only logged (not raised), enable the stonepy.plugins logger while developing a plugin so you can see why a resource silently failed to appear:
Version-compatibility contract: requires_stonepy¶
stonepy._core.resource defines ABI_VERSION = 1, but the loader does not check that integer. The compatibility contract that the loader actually enforces is an optional class attribute named requires_stonepy on your resource class, validated by _check_requires_stonepy:
- If the attribute is absent (
None), no check is performed. - If present but not a
str, loading raisesValueError("plugin resource '<name>' has invalid requires_stonepy"). - If present and the current stonepy version does not satisfy it, loading raises
ValueError("plugin resource '<name>' requires stonepy <req>, but current version is <ver>").
The requirement string is a comma-separated list of simple constraints. Each part must match operator + version, where the operator is one of >=, <=, >, <, ==, and the version is one to three dotted numeric components (for example ">=0.1.0", ">=0.1,<0.2"). A part that does not match this shape raises ValueError("invalid requires_stonepy requirement: <req>").
Version comparison uses only the first three dotted components; any non-numeric component is treated as 0 and missing components are padded with 0. The current version comes from importlib.metadata.version("stonepy"), falling back to "0.1.0" if the package metadata is not found.
2. allow_overrides semantics¶
ClientConfig.allow_overrides is a tuple[str, ...] (default ()). It is the allow-list of names that a plugin is permitted to share with a built-in resource. It only affects the collision check described above:
- A plugin name that matches a built-in name is rejected with
ValueErrorunless that name is inallow_overrides. - Adding the name to
allow_overrideslets the plugin load under that name into the_pluginsdict.
from stonepy import ClientConfig
config = ClientConfig(
base_url="https://ciapi.cityindex.com/TradingAPI",
enable_plugins=True,
allow_overrides=("order",), # permit a plugin named "order"
)
Warning
allow_overrides does not replace the built-in resource property. The built-in client.order property still returns the generated OrderResource; the overriding plugin is only reachable through client.plugin("order"). The two coexist - allow_overrides simply suppresses the collision error so the plugin can be loaded alongside the built-in.
3. Supplying a custom status_decoder¶
After a successful HTTP response is parsed, the pipeline can raise OrderRejectedError based on
the status domain declared by the endpoint spec. ClientConfig.status_decoder can replace the
top-level numeric decision, and has this backward-compatible type:
# src/stonepy/_core/status.py
StatusDecision: TypeAlias = BusinessStatus | bool | str | None
StatusDecoder: TypeAlias = Callable[[int, int | None], StatusDecision]
A decoder receives (status, status_reason) (the status_reason may be None) and returns a
StatusDecision. It is called for the top-level status on both INSTRUCTION and ORDER specs;
the signature does not include the domain. The pipeline interprets its return value as follows:
BusinessStatus(is_rejection=..., reason=...)- used directly.bool-Truemeans rejected with no reason;Falsemeans accepted.str- treated as rejected, with the string used as the rejection reason.None- treated as accepted.
When ClientConfig keeps the default callable, the pipeline selects its built-in logic by domain:
- Instruction acknowledgements reject
RedCard(2) andError(4). An undocumented numeric instruction status raisesOrderStatusUnknownError. - Order/simulation acknowledgements reject
Rejected(5) andRedCard(10); unknown numeric lifecycle values are informational.
Supplying any other callable fully replaces that top-level numeric logic, including the unknown instruction-code safeguard. The custom callable therefore needs a policy that is valid for both numeric vocabularies, even though some numbers have different meanings between them.
Where the check runs¶
Business-status checking runs only for endpoint specs with an explicit non-NONE StatusDomain.
A custom decoder does not broaden checking to read endpoints that merely echo stored status.
The custom decoder replaces only the top-level numeric decision. SaveOrder's closed text
Success/Failure check and instruction responses' nested Orders[] checks remain built in;
fixed-margin responses likewise retain their OrderStatusId check. Nested quote statuses are out
of scope.
Writing one¶
from stonepy import ClientConfig
from stonepy.models import OrderStatus
from stonepy._core.status import BusinessStatus, StatusDecision
def strict_decoder(status: int, status_reason: int | None) -> StatusDecision:
# Example custom policy: require an OrderStatus-style Accepted code. This same numeric rule
# also receives instruction statuses, so use it only when that cross-domain policy is wanted.
if status == int(OrderStatus.Accepted):
return BusinessStatus(False)
return BusinessStatus(True, reason=f"status={status} reason={status_reason}")
config = ClientConfig(
base_url="https://ciapi.cityindex.com/TradingAPI",
status_decoder=strict_decoder,
)
Disabling business-status checks¶
status_decoder is StatusDecoder | None. Setting it to None disables all business-status
checks, including execution-text and nested-order checks, so no OrderRejectedError or
OrderStatusUnknownError is raised from a 2xx response:
from stonepy import ClientConfig
config = ClientConfig(
base_url="https://ciapi.cityindex.com/TradingAPI",
status_decoder=None, # never raise a business-status exception on 2xx
)
Warning
Setting status_decoder=None means a 2xx response whose body reports a rejection or an indeterminate acknowledgement will be returned as a normal result. Inspect the status fields yourself before acting, especially in any flow that places or cancels live orders.
Note
ClientConfig.from_env() treats status_decoder specially: it is only overridden when you pass it explicitly as a keyword. Omitting it keeps default_status_decoder; passing status_decoder=None is honored as an explicit disable.