SPV API¶
The SPV client, session auth layer, bootstrap, certificate discovery, and value types.
anafpy.spv.client ¶
Async client for the SPV web services (webserviced.anaf.ro/SPVWS2/rest).
Read-only by design: list inbox messages (listaMesaje), download documents
(descarcare), request official reports (cerere) and wait for their
asynchronous delivery. Declaration submission is out of scope.
Transport model (see docs/anaf-reference/spv/api.md §1.1): the certificate
is involved only in the interactive session bootstrap
(:class:~anafpy.spv.bootstrap.CurlBootstrapper — drive it via
:meth:SpvClient.login); every request here is plain httpx riding the APM
cookies. The cookie credential lives in :mod:anafpy.spv.auth — construct the
client with a :class:~anafpy.spv.auth.SpvSessionProvider, mirroring how the
OAuth clients take a TokenProvider. The :class:~anafpy.spv.auth.SpvAuth
flow attaches the cookies, follows the APM's occasional /my.policy_nonce
revalidation bounces transparently, and raises
:class:~anafpy.exceptions.AnafAuthError on a redirect to bare /my.policy
(the client never re-runs the bootstrap on its own: that fires the owner's
2FA, which must stay a deliberate act).
Unlike the OAuth clients' no-retry stance, the SPV reads (listaMesaje,
descarcare) retry transient network failures with exponential backoff and
jitter — every SPV operation is an idempotent GET, ANAF documents no rate
limits, and the polling flows would otherwise surface every blip. cerere
stays single-shot (it creates server-side work); dedupe of repeated requests is
deliberately a caller concern — the MCP layer guards agent loops.
SpvClient ¶
SpvClient(provider: SpvSessionProvider, *, http: AsyncClient | None = None, timeout: float = 60.0)
Bases: HttpClientBase
Talks to a taxpayer's SPV over an established APM cookie session.
Construct with a :class:~anafpy.spv.auth.SpvSessionProvider over the
session store an earlier :meth:login filled. The client owns an
httpx.AsyncClient (unless one is injected — it must then carry
:class:~anafpy.spv.auth.SpvAuth itself and a non-empty base_url;
an empty one raises :class:~anafpy.exceptions.AnafConfigError, since
injected clients are never mutated) and should be used as
an async context manager. Cookie rotations are saved back to the store.
login
async
¶
login() -> SpvSession
Establish a fresh APM session (delegates to the provider).
Interactive: the certificate middleware's PIN/2FA prompt fires. The new session is saved to the provider's store, where the next request picks it up.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
the provider was built without a |
AnafAuthError
|
the handshake failed or timed out (retryable — the bootstrap is intermittently flaky; the prompt fires again). |
list_messages
async
¶
list_messages(days: int, *, cif: str | None = None) -> MessageList
Inbox messages that arrived in the last days days (listaMesaje).
cif narrows to one CUI/CNP; by default ANAF returns every message
the certificate has rights for. Besides the messages, the result
carries the certificate's authorization inventory
(:attr:~anafpy.spv.models.MessageList.authorized_cuis).
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
|
AnafAuthError
|
no session, or it expired (log in again). |
AnafResponseError
|
ANAF reported a genuine error; a benign "no
messages in the window" note yields empty |
download_document
async
¶
download_document(message_id: str) -> SpvDocument
Download one inbox document (descarcare) — PDF bytes.
Raises:
| Type | Description |
|---|---|
AnafResponseError
|
ANAF answered with an |
request_report
async
¶
request_report(request: ReportRequest) -> ReportRequestResult
File a report request (cerere); the report arrives asynchronously.
The returned request_id will show up as an inbox message's
request_id once the report is generated — poll with
:meth:wait_for_report or match it in :meth:list_messages output.
This method deliberately does no transport retry — one call, one
result-or-raise — and no dedupe: every call files a request with ANAF
(a repeated cerere is harmless but yields a second inbox message).
Callers that might repeat themselves (agent loops) own their dedupe —
the MCP layer is where that guard lives.
Raises:
| Type | Description |
|---|---|
AnafAuthError
|
no session, or it expired. |
AnafResponseError
|
ANAF refused the request (no rights, invalid CUI, ...) — verbatim Romanian plus an English hint. |
wait_for_report
async
¶
wait_for_report(request_id: str, *, cif: str | None = None, days: int = 7, timeout: float = 600.0, initial_wait: float = 15.0, max_wait: float = 120.0) -> SpvDocument
Poll the inbox until the report for request_id lands, then download.
Polls :meth:list_messages (a days-wide window, optionally narrowed
by cif) with growing intervals — generous by design, ANAF documents
no SLA. Raises :class:TimeoutError when the budget runs out; the
request itself stays valid, so retrying later with the same
request_id is always safe.
anafpy.spv.auth ¶
SPV session lifecycle: hand out the APM cookie set, persisting rotations.
The SPV analog of :mod:anafpy.auth.provider. The per-request credential is
not the certificate (that participates only in the interactive login bootstrap
— see :mod:anafpy.spv.bootstrap) but the F5 APM cookie session it mints:
a bearer credential, exactly like an OAuth access token. So the same two-piece
shape applies:
- :class:
SpvSessionProvidermirrorsTokenProvider— the :class:~anafpy.spv.session.SessionStoreis the single source of truth, and the provider also owns the deliberate :meth:~SpvSessionProvider.login. - :class:
SpvAuthmirrorsAnafAuth— anhttpx.Authflow that attaches the cookies, follows the APM's mid-session/my.policy_noncerevalidation hops (re-yielding requests, the same mechanismAnafAuthuses for its 401 retry), and persists rotated cookies back through the provider.
The one deliberate asymmetry: AnafAuth refreshes on a 401, but
:class:SpvAuth has no recovery leg. Re-establishing an SPV session means
re-running the certificate bootstrap, which fires the owner's PIN/2FA — that
must stay a deliberate act, so a bounce to the APM login wall raises
:class:~anafpy.exceptions.AnafAuthError, full stop. Do not "complete" the
symmetry by calling :meth:SpvSessionProvider.login from the auth flow.
SpvSessionProvider ¶
SpvSessionProvider(*, store: SessionStore, bootstrapper: SessionBootstrapper | None = None)
Provides the current APM cookie set, persisting rotations and owning login.
The SessionStore is the single source of truth: the provider keeps no
session state of its own. Every operation reads the freshest persisted set
under the lock, so a login performed by another process sharing the store
(the CLI, a second server) is picked up on the next call.
cookies
async
¶
cookies() -> dict[str, str]
The stored session's cookie set.
Raises:
| Type | Description |
|---|---|
AnafAuthError
|
no session is stored — log in first. |
rotated
async
¶
rotated(cookies: dict[str, str], *, seen: dict[str, str]) -> None
Persist a cookie set the APM rotated mid-flight (save-if-changed).
seen is the set the request started from. The save happens only
while the store still holds it: if another process replaced the
session mid-flight (a fresh login), this rotation belongs to the dead
session and must not clobber the new one (last-writer-wins would).
login
async
¶
login() -> SpvSession
Establish a fresh APM session via the configured bootstrapper.
Interactive: the certificate middleware's PIN/2FA prompt fires. The new session replaces whatever the store held.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
the provider was built without a |
AnafAuthError
|
the handshake failed or timed out (retryable — the bootstrap is intermittently flaky; the prompt fires again). |
SpvAuth ¶
SpvAuth(provider: SpvSessionProvider)
Bases: Auth
httpx auth flow: attach the APM cookies, follow revalidation hops.
Every request gets the stored cookie set; a redirect to
/my.policy_nonce is followed transparently (folding in rotated
cookies), a bounce to the bare login wall raises
:class:~anafpy.exceptions.AnafAuthError, and an off-host redirect is
refused. Rotated cookies are saved back through the provider.
The client must send with follow_redirects=False so this flow sees the
raw 302s instead of httpx silently following them onto the login wall.
anafpy.spv.bootstrap ¶
SPV session bootstrap — the one step that needs the qualified certificate.
M0 established (see docs/anaf-reference/spv/api.md §1.1) that SPV's mTLS is
an F5 APM login choreography, not a per-request transport: one redirect
chain through /my.policy performs the client-certificate TLS renegotiation
(firing the token PIN / cloud-HSM 2FA), and everything afterwards rides the
resulting cookies. So the platform-specific seam is a
:class:SessionBootstrapper that produces an :class:~.session.SpvSession —
not a request transport.
:class:CurlBootstrapper drives the OS-shipped curl in a subprocess:
- macOS —
/usr/bin/curlwith the SecureTransport backend, which takes the identity by Keychain name and, unlike Apple's own NSURLSession (which hangs, verified 2026-07-12), survives the mid-connection renegotiation. - Windows —
System32\curl.exe(Schannel build) with theCurrentUser\MY\<thumbprint>cert-store syntax.
Python-level alternatives were evaluated and rejected: CPython's ssl only
loads private keys from files, so no httpx/requests-based stack can sign with a
non-exportable platform-store key.
The bootstrap is interactive (the 2FA prompt fires every time — the middleware caches authorizations only for minutes) and intermittently flaky, so it is bounded by a timeout and surfaces actionable errors instead of hanging.
The platform machinery (curl resolution, cert selectors, TLS-backend pin,
subprocess runner, failure taxonomy) lives in :mod:anafpy._transport.curl,
shared with the declaration portal bootstrap
(:class:anafpy.declaratii.upload.PortalCurlBootstrapper) — only the SPV
choreography (the one-probe redirect chain) and its success judgment (the SPV
JSON) live here.
SessionBootstrapper ¶
Bases: Protocol
Performs the certificate handshake and returns the APM cookie session.
CurlBootstrapper ¶
CurlBootstrapper(identity: str, *, timeout: float = 240.0, curl_path: str | None = None, platform: str | None = None)
Bases: CurlBootstrapperBase
Establishes an SPV session via the platform curl and its native key store.
The choreography is a single probe: one --location chain through
/my.policy (the renegotiation fires the 2FA) that must land on the SPV
listaMesaje JSON. Constructor arguments are the shared base's
(:class:~anafpy._transport.curl.CurlBootstrapperBase): identity — the
Keychain identity name on macOS (see
:func:~anafpy.spv.certs.list_keychain_identities), the SHA-1
thumbprint in CurrentUser\MY on Windows — plus timeout,
curl_path, and platform.
bootstrap
async
¶
bootstrap() -> SpvSession
Run the handshake and return the authenticated session.
Success is judged by the payload, not curl's exit code: ANAF's F5
closes the final connection without a TLS close_notify, so a fully
successful bootstrap still exits 56 (SSLRead() error -9806,
live-observed 2026-07-12). If the body is the SPV JSON and the jar
holds the APM session cookie, the handshake worked.
Raises:
| Type | Description |
|---|---|
AnafAuthError
|
the handshake timed out (usually an unanswered or stalled 2FA), curl failed, or ANAF answered with something other than the SPV JSON (certificate without SPV rights, APM hangup). |
parse_netscape_cookies ¶
parse_netscape_cookies(text: str) -> dict[str, str]
Cookie name -> value from a curl-written Netscape cookie jar.
Hand-rolled because stdlib MozillaCookieJar silently skips the
#HttpOnly_ lines curl emits for HttpOnly cookies.
anafpy.spv.session ¶
SPV session state and its persistence.
An SPV "session" is the F5 BIG-IP APM cookie set (MRHSession & friends)
minted by the one certificate-authenticated handshake at /my.policy — see
docs/anaf-reference/spv/api.md §1.1. After that handshake every request rides
the cookies, so the cookie set is a bearer credential to the taxpayer's SPV
and gets the same custody treatment as the OAuth tokens: a
:class:SessionStore protocol with an atomic, 0o600 JSON file backend.
The APM rotates MRHSession mid-session (revalidation hops), so the client
re-saves the session whenever the cookie values change.
SpvSession ¶
Bases: BaseModel
The APM cookie set plus when it was established.
cookies maps cookie name to value for the webserviced.anaf.ro domain
(the only host SPV lives on — domain/path carry no information worth
persisting). established_at is when the certificate handshake ran; the
APM idle timeout is not published, so staleness is discovered by the first
request bouncing to /my.policy, not predicted from this timestamp.
is_authenticated_shape
property
¶
is_authenticated_shape: bool
Whether the cookie set contains the APM session cookie at all.
SessionStore ¶
Bases: Protocol
Where SPV sessions are loaded from and saved to.
MemorySessionStore ¶
MemorySessionStore(session: SpvSession | None = None)
In-process session store (tests, ephemeral runtimes).
FileSessionStore ¶
FileSessionStore(path: str | PathLike[str] = DEFAULT_SESSION_PATH)
JSON-file session store, written atomically with 0o600 permissions.
load ¶
load() -> SpvSession | None
The stored session, or None when no store file exists.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
the file exists but cannot be read or parsed — stay in the AnafError hierarchy instead of leaking a pydantic/OS error. |
clear ¶
clear() -> None
Delete the store file; a no-op when none exists.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
the file exists but cannot be removed. |
anafpy.spv.certs ¶
Qualified-certificate discovery in the platform key stores.
Both platforms enumerate identities the OS considers usable for TLS client
auth — certificate + private key present, clientAuth EKU, not expired —
including token/cloud-HSM ones surfaced through their middleware (SafeNet,
certSIGN vToken, ...):
- macOS —
security find-identity -v -p ssl-client(file-based Keychain identities) merged withsc_auth identities(smartcard/CryptoTokenKit identities — USB tokens and cloud vTokens;find-identitycannot see these at all, live-verified 2026-07-12 with a certSIGN vToken); - Windows — a PowerShell one-liner over
Cert:\CurrentUser\My.
The two macOS tools hash differently (find-identity prints the
certificate's SHA-1; sc_auth prints its own identity hash), so treat
sha1_thumbprint as an opaque selector that is only compared against the
same discovery output — which is all :func:identity_by_thumbprint does.
:attr:StoreIdentity.bootstrap_identity is what
:class:~anafpy.spv.bootstrap.CurlBootstrapper needs: the Keychain name on
macOS (curl's SecureTransport selects by name), the SHA-1 thumbprint on
Windows (Schannel's cert-store syntax). The thumbprint is the stable identifier
worth persisting — names can collide, e.g. after a renewal, which
:func:identity_by_thumbprint refuses on macOS where curl would pick blindly.
The user's chosen certificate is persisted as a small JSON file
(:func:save_selected_identity / :func:load_selected_identity) — a
thumbprint is an identifier, not a secret, so a plain config file is fine.
StoreIdentity ¶
Bases: BaseModel
One usable TLS-client identity in a platform key store.
bootstrap_identity
property
¶
bootstrap_identity: str
The --cert selector for :class:~anafpy.spv.bootstrap.CurlBootstrapper:
the Keychain name on macOS, the thumbprint on Windows.
SelectedIdentity ¶
Bases: BaseModel
The persisted certificate choice (spv_select_certificate / CLI).
discover_identities ¶
discover_identities() -> list[StoreIdentity]
Usable TLS-client identities in this platform's key store.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
unsupported platform, or the platform tool failed. |
list_keychain_identities ¶
list_keychain_identities() -> list[StoreIdentity]
Valid TLS-client identities on macOS: Keychain and smartcard/CTK.
security find-identity only sees file-based keychains; the token-backed
identities that actually hold qualified certificates come from sc_auth.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
the |
parse_find_identity_output ¶
parse_find_identity_output(stdout: str) -> list[StoreIdentity]
Parse security find-identity -v output (separated for testability).
parse_sc_auth_output ¶
parse_sc_auth_output(stdout: str) -> list[StoreIdentity]
Parse sc_auth identities output (separated for testability).
The output mixes headers (SmartCard: ..., Unpaired identities:)
with <40-hex-hash>\t<name> identity lines; only the latter match.
list_windows_identities ¶
list_windows_identities() -> list[StoreIdentity]
Valid client-auth identities in Cert:\CurrentUser\My.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
PowerShell failed or emitted an unrecognisable shape. |
parse_windows_identities ¶
parse_windows_identities(json_text: str) -> list[StoreIdentity]
Parse the discovery script's JSON output (separated for testability).
identity_by_thumbprint ¶
identity_by_thumbprint(thumbprint: str) -> StoreIdentity
Resolve a thumbprint to its identity in this platform's store.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
no identity with that thumbprint; or (macOS only) its name is shared by another identity — curl selects by name there, so an ambiguous name could silently pick the wrong certificate. |
load_selected_identity ¶
load_selected_identity(path: str | PathLike[str] = DEFAULT_IDENTITY_PATH) -> SelectedIdentity | None
The persisted certificate selection, or None when none was made.
Raises:
| Type | Description |
|---|---|
AnafConfigError
|
the file exists but cannot be read or parsed. |
save_selected_identity ¶
save_selected_identity(identity: StoreIdentity | SelectedIdentity, path: str | PathLike[str] = DEFAULT_IDENTITY_PATH) -> SelectedIdentity
Persist the certificate choice; returns what was saved.
anafpy.spv.models ¶
Value types for the SPV web services (webserviced.anaf.ro/SPVWS2/rest).
Everything here mirrors the vendored ClientSPV documentation
(docs/anaf-reference/spv/api.md): the inbox message shapes (§2), the
cerere report nomenclature with its per-type parameter requirements (§4),
and the fixed motiv list for income certificates (§4.2). Per the repo's
hybrid error model, these are values; the Romanian eroare texts become
:class:~anafpy.exceptions.AnafResponseError in the client, decorated with the
English hints from :func:english_error_hint.
tip is an open string on the wire (live-observed values include a
trailing space: "DECLARATIE "), so :class:SpvMessage keeps it verbatim
and offers :attr:SpvMessage.kind for trimmed comparisons.
SpvMessage ¶
Bases: BaseModel
One inbox message from listaMesaje.
kind
property
¶
kind: str
type_ normalised for comparison (trimmed — live-observed values
carry trailing spaces).
SpvEnvelope ¶
Bases: BaseModel
The identity stamp ANAF puts on every authenticated SPV response.
cnp and certificate_serial identify the certificate the session was
established with; title is the response's titlu. All optional: the
benign no-results listaMesaje shape carries no identity fields at all.
MessageList ¶
Bases: SpvEnvelope
Outcome of listaMesaje.
authorized_cuis (the wire's comma-separated cui) is the certificate's
authorization inventory — every CUI/CNP it may query; listaMesaje is
the only endpoint that returns it. A benign "no messages in the window"
answer yields empty messages with the note kept in note.
SpvDocument ¶
Bases: BaseModel
A document downloaded via descarcare — usually a PDF.
ReportRequestResult ¶
Bases: SpvEnvelope
Outcome of cerere — the request was accepted, not answered.
The report itself arrives asynchronously as an inbox message whose
request_id equals this request_id; download it via descarcare.
(cerere echoes the envelope's cnp/serial but, unlike
listaMesaje, no cui authorization list.)
ReportType ¶
Bases: DescribedStrEnum
tip values accepted by cerere (vendored README, verbatim casing).
Members are named after ANAF's own report names, per the repo convention
for ANAF-code enums, and are declared (value, description) — the
stdlib enum-with-attributes pattern. :attr:description is a one-line
English rendering of the README's per-type explanation (api.md §4.1) so a
caller choosing a report — the MCP model in particular — sees what each
one is, not just the bare declaration code; timing and natural-/legal-
person scope are folded in where the README states them, and parameter
names are avoided (the library and the MCP tool spell them differently).
A member declared without a description fails at import time.
CAF is deliberately absent — the README states it is not yet
requestable via the web service.
ReportRequest ¶
Bases: BaseModel
A validated cerere — invalid parameter combinations fail here, before
any wire call.
Field names are English; :meth:wire_params produces ANAF's Romanian query
names per :data:REPORT_PARAMETER_WIRE_NAMES (an, luna, motiv,
numar_inregistrare, cui_pui, lunai/lunas).
required_parameters ¶
required_parameters(type_: ReportType) -> tuple[str, ...]
The parameters a :class:ReportRequest must carry for type_
(model-field names, not wire names).
Groups per the vendored README's example calls (api.md §4.1 — the groupings
are inferred from the examples; ANAF's own validation stays the authority
on any discrepancy). The match is exhaustive over :class:ReportType, so
mypy --strict flags an unclassified new member as a missing return.
optional_parameters ¶
optional_parameters(type_: ReportType) -> tuple[str, ...]
The parameters type_ accepts but does not require (model-field
names) — today only Fisa Rol's optional branch_cui.
english_error_hint ¶
english_error_hint(eroare: str) -> str | None
Best-effort English hint for a Romanian SPV eroare text, or None.
The verbatim Romanian text stays authoritative and is always surfaced alongside; this only orients non-Romanian-speaking callers.