edgegrid-python 2.0.4__py3-none-any.whl → 2.0.6__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.
@@ -36,6 +36,6 @@ from .edgerc import EdgeRc
36
36
  __all__ = ['EdgeGridAuth', 'EdgeRc']
37
37
 
38
38
  __title__ = 'edgegrid-python'
39
- __version__ = '2.0.4'
39
+ __version__ = '2.0.6'
40
40
  __license__ = 'Apache 2.0'
41
- __copyright__ = 'Copyright 2025 Akamai Technologies'
41
+ __copyright__ = 'Copyright 2026 Akamai Technologies'
@@ -0,0 +1,41 @@
1
+ """
2
+ akamai.edgegrid
3
+ ~~~~~~~~~~~~~~~
4
+
5
+ This library provides an authentication handler for Requests that implements the
6
+ Akamai {OPEN} EdgeGrid client authentication protocol as
7
+ specified by https://developer.akamai.com/introduction/Client_Auth.html.
8
+ For more information visit https://developer.akamai.com.
9
+
10
+ usage:
11
+
12
+ >>> import requests
13
+ >>> from akamai.edgegrid import EdgeGridAuth
14
+ >>> from urlparse import urljoin
15
+
16
+ >>> baseurl = 'https://akaa-WWWWWWWWWWWW.luna.akamaiapis.net/'
17
+ >>> s = requests.Session()
18
+ >>> s.auth = EdgeGridAuth(
19
+ client_token='akab-XXXXXXXXXXXXXXXXXXXXXXX',
20
+ client_secret='YYYYYYYYYYYYYYYYYYYYYYYYYY',
21
+ access_token='akab-ZZZZZZZZZZZZZZZZZZZZZZZZZZZ'
22
+ )
23
+
24
+ ... now you have a requests session object that can be used to make {OPEN} requests
25
+
26
+ >>> result = s.get(urljoin(baseurl, '/diagnostic-tools/v1/locations'))
27
+ >>> result.status_code
28
+ 200
29
+ >>> result.json()['locations'][0]
30
+ Hongkong, Hong Kong
31
+ """
32
+
33
+ from .edgegrid import EdgeGridAuth
34
+ from .edgerc import EdgeRc
35
+
36
+ __all__ = ['EdgeGridAuth', 'EdgeRc']
37
+
38
+ __title__ = 'edgegrid-python'
39
+ __version__ = '2.0.6'
40
+ __license__ = 'Apache 2.0'
41
+ __copyright__ = 'Copyright 2026 Akamai Technologies'
@@ -0,0 +1,323 @@
1
+ # pylint: disable=too-many-arguments,missing-function-docstring
2
+ """EdgeGrid requests Auth handler"""
3
+
4
+ import logging
5
+ import uuid
6
+ import hashlib
7
+ import hmac
8
+ import base64
9
+ import re
10
+ import os
11
+ from time import gmtime, strftime
12
+ from urllib.parse import urlparse
13
+
14
+ from requests.auth import AuthBase
15
+
16
+ from .edgerc import EdgeRc
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ __all__ = ['EdgeGridAuth']
21
+
22
+
23
+ def eg_timestamp():
24
+ """Generates EdgeGrid compatible timestamp"""
25
+ return strftime('%Y%m%dT%H:%M:%S+0000', gmtime())
26
+
27
+
28
+ def new_nonce():
29
+ return uuid.uuid4()
30
+
31
+
32
+ def base64_hmac_sha256(data, key):
33
+ return base64.b64encode(
34
+ hmac.new(
35
+ key.encode('utf8'),
36
+ data.encode('utf8'),
37
+ hashlib.sha256).digest()
38
+ ).decode('utf8')
39
+
40
+
41
+ def base64_sha256(data):
42
+ digest = hashlib.sha256(data).digest()
43
+ return base64.b64encode(digest).decode('utf8')
44
+
45
+
46
+ def read_stream_and_rewind(f, max_read):
47
+ """Reads up to read_max bytes from a python file object (like _io.BufferedReader)
48
+ or a MultipartEncoder object, then rewinds the stream.
49
+
50
+ The read() method of these objects is decorated by httpie with a side-effect code which
51
+ prints the body content to stdout when the 'B' option is specified for --print. However,
52
+ it does not trigger in the pre-request phase when this plugin is executed. Still, after reading
53
+ we must set the stream position to the beginning to not impact subsequent reads outside
54
+ the plugin. (We don't assume we can be passed a partially read stream to the plugin.)
55
+
56
+ Raises TypeError if read() or seek() is not supported by f or f._buffer. May potentially raise
57
+ OSError for any failed I/O operation, in particular io.UnsupportedOperation if the stream is
58
+ not seekable (e.g. is a pipe which we don't expect here as httpie reads pipe contents and
59
+ sets body as bytes).
60
+ """
61
+ try:
62
+ res = f.read(max_read)
63
+ except AttributeError as exc:
64
+ raise TypeError(f'akamai.edgegrid: unexpected body type: {type(f).__name__}') from exc
65
+
66
+ try:
67
+ f.seek(0)
68
+ except AttributeError:
69
+ # a MultipartEncoder
70
+ try:
71
+ # During read(), MultipartEncoder lazily loads its upload parts into self._buffer
72
+ # depending on the requested number of bytes. Then a regular read() on self._buffer
73
+ # is performed. Therefore, rewinding self._buffer effectively rewinds the whole
74
+ # MultipartEncoder content.
75
+ # pylint: disable=protected-access
76
+ f._buffer.seek(0)
77
+ except AttributeError as exc:
78
+ raise TypeError(f'akamai.edgegrid: unexpected body type: {type(f).__name__}') from exc
79
+ return res
80
+
81
+
82
+ def read_body_content(body, max_body):
83
+ """The body argument may be one of the following:
84
+ 1. bytes object
85
+ 2. str object
86
+ 3. _io.BufferedReader object for body input from file
87
+ 4. requests_toolbelt.MultipartEncoder object for multipart form requests
88
+ 5. httpie.uploads.ChunkedUploadStream object for chunked transfer encoding
89
+ (when --chunked, currently not supported)
90
+ May raise TypeError for unexpected input type or OSError for I/O operations.
91
+ """
92
+ if isinstance(body, bytes):
93
+ return body[:max_body]
94
+ if isinstance(body, str):
95
+ return body.encode('utf8')[:max_body]
96
+ return read_stream_and_rewind(body, max_body)
97
+
98
+
99
+ def determine_body_len(body):
100
+ """May raise exception if body appears to be a file (is not a str, bytes or MultipartEncoder)
101
+ but either:
102
+ - has no fileno method (TypeError)
103
+ - raises OSError while trying to calculate the length using the file descriptor"""
104
+ if isinstance(body, bytes):
105
+ return len(body)
106
+ if isinstance(body, str):
107
+ return len(body.encode('utf8'))
108
+
109
+ try:
110
+ # a MultipartEncoder?
111
+ return body.len
112
+ except AttributeError:
113
+ # a file object?
114
+ try:
115
+ return os.stat(body.fileno()).st_size
116
+ except AttributeError as exc:
117
+ raise TypeError(
118
+ f'akamai.edgegrid: unexpected body type: {type(body).__name__}') from exc
119
+
120
+
121
+ class EdgeGridAuth(AuthBase):
122
+ """A Requests authentication handler that provides Akamai {OPEN} EdgeGrid support.
123
+
124
+ Basic Usage::
125
+ >>> import requests
126
+ >>> from akamai.edgegrid import EdgeGridAuth
127
+ >>> s = requests.Session()
128
+ >>> s.auth = EdgeGridAuth(
129
+ client_token='cccccccccccccccccc',
130
+ client_secret='sssssssssssssssss',
131
+ access_token='aaaaaaaaaaaaaaaaa'
132
+ )
133
+
134
+ """
135
+
136
+ def __init__(self, client_token, client_secret, access_token,
137
+ *, headers_to_sign=(), max_body=131072):
138
+ """Initialize authentication using the given parameters from the Akamai OPEN APIs
139
+ Interface:
140
+
141
+ :param client_token: Client token provided by "Credentials" ui
142
+ :param client_secret: Client secret provided by "Credentials" ui
143
+ :param access_token: Access token provided by "Authorizations" ui
144
+ :param headers_to_sign: An ordered list header names that will be included in
145
+ the signature. This will be provided by specific APIs. (default [])
146
+ :param max_body: Maximum content body size for POST requests. This will be provided by
147
+ specific APIs. (default 131072)
148
+
149
+ """
150
+ # pylint: disable=invalid-name
151
+ self.ah = EdgeGridAuthHeaders(
152
+ client_token,
153
+ client_secret,
154
+ access_token,
155
+ headers_to_sign=headers_to_sign,
156
+ max_body=max_body
157
+ )
158
+
159
+ @staticmethod
160
+ def from_edgerc(rcinput, section='default'):
161
+ """
162
+ Returns an EdgeGridAuth object from the configuration from the given section
163
+ of the given edgerc file.
164
+
165
+ :param rcinput: EdgeRc instance or path to the edgerc file
166
+ :param section: the section to use (this is the [bracketed] part of the edgerc,
167
+ default is 'default')
168
+
169
+ """
170
+ if isinstance(rcinput, EdgeRc):
171
+ edgerc = rcinput
172
+ else:
173
+ edgerc = EdgeRc(rcinput)
174
+
175
+ return EdgeGridAuth(
176
+ client_token=edgerc.get(section, 'client_token'),
177
+ client_secret=edgerc.get(section, 'client_secret'),
178
+ access_token=edgerc.get(section, 'access_token'),
179
+ headers_to_sign=edgerc.getlist(section, 'headers_to_sign'),
180
+ max_body=edgerc.getint(section, 'max_body')
181
+ )
182
+
183
+ def handle_redirect(self, res, **_):
184
+ if res.is_redirect:
185
+ redirect_location = res.headers['location']
186
+
187
+ logger.debug("signing the redirected url: %s", redirect_location)
188
+ request_to_sign = res.request.copy()
189
+ request_to_sign.url = redirect_location
190
+
191
+ res.request.headers['Authorization'] = self.ah.make_auth_header(
192
+ request_to_sign, eg_timestamp(), new_nonce())
193
+
194
+ def __call__(self, r):
195
+ timestamp = eg_timestamp()
196
+ nonce = new_nonce()
197
+
198
+ r.headers['Authorization'] = self.ah.make_auth_header(r, timestamp, nonce)
199
+ r.register_hook('response', self.handle_redirect)
200
+ return r
201
+
202
+
203
+ class EdgeGridAuthHeaders:
204
+ """
205
+ A class for preparing requests authentication headers needed for
206
+ Akamai {OPEN} EdgeGrid support.
207
+ """
208
+ def __init__(self, client_token, client_secret, access_token,
209
+ *, headers_to_sign=(), max_body=131072):
210
+ self.client_token = client_token
211
+ self.client_secret = client_secret
212
+ self.access_token = access_token
213
+ self.headers_to_sign = [h.lower() for h in headers_to_sign]
214
+ self.max_body = max_body
215
+
216
+ def make_signing_key(self, timestamp):
217
+ signing_key = base64_hmac_sha256(timestamp, self.client_secret)
218
+ logger.debug('signing key: %s', signing_key)
219
+ return signing_key
220
+
221
+ def canonicalize_headers(self, headers):
222
+ spaces_re = re.compile('\\s+')
223
+
224
+ # note: r.headers is a case-insensitive dict and self.headers_to_sign
225
+ # should already be in lowercase at this point
226
+ # pylint: disable=consider-using-f-string
227
+ return '\t'.join([
228
+ "%s:%s" % (h, spaces_re.sub(' ', headers[h].strip()))
229
+ for h in self.headers_to_sign if h in headers
230
+ ])
231
+
232
+ def make_content_hash(self, body, method):
233
+ logger.debug("body is '%s'", body)
234
+ content_hash = ""
235
+ if method == 'POST':
236
+ buf = read_body_content(body, self.max_body)
237
+ if buf:
238
+ logger.debug("signing content: %s", buf)
239
+ content_hash = base64_sha256(buf)
240
+ try:
241
+ body_len = determine_body_len(body)
242
+ if body_len > self.max_body:
243
+ logger.debug(
244
+ "data length %d is larger than maximum %d "
245
+ "and will be truncated for computing the hash",
246
+ body_len, self.max_body)
247
+ except (TypeError, OSError) as e:
248
+ # body length is needed only for debugging: just log a possible exception
249
+ logger.warning("cannot determine length of request body=%s: %s", body, e)
250
+ logger.debug("content hash is '%s'", content_hash)
251
+ return content_hash
252
+
253
+ @staticmethod
254
+ def get_header_versions(header=None):
255
+ if header is None:
256
+ header = {}
257
+
258
+ version_header = ''
259
+ akamai_cli = os.getenv('AKAMAI_CLI')
260
+ akamai_cli_version = os.getenv('AKAMAI_CLI_VERSION')
261
+ if akamai_cli and akamai_cli_version:
262
+ version_header += " AkamaiCLI/" + akamai_cli_version
263
+
264
+ akamai_cli_command = os.getenv('AKAMAI_CLI_COMMAND')
265
+ akamai_cli_command_version = os.getenv('AKAMAI_CLI_COMMAND_VERSION')
266
+ if akamai_cli_command and akamai_cli_command_version:
267
+ version_header += " AkamaiCLI-" + akamai_cli_command + \
268
+ "/" + akamai_cli_command_version
269
+
270
+ if version_header != '':
271
+ if 'User-Agent' not in header:
272
+ header['User-Agent'] = version_header.strip()
273
+ else:
274
+ header['User-Agent'] += version_header
275
+
276
+ return header
277
+
278
+ def make_data_to_sign(self, request, auth_header):
279
+ parsed_url = urlparse(request.url)
280
+
281
+ if request.headers.get('Host', False):
282
+ netloc = request.headers['Host']
283
+ else:
284
+ netloc = parsed_url.netloc
285
+
286
+ self.get_header_versions(request.headers)
287
+
288
+ data_to_sign = '\t'.join([
289
+ request.method,
290
+ parsed_url.scheme,
291
+ netloc,
292
+ # Note: relative URL constraints are handled by requests when it sets up 'r'
293
+ parsed_url.path + (';' + parsed_url.params if parsed_url.params else "") +
294
+ ('?' + parsed_url.query if parsed_url.query else ""),
295
+ self.canonicalize_headers(request.headers),
296
+ self.make_content_hash(request.body or '', request.method),
297
+ auth_header
298
+ ])
299
+ logger.debug('data to sign: %s', '\\t'.join(data_to_sign.split('\t')))
300
+ return data_to_sign
301
+
302
+ def sign_request(self, request, timestamp, auth_header):
303
+ return base64_hmac_sha256(
304
+ self.make_data_to_sign(request, auth_header),
305
+ self.make_signing_key(timestamp)
306
+ )
307
+
308
+ def make_auth_header(self, request, timestamp, nonce):
309
+ kvps = [
310
+ ('client_token', self.client_token),
311
+ ('access_token', self.access_token),
312
+ ('timestamp', timestamp),
313
+ ('nonce', nonce),
314
+ ]
315
+ auth_header = "EG1-HMAC-SHA256 " + \
316
+ ';'.join([f"{k}={v}" for k, v in kvps]) + ';'
317
+ logger.debug('unsigned authorization header: %s', auth_header)
318
+
319
+ signed_auth_header = auth_header + \
320
+ 'signature=' + self.sign_request(request, timestamp, auth_header)
321
+
322
+ logger.debug('signed authorization header: %s', signed_auth_header)
323
+ return signed_auth_header
@@ -0,0 +1,37 @@
1
+ """Support for .edgerc file format"""
2
+
3
+ import logging
4
+ from configparser import ConfigParser
5
+ from os.path import expanduser
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class EdgeRc(ConfigParser):
11
+ """Class for managing .edgerc files"""
12
+ def __init__(self, filename):
13
+ ConfigParser.__init__(self,
14
+ {'client_token': '',
15
+ 'client_secret': '',
16
+ 'host': '',
17
+ 'access_token': '',
18
+ 'max_body': '131072',
19
+ 'headers_to_sign': 'None'})
20
+ logger.debug("loading edgerc from %s", filename)
21
+
22
+ self.read(expanduser(filename))
23
+
24
+ logger.debug("successfully loaded edgerc")
25
+
26
+ def optionxform(self, optionstr):
27
+ """support both max_body and max-body style keys"""
28
+ return optionstr.replace('-', '_')
29
+
30
+ def getlist(self, section, option):
31
+ """
32
+ returns the named option as a list, splitting the original value by ','
33
+ """
34
+ value = self.get(section, option)
35
+ if value:
36
+ return value.split(',')
37
+ return None
File without changes
@@ -0,0 +1,44 @@
1
+ # pylint: disable=missing-function-docstring
2
+ """Unit tests helpers"""
3
+
4
+ import json
5
+ import os
6
+ import pytest
7
+
8
+ test_dir = os.path.abspath(os.path.dirname(__file__))
9
+
10
+
11
+ def cases():
12
+ with open(f'{test_dir}/testcases.json', encoding="utf-8") as data:
13
+ data = json.load(data)
14
+ return data
15
+
16
+
17
+ @pytest.fixture(scope="package")
18
+ def testdata():
19
+ with open(f'{test_dir}/testdata.json', encoding="utf-8") as data:
20
+ data = json.load(data)
21
+ return data
22
+
23
+
24
+ def names(tests):
25
+ result = []
26
+ for test in tests:
27
+ result.append(test["testName"])
28
+ return result
29
+
30
+
31
+ @pytest.fixture
32
+ def multipart_fields():
33
+ with open(f'{test_dir}/sample_file.txt', "rb") as f:
34
+ result = {
35
+ "foo": "bar",
36
+ "baz": ("sample_file.txt", f),
37
+ }
38
+ yield result
39
+
40
+
41
+ @pytest.fixture
42
+ def sample_file():
43
+ with open(f'{test_dir}/sample_file.txt', "rb") as f:
44
+ yield f