day2 0.2.2__tar.gz → 0.2.4__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 (68) hide show
  1. {day2-0.2.2 → day2-0.2.4}/PKG-INFO +1 -29
  2. {day2-0.2.2 → day2-0.2.4}/README.md +0 -28
  3. day2-0.2.4/day2/auth/credentials.py +398 -0
  4. {day2-0.2.2 → day2-0.2.4}/day2/client/base.py +11 -0
  5. {day2-0.2.2 → day2-0.2.4}/day2/client/config.py +4 -0
  6. day2-0.2.4/day2/client/constants.py +23 -0
  7. day2-0.2.4/day2/client/profile.py +624 -0
  8. day2-0.2.4/day2/client/user_context.py +158 -0
  9. {day2-0.2.2 → day2-0.2.4}/day2/models/__init__.py +16 -0
  10. {day2-0.2.2 → day2-0.2.4}/day2/models/assessment.py +126 -2
  11. day2-0.2.4/day2/models/report.py +73 -0
  12. {day2-0.2.2 → day2-0.2.4}/day2/resources/__init__.py +2 -1
  13. {day2-0.2.2 → day2-0.2.4}/day2/resources/assessment.py +114 -2
  14. day2-0.2.4/day2/resources/report.py +115 -0
  15. {day2-0.2.2 → day2-0.2.4}/day2/resources/tenant.py +1 -1
  16. day2-0.2.4/day2/resources/user.py +43 -0
  17. day2-0.2.4/day2/session.py +398 -0
  18. day2-0.2.4/day2/utils/__init__.py +0 -0
  19. day2-0.2.4/day2/utils/helpers.py +27 -0
  20. {day2-0.2.2 → day2-0.2.4}/day2.egg-info/PKG-INFO +1 -29
  21. {day2-0.2.2 → day2-0.2.4}/day2.egg-info/SOURCES.txt +13 -13
  22. {day2-0.2.2 → day2-0.2.4}/day2_cli/cli.py +7 -12
  23. day2-0.2.4/day2_cli/commands/__init__.py +10 -0
  24. {day2-0.2.2 → day2-0.2.4}/day2_cli/commands/assessment.py +476 -268
  25. day2-0.2.4/day2_cli/commands/auth.py +1042 -0
  26. {day2-0.2.2 → day2-0.2.4}/day2_cli/commands/cost.py +13 -46
  27. day2-0.2.4/day2_cli/commands/profile.py +941 -0
  28. day2-0.2.4/day2_cli/commands/report.py +208 -0
  29. {day2-0.2.2 → day2-0.2.4}/day2_cli/commands/tenant.py +48 -98
  30. day2-0.2.4/day2_cli/utils/context.py +208 -0
  31. day2-0.2.4/day2_cli/utils/exit_codes.py +76 -0
  32. {day2-0.2.2 → day2-0.2.4}/pyproject.toml +47 -1
  33. day2-0.2.4/tests/test_exceptions.py +243 -0
  34. day2-0.2.2/day2/auth/credentials.py +0 -118
  35. day2-0.2.2/day2/session.py +0 -187
  36. day2-0.2.2/day2_cli/commands/__init__.py +0 -7
  37. day2-0.2.2/day2_cli/commands/auth.py +0 -132
  38. day2-0.2.2/day2_cli/commands/config.py +0 -306
  39. day2-0.2.2/day2_cli/utils/config.py +0 -132
  40. day2-0.2.2/tests/test_cli_auth.py +0 -201
  41. day2-0.2.2/tests/test_cli_config.py +0 -234
  42. day2-0.2.2/tests/test_client_base.py +0 -231
  43. day2-0.2.2/tests/test_config.py +0 -300
  44. day2-0.2.2/tests/test_models_assessment.py +0 -418
  45. day2-0.2.2/tests/test_models_cost.py +0 -112
  46. day2-0.2.2/tests/test_models_tenant.py +0 -162
  47. day2-0.2.2/tests/test_resources_assessment.py +0 -661
  48. day2-0.2.2/tests/test_resources_cost.py +0 -90
  49. day2-0.2.2/tests/test_resources_tenant.py +0 -230
  50. day2-0.2.2/tests/test_session.py +0 -163
  51. {day2-0.2.2 → day2-0.2.4}/LICENSE +0 -0
  52. {day2-0.2.2 → day2-0.2.4}/day2/__init__.py +0 -0
  53. {day2-0.2.2 → day2-0.2.4}/day2/_version.py +0 -0
  54. {day2-0.2.2 → day2-0.2.4}/day2/auth/__init__.py +0 -0
  55. {day2-0.2.2 → day2-0.2.4}/day2/client/__init__.py +0 -0
  56. {day2-0.2.2 → day2-0.2.4}/day2/exceptions.py +0 -0
  57. {day2-0.2.2 → day2-0.2.4}/day2/models/cost.py +0 -0
  58. {day2-0.2.2 → day2-0.2.4}/day2/models/tenant.py +0 -0
  59. {day2-0.2.2 → day2-0.2.4}/day2/resources/cost.py +0 -0
  60. {day2-0.2.2 → day2-0.2.4}/day2.egg-info/dependency_links.txt +0 -0
  61. {day2-0.2.2 → day2-0.2.4}/day2.egg-info/entry_points.txt +0 -0
  62. {day2-0.2.2 → day2-0.2.4}/day2.egg-info/requires.txt +0 -0
  63. {day2-0.2.2 → day2-0.2.4}/day2.egg-info/top_level.txt +0 -0
  64. {day2-0.2.2 → day2-0.2.4}/day2_cli/__init__.py +0 -0
  65. {day2-0.2.2 → day2-0.2.4}/day2_cli/utils/__init__.py +0 -0
  66. {day2-0.2.2 → day2-0.2.4}/day2_cli/utils/formatters.py +0 -0
  67. {day2-0.2.2 → day2-0.2.4}/day2_cli/utils/output_formatter.py +0 -0
  68. {day2-0.2.2 → day2-0.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: day2
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: MontyCloud DAY2 SDK for Python
5
5
  Author-email: MontyCloud <info@montycloud.com>
6
6
  Requires-Python: >=3.11
@@ -220,34 +220,6 @@ default_session.set_tenant("org-123")
220
220
  assessments = day2.assessment().list_assessments(tenant_id="tenant-123", status="PENDING")
221
221
  ```
222
222
 
223
- ## Running Examples
224
-
225
- The SDK comes with example scripts in the `examples` directory that demonstrate various features. To run these examples:
226
-
227
- 1. Clone the repository:
228
- ```bash
229
- git clone https://github.com/montycloud/day2-sdk.git
230
- cd day2-sdk
231
- ```
232
-
233
- 2. Create a virtual environment:
234
- ```bash
235
- python -m venv .venv
236
- source .venv/bin/activate # On Windows: .venv\Scripts\activate
237
- ```
238
-
239
- 3. Install the package in development mode:
240
- ```bash
241
- pip install -e .
242
- ```
243
-
244
- 4. Run an example:
245
- ```bash
246
- python examples/basic_usage.py
247
- ```
248
-
249
- Note: Some examples may require you to have valid API credentials. Make sure to set up your authentication as described in the [Authentication](#authentication) section before running the examples.
250
-
251
223
  ## Error Handling
252
224
 
253
225
  The SDK provides comprehensive error handling with custom exceptions:
@@ -182,34 +182,6 @@ default_session.set_tenant("org-123")
182
182
  assessments = day2.assessment().list_assessments(tenant_id="tenant-123", status="PENDING")
183
183
  ```
184
184
 
185
- ## Running Examples
186
-
187
- The SDK comes with example scripts in the `examples` directory that demonstrate various features. To run these examples:
188
-
189
- 1. Clone the repository:
190
- ```bash
191
- git clone https://github.com/montycloud/day2-sdk.git
192
- cd day2-sdk
193
- ```
194
-
195
- 2. Create a virtual environment:
196
- ```bash
197
- python -m venv .venv
198
- source .venv/bin/activate # On Windows: .venv\Scripts\activate
199
- ```
200
-
201
- 3. Install the package in development mode:
202
- ```bash
203
- pip install -e .
204
- ```
205
-
206
- 4. Run an example:
207
- ```bash
208
- python examples/basic_usage.py
209
- ```
210
-
211
- Note: Some examples may require you to have valid API credentials. Make sure to set up your authentication as described in the [Authentication](#authentication) section before running the examples.
212
-
213
185
  ## Error Handling
214
186
 
215
187
  The SDK provides comprehensive error handling with custom exceptions:
@@ -0,0 +1,398 @@
1
+ """Credential management for the MontyCloud SDK."""
2
+
3
+ import configparser
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ class Credentials:
10
+ """Manages API keys and other authentication credentials."""
11
+
12
+ def __init__(
13
+ self,
14
+ api_key: Optional[str] = None,
15
+ api_secret_key: Optional[str] = None,
16
+ profile_name: Optional[str] = None,
17
+ base_url: Optional[str] = None,
18
+ ):
19
+ """Initialize credentials.
20
+
21
+ Args:
22
+ api_key: API key for authentication. If not provided, will attempt to load
23
+ from environment variables or configuration file.
24
+ api_secret_key: API secret key for authentication. If not provided, will attempt to load
25
+ from environment variables or configuration file.
26
+ profile_name: Profile name for credential section determination
27
+ base_url: Base URL for credential section determination
28
+ """
29
+ self.profile_name = profile_name
30
+ self.base_url = base_url
31
+
32
+ # Determine which credential section to use
33
+ self.credential_section = self._determine_credential_section(profile_name)
34
+
35
+ self.api_key = api_key or self._load_from_env() or self._load_from_config()
36
+ self.secret_key = (
37
+ api_secret_key
38
+ or self._load_api_secret_key_from_env()
39
+ or self._load_api_secret_key_from_config()
40
+ )
41
+
42
+ if not self.api_key:
43
+ raise ValueError(
44
+ "No API key provided. Please provide an API key via the constructor, "
45
+ "environment variable DAY2_API_KEY, or configuration file."
46
+ )
47
+
48
+ def _get_credentials_file_path(self) -> Path:
49
+ """Get the path to the credentials file.
50
+
51
+ Uses the same directory resolution logic as ProfileManager:
52
+ 1. Check DAY2_CONFIG_PATH environment variable directory
53
+ 2. Fall back to ~/.day2/ directory
54
+
55
+ Returns:
56
+ Path to the credentials file.
57
+ """
58
+ if os.environ.get("DAY2_CONFIG_PATH"):
59
+ config_file_path = Path(os.environ["DAY2_CONFIG_PATH"])
60
+ config_dir = config_file_path.parent
61
+ else:
62
+ config_dir = Path.home() / ".day2"
63
+
64
+ return config_dir / "credentials"
65
+
66
+ def _determine_credential_section(self, profile_name: Optional[str]) -> str:
67
+ """Determine which credential section to use.
68
+
69
+ This now uses a simpler approach:
70
+ - If no profile name, use DEFAULT
71
+ - Otherwise, try the profile's own section first
72
+ - The actual loading logic will handle fallback to DEFAULT
73
+
74
+ Args:
75
+ profile_name: Profile name
76
+
77
+ Returns:
78
+ Credential section name to use (profile name or "DEFAULT")
79
+ """
80
+ if not profile_name:
81
+ return "DEFAULT"
82
+
83
+ # Return the profile name - the loading logic will handle checking
84
+ # if credentials exist there and falling back to DEFAULT if needed
85
+ return profile_name
86
+
87
+ def _load_from_env(self) -> Optional[str]:
88
+ """Load API key from environment variables.
89
+
90
+ Returns:
91
+ API key if found in environment variables, None otherwise.
92
+ """
93
+ return os.environ.get("DAY2_API_KEY")
94
+
95
+ def _load_api_secret_key_from_env(self) -> Optional[str]:
96
+ """Load API secret key from environment variables.
97
+
98
+ Returns:
99
+ API secret key if found in environment variables, None otherwise.
100
+ """
101
+ return os.environ.get("DAY2_API_SECRET_KEY")
102
+
103
+ def _normalize_section_name(self, section_name: str) -> str:
104
+ """Normalize section name for consistent storage.
105
+
106
+ Args:
107
+ section_name: The section name to normalize
108
+
109
+ Returns:
110
+ Normalized section name (DEFAULT for default, original case for others)
111
+ """
112
+ if section_name.lower() == "default":
113
+ return "DEFAULT"
114
+ return section_name
115
+
116
+ def _find_section_case_insensitive(
117
+ self, config_parser: configparser.ConfigParser, section_name: str
118
+ ) -> Optional[str]:
119
+ """Find a section in the config parser, case-insensitively.
120
+
121
+ Args:
122
+ config_parser: The ConfigParser instance to search
123
+ section_name: The section name to find (case-insensitive)
124
+
125
+ Returns:
126
+ The actual section name if found, None otherwise.
127
+ """
128
+ # Check exact match first
129
+ if config_parser.has_section(section_name):
130
+ return section_name
131
+
132
+ # Check case-insensitive match
133
+ for actual_section in config_parser.sections():
134
+ if actual_section.lower() == section_name.lower():
135
+ return actual_section
136
+
137
+ # Special handling for DEFAULT section (configparser treats it specially)
138
+ if section_name.upper() == "DEFAULT":
139
+ # Check if DEFAULT exists in any case
140
+ if "DEFAULT" in config_parser:
141
+ return "DEFAULT"
142
+ for actual_section in config_parser.sections():
143
+ if actual_section.lower() == "default":
144
+ return actual_section
145
+
146
+ return None
147
+
148
+ def _load_from_config(self) -> Optional[str]:
149
+ """Load API key from credentials file.
150
+
151
+ Returns:
152
+ API key if found in credentials file, None otherwise.
153
+ """
154
+ credentials_file = self._get_credentials_file_path()
155
+
156
+ if not credentials_file.exists():
157
+ return None
158
+
159
+ try:
160
+ config_parser = configparser.ConfigParser()
161
+ config_parser.read(credentials_file)
162
+
163
+ # First, always check if the profile has its own credentials
164
+ if self.profile_name:
165
+ profile_section = self._find_section_case_insensitive(
166
+ config_parser, self.profile_name
167
+ )
168
+ if profile_section:
169
+ api_key = config_parser.get(
170
+ profile_section, "api_key", fallback=None
171
+ )
172
+ if api_key:
173
+ # Update credential_section to reflect what we actually used
174
+ self.credential_section = profile_section
175
+ return api_key
176
+
177
+ # Then try the determined section (case-insensitive)
178
+ actual_section = self._find_section_case_insensitive(
179
+ config_parser, self.credential_section
180
+ )
181
+ if actual_section:
182
+ return config_parser.get(actual_section, "api_key", fallback=None)
183
+
184
+ # Fall back to DEFAULT section if profile section doesn't exist (case-insensitive)
185
+ default_section = self._find_section_case_insensitive(
186
+ config_parser, "DEFAULT"
187
+ )
188
+ if default_section:
189
+ return config_parser.get(default_section, "api_key", fallback=None)
190
+ return None
191
+ except (configparser.Error, IOError):
192
+ return None
193
+
194
+ def _load_api_secret_key_from_config(self) -> Optional[str]:
195
+ """Load API secret key from credentials file.
196
+
197
+ Returns:
198
+ API secret key if found in credentials file, None otherwise.
199
+ """
200
+ credentials_file = self._get_credentials_file_path()
201
+
202
+ if not credentials_file.exists():
203
+ return None
204
+
205
+ try:
206
+ config_parser = configparser.ConfigParser()
207
+ config_parser.read(credentials_file)
208
+
209
+ # First, always check if the profile has its own credentials
210
+ if self.profile_name:
211
+ profile_section = self._find_section_case_insensitive(
212
+ config_parser, self.profile_name
213
+ )
214
+ if profile_section:
215
+ secret_key = config_parser.get(
216
+ profile_section, "api_secret_key", fallback=None
217
+ )
218
+ if secret_key:
219
+ return secret_key
220
+
221
+ # Then try the determined section (case-insensitive)
222
+ actual_section = self._find_section_case_insensitive(
223
+ config_parser, self.credential_section
224
+ )
225
+ if actual_section:
226
+ return config_parser.get(
227
+ actual_section, "api_secret_key", fallback=None
228
+ )
229
+
230
+ # Fall back to DEFAULT section if profile section doesn't exist (case-insensitive)
231
+ default_section = self._find_section_case_insensitive(
232
+ config_parser, "DEFAULT"
233
+ )
234
+ if default_section:
235
+ return config_parser.get(
236
+ default_section, "api_secret_key", fallback=None
237
+ )
238
+ return None
239
+ except (configparser.Error, IOError):
240
+ return None
241
+
242
+ def save_to_config(self, section_name: Optional[str] = None) -> None:
243
+ """Save credentials to credentials file.
244
+
245
+ Args:
246
+ section_name: Section name to save credentials to. If None, uses DEFAULT.
247
+ """
248
+ if not self.api_key:
249
+ return
250
+
251
+ config_dir = Path.home() / ".day2"
252
+ config_dir.mkdir(exist_ok=True)
253
+
254
+ credentials_file = config_dir / "credentials"
255
+
256
+ # Use provided section name or default to DEFAULT
257
+ target_section = self._normalize_section_name(section_name or "DEFAULT")
258
+
259
+ # Load existing credentials if they exist
260
+ config_parser = configparser.ConfigParser()
261
+ if credentials_file.exists():
262
+ config_parser.read(credentials_file)
263
+
264
+ # Handle case-insensitive section merging
265
+ self._merge_section_case_insensitive(config_parser, target_section)
266
+
267
+ # Update config with API key and secret
268
+ config_parser[target_section]["api_key"] = self.api_key
269
+ if self.secret_key:
270
+ config_parser[target_section]["api_secret_key"] = self.secret_key
271
+
272
+ # Save credentials
273
+ with open(credentials_file, "w", encoding="utf-8") as f:
274
+ config_parser.write(f)
275
+
276
+ def _merge_section_case_insensitive(
277
+ self, config_parser: configparser.ConfigParser, target_section: str
278
+ ) -> None:
279
+ """Merge existing section with different case into target section.
280
+
281
+ Args:
282
+ config_parser: The ConfigParser instance to modify
283
+ target_section: The normalized target section name
284
+ """
285
+ existing_section = self._find_section_case_insensitive(
286
+ config_parser, target_section
287
+ )
288
+
289
+ if existing_section and existing_section != target_section:
290
+ # Copy existing values to preserve them
291
+ existing_values = dict(config_parser[existing_section])
292
+ config_parser.remove_section(existing_section)
293
+ config_parser[target_section] = existing_values
294
+ elif not existing_section:
295
+ # Create new section
296
+ config_parser[target_section] = {}
297
+
298
+ @classmethod
299
+ def save_credentials_to_section(
300
+ cls,
301
+ api_key: str,
302
+ api_secret_key: Optional[str],
303
+ section_name: str,
304
+ credentials_file_path: Optional[Path] = None,
305
+ ) -> None:
306
+ """Save credentials to a specific section in the credentials file.
307
+
308
+ This is a utility method for auth.py to save credentials without creating a Credentials instance.
309
+
310
+ Args:
311
+ api_key: API key to save
312
+ api_secret_key: API secret key to save (optional)
313
+ section_name: Section name to save to
314
+ credentials_file_path: Optional custom path to credentials file
315
+ """
316
+ if credentials_file_path:
317
+ credentials_file = credentials_file_path
318
+ credentials_file.parent.mkdir(exist_ok=True)
319
+ else:
320
+ config_dir = Path.home() / ".day2"
321
+ config_dir.mkdir(exist_ok=True)
322
+ credentials_file = config_dir / "credentials"
323
+
324
+ # Normalize section name
325
+ normalized_section = cls.normalize_section_name(section_name)
326
+
327
+ # Load existing credentials
328
+ config_parser = configparser.ConfigParser()
329
+ if credentials_file.exists():
330
+ config_parser.read(credentials_file)
331
+
332
+ # Handle case-insensitive section merging
333
+ cls._merge_section_case_insensitive_static(config_parser, normalized_section)
334
+
335
+ # Update credentials
336
+ config_parser[normalized_section]["api_key"] = api_key or ""
337
+ if api_secret_key:
338
+ config_parser[normalized_section]["api_secret_key"] = api_secret_key
339
+
340
+ # Save credentials
341
+ with open(credentials_file, "w", encoding="utf-8") as f:
342
+ config_parser.write(f)
343
+
344
+ @staticmethod
345
+ def normalize_section_name(section_name: str) -> str:
346
+ """Normalize section name for consistent storage.
347
+
348
+ Args:
349
+ section_name: The section name to normalize
350
+
351
+ Returns:
352
+ Normalized section name (DEFAULT for default, original case for others)
353
+ """
354
+ if section_name.lower() == "default":
355
+ return "DEFAULT"
356
+ return section_name
357
+
358
+ @staticmethod
359
+ def _find_section_case_insensitive_static(
360
+ config_parser: configparser.ConfigParser, section_name: str
361
+ ) -> Optional[str]:
362
+ """Static version of _find_section_case_insensitive for class methods."""
363
+ # Check exact match first
364
+ if config_parser.has_section(section_name):
365
+ return section_name
366
+
367
+ # Check case-insensitive match
368
+ for actual_section in config_parser.sections():
369
+ if actual_section.lower() == section_name.lower():
370
+ return actual_section
371
+
372
+ # Special handling for DEFAULT section
373
+ if section_name.upper() == "DEFAULT":
374
+ if "DEFAULT" in config_parser:
375
+ return "DEFAULT"
376
+ for actual_section in config_parser.sections():
377
+ if actual_section.lower() == "default":
378
+ return actual_section
379
+
380
+ return None
381
+
382
+ @staticmethod
383
+ def _merge_section_case_insensitive_static(
384
+ config_parser: configparser.ConfigParser, target_section: str
385
+ ) -> None:
386
+ """Static version of _merge_section_case_insensitive for class methods."""
387
+ existing_section = Credentials._find_section_case_insensitive_static(
388
+ config_parser, target_section
389
+ )
390
+
391
+ if existing_section and existing_section != target_section:
392
+ # Copy existing values to preserve them
393
+ existing_values = dict(config_parser[existing_section])
394
+ config_parser.remove_section(existing_section)
395
+ config_parser[target_section] = existing_values
396
+ elif not existing_section:
397
+ # Create new section
398
+ config_parser[target_section] = {}
@@ -73,6 +73,17 @@ class BaseClient:
73
73
  ),
74
74
  }
75
75
 
76
+ # Add User-Agent header
77
+ from day2._version import __version__
78
+
79
+ user_agent = f"day2-sdk/{__version__}"
80
+ if (
81
+ hasattr(self.session, "user_agent_suffix")
82
+ and self.session.user_agent_suffix
83
+ ):
84
+ user_agent += f" {self.session.user_agent_suffix}"
85
+ headers["User-Agent"] = user_agent
86
+
76
87
  # Add Authorization header if available
77
88
  # Use auth_token (which is actually the api_secret_key internally)
78
89
  if (
@@ -49,6 +49,9 @@ class Config:
49
49
  # CLI output format (table or json)
50
50
  output_format: str = "table"
51
51
 
52
+ # Profile name
53
+ profile: str = ""
54
+
52
55
  @property
53
56
  def api_url(self) -> str:
54
57
  """Get the full API URL.
@@ -74,6 +77,7 @@ class Config:
74
77
  "retry_min_delay": self.retry_min_delay,
75
78
  "retry_max_delay": self.retry_max_delay,
76
79
  "output_format": self.output_format,
80
+ "profile": self.profile,
77
81
  }
78
82
 
79
83
  @classmethod
@@ -0,0 +1,23 @@
1
+ """Constants for the MontyCloud DAY2 SDK."""
2
+
3
+ # Configuration constants
4
+ DEFAULT_SECTION = "DEFAULT"
5
+ PROFILE_PREFIX = "profile_"
6
+ DEFAULT_PROFILE_KEY = "default_profile"
7
+ CONFIG_DIR = ".day2"
8
+ CONFIG_FILE = "config"
9
+
10
+ # Environment variable keys
11
+ ENV_PROFILE_KEY = "DAY2_PROFILE"
12
+
13
+ # Configuration keys
14
+ KEY_TENANT_ID = "tenant_id"
15
+ KEY_BASE_URL = "base_url"
16
+ KEY_API_VERSION = "api_version"
17
+ KEY_TIMEOUT = "timeout"
18
+ KEY_MAX_RETRIES = "max_retries"
19
+ KEY_RETRY_BACKOFF = "retry_backoff_factor"
20
+ KEY_RETRY_MIN_DELAY = "retry_min_delay"
21
+ KEY_RETRY_MAX_DELAY = "retry_max_delay"
22
+ KEY_OUTPUT_FORMAT = "output_format"
23
+ KEY_PROFILE = "profile"