mtn-cloud 0.3.2__tar.gz → 0.3.3__tar.gz
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.
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/PKG-INFO +14 -2
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/README.md +12 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/advanced-cookbook.md +15 -4
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/client.md +17 -3
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/_version.py +1 -1
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/client.py +9 -8
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/config.py +28 -8
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/http.py +175 -40
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_client.py +35 -2
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_config.py +40 -4
- mtn_cloud-0.3.3/tests/test_http.py +335 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/.gitignore +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/LICENSE +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-overview.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/archive-buckets.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/backups.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/clouds.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/groups.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/index.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/instance-types.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/instances.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/networks.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/plans.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/security-groups.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/storage-buckets.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/virtual-images.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/backups.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/index.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/instances.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/networking.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/quickstart.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/recipes.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/security-groups.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/storage.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/stylesheets/extra.css +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/virtual-images.md +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/pyproject.toml +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/__init__.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/exceptions.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/__init__.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/archive.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/backup.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/base.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/cloud.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/group.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/instance.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/instance_type.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/network.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/plan.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/resource_pool.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/security_group.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/snapshot.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/storage_bucket.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/user.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/virtual_image.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/volume.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/py.typed +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/__init__.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/archive_buckets.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/backups.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/base.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/clouds.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/groups.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/instance_types.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/instances.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/networks.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/plans.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/security_groups.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/storage_buckets.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/virtual_images.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/__init__.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/conftest.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_archive_buckets.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_backups.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_clouds.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_exceptions.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_groups.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_instance_types.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_instances.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_networks.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_plans.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_security_groups.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_storage_buckets.py +0 -0
- {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_virtual_images.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: mtn-cloud
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Community Python SDK for MTN Cloud - Deploy and manage cloud resources with ease
|
|
5
5
|
Project-URL: Homepage, https://github.com/mahveotm/mtn-cloud-python
|
|
6
6
|
Project-URL: Documentation, https://github.com/mahveotm/mtn-cloud-python#readme
|
|
@@ -405,6 +405,8 @@ Environment variables:
|
|
|
405
405
|
| `MTN_CLOUD_MAX_RETRIES` | Maximum retry attempts | `3` |
|
|
406
406
|
| `MTN_CLOUD_RETRY_DELAY` | Retry backoff factor | `1.0` |
|
|
407
407
|
| `MTN_CLOUD_VERIFY_SSL` | Enable SSL verification | `true` |
|
|
408
|
+
| `MTN_CLOUD_USER_AGENT` | Application identity appended to the SDK user agent | - |
|
|
409
|
+
| `MTN_CLOUD_DEBUG` | Enable bounded, secret-redacted HTTP debug logs | `false` |
|
|
408
410
|
|
|
409
411
|
Programmatic configuration:
|
|
410
412
|
|
|
@@ -417,11 +419,21 @@ config = MTNCloudConfig(
|
|
|
417
419
|
max_retries=5,
|
|
418
420
|
retry_delay=1.5,
|
|
419
421
|
verify_ssl=True,
|
|
422
|
+
user_agent="my-automation/1.0", # appended after the required SDK identity
|
|
420
423
|
)
|
|
421
424
|
|
|
422
425
|
cloud = MTNCloud(config=config)
|
|
423
426
|
```
|
|
424
427
|
|
|
428
|
+
Tokens and passwords are stored as masked Pydantic secret values, and debug
|
|
429
|
+
logging recursively redacts credential-shaped fields. The transport retries
|
|
430
|
+
transient failures only for safe read methods (`GET`, `HEAD`, and `OPTIONS`);
|
|
431
|
+
create, update, action, and delete requests are never status-retried automatically.
|
|
432
|
+
|
|
433
|
+
The default `mtn-cloud-python/<version>` user-agent prefix is required by the
|
|
434
|
+
MTN Cloud API edge. A configured `user_agent` is treated as an application
|
|
435
|
+
suffix so the SDK identity is preserved.
|
|
436
|
+
|
|
425
437
|
## Examples
|
|
426
438
|
|
|
427
439
|
| Script | What it demonstrates |
|
|
@@ -363,6 +363,8 @@ Environment variables:
|
|
|
363
363
|
| `MTN_CLOUD_MAX_RETRIES` | Maximum retry attempts | `3` |
|
|
364
364
|
| `MTN_CLOUD_RETRY_DELAY` | Retry backoff factor | `1.0` |
|
|
365
365
|
| `MTN_CLOUD_VERIFY_SSL` | Enable SSL verification | `true` |
|
|
366
|
+
| `MTN_CLOUD_USER_AGENT` | Application identity appended to the SDK user agent | - |
|
|
367
|
+
| `MTN_CLOUD_DEBUG` | Enable bounded, secret-redacted HTTP debug logs | `false` |
|
|
366
368
|
|
|
367
369
|
Programmatic configuration:
|
|
368
370
|
|
|
@@ -375,11 +377,21 @@ config = MTNCloudConfig(
|
|
|
375
377
|
max_retries=5,
|
|
376
378
|
retry_delay=1.5,
|
|
377
379
|
verify_ssl=True,
|
|
380
|
+
user_agent="my-automation/1.0", # appended after the required SDK identity
|
|
378
381
|
)
|
|
379
382
|
|
|
380
383
|
cloud = MTNCloud(config=config)
|
|
381
384
|
```
|
|
382
385
|
|
|
386
|
+
Tokens and passwords are stored as masked Pydantic secret values, and debug
|
|
387
|
+
logging recursively redacts credential-shaped fields. The transport retries
|
|
388
|
+
transient failures only for safe read methods (`GET`, `HEAD`, and `OPTIONS`);
|
|
389
|
+
create, update, action, and delete requests are never status-retried automatically.
|
|
390
|
+
|
|
391
|
+
The default `mtn-cloud-python/<version>` user-agent prefix is required by the
|
|
392
|
+
MTN Cloud API edge. A configured `user_agent` is treated as an application
|
|
393
|
+
suffix so the SDK identity is preserved.
|
|
394
|
+
|
|
383
395
|
## Examples
|
|
384
396
|
|
|
385
397
|
| Script | What it demonstrates |
|
|
@@ -5,14 +5,16 @@ Production-friendly patterns for robust automation.
|
|
|
5
5
|
## Configure a Resilient Client
|
|
6
6
|
|
|
7
7
|
```python
|
|
8
|
-
from mtn_cloud import MTNCloud
|
|
8
|
+
from mtn_cloud import MTNCloud, MTNCloudConfig
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
config = MTNCloudConfig(
|
|
11
11
|
token="your-api-token",
|
|
12
12
|
timeout=45,
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
max_retries=3,
|
|
14
|
+
retry_delay=1.0,
|
|
15
|
+
user_agent="my-automation/1.0",
|
|
15
16
|
)
|
|
17
|
+
cloud = MTNCloud(config=config)
|
|
16
18
|
```
|
|
17
19
|
|
|
18
20
|
Use environment variables for deployment:
|
|
@@ -24,6 +26,15 @@ export MTN_CLOUD_MAX_RETRIES="5"
|
|
|
24
26
|
export MTN_CLOUD_RETRY_DELAY="1.5"
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
Retries apply only to safe reads (`GET`, `HEAD`, and `OPTIONS`). The SDK does
|
|
30
|
+
not automatically retry provisioning, actions, updates, or deletes because a
|
|
31
|
+
server or proxy error does not prove that a write was rejected before execution.
|
|
32
|
+
|
|
33
|
+
`user_agent` adds an application identity after the SDK's required
|
|
34
|
+
`mtn-cloud-python/<version>` prefix. Debug logging is bounded and recursively
|
|
35
|
+
redacts tokens, passwords, access keys, secret keys, cookies, authorization
|
|
36
|
+
values, and similar credential fields.
|
|
37
|
+
|
|
27
38
|
## Pattern: Idempotent "Get or Create"
|
|
28
39
|
|
|
29
40
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Client: `MTNCloud`
|
|
2
2
|
|
|
3
|
-
### `MTNCloud(token=None, username=None, password=None, url=None, timeout=None, verify_ssl=
|
|
3
|
+
### `MTNCloud(token=None, username=None, password=None, url=None, timeout=None, verify_ssl=None, config=None)`
|
|
4
4
|
|
|
5
5
|
Creates an SDK client and lazy-loads resource managers.
|
|
6
6
|
|
|
@@ -10,12 +10,27 @@ Creates an SDK client and lazy-loads resource managers.
|
|
|
10
10
|
- `username`, `password`: OAuth password-grant alternative
|
|
11
11
|
- `url`: MTN Cloud base console URL, no `/api` needed
|
|
12
12
|
- `timeout`: request timeout in seconds
|
|
13
|
-
- `verify_ssl`: enable/disable TLS certificate validation
|
|
13
|
+
- `verify_ssl`: enable/disable TLS certificate validation; when omitted,
|
|
14
|
+
`MTN_CLOUD_VERIFY_SSL` or the configuration default is used
|
|
14
15
|
- `config`: explicit `MTNCloudConfig` object (overrides other args)
|
|
15
16
|
- Returns: `MTNCloud`
|
|
16
17
|
- Raises:
|
|
17
18
|
- `pydantic.ValidationError` if config values violate `MTNCloudConfig` constraints
|
|
18
19
|
|
|
20
|
+
Tokens and passwords are stored as masked secret values. Use
|
|
21
|
+
`config.get_token_value()` or `config.get_password_value()` only when an explicit
|
|
22
|
+
integration needs the underlying value; normal SDK usage never needs to reveal
|
|
23
|
+
them.
|
|
24
|
+
|
|
25
|
+
The transport:
|
|
26
|
+
|
|
27
|
+
- preserves the `mtn-cloud-python/<version>` user-agent identity required by the
|
|
28
|
+
MTN API edge;
|
|
29
|
+
- recursively redacts secrets from debug logs and raised error response payloads;
|
|
30
|
+
- retries 429 and transient 5xx responses only for `GET`, `HEAD`, and `OPTIONS`;
|
|
31
|
+
- does not automatically status-retry `POST`, `PUT`, `PATCH`, or `DELETE` calls;
|
|
32
|
+
- handles both delta-seconds and HTTP-date `Retry-After` headers.
|
|
33
|
+
|
|
19
34
|
### `whoami() -> User`
|
|
20
35
|
|
|
21
36
|
- Endpoint: `GET /api/whoami`
|
|
@@ -41,4 +56,3 @@ Connectivity/auth convenience check.
|
|
|
41
56
|
- Parameters: none
|
|
42
57
|
- Returns: `None`
|
|
43
58
|
- Raises: none
|
|
44
|
-
|
|
@@ -82,7 +82,7 @@ class MTNCloud:
|
|
|
82
82
|
password: str | None = None,
|
|
83
83
|
url: str | None = None,
|
|
84
84
|
timeout: float | None = None,
|
|
85
|
-
verify_ssl: bool =
|
|
85
|
+
verify_ssl: bool | None = None,
|
|
86
86
|
config: MTNCloudConfig | None = None,
|
|
87
87
|
) -> None:
|
|
88
88
|
"""
|
|
@@ -94,7 +94,8 @@ class MTNCloud:
|
|
|
94
94
|
password: Password for authentication
|
|
95
95
|
url: API base URL (default: https://console.cloud.mtn.ng)
|
|
96
96
|
timeout: Request timeout in seconds
|
|
97
|
-
verify_ssl: Verify SSL certificates
|
|
97
|
+
verify_ssl: Verify SSL certificates. When omitted, configuration and
|
|
98
|
+
environment settings are respected.
|
|
98
99
|
config: Full configuration object (overrides other args)
|
|
99
100
|
"""
|
|
100
101
|
if config:
|
|
@@ -102,17 +103,17 @@ class MTNCloud:
|
|
|
102
103
|
else:
|
|
103
104
|
config_kwargs: dict[str, Any] = {}
|
|
104
105
|
|
|
105
|
-
if token:
|
|
106
|
+
if token is not None:
|
|
106
107
|
config_kwargs["token"] = token
|
|
107
|
-
if username:
|
|
108
|
+
if username is not None:
|
|
108
109
|
config_kwargs["username"] = username
|
|
109
|
-
if password:
|
|
110
|
+
if password is not None:
|
|
110
111
|
config_kwargs["password"] = password
|
|
111
|
-
if url:
|
|
112
|
+
if url is not None:
|
|
112
113
|
config_kwargs["url"] = url
|
|
113
|
-
if timeout:
|
|
114
|
+
if timeout is not None:
|
|
114
115
|
config_kwargs["timeout"] = timeout
|
|
115
|
-
if not
|
|
116
|
+
if verify_ssl is not None:
|
|
116
117
|
config_kwargs["verify_ssl"] = verify_ssl
|
|
117
118
|
|
|
118
119
|
self._config = MTNCloudConfig(**config_kwargs)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Configuration models and defaults for the MTN Cloud SDK."""
|
|
2
2
|
|
|
3
|
-
from pydantic import Field, field_validator
|
|
3
|
+
from pydantic import Field, SecretStr, field_validator
|
|
4
4
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
5
|
|
|
6
6
|
from mtn_cloud._version import __version__
|
|
@@ -32,7 +32,10 @@ class MTNCloudConfig(BaseSettings):
|
|
|
32
32
|
MTN_CLOUD_URL: API base URL
|
|
33
33
|
MTN_CLOUD_TIMEOUT: Request timeout in seconds
|
|
34
34
|
MTN_CLOUD_MAX_RETRIES: Maximum retry attempts
|
|
35
|
+
MTN_CLOUD_RETRY_DELAY: Base retry backoff factor
|
|
35
36
|
MTN_CLOUD_VERIFY_SSL: Enable/disable SSL verification
|
|
37
|
+
MTN_CLOUD_USER_AGENT: Application identity appended to the SDK user agent
|
|
38
|
+
MTN_CLOUD_DEBUG: Enable secret-redacted debug logging
|
|
36
39
|
"""
|
|
37
40
|
|
|
38
41
|
model_config = SettingsConfigDict(
|
|
@@ -43,7 +46,7 @@ class MTNCloudConfig(BaseSettings):
|
|
|
43
46
|
extra="ignore",
|
|
44
47
|
)
|
|
45
48
|
|
|
46
|
-
token:
|
|
49
|
+
token: SecretStr | None = Field(
|
|
47
50
|
default=None,
|
|
48
51
|
description="MTN Cloud API access token",
|
|
49
52
|
)
|
|
@@ -53,7 +56,7 @@ class MTNCloudConfig(BaseSettings):
|
|
|
53
56
|
description="MTN Cloud username (alternative to token)",
|
|
54
57
|
)
|
|
55
58
|
|
|
56
|
-
password:
|
|
59
|
+
password: SecretStr | None = Field(
|
|
57
60
|
default=None,
|
|
58
61
|
description="MTN Cloud password (use with username)",
|
|
59
62
|
)
|
|
@@ -96,7 +99,7 @@ class MTNCloudConfig(BaseSettings):
|
|
|
96
99
|
|
|
97
100
|
user_agent: str = Field(
|
|
98
101
|
default=DEFAULT_USER_AGENT,
|
|
99
|
-
description="
|
|
102
|
+
description="Application identity appended to the MTN-compatible SDK user agent",
|
|
100
103
|
)
|
|
101
104
|
|
|
102
105
|
debug: bool = Field(
|
|
@@ -110,6 +113,19 @@ class MTNCloudConfig(BaseSettings):
|
|
|
110
113
|
"""Ensure URL doesn't have trailing slash."""
|
|
111
114
|
return v.rstrip("/")
|
|
112
115
|
|
|
116
|
+
@field_validator("user_agent")
|
|
117
|
+
@classmethod
|
|
118
|
+
def normalize_user_agent(cls, value: str) -> str:
|
|
119
|
+
"""Preserve the SDK identity required by MTN's API edge."""
|
|
120
|
+
normalized = value.strip()
|
|
121
|
+
if not normalized:
|
|
122
|
+
raise ValueError("user_agent cannot be empty")
|
|
123
|
+
if "\r" in normalized or "\n" in normalized:
|
|
124
|
+
raise ValueError("user_agent cannot contain line breaks")
|
|
125
|
+
if DEFAULT_USER_AGENT in normalized:
|
|
126
|
+
return normalized
|
|
127
|
+
return f"{DEFAULT_USER_AGENT} {normalized}"
|
|
128
|
+
|
|
113
129
|
@property
|
|
114
130
|
def api_url(self) -> str:
|
|
115
131
|
"""Get the full API URL."""
|
|
@@ -120,6 +136,14 @@ class MTNCloudConfig(BaseSettings):
|
|
|
120
136
|
"""Check if any authentication credentials are configured."""
|
|
121
137
|
return bool(self.token or (self.username and self.password))
|
|
122
138
|
|
|
139
|
+
def get_token_value(self) -> str | None:
|
|
140
|
+
"""Return the token for the HTTP transport without exposing it in reprs."""
|
|
141
|
+
return self.token.get_secret_value() if self.token is not None else None
|
|
142
|
+
|
|
143
|
+
def get_password_value(self) -> str | None:
|
|
144
|
+
"""Return the password for the authentication transport."""
|
|
145
|
+
return self.password.get_secret_value() if self.password is not None else None
|
|
146
|
+
|
|
123
147
|
def get_auth_method(self) -> str:
|
|
124
148
|
"""Determine the authentication method to use."""
|
|
125
149
|
if self.token:
|
|
@@ -127,7 +151,3 @@ class MTNCloudConfig(BaseSettings):
|
|
|
127
151
|
elif self.username and self.password:
|
|
128
152
|
return "credentials"
|
|
129
153
|
return "none"
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
# Shared default configuration for callers that want module-level settings.
|
|
133
|
-
default_config = MTNCloudConfig()
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"""Low-level HTTP transport, authentication, and error mapping."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
4
8
|
from typing import Any, Literal
|
|
5
9
|
|
|
6
10
|
import requests
|
|
11
|
+
from pydantic import SecretStr
|
|
7
12
|
from requests.adapters import HTTPAdapter
|
|
8
13
|
from urllib3.util.retry import Retry
|
|
9
14
|
|
|
@@ -26,6 +31,52 @@ from mtn_cloud.exceptions import (
|
|
|
26
31
|
logger = logging.getLogger("mtn_cloud.http")
|
|
27
32
|
|
|
28
33
|
HTTPMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
34
|
+
SAFE_RETRY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
35
|
+
REDACTED = "<redacted>"
|
|
36
|
+
MAX_LOG_VALUE_LENGTH = 4_096
|
|
37
|
+
|
|
38
|
+
_SENSITIVE_KEY_FRAGMENTS = (
|
|
39
|
+
"accesskey",
|
|
40
|
+
"accesstoken",
|
|
41
|
+
"apikey",
|
|
42
|
+
"authorization",
|
|
43
|
+
"cookie",
|
|
44
|
+
"credential",
|
|
45
|
+
"password",
|
|
46
|
+
"passwd",
|
|
47
|
+
"privatekey",
|
|
48
|
+
"secret",
|
|
49
|
+
"token",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_sensitive_key(key: Any) -> bool:
|
|
54
|
+
"""Return whether a mapping key conventionally contains secret material."""
|
|
55
|
+
normalized = "".join(character for character in str(key).lower() if character.isalnum())
|
|
56
|
+
return any(fragment in normalized for fragment in _SENSITIVE_KEY_FRAGMENTS)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _redact_sensitive(value: Any, *, known_secrets: Sequence[str] = ()) -> Any:
|
|
60
|
+
"""Recursively redact secret fields and known secret values."""
|
|
61
|
+
if isinstance(value, SecretStr):
|
|
62
|
+
return REDACTED
|
|
63
|
+
if isinstance(value, Mapping):
|
|
64
|
+
return {
|
|
65
|
+
key: REDACTED
|
|
66
|
+
if _is_sensitive_key(key)
|
|
67
|
+
else _redact_sensitive(item, known_secrets=known_secrets)
|
|
68
|
+
for key, item in value.items()
|
|
69
|
+
}
|
|
70
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
71
|
+
return [_redact_sensitive(item, known_secrets=known_secrets) for item in value]
|
|
72
|
+
if isinstance(value, bytes):
|
|
73
|
+
return f"<bytes:{len(value)}>"
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
redacted = value
|
|
76
|
+
for secret in sorted({secret for secret in known_secrets if secret}, key=len, reverse=True):
|
|
77
|
+
redacted = redacted.replace(secret, REDACTED)
|
|
78
|
+
return redacted
|
|
79
|
+
return value
|
|
29
80
|
|
|
30
81
|
|
|
31
82
|
class HTTPClient:
|
|
@@ -53,7 +104,7 @@ class HTTPClient:
|
|
|
53
104
|
self._token: str | None = None
|
|
54
105
|
|
|
55
106
|
if config.token:
|
|
56
|
-
self._token = config.
|
|
107
|
+
self._token = config.get_token_value()
|
|
57
108
|
|
|
58
109
|
@property
|
|
59
110
|
def session(self) -> requests.Session:
|
|
@@ -70,7 +121,7 @@ class HTTPClient:
|
|
|
70
121
|
total=self.config.max_retries,
|
|
71
122
|
backoff_factor=self.config.retry_delay,
|
|
72
123
|
status_forcelist=[429, 500, 502, 503, 504],
|
|
73
|
-
allowed_methods=
|
|
124
|
+
allowed_methods=SAFE_RETRY_METHODS,
|
|
74
125
|
respect_retry_after_header=True,
|
|
75
126
|
raise_on_status=False,
|
|
76
127
|
)
|
|
@@ -119,7 +170,7 @@ class HTTPClient:
|
|
|
119
170
|
}
|
|
120
171
|
data = {
|
|
121
172
|
"username": self.config.username,
|
|
122
|
-
"password": self.config.
|
|
173
|
+
"password": self.config.get_password_value(),
|
|
123
174
|
}
|
|
124
175
|
|
|
125
176
|
try:
|
|
@@ -131,19 +182,26 @@ class HTTPClient:
|
|
|
131
182
|
)
|
|
132
183
|
|
|
133
184
|
if response.status_code != 200:
|
|
134
|
-
response_payload
|
|
135
|
-
|
|
136
|
-
response_payload = response.json() if response.text else None
|
|
137
|
-
except ValueError:
|
|
138
|
-
response_payload = {"raw": response.text} if response.text else None
|
|
185
|
+
response_payload = self._parse_response_body(response)
|
|
186
|
+
safe_payload = self._redact(response_payload)
|
|
139
187
|
raise AuthenticationError(
|
|
140
|
-
|
|
188
|
+
self._extract_error_message(safe_payload),
|
|
141
189
|
status_code=response.status_code,
|
|
142
|
-
response=
|
|
190
|
+
response=safe_payload,
|
|
143
191
|
)
|
|
144
192
|
|
|
145
|
-
|
|
146
|
-
|
|
193
|
+
try:
|
|
194
|
+
result = response.json()
|
|
195
|
+
token = result["access_token"]
|
|
196
|
+
if not isinstance(token, str) or not token:
|
|
197
|
+
raise ValueError("access_token must be a non-empty string")
|
|
198
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
199
|
+
safe_payload = self._redact(self._parse_response_body(response))
|
|
200
|
+
raise AuthenticationError(
|
|
201
|
+
"Authentication response did not contain a valid access token.",
|
|
202
|
+
status_code=response.status_code,
|
|
203
|
+
response=safe_payload,
|
|
204
|
+
) from exc
|
|
147
205
|
self._token = token
|
|
148
206
|
|
|
149
207
|
logger.debug("Successfully authenticated")
|
|
@@ -197,16 +255,18 @@ class HTTPClient:
|
|
|
197
255
|
"""
|
|
198
256
|
url = self._build_url(path)
|
|
199
257
|
request_headers = {**self._get_headers(), **(headers or {})}
|
|
200
|
-
request_timeout = timeout
|
|
258
|
+
request_timeout = timeout if timeout is not None else self.config.timeout
|
|
201
259
|
|
|
202
260
|
if self.config.debug:
|
|
203
|
-
logger.debug(
|
|
261
|
+
logger.debug("Request: %s %s", method, url)
|
|
204
262
|
if params:
|
|
205
|
-
logger.debug(
|
|
263
|
+
logger.debug("Params: %s", self._format_for_log(params))
|
|
206
264
|
if json:
|
|
207
|
-
logger.debug(
|
|
265
|
+
logger.debug("JSON: %s", self._format_for_log(json))
|
|
266
|
+
if data:
|
|
267
|
+
logger.debug("Data: %s", self._format_for_log(data))
|
|
208
268
|
if files:
|
|
209
|
-
logger.debug(
|
|
269
|
+
logger.debug("Multipart fields: %s", sorted(files))
|
|
210
270
|
|
|
211
271
|
try:
|
|
212
272
|
response = self.session.request(
|
|
@@ -253,27 +313,25 @@ class HTTPClient:
|
|
|
253
313
|
"""
|
|
254
314
|
request_id = response.headers.get("X-Request-Id")
|
|
255
315
|
|
|
256
|
-
|
|
257
|
-
body = response.json() if response.text else {}
|
|
258
|
-
except ValueError:
|
|
259
|
-
body = {"raw": response.text}
|
|
316
|
+
body = self._parse_response_body(response)
|
|
260
317
|
|
|
261
318
|
if self.config.debug:
|
|
262
|
-
logger.debug(
|
|
263
|
-
logger.debug(
|
|
319
|
+
logger.debug("Response: %s", response.status_code)
|
|
320
|
+
logger.debug("Body: %s", self._format_for_log(body))
|
|
264
321
|
|
|
265
322
|
if 200 <= response.status_code < 300:
|
|
266
323
|
return body
|
|
267
324
|
|
|
268
|
-
|
|
325
|
+
safe_body = self._redact(body)
|
|
326
|
+
error_message = self._extract_error_message(safe_body)
|
|
269
327
|
status_code = response.status_code
|
|
270
328
|
|
|
271
329
|
if status_code == 400:
|
|
272
|
-
errors =
|
|
330
|
+
errors = safe_body.get("errors", [])
|
|
273
331
|
raise ValidationError(
|
|
274
332
|
message=error_message,
|
|
275
333
|
errors=errors,
|
|
276
|
-
response=
|
|
334
|
+
response=safe_body,
|
|
277
335
|
request_id=request_id,
|
|
278
336
|
)
|
|
279
337
|
|
|
@@ -283,47 +341,49 @@ class HTTPClient:
|
|
|
283
341
|
raise AuthenticationError(
|
|
284
342
|
message=error_message,
|
|
285
343
|
status_code=status_code,
|
|
286
|
-
response=
|
|
344
|
+
response=safe_body,
|
|
287
345
|
request_id=request_id,
|
|
288
346
|
)
|
|
289
347
|
|
|
290
348
|
if status_code == 403:
|
|
291
349
|
raise ForbiddenError(
|
|
292
350
|
message=error_message,
|
|
293
|
-
response=
|
|
351
|
+
response=safe_body,
|
|
294
352
|
request_id=request_id,
|
|
295
353
|
)
|
|
296
354
|
|
|
297
355
|
if status_code == 404:
|
|
298
356
|
raise NotFoundError(
|
|
299
357
|
message=error_message,
|
|
300
|
-
response=
|
|
358
|
+
response=safe_body,
|
|
301
359
|
request_id=request_id,
|
|
302
360
|
)
|
|
303
361
|
|
|
304
362
|
if status_code == 402:
|
|
305
363
|
raise QuotaExceededError(
|
|
306
364
|
message=error_message,
|
|
307
|
-
quota_type=
|
|
308
|
-
current=(
|
|
309
|
-
|
|
310
|
-
|
|
365
|
+
quota_type=safe_body.get("quotaType") or safe_body.get("quota"),
|
|
366
|
+
current=(
|
|
367
|
+
safe_body.get("current") if isinstance(safe_body.get("current"), int) else None
|
|
368
|
+
),
|
|
369
|
+
limit=(safe_body.get("limit") if isinstance(safe_body.get("limit"), int) else None),
|
|
370
|
+
response=safe_body,
|
|
311
371
|
request_id=request_id,
|
|
312
372
|
)
|
|
313
373
|
|
|
314
374
|
if status_code == 409:
|
|
315
375
|
raise ResourceConflictError(
|
|
316
376
|
message=error_message,
|
|
317
|
-
response=
|
|
377
|
+
response=safe_body,
|
|
318
378
|
request_id=request_id,
|
|
319
379
|
)
|
|
320
380
|
|
|
321
381
|
if status_code == 429:
|
|
322
|
-
retry_after = response.headers.get("Retry-After")
|
|
382
|
+
retry_after = self._parse_retry_after(response.headers.get("Retry-After"))
|
|
323
383
|
raise RateLimitError(
|
|
324
384
|
message=error_message,
|
|
325
|
-
retry_after=
|
|
326
|
-
response=
|
|
385
|
+
retry_after=retry_after,
|
|
386
|
+
response=safe_body,
|
|
327
387
|
request_id=request_id,
|
|
328
388
|
)
|
|
329
389
|
|
|
@@ -331,14 +391,14 @@ class HTTPClient:
|
|
|
331
391
|
raise ServerError(
|
|
332
392
|
message=error_message,
|
|
333
393
|
status_code=status_code,
|
|
334
|
-
response=
|
|
394
|
+
response=safe_body,
|
|
335
395
|
request_id=request_id,
|
|
336
396
|
)
|
|
337
397
|
|
|
338
398
|
raise MTNCloudError(
|
|
339
399
|
message=error_message,
|
|
340
400
|
status_code=status_code,
|
|
341
|
-
response=
|
|
401
|
+
response=safe_body,
|
|
342
402
|
request_id=request_id,
|
|
343
403
|
)
|
|
344
404
|
|
|
@@ -372,6 +432,81 @@ class HTTPClient:
|
|
|
372
432
|
|
|
373
433
|
return "Unknown error"
|
|
374
434
|
|
|
435
|
+
def _known_secrets(self) -> tuple[str, ...]:
|
|
436
|
+
"""Return configured secret values for value-based redaction."""
|
|
437
|
+
return tuple(
|
|
438
|
+
secret
|
|
439
|
+
for secret in (
|
|
440
|
+
self._token,
|
|
441
|
+
self.config.get_token_value(),
|
|
442
|
+
self.config.get_password_value(),
|
|
443
|
+
)
|
|
444
|
+
if secret
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def _redact(self, value: Any) -> Any:
|
|
448
|
+
"""Redact configured and conventionally named secrets."""
|
|
449
|
+
return _redact_sensitive(value, known_secrets=self._known_secrets())
|
|
450
|
+
|
|
451
|
+
def _format_for_log(self, value: Any) -> str:
|
|
452
|
+
"""Return a bounded, redacted representation suitable for debug logs."""
|
|
453
|
+
formatted = repr(self._redact(value))
|
|
454
|
+
if len(formatted) <= MAX_LOG_VALUE_LENGTH:
|
|
455
|
+
return formatted
|
|
456
|
+
return f"{formatted[:MAX_LOG_VALUE_LENGTH]}... <truncated>"
|
|
457
|
+
|
|
458
|
+
def _parse_response_body(self, response: requests.Response) -> dict[str, Any]:
|
|
459
|
+
"""Normalize JSON bodies and safely describe non-JSON API errors."""
|
|
460
|
+
if not response.text:
|
|
461
|
+
return {}
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
parsed = response.json()
|
|
465
|
+
except ValueError:
|
|
466
|
+
if 200 <= response.status_code < 300:
|
|
467
|
+
return {"raw": response.text}
|
|
468
|
+
|
|
469
|
+
content_type = response.headers.get("Content-Type", "unknown content type")
|
|
470
|
+
return {
|
|
471
|
+
"message": (
|
|
472
|
+
f"Non-JSON response from API (HTTP {response.status_code}, {content_type})"
|
|
473
|
+
),
|
|
474
|
+
"contentType": content_type,
|
|
475
|
+
"contentLength": len(response.content),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if isinstance(parsed, dict):
|
|
479
|
+
return parsed
|
|
480
|
+
return {"data": parsed}
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _parse_retry_after(
|
|
484
|
+
value: str | None,
|
|
485
|
+
*,
|
|
486
|
+
now: datetime | None = None,
|
|
487
|
+
) -> int | None:
|
|
488
|
+
"""Parse Retry-After delta-seconds or an HTTP date into whole seconds."""
|
|
489
|
+
if not value:
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
normalized = value.strip()
|
|
493
|
+
try:
|
|
494
|
+
return max(0, int(normalized))
|
|
495
|
+
except ValueError:
|
|
496
|
+
pass
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
retry_at = parsedate_to_datetime(normalized)
|
|
500
|
+
except (TypeError, ValueError, OverflowError):
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
if retry_at.tzinfo is None:
|
|
504
|
+
retry_at = retry_at.replace(tzinfo=timezone.utc)
|
|
505
|
+
reference = now or datetime.now(timezone.utc)
|
|
506
|
+
if reference.tzinfo is None:
|
|
507
|
+
reference = reference.replace(tzinfo=timezone.utc)
|
|
508
|
+
return max(0, math.ceil((retry_at - reference).total_seconds()))
|
|
509
|
+
|
|
375
510
|
# Convenience methods
|
|
376
511
|
def get(
|
|
377
512
|
self,
|
|
@@ -432,7 +567,7 @@ class HTTPClient:
|
|
|
432
567
|
"""
|
|
433
568
|
url = self._build_url(path)
|
|
434
569
|
request_headers = {**self._get_headers(), **(headers or {})}
|
|
435
|
-
request_timeout = timeout
|
|
570
|
+
request_timeout = timeout if timeout is not None else self.config.timeout
|
|
436
571
|
|
|
437
572
|
try:
|
|
438
573
|
response = self.session.request(
|