pynettcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pynettcp-0.1.0/CHANGELOG.md +31 -0
- pynettcp-0.1.0/LICENSE +21 -0
- pynettcp-0.1.0/MANIFEST.in +7 -0
- pynettcp-0.1.0/PKG-INFO +255 -0
- pynettcp-0.1.0/README.md +220 -0
- pynettcp-0.1.0/pynettcp/__init__.py +94 -0
- pynettcp-0.1.0/pynettcp/channel.py +191 -0
- pynettcp-0.1.0/pynettcp/codegen/__init__.py +19 -0
- pynettcp-0.1.0/pynettcp/codegen/__main__.py +74 -0
- pynettcp-0.1.0/pynettcp/codegen/emit.py +412 -0
- pynettcp-0.1.0/pynettcp/codegen/metadata.py +95 -0
- pynettcp-0.1.0/pynettcp/codegen/wsdl.py +402 -0
- pynettcp-0.1.0/pynettcp/contract.py +493 -0
- pynettcp-0.1.0/pynettcp/dictionary.py +531 -0
- pynettcp-0.1.0/pynettcp/errors.py +17 -0
- pynettcp-0.1.0/pynettcp/faults.py +156 -0
- pynettcp-0.1.0/pynettcp/framing.py +392 -0
- pynettcp-0.1.0/pynettcp/nbfs.py +208 -0
- pynettcp-0.1.0/pynettcp/nbfx.py +845 -0
- pynettcp-0.1.0/pynettcp/nns.py +265 -0
- pynettcp-0.1.0/pynettcp/py.typed +0 -0
- pynettcp-0.1.0/pynettcp/security.py +159 -0
- pynettcp-0.1.0/pynettcp/soap.py +211 -0
- pynettcp-0.1.0/pynettcp/varint.py +93 -0
- pynettcp-0.1.0/pynettcp/xmltree.py +158 -0
- pynettcp-0.1.0/pynettcp.egg-info/PKG-INFO +255 -0
- pynettcp-0.1.0/pynettcp.egg-info/SOURCES.txt +48 -0
- pynettcp-0.1.0/pynettcp.egg-info/dependency_links.txt +1 -0
- pynettcp-0.1.0/pynettcp.egg-info/entry_points.txt +2 -0
- pynettcp-0.1.0/pynettcp.egg-info/requires.txt +8 -0
- pynettcp-0.1.0/pynettcp.egg-info/top_level.txt +1 -0
- pynettcp-0.1.0/pyproject.toml +63 -0
- pynettcp-0.1.0/setup.cfg +4 -0
- pynettcp-0.1.0/tests/conftest.py +143 -0
- pynettcp-0.1.0/tests/golden/demo_metadata.xml +2 -0
- pynettcp-0.1.0/tests/golden/framing_none.json +38 -0
- pynettcp-0.1.0/tests/golden/framing_transport.json +1 -0
- pynettcp-0.1.0/tests/golden/session_messages.json +31 -0
- pynettcp-0.1.0/tests/rig/DemoClient.cs +58 -0
- pynettcp-0.1.0/tests/rig/DemoService.cs +308 -0
- pynettcp-0.1.0/tests/rig/DemoService.exe.config +31 -0
- pynettcp-0.1.0/tests/rig/NmfEcho.cs +156 -0
- pynettcp-0.1.0/tests/rig/NnsEcho.cs +94 -0
- pynettcp-0.1.0/tests/test_codegen.py +473 -0
- pynettcp-0.1.0/tests/test_framing.py +246 -0
- pynettcp-0.1.0/tests/test_nbfs_session.py +245 -0
- pynettcp-0.1.0/tests/test_nbfx_differential.py +346 -0
- pynettcp-0.1.0/tests/test_rich_contracts.py +240 -0
- pynettcp-0.1.0/tests/test_security.py +213 -0
- pynettcp-0.1.0/tests/test_soap_envelope.py +178 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
This project follows [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-09-20
|
|
7
|
+
|
|
8
|
+
First public release.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `[MC-NMF]` .NET Message Framing over a plain TCP socket.
|
|
12
|
+
- `[MS-NNS]` NegotiateStream transport security via `pyspnego` (Kerberos/NTLM).
|
|
13
|
+
- `[MC-NBFX]` / `[MC-NBFS]` binary XML encoding with static and per-session
|
|
14
|
+
dictionaries, byte-identical to WCF for the verified fixtures.
|
|
15
|
+
- SOAP 1.2 + WS-Addressing 1.0 envelopes, typed SOAP fault parsing.
|
|
16
|
+
- `Channel`, the low-level duplex-session client.
|
|
17
|
+
- WSDL/MEX code generation (`pynettcp-gen`, `python -m pynettcp.codegen`).
|
|
18
|
+
- `PyNetTcpError`, a single base class for every error the library raises.
|
|
19
|
+
- `py.typed` marker, so type hints reach consumers.
|
|
20
|
+
|
|
21
|
+
### Known limitations
|
|
22
|
+
- `SecurityMode.Message`, reliable sessions, transactions, streaming,
|
|
23
|
+
duplex callbacks, MTOM and SOAP 1.1 are not implemented.
|
|
24
|
+
- `xs:choice`, `xs:any`, `complexContent` inheritance and `xsi:type`
|
|
25
|
+
polymorphism raise `UnsupportedSchema` at generation time.
|
|
26
|
+
- A `Channel` serialises concurrent calls; use one channel per thread for
|
|
27
|
+
throughput.
|
|
28
|
+
- The session dictionary is not reset at `MaxSessionSize`; a long-lived
|
|
29
|
+
channel that keeps interning new strings will raise `SessionSizeExceeded`.
|
|
30
|
+
- A WSDL describing several services (a Dynamics AX *service group*) is
|
|
31
|
+
flattened into a single generated client.
|
pynettcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fakhir Khan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pynettcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pynettcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Native Python client for WCF net.tcp:// services -- no CLR, no proxy DLL
|
|
5
|
+
Author-email: Fakhir Khan <khanfakhir748@gmail.com>
|
|
6
|
+
Maintainer-email: Fakhir Khan <khanfakhir748@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/FakhirK90/pynettcp
|
|
9
|
+
Project-URL: Repository, https://github.com/FakhirK90/pynettcp
|
|
10
|
+
Project-URL: Issues, https://github.com/FakhirK90/pynettcp/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/FakhirK90/pynettcp/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: wcf,net.tcp,NetTcpBinding,soap,MC-NMF,MC-NBFX,MC-NBFS,MS-NNS,dynamics-ax
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Topic :: Software Development :: Object Brokering
|
|
23
|
+
Classifier: Topic :: System :: Networking
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Provides-Extra: security
|
|
29
|
+
Requires-Dist: pyspnego>=0.9; extra == "security"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pynettcp[security]; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
33
|
+
Requires-Dist: pythonnet>=3.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# pynettcp
|
|
37
|
+
|
|
38
|
+
A native Python client for WCF `net.tcp://` services. No CLR, no `pythonnet`, no generated C# proxy DLL.
|
|
39
|
+
|
|
40
|
+
## Why
|
|
41
|
+
|
|
42
|
+
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:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Python -> pythonnet -> CLR -> proxy.dll (svcutil) -> WCF -> net.tcp://host:port
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
`net.tcp` is not proprietary. It is four published Microsoft Open Specifications stacked on a plain TCP socket:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Python -> socket -> net.tcp://host:port
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Layer | Specification | Module |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| Framing | [MC-NMF], [MC-NMFTB] | `framing.py` |
|
|
59
|
+
| Security | [MS-NNS] | `nns.py`, `security.py` |
|
|
60
|
+
| Encoding | [MC-NBFX], [MC-NBFS] | `nbfx.py`, `nbfs.py`, `dictionary.py` |
|
|
61
|
+
| Messaging | SOAP 1.2 + WS-Addressing | `soap.py`, `channel.py`, `faults.py` |
|
|
62
|
+
|
|
63
|
+
## Status
|
|
64
|
+
|
|
65
|
+
| Milestone | State |
|
|
66
|
+
|---|---|
|
|
67
|
+
| M0 — oracle harness, static dictionary | done |
|
|
68
|
+
| M1 — `varint`, `nbfx` encoder/decoder | done, byte-exact vs .NET |
|
|
69
|
+
| M2 — `nbfs` session dictionary, `soap` envelope | done, byte-exact vs WCF |
|
|
70
|
+
| M3 — `framing` (MC-NMF), `channel`, demo service | done, **live calls work** |
|
|
71
|
+
| M4 — `nns`, `security` (NegotiateStream) | done, **authenticated calls work** |
|
|
72
|
+
| M5 — WSDL → Python codegen | done, **generated clients work** |
|
|
73
|
+
|
|
74
|
+
All five milestones are complete: `pynettcp` reads a service's WSDL, generates a
|
|
75
|
+
typed client, and calls it over `net.tcp` — unsecured or with Windows
|
|
76
|
+
authentication, encrypted and signed over Kerberos/NTLM.
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
Generate a client from the service's metadata — this is the `svcutil` replacement:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
python -m pynettcp.codegen net.tcp://host:8899/Demo/mex -o demo_client.py
|
|
84
|
+
python -m pynettcp.codegen http://host:8101/Svc?wsdl -o svc_client.py
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Then call it:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from demo_client import DemoServiceClient, LeaveRequest, DemoContext, ServiceFault
|
|
91
|
+
|
|
92
|
+
with DemoServiceClient(security="transport", spn="host/server.corp.com") as client:
|
|
93
|
+
client.echo("hello") # -> 'hello'
|
|
94
|
+
client.add(20, 22) # -> 42
|
|
95
|
+
client.create_leave(LeaveRequest(EmployeeId="E42", TotalDays=3))
|
|
96
|
+
# -> LeaveResult(Reference='LV-E42-3', Accepted=True)
|
|
97
|
+
|
|
98
|
+
# SOAP headers become keyword-only arguments
|
|
99
|
+
client.place_order("WIDGET-1", 5, context=DemoContext(Company="ACME"))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
SOAP headers matter more than they look. They are declared in `wsdl:binding`
|
|
103
|
+
rather than in the portType, so they never appear among an operation's
|
|
104
|
+
parameters — and a service that requires one rejects every call without it.
|
|
105
|
+
Dynamics AX's `CallContext` is the standard example.
|
|
106
|
+
|
|
107
|
+
`examples/generated/demo_client.py` is real generator output, checked in so the
|
|
108
|
+
shape is visible without running anything.
|
|
109
|
+
|
|
110
|
+
The low-level API is still there when a service has no usable metadata:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from pynettcp import Channel, SessionStr
|
|
114
|
+
|
|
115
|
+
with Channel("net.tcp://host:8899/Demo") as channel:
|
|
116
|
+
reply = channel.call(action="urn:DemoService/IDemoService/Echo", body=...)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Verified end to end against the demo service, in both security modes: metadata
|
|
120
|
+
fetched over `net.tcp` MEX, generated clients calling every operation, complex
|
|
121
|
+
types round-tripping as dataclasses, 300 KB payloads spanning many TCP segments
|
|
122
|
+
(and, when secured, many encrypted frames), SOAP faults raised as typed
|
|
123
|
+
exceptions, and framing faults (`EndpointNotFound`) for a bad endpoint path.
|
|
124
|
+
|
|
125
|
+
### Scope
|
|
126
|
+
|
|
127
|
+
Supported: document/literal operations, SOAP headers, `xs:sequence` complex
|
|
128
|
+
types, nested types, arrays of scalars and of complex types, enums, `Guid`,
|
|
129
|
+
`decimal`, `TimeSpan`, nullable value types, anonymous inline types (WCF
|
|
130
|
+
dictionaries), `simpleType` aliases, types spanning namespaces, typed faults,
|
|
131
|
+
and both security modes.
|
|
132
|
+
|
|
133
|
+
Not supported: `xs:choice`, `xs:any`, inheritance via `complexContent`, and
|
|
134
|
+
`xsi:type` polymorphism — these raise `UnsupportedSchema` naming the construct,
|
|
135
|
+
rather than generating a client that fails later against the server.
|
|
136
|
+
|
|
137
|
+
Typed faults come back as the fault's own contract type:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
try:
|
|
141
|
+
client.validate_order(order)
|
|
142
|
+
except ServiceFault as e:
|
|
143
|
+
e.reason # 'order failed validation'
|
|
144
|
+
e.code # 's:Sender'
|
|
145
|
+
e.detail.Problems # ['order has no lines', 'no shipping address']
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**pynettcp reproduces a real three-message WCF session byte-for-byte:**
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
msg E1: ours= 216B wcf= 216B identical=True
|
|
152
|
+
msg E2: ours= 114B wcf= 114B identical=True
|
|
153
|
+
msg E3: ours= 114B wcf= 114B identical=True
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
That single test exercises the session-block framing, dictionary id
|
|
157
|
+
allocation, namespace placement and text-record selection all at once.
|
|
158
|
+
|
|
159
|
+
## Design note: the preamble must not share a read with the first token
|
|
160
|
+
|
|
161
|
+
The single hardest bug in the project, recorded here because it costs days.
|
|
162
|
+
|
|
163
|
+
A secured connection ends its preamble with `UpgradeRequest`, then exchanges
|
|
164
|
+
SPNEGO tokens. WCF's client pipelines those — it sends the first token without
|
|
165
|
+
waiting for the `UpgradeResponse` — so it is tempting to do the same.
|
|
166
|
+
|
|
167
|
+
Don't. WCF's `ServerSessionPreambleConnectionReader` decodes the preamble out of
|
|
168
|
+
whatever a single socket read returned, and after the upgrade it resumes
|
|
169
|
+
decoding **from that same buffer**. If the first token shares a read with the
|
|
170
|
+
preamble, those bytes are still sitting there and get decoded as the record that
|
|
171
|
+
should have followed. The server lands on byte 2 of `16 01 00 00 7b` and reports:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
Expected record type 'PreambleEnd', found 'Version'
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
having already logged *"The stream security upgrade was accepted successfully"*.
|
|
178
|
+
So it presents as a crypto failure, and on the wire it is just a connection
|
|
179
|
+
reset with no diagnostic. `pynettcp` waits for the `UpgradeResponse`, which
|
|
180
|
+
turns the boundary from a race into a fact for one round trip per connection.
|
|
181
|
+
|
|
182
|
+
Finding this needed `tests/rig/NmfEcho.cs` — a transparent MC-NMF server that
|
|
183
|
+
performs the same upgrade and prints every byte it decrypts. It proved the
|
|
184
|
+
NegotiateStream layer was correct while WCF was still rejecting us, which is
|
|
185
|
+
what narrowed the search to buffering.
|
|
186
|
+
|
|
187
|
+
## Testing policy
|
|
188
|
+
|
|
189
|
+
Tests never touch a production service. Byte fixtures come from a throwaway
|
|
190
|
+
`DataContract` compiled on the fly (`tools/gen_session_fixtures.py`), and the
|
|
191
|
+
live tests run against a purpose-built demo WCF service in `tests/rig` that the
|
|
192
|
+
suite compiles on demand, starts on a random free port and tears down
|
|
193
|
+
afterwards — never against anyone's real ERP endpoint.
|
|
194
|
+
|
|
195
|
+
## How correctness is established
|
|
196
|
+
|
|
197
|
+
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:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
$ python -m pytest -q
|
|
201
|
+
202 passed
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`pythonnet` is a *development* dependency for this purpose only. The shipped library never loads the CLR.
|
|
205
|
+
|
|
206
|
+
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.
|
|
207
|
+
|
|
208
|
+
### Known deliberate divergence
|
|
209
|
+
|
|
210
|
+
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.
|
|
211
|
+
|
|
212
|
+
## Design note: dictionary compression is opt-in, and static ≠ session
|
|
213
|
+
|
|
214
|
+
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.
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
218
|
+
`pynettcp` models both rules with two marker types — `DictStr` for envelope infrastructure, `SessionStr` for anything from the data contract:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from pynettcp import BinaryXmlWriter, DictStr
|
|
222
|
+
|
|
223
|
+
SOAP12 = DictStr("http://www.w3.org/2003/05/soap-envelope")
|
|
224
|
+
|
|
225
|
+
w = BinaryXmlWriter()
|
|
226
|
+
w.start_element("s", DictStr("Envelope"), SOAP12)
|
|
227
|
+
w.start_element("s", DictStr("Body"), SOAP12)
|
|
228
|
+
w.end_element()
|
|
229
|
+
w.end_element()
|
|
230
|
+
w.getvalue() # -> 56 02 0b 01 73 04 56 0e 01 01
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Design note: session mode has a hidden prefix
|
|
234
|
+
|
|
235
|
+
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:
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
<varint: byte length of block>
|
|
239
|
+
<varint len><UTF-8 bytes> ... repeated
|
|
240
|
+
<NBFX document>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
pip install -e ".[dev]"
|
|
249
|
+
python -m pytest -q
|
|
250
|
+
python tools/gen_dictionary.py # regenerate the static table (needs .NET)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT.
|
pynettcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# pynettcp
|
|
2
|
+
|
|
3
|
+
A native Python client for WCF `net.tcp://` services. No CLR, no `pythonnet`, no generated C# proxy DLL.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
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:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Python -> pythonnet -> CLR -> proxy.dll (svcutil) -> WCF -> net.tcp://host:port
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
`net.tcp` is not proprietary. It is four published Microsoft Open Specifications stacked on a plain TCP socket:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Python -> socket -> net.tcp://host:port
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Layer | Specification | Module |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| Framing | [MC-NMF], [MC-NMFTB] | `framing.py` |
|
|
24
|
+
| Security | [MS-NNS] | `nns.py`, `security.py` |
|
|
25
|
+
| Encoding | [MC-NBFX], [MC-NBFS] | `nbfx.py`, `nbfs.py`, `dictionary.py` |
|
|
26
|
+
| Messaging | SOAP 1.2 + WS-Addressing | `soap.py`, `channel.py`, `faults.py` |
|
|
27
|
+
|
|
28
|
+
## Status
|
|
29
|
+
|
|
30
|
+
| Milestone | State |
|
|
31
|
+
|---|---|
|
|
32
|
+
| M0 — oracle harness, static dictionary | done |
|
|
33
|
+
| M1 — `varint`, `nbfx` encoder/decoder | done, byte-exact vs .NET |
|
|
34
|
+
| M2 — `nbfs` session dictionary, `soap` envelope | done, byte-exact vs WCF |
|
|
35
|
+
| M3 — `framing` (MC-NMF), `channel`, demo service | done, **live calls work** |
|
|
36
|
+
| M4 — `nns`, `security` (NegotiateStream) | done, **authenticated calls work** |
|
|
37
|
+
| M5 — WSDL → Python codegen | done, **generated clients work** |
|
|
38
|
+
|
|
39
|
+
All five milestones are complete: `pynettcp` reads a service's WSDL, generates a
|
|
40
|
+
typed client, and calls it over `net.tcp` — unsecured or with Windows
|
|
41
|
+
authentication, encrypted and signed over Kerberos/NTLM.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Generate a client from the service's metadata — this is the `svcutil` replacement:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
python -m pynettcp.codegen net.tcp://host:8899/Demo/mex -o demo_client.py
|
|
49
|
+
python -m pynettcp.codegen http://host:8101/Svc?wsdl -o svc_client.py
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then call it:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from demo_client import DemoServiceClient, LeaveRequest, DemoContext, ServiceFault
|
|
56
|
+
|
|
57
|
+
with DemoServiceClient(security="transport", spn="host/server.corp.com") as client:
|
|
58
|
+
client.echo("hello") # -> 'hello'
|
|
59
|
+
client.add(20, 22) # -> 42
|
|
60
|
+
client.create_leave(LeaveRequest(EmployeeId="E42", TotalDays=3))
|
|
61
|
+
# -> LeaveResult(Reference='LV-E42-3', Accepted=True)
|
|
62
|
+
|
|
63
|
+
# SOAP headers become keyword-only arguments
|
|
64
|
+
client.place_order("WIDGET-1", 5, context=DemoContext(Company="ACME"))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
SOAP headers matter more than they look. They are declared in `wsdl:binding`
|
|
68
|
+
rather than in the portType, so they never appear among an operation's
|
|
69
|
+
parameters — and a service that requires one rejects every call without it.
|
|
70
|
+
Dynamics AX's `CallContext` is the standard example.
|
|
71
|
+
|
|
72
|
+
`examples/generated/demo_client.py` is real generator output, checked in so the
|
|
73
|
+
shape is visible without running anything.
|
|
74
|
+
|
|
75
|
+
The low-level API is still there when a service has no usable metadata:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from pynettcp import Channel, SessionStr
|
|
79
|
+
|
|
80
|
+
with Channel("net.tcp://host:8899/Demo") as channel:
|
|
81
|
+
reply = channel.call(action="urn:DemoService/IDemoService/Echo", body=...)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Verified end to end against the demo service, in both security modes: metadata
|
|
85
|
+
fetched over `net.tcp` MEX, generated clients calling every operation, complex
|
|
86
|
+
types round-tripping as dataclasses, 300 KB payloads spanning many TCP segments
|
|
87
|
+
(and, when secured, many encrypted frames), SOAP faults raised as typed
|
|
88
|
+
exceptions, and framing faults (`EndpointNotFound`) for a bad endpoint path.
|
|
89
|
+
|
|
90
|
+
### Scope
|
|
91
|
+
|
|
92
|
+
Supported: document/literal operations, SOAP headers, `xs:sequence` complex
|
|
93
|
+
types, nested types, arrays of scalars and of complex types, enums, `Guid`,
|
|
94
|
+
`decimal`, `TimeSpan`, nullable value types, anonymous inline types (WCF
|
|
95
|
+
dictionaries), `simpleType` aliases, types spanning namespaces, typed faults,
|
|
96
|
+
and both security modes.
|
|
97
|
+
|
|
98
|
+
Not supported: `xs:choice`, `xs:any`, inheritance via `complexContent`, and
|
|
99
|
+
`xsi:type` polymorphism — these raise `UnsupportedSchema` naming the construct,
|
|
100
|
+
rather than generating a client that fails later against the server.
|
|
101
|
+
|
|
102
|
+
Typed faults come back as the fault's own contract type:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
try:
|
|
106
|
+
client.validate_order(order)
|
|
107
|
+
except ServiceFault as e:
|
|
108
|
+
e.reason # 'order failed validation'
|
|
109
|
+
e.code # 's:Sender'
|
|
110
|
+
e.detail.Problems # ['order has no lines', 'no shipping address']
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**pynettcp reproduces a real three-message WCF session byte-for-byte:**
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
msg E1: ours= 216B wcf= 216B identical=True
|
|
117
|
+
msg E2: ours= 114B wcf= 114B identical=True
|
|
118
|
+
msg E3: ours= 114B wcf= 114B identical=True
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
That single test exercises the session-block framing, dictionary id
|
|
122
|
+
allocation, namespace placement and text-record selection all at once.
|
|
123
|
+
|
|
124
|
+
## Design note: the preamble must not share a read with the first token
|
|
125
|
+
|
|
126
|
+
The single hardest bug in the project, recorded here because it costs days.
|
|
127
|
+
|
|
128
|
+
A secured connection ends its preamble with `UpgradeRequest`, then exchanges
|
|
129
|
+
SPNEGO tokens. WCF's client pipelines those — it sends the first token without
|
|
130
|
+
waiting for the `UpgradeResponse` — so it is tempting to do the same.
|
|
131
|
+
|
|
132
|
+
Don't. WCF's `ServerSessionPreambleConnectionReader` decodes the preamble out of
|
|
133
|
+
whatever a single socket read returned, and after the upgrade it resumes
|
|
134
|
+
decoding **from that same buffer**. If the first token shares a read with the
|
|
135
|
+
preamble, those bytes are still sitting there and get decoded as the record that
|
|
136
|
+
should have followed. The server lands on byte 2 of `16 01 00 00 7b` and reports:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
Expected record type 'PreambleEnd', found 'Version'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
having already logged *"The stream security upgrade was accepted successfully"*.
|
|
143
|
+
So it presents as a crypto failure, and on the wire it is just a connection
|
|
144
|
+
reset with no diagnostic. `pynettcp` waits for the `UpgradeResponse`, which
|
|
145
|
+
turns the boundary from a race into a fact for one round trip per connection.
|
|
146
|
+
|
|
147
|
+
Finding this needed `tests/rig/NmfEcho.cs` — a transparent MC-NMF server that
|
|
148
|
+
performs the same upgrade and prints every byte it decrypts. It proved the
|
|
149
|
+
NegotiateStream layer was correct while WCF was still rejecting us, which is
|
|
150
|
+
what narrowed the search to buffering.
|
|
151
|
+
|
|
152
|
+
## Testing policy
|
|
153
|
+
|
|
154
|
+
Tests never touch a production service. Byte fixtures come from a throwaway
|
|
155
|
+
`DataContract` compiled on the fly (`tools/gen_session_fixtures.py`), and the
|
|
156
|
+
live tests run against a purpose-built demo WCF service in `tests/rig` that the
|
|
157
|
+
suite compiles on demand, starts on a random free port and tears down
|
|
158
|
+
afterwards — never against anyone's real ERP endpoint.
|
|
159
|
+
|
|
160
|
+
## How correctness is established
|
|
161
|
+
|
|
162
|
+
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:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
$ python -m pytest -q
|
|
166
|
+
202 passed
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`pythonnet` is a *development* dependency for this purpose only. The shipped library never loads the CLR.
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
### Known deliberate divergence
|
|
174
|
+
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
## Design note: dictionary compression is opt-in, and static ≠ session
|
|
178
|
+
|
|
179
|
+
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.
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
`pynettcp` models both rules with two marker types — `DictStr` for envelope infrastructure, `SessionStr` for anything from the data contract:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from pynettcp import BinaryXmlWriter, DictStr
|
|
187
|
+
|
|
188
|
+
SOAP12 = DictStr("http://www.w3.org/2003/05/soap-envelope")
|
|
189
|
+
|
|
190
|
+
w = BinaryXmlWriter()
|
|
191
|
+
w.start_element("s", DictStr("Envelope"), SOAP12)
|
|
192
|
+
w.start_element("s", DictStr("Body"), SOAP12)
|
|
193
|
+
w.end_element()
|
|
194
|
+
w.end_element()
|
|
195
|
+
w.getvalue() # -> 56 02 0b 01 73 04 56 0e 01 01
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Design note: session mode has a hidden prefix
|
|
199
|
+
|
|
200
|
+
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:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
<varint: byte length of block>
|
|
204
|
+
<varint len><UTF-8 bytes> ... repeated
|
|
205
|
+
<NBFX document>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
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.
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
pip install -e ".[dev]"
|
|
214
|
+
python -m pytest -q
|
|
215
|
+
python tools/gen_dictionary.py # regenerate the static table (needs .NET)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""pynettcp -- a native Python client for WCF ``net.tcp://`` services.
|
|
2
|
+
|
|
3
|
+
No CLR, no pythonnet, no generated C# proxy assembly. ``net.tcp`` is not a
|
|
4
|
+
proprietary black box; it is four published Microsoft Open Specifications
|
|
5
|
+
stacked on a plain TCP socket, and this package implements them:
|
|
6
|
+
|
|
7
|
+
=========== ========================== ==========================================
|
|
8
|
+
Layer Specification Module
|
|
9
|
+
=========== ========================== ==========================================
|
|
10
|
+
Framing [MC-NMF], [MC-NMFTB] ``framing``
|
|
11
|
+
Security [MS-NNS] ``nns``, ``security``
|
|
12
|
+
Encoding [MC-NBFX], [MC-NBFS] ``nbfx``, ``nbfs``, ``dictionary``
|
|
13
|
+
Messaging SOAP 1.2 + WS-Addressing ``soap``, ``channel``, ``faults``
|
|
14
|
+
=========== ========================== ==========================================
|
|
15
|
+
|
|
16
|
+
Point the generator at a service and call the result::
|
|
17
|
+
|
|
18
|
+
python -m pynettcp.codegen net.tcp://host:8899/Demo/mex -o demo_client.py
|
|
19
|
+
|
|
20
|
+
Status: complete for the supported schema subset. Real calls succeed against a
|
|
21
|
+
``NetTcpBinding`` service in both security modes, the encoding reproduces real
|
|
22
|
+
WCF session messages byte-for-byte, and generated clients drive it end to end.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
from .channel import Channel, ChannelError
|
|
30
|
+
from .errors import PyNetTcpError
|
|
31
|
+
from .framing import (
|
|
32
|
+
FramingConnection,
|
|
33
|
+
FramingError,
|
|
34
|
+
FramingFault,
|
|
35
|
+
connect,
|
|
36
|
+
connect_secure,
|
|
37
|
+
)
|
|
38
|
+
from .nns import NegotiateStream, NnsError
|
|
39
|
+
from .nbfs import SessionCodec, SessionDictionary, SessionSizeExceeded
|
|
40
|
+
from .nbfx import (
|
|
41
|
+
BinaryXmlReader,
|
|
42
|
+
BinaryXmlWriter,
|
|
43
|
+
DictStr,
|
|
44
|
+
NbfxError,
|
|
45
|
+
SessionStr,
|
|
46
|
+
StaticDictionary,
|
|
47
|
+
)
|
|
48
|
+
from .contract import ComplexType, ElementSpec, Field, TypeRegistry
|
|
49
|
+
from .soap import Envelope, parse_envelope, write_envelope
|
|
50
|
+
from .xmltree import events_to_element, events_to_string
|
|
51
|
+
from .varint import decode_multibyte_int31, encode_multibyte_int31
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"__version__",
|
|
55
|
+
# errors
|
|
56
|
+
"PyNetTcpError",
|
|
57
|
+
# channel
|
|
58
|
+
"Channel",
|
|
59
|
+
"ChannelError",
|
|
60
|
+
# framing
|
|
61
|
+
"FramingConnection",
|
|
62
|
+
"FramingError",
|
|
63
|
+
"FramingFault",
|
|
64
|
+
"connect",
|
|
65
|
+
"connect_secure",
|
|
66
|
+
# security
|
|
67
|
+
"NegotiateStream",
|
|
68
|
+
"NnsError",
|
|
69
|
+
# encoding
|
|
70
|
+
"BinaryXmlReader",
|
|
71
|
+
"BinaryXmlWriter",
|
|
72
|
+
"DictStr",
|
|
73
|
+
"SessionStr",
|
|
74
|
+
"StaticDictionary",
|
|
75
|
+
"NbfxError",
|
|
76
|
+
# session
|
|
77
|
+
"SessionCodec",
|
|
78
|
+
"SessionDictionary",
|
|
79
|
+
"SessionSizeExceeded",
|
|
80
|
+
# messaging
|
|
81
|
+
"write_envelope",
|
|
82
|
+
"parse_envelope",
|
|
83
|
+
"Envelope",
|
|
84
|
+
# contracts
|
|
85
|
+
"ElementSpec",
|
|
86
|
+
"Field",
|
|
87
|
+
"ComplexType",
|
|
88
|
+
"TypeRegistry",
|
|
89
|
+
"events_to_element",
|
|
90
|
+
"events_to_string",
|
|
91
|
+
# primitives
|
|
92
|
+
"encode_multibyte_int31",
|
|
93
|
+
"decode_multibyte_int31",
|
|
94
|
+
]
|