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.
Files changed (50) hide show
  1. pynettcp-0.1.0/CHANGELOG.md +31 -0
  2. pynettcp-0.1.0/LICENSE +21 -0
  3. pynettcp-0.1.0/MANIFEST.in +7 -0
  4. pynettcp-0.1.0/PKG-INFO +255 -0
  5. pynettcp-0.1.0/README.md +220 -0
  6. pynettcp-0.1.0/pynettcp/__init__.py +94 -0
  7. pynettcp-0.1.0/pynettcp/channel.py +191 -0
  8. pynettcp-0.1.0/pynettcp/codegen/__init__.py +19 -0
  9. pynettcp-0.1.0/pynettcp/codegen/__main__.py +74 -0
  10. pynettcp-0.1.0/pynettcp/codegen/emit.py +412 -0
  11. pynettcp-0.1.0/pynettcp/codegen/metadata.py +95 -0
  12. pynettcp-0.1.0/pynettcp/codegen/wsdl.py +402 -0
  13. pynettcp-0.1.0/pynettcp/contract.py +493 -0
  14. pynettcp-0.1.0/pynettcp/dictionary.py +531 -0
  15. pynettcp-0.1.0/pynettcp/errors.py +17 -0
  16. pynettcp-0.1.0/pynettcp/faults.py +156 -0
  17. pynettcp-0.1.0/pynettcp/framing.py +392 -0
  18. pynettcp-0.1.0/pynettcp/nbfs.py +208 -0
  19. pynettcp-0.1.0/pynettcp/nbfx.py +845 -0
  20. pynettcp-0.1.0/pynettcp/nns.py +265 -0
  21. pynettcp-0.1.0/pynettcp/py.typed +0 -0
  22. pynettcp-0.1.0/pynettcp/security.py +159 -0
  23. pynettcp-0.1.0/pynettcp/soap.py +211 -0
  24. pynettcp-0.1.0/pynettcp/varint.py +93 -0
  25. pynettcp-0.1.0/pynettcp/xmltree.py +158 -0
  26. pynettcp-0.1.0/pynettcp.egg-info/PKG-INFO +255 -0
  27. pynettcp-0.1.0/pynettcp.egg-info/SOURCES.txt +48 -0
  28. pynettcp-0.1.0/pynettcp.egg-info/dependency_links.txt +1 -0
  29. pynettcp-0.1.0/pynettcp.egg-info/entry_points.txt +2 -0
  30. pynettcp-0.1.0/pynettcp.egg-info/requires.txt +8 -0
  31. pynettcp-0.1.0/pynettcp.egg-info/top_level.txt +1 -0
  32. pynettcp-0.1.0/pyproject.toml +63 -0
  33. pynettcp-0.1.0/setup.cfg +4 -0
  34. pynettcp-0.1.0/tests/conftest.py +143 -0
  35. pynettcp-0.1.0/tests/golden/demo_metadata.xml +2 -0
  36. pynettcp-0.1.0/tests/golden/framing_none.json +38 -0
  37. pynettcp-0.1.0/tests/golden/framing_transport.json +1 -0
  38. pynettcp-0.1.0/tests/golden/session_messages.json +31 -0
  39. pynettcp-0.1.0/tests/rig/DemoClient.cs +58 -0
  40. pynettcp-0.1.0/tests/rig/DemoService.cs +308 -0
  41. pynettcp-0.1.0/tests/rig/DemoService.exe.config +31 -0
  42. pynettcp-0.1.0/tests/rig/NmfEcho.cs +156 -0
  43. pynettcp-0.1.0/tests/rig/NnsEcho.cs +94 -0
  44. pynettcp-0.1.0/tests/test_codegen.py +473 -0
  45. pynettcp-0.1.0/tests/test_framing.py +246 -0
  46. pynettcp-0.1.0/tests/test_nbfs_session.py +245 -0
  47. pynettcp-0.1.0/tests/test_nbfx_differential.py +346 -0
  48. pynettcp-0.1.0/tests/test_rich_contracts.py +240 -0
  49. pynettcp-0.1.0/tests/test_security.py +213 -0
  50. 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.
@@ -0,0 +1,7 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ recursive-include tests *.py
5
+ recursive-include tests/golden *.json *.xml
6
+ recursive-include tests/rig *.cs *.config
7
+ global-exclude *.pyc *.pyo *.exe *.pdb *.svclog __pycache__
@@ -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.
@@ -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
+ ]