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.
Files changed (84) hide show
  1. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/PKG-INFO +14 -2
  2. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/README.md +12 -0
  3. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/advanced-cookbook.md +15 -4
  4. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/client.md +17 -3
  5. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/_version.py +1 -1
  6. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/client.py +9 -8
  7. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/config.py +28 -8
  8. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/http.py +175 -40
  9. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_client.py +35 -2
  10. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_config.py +40 -4
  11. mtn_cloud-0.3.3/tests/test_http.py +335 -0
  12. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/.gitignore +0 -0
  13. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/LICENSE +0 -0
  14. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-overview.md +0 -0
  15. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/archive-buckets.md +0 -0
  16. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/backups.md +0 -0
  17. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/clouds.md +0 -0
  18. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/groups.md +0 -0
  19. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/index.md +0 -0
  20. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/instance-types.md +0 -0
  21. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/instances.md +0 -0
  22. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/networks.md +0 -0
  23. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/plans.md +0 -0
  24. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/security-groups.md +0 -0
  25. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/storage-buckets.md +0 -0
  26. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/api-reference/virtual-images.md +0 -0
  27. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/backups.md +0 -0
  28. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/index.md +0 -0
  29. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/instances.md +0 -0
  30. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/networking.md +0 -0
  31. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/quickstart.md +0 -0
  32. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/recipes.md +0 -0
  33. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/security-groups.md +0 -0
  34. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/storage.md +0 -0
  35. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/stylesheets/extra.css +0 -0
  36. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/docs/virtual-images.md +0 -0
  37. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/pyproject.toml +0 -0
  38. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/__init__.py +0 -0
  39. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/exceptions.py +0 -0
  40. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/__init__.py +0 -0
  41. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/archive.py +0 -0
  42. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/backup.py +0 -0
  43. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/base.py +0 -0
  44. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/cloud.py +0 -0
  45. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/group.py +0 -0
  46. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/instance.py +0 -0
  47. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/instance_type.py +0 -0
  48. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/network.py +0 -0
  49. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/plan.py +0 -0
  50. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/resource_pool.py +0 -0
  51. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/security_group.py +0 -0
  52. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/snapshot.py +0 -0
  53. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/storage_bucket.py +0 -0
  54. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/user.py +0 -0
  55. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/virtual_image.py +0 -0
  56. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/models/volume.py +0 -0
  57. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/py.typed +0 -0
  58. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/__init__.py +0 -0
  59. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/archive_buckets.py +0 -0
  60. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/backups.py +0 -0
  61. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/base.py +0 -0
  62. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/clouds.py +0 -0
  63. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/groups.py +0 -0
  64. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/instance_types.py +0 -0
  65. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/instances.py +0 -0
  66. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/networks.py +0 -0
  67. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/plans.py +0 -0
  68. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/security_groups.py +0 -0
  69. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/storage_buckets.py +0 -0
  70. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/src/mtn_cloud/resources/virtual_images.py +0 -0
  71. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/__init__.py +0 -0
  72. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/conftest.py +0 -0
  73. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_archive_buckets.py +0 -0
  74. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_backups.py +0 -0
  75. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_clouds.py +0 -0
  76. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_exceptions.py +0 -0
  77. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_groups.py +0 -0
  78. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_instance_types.py +0 -0
  79. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_instances.py +0 -0
  80. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_networks.py +0 -0
  81. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_plans.py +0 -0
  82. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_security_groups.py +0 -0
  83. {mtn_cloud-0.3.2 → mtn_cloud-0.3.3}/tests/test_storage_buckets.py +0 -0
  84. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: mtn-cloud
3
- Version: 0.3.2
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
- cloud = MTNCloud(
10
+ config = MTNCloudConfig(
11
11
  token="your-api-token",
12
12
  timeout=45,
13
- # Retry values are controlled through MTNCloudConfig defaults:
14
- # max_retries=3, retry_delay=1.0
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=True, config=None)`
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
-
@@ -1,3 +1,3 @@
1
1
  """Package version metadata."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.3"
@@ -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 = True,
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 verify_ssl:
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: str | None = Field(
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: str | None = Field(
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="User-Agent header for API requests",
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.token
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=["GET", "POST", "PUT", "PATCH", "DELETE"],
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.password,
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: dict[str, Any] | None
135
- try:
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
- f"Authentication failed: {response.text}",
188
+ self._extract_error_message(safe_payload),
141
189
  status_code=response.status_code,
142
- response=response_payload,
190
+ response=safe_payload,
143
191
  )
144
192
 
145
- result = response.json()
146
- token: str = result["access_token"]
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 or self.config.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(f"Request: {method} {url}")
261
+ logger.debug("Request: %s %s", method, url)
204
262
  if params:
205
- logger.debug(f"Params: {params}")
263
+ logger.debug("Params: %s", self._format_for_log(params))
206
264
  if json:
207
- logger.debug(f"JSON: {json}")
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(f"Files: {list(files.keys())}")
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
- try:
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(f"Response: {response.status_code}")
263
- logger.debug(f"Body: {body}")
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
- error_message = self._extract_error_message(body)
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 = body.get("errors", [])
330
+ errors = safe_body.get("errors", [])
273
331
  raise ValidationError(
274
332
  message=error_message,
275
333
  errors=errors,
276
- response=body,
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=body,
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=body,
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=body,
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=body.get("quotaType") or body.get("quota"),
308
- current=(body.get("current") if isinstance(body.get("current"), int) else None),
309
- limit=body.get("limit") if isinstance(body.get("limit"), int) else None,
310
- response=body,
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=body,
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=int(retry_after) if retry_after else None,
326
- response=body,
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=body,
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=body,
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 or self.config.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(