python-tls-client 1.0.2__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.
tls_client/sessions.py ADDED
@@ -0,0 +1,518 @@
1
+ from .cffi import request, freeMemory, destroySession
2
+ from .cookies import cookiejar_from_dict, merge_cookies, extract_cookies_to_jar
3
+ from .exceptions import TLSClientExeption
4
+ from .response import build_response, Response
5
+ from .settings import ClientIdentifiers
6
+ from .structures import CaseInsensitiveDict
7
+ from .__version__ import __version__
8
+
9
+ from typing import Any, Dict, List, Optional, Union
10
+ from json import dumps, loads
11
+ import urllib.parse
12
+ import base64
13
+ import ctypes
14
+ import uuid
15
+
16
+
17
+ class Session:
18
+
19
+ def __init__(
20
+ self,
21
+ client_identifier: ClientIdentifiers = "chrome_120",
22
+ ja3_string: Optional[str] = None,
23
+ h2_settings: Optional[Dict[str, int]] = None,
24
+ h2_settings_order: Optional[List[str]] = None,
25
+ supported_signature_algorithms: Optional[List[str]] = None,
26
+ supported_delegated_credentials_algorithms: Optional[List[str]] = None,
27
+ supported_versions: Optional[List[str]] = None,
28
+ key_share_curves: Optional[List[str]] = None,
29
+ cert_compression_algo: str = None,
30
+ additional_decode: str = None,
31
+ pseudo_header_order: Optional[List[str]] = None,
32
+ connection_flow: Optional[int] = None,
33
+ priority_frames: Optional[list] = None,
34
+ header_order: Optional[List[str]] = None,
35
+ header_priority: Optional[List[str]] = None,
36
+ random_tls_extension_order: Optional = False,
37
+ force_http1: Optional = False,
38
+ catch_panics: Optional = False,
39
+ debug: Optional = False,
40
+ certificate_pinning: Optional[Dict[str, List[str]]] = None,
41
+ ) -> None:
42
+ self._session_id = str(uuid.uuid4())
43
+ # --- Standard Settings ----------------------------------------------------------------------------------------
44
+
45
+ # Case-insensitive dictionary of headers, send on each request
46
+ self.headers = CaseInsensitiveDict(
47
+ {
48
+ "User-Agent": f"tls-client/{__version__}",
49
+ "Accept-Encoding": "gzip, deflate, br",
50
+ "Accept": "*/*",
51
+ "Connection": "keep-alive",
52
+ }
53
+ )
54
+
55
+ # Example:
56
+ # {
57
+ # "http": "http://user:pass@ip:port",
58
+ # "https": "http://user:pass@ip:port"
59
+ # }
60
+ self.proxies = {}
61
+
62
+ # Dictionary of querystring data to attach to each request. The dictionary values may be lists for representing
63
+ # multivalued query parameters.
64
+ self.params = {}
65
+
66
+ # CookieJar containing all currently outstanding cookies set on this session
67
+ self.cookies = cookiejar_from_dict({})
68
+
69
+ # Timeout
70
+ self.timeout_seconds = 30
71
+
72
+ # Certificate pinning
73
+ self.certificate_pinning = certificate_pinning
74
+
75
+ # --- Advanced Settings ----------------------------------------------------------------------------------------
76
+
77
+ # Examples:
78
+ # Chrome --> chrome_103, chrome_104, chrome_105, chrome_106
79
+ # Firefox --> firefox_102, firefox_104
80
+ # Opera --> opera_89, opera_90
81
+ # Safari --> safari_15_3, safari_15_6_1, safari_16_0
82
+ # iOS --> safari_ios_15_5, safari_ios_15_6, safari_ios_16_0
83
+ # iPadOS --> safari_ios_15_6
84
+ #
85
+ # for all possible client identifiers, check out the settings.py
86
+ self.client_identifier = client_identifier
87
+
88
+ # Set JA3 --> TLSVersion, Ciphers, Extensions, EllipticCurves, EllipticCurvePointFormats
89
+ # Example:
90
+ # 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0
91
+ self.ja3_string = ja3_string
92
+
93
+ # HTTP2 Header Frame Settings
94
+ # Possible Settings:
95
+ # HEADER_TABLE_SIZE
96
+ # SETTINGS_ENABLE_PUSH
97
+ # MAX_CONCURRENT_STREAMS
98
+ # INITIAL_WINDOW_SIZE
99
+ # MAX_FRAME_SIZE
100
+ # MAX_HEADER_LIST_SIZE
101
+ #
102
+ # Example:
103
+ # {
104
+ # "HEADER_TABLE_SIZE": 65536,
105
+ # "MAX_CONCURRENT_STREAMS": 1000,
106
+ # "INITIAL_WINDOW_SIZE": 6291456,
107
+ # "MAX_HEADER_LIST_SIZE": 262144
108
+ # }
109
+ self.h2_settings = h2_settings
110
+
111
+ # HTTP2 Header Frame Settings Order
112
+ # Example:
113
+ # [
114
+ # "HEADER_TABLE_SIZE",
115
+ # "MAX_CONCURRENT_STREAMS",
116
+ # "INITIAL_WINDOW_SIZE",
117
+ # "MAX_HEADER_LIST_SIZE"
118
+ # ]
119
+ self.h2_settings_order = h2_settings_order
120
+
121
+ # Supported Signature Algorithms
122
+ # Possible Settings:
123
+ # PKCS1WithSHA256
124
+ # PKCS1WithSHA384
125
+ # PKCS1WithSHA512
126
+ # PSSWithSHA256
127
+ # PSSWithSHA384
128
+ # PSSWithSHA512
129
+ # ECDSAWithP256AndSHA256
130
+ # ECDSAWithP384AndSHA384
131
+ # ECDSAWithP521AndSHA512
132
+ # PKCS1WithSHA1
133
+ # ECDSAWithSHA1
134
+ #
135
+ # Example:
136
+ # [
137
+ # "ECDSAWithP256AndSHA256",
138
+ # "PSSWithSHA256",
139
+ # "PKCS1WithSHA256",
140
+ # "ECDSAWithP384AndSHA384",
141
+ # "PSSWithSHA384",
142
+ # "PKCS1WithSHA384",
143
+ # "PSSWithSHA512",
144
+ # "PKCS1WithSHA512",
145
+ # ]
146
+ self.supported_signature_algorithms = supported_signature_algorithms
147
+
148
+ # Supported Delegated Credentials Algorithms
149
+ # Possible Settings:
150
+ # PKCS1WithSHA256
151
+ # PKCS1WithSHA384
152
+ # PKCS1WithSHA512
153
+ # PSSWithSHA256
154
+ # PSSWithSHA384
155
+ # PSSWithSHA512
156
+ # ECDSAWithP256AndSHA256
157
+ # ECDSAWithP384AndSHA384
158
+ # ECDSAWithP521AndSHA512
159
+ # PKCS1WithSHA1
160
+ # ECDSAWithSHA1
161
+ #
162
+ # Example:
163
+ # [
164
+ # "ECDSAWithP256AndSHA256",
165
+ # "PSSWithSHA256",
166
+ # "PKCS1WithSHA256",
167
+ # "ECDSAWithP384AndSHA384",
168
+ # "PSSWithSHA384",
169
+ # "PKCS1WithSHA384",
170
+ # "PSSWithSHA512",
171
+ # "PKCS1WithSHA512",
172
+ # ]
173
+ self.supported_delegated_credentials_algorithms = supported_delegated_credentials_algorithms
174
+
175
+ # Supported Versions
176
+ # Possible Settings:
177
+ # GREASE
178
+ # 1.3
179
+ # 1.2
180
+ # 1.1
181
+ # 1.0
182
+ #
183
+ # Example:
184
+ # [
185
+ # "GREASE",
186
+ # "1.3",
187
+ # "1.2"
188
+ # ]
189
+ self.supported_versions = supported_versions
190
+
191
+ # Key Share Curves
192
+ # Possible Settings:
193
+ # GREASE
194
+ # P256
195
+ # P384
196
+ # P521
197
+ # X25519
198
+ #
199
+ # Example:
200
+ # [
201
+ # "GREASE",
202
+ # "X25519"
203
+ # ]
204
+ self.key_share_curves = key_share_curves
205
+
206
+ # Cert Compression Algorithm
207
+ # Examples: "zlib", "brotli", "zstd"
208
+ self.cert_compression_algo = cert_compression_algo
209
+
210
+ # Additional Decode
211
+ # Make sure the go code decodes the response body once explicit by provided algorithm.
212
+ # Examples: null, "gzip", "br", "deflate"
213
+ self.additional_decode = additional_decode
214
+
215
+ # Pseudo Header Order (:authority, :method, :path, :scheme)
216
+ # Example:
217
+ # [
218
+ # ":method",
219
+ # ":authority",
220
+ # ":scheme",
221
+ # ":path"
222
+ # ]
223
+ self.pseudo_header_order = pseudo_header_order
224
+
225
+ # Connection Flow / Window Size Increment
226
+ # Example:
227
+ # 15663105
228
+ self.connection_flow = connection_flow
229
+
230
+ # Example:
231
+ # [
232
+ # {
233
+ # "streamID": 3,
234
+ # "priorityParam": {
235
+ # "weight": 201,
236
+ # "streamDep": 0,
237
+ # "exclusive": false
238
+ # }
239
+ # },
240
+ # {
241
+ # "streamID": 5,
242
+ # "priorityParam": {
243
+ # "weight": 101,
244
+ # "streamDep": false,
245
+ # "exclusive": 0
246
+ # }
247
+ # }
248
+ # ]
249
+ self.priority_frames = priority_frames
250
+
251
+ # Order of your headers
252
+ # Example:
253
+ # [
254
+ # "key1",
255
+ # "key2"
256
+ # ]
257
+ self.header_order = header_order
258
+
259
+ # Header Priority
260
+ # Example:
261
+ # {
262
+ # "streamDep": 1,
263
+ # "exclusive": true,
264
+ # "weight": 1
265
+ # }
266
+ self.header_priority = header_priority
267
+
268
+ # randomize tls extension order
269
+ self.random_tls_extension_order = random_tls_extension_order
270
+
271
+ # force HTTP1
272
+ self.force_http1 = force_http1
273
+
274
+ # catch panics
275
+ # avoid the tls client to print the whole stacktrace when a panic (critical go error) happens
276
+ self.catch_panics = catch_panics
277
+
278
+ # debugging
279
+ self.debug = debug
280
+
281
+ def __enter__(self):
282
+ return self
283
+
284
+ def __exit__(self, *args):
285
+ self.close()
286
+
287
+ def close(self) -> str:
288
+ destroy_session_payload = {
289
+ "sessionId": self._session_id
290
+ }
291
+
292
+ destroy_session_response = destroySession(dumps(destroy_session_payload).encode('utf-8'))
293
+ # we dereference the pointer to a byte array
294
+ destroy_session_response_bytes = ctypes.string_at(destroy_session_response)
295
+ # convert our byte array to a string (tls client returns json)
296
+ destroy_session_response_string = destroy_session_response_bytes.decode('utf-8')
297
+ # convert response string to json
298
+ destroy_session_response_object = loads(destroy_session_response_string)
299
+
300
+ freeMemory(destroy_session_response_object['id'].encode('utf-8'))
301
+
302
+ return destroy_session_response_string
303
+
304
+ def execute_request(
305
+ self,
306
+ method: str,
307
+ url: str,
308
+ params: Optional[dict] = None, # Optional[dict[str, str]]
309
+ data: Optional[Union[str, dict]] = None,
310
+ headers: Optional[dict] = None, # Optional[dict[str, str]]
311
+ cookies: Optional[dict] = None, # Optional[dict[str, str]]
312
+ json: Optional[dict] = None, # Optional[dict]
313
+ allow_redirects: Optional[bool] = False,
314
+ insecure_skip_verify: Optional[bool] = False,
315
+ timeout_seconds: Optional[int] = None,
316
+ proxy: Optional[dict] = None # Optional[dict[str, str]]
317
+ ) -> Response:
318
+ # --- URL ------------------------------------------------------------------------------------------------------
319
+ # Prepare URL - add params to url
320
+ if params is not None:
321
+ url = f"{url}?{urllib.parse.urlencode(params, doseq=True)}"
322
+
323
+ # --- Request Body ---------------------------------------------------------------------------------------------
324
+ # Prepare request body - build request body
325
+ # Data has priority. JSON is only used if data is None.
326
+ if data is None and json is not None:
327
+ if type(json) in [dict, list]:
328
+ json = dumps(json)
329
+ request_body = json
330
+ content_type = "application/json"
331
+ elif data is not None and type(data) not in [str, bytes]:
332
+ request_body = urllib.parse.urlencode(data, doseq=True)
333
+ content_type = "application/x-www-form-urlencoded"
334
+ else:
335
+ request_body = data
336
+ content_type = None
337
+ # set content type if it isn't set
338
+ if content_type is not None and "content-type" not in self.headers:
339
+ self.headers["Content-Type"] = content_type
340
+
341
+ # --- Headers --------------------------------------------------------------------------------------------------
342
+ if self.headers is None:
343
+ headers = CaseInsensitiveDict(headers)
344
+ elif headers is None:
345
+ headers = self.headers
346
+ else:
347
+ merged_headers = CaseInsensitiveDict(self.headers)
348
+ merged_headers.update(headers)
349
+
350
+ # Remove items, where the key or value is set to None.
351
+ none_keys = [k for (k, v) in merged_headers.items() if v is None or k is None]
352
+ for key in none_keys:
353
+ del merged_headers[key]
354
+
355
+ headers = merged_headers
356
+
357
+ # --- Cookies --------------------------------------------------------------------------------------------------
358
+ cookies = cookies or {}
359
+ # Merge with session cookies
360
+ cookies = merge_cookies(self.cookies, cookies)
361
+ # turn cookie jar into dict
362
+ # in the cookie value the " gets removed, because the fhttp library in golang doesn't accept the character
363
+ request_cookies = [
364
+ {'domain': c.domain, 'expires': c.expires, 'name': c.name, 'path': c.path, 'value': c.value.replace('"', "")}
365
+ for c in cookies
366
+ ]
367
+
368
+ # --- Proxy ----------------------------------------------------------------------------------------------------
369
+ proxy = proxy or self.proxies
370
+
371
+ if type(proxy) is dict and "http" in proxy:
372
+ proxy = proxy["http"]
373
+ elif type(proxy) is str:
374
+ proxy = proxy
375
+ else:
376
+ proxy = ""
377
+
378
+ # --- Timeout --------------------------------------------------------------------------------------------------
379
+ # maximum time to wait for a response
380
+
381
+ timeout_seconds = timeout_seconds or self.timeout_seconds
382
+
383
+ # --- Certificate pinning --------------------------------------------------------------------------------------
384
+ # pins a certificate so that it restricts which certificates are considered valid
385
+
386
+ certificate_pinning = self.certificate_pinning
387
+
388
+ # --- Request --------------------------------------------------------------------------------------------------
389
+ is_byte_request = isinstance(request_body, (bytes, bytearray))
390
+ request_payload = {
391
+ "sessionId": self._session_id,
392
+ "followRedirects": allow_redirects,
393
+ "forceHttp1": self.force_http1,
394
+ "withDebug": self.debug,
395
+ "catchPanics": self.catch_panics,
396
+ "headers": dict(headers),
397
+ "headerOrder": self.header_order,
398
+ "insecureSkipVerify": insecure_skip_verify,
399
+ "isByteRequest": is_byte_request,
400
+ # Force the Go backend to return the raw response body as a base64
401
+ # data-URI. Without this flag, binary bytes > 127 get mangled by
402
+ # UTF-8 transcoding (byteReplacer U+FFFD) inside the C library,
403
+ # corrupting binary responses (protobuf, images, ...).
404
+ "isByteResponse": True,
405
+ "additionalDecode": self.additional_decode,
406
+ "proxyUrl": proxy,
407
+ "requestUrl": url,
408
+ "requestMethod": method,
409
+ "requestBody": base64.b64encode(request_body).decode() if is_byte_request else request_body,
410
+ "requestCookies": request_cookies,
411
+ "timeoutSeconds": timeout_seconds,
412
+ }
413
+ if certificate_pinning:
414
+ request_payload["certificatePinningHosts"] = certificate_pinning
415
+ if self.client_identifier is None:
416
+ request_payload["customTlsClient"] = {
417
+ "ja3String": self.ja3_string,
418
+ "h2Settings": self.h2_settings,
419
+ "h2SettingsOrder": self.h2_settings_order,
420
+ "pseudoHeaderOrder": self.pseudo_header_order,
421
+ "connectionFlow": self.connection_flow,
422
+ "priorityFrames": self.priority_frames,
423
+ "headerPriority": self.header_priority,
424
+ "certCompressionAlgo": self.cert_compression_algo,
425
+ "supportedVersions": self.supported_versions,
426
+ "supportedSignatureAlgorithms": self.supported_signature_algorithms,
427
+ "supportedDelegatedCredentialsAlgorithms": self.supported_delegated_credentials_algorithms ,
428
+ "keyShareCurves": self.key_share_curves,
429
+ }
430
+ else:
431
+ request_payload["tlsClientIdentifier"] = self.client_identifier
432
+ request_payload["withRandomTLSExtensionOrder"] = self.random_tls_extension_order
433
+
434
+ # this is a pointer to the response
435
+ response = request(dumps(request_payload).encode('utf-8'))
436
+ # dereference the pointer to a byte array
437
+ response_bytes = ctypes.string_at(response)
438
+ # convert our byte array to a string (tls client returns json)
439
+ response_string = response_bytes.decode('utf-8')
440
+ # convert response string to json
441
+ response_object = loads(response_string)
442
+ # free the memory
443
+ freeMemory(response_object['id'].encode('utf-8'))
444
+ # --- Response -------------------------------------------------------------------------------------------------
445
+ # Error handling
446
+ if response_object["status"] == 0:
447
+ raise TLSClientExeption(response_object["body"])
448
+ # Set response cookies
449
+ response_cookie_jar = extract_cookies_to_jar(
450
+ request_url=url,
451
+ request_headers=headers,
452
+ cookie_jar=cookies,
453
+ response_headers=response_object["headers"]
454
+ )
455
+ # build response class
456
+ return build_response(response_object, response_cookie_jar)
457
+
458
+ def get(
459
+ self,
460
+ url: str,
461
+ **kwargs: Any
462
+ ) -> Response:
463
+ """Sends a GET request"""
464
+ return self.execute_request(method="GET", url=url, **kwargs)
465
+
466
+ def options(
467
+ self,
468
+ url: str,
469
+ **kwargs: Any
470
+ ) -> Response:
471
+ """Sends a OPTIONS request"""
472
+ return self.execute_request(method="OPTIONS", url=url, **kwargs)
473
+
474
+ def head(
475
+ self,
476
+ url: str,
477
+ **kwargs: Any
478
+ ) -> Response:
479
+ """Sends a HEAD request"""
480
+ return self.execute_request(method="HEAD", url=url, **kwargs)
481
+
482
+ def post(
483
+ self,
484
+ url: str,
485
+ data: Optional[Union[str, dict]] = None,
486
+ json: Optional[dict] = None,
487
+ **kwargs: Any
488
+ ) -> Response:
489
+ """Sends a POST request"""
490
+ return self.execute_request(method="POST", url=url, data=data, json=json, **kwargs)
491
+
492
+ def put(
493
+ self,
494
+ url: str,
495
+ data: Optional[Union[str, dict]] = None,
496
+ json: Optional[dict] = None,
497
+ **kwargs: Any
498
+ ) -> Response:
499
+ """Sends a PUT request"""
500
+ return self.execute_request(method="PUT", url=url, data=data, json=json, **kwargs)
501
+
502
+ def patch(
503
+ self,
504
+ url: str,
505
+ data: Optional[Union[str, dict]] = None,
506
+ json: Optional[dict] = None,
507
+ **kwargs: Any
508
+ ) -> Response:
509
+ """Sends a PATCH request"""
510
+ return self.execute_request(method="PATCH", url=url, data=data, json=json, **kwargs)
511
+
512
+ def delete(
513
+ self,
514
+ url: str,
515
+ **kwargs: Any
516
+ ) -> Response:
517
+ """Sends a DELETE request"""
518
+ return self.execute_request(method="DELETE", url=url, **kwargs)
tls_client/settings.py ADDED
@@ -0,0 +1,64 @@
1
+ from typing_extensions import Literal, TypeAlias
2
+
3
+ ClientIdentifiers: TypeAlias = Literal[
4
+ # Chrome
5
+ "chrome_103",
6
+ "chrome_104",
7
+ "chrome_105",
8
+ "chrome_106",
9
+ "chrome_107",
10
+ "chrome_108",
11
+ "chrome_109",
12
+ "chrome_110",
13
+ "chrome_111",
14
+ "chrome_112",
15
+ "chrome_116_PSK",
16
+ "chrome_116_PSK_PQ",
17
+ "chrome_117",
18
+ "chrome_120",
19
+ # Safari
20
+ "safari_15_6_1",
21
+ "safari_16_0",
22
+ # iOS (Safari)
23
+ "safari_ios_15_5",
24
+ "safari_ios_15_6",
25
+ "safari_ios_16_0",
26
+ # iPadOS (Safari)
27
+ "safari_ios_15_6",
28
+ # FireFox
29
+ "firefox_102",
30
+ "firefox_104",
31
+ "firefox_105",
32
+ "firefox_106",
33
+ "firefox_108",
34
+ "firefox_110",
35
+ "firefox_117",
36
+ "firefox_120",
37
+ # Opera
38
+ "opera_89",
39
+ "opera_90",
40
+ "opera_91",
41
+ # OkHttp4
42
+ "okhttp4_android_7",
43
+ "okhttp4_android_8",
44
+ "okhttp4_android_9",
45
+ "okhttp4_android_10",
46
+ "okhttp4_android_11",
47
+ "okhttp4_android_12",
48
+ "okhttp4_android_13",
49
+ # Custom
50
+ "zalando_ios_mobile",
51
+ "zalando_android_mobile",
52
+ "nike_ios_mobile",
53
+ "nike_android_mobile",
54
+ "mms_ios",
55
+ "mms_ios_2",
56
+ "mms_ios_3",
57
+ "mesh_ios",
58
+ "mesh_ios_2",
59
+ "mesh_android",
60
+ "mesh_android_2",
61
+ "confirmed_ios",
62
+ "confirmed_android",
63
+ "confirmed_android_2",
64
+ ]
@@ -0,0 +1,74 @@
1
+ from typing import MutableMapping, Mapping
2
+ from collections import OrderedDict
3
+
4
+
5
+ class CaseInsensitiveDict(MutableMapping):
6
+ """Origin: requests library (https://github.com/psf/requests)
7
+
8
+ A case-insensitive ``dict``-like object.
9
+
10
+ Implements all methods and operations of
11
+ ``MutableMapping`` as well as dict's ``copy``. Also
12
+ provides ``lower_items``.
13
+
14
+ All keys are expected to be strings. The structure remembers the
15
+ case of the last key to be set, and ``iter(instance)``,
16
+ ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()``
17
+ will contain case-sensitive keys. However, querying and contains
18
+ testing is case insensitive::
19
+
20
+ cid = CaseInsensitiveDict()
21
+ cid['Accept'] = 'application/json'
22
+ cid['aCCEPT'] == 'application/json' # True
23
+ list(cid) == ['Accept'] # True
24
+
25
+ For example, ``headers['content-encoding']`` will return the
26
+ value of a ``'Content-Encoding'`` response header, regardless
27
+ of how the header name was originally stored.
28
+
29
+ If the constructor, ``.update``, or equality comparison
30
+ operations are given keys that have equal ``.lower()``s, the
31
+ behavior is undefined.
32
+ """
33
+
34
+ def __init__(self, data=None, **kwargs):
35
+ self._store = OrderedDict()
36
+ if data is None:
37
+ data = {}
38
+ self.update(data, **kwargs)
39
+
40
+ def __setitem__(self, key, value):
41
+ # Use the lowercased key for lookups, but store the actual
42
+ # key alongside the value.
43
+ self._store[key.lower()] = (key, value)
44
+
45
+ def __getitem__(self, key):
46
+ return self._store[key.lower()][1]
47
+
48
+ def __delitem__(self, key):
49
+ del self._store[key.lower()]
50
+
51
+ def __iter__(self):
52
+ return (casedkey for casedkey, mappedvalue in self._store.values())
53
+
54
+ def __len__(self):
55
+ return len(self._store)
56
+
57
+ def lower_items(self):
58
+ """Like iteritems(), but with all lowercase keys."""
59
+ return ((lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items())
60
+
61
+ def __eq__(self, other):
62
+ if isinstance(other, Mapping):
63
+ other = CaseInsensitiveDict(other)
64
+ else:
65
+ return NotImplemented
66
+ # Compare insensitively
67
+ return dict(self.lower_items()) == dict(other.lower_items())
68
+
69
+ # Copy is required
70
+ def copy(self):
71
+ return CaseInsensitiveDict(self._store.values())
72
+
73
+ def __repr__(self):
74
+ return str(dict(self.items()))