crate 2.0.0.dev0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
crate/client/http.py ADDED
@@ -0,0 +1,694 @@
1
+ # -*- coding: utf-8; -*-
2
+ #
3
+ # Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
4
+ # license agreements. See the NOTICE file distributed with this work for
5
+ # additional information regarding copyright ownership. Crate licenses
6
+ # this file to you under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License. You may
8
+ # obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15
+ # License for the specific language governing permissions and limitations
16
+ # under the License.
17
+ #
18
+ # However, if you have executed another commercial license agreement
19
+ # with Crate these terms will supersede the license and you may use the
20
+ # software solely pursuant to the terms of the relevant commercial agreement.
21
+
22
+
23
+ import calendar
24
+ import heapq
25
+ import io
26
+ import logging
27
+ import os
28
+ import re
29
+ import socket
30
+ import ssl
31
+ import threading
32
+ from base64 import b64encode
33
+ from datetime import date, datetime, timezone
34
+ from decimal import Decimal
35
+ from time import time
36
+ from urllib.parse import urlparse
37
+ from uuid import UUID
38
+
39
+ import orjson
40
+ import urllib3
41
+ from urllib3 import connection_from_url
42
+ from urllib3.connection import HTTPConnection
43
+ from urllib3.exceptions import (
44
+ HTTPError,
45
+ MaxRetryError,
46
+ ProtocolError,
47
+ ProxyError,
48
+ ReadTimeoutError,
49
+ SSLError,
50
+ )
51
+ from urllib3.util.retry import Retry
52
+ from verlib2 import Version
53
+
54
+ from crate.client.exceptions import (
55
+ BlobLocationNotFoundException,
56
+ ConnectionError,
57
+ DigestNotFoundException,
58
+ IntegrityError,
59
+ ProgrammingError,
60
+ )
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+
65
+ _HTTP_PAT = pat = re.compile("https?://.+", re.I)
66
+ SRV_UNAVAILABLE_STATUSES = {502, 503, 504, 509}
67
+ PRESERVE_ACTIVE_SERVER_EXCEPTIONS = {ConnectionResetError, BrokenPipeError}
68
+ SSL_ONLY_ARGS = {"ca_certs", "cert_reqs", "cert_file", "key_file"}
69
+
70
+
71
+ def super_len(o):
72
+ if hasattr(o, "__len__"):
73
+ return len(o)
74
+ if hasattr(o, "len"):
75
+ return o.len
76
+ if hasattr(o, "fileno"):
77
+ try:
78
+ fileno = o.fileno()
79
+ except io.UnsupportedOperation:
80
+ pass
81
+ else:
82
+ return os.fstat(fileno).st_size
83
+ if hasattr(o, "getvalue"):
84
+ # e.g. BytesIO, cStringIO.StringI
85
+ return len(o.getvalue())
86
+ return None
87
+
88
+
89
+ epoch_aware = datetime(1970, 1, 1, tzinfo=timezone.utc)
90
+ epoch_naive = datetime(1970, 1, 1)
91
+
92
+
93
+ def cratedb_json_encoder(obj):
94
+ """
95
+ Encoder function for orjson.
96
+
97
+ https://github.com/ijl/orjson#default
98
+ https://github.com/ijl/orjson#opt_passthrough_datetime
99
+ """
100
+ if isinstance(obj, (Decimal, UUID)):
101
+ return str(obj)
102
+ if isinstance(obj, datetime):
103
+ if obj.tzinfo is not None:
104
+ delta = obj - epoch_aware
105
+ else:
106
+ delta = obj - epoch_naive
107
+ return int(
108
+ delta.microseconds / 1000.0
109
+ + (delta.seconds + delta.days * 24 * 3600) * 1000.0
110
+ )
111
+ if isinstance(obj, date):
112
+ return calendar.timegm(obj.timetuple()) * 1000
113
+ return obj
114
+
115
+
116
+ class Server:
117
+ def __init__(self, server, **pool_kw):
118
+ socket_options = _get_socket_opts(
119
+ pool_kw.pop("socket_keepalive", False),
120
+ pool_kw.pop("socket_tcp_keepidle", None),
121
+ pool_kw.pop("socket_tcp_keepintvl", None),
122
+ pool_kw.pop("socket_tcp_keepcnt", None),
123
+ )
124
+ self.pool = connection_from_url(
125
+ server,
126
+ socket_options=socket_options,
127
+ **pool_kw,
128
+ )
129
+
130
+ def request(
131
+ self,
132
+ method,
133
+ path,
134
+ data=None,
135
+ stream=False,
136
+ headers=None,
137
+ username=None,
138
+ password=None,
139
+ schema=None,
140
+ backoff_factor=0,
141
+ **kwargs,
142
+ ):
143
+ """Send a request
144
+
145
+ Always set the Content-Length and the Content-Type header.
146
+ """
147
+ if headers is None:
148
+ headers = {}
149
+ if "Content-Length" not in headers:
150
+ length = super_len(data)
151
+ if length is not None:
152
+ headers["Content-Length"] = length
153
+
154
+ # Authentication credentials
155
+ if username is not None:
156
+ if "Authorization" not in headers and username is not None:
157
+ credentials = username + ":"
158
+ if password is not None:
159
+ credentials += password
160
+ headers["Authorization"] = "Basic %s" % b64encode(
161
+ credentials.encode("utf-8")
162
+ ).decode("utf-8")
163
+ # For backwards compatibility with Crate <= 2.2
164
+ if "X-User" not in headers:
165
+ headers["X-User"] = username
166
+
167
+ if schema is not None:
168
+ headers["Default-Schema"] = schema
169
+ headers["Accept"] = "application/json"
170
+ headers["Content-Type"] = "application/json"
171
+ kwargs["assert_same_host"] = False
172
+ kwargs["redirect"] = False
173
+ kwargs["retries"] = Retry(read=0, backoff_factor=backoff_factor)
174
+ return self.pool.urlopen(
175
+ method,
176
+ path,
177
+ body=data,
178
+ preload_content=not stream,
179
+ headers=headers,
180
+ **kwargs,
181
+ )
182
+
183
+ def close(self):
184
+ self.pool.close()
185
+
186
+
187
+ def _json_from_response(response):
188
+ try:
189
+ return orjson.loads(response.data)
190
+ except ValueError as ex:
191
+ raise ProgrammingError(
192
+ "Invalid server response of content-type '{}':\n{}".format(
193
+ response.headers.get("content-type", "unknown"),
194
+ response.data.decode("utf-8"),
195
+ )
196
+ ) from ex
197
+
198
+
199
+ def _blob_path(table, digest):
200
+ return "/_blobs/{table}/{digest}".format(table=table, digest=digest)
201
+
202
+
203
+ def _ex_to_message(ex):
204
+ return getattr(ex, "message", None) or str(ex) or repr(ex)
205
+
206
+
207
+ def _raise_for_status(response):
208
+ """
209
+ Raise `IntegrityError` exceptions for `DuplicateKeyException` errors.
210
+ """
211
+ try:
212
+ return _raise_for_status_real(response)
213
+ except ProgrammingError as ex:
214
+ if "DuplicateKeyException" in ex.message:
215
+ raise IntegrityError(ex.message, error_trace=ex.error_trace) from ex
216
+ raise
217
+
218
+
219
+ def _raise_for_status_real(response):
220
+ """make sure that only crate.exceptions are raised that are defined in
221
+ the DB-API specification"""
222
+ message = ""
223
+ if 400 <= response.status < 500:
224
+ message = "%s Client Error: %s" % (response.status, response.reason)
225
+ elif 500 <= response.status < 600:
226
+ message = "%s Server Error: %s" % (response.status, response.reason)
227
+ else:
228
+ return
229
+ if response.status == 503:
230
+ raise ConnectionError(message)
231
+ if response.headers.get("content-type", "").startswith("application/json"):
232
+ data = orjson.loads(response.data)
233
+ error = data.get("error", {})
234
+ error_trace = data.get("error_trace", None)
235
+ if "results" in data:
236
+ errors = [
237
+ res["error_message"]
238
+ for res in data["results"]
239
+ if res.get("error_message")
240
+ ]
241
+ if errors:
242
+ raise ProgrammingError("\n".join(errors))
243
+ if isinstance(error, dict):
244
+ raise ProgrammingError(
245
+ error.get("message", ""), error_trace=error_trace
246
+ )
247
+ raise ProgrammingError(error, error_trace=error_trace)
248
+ raise ProgrammingError(message)
249
+
250
+
251
+ def _server_url(server):
252
+ """
253
+ Normalizes a given server string to an url
254
+
255
+ >>> print(_server_url('a'))
256
+ http://a
257
+ >>> print(_server_url('a:9345'))
258
+ http://a:9345
259
+ >>> print(_server_url('https://a:9345'))
260
+ https://a:9345
261
+ >>> print(_server_url('https://a'))
262
+ https://a
263
+ >>> print(_server_url('demo.crate.io'))
264
+ http://demo.crate.io
265
+ """
266
+ if not _HTTP_PAT.match(server):
267
+ server = "http://%s" % server
268
+ parsed = urlparse(server)
269
+ url = "%s://%s" % (parsed.scheme, parsed.netloc)
270
+ return url
271
+
272
+
273
+ def _to_server_list(servers):
274
+ if isinstance(servers, str):
275
+ servers = servers.split()
276
+ return [_server_url(s) for s in servers]
277
+
278
+
279
+ def _pool_kw_args(
280
+ verify_ssl_cert,
281
+ ca_cert,
282
+ client_cert,
283
+ client_key,
284
+ timeout=None,
285
+ pool_size=None,
286
+ ):
287
+ ca_cert = ca_cert or os.environ.get("REQUESTS_CA_BUNDLE", None)
288
+ if ca_cert and not os.path.exists(ca_cert):
289
+ # Sanity check
290
+ raise IOError('CA bundle file "{}" does not exist.'.format(ca_cert))
291
+
292
+ kw = {
293
+ "ca_certs": ca_cert,
294
+ "cert_reqs": ssl.CERT_REQUIRED if verify_ssl_cert else ssl.CERT_NONE,
295
+ "cert_file": client_cert,
296
+ "key_file": client_key,
297
+ }
298
+ if timeout is not None:
299
+ if isinstance(timeout, str):
300
+ timeout = float(timeout)
301
+ kw["timeout"] = timeout
302
+ if pool_size is not None:
303
+ kw["maxsize"] = int(pool_size)
304
+ return kw
305
+
306
+
307
+ def _remove_certs_for_non_https(server, kwargs):
308
+ if server.lower().startswith("https"):
309
+ return kwargs
310
+ used_ssl_args = SSL_ONLY_ARGS & set(kwargs.keys())
311
+ if used_ssl_args:
312
+ kwargs = kwargs.copy()
313
+ for arg in used_ssl_args:
314
+ kwargs.pop(arg)
315
+ return kwargs
316
+
317
+
318
+ def _update_pool_kwargs_for_ssl_minimum_version(server, kwargs):
319
+ """
320
+ On urllib3 v2, re-add support for TLS 1.0 and TLS 1.1.
321
+
322
+ https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html#https-requires-tls-1-2
323
+ """
324
+ if Version(urllib3.__version__) >= Version("2"):
325
+ from urllib3.util import parse_url
326
+
327
+ scheme, _, host, port, *_ = parse_url(server)
328
+ if scheme == "https":
329
+ kwargs["ssl_minimum_version"] = ssl.TLSVersion.MINIMUM_SUPPORTED
330
+
331
+
332
+ def _create_sql_payload(stmt, args, bulk_args):
333
+ if not isinstance(stmt, str):
334
+ raise ValueError("stmt is not a string")
335
+ if args and bulk_args:
336
+ raise ValueError("Cannot provide both: args and bulk_args")
337
+
338
+ data = {"stmt": stmt}
339
+ if args:
340
+ data["args"] = args
341
+ if bulk_args:
342
+ data["bulk_args"] = bulk_args
343
+ return orjson.dumps(
344
+ data,
345
+ default=cratedb_json_encoder,
346
+ option=orjson.OPT_PASSTHROUGH_DATETIME,
347
+ )
348
+
349
+
350
+ def _get_socket_opts(
351
+ keepalive=True, tcp_keepidle=None, tcp_keepintvl=None, tcp_keepcnt=None
352
+ ):
353
+ """
354
+ Return an optional list of socket options for urllib3's HTTPConnection
355
+ constructor.
356
+ """
357
+ if not keepalive:
358
+ return None
359
+
360
+ # always use TCP keepalive
361
+ opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
362
+
363
+ # hasattr check because some options depend on system capabilities
364
+ # see https://docs.python.org/3/library/socket.html#socket.SOMAXCONN
365
+ if hasattr(socket, "TCP_KEEPIDLE") and tcp_keepidle is not None:
366
+ opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, tcp_keepidle))
367
+ if hasattr(socket, "TCP_KEEPINTVL") and tcp_keepintvl is not None:
368
+ opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, tcp_keepintvl))
369
+ if hasattr(socket, "TCP_KEEPCNT") and tcp_keepcnt is not None:
370
+ opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, tcp_keepcnt))
371
+
372
+ # additionally use urllib3's default socket options
373
+ return list(HTTPConnection.default_socket_options) + opts
374
+
375
+
376
+ class Client:
377
+ """
378
+ Crate connection client using CrateDB's HTTP API.
379
+ """
380
+
381
+ SQL_PATH = "/_sql?types=true"
382
+ """Crate URI path for issuing SQL statements."""
383
+
384
+ retry_interval = 30
385
+ """Retry interval for failed servers in seconds."""
386
+
387
+ default_server = "http://127.0.0.1:4200"
388
+ """Default server to use if no servers are given on instantiation."""
389
+
390
+ def __init__(
391
+ self,
392
+ servers=None,
393
+ timeout=None,
394
+ backoff_factor=0,
395
+ verify_ssl_cert=True,
396
+ ca_cert=None,
397
+ error_trace=False,
398
+ cert_file=None,
399
+ key_file=None,
400
+ ssl_relax_minimum_version=False,
401
+ username=None,
402
+ password=None,
403
+ schema=None,
404
+ pool_size=None,
405
+ socket_keepalive=True,
406
+ socket_tcp_keepidle=None,
407
+ socket_tcp_keepintvl=None,
408
+ socket_tcp_keepcnt=None,
409
+ ):
410
+ if not servers:
411
+ servers = [self.default_server]
412
+ else:
413
+ servers = _to_server_list(servers)
414
+
415
+ # Try to derive credentials from first server argument if not
416
+ # explicitly given.
417
+ if servers and not username:
418
+ try:
419
+ url = urlparse(servers[0])
420
+ if url.username is not None:
421
+ username = url.username
422
+ if url.password is not None:
423
+ password = url.password
424
+ except Exception as ex:
425
+ logger.warning(
426
+ "Unable to decode credentials from database "
427
+ "URI, so connecting to CrateDB without "
428
+ "authentication: {ex}".format(ex=ex)
429
+ )
430
+
431
+ self._active_servers = servers
432
+ self._inactive_servers = []
433
+ pool_kw = _pool_kw_args(
434
+ verify_ssl_cert,
435
+ ca_cert,
436
+ cert_file,
437
+ key_file,
438
+ timeout,
439
+ pool_size,
440
+ )
441
+ pool_kw.update(
442
+ {
443
+ "socket_keepalive": socket_keepalive,
444
+ "socket_tcp_keepidle": socket_tcp_keepidle,
445
+ "socket_tcp_keepintvl": socket_tcp_keepintvl,
446
+ "socket_tcp_keepcnt": socket_tcp_keepcnt,
447
+ }
448
+ )
449
+ self.ssl_relax_minimum_version = ssl_relax_minimum_version
450
+ self.backoff_factor = backoff_factor
451
+ self.server_pool = {}
452
+ self._update_server_pool(servers, **pool_kw)
453
+ self._pool_kw = pool_kw
454
+ self._lock = threading.RLock()
455
+ self._local = threading.local()
456
+ self.username = username
457
+ self.password = password
458
+ self.schema = schema
459
+
460
+ self.path = self.SQL_PATH
461
+ if error_trace:
462
+ self.path += "&error_trace=true"
463
+
464
+ def close(self):
465
+ for server in self.server_pool.values():
466
+ server.close()
467
+
468
+ def _create_server(self, server, **pool_kw):
469
+ kwargs = _remove_certs_for_non_https(server, pool_kw)
470
+ # After updating to urllib3 v2, optionally retain support
471
+ # for TLS 1.0 and TLS 1.1, in order to support connectivity
472
+ # to older versions of CrateDB.
473
+ if self.ssl_relax_minimum_version:
474
+ _update_pool_kwargs_for_ssl_minimum_version(server, kwargs)
475
+ self.server_pool[server] = Server(server, **kwargs)
476
+
477
+ def _update_server_pool(self, servers, **pool_kw):
478
+ for server in servers:
479
+ self._create_server(server, **pool_kw)
480
+
481
+ def sql(self, stmt, parameters=None, bulk_parameters=None):
482
+ """
483
+ Execute SQL stmt against the crate server.
484
+ """
485
+ if stmt is None:
486
+ return None
487
+
488
+ data = _create_sql_payload(stmt, parameters, bulk_parameters)
489
+ logger.debug("Sending request to %s with payload: %s", self.path, data)
490
+ content = self._json_request("POST", self.path, data=data)
491
+ logger.debug("JSON response for stmt(%s): %s", stmt, content)
492
+
493
+ return content
494
+
495
+ def server_infos(self, server):
496
+ response = self._request("GET", "/", server=server)
497
+ _raise_for_status(response)
498
+ content = _json_from_response(response)
499
+ node_name = content.get("name")
500
+ node_version = content.get("version", {}).get("number", "0.0.0")
501
+ return server, node_name, node_version
502
+
503
+ def blob_put(self, table, digest, data) -> bool:
504
+ """
505
+ Stores the contents of the file like @data object in a blob under the
506
+ given table and digest.
507
+ """
508
+ response = self._request("PUT", _blob_path(table, digest), data=data)
509
+ if response.status == 201:
510
+ # blob created
511
+ return True
512
+ if response.status == 409:
513
+ # blob exists
514
+ return False
515
+ if response.status in (400, 404):
516
+ raise BlobLocationNotFoundException(table, digest)
517
+ _raise_for_status(response)
518
+ return False
519
+
520
+ def blob_del(self, table, digest) -> bool:
521
+ """
522
+ Deletes the blob with given digest under the given table.
523
+ """
524
+ response = self._request("DELETE", _blob_path(table, digest))
525
+ if response.status == 204:
526
+ return True
527
+ if response.status == 404:
528
+ return False
529
+ _raise_for_status(response)
530
+ return False
531
+
532
+ def blob_get(self, table, digest, chunk_size=1024 * 128):
533
+ """
534
+ Returns a file like object representing the contents of the blob
535
+ with the given digest.
536
+ """
537
+ response = self._request("GET", _blob_path(table, digest), stream=True)
538
+ if response.status == 404:
539
+ raise DigestNotFoundException(table, digest)
540
+ _raise_for_status(response)
541
+ return response.stream(amt=chunk_size)
542
+
543
+ def blob_exists(self, table, digest) -> bool:
544
+ """
545
+ Returns true if the blob with the given digest exists
546
+ under the given table.
547
+ """
548
+ response = self._request("HEAD", _blob_path(table, digest))
549
+ if response.status == 200:
550
+ return True
551
+ elif response.status == 404:
552
+ return False
553
+ _raise_for_status(response)
554
+ return False
555
+
556
+ def _add_server(self, server):
557
+ with self._lock:
558
+ if server not in self.server_pool:
559
+ self._create_server(server, **self._pool_kw)
560
+
561
+ def _request(self, method, path, server=None, **kwargs):
562
+ """Execute a request to the cluster
563
+
564
+ A server is selected from the server pool.
565
+ """
566
+ while True:
567
+ next_server = server or self._get_server()
568
+ try:
569
+ response = self.server_pool[next_server].request(
570
+ method,
571
+ path,
572
+ username=self.username,
573
+ password=self.password,
574
+ backoff_factor=self.backoff_factor,
575
+ schema=self.schema,
576
+ **kwargs,
577
+ )
578
+ redirect_location = response.get_redirect_location()
579
+ if redirect_location and 300 <= response.status <= 308:
580
+ redirect_server = _server_url(redirect_location)
581
+ self._add_server(redirect_server)
582
+ return self._request(
583
+ method, path, server=redirect_server, **kwargs
584
+ )
585
+ if not server and response.status in SRV_UNAVAILABLE_STATUSES:
586
+ with self._lock:
587
+ # drop server from active ones
588
+ self._drop_server(next_server, response.reason)
589
+ else:
590
+ return response
591
+ except (
592
+ MaxRetryError,
593
+ ReadTimeoutError,
594
+ SSLError,
595
+ HTTPError,
596
+ ProxyError,
597
+ ) as ex:
598
+ ex_message = _ex_to_message(ex)
599
+ if server:
600
+ raise ConnectionError(
601
+ "Server not available, exception: %s" % ex_message
602
+ ) from ex
603
+ preserve_server = False
604
+ if isinstance(ex, ProtocolError):
605
+ preserve_server = any(
606
+ t in [type(arg) for arg in ex.args]
607
+ for t in PRESERVE_ACTIVE_SERVER_EXCEPTIONS
608
+ )
609
+ if not preserve_server:
610
+ with self._lock:
611
+ # drop server from active ones
612
+ self._drop_server(next_server, ex_message)
613
+ except Exception as e:
614
+ raise ProgrammingError(_ex_to_message(e)) from e
615
+
616
+ def _json_request(self, method, path, data):
617
+ """
618
+ Issue request against the crate HTTP API.
619
+ """
620
+
621
+ response = self._request(method, path, data=data)
622
+ _raise_for_status(response)
623
+ if len(response.data) > 0:
624
+ return _json_from_response(response)
625
+ return response.data
626
+
627
+ def _get_server(self):
628
+ """
629
+ Get server to use for request.
630
+ Also process inactive server list, re-add them after given interval.
631
+ """
632
+ with self._lock:
633
+ inactive_server_count = len(self._inactive_servers)
634
+ for _ in range(inactive_server_count):
635
+ try:
636
+ ts, server, message = heapq.heappop(self._inactive_servers)
637
+ except IndexError:
638
+ pass
639
+ else:
640
+ if (ts + self.retry_interval) > time():
641
+ # Not yet, put it back
642
+ heapq.heappush(
643
+ self._inactive_servers, (ts, server, message)
644
+ )
645
+ else:
646
+ self._active_servers.append(server)
647
+ logger.warning(
648
+ "Restored server %s into active pool", server
649
+ )
650
+
651
+ # if none is old enough, use oldest
652
+ if not self._active_servers:
653
+ ts, server, message = heapq.heappop(self._inactive_servers)
654
+ self._active_servers.append(server)
655
+ logger.info("Restored server %s into active pool", server)
656
+
657
+ server = self._active_servers[0]
658
+ self._roundrobin()
659
+
660
+ return server
661
+
662
+ @property
663
+ def active_servers(self):
664
+ """get the active servers for this client"""
665
+ with self._lock:
666
+ return list(self._active_servers)
667
+
668
+ def _drop_server(self, server, message):
669
+ """
670
+ Drop server from active list and adds it to the inactive ones.
671
+ """
672
+ try:
673
+ self._active_servers.remove(server)
674
+ except ValueError:
675
+ pass
676
+ else:
677
+ heapq.heappush(self._inactive_servers, (time(), server, message))
678
+ logger.warning("Removed server %s from active pool", server)
679
+
680
+ # if this is the last server raise exception, otherwise try next
681
+ if not self._active_servers:
682
+ raise ConnectionError(
683
+ ("No more Servers available, exception from last server: %s")
684
+ % message
685
+ )
686
+
687
+ def _roundrobin(self):
688
+ """
689
+ Very simple round-robin implementation
690
+ """
691
+ self._active_servers.append(self._active_servers.pop(0))
692
+
693
+ def __repr__(self):
694
+ return "<Client {0}>".format(str(self._active_servers))
File without changes