Skip to main content

pynettcp

A native Python client for WCF net.tcp:// services. No CLR, no pythonnet, no generated C# proxy DLL.

Why

There is no Python library that can call a WCF NetTcpBinding service. zeep speaks SOAP over HTTP only. So the usual workaround is to route through .NET:

Python  ->  pythonnet  ->  CLR  ->  proxy.dll (svcutil)  ->  WCF  ->  net.tcp://host:port

That drags in .NET Framework and Visual Studio on every machine, needs the proxy DLL rebuilt and redeployed whenever the service contract changes, and inherits CLR-hosting quirks — most notoriously that .NET reads the host process config, so python.exe.config is consulted and the DLL's own .config is silently ignored.

net.tcp is not proprietary. It is four published Microsoft Open Specifications stacked on a plain TCP socket:

Python  ->  socket  ->  net.tcp://host:port
Layer Specification Module
Framing [MC-NMF], [MC-NMFTB] framing.py
Security [MS-NNS] nns.py, security.py
Encoding [MC-NBFX], [MC-NBFS] nbfx.py, nbfs.py, dictionary.py
Messaging SOAP 1.2 + WS-Addressing soap.py, channel.py, faults.py

Status

Milestone State
M0 — oracle harness, static dictionary done
M1 — varint, nbfx encoder/decoder done, byte-exact vs .NET
M2 — nbfs session dictionary, soap envelope done, byte-exact vs WCF
M3 — framing (MC-NMF), channel, demo service done, live calls work
M4 — nns, security (NegotiateStream) done, authenticated calls work
M5 — WSDL → Python codegen done, generated clients work

All five milestones are complete: pynettcp reads a service's WSDL, generates a typed client, and calls it over net.tcp — unsecured or with Windows authentication, encrypted and signed over Kerberos/NTLM.

Usage

Generate a client from the service's metadata — this is the svcutil replacement:

python -m pynettcp.codegen net.tcp://host:8899/Demo/mex -o demo_client.py
python -m pynettcp.codegen http://host:8101/Svc?wsdl   -o svc_client.py

Then call it:

from demo_client import DemoServiceClient, LeaveRequest, DemoContext, ServiceFault

with DemoServiceClient(security="transport", spn="host/server.corp.com") as client:
    client.echo("hello")                      # -> 'hello'
    client.add(20, 22)                        # -> 42
    client.create_leave(LeaveRequest(EmployeeId="E42", TotalDays=3))
    # -> LeaveResult(Reference='LV-E42-3', Accepted=True)

    # SOAP headers become keyword-only arguments
    client.place_order("WIDGET-1", 5, context=DemoContext(Company="ACME"))

SOAP headers matter more than they look. They are declared in wsdl:binding rather than in the portType, so they never appear among an operation's parameters — and a service that requires one rejects every call without it. Dynamics AX's CallContext is the standard example.

examples/generated/demo_client.py is real generator output, checked in so the shape is visible without running anything.

The low-level API is still there when a service has no usable metadata:

from pynettcp import Channel, SessionStr

with Channel("net.tcp://host:8899/Demo") as channel:
    reply = channel.call(action="urn:DemoService/IDemoService/Echo", body=...)

Verified end to end against the demo service, in both security modes: metadata fetched over net.tcp MEX, generated clients calling every operation, complex types round-tripping as dataclasses, 300 KB payloads spanning many TCP segments (and, when secured, many encrypted frames), SOAP faults raised as typed exceptions, and framing faults (EndpointNotFound) for a bad endpoint path.

Scope

Supported: document/literal operations, SOAP headers, xs:sequence complex types, nested types, arrays of scalars and of complex types, enums, Guid, decimal, TimeSpan, nullable value types, anonymous inline types (WCF dictionaries), simpleType aliases, types spanning namespaces, typed faults, and both security modes.

Not supported: xs:choice, xs:any, inheritance via complexContent, and xsi:type polymorphism — these raise UnsupportedSchema naming the construct, rather than generating a client that fails later against the server.

Typed faults come back as the fault's own contract type:

try:
    client.validate_order(order)
except ServiceFault as e:
    e.reason            # 'order failed validation'
    e.code              # 's:Sender'
    e.detail.Problems   # ['order has no lines', 'no shipping address']

pynettcp reproduces a real three-message WCF session byte-for-byte:

msg E1: ours= 216B wcf= 216B  identical=True
msg E2: ours= 114B wcf= 114B  identical=True
msg E3: ours= 114B wcf= 114B  identical=True

That single test exercises the session-block framing, dictionary id allocation, namespace placement and text-record selection all at once.

Design note: the preamble must not share a read with the first token

The single hardest bug in the project, recorded here because it costs days.

A secured connection ends its preamble with UpgradeRequest, then exchanges SPNEGO tokens. WCF's client pipelines those — it sends the first token without waiting for the UpgradeResponse — so it is tempting to do the same.

Don't. WCF's ServerSessionPreambleConnectionReader decodes the preamble out of whatever a single socket read returned, and after the upgrade it resumes decoding from that same buffer. If the first token shares a read with the preamble, those bytes are still sitting there and get decoded as the record that should have followed. The server lands on byte 2 of 16 01 00 00 7b and reports:

Expected record type 'PreambleEnd', found 'Version'

having already logged "The stream security upgrade was accepted successfully". So it presents as a crypto failure, and on the wire it is just a connection reset with no diagnostic. pynettcp waits for the UpgradeResponse, which turns the boundary from a race into a fact for one round trip per connection.

Finding this needed tests/rig/NmfEcho.cs — a transparent MC-NMF server that performs the same upgrade and prints every byte it decrypts. It proved the NegotiateStream layer was correct while WCF was still rejecting us, which is what narrowed the search to buffering.

Testing policy

Tests never touch a production service. Byte fixtures come from a throwaway DataContract compiled on the fly (tools/gen_session_fixtures.py), and the live tests run against a purpose-built demo WCF service in tests/rig that the suite compiles on demand, starts on a random free port and tears down afterwards — never against anyone's real ERP endpoint.

How correctness is established

Every claim about the wire format is checked against WCF's own encoder, not against a reading of the specification. tools/oracle.py drives .NET's XmlDictionaryWriter in-process through pythonnet, and the test suite asserts our bytes are identical:

$ python -m pytest -q
202 passed

pythonnet is a development dependency for this purpose only. The shipped library never loads the CLR.

The static string table is generated the same way rather than transcribed — python tools/gen_dictionary.py reflects 487 entries straight out of System.ServiceModel.ServiceModelDictionary.Version1, so a typo cannot silently corrupt every message.

Known deliberate divergence

Past roughly 169 UTF-8 bytes, .NET stops folding EndElement into the text record, because the string no longer fits the 512-byte buffer it would have to rewrite. That is an artefact of its internal buffering, not a protocol rule: 98 <len> <bytes> 01 and 99 <len> <bytes> are both valid NBFX and decode identically. We always fold, and the tests assert semantic rather than byte equality for long text.

Design note: dictionary compression is opt-in, and static ≠ session

NBFX compresses strings via a shared dictionary, but only where the caller asks for it. .NET expresses this through overloads — WriteStartElement(string, ...) writes a literal record, WriteStartElement(XmlDictionaryString, ...) writes a dictionary record — and a plain string whose text happens to appear in the table is not compressed.

There is a second, subtler rule. Whether a string goes to the static table or the session table is decided by which dictionary it came from, not by looking its text up. WCF proves it: http://www.w3.org/2001/XMLSchema-instance sits at static index 441, yet a DataContract body still interns it into the session, because the serializer's strings belong to its own dictionary.

pynettcp models both rules with two marker types — DictStr for envelope infrastructure, SessionStr for anything from the data contract:

from pynettcp import BinaryXmlWriter, DictStr

SOAP12 = DictStr("http://www.w3.org/2003/05/soap-envelope")

w = BinaryXmlWriter()
w.start_element("s", DictStr("Envelope"), SOAP12)
w.start_element("s", DictStr("Body"), SOAP12)
w.end_element()
w.end_element()
w.getvalue()   # -> 56 02 0b 01 73 04 56 0e 01 01

Design note: session mode has a hidden prefix

Under NetTcpBinding the payload of a SizedEnvelope is not a bare NBFX document. It is preceded by the strings that message adds to the dictionary both peers build up over the connection:

<varint: byte length of block>
<varint len><UTF-8 bytes>  ... repeated
<NBFX document>

Message 1 of the fixture opens with a 103-byte block naming six strings; messages 2 and 3 open with 00 and refer to them by id. Skipping that prefix makes the server read a string length as a record type and drop the connection. Read and write dictionaries are numbered independently — SessionCodec keeps one for each direction.

Development

pip install -e ".[dev]"
python -m pytest -q
python tools/gen_dictionary.py     # regenerate the static table (needs .NET)

License

MIT.

Metadata

Release files for pynettcp 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pynettcp 0.1.0
File Size Uploaded
pynettcp-0.1.0.tar.gz 91.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pynettcp 0.1.0
File Interpreter ABI Platform
pynettcp-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 155.3 kB

Release files / pynettcp-0.1.0.tar.gz

Download URL pynettcp-0.1.0.tar.gz
Size 91.6 kB
Tags Source
SHA-256 checksum
How to use checksums
77f1e2d7a062b658ab26b71565a6a0894d37355b7152b52bc0fb1fc5f76ddeff
BLAKE2b-256 checksum
How to use checksums
3cd2fda37439e6aaccf63a1931e74aa3eb0a442b5818e3e5bfe32c9fd3a73cb1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.0

Release files / pynettcp-0.1.0-py3-none-any.whl

Download URL pynettcp-0.1.0-py3-none-any.whl
Size 63.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6812e6a3605975d1b5de0636cf9533898285c01c0c1f3cbcbced2a244eea8215
BLAKE2b-256 checksum
How to use checksums
267a864f7e5b8b7c9407418406b0cd434a576d619013514c37b14253c0800c74
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.0

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page