Defining connectors¶
The @connector decorator turns a def into a frozen Connector: a small
callable plus the metadata Parsimony needs to call it, validate its output, render it for an
LLM, and stamp its results with provenance. This page covers the decorator in depth — its two
call forms, every keyword, the defaults it derives, the validation it runs at decoration time,
and what happens to your return value when the connector is called.
All three names come from the package root:
loader and enumerator are stricter variants built on top of connector; they get their own
page. This page is about the general @connector. See
loaders and enumerators for the two specialized verbs.
The two decorator forms¶
@connector is overloaded so it works bare or called.
import pandas as pd
from parsimony import connector
@connector
def demo_search(query: str) -> pd.DataFrame:
"""Search for test series by keyword."""
return pd.DataFrame({"id": ["A", "B"], "title": [f"Series about {query}", "Other"]})
# Called form, with keyword options:
@connector(name="ecb_search", tags=["search"])
def search(query: str) -> pd.DataFrame:
"""Search ECB series by keyword."""
return pd.DataFrame({"id": ["X"], "title": [query]})
Both produce a Connector instance. The bare form passes your function straight to the
decorator; the called form returns a decorator that wraps it. Use @connector() (called, no
arguments) only if you want the called form without options — @connector is the idiomatic
bare spelling.
The decorated object is a frozen dataclass, not your original function. Its parameters are the
connector's call surface — there is no separate params: SomeModel wrapper, and the conformance
suite forbids one. Pass flat, top-level scalar parameters.
Connectors are always synchronous
The wrapped function must be a plain function. An async def raises
TypeError("<name>: connector function must be synchronous; ...") at decoration time.
Calling a connector is also synchronous — see Calling below.
Name and description¶
@connector
def demo_search(query: str) -> pd.DataFrame:
"""Search for test series by keyword."""
...
assert demo_search.name == "demo_search"
assert demo_search.description == "Search for test series by keyword."
namedefaults tofn.__name__. An explicitname=overrides it. The name becomes the connector's identity in aConnectorscollection and is recorded asprovenance.sourceon every result.descriptiondefaults to the strippedfn.__doc__. You can override it withdescription=. A description is required — if both the docstring anddescription=are empty, decoration raisesValueError("<name>: add a docstring or pass description= ...").
The description is not decoration: it is the text an LLM reads to decide whether to call the connector, so write it as a precise capability statement.
Empty description is a hard error
Decorator keywords¶
| Keyword | Type | Default | Purpose |
|---|---|---|---|
name |
str \| None |
fn.__name__ |
Connector identity; becomes provenance.source. |
description |
str \| None |
stripped fn.__doc__ |
Required capability text. |
output |
OutputSpec \| None |
None |
Declarative column-role schema, attached to every result unchanged. |
tags |
list[str] \| None |
() |
Free-form labels used by Connectors.search/filter. |
properties |
dict[str, Any] \| None |
{} |
Exact-match metadata used by Connectors.search. |
secrets |
tuple[str, ...] |
() |
Parameter names to strip from provenance. |
requires |
tuple[str, ...] |
() |
Env-var names that must resolve for a call to succeed; surfaced in the cards, never read by core. |
tags and properties are stored as read-only views (tags as a tuple, properties as a
MappingProxyType). output is an OutputSpec; when present, it is attached to
every result on result.output_spec — the framework never inspects or applies it to the data
itself. The secrets, requires, and output keywords are covered in their own sections below.
@connector(tags=["finance"], properties={"region": "us"})
def fetch(q: str) -> dict:
"""Fetch a finance value."""
return {"q": q}
assert fetch.tags == ("finance",)
assert dict(fetch.properties) == {"region": "us"}
Declaring secrets¶
secrets= names parameters whose values must never appear in
provenance. At decoration time the names are validated against the
function's actual parameters — an unknown name raises
ValueError("secrets references unknown parameters: [...]").
import pandas as pd
from parsimony import connector
@connector(secrets=("api_key",))
def keyed(query: str, api_key: str) -> pd.DataFrame:
"""Fetch data using an API key."""
return pd.DataFrame({"q": [query]})
result = keyed(query="GDP", api_key="sk-secret")
assert result.provenance.params == {"query": "GDP"} # api_key stripped
A declared secret is stripped from provenance.params whether you supply it at call time (as
above) or fix it with bind(). Binding additionally removes the parameter from the connector's
exposed call surface, so it never shows up in describe()/to_llm() cards either. That is the
canonical idiom for injecting credentials and base URLs without leaking them to an agent — see
binding.
Stripping is name-based
Only the exact declared parameter names are removed. A sensitive value passed under a
parameter that is not listed in secrets= is recorded in provenance verbatim.
Declaring required env vars¶
secrets= and requires= answer two different questions, and they are independent:
secrets=— must this value be hidden? It names parameters whose call-time values are redacted from provenance and error surfaces. It says nothing about whether the connector can run without the value.requires=— must this value exist? It names environment variables that must resolve for a call to succeed. A name inrequires=is exactly the env var thatUnauthorizedErrornames when the connector is called with nothing configured.
requires= is a static declaration, never resolution — core stores the tuple verbatim and
never reads os.environ from it (nor validates the names against the signature; an env var need
not correspond to any parameter). Resolving the value, and fast-failing when it is absent, is
the connector body's job — typically via require_key (from
parsimony.transport.helpers), which raises UnauthorizedError(env_var=...) before any
network call. requires= is the matching declaration of that runtime behaviour, so tooling
can report what a connector needs without importing and calling it.
import os
import pandas as pd
from parsimony import connector
from parsimony.transport.helpers import require_key
@connector(secrets=("api_key",), requires=("FRED_API_KEY",))
def fred_fetch(series_id: str, api_key: str = "") -> pd.DataFrame:
"""Fetch FRED observations by series_id."""
key = require_key(api_key, env_var="FRED_API_KEY", provider="fred") # fast-fails if absent
return pd.DataFrame({"date": ["2020-01-01"], "value": [1.0]})
Because the two axes are independent, all four combinations occur:
requires non-empty |
requires empty |
|
|---|---|---|
secrets non-empty |
required key — the call cannot succeed unconfigured (fred, eia) |
optional key — a secret-marked param the call does not require; the key only lifts a quota (riksbank, bls) |
secrets empty |
a required env var with no parameter at all — e.g. sec_edgar's SEC_EDGAR_USER_AGENT header |
keyless (treasury, boc) |
Neither field derives from the other. For an optional key, the env var the connector reads
when the parameter is omitted lives only in the docstring/prose — requires= stays empty
because the call still succeeds without it.
Namespace hints¶
Annotate a parameter with Annotated[T, "ns:<namespace>"] to declare which catalog
namespace its values belong to. The framework parses these into
namespace_hints and surfaces them in the LLM-facing cards.
from typing import Annotated
import pandas as pd
from parsimony import connector
from parsimony.result import Column, ColumnRole, OutputSpec
OUT = OutputSpec(columns=[
Column(name="date", role=ColumnRole.KEY, namespace="fred_series"),
Column(name="value", role=ColumnRole.DATA),
])
@connector(output=OUT, requires=("FRED_API_KEY",))
def fred_fetch(series_id: Annotated[str, "ns:fred_series"]) -> pd.DataFrame:
"""Fetch FRED time series observations by series_id."""
return pd.DataFrame({"date": ["2020-01-01"], "value": [1.0]})
assert dict(fred_fetch.namespace_hints) == {"series_id": "fred_series"}
assert "[ns:fred_series]" in fred_fetch.to_llm()
A hint tells a downstream agent that series_id accepts codes drawn from the fred_series
namespace — the same namespace a sibling enumerator would populate
in a catalog. An empty hint ("ns:") is ignored.
Calling¶
Calling a connector binds your arguments against the exposed signature,
applies defaults, calls your function, and wraps the raw return value into a
Result.
from parsimony import connector
import pandas as pd
@connector
def demo_search(query: str) -> pd.DataFrame:
"""Search for test series by keyword."""
return pd.DataFrame({"id": ["A", "B"], "title": [query, "Other"]})
result = demo_search(query="GDP")
print(result.raw) # the DataFrame you returned (also result.frame)
print(result.provenance.source) # 'demo_search'
print(result.provenance.params) # {'query': 'GDP'}
Invalid call-time arguments raise TypeError("Invalid parameters for connector '<name>': ...")
(e.g. a missing required argument or an unexpected keyword), so callers get a clear,
connector-named error rather than a raw binding failure.
How return values are wrapped¶
You return raw data; the framework builds the result envelope. There is one
result type, Result; a DataFrame return is simply a Result whose raw is the
frame (result.is_tabular is then True). The rules:
| Return value | Result |
|---|---|
DataFrame / Series |
Result carrying the frame unchanged, plus output_spec if output= was set (is_tabular) |
scalar / dict / any other |
Result (the value lands on result.raw) |
tuple |
TypeError |
Result |
TypeError |
Returning a (data, properties) tuple or a Result is rejected with
TypeError(... must return raw data ...). The framework — not your connector — owns the
execution envelope and the provenance on it. Put provider facts in
DataFrame columns, not in a side-channel tuple.
@connector
def bad(q: str) -> tuple:
"""Returns a forbidden tuple."""
return (pd.DataFrame({"a": [1]}), {"prop": 1})
# bad(q="x")
# TypeError: connector 'bad': must return raw data, not (data, properties) tuples; ...
Provenance is always framework-built: source is the connector name, source_description is
the description, fetched_at is the current UTC time, params is the call-time arguments with
declared secrets removed, and properties is empty. Bound arguments are never recorded as
provenance params — only call-time arguments are.
The output schema is attached, not applied¶
When output= is an OutputSpec, it is attached to the result as
result.output_spec verbatim — the framework never inspects your returned DataFrame's
columns against it, never coerces a dtype, never renames or reorders a column, and never drops or
adds one. Whatever columns you return are exactly the columns on result.raw.
import pandas as pd
from parsimony import connector
from parsimony.result import Column, ColumnRole, OutputSpec
OUT = OutputSpec(columns=[
Column(name="date", role=ColumnRole.KEY, namespace="demo"),
Column(name="value", role=ColumnRole.DATA),
])
@connector(output=OUT)
def fetch(series_id: str) -> pd.DataFrame:
"""Fetch demo observations."""
return pd.DataFrame({"date": ["2020-01-01"], "value": [1.0], "extra": ["z"]})
result = fetch(series_id="X")
assert list(result.raw.columns) == ["date", "value", "extra"] # returned unchanged, incl. "extra"
assert [c.name for c in result.output_spec.columns] == ["date", "value"] # schema is unrelated
result.columns (the schema's declared Columns, if you attached one) and result.raw.columns
(the DataFrame's actual columns) are two independent things now — nothing keeps them in sync for
you. Anything that needs both aligned — an entity projection, a
data store load — validates that alignment itself, at the point it is
used, and raises ValueError if a declared column is missing from the data.
No dtype coercion, ever
Column has no dtype= field. If a provider hands you string-typed dates or numbers,
pd.to_datetime(...) / pd.to_numeric(...) (or equivalent) in the connector body, before you
return — see Results and output schemas for the rationale.
Programmer errors stay plain exceptions
A forbidden tuple/Result return, an unknown call argument, or a malformed OutputSpec stay
as TypeError / ValueError / pydantic ValidationError — they are not part of the
ParseError operational-error taxonomy. See Errors for the full
taxonomy and when a connector should raise ParseError itself.
Projections: describe() and to_llm()¶
Every connector renders itself two ways. Both operate on the exposed signature, so bound parameters (including bound secrets) are invisible in both.
describe()— a multi-line, human-readable block: header, description, aParameterssection (each parameter's type,required/optional, and anynamespace=hint), aRequires: <ENV_VAR>, ...line whenrequires=is non-empty, anOutput Schemasection (column name + role + namespace) whenoutput=is set, andTags/Propertieslines.to_llm()— a compact, token-efficient card for system prompts:### <name> [tags] (needs <ENV_VAR>)(the(needs …)suffix appears only whenrequires=is non-empty), the collapsed description (with aReturns: <col> (ROLE ns:x), ...line when anOutputSpecdeclares columns — onename (ROLE)token per LLM-visible column,ns:appended for KEY/METADATA columns that declare a namespace, columns withexclude_from_llm_viewomitted), then one- <param>?: <type> [ns:x]line per exposed parameter (?marks optional,[ns:...]marks a namespace hint).
print(fred_fetch.to_llm())
# ### fred_fetch (needs FRED_API_KEY)
# Fetch FRED time series observations by series_id. Returns: date (KEY ns:fred_series), value (DATA).
# - series_id: str [ns:fred_series]
print(fred_fetch.describe())
# Connector: fred_fetch
# ─────────────────────
#
# Fetch FRED time series observations by series_id.
#
# Parameters:
# series_id: str (required) — namespace='fred_series'
#
# Requires: FRED_API_KEY
#
# Output Schema:
# date KEY namespace='fred_series'
# value DATA
Secrets and *args/**kwargs stay out of cards
Bound parameters are dropped from exposed_signature, so a bound secret never reaches a
card. Variadic *args / **kwargs parameters are also skipped by both projections.
Inspecting a connector¶
Connector is a frozen dataclass. The fields and helpers most useful when defining connectors:
| Member | Kind | What it gives you |
|---|---|---|
name |
field | The connector's identity. |
description |
field | The required capability text. |
tags |
field | tuple[str, ...] of labels. |
properties |
field | Read-only metadata mapping. |
namespace_hints |
field | Read-only {param: namespace} mapping from Annotated hints. |
secrets |
field | tuple[str, ...] of secret parameter names. |
requires |
field | tuple[str, ...] of env-var names the call needs to succeed. |
output_spec |
field | The OutputSpec or None. |
exposed_signature |
property | The post-binding inspect.Signature callers and cards see. |
describe() / to_llm() |
method | Human and LLM projections. |
Because the dataclass is frozen, you never mutate a connector; bind() returns a new
instance. That, plus composing connectors into a collection, is covered in
Calling, binding, and composing.
See also¶
- Loaders and enumerators — the two stricter connector verbs and their output contracts.
- Calling, binding, and composing — calling connectors, fixing parameters with
bind, andConnectorscollections. - Results and output schemas —
OutputSpec,Column,ColumnRole,Provenance, and the entity projection. - Errors — the typed exception taxonomy connectors raise and
ParseErrortranslation.