Skip to content

Commit ffc5c93

Browse files
committed
allow temporary credential overrides with api calls for multi-cloud codebases
1 parent e1aef05 commit ffc5c93

7 files changed

Lines changed: 140 additions & 5 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,27 @@ cloudinary.utils.cloudinary_url("sample.jpg", width=100, height=150, crop="fill"
7575
cloudinary.uploader.upload("my_picture.jpg")
7676
```
7777

78+
### Per-call account configuration
79+
For multi-account or multi-tenant applications, you can override configuration for a single API call by passing
80+
`cloudinary_config`. This does not mutate the global SDK configuration.
81+
82+
```python
83+
cloudinary.api.ping(cloudinary_config={
84+
"cloud_name": "tenant_cloud",
85+
"api_key": "tenant_key",
86+
"api_secret": "tenant_secret",
87+
})
88+
89+
cloudinary.uploader.upload(
90+
"my_picture.jpg",
91+
cloudinary_config={
92+
"cloud_name": "tenant_cloud",
93+
"api_key": "tenant_key",
94+
"api_secret": "tenant_secret",
95+
},
96+
)
97+
```
98+
7899
### Django
79100
- [See full documentation](https://cloudinary.com/documentation/django_image_and_video_upload#django_forms_and_models).
80101

cloudinary/api_client/call_account_api.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import cloudinary
22
from cloudinary.api_client.execute_request import execute_request
33
from cloudinary.provisioning.account_config import account_config
4-
from cloudinary.utils import get_http_connector, normalize_params
4+
from cloudinary.utils import get_http_connector, normalize_params, consume_cloudinary_config
55

66
PROVISIONING_SUB_PATH = "provisioning"
77
ACCOUNT_SUB_PATH = "accounts"
@@ -10,13 +10,15 @@
1010

1111
# Account-scoped, authenticated call: provisioning/accounts/{account_id}/...
1212
def _call_account_api(method, uri, params=None, headers=None, **options):
13+
options = consume_cloudinary_config(options)
1314
account_uri = [ACCOUNT_SUB_PATH, _account_id(options)] + uri
1415
return _execute_account_request(method, account_uri, _account_auth(options),
1516
params=params, headers=headers, **options)
1617

1718

1819
# Public, unauthenticated call: provisioning/... with no account_id or credentials
1920
def _call_public_account_api(method, uri, params=None, headers=None, **options):
21+
options = consume_cloudinary_config(options)
2022
return _execute_account_request(method, uri, {"anonymous": True},
2123
params=params, headers=headers, **options)
2224

@@ -41,6 +43,7 @@ def _account_auth(options):
4143
# Core transport: builds the provisioning URL and dispatches with the resolved auth.
4244
# The API version can be overridden via the "api_version" option (defaults to cloudinary.API_VERSION).
4345
def _execute_account_request(method, uri, auth, params=None, headers=None, **options):
46+
options = consume_cloudinary_config(options)
4447
prefix = options.pop("upload_prefix",
4548
cloudinary.config().upload_prefix) or "https://api.cloudinary.com"
4649
api_version = options.pop("api_version", cloudinary.API_VERSION)

cloudinary/api_client/call_api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import cloudinary
44
from cloudinary.api_client.execute_request import execute_request
5-
from cloudinary.utils import get_http_connector, normalize_params
5+
from cloudinary.utils import get_http_connector, normalize_params, consume_cloudinary_config
66

77
logger = cloudinary.logger
88
_http = get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS)
@@ -52,6 +52,7 @@ def call_api(method, uri, params, **options):
5252

5353

5454
def _call_api(method, uri, params=None, body=None, headers=None, extra_headers=None, **options):
55+
options = consume_cloudinary_config(options)
5556
prefix = options.pop("upload_prefix",
5657
cloudinary.config().upload_prefix) or "https://api.cloudinary.com"
5758
cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name)

cloudinary/uploader.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def upload(file, **options):
194194
- etc.
195195
:rtype: dict
196196
"""
197+
options = utils.consume_cloudinary_config(options)
197198
params = utils.build_upload_params(**options)
198199
return call_cacheable_api("upload", params, file=file, **options)
199200

@@ -275,12 +276,13 @@ def _upload_large_part_with_auth_retry(file, http_headers, options):
275276
:rtype: dict
276277
"""
277278
# Pin the token so the value handed to the callback is the one actually sent.
278-
token = cloudinary.config().oauth_token
279+
options = utils.consume_cloudinary_config(options)
280+
token = options.get("oauth_token", cloudinary.config().oauth_token)
279281
pinned = dict(options, oauth_token=token) if token else options
280282
try:
281283
return upload_large_part(file, http_headers=http_headers, **pinned)
282284
except AuthorizationRequired:
283-
callback = cloudinary.config().oauth_token_refresh_callback
285+
callback = options.get("oauth_token_refresh_callback", cloudinary.config().oauth_token_refresh_callback)
284286
if not callback:
285287
raise
286288
callback(token)
@@ -303,6 +305,8 @@ def upload_large(file, **options):
303305
:return: The result of the upload API call.
304306
:rtype: dict
305307
"""
308+
options = utils.consume_cloudinary_config(options)
309+
306310
if utils.is_remote_url(file):
307311
return upload(file, **options)
308312

@@ -357,6 +361,7 @@ def upload_large_part(file, **options):
357361
:return: The result of the chunk upload API call.
358362
:rtype: dict
359363
"""
364+
options = utils.consume_cloudinary_config(options)
360365
params = utils.build_upload_params(**options)
361366

362367
if 'resource_type' not in options:
@@ -859,6 +864,7 @@ def call_cacheable_api(action, params, http_headers=None, return_error=False, un
859864
:return: The parsed JSON response from Cloudinary.
860865
:rtype: dict
861866
"""
867+
options = utils.consume_cloudinary_config(options)
862868
result = call_api(action, params, http_headers, return_error, unsigned, file, timeout, **options)
863869
if "use_cache" in options or cloudinary.config().use_cache:
864870
_save_responsive_breakpoints_to_cache(result)
@@ -892,6 +898,7 @@ def call_api(action, params, http_headers=None, return_error=False, unsigned=Fal
892898
893899
:raises Error: If an HTTP error or a Cloudinary error occurs.
894900
"""
901+
options = utils.consume_cloudinary_config(options)
895902
params = utils.cleanup_params(params)
896903

897904
headers = {"User-Agent": cloudinary.get_user_agent()}

cloudinary/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
from six import iteritems
2121
from urllib3 import ProxyManager, PoolManager
2222

23+
try:
24+
from collections.abc import Mapping
25+
except ImportError:
26+
from collections import Mapping
27+
2328
import cloudinary
2429
from cloudinary import auth_token
2530
from cloudinary.api_client.tcp_keep_alive_manager import TCPKeepAlivePoolManager, TCPKeepAliveProxyManager
@@ -171,6 +176,8 @@
171176
SIGNATURE_SHA256: hashlib.sha256,
172177
}
173178

179+
CLOUDINARY_CONFIG_OPTION = "cloudinary_config"
180+
174181

175182
def compute_hex_hash(s, algorithm=SIGNATURE_SHA1):
176183
"""
@@ -196,6 +203,21 @@ def build_array(arg):
196203
return [arg]
197204

198205

206+
def consume_cloudinary_config(options):
207+
config_overrides = options.pop(CLOUDINARY_CONFIG_OPTION, None)
208+
209+
if config_overrides is None:
210+
return options
211+
212+
if not isinstance(config_overrides, Mapping):
213+
raise ValueError("{} must be a dictionary".format(CLOUDINARY_CONFIG_OPTION))
214+
215+
for key, value in config_overrides.items():
216+
options.setdefault(key, value)
217+
218+
return options
219+
220+
199221
def build_list_of_dicts(val):
200222
"""
201223
Converts a value that can be presented as a list of dict.
@@ -614,6 +636,7 @@ def normalize_params(params):
614636

615637

616638
def sign_request(params, options):
639+
options = consume_cloudinary_config(options)
617640
api_key = options.get("api_key", cloudinary.config().api_key)
618641
if not api_key:
619642
raise ValueError("Must supply api_key")
@@ -798,6 +821,7 @@ def unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain,
798821

799822

800823
def build_distribution_domain(options):
824+
options = consume_cloudinary_config(options)
801825
source = options.pop('source', '')
802826
cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name or None)
803827
if cloud_name is None:
@@ -906,6 +930,7 @@ def cloudinary_url(source, **options):
906930

907931

908932
def base_api_url(path, **options):
933+
options = consume_cloudinary_config(options)
909934
cloudinary_prefix = options.get("upload_prefix", cloudinary.config().upload_prefix) \
910935
or "https://api.cloudinary.com"
911936
cloud_name = options.get("cloud_name", cloudinary.config().cloud_name)
@@ -1161,6 +1186,7 @@ def build_custom_headers(headers):
11611186

11621187

11631188
def build_upload_params(**options):
1189+
options = consume_cloudinary_config(options)
11641190
params = {param_name: options.get(param_name) for param_name in __SIMPLE_UPLOAD_PARAMS if param_name in options}
11651191
params["upload_preset"] = params.pop("upload_preset", cloudinary.config().upload_preset)
11661192

test/test_api_authorization.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import cloudinary
66
from cloudinary import api
77
from cloudinary import uploader
8-
from test.helper_test import TEST_IMAGE, get_headers, get_params, URLLIB3_REQUEST, patch
8+
from test.helper_test import TEST_IMAGE, get_headers, get_params, get_uri, URLLIB3_REQUEST, patch
99
from test.test_api import MOCK_RESPONSE
1010
from test.test_config import OAUTH_TOKEN, CLOUD_NAME, API_KEY, API_SECRET
1111
from test.test_uploader import API_TEST_PRESET
@@ -61,6 +61,29 @@ def test_missing_credentials_admin_api(self, mocker):
6161
with six.assertRaisesRegex(self, Exception, "Must supply api_key"):
6262
api.ping()
6363

64+
@patch(URLLIB3_REQUEST)
65+
def test_temporary_config_admin_api(self, mocker):
66+
self.config.oauth_token = None
67+
self.config.api_key = None
68+
self.config.api_secret = None
69+
mocker.return_value = MOCK_RESPONSE
70+
71+
api.ping(cloudinary_config={
72+
"cloud_name": "temporary_cloud",
73+
"api_key": "temporary_key",
74+
"api_secret": "temporary_secret",
75+
})
76+
77+
headers = get_headers(mocker)
78+
79+
self.assertTrue("authorization" in headers)
80+
self.assertEqual("Basic dGVtcG9yYXJ5X2tleTp0ZW1wb3Jhcnlfc2VjcmV0", headers["authorization"])
81+
self.assertTrue(get_uri(mocker).endswith("/temporary_cloud/ping"))
82+
83+
self.assertIsNone(self.config.api_key)
84+
self.assertIsNone(self.config.api_secret)
85+
self.assertEqual(CLOUD_NAME, self.config.cloud_name)
86+
6487
@patch(URLLIB3_REQUEST)
6588
def test_oauth_token_upload_api(self, mocker):
6689
self.config.oauth_token = OAUTH_TOKEN
@@ -119,6 +142,31 @@ def test_missing_credentials_upload_api(self, mocker):
119142
params = get_params(mocker)
120143
self.assertTrue("upload_preset" in params)
121144

145+
@patch(URLLIB3_REQUEST)
146+
def test_temporary_config_upload_api(self, mocker):
147+
self.config.oauth_token = None
148+
self.config.api_key = None
149+
self.config.api_secret = None
150+
mocker.return_value = MOCK_RESPONSE
151+
152+
uploader.upload(TEST_IMAGE, cloudinary_config={
153+
"cloud_name": "temporary_cloud",
154+
"api_key": "temporary_key",
155+
"api_secret": "temporary_secret",
156+
"upload_preset": API_TEST_PRESET,
157+
})
158+
159+
self.assertTrue(get_uri(mocker).endswith("/temporary_cloud/image/upload"))
160+
161+
params = get_params(mocker)
162+
self.assertEqual("temporary_key", params["api_key"])
163+
self.assertEqual(API_TEST_PRESET, params["upload_preset"])
164+
self.assertIn("signature", params)
165+
166+
self.assertIsNone(self.config.api_key)
167+
self.assertIsNone(self.config.api_secret)
168+
self.assertEqual(CLOUD_NAME, self.config.cloud_name)
169+
122170

123171
if __name__ == '__main__':
124172
unittest.main()

test/test_provisioning_api.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,5 +355,34 @@ def test_create_agent_account_parses_response(self):
355355
self.assertIn("guidance", res)
356356

357357

358+
class AccountApiTemporaryConfigTest(unittest.TestCase):
359+
def tearDown(self):
360+
reset_config()
361+
362+
def test_temporary_config_account_api(self):
363+
with patch(URLLIB3_REQUEST) as mocker:
364+
mocker.return_value = api_response_mock()
365+
cloudinary.provisioning.sub_accounts(cloudinary_config={
366+
"account_id": "temporary_account",
367+
"provisioning_api_key": "temporary_key",
368+
"provisioning_api_secret": "temporary_secret",
369+
"upload_prefix": "https://custom.example.com",
370+
})
371+
372+
self.assertEqual("GET", get_method(mocker))
373+
self.assertTrue(
374+
get_uri(mocker).endswith("/v1_1/provisioning/accounts/temporary_account/sub_accounts")
375+
)
376+
377+
headers = get_headers(mocker)
378+
self.assertTrue("authorization" in headers)
379+
self.assertEqual("Basic dGVtcG9yYXJ5X2tleTp0ZW1wb3Jhcnlfc2VjcmV0", headers["authorization"])
380+
381+
config = account_config()
382+
self.assertIsNone(config.account_id)
383+
self.assertIsNone(config.provisioning_api_key)
384+
self.assertIsNone(config.provisioning_api_secret)
385+
386+
358387
if __name__ == '__main__':
359388
unittest.main()

0 commit comments

Comments
 (0)