File: cloudflare.py

package info (click to toggle)
python-cloudflare 2.20.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 1,048 kB
  • sloc: python: 6,932; makefile: 138; sh: 76
file content (1177 lines) | stat: -rw-r--r-- 55,238 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
""" Cloudflare v4 API

A Python interface Cloudflare's v4 API.

See README.md for detailed/further reading.

Copyright (c) 2016 thru 2024, Cloudflare. All rights reserved.
"""

import json
import keyword

from .network import CFnetwork, CFnetworkError
from .logging_helper import CFlogger
from .utils import user_agent, build_curl
from .read_configs import read_configs, ReadConfigError
from .api_v4 import api_v4
from .api_extras import api_extras
from .api_decode_from_openapi import api_decode_from_openapi
from .exceptions import CloudFlareAPIError, CloudFlareInternalError
from .warning_2_20 import warning_2_20, warn_warning_2_20, indent_warning_2_20

BASE_URL = 'https://api.cloudflare.com/client/v4'
OPENAPI_URL = 'https://github.com/cloudflare/api-schemas/raw/main/openapi.json'

DEFAULT_GLOBAL_REQUEST_TIMEOUT = 5
DEFAULT_MAX_REQUEST_RETRIES = 5

class CloudFlare():
    """ A Python interface Cloudflare's v4 API.

    :param email: Authentication email (if not provided by config methods).
    :param key: Authentication key (if not provided by config methods).
    :param token: Authentication token (if not provided by config methods).
    :param certtoken: Authentication certtoken (if not provided by config methods).
    :param debug: Debug is enabled by setting to True.
    :param raw: Set to True to force raw responses so you can see paging.
    :param use_sessions: The default is True; rarely needs changing.
    :param profile: Profile name (default is "CloudFlare").
    :param base_url: Rarely changed Cloudflare API URL.
    :param global_request_timeout: Timeout value (default is 5 seconds).
    :param max_request_retries: Number of retry times (default is 5 times).
    :param http_headers: Additional HTTP headers (as a list).
    :return: New instance of CloudFlare()

    A Python interface Cloudflare's v4 API.
    """

    class _v4base():
        """ :meta private: """

        def __init__(self, config, warnings=True):
            """ :meta private: """

            self.network = None
            self.config = config

            self.api_email = config['email'] if 'email' in config else None
            self.api_key = config['key'] if 'key' in config else None
            self.api_token = config['token'] if 'token' in config else None
            self.api_certtoken = config['certtoken'] if 'certtoken' in config else None

            # We must have a base_url value
            self.base_url = config['base_url'] if 'base_url' in config else BASE_URL

            # The modern-day API definition comes from here (soon)
            self.openapi_url = config['openapi_url'] if 'openapi_url' in config else OPENAPI_URL

            self.raw = config['raw']
            self.use_sessions = config['use_sessions']
            self.global_request_timeout = config['global_request_timeout'] if 'global_request_timeout' in config else DEFAULT_GLOBAL_REQUEST_TIMEOUT
            self.max_request_retries = config['max_request_retries'] if 'max_request_retries' in config else DEFAULT_MAX_REQUEST_RETRIES
            try:
                self.global_request_timeout = int(self.global_request_timeout)
            except (TypeError, ValueError):
                self.global_request_timeout = DEFAULT_GLOBAL_REQUEST_TIMEOUT
            try:
                self.max_request_retries = int(self.max_request_retries)
            except (TypeError, ValueError):
                self.max_request_retries = DEFAULT_MAX_REQUEST_RETRIES
            self.additional_http_headers = config['http_headers'] if 'http_headers' in config else None
            self.profile = config['profile']
            self.network = CFnetwork(
                use_sessions=self.use_sessions,
                global_request_timeout=self.global_request_timeout,
                max_request_retries=self.max_request_retries
            )
            self.user_agent = user_agent()

            self.logger = CFlogger(config['debug']).getLogger() if 'debug' in config and config['debug'] else None

            if warnings:
                # After 2.20.* there is a warning message posted to handle un-pinned versions
                warning = warning_2_20()
                if warning:
                    # we are running 2.20.* or above and hence it's time to warn the user
                    if self.logger:
                        self.logger.warning(indent_warning_2_20(warning))
                    else:
                        warn_warning_2_20(indent_warning_2_20(warning))

        def __del__(self):
            if self.network:
                del self.network
                self.network = None

        def _add_headers(self, method, data, files, content_type=None):
            """ Add default headers """
            self.headers = {}
            self.headers['User-Agent'] = self.user_agent
            if method == 'GET':
                # no content type needed - except we throw in a default just for grin's
                self.headers['Content-Type'] = 'application/json'
            elif content_type is not None and method in content_type:
                # this api endpoint and this method requires a specific content type.
                ct = content_type[method]
                if isinstance(ct, list):
                    # How do we choose from more than one content type?
                    found = False
                    for t in ct:
                        # we have to match against the data type - arggg!
                        if 'application/octet-stream' == t and isinstance(data, (bytes,bytearray)):
                            self.headers['Content-Type'] = t
                            found = True
                            break
                        if 'application/json' == t and isinstance(data, (list,dict)):
                            self.headers['Content-Type'] = t
                            found = True
                            break
                        if 'application/javascript' == t and isinstance(data, str):
                            self.headers['Content-Type'] = t
                            found = True
                            break
                    if not found:
                        # punt - pick first - we can't do anything else!
                        self.headers['Content-Type'] = ct[0]
                else:
                    self.headers['Content-Type'] = ct
            else:
                # default choice
                self.headers['Content-Type'] = 'application/json'

            # now adjust Content-Type based on data and files
            if method != 'GET':
                if self.headers['Content-Type'] == 'application/json' and isinstance(data, str):
                    # passing javascript vs JSON
                    self.headers['Content-Type'] = 'application/javascript'
                if self.headers['Content-Type'] == 'application/json' and isinstance(data, (bytes,bytearray)):
                    # passing binary file vs JSON
                    self.headers['Content-Type'] = 'application/octet-stream'
                if data and len(data) > 0 and self.headers['Content-Type'] == 'multipart/form-data':
                    # convert from params to files (i.e multipart/form-data)
                    if files is None:
                        files = set()
                    for k,v in data.items():
                        if isinstance(v, (dict, list)):
                            files.add((k, (None, json.dumps(v), 'application/json')))
                        else:
                            files.add((k, (None, v)))
                    # we have replaced data's values into files
                    data = None
                if data is not None and len(data) == 0:
                    data = None
                if files is not None and len(files) == 0:
                    files = None
                if data is None and files is None and self.headers['Content-Type'] == 'multipart/form-data':
                    # can't have zero length multipart/form-data and as there's no data or files; we don't need it
                    del self.headers['Content-Type']
                if files:
                    # overwrite Content-Type as we are uploading data
                    self.headers['Content-Type'] = 'multipart/form-data'
                    # however something isn't right and this works ... look at again later!
                    del self.headers['Content-Type']
            if self.additional_http_headers:
                for h in self.additional_http_headers:
                    t, v = h.split(':', 1)
                    t = t.strip()
                    v = v.strip()
                    if len(v) > 0 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")):
                        v = v[1:-1]
                    self.headers[t] = v
            return data, files

        def _add_auth_headers(self, method):
            """ Add authentication headers """

            v = 'email' + '.' + method.lower()
            api_email = self.config[v] if v in self.config else self.api_email
            v = 'key' + '.' + method.lower()
            api_key = self.config[v] if v in self.config else self.api_key
            v = 'token' + '.' + method.lower()
            api_token = self.config[v] if v in self.config else self.api_token

            if api_email is None and api_key is None and api_token is None:
                if self.logger:
                    self.logger.debug('neither email/key or token defined')
                raise CloudFlareAPIError(0, 'neither email/key or token defined')

            if api_key is not None and api_token is not None:
                if self.logger:
                    self.logger.debug('confused info - both key and token defined')
                raise CloudFlareAPIError(0, 'confused info - both key and token defined')

            if api_email is not None and api_key is None and api_token is None:
                if self.logger:
                    self.logger.debug('email defined however neither key or token defined')
                raise CloudFlareAPIError(0, 'email defined however neither key or token defined')

            # We know at this point that at-least one api_* is set and no confusion!

            if api_email is None and api_token is not None:
                # post issue-114 - token is used
                self.headers['Authorization'] = 'Bearer %s' % (api_token)
            elif api_email is None and api_key is not None:
                # pre issue-114 - key is used vs token - backward compat
                self.headers['Authorization'] = 'Bearer %s' % (api_key)
            elif api_email is not None and api_key is not None:
                # boring old school email/key methodology (token ignored)
                self.headers['X-Auth-Email'] = api_email
                self.headers['X-Auth-Key'] = api_key
            elif api_email is not None and api_token is not None:
                # boring old school email/key methodology (token ignored)
                self.headers['X-Auth-Email'] = api_email
                self.headers['X-Auth-Key'] = api_token
            else:
                raise CloudFlareInternalError(0, 'coding issue!')

        def _add_certtoken_headers(self, method):
            """ Add authentication headers """

            v = 'certtoken' + '.' + method.lower()
            if v in self.config:
                api_certtoken = self.config[v] # use specific value for this method
            else:
                api_certtoken = self.api_certtoken # use generic value for all methods

            if api_certtoken is None:
                if self.logger:
                    self.logger.debug('no cert token defined')
                raise CloudFlareAPIError(0, 'no cert token defined')
            self.headers['X-Auth-User-Service-Key'] = api_certtoken

        def do_not_available(self, method, parts, identifiers, params=None, data=None, files=None, content_type=None):
            """ Cloudflare v4 API"""

            # base class simply returns not available - no processing of any arguments
            if self.logger:
                self.logger.debug('call for this method not available')
            raise CloudFlareAPIError(0, 'call for this method not available')

        def do_no_auth(self, method, parts, identifiers, params=None, data=None, files=None, content_type=None):
            """ Cloudflare v4 API"""

            data, files = self._add_headers(method, data, files, content_type)
            # We decide at this point if we are sending json or string data
            if isinstance(data, (str,bytes,bytearray)):
                return self._call(method, parts, identifiers, params, data, None, files)
            return self._call(method, parts, identifiers, params, None, data, files)

        def do_auth(self, method, parts, identifiers, params=None, data=None, files=None, content_type=None):
            """ Cloudflare v4 API"""

            data, files = self._add_headers(method, data, files, content_type)
            self._add_auth_headers(method)
            # We decide at this point if we are sending json or string data
            if isinstance(data, (str,bytes,bytearray)):
                return self._call(method, parts, identifiers, params, data, None, files)
            return self._call(method, parts, identifiers, params, None, data, files)

        def do_auth_unwrapped(self, method, parts, identifiers, params=None, data=None, files=None, content_type=None):
            """ Cloudflare v4 API"""

            data, files = self._add_headers(method, data, files, content_type)
            self._add_auth_headers(method)
            # We decide at this point if we are sending json or string data
            if isinstance(data, (str,bytes,bytearray)):
                return self._call_unwrapped(method, parts, identifiers, params, data, None, files)
            return self._call_unwrapped(method, parts, identifiers, params, None, data, files)

        def do_certauth(self, method, parts, identifiers, params=None, data=None, files=None, content_type=None):
            """ Cloudflare v4 API"""

            data, files = self._add_headers(method, data, files, content_type)
            self._add_certtoken_headers(method)
            # We decide at this point if we are sending json or string data
            if isinstance(data, (str,bytes,bytearray)):
                return self._call(method, parts, identifiers, params, data, None, files)
            return self._call(method, parts, identifiers, params, None, data, files)

        def _call_network(self, method, headers, parts, identifiers, params, data_str, data_json, files):
            """ Cloudflare v4 API"""

            if (method is None) or (parts[0] is None):
                # should never happen
                raise CloudFlareInternalError(0, 'You must specify a method and endpoint')

            if len(parts) > 1 and parts[1] is not None or (data_str is not None and method == 'GET'):
                if identifiers[0] is None:
                    raise CloudFlareAPIError(0, 'You must specify first identifier')
                if identifiers[1] is None:
                    url = (self.base_url + '/'
                           + parts[0] + '/'
                           + str(identifiers[0]) + '/'
                           + parts[1])
                else:
                    url = (self.base_url + '/'
                           + parts[0] + '/'
                           + str(identifiers[0]) + '/'
                           + parts[1] + '/'
                           + str(identifiers[1]))
            else:
                if identifiers[0] is None:
                    url = (self.base_url + '/'
                           + parts[0])
                else:
                    url = (self.base_url + '/'
                           + parts[0] + '/'
                           + str(identifiers[0]))

            if len(parts) > 2 and parts[2]:
                url += '/' + parts[2]
                if identifiers[2]:
                    url += '/' + str(identifiers[2])
                if len(parts) > 3 and parts[3]:
                    url += '/' + parts[3]
                    if identifiers[3]:
                        url += '/' + str(identifiers[3])
                    if len(parts) > 4 and parts[4]:
                        url += '/' + parts[4]

            if self.logger:
                msg = build_curl(method, url, headers, params, data_str, data_json, files)
                self.logger.debug('Call: emulated curl command ...\n%s', msg)

            try:
                response = self.network(method, url, headers, params, data_str, data_json, files)
            except CFnetworkError as e:
                if self.logger:
                    self.logger.debug('Call: network error: %s', e)
                raise CloudFlareAPIError(0, str(e)) from None
            except Exception as e:
                if self.logger:
                    self.logger.debug('Call: network exception! %s', e)
                raise CloudFlareAPIError(0, 'network exception: %s' % (e)) from None

            # Create response_{type|code|data}
            try:
                response_type = response.headers['Content-Type']
                if ';' in response_type:
                    # remove the ;paramaters part (like charset=, etc.)
                    response_type = response_type[0:response_type.rfind(';')]
                response_type = response_type.strip().lower()
            except KeyError:
                # API should always response; but if it doesn't; here's the default
                response_type = 'application/octet-stream'
            response_code = response.status_code
            response_data = response.content
            if not isinstance(response_data, (str, bytes, bytearray)):
                # the more I think about it; then less likely this will ever be called
                try:
                    response_data = response_data.decode('utf-8')
                except UnicodeDecodeError:
                    pass

            if self.logger:
                if 'text/' == response_type[0:5] or response_type in ['application/javascript', 'application/json']:
                    if len(response_data) > 180:
                        self.logger.debug('Response: %d, %s, %s...', response_code, response_type, response_data[0:180])
                    else:
                        self.logger.debug('Response: %d, %s, %s', response_code, response_type, response_data)
                else:
                    self.logger.debug('Response: %d, %s, %s', response_code, response_type, '...')

            if response_code == 429:
                # 429 Too Many Requests
                # The HTTP 429 Too Many Requests response status code indicates the user
                # has sent too many requests in a given amount of time ("rate limiting").
                # A Retry-After header might be included to this response indicating how
                # long to wait before making a new request.
                try:
                    retry_after = response.headers['Retry-After']
                except (KeyError,IndexError):
                    retry_after = ''
                # XXX/TODO no processing for now - but could try again within library
                if self.logger:
                    self.logger.debug('Response: 429 Header Retry-After: %s', retry_after)

            # if response_code in [400,401,403,404,405,412,500]:
            if 400 <= response_code <= 499 or response_code == 500:
                # The /certificates API call insists on a 500 error return and yet has valid error data
                # Other API calls can return 400 or 4xx with valid response data
                # lets check and convert if able
                try:
                    j = json.loads(response_data)
                    if len(j) == 2 and 'code' in j and 'error' in j:
                        # This is an incorrect response from the API (happens on 404's) - but we can handle it cleanly here
                        # {\n  "code": 1000,\n  "error": "not_found"\n}
                        response_data = '{"errors": [{"code": %d, "message": "%s"}], "success": false, "result": null}' % (j['code'], j['error'])
                        response_data = response_data.encode()
                        response_code = 200
                    elif 'success' in j and 'errors' in j:
                        # yippe - try to continue by allowing to process fully
                        response_code = 200
                    else:
                        # no go - it's not a Cloudflare error format
                        pass
                except (ValueError, json.decoder.JSONDecodeError):
                    # ignore - maybe a real error that's not json, let proceed!
                    pass

            if 500 <= response_code <= 599:
                # 500 Internal Server Error
                # 501 Not Implemented
                # 502 Bad Gateway
                # 503 Service Unavailable
                # 504 Gateway Timeout
                # 505 HTTP Version Not Supported
                # 506 Variant Also Negotiates
                # 507 Insufficient Storage
                # 508 Loop Detected
                # 509 Unassigned
                # 510 Not Extended
                # 511 Network Authentication Required

                # the libary doesn't deal with these errors, just pass upwards!
                # there's no value to add and the returned data is questionable or not useful
                response.raise_for_status()

                # should not be reached
                raise CloudFlareInternalError(0, 'internal error in status code processing')

            # if 400 <= response_code <= 499:
            #    # 400 Bad Request
            #    # 401 Unauthorized
            #    # 403 Forbidden
            #    # 405 Method Not Allowed
            #    # 415 Unsupported Media Type
            #    # 429 Too many requests
            #
            #    # don't deal with these errors, just pass upwards!
            #    response.raise_for_status()

            # if 300 <= response_code <= 399:
            #    # 304 Not Modified
            #
            #    # don't deal with these errors, just pass upwards!
            #    response.raise_for_status()

            # should be a 200 response at this point

            return [response_type, response_code, response_data]

        def _raw(self, method, headers, parts, identifiers, params, data_str, data_json, files):
            """ Cloudflare v4 API"""

            [response_type, response_code, response_data] = self._call_network(method,
                                                                               headers, parts,
                                                                               identifiers,
                                                                               params, data_str, data_json, files)

            # API can return HTTP code OK, CREATED, ACCEPTED, or NO-CONTENT - all of which are a-ok.
            if response_code not in [200, 201, 202, 204]:
                # 3xx & 4xx errors (5xx's handled above)
                response_data = {'success': False,
                                 'errors': [{'code': response_code, 'message':'HTTP response code %d' % response_code}],
                                 'result': str(response_data)}

                # it would be nice to return the error code and content type values; but not quite yet
                return response_data

            if response_type == 'application/json':
                # API says it's JSON; so it better be parsable as JSON
                # NDJSON is returned by Enterprise Log Share i.e. /zones/:id/logs/received
                if hasattr(response_data, 'decode'):
                    try:
                        response_data = response_data.decode('utf-8')
                    except UnicodeDecodeError:
                        # clearly not a string that can be decoded!
                        if self.logger:
                            self.logger.debug('Response: decode(utf-8) failed, reverting to binary response')
                        # return binary
                        return {'success': True, 'result': response_data}
                try:
                    if response_data == '':
                        # This should really be 'null' but it isn't. Even then, it's wrong!
                        response_data = None
                    else:
                        response_data = json.loads(response_data)
                except (ValueError,json.decoder.JSONDecodeError):
                    # Lets see if it's NDJSON data
                    # NDJSON is a series of JSON elements with newlines between each element
                    try:
                        r = []
                        for line in response_data.splitlines():
                            r.append(json.loads(line))
                        response_data = r
                    except (ValueError, json.decoder.JSONDecodeError):
                        # While this should not happen; it's always possible
                        if self.logger:
                            self.logger.debug('Response data not JSON: %r', response_data)
                        raise CloudFlareAPIError(0, 'JSON parse failed - report to Cloudflare.') from None

                if isinstance(response_data, dict) and 'success' in response_data:
                    return response_data
                # if it's not a dict then it's not going to have 'success'
                return {'success': True, 'result': response_data}

            if response_type in ['text/plain', 'application/octet-stream']:
                # API says it's text; but maybe it's actually JSON? - should be fixed in API
                if hasattr(response_data, 'decode'):
                    try:
                        response_data = response_data.decode('utf-8')
                    except UnicodeDecodeError:
                        # clearly not a string that can be decoded!
                        if self.logger:
                            self.logger.debug('Response: decode(utf-8) failed, reverting to binary response')
                        # return binary
                        return {'success': True, 'result': response_data}
                try:
                    if response_data == '':
                        # This should really be 'null' but it isn't. Even then, it's wrong!
                        response_data = None
                    else:
                        response_data = json.loads(response_data)
                except (ValueError, json.decoder.JSONDecodeError):
                    # So it wasn't JSON - moving on as if it's text!
                    pass
                if isinstance(response_data, dict) and 'success' in response_data:
                    return response_data
                return {'success': True, 'result': response_data}

            if response_type in ['text/javascript', 'application/javascript', 'text/html', 'text/css', 'text/csv']:
                # used by Cloudflare workers etc
                if hasattr(response_data, 'decode'):
                    try:
                        response_data = response_data.decode('utf-8')
                    except UnicodeDecodeError:
                        # clearly not a string that can be decoded!
                        if self.logger:
                            self.logger.debug('Response: decode(utf-8) failed, reverting to binary response')
                        # return binary
                        return {'success': True, 'result': response_data}
                return {'success': True, 'result': str(response_data)}

            if response_type in ['application/pdf', 'application/zip'] or response_type[0:6] in ['audio/', 'image/', 'video/']:
                # it's raw/binary - just pass thru
                return {'success': True, 'result': response_data}

            # Assuming nothing - but continuing anyway as if its a string
            if hasattr(response_data, 'decode'):
                try:
                    response_data = response_data.decode('utf-8')
                except UnicodeDecodeError:
                    # clearly not a string that can be decoded!
                    if self.logger:
                        self.logger.debug('Response: decode(utf-8) failed, reverting to binary response')
                    # return binary
                    return {'success': True, 'result': response_data}
            return {'success': True, 'result': str(response_data)}

        def _call(self, method, parts, identifiers, params, data_str, data_json, files):
            """ Cloudflare v4 API"""

            response_data = self._raw(method, self.headers, parts, identifiers, params, data_str, data_json, files)

            # Sanatize the returned results - just in case API is messed up
            if 'success' not in response_data:
                # { "data": null, "errors": [ { "message": "request must be a POST", "path": null, "extensions": { "timestamp": "20...
                # XXX/TODO should be retested and aybe recoded/deleted
                if 'errors' in response_data:
                    if response_data['errors'] is None:
                        # Only happens on /graphql call
                        if self.logger:
                            self.logger.debug('Response: assuming success = "True"')
                        response_data['success'] = True
                    else:
                        if self.logger:
                            self.logger.debug('Response: assuming success = "False"')
                        # The following only happens on /graphql call
                        try:
                            message = response_data['errors'][0]['message']
                        except KeyError:
                            message = ''
                        try:
                            location = str(response_data['errors'][0]['location'])
                        except KeyError:
                            location = ''
                        try:
                            path = '>'.join(response_data['errors'][0]['path'])
                        except KeyError:
                            path = ''
                        response_data['errors'] = [{'code': 99999, 'message': message + ' - ' + location + ' - ' + path}]
                        response_data['success'] = False
                else:
                    if 'result' not in response_data:
                        # Only happens on /certificates call
                        # should be fixed in /certificates API
                        # may well be fixed by now
                        if self.logger:
                            self.logger.debug('Response: assuming success = "False"')
                        r = response_data
                        response_data['errors'] = []
                        response_data['errors'].append(r)
                        response_data['success'] = False
                    else:
                        if self.logger:
                            self.logger.debug('Response: assuming success = "True"')
                        response_data['success'] = True

            if response_data['success'] is False:
                if 'errors' in response_data and response_data['errors'] is not None:
                    errors = response_data['errors'][0]
                else:
                    errors = {}
                if 'code' in errors:
                    code = errors['code']
                else:
                    code = 99998
                if 'message' in errors:
                    message = errors['message']
                elif 'error' in errors:
                    message = errors['error']
                else:
                    message = ''
                # if 'messages' in response_data:
                #     errors['error_chain'] = response_data['messages']
                if 'error_chain' in errors:
                    error_chain = errors['error_chain']
                    for error in error_chain:
                        if self.logger:
                            self.logger.debug('Response: error %d %s - chain', error['code'], error['message'])
                    if self.logger:
                        self.logger.debug('Response: error %d %s', code, message)
                    raise CloudFlareAPIError(code, message, error_chain)

                if self.logger:
                    self.logger.debug('Response: error %d %s', code, message)
                raise CloudFlareAPIError(code, message)

            if self.raw:
                result = {}
                # theres always a result value - unless it's a graphql query
                try:
                    result['result'] = response_data['result']
                except KeyError:
                    result['result'] = response_data
                # theres may not be a result_info on every call
                if 'result_info' in response_data:
                    result['result_info'] = response_data['result_info']
                # no need to return success, errors, or messages as they return via an exception
            else:
                # theres always a result value - unless it's a graphql query
                try:
                    result = response_data['result']
                except KeyError:
                    result = response_data

            if self.logger:
                if isinstance(result, (str, dict, list)):
                    if len(str(result)) > 180:
                        self.logger.debug('Response: %s...', str(result)[0:180].replace('\n', ' '))
                    else:
                        self.logger.debug('Response: %s', str(result).replace('\n', ' '))
                elif isinstance(result, (bytes,bytearray)):
                    self.logger.debug('Response: %s', result[0:180])
                else:
                    self.logger.debug('Response: %s', '...')
            return result

        def _call_unwrapped(self, method, parts, identifiers, params, data_str, data_json, files):
            """ Cloudflare v4 API"""

            response_data = self._raw(method, self.headers, parts, identifiers, params, data_str, data_json, files)
            if self.logger:
                self.logger.debug('Response: %s', response_data)
            result = response_data
            return result

        def api_from_openapi(self, url=None):
            """ Cloudflare v4 API"""

            if url is None:
                url = self.openapi_url

            try:
                v = self._read_from_web(url)
            except Exception as e:
                if self.logger:
                    self.logger.debug('OpenAPI read from web failed: %s', e)
                raise CloudFlareAPIError(0, 'OpenAPI read from web failed: %s' % (e)) from None

            try:
                v, openapi_version, cloudflare_version, cloudflare_url = api_decode_from_openapi(v)
            except SyntaxError as e:
                if self.logger:
                    self.logger.debug('OpenAPI bad json file: %s', e)
                raise CloudFlareAPIError(0, 'OpenAPI bad json file: %s' % (e)) from None

            # if self.base_url != cloudflare_url:
            #    # XXX/TODO should this be recorded or throw an error?
            #    pass

            if self.logger:
                self.logger.debug('OpenAPI version: %s, Cloudflare API version: %s url: %s', openapi_version, cloudflare_version, cloudflare_url)
            return v

        def _read_from_web(self, url):
            """ Cloudflare v4 API"""
            try:
                if self.logger:
                    self.logger.debug('Call: doit!')
                response = self.network('GET', url)
                if self.logger:
                    self.logger.debug('Call: done!')
            except Exception as e:
                if self.logger:
                    self.logger.debug('Call: exception! "%s"', e)
                raise CloudFlareAPIError(0, 'connection failed.') from None

            return response.text

    class _CFbase():
        """ :meta private: """

        def __init__(self, base, parts, content_type=None):
            """ Cloudflare v4 API"""

            self._base = base
            self._parts = parts
            if content_type:
                self._content_type = content_type
            self._do = self._base.do_not_available

        def __call__(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None):
            """ Cloudflare v4 API"""

            # This is the same as a get()
            return self.get(identifier1, identifier2, identifier3, identifier4, params=params, data=data)

        def __str__(self):
            """ Cloudflare v4 API"""

            return '[' + '/' + '/:id/'.join(self._parts) + ']'

        def __repr__(self):
            """ Cloudflare v4 API"""

            return '[' + '/' + '/:id/'.join(self._parts) + ']'

        def get(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._do('GET', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, self._content_type)
                return self._do('GET', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

        def patch(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._do('PATCH', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, self._content_type)
                return self._do('PATCH', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

        def post(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None, files=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._do('POST', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, files, self._content_type)
                return self._do('POST', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, files)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

        def put(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None, files=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._do('PUT', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, files, self._content_type)
                return self._do('PUT', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, files)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

        def delete(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._do('DELETE', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, self._content_type)
                return self._do('DELETE', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

    class _CFbaseUnused(_CFbase):
        """ :meta private: """

        def __init__(self, base, parts, content_type):
            """ Cloudflare v4 API"""

            super().__init__(base, parts, content_type)
            self._do = self._base.do_not_available

    class _CFbaseNoAuth(_CFbase):
        """ :meta private: """

        def __init__(self, base, parts, content_type):
            """ Cloudflare v4 API"""

            super().__init__(base, parts, content_type)
            self._do = self._base.do_no_auth
            self._valid = True

        def patch(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._base.do_not_available('PATCH', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, self._content_type)
                return self._base.do_not_available('PATCH', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

        def post(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None, files=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._base.do_not_available('POST', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, files, self._content_type)
                return self._base.do_not_available('POST', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, files)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

        def put(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None, files=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._base.do_not_available('PUT', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, self._content_type)
                return self._base.do_not_available('PUT', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

        def delete(self, identifier1=None, identifier2=None, identifier3=None, identifier4=None, params=None, data=None):
            """ Cloudflare v4 API"""

            try:
                if getattr(self, '_content_type', False):
                    return self._base.do_not_available('DELETE', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data, self._content_type)
                return self._base.do_not_available('DELETE', self._parts, [identifier1, identifier2, identifier3, identifier4], params, data)
            except CloudFlareAPIError as e:
                raise CloudFlareAPIError(e=e) from None

    class _CFbaseAuth(_CFbase):
        """ :meta private: """

        def __init__(self, base, parts, content_type):
            """ Cloudflare v4 API"""

            super().__init__(base, parts, content_type)
            self._do = self._base.do_auth
            self._valid = True

    class _CFbaseAuthUnwrapped(_CFbase):
        """ :meta private: """

        def __init__(self, base, parts, content_type):
            """ Cloudflare v4 API"""

            super().__init__(base, parts, content_type)
            self._do = self._base.do_auth_unwrapped
            self._valid = True

    class _CFbaseAuthCert(_CFbase):
        """ :meta private: """

        def __init__(self, base, parts, content_type):
            """ Cloudflare v4 API"""

            super().__init__(base, parts, content_type)
            self._do = self._base.do_certauth
            self._valid = True

    @classmethod
    def sanitize_verb(cls, v):
        """ sanitize_verb """
        # keywords are also changed to have underscore appended so it can used with Python code
        if keyword.iskeyword(v):
            v = v + '_'
        # AI functions introduce '@' symbol - i.e .../@cf/... they are replaced with at_
        if '@' == v[0]:
            v = 'at_' + v[1:]
        # AI functions introduce '.' symbol - i.e 1.0 they are replaced with underscore
        if '.' in v:
            v = v.replace('.','_')
        # dashes (vs underscores) cause issues in Python and other languages. they are replaced with underscores
        if '-' in v:
            v = v.replace('-','_')
        return v

    def add_carefully(self, t, *parts, content_type=None):
        """ add_carefully()
        """
        self.add(t, parts, content_type, auto=False)

    def add(self, t, *parts, content_type=None, auto=True):
        """ add()

        :param t: type of API call.
        :param p1: part1 of API call.
        :param p2: part1 of API call.
        :param p3: part1 of API call.
        :param p4: part1 of API call.
        :param p5: part1 of API call.
        :param content_type: optional value for the HTTP Content-Type for an API call.

        add() is the core fuction that creates a new API endpoint that can be called later on.
        """

        api_sections = []
        for p in parts:
            api_sections += p.split('/')

        branch = self
        for api_part in api_sections[0:-1]:
            try:
                branch = getattr(branch, CloudFlare.sanitize_verb(api_part))
            except AttributeError:
                # missing path - should never happen unless api_v4 is a busted file or add_all() used
                if not auto:
                    raise CloudFlareAPIError(0, 'api load: api_part **%s** missing when adding path /%s' % (api_part, '/'.join(api_sections))) from None
                # create intermediate path as required
                f = self._CFbaseUnused(self._base, parts, content_type=None)
                setattr(branch, CloudFlare.sanitize_verb(api_part), f)
                branch = getattr(branch, CloudFlare.sanitize_verb(api_part))

        api_part = api_sections[-1]
        try:
            branch = getattr(branch, CloudFlare.sanitize_verb(api_part))
            # we only are here becuase the name already exists - don't let it overwrite - should never happen unless api_v4 is a busted file
            raise CloudFlareAPIError(0, 'api load: duplicate api_part found: %s/**%s**' % ('/'.join(api_sections[0:-1]), api_part))
        except AttributeError:
            # this is the required behavior - i.e. it's a new node to create
            pass

        if t == 'VOID':
            f = self._CFbaseUnused(self._base, parts, content_type=None)
        elif t == 'OPEN':
            f = self._CFbaseNoAuth(self._base, parts, content_type=content_type)
        elif t == 'AUTH':
            f = self._CFbaseAuth(self._base, parts, content_type=content_type)
        elif t == 'AUTH_UNWRAPPED':
            f = self._CFbaseAuthUnwrapped(self._base, parts, content_type=content_type)
        elif t == 'CERT':
            f = self._CFbaseAuthCert(self._base, parts, content_type=content_type)
        else:
            # should never happen
            raise CloudFlareAPIError(0, 'api load type mismatch')

        setattr(branch, CloudFlare.sanitize_verb(api_part), f)

    def find(self, cmd):
        """ find()

        :param cmd: API in slash format
        :return: fuction to call for that API

        You can use this call to convert a string API command into the actual function call
        """
        m = self
        for verb in cmd.split('/'):
            if verb == '' or verb[0] == ':':
                continue
            try:
                m = getattr(m, CloudFlare.sanitize_verb(verb))
            except AttributeError:
                raise AttributeError('%s: not found' % (verb)) from None
        return m

    def api_list(self):
        """ api_list()

        :return: list of API calls

        A recursive walk of the api tree returning a list of api calls
        """
        return self._api_list(m=self)

    def _api_list(self, m=None, s=''):
        """ :meta private: """
        w = []
        for n in sorted(dir(m)):
            if n[0] == '_':
                # internal
                continue
            if n in ['delete', 'get', 'patch', 'post', 'put']:
                # gone too far
                continue
            try:
                a = getattr(m, n)
            except AttributeError:
                # really should not happen!
                raise CloudFlareAPIError(0, '%s: not found - should not happen' % (n)) from None
            d = dir(a)
            if '_base' not in d:
                continue
            # it's a known api call - lets show the result and continue down the tree
            if '_parts' in d and '_valid' in d:
                if 'delete' in d or 'get' in d or 'patch' in d or 'post' in d or 'put' in d:
                    # only show the result if a call exists for this part
                    if n[-1] == '_':
                        if keyword.iskeyword(n[:-1]):
                            # should always be a keyword - but now nothing needs to be done
                            pass
                        # remove the extra keyword postfix'ed with underscore
                        w.append(str(a)[1:-1])
                    else:
                        # handle underscores by returning the actual API call vs the method name
                        w.append(str(a)[1:-1])
            # now recurse downwards into the tree
            w = w + self._api_list(a, s + '/' + n)
        return w

    def api_from_openapi(self, url=None):
        """ api_from_openapi()

        :param url: OpenAPI URL or None if you use the built official URL

        """

        return self._base.api_from_openapi(url)

    def __init__(self, email=None, key=None, token=None, certtoken=None, debug=False, raw=False, use_sessions=True, profile=None, base_url=None, global_request_timeout=None, max_request_retries=None, http_headers=None, warnings=True):
        """ :meta private: """

        self._base = None

        if email is not None and not isinstance(email, str):
            raise TypeError('email is %s - must be str' % (type(email)))
        if key is not None and not isinstance(key, str):
            raise TypeError('key is %s - must be str' % (type(key)))
        if token is not None and not isinstance(token, str):
            raise TypeError('token is %s - must be str' % (type(token)))
        if certtoken is not None and not isinstance(certtoken, str):
            raise TypeError('certtoken is %s - must be str' % (type(certtoken)))

        try:
            config = read_configs(profile)
        except ReadConfigError as e:
            raise e

        # class creation values override all configuration values
        if email is not None:
            config['email'] = email
        if key is not None:
            config['key'] = key
        if token is not None:
            config['token'] = token
        if certtoken is not None:
            config['certtoken'] = certtoken
        if debug is not None:
            config['debug'] = debug
        if raw is not None:
            config['raw'] = raw
        if use_sessions is not None:
            config['use_sessions'] = use_sessions
        if profile is not None:
            config['profile'] = profile
        if base_url is not None:
            config['base_url'] = base_url
        if global_request_timeout is not None:
            config['global_request_timeout'] = global_request_timeout
        if max_request_retries is not None:
            config['max_request_retries'] = max_request_retries
        if http_headers is not None:
            if not isinstance(http_headers, list):
                raise TypeError('http_headers is not a list')
            for h in http_headers:
                try:
                    t, v = h.split(':', 1)
                except ValueError:
                    # clearly a bad header syntax
                    raise TypeError('http_headers bad syntax') from None
                if len(t.strip()) == 0:
                    raise TypeError('http_headers bad syntax') from None
            config['http_headers'] = http_headers

        # we do not need to handle item.call values - they pass straight thru

        for k,v in config.items():
            if v == '':
                config[k] = None

        self._base = self._v4base(config, warnings=warnings)

        # add the API calls
        try:
            api_v4(self)
            if 'extras' in config and config['extras']:
                api_extras(self, config['extras'])
        except Exception as e:
            raise e

    def __del__(self):
        """ :meta private: """

        if self._base:
            del self._base
            self._base = None

    def __call__(self):
        """ :meta private: """

        raise TypeError('object is not callable')

    def __enter__(self):
        """ :meta private: """
        return self

    def __exit__(self, t, v, tb):
        """ :meta private: """
        if t is None:
            return True
        # pretend we didn't deal with raised error - which is true
        return False

    def __str__(self):
        """ :meta private: """

        if self._base.api_email is None:
            s = '["%s","%s"]' % (self._base.profile, 'REDACTED')
        else:
            s = '["%s","%s","%s"]' % (self._base.profile, self._base.api_email, 'REDACTED')
        return s

    def __repr__(self):
        """ :meta private: """

        if self._base.api_email is None:
            s = '%s,%s("%s","%s","%s","%s",%s,"%s")' % (
                self.__module__, type(self).__name__,
                self._base.profile, 'REDACTED', 'REDACTED',
                self._base.base_url, self._base.raw, self._base.user_agent
            )
        else:
            s = '%s,%s("%s","%s","%s","%s","%s",%s,"%s")' % (
                self.__module__, type(self).__name__,
                self._base.profile, self._base.api_email, 'REDACTED', 'REDACTED',
                self._base.base_url, self._base.raw, self._base.user_agent
            )
        return s

    def __getattr__(self, key):
        """ :meta private: """

        # this code will expand later
        if key in dir(self):
            return self[key]
        # this is call to a non-existent endpoint
        raise AttributeError(key)

class Cloudflare(CloudFlare):
    """ A Python interface Cloudflare's v4 API.

    Alternate upper/lowercase version.
    """

class cloudflare(CloudFlare):
    """ A Python interface Cloudflare's v4 API.

    Alternate upper/lowercase version.
    """