python-snap7 3.0.0__py3-none-any.whl
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.
- python_snap7-3.0.0.dist-info/METADATA +94 -0
- python_snap7-3.0.0.dist-info/RECORD +22 -0
- python_snap7-3.0.0.dist-info/WHEEL +5 -0
- python_snap7-3.0.0.dist-info/entry_points.txt +2 -0
- python_snap7-3.0.0.dist-info/licenses/LICENSE +20 -0
- python_snap7-3.0.0.dist-info/top_level.txt +1 -0
- snap7/__init__.py +34 -0
- snap7/client.py +1923 -0
- snap7/connection.py +456 -0
- snap7/datatypes.py +312 -0
- snap7/error.py +444 -0
- snap7/logo.py +222 -0
- snap7/partner.py +680 -0
- snap7/py.typed +0 -0
- snap7/s7protocol.py +1509 -0
- snap7/server/__init__.py +2681 -0
- snap7/server/__main__.py +41 -0
- snap7/type.py +391 -0
- snap7/util/__init__.py +108 -0
- snap7/util/db.py +789 -0
- snap7/util/getters.py +766 -0
- snap7/util/setters.py +733 -0
snap7/connection.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ISO on TCP connection management (RFC 1006).
|
|
3
|
+
|
|
4
|
+
Implements TPKT (Transport Service on top of TCP) and COTP (Connection Oriented
|
|
5
|
+
Transport Protocol) layers for S7 communication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
import struct
|
|
10
|
+
import logging
|
|
11
|
+
from enum import IntEnum
|
|
12
|
+
from typing import Optional, Type
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
|
|
15
|
+
from .error import S7ConnectionError, S7TimeoutError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TPDUSize(IntEnum):
|
|
19
|
+
"""TPDU sizes per ISO 8073 / RFC 905.
|
|
20
|
+
|
|
21
|
+
The value is the exponent: actual size = 2^value bytes.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
S_128 = 0x07
|
|
25
|
+
S_256 = 0x08
|
|
26
|
+
S_512 = 0x09
|
|
27
|
+
S_1024 = 0x0A
|
|
28
|
+
S_2048 = 0x0B
|
|
29
|
+
S_4096 = 0x0C
|
|
30
|
+
S_8192 = 0x0D
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ISOTCPConnection:
|
|
37
|
+
"""
|
|
38
|
+
ISO on TCP connection implementation.
|
|
39
|
+
|
|
40
|
+
Handles the transport layer for S7 communication including:
|
|
41
|
+
- TCP socket management
|
|
42
|
+
- TPKT framing (RFC 1006)
|
|
43
|
+
- COTP connection setup and data transfer
|
|
44
|
+
- PDU size negotiation
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
# COTP PDU types
|
|
48
|
+
COTP_CR = 0xE0 # Connection Request
|
|
49
|
+
COTP_CC = 0xD0 # Connection Confirm
|
|
50
|
+
COTP_DR = 0x80 # Disconnect Request
|
|
51
|
+
COTP_DC = 0xC0 # Disconnect Confirm
|
|
52
|
+
COTP_DT = 0xF0 # Data Transfer
|
|
53
|
+
COTP_ED = 0x10 # Expedited Data
|
|
54
|
+
COTP_AK = 0x60 # Data Acknowledgment
|
|
55
|
+
COTP_EA = 0x20 # Expedited Acknowledgment
|
|
56
|
+
COTP_RJ = 0x50 # Reject
|
|
57
|
+
COTP_ER = 0x70 # Error
|
|
58
|
+
|
|
59
|
+
# COTP parameter codes (ISO 8073)
|
|
60
|
+
COTP_PARAM_PDU_SIZE = 0xC0
|
|
61
|
+
COTP_PARAM_CALLING_TSAP = 0xC1
|
|
62
|
+
COTP_PARAM_CALLED_TSAP = 0xC2
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
host: str,
|
|
67
|
+
port: int = 102,
|
|
68
|
+
local_tsap: int = 0x0100,
|
|
69
|
+
remote_tsap: int = 0x0102,
|
|
70
|
+
tpdu_size: TPDUSize = TPDUSize.S_1024,
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Initialize ISO TCP connection.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
host: Target PLC IP address
|
|
77
|
+
port: TCP port (default 102 for S7)
|
|
78
|
+
local_tsap: Local Transport Service Access Point
|
|
79
|
+
remote_tsap: Remote Transport Service Access Point
|
|
80
|
+
tpdu_size: TPDU size to request during COTP negotiation
|
|
81
|
+
"""
|
|
82
|
+
self.host = host
|
|
83
|
+
self.port = port
|
|
84
|
+
self.local_tsap = local_tsap
|
|
85
|
+
self.remote_tsap = remote_tsap
|
|
86
|
+
self.tpdu_size = tpdu_size
|
|
87
|
+
self.socket: Optional[socket.socket] = None
|
|
88
|
+
self.connected = False
|
|
89
|
+
self.pdu_size = 240 # Default PDU size, negotiated during connection
|
|
90
|
+
self.timeout = 5.0 # Default timeout in seconds
|
|
91
|
+
|
|
92
|
+
# Connection parameters
|
|
93
|
+
self.src_ref = 0x0001 # Source reference
|
|
94
|
+
self.dst_ref = 0x0000 # Destination reference (assigned by peer)
|
|
95
|
+
|
|
96
|
+
def connect(self, timeout: float = 5.0) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Establish ISO on TCP connection.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
timeout: Connection timeout in seconds
|
|
102
|
+
"""
|
|
103
|
+
self.timeout = timeout
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# Step 1: TCP connection
|
|
107
|
+
self._tcp_connect()
|
|
108
|
+
|
|
109
|
+
# Step 2: ISO connection (COTP handshake)
|
|
110
|
+
self._iso_connect()
|
|
111
|
+
|
|
112
|
+
self.connected = True
|
|
113
|
+
logger.info(f"Connected to {self.host}:{self.port}, PDU size: {self.pdu_size}")
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
self.disconnect()
|
|
117
|
+
if isinstance(e, (S7ConnectionError, S7TimeoutError)):
|
|
118
|
+
raise
|
|
119
|
+
else:
|
|
120
|
+
raise S7ConnectionError(f"Connection failed: {e}")
|
|
121
|
+
|
|
122
|
+
def disconnect(self) -> None:
|
|
123
|
+
"""Disconnect from S7 device."""
|
|
124
|
+
if self.socket:
|
|
125
|
+
try:
|
|
126
|
+
if self.connected:
|
|
127
|
+
# Send COTP disconnect request
|
|
128
|
+
self._send_cotp_disconnect()
|
|
129
|
+
self.socket.close()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass # Ignore errors during disconnect
|
|
132
|
+
finally:
|
|
133
|
+
self.socket = None
|
|
134
|
+
self.connected = False
|
|
135
|
+
logger.info(f"Disconnected from {self.host}:{self.port}")
|
|
136
|
+
|
|
137
|
+
def send_data(self, data: bytes) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Send data over ISO connection.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
data: S7 PDU data to send
|
|
143
|
+
"""
|
|
144
|
+
if not self.connected or self.socket is None:
|
|
145
|
+
raise S7ConnectionError("Not connected")
|
|
146
|
+
|
|
147
|
+
# Wrap data in COTP Data Transfer PDU
|
|
148
|
+
cotp_data = self._build_cotp_dt(data)
|
|
149
|
+
|
|
150
|
+
# Wrap in TPKT frame
|
|
151
|
+
tpkt_frame = self._build_tpkt(cotp_data)
|
|
152
|
+
|
|
153
|
+
# Send over TCP
|
|
154
|
+
try:
|
|
155
|
+
self.socket.sendall(tpkt_frame)
|
|
156
|
+
logger.debug(f"Sent {len(tpkt_frame)} bytes")
|
|
157
|
+
except socket.error as e:
|
|
158
|
+
self.connected = False
|
|
159
|
+
raise S7ConnectionError(f"Send failed: {e}")
|
|
160
|
+
|
|
161
|
+
def receive_data(self) -> bytes:
|
|
162
|
+
"""
|
|
163
|
+
Receive data from ISO connection.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
S7 PDU data
|
|
167
|
+
"""
|
|
168
|
+
if not self.connected:
|
|
169
|
+
raise S7ConnectionError("Not connected")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# Receive TPKT header (4 bytes)
|
|
173
|
+
tpkt_header = self._recv_exact(4)
|
|
174
|
+
|
|
175
|
+
# Parse TPKT header
|
|
176
|
+
version, reserved, length = struct.unpack(">BBH", tpkt_header)
|
|
177
|
+
|
|
178
|
+
if version != 3:
|
|
179
|
+
raise S7ConnectionError(f"Invalid TPKT version: {version}")
|
|
180
|
+
|
|
181
|
+
# Receive remaining data
|
|
182
|
+
remaining = length - 4
|
|
183
|
+
if remaining <= 0:
|
|
184
|
+
raise S7ConnectionError("Invalid TPKT length")
|
|
185
|
+
|
|
186
|
+
payload = self._recv_exact(remaining)
|
|
187
|
+
|
|
188
|
+
# Parse COTP header and extract data
|
|
189
|
+
return self._parse_cotp_data(payload)
|
|
190
|
+
|
|
191
|
+
except socket.timeout:
|
|
192
|
+
self.connected = False
|
|
193
|
+
raise S7TimeoutError("Receive timeout")
|
|
194
|
+
except socket.error as e:
|
|
195
|
+
self.connected = False
|
|
196
|
+
raise S7ConnectionError(f"Receive failed: {e}")
|
|
197
|
+
|
|
198
|
+
def _tcp_connect(self) -> None:
|
|
199
|
+
"""Establish TCP connection."""
|
|
200
|
+
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
201
|
+
self.socket.settimeout(self.timeout)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
self.socket.connect((self.host, self.port))
|
|
205
|
+
logger.debug(f"TCP connected to {self.host}:{self.port}")
|
|
206
|
+
except socket.error as e:
|
|
207
|
+
raise S7ConnectionError(f"TCP connection failed: {e}")
|
|
208
|
+
|
|
209
|
+
def _iso_connect(self) -> None:
|
|
210
|
+
"""Establish ISO connection using COTP handshake."""
|
|
211
|
+
if self.socket is None:
|
|
212
|
+
raise S7ConnectionError("Socket not initialized")
|
|
213
|
+
|
|
214
|
+
# Send Connection Request
|
|
215
|
+
cr_pdu = self._build_cotp_cr()
|
|
216
|
+
tpkt_frame = self._build_tpkt(cr_pdu)
|
|
217
|
+
|
|
218
|
+
self.socket.sendall(tpkt_frame)
|
|
219
|
+
logger.debug("Sent COTP Connection Request")
|
|
220
|
+
|
|
221
|
+
# Receive Connection Confirm
|
|
222
|
+
tpkt_header = self._recv_exact(4)
|
|
223
|
+
version, reserved, length = struct.unpack(">BBH", tpkt_header)
|
|
224
|
+
|
|
225
|
+
if version != 3:
|
|
226
|
+
raise S7ConnectionError(f"Invalid TPKT version in response: {version}")
|
|
227
|
+
|
|
228
|
+
payload = self._recv_exact(length - 4)
|
|
229
|
+
self._parse_cotp_cc(payload)
|
|
230
|
+
|
|
231
|
+
logger.debug("Received COTP Connection Confirm")
|
|
232
|
+
|
|
233
|
+
def _build_tpkt(self, payload: bytes) -> bytes:
|
|
234
|
+
"""
|
|
235
|
+
Build TPKT frame.
|
|
236
|
+
|
|
237
|
+
TPKT Header (4 bytes):
|
|
238
|
+
- Version (1 byte): Always 3
|
|
239
|
+
- Reserved (1 byte): Always 0
|
|
240
|
+
- Length (2 bytes): Total frame length including header
|
|
241
|
+
"""
|
|
242
|
+
length = len(payload) + 4
|
|
243
|
+
return struct.pack(">BBH", 3, 0, length) + payload
|
|
244
|
+
|
|
245
|
+
def _build_cotp_cr(self) -> bytes:
|
|
246
|
+
"""
|
|
247
|
+
Build COTP Connection Request PDU.
|
|
248
|
+
|
|
249
|
+
COTP CR format:
|
|
250
|
+
- PDU Length: Length of COTP header (excluding this byte)
|
|
251
|
+
- PDU Type: 0xE0 (Connection Request)
|
|
252
|
+
- Destination Reference: 2 bytes
|
|
253
|
+
- Source Reference: 2 bytes
|
|
254
|
+
- Class/Option: 1 byte
|
|
255
|
+
- Parameters: Variable length
|
|
256
|
+
"""
|
|
257
|
+
# Basic COTP CR without parameters
|
|
258
|
+
base_pdu = struct.pack(
|
|
259
|
+
">BBHHB",
|
|
260
|
+
6, # PDU length (header without parameters)
|
|
261
|
+
self.COTP_CR, # PDU type
|
|
262
|
+
0x0000, # Destination reference (0 for CR)
|
|
263
|
+
self.src_ref, # Source reference
|
|
264
|
+
0x00, # Class/option (Class 0, no extended formats)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Add TSAP parameters
|
|
268
|
+
tsap_length = 2 # TSAP values are 2 bytes (unsigned short)
|
|
269
|
+
# Calling TSAP (local)
|
|
270
|
+
calling_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLING_TSAP, tsap_length, self.local_tsap)
|
|
271
|
+
# Called TSAP (remote)
|
|
272
|
+
called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, tsap_length, self.remote_tsap)
|
|
273
|
+
# PDU Size parameter (ISO 8073 code, e.g. 0x0A = 1024 bytes)
|
|
274
|
+
pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
|
|
275
|
+
|
|
276
|
+
parameters = calling_tsap + called_tsap + pdu_size_param
|
|
277
|
+
|
|
278
|
+
# Update PDU length to include parameters
|
|
279
|
+
total_length = 6 + len(parameters)
|
|
280
|
+
pdu = struct.pack(">B", total_length) + base_pdu[1:] + parameters
|
|
281
|
+
|
|
282
|
+
return pdu
|
|
283
|
+
|
|
284
|
+
def _parse_cotp_cc(self, data: bytes) -> None:
|
|
285
|
+
"""
|
|
286
|
+
Parse COTP Connection Confirm PDU.
|
|
287
|
+
|
|
288
|
+
Extracts destination reference and negotiated PDU size.
|
|
289
|
+
"""
|
|
290
|
+
if len(data) < 7:
|
|
291
|
+
raise S7ConnectionError("Invalid COTP CC: too short")
|
|
292
|
+
|
|
293
|
+
pdu_len, pdu_type, dst_ref, src_ref, class_opt = struct.unpack(">BBHHB", data[:7])
|
|
294
|
+
|
|
295
|
+
if pdu_type != self.COTP_CC:
|
|
296
|
+
raise S7ConnectionError(f"Expected COTP CC, got {pdu_type:#02x}")
|
|
297
|
+
|
|
298
|
+
self.dst_ref = dst_ref
|
|
299
|
+
|
|
300
|
+
# Parse parameters if present
|
|
301
|
+
if len(data) > 7:
|
|
302
|
+
self._parse_cotp_parameters(data[7:])
|
|
303
|
+
|
|
304
|
+
def _parse_cotp_parameters(self, params: bytes) -> None:
|
|
305
|
+
"""Parse COTP parameters from Connection Confirm."""
|
|
306
|
+
offset = 0
|
|
307
|
+
|
|
308
|
+
while offset < len(params):
|
|
309
|
+
if offset + 2 > len(params):
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
param_code = params[offset]
|
|
313
|
+
param_len = params[offset + 1]
|
|
314
|
+
|
|
315
|
+
if offset + 2 + param_len > len(params):
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
param_data = params[offset + 2 : offset + 2 + param_len]
|
|
319
|
+
|
|
320
|
+
if param_code == self.COTP_PARAM_PDU_SIZE:
|
|
321
|
+
# PDU Size parameter
|
|
322
|
+
if param_len == 1:
|
|
323
|
+
# ISO 8073 code: size = 2^code
|
|
324
|
+
self.pdu_size = 1 << param_data[0]
|
|
325
|
+
elif param_len == 2:
|
|
326
|
+
# Raw 2-byte value
|
|
327
|
+
self.pdu_size = struct.unpack(">H", param_data)[0]
|
|
328
|
+
logger.debug(f"Negotiated PDU size: {self.pdu_size}")
|
|
329
|
+
else:
|
|
330
|
+
logger.debug(f"Unsupported COTP parameter: code={param_code:#04x}, length={param_len}")
|
|
331
|
+
|
|
332
|
+
offset += 2 + param_len
|
|
333
|
+
|
|
334
|
+
def _build_cotp_dt(self, data: bytes) -> bytes:
|
|
335
|
+
"""
|
|
336
|
+
Build COTP Data Transfer PDU.
|
|
337
|
+
|
|
338
|
+
COTP DT format:
|
|
339
|
+
- PDU Length: 2 (fixed for DT)
|
|
340
|
+
- PDU Type: 0xF0 (Data Transfer)
|
|
341
|
+
- EOT + Number: 0x80 (End of TSDU, sequence number 0)
|
|
342
|
+
- Data: Variable length
|
|
343
|
+
"""
|
|
344
|
+
header = struct.pack(">BBB", 2, self.COTP_DT, 0x80)
|
|
345
|
+
return header + data
|
|
346
|
+
|
|
347
|
+
def _parse_cotp_data(self, cotp_pdu: bytes) -> bytes:
|
|
348
|
+
"""
|
|
349
|
+
Parse COTP Data Transfer PDU and extract S7 data.
|
|
350
|
+
"""
|
|
351
|
+
if len(cotp_pdu) < 3:
|
|
352
|
+
raise S7ConnectionError("Invalid COTP DT: too short")
|
|
353
|
+
|
|
354
|
+
pdu_len, pdu_type, eot_num = struct.unpack(">BBB", cotp_pdu[:3])
|
|
355
|
+
|
|
356
|
+
if pdu_type != self.COTP_DT:
|
|
357
|
+
raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
|
|
358
|
+
|
|
359
|
+
return cotp_pdu[3:] # Return data portion
|
|
360
|
+
|
|
361
|
+
def _send_cotp_disconnect(self) -> None:
|
|
362
|
+
"""Send COTP Disconnect Request."""
|
|
363
|
+
if self.socket is None:
|
|
364
|
+
return # Nothing to disconnect
|
|
365
|
+
|
|
366
|
+
dr_pdu = struct.pack(
|
|
367
|
+
">BBHHBB",
|
|
368
|
+
6, # PDU length
|
|
369
|
+
self.COTP_DR, # PDU type
|
|
370
|
+
self.dst_ref, # Destination reference
|
|
371
|
+
self.src_ref, # Source reference
|
|
372
|
+
0x00, # Reason (normal disconnect)
|
|
373
|
+
0x00, # Additional info
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
tpkt_frame = self._build_tpkt(dr_pdu)
|
|
377
|
+
try:
|
|
378
|
+
self.socket.sendall(tpkt_frame)
|
|
379
|
+
except socket.error:
|
|
380
|
+
pass # Ignore errors during disconnect
|
|
381
|
+
|
|
382
|
+
def _recv_exact(self, size: int) -> bytes:
|
|
383
|
+
"""
|
|
384
|
+
Receive exactly the specified number of bytes.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
size: Number of bytes to receive
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Received data
|
|
391
|
+
|
|
392
|
+
Raises:
|
|
393
|
+
S7ConnectionError: If connection is lost
|
|
394
|
+
S7TimeoutError: If timeout occurs
|
|
395
|
+
"""
|
|
396
|
+
if self.socket is None:
|
|
397
|
+
raise S7ConnectionError("Socket not initialized")
|
|
398
|
+
|
|
399
|
+
data = bytearray()
|
|
400
|
+
|
|
401
|
+
while len(data) < size:
|
|
402
|
+
try:
|
|
403
|
+
chunk = self.socket.recv(size - len(data))
|
|
404
|
+
if not chunk:
|
|
405
|
+
self.connected = False
|
|
406
|
+
raise S7ConnectionError("Connection closed by peer")
|
|
407
|
+
data.extend(chunk)
|
|
408
|
+
except socket.timeout:
|
|
409
|
+
self.connected = False
|
|
410
|
+
raise S7TimeoutError("Receive timeout")
|
|
411
|
+
except socket.error as e:
|
|
412
|
+
self.connected = False
|
|
413
|
+
raise S7ConnectionError(f"Receive error: {e}")
|
|
414
|
+
|
|
415
|
+
return bytes(data)
|
|
416
|
+
|
|
417
|
+
def check_connection(self) -> bool:
|
|
418
|
+
"""Check if the TCP connection is still alive.
|
|
419
|
+
|
|
420
|
+
Uses a non-blocking socket peek to detect broken connections.
|
|
421
|
+
"""
|
|
422
|
+
if not self.connected or self.socket is None:
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
original_timeout = self.socket.gettimeout()
|
|
427
|
+
self.socket.settimeout(0)
|
|
428
|
+
try:
|
|
429
|
+
data = self.socket.recv(1, socket.MSG_PEEK)
|
|
430
|
+
if not data:
|
|
431
|
+
self.connected = False
|
|
432
|
+
return False
|
|
433
|
+
return True
|
|
434
|
+
except BlockingIOError:
|
|
435
|
+
# No data available but connection is still alive
|
|
436
|
+
return True
|
|
437
|
+
except (socket.error, OSError):
|
|
438
|
+
self.connected = False
|
|
439
|
+
return False
|
|
440
|
+
finally:
|
|
441
|
+
self.socket.settimeout(original_timeout)
|
|
442
|
+
except Exception:
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
def __enter__(self) -> "ISOTCPConnection":
|
|
446
|
+
"""Context manager entry."""
|
|
447
|
+
return self
|
|
448
|
+
|
|
449
|
+
def __exit__(
|
|
450
|
+
self,
|
|
451
|
+
exc_type: Optional[Type[BaseException]],
|
|
452
|
+
exc_val: Optional[BaseException],
|
|
453
|
+
exc_tb: Optional[TracebackType],
|
|
454
|
+
) -> None:
|
|
455
|
+
"""Context manager exit."""
|
|
456
|
+
self.disconnect()
|