def connect(
ashost: str,
sysnr: str | int,
client: str,
user: str | None = None,
passwd: str | None = None,
*,
lang: str = _DEFAULT_LANG,
strict_params: bool = False,
timeout: float | None = None,
connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT,
read_timeout: float | None = DEFAULT_READ_TIMEOUT,
metadata_cache: MetadataCache | None = None,
metadata_cache_key: str | None = None,
saprouter: str | None = None,
mshost: str | None = None,
msserv: int | str | None = None,
ms_http_port: int | str | None = None,
ms_use_http: bool = True,
sysid: str | None = None,
group: str | None = None,
wshost: str | None = None,
wsport: int | None = None,
ws_path: str | None = None,
ws_proxy_host: str | None = None,
ws_proxy_port: int | None = None,
ws_proxy_user: str | None = None,
ws_proxy_pass: str | None = None,
ws_tls_verify: bool = True,
trace: RfcTrace | None = None,
snc_lib: str | None = None,
snc_partnername: str | None = None,
snc_myname: str | None = None,
snc_qop: int | None = None,
snc_sso: bool | None = None,
max_retries: int = 3,
retry_delay: float = 1.0,
tid_store: TidStore | None = None,
unit_store: UnitStore | None = None,
) -> Connection:
"""Open and return a ready Connection (blocking).
Four transport paths:
- message server: ``mshost`` set → resolve the least-loaded app server via
MessageServerClient.resolve(group), then direct-TCP connect (TRANS-03).
- SAProuter: ``saprouter`` set → prepend the NI_ROUTE prefix before the
direct-TCP handshake (TRANS-02).
- wRFC (WebSocket RFC over TLS): ``wshost`` set → route through
``connect_ws`` (SEC-05, D-16/D-17). ``wsport`` defaults to 443 and
``ws_path`` to ``/sap/bc/rfc`` (D-19). Optional ``ws_proxy_*`` params
tunnel the connection through an HTTP CONNECT forward proxy (D-20).
- SNC (Secure Network Communications): ``snc_lib`` set (and ``wshost``
absent) → wrap the direct-TCP transport in an :class:`~saprfclib.snc.SncTransport`
that drives the GSS-API handshake to COMPLETE before any data is sent
(SEC-02/03/04/06, D-13). ``snc_lib`` presence is the activation switch —
there is no separate mode flag. ``snc_qop`` defaults to 3 (privacy) and
``snc_sso`` to False (D-12). ``wshost`` takes precedence: SNC-over-wRFC
is out of scope for Phase 7.
- direct: ``port = 3300 + int(sysnr)`` (gateway port), connect_tcp, handshake.
``lang`` is the logon language. Accepts the one-character SAP code ('E' English,
'D' German, 'S' Spanish, …) or the two-character ISO code ('EN', 'DE', 'ES'); an
ISO code is converted before the logon frame is built, matching the SDK's LANG
option.
``user`` and ``passwd`` may both be omitted. That is read as a deliberate
anonymous attempt and the logon frame goes out without the user and password
records — some systems answer a small set of function modules that way, while a
hardened one refuses below the RFC layer and raises ``CommunicationError``.
Supplying exactly one of the two raises ``ValueError``, since that is a missing
setting rather than a request to connect anonymously. SNC connections are
unaffected: ``snc_lib`` carries its own credentials.
``strict_params`` controls what ``call()`` does with a keyword argument the
function interface does not declare. The default (False) drops it and logs a
warning, which is what callers porting from pyrfc expect when they pass a
superset of kwargs across differing SAP releases. Set True to raise ValueError
instead — worth doing when a dropped argument would change the result, since the
server has no way to tell you an argument never arrived.
``trace`` attaches an :class:`~saprfclib.trace.RfcTrace`, which writes an
SDK-format trace file of every frame. It is a parameter rather than an
environment variable on purpose: the SDK reads ``RFC_TRACE`` from the
environment, but a process that starts writing traffic to disk because of a
variable it inherited is a surprise, and the file — though credential-redacted
— still contains everything else that crossed the wire. Turning it on should
be visible at the call site.
The SAProuter and message-server wire formats were live-verified after this
docstring first called them unverified: the NI_ROUTE payload is byte-exact
against a capture (``tests/golden/router/ni_route_payload.bin``), a router
that accepts a route answers ``NI_PONG`` and one that refuses answers
``NI_RTERR``, and the message server answers the binary attach and
server-list frames as ``MSG_SERVER``. What remains unconfirmed is narrower and
sits in ``router.py``: some field boundaries inside a server-list entry, and
whether the entry count is carried in the header or only implied by the
payload length. ``passwd``,
``ws_proxy_pass``, ``snc_lib``, ``snc_partnername`` and ``snc_myname`` are
never logged or echoed into any log message or exception string (threats
T-03-CRED2 / T-07-CRED / T-07-PROXY-CRED).
"""
# Imported lazily so the direct-TCP facade carries no hard dependency on the
# alternate-transport layer (router.py, plan 03-03 Task 2).
from saprfclib.router import (
open_route,
open_route_async,
parse_route_string,
)
user, passwd = _resolve_credentials(user, passwd, snc_lib=snc_lib, ashost=ashost)
if mshost is not None:
# Message-server group logon: resolve to a concrete (ashost, sysnr).
ashost, sysnr = _resolve_via_message_server(
mshost,
group=group,
sysid=sysid,
msserv=msserv,
ms_http_port=ms_http_port,
use_http=ms_use_http,
timeout=timeout,
connect_timeout=connect_timeout,
read_timeout=read_timeout,
)
# Gateway port. Confirmed by SAP's "TCP/IP Ports of All SAP Products":
# Gateway sapgw<NN> 3300 range 3300-3399 33<NN>
# Gateway secured sapgw<NN>s 4800 range 4800-4899 48<NN>
# <NN> is the application server's own instance number here, unlike the
# message server. Also confirmed live: the A4H message server reports
# RFC=3300 and RFCS=4800 for a sysnr-00 application server.
sysnr = _validate_sysnr(sysnr)
port = (4800 if snc_lib is not None else 3300) + sysnr
# ------------------------------------------------------------------ #
# Transport routing (Phase 7): wRFC first, then SNC, then plain TCP. #
# Both branches are additive — when ``wshost`` and ``snc_lib`` are #
# None the plain connect_tcp path below is byte-for-byte unchanged #
# (SEC-01 / T-07-REGRESSION). ``wshost`` wins over ``snc_lib`` because #
# SNC-over-wRFC is out of scope for Phase 7 (D-13). #
# ------------------------------------------------------------------ #
if wshost is not None:
# wRFC over TLS (SEC-05, D-16/D-17). Lazy import mirrors the
# router lazy import above so a bare ``import saprfclib`` never hard-
# depends on the WebSocket stack at import time.
from saprfclib.ws import connect_ws
transport = connect_ws(
wshost,
wsport or 443,
ws_path=ws_path or "/sap/bc/rfc?sap-apc-stateful=true",
ws_proxy_host=ws_proxy_host,
ws_proxy_port=ws_proxy_port,
ws_proxy_user=ws_proxy_user,
ws_proxy_pass=ws_proxy_pass,
user=user,
passwd=passwd,
sap_client=client,
verify=ws_tls_verify,
timeout=timeout,
read_timeout=read_timeout,
)
conn = Connection(
transport, # type: ignore[arg-type]
strict_params=strict_params,
metadata_cache=metadata_cache,
metadata_cache_key=metadata_cache_key,
)
elif snc_lib is not None:
# SNC (SEC-02/03/04/06, D-13): SAP protocol order requires the NI
# version exchange to complete on the plain channel BEFORE the GSS
# frames are sent. SncTransport then drives FR_INIT/FR_ACCEPT to
# COMPLETE; GW connect and logon flow through the encrypted channel.
#
# T-07-CRED: snc_lib / snc_partnername / snc_myname are passed
# straight through — never placed into a log or an exception string.
from saprfclib.snc import SncTransport
_inner = connect_tcp(
ashost,
port,
timeout=timeout,
connect_timeout=connect_timeout,
read_timeout=read_timeout,
trace=trace,
)
# Step 1: NI version exchange on the plain inner transport.
_snc_sess = Session()
try:
_snc_lip = _inner._sock.getsockname()[0]
except Exception:
_snc_lip = "127.0.0.1"
_inner.send_message(_snc_sess.start(local_ip=_snc_lip))
_snc_sess.feed(_inner.recv_message())
# _snc_sess is now NI_VERSIONED.
# Step 2: GSS handshake on the versioned channel.
transport = SncTransport( # type: ignore[assignment]
_inner,
snc_lib=snc_lib,
snc_partnername=snc_partnername, # type: ignore[arg-type]
snc_myname=snc_myname,
snc_qop=snc_qop or 3, # D-12: privacy is the default QOP
snc_sso=snc_sso or False, # D-12: SSO2 off by default (D-23 gap)
)
# Step 3: Connection with the pre-versioned session so _handshake()
# resumes from NI_VERSIONED (skips the NI leg, starts at GW connect).
conn = Connection(
transport, # type: ignore[arg-type]
strict_params=strict_params,
metadata_cache=metadata_cache,
metadata_cache_key=metadata_cache_key,
)
conn._session = _snc_sess
conn._snc_mode = True
else:
# ------------------------------------------------------------------ #
# Classic async-core path (D-06/D-07): direct TCP / SAProuter / #
# message-server connections all use AsyncConnection + _LoopThread. #
# SAProuter NI_ROUTE is prepended inside the async setup coroutine. #
# Returns early — the shared saprouter/handshake lines below are for #
# SNC / wRFC paths only (scope boundary — Phase 9). #
# ------------------------------------------------------------------ #
loop_thread = _LoopThread()
# Capture locals for the async closure (avoid late-binding issues).
_ashost = ashost
_port = port
_timeout = timeout
_connect_timeout = connect_timeout
_read_timeout = read_timeout
_metadata_cache = metadata_cache
_metadata_cache_key = metadata_cache_key
_saprouter = saprouter
_client = client
_user = user
_passwd = passwd
_lang = lang
_strict = strict_params
_sysnr = int(sysnr)
_max_retries = max_retries
_retry_delay = retry_delay
_tid_store = tid_store
_unit_store = unit_store
async def _async_setup() -> AsyncConnection:
# Use connect_tcp (sync, patchable in tests) wrapped in a thin async shim.
# connect_async() uses real asyncio open_connection for non-blocking I/O.
# This keeps the existing test suite (which patches connect_tcp) green (D-07).
sync_t = connect_tcp(
_ashost,
_port,
timeout=_timeout,
connect_timeout=_connect_timeout,
read_timeout=_read_timeout,
trace=trace,
)
at: _SyncToAsyncTransport = _SyncToAsyncTransport(sync_t)
if _saprouter is not None:
hops = parse_route_string(_saprouter)
await open_route_async(at, hops, _ashost, str(_port))
ac = AsyncConnection(
at, # type: ignore[arg-type]
max_retries=_max_retries,
retry_delay=_retry_delay,
tid_store=_tid_store,
unit_store=_unit_store,
strict_params=_strict,
metadata_cache=_metadata_cache,
metadata_cache_key=_metadata_cache_key,
)
await ac._handshake(
client=_client,
user=_user,
passwd=_passwd,
ashost=_ashost,
sysnr=_sysnr,
lang=_lang,
)
return ac
try:
async_conn = loop_thread.run(_async_setup())
except Exception:
loop_thread.close()
raise
return Connection._from_async(async_conn, loop_thread)
if saprouter is not None:
# Prepend the NI_ROUTE control frame before the handshake (TRANS-02).
# Wire format confirmed from live capture 2026-06-27.
# NOTE: only reached by SNC/wRFC branches (classic path returns above).
hops = parse_route_string(saprouter)
open_route(transport, hops, ashost, str(port))
conn._handshake(
client=client, user=user, passwd=passwd, ashost=ashost, sysnr=int(sysnr), lang=lang
)
return conn