day2 0.2.2__tar.gz → 0.2.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.
- {day2-0.2.2 → day2-0.2.3}/PKG-INFO +47 -1
- {day2-0.2.2 → day2-0.2.3}/README.md +46 -0
- day2-0.2.3/day2/auth/credentials.py +400 -0
- {day2-0.2.2 → day2-0.2.3}/day2/client/base.py +11 -0
- {day2-0.2.2 → day2-0.2.3}/day2/client/config.py +4 -0
- day2-0.2.3/day2/client/constants.py +23 -0
- day2-0.2.3/day2/client/profile.py +624 -0
- day2-0.2.3/day2/client/user_context.py +158 -0
- {day2-0.2.2 → day2-0.2.3}/day2/models/__init__.py +11 -0
- {day2-0.2.2 → day2-0.2.3}/day2/models/assessment.py +124 -2
- day2-0.2.3/day2/models/report.py +41 -0
- {day2-0.2.2 → day2-0.2.3}/day2/resources/__init__.py +2 -1
- {day2-0.2.2 → day2-0.2.3}/day2/resources/assessment.py +110 -0
- day2-0.2.3/day2/resources/report.py +80 -0
- day2-0.2.3/day2/resources/user.py +43 -0
- day2-0.2.3/day2/session.py +422 -0
- {day2-0.2.2 → day2-0.2.3}/day2.egg-info/PKG-INFO +47 -1
- {day2-0.2.2 → day2-0.2.3}/day2.egg-info/SOURCES.txt +11 -13
- {day2-0.2.2 → day2-0.2.3}/day2_cli/cli.py +7 -12
- day2-0.2.3/day2_cli/commands/__init__.py +10 -0
- {day2-0.2.2 → day2-0.2.3}/day2_cli/commands/assessment.py +474 -266
- day2-0.2.3/day2_cli/commands/auth.py +1020 -0
- {day2-0.2.2 → day2-0.2.3}/day2_cli/commands/cost.py +13 -46
- day2-0.2.3/day2_cli/commands/profile.py +901 -0
- day2-0.2.3/day2_cli/commands/report.py +137 -0
- {day2-0.2.2 → day2-0.2.3}/day2_cli/commands/tenant.py +48 -98
- day2-0.2.3/day2_cli/utils/context.py +208 -0
- day2-0.2.3/day2_cli/utils/exit_codes.py +76 -0
- {day2-0.2.2 → day2-0.2.3}/pyproject.toml +47 -1
- day2-0.2.3/tests/test_exceptions.py +243 -0
- day2-0.2.2/day2/auth/credentials.py +0 -118
- day2-0.2.2/day2/session.py +0 -187
- day2-0.2.2/day2_cli/commands/__init__.py +0 -7
- day2-0.2.2/day2_cli/commands/auth.py +0 -132
- day2-0.2.2/day2_cli/commands/config.py +0 -306
- day2-0.2.2/day2_cli/utils/config.py +0 -132
- day2-0.2.2/tests/test_cli_auth.py +0 -201
- day2-0.2.2/tests/test_cli_config.py +0 -234
- day2-0.2.2/tests/test_client_base.py +0 -231
- day2-0.2.2/tests/test_config.py +0 -300
- day2-0.2.2/tests/test_models_assessment.py +0 -418
- day2-0.2.2/tests/test_models_cost.py +0 -112
- day2-0.2.2/tests/test_models_tenant.py +0 -162
- day2-0.2.2/tests/test_resources_assessment.py +0 -661
- day2-0.2.2/tests/test_resources_cost.py +0 -90
- day2-0.2.2/tests/test_resources_tenant.py +0 -230
- day2-0.2.2/tests/test_session.py +0 -163
- {day2-0.2.2 → day2-0.2.3}/LICENSE +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/__init__.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/_version.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/auth/__init__.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/client/__init__.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/exceptions.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/models/cost.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/models/tenant.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/resources/cost.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2/resources/tenant.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2.egg-info/dependency_links.txt +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2.egg-info/entry_points.txt +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2.egg-info/requires.txt +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2.egg-info/top_level.txt +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2_cli/__init__.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2_cli/utils/__init__.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2_cli/utils/formatters.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/day2_cli/utils/output_formatter.py +0 -0
- {day2-0.2.2 → day2-0.2.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: day2
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: MontyCloud DAY2 SDK for Python
|
|
5
5
|
Author-email: MontyCloud <info@montycloud.com>
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -276,6 +276,52 @@ except Exception as e:
|
|
|
276
276
|
print(f"Unexpected error: {e}")
|
|
277
277
|
```
|
|
278
278
|
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
### Documentation
|
|
282
|
+
|
|
283
|
+
The project uses MkDocs for documentation. Here are the available commands:
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
# Build documentation locally
|
|
287
|
+
make docs
|
|
288
|
+
|
|
289
|
+
# Serve documentation for local development
|
|
290
|
+
make serve-docs
|
|
291
|
+
|
|
292
|
+
# Deploy documentation to GitHub Pages (from main branch)
|
|
293
|
+
make deploy-docs
|
|
294
|
+
|
|
295
|
+
# Deploy documentation from any branch to GitHub Pages
|
|
296
|
+
make deploy-branch-docs
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The `deploy-branch-docs` command is useful for:
|
|
300
|
+
- Testing documentation changes before merging to main
|
|
301
|
+
- Deploying documentation from feature branches
|
|
302
|
+
- Creating documentation previews for pull requests
|
|
303
|
+
|
|
304
|
+
**Note**: `deploy-branch-docs` will overwrite the current GitHub Pages content, so use with caution in production environments.
|
|
305
|
+
|
|
306
|
+
### Other Development Commands
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
# Run tests
|
|
310
|
+
make test
|
|
311
|
+
|
|
312
|
+
# Run linting
|
|
313
|
+
make lint
|
|
314
|
+
|
|
315
|
+
# Format code
|
|
316
|
+
make format
|
|
317
|
+
|
|
318
|
+
# Run type checking
|
|
319
|
+
make mypy
|
|
320
|
+
|
|
321
|
+
# Run all CI checks
|
|
322
|
+
make ci
|
|
323
|
+
```
|
|
324
|
+
|
|
279
325
|
## License
|
|
280
326
|
|
|
281
327
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
@@ -238,6 +238,52 @@ except Exception as e:
|
|
|
238
238
|
print(f"Unexpected error: {e}")
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
+
## Development
|
|
242
|
+
|
|
243
|
+
### Documentation
|
|
244
|
+
|
|
245
|
+
The project uses MkDocs for documentation. Here are the available commands:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
# Build documentation locally
|
|
249
|
+
make docs
|
|
250
|
+
|
|
251
|
+
# Serve documentation for local development
|
|
252
|
+
make serve-docs
|
|
253
|
+
|
|
254
|
+
# Deploy documentation to GitHub Pages (from main branch)
|
|
255
|
+
make deploy-docs
|
|
256
|
+
|
|
257
|
+
# Deploy documentation from any branch to GitHub Pages
|
|
258
|
+
make deploy-branch-docs
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The `deploy-branch-docs` command is useful for:
|
|
262
|
+
- Testing documentation changes before merging to main
|
|
263
|
+
- Deploying documentation from feature branches
|
|
264
|
+
- Creating documentation previews for pull requests
|
|
265
|
+
|
|
266
|
+
**Note**: `deploy-branch-docs` will overwrite the current GitHub Pages content, so use with caution in production environments.
|
|
267
|
+
|
|
268
|
+
### Other Development Commands
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
# Run tests
|
|
272
|
+
make test
|
|
273
|
+
|
|
274
|
+
# Run linting
|
|
275
|
+
make lint
|
|
276
|
+
|
|
277
|
+
# Format code
|
|
278
|
+
make format
|
|
279
|
+
|
|
280
|
+
# Run type checking
|
|
281
|
+
make mypy
|
|
282
|
+
|
|
283
|
+
# Run all CI checks
|
|
284
|
+
make ci
|
|
285
|
+
```
|
|
286
|
+
|
|
241
287
|
## License
|
|
242
288
|
|
|
243
289
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
@@ -0,0 +1,400 @@
|
|
|
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(
|
|
34
|
+
profile_name, base_url
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.api_key = api_key or self._load_from_env() or self._load_from_config()
|
|
38
|
+
self.secret_key = (
|
|
39
|
+
api_secret_key
|
|
40
|
+
or self._load_api_secret_key_from_env()
|
|
41
|
+
or self._load_api_secret_key_from_config()
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if not self.api_key:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"No API key provided. Please provide an API key via the constructor, "
|
|
47
|
+
"environment variable DAY2_API_KEY, or configuration file."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _get_credentials_file_path(self) -> Path:
|
|
51
|
+
"""Get the path to the credentials file.
|
|
52
|
+
|
|
53
|
+
Uses the same directory resolution logic as ProfileManager:
|
|
54
|
+
1. Check DAY2_CONFIG_PATH environment variable directory
|
|
55
|
+
2. Fall back to ~/.day2/ directory
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Path to the credentials file.
|
|
59
|
+
"""
|
|
60
|
+
if os.environ.get("DAY2_CONFIG_PATH"):
|
|
61
|
+
config_file_path = Path(os.environ["DAY2_CONFIG_PATH"])
|
|
62
|
+
config_dir = config_file_path.parent
|
|
63
|
+
else:
|
|
64
|
+
config_dir = Path.home() / ".day2"
|
|
65
|
+
|
|
66
|
+
return config_dir / "credentials"
|
|
67
|
+
|
|
68
|
+
def _determine_credential_section(
|
|
69
|
+
self, profile_name: Optional[str], base_url: Optional[str]
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Determine which credential section to use based on profile and base URL.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
profile_name: Profile name
|
|
75
|
+
base_url: Base URL for the profile
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Credential section name to use ("DEFAULT" or profile name)
|
|
79
|
+
"""
|
|
80
|
+
if not profile_name:
|
|
81
|
+
return "DEFAULT"
|
|
82
|
+
|
|
83
|
+
# Use smart detection to determine if we should use profile-specific credentials
|
|
84
|
+
if self._should_use_profile_credentials(base_url):
|
|
85
|
+
return profile_name
|
|
86
|
+
return "DEFAULT"
|
|
87
|
+
|
|
88
|
+
def _should_use_profile_credentials(self, base_url: Optional[str]) -> bool:
|
|
89
|
+
"""Determine if a profile should use profile-specific credentials.
|
|
90
|
+
|
|
91
|
+
Logic:
|
|
92
|
+
- If base_url is different from the default base_url, use profile-specific credentials
|
|
93
|
+
- Otherwise, use shared credentials for the common MSP case
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
base_url: Base URL for this profile (or None to use default)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if profile should use profile-specific credentials, False for shared
|
|
100
|
+
"""
|
|
101
|
+
# Default base URL
|
|
102
|
+
default_base_url = "https://api.montycloud.com/day2/api"
|
|
103
|
+
|
|
104
|
+
# If no base_url provided, use default
|
|
105
|
+
if not base_url:
|
|
106
|
+
base_url = default_base_url
|
|
107
|
+
|
|
108
|
+
# Check if this base_url differs from default (different region/environment)
|
|
109
|
+
if base_url != default_base_url:
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
# Default to shared credentials for the common MSP case
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def _load_from_env(self) -> Optional[str]:
|
|
116
|
+
"""Load API key from environment variables.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
API key if found in environment variables, None otherwise.
|
|
120
|
+
"""
|
|
121
|
+
return os.environ.get("DAY2_API_KEY")
|
|
122
|
+
|
|
123
|
+
def _load_api_secret_key_from_env(self) -> Optional[str]:
|
|
124
|
+
"""Load API secret key from environment variables.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
API secret key if found in environment variables, None otherwise.
|
|
128
|
+
"""
|
|
129
|
+
return os.environ.get("DAY2_API_SECRET_KEY")
|
|
130
|
+
|
|
131
|
+
def _normalize_section_name(self, section_name: str) -> str:
|
|
132
|
+
"""Normalize section name for consistent storage.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
section_name: The section name to normalize
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Normalized section name (DEFAULT for default, original case for others)
|
|
139
|
+
"""
|
|
140
|
+
if section_name.lower() == "default":
|
|
141
|
+
return "DEFAULT"
|
|
142
|
+
return section_name
|
|
143
|
+
|
|
144
|
+
def _find_section_case_insensitive(
|
|
145
|
+
self, config_parser: configparser.ConfigParser, section_name: str
|
|
146
|
+
) -> Optional[str]:
|
|
147
|
+
"""Find a section in the config parser, case-insensitively.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
config_parser: The ConfigParser instance to search
|
|
151
|
+
section_name: The section name to find (case-insensitive)
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The actual section name if found, None otherwise.
|
|
155
|
+
"""
|
|
156
|
+
# Check exact match first
|
|
157
|
+
if config_parser.has_section(section_name):
|
|
158
|
+
return section_name
|
|
159
|
+
|
|
160
|
+
# Check case-insensitive match
|
|
161
|
+
for actual_section in config_parser.sections():
|
|
162
|
+
if actual_section.lower() == section_name.lower():
|
|
163
|
+
return actual_section
|
|
164
|
+
|
|
165
|
+
# Special handling for DEFAULT section (configparser treats it specially)
|
|
166
|
+
if section_name.upper() == "DEFAULT":
|
|
167
|
+
# Check if DEFAULT exists in any case
|
|
168
|
+
if "DEFAULT" in config_parser:
|
|
169
|
+
return "DEFAULT"
|
|
170
|
+
for actual_section in config_parser.sections():
|
|
171
|
+
if actual_section.lower() == "default":
|
|
172
|
+
return actual_section
|
|
173
|
+
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def _load_from_config(self) -> Optional[str]:
|
|
177
|
+
"""Load API key from credentials file.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
API key if found in credentials file, None otherwise.
|
|
181
|
+
"""
|
|
182
|
+
credentials_file = self._get_credentials_file_path()
|
|
183
|
+
|
|
184
|
+
if not credentials_file.exists():
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
config_parser = configparser.ConfigParser()
|
|
189
|
+
config_parser.read(credentials_file)
|
|
190
|
+
|
|
191
|
+
# Try to get from the determined section first (case-insensitive)
|
|
192
|
+
actual_section = self._find_section_case_insensitive(
|
|
193
|
+
config_parser, self.credential_section
|
|
194
|
+
)
|
|
195
|
+
if actual_section:
|
|
196
|
+
return config_parser.get(actual_section, "api_key", fallback=None)
|
|
197
|
+
|
|
198
|
+
# Fall back to DEFAULT section if profile section doesn't exist (case-insensitive)
|
|
199
|
+
default_section = self._find_section_case_insensitive(
|
|
200
|
+
config_parser, "DEFAULT"
|
|
201
|
+
)
|
|
202
|
+
if default_section:
|
|
203
|
+
return config_parser.get(default_section, "api_key", fallback=None)
|
|
204
|
+
return None
|
|
205
|
+
except (configparser.Error, IOError):
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def _load_api_secret_key_from_config(self) -> Optional[str]:
|
|
209
|
+
"""Load API secret key from credentials file.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
API secret key if found in credentials file, None otherwise.
|
|
213
|
+
"""
|
|
214
|
+
credentials_file = self._get_credentials_file_path()
|
|
215
|
+
|
|
216
|
+
if not credentials_file.exists():
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
config_parser = configparser.ConfigParser()
|
|
221
|
+
config_parser.read(credentials_file)
|
|
222
|
+
|
|
223
|
+
# Try to get from the determined section first (case-insensitive)
|
|
224
|
+
actual_section = self._find_section_case_insensitive(
|
|
225
|
+
config_parser, self.credential_section
|
|
226
|
+
)
|
|
227
|
+
if actual_section:
|
|
228
|
+
return config_parser.get(
|
|
229
|
+
actual_section, "api_secret_key", fallback=None
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Fall back to DEFAULT section if profile section doesn't exist (case-insensitive)
|
|
233
|
+
default_section = self._find_section_case_insensitive(
|
|
234
|
+
config_parser, "DEFAULT"
|
|
235
|
+
)
|
|
236
|
+
if default_section:
|
|
237
|
+
return config_parser.get(
|
|
238
|
+
default_section, "api_secret_key", fallback=None
|
|
239
|
+
)
|
|
240
|
+
return None
|
|
241
|
+
except (configparser.Error, IOError):
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def save_to_config(self, section_name: Optional[str] = None) -> None:
|
|
245
|
+
"""Save credentials to credentials file.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
section_name: Section name to save credentials to. If None, uses DEFAULT.
|
|
249
|
+
"""
|
|
250
|
+
if not self.api_key:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
config_dir = Path.home() / ".day2"
|
|
254
|
+
config_dir.mkdir(exist_ok=True)
|
|
255
|
+
|
|
256
|
+
credentials_file = config_dir / "credentials"
|
|
257
|
+
|
|
258
|
+
# Use provided section name or default to DEFAULT
|
|
259
|
+
target_section = self._normalize_section_name(section_name or "DEFAULT")
|
|
260
|
+
|
|
261
|
+
# Load existing credentials if they exist
|
|
262
|
+
config_parser = configparser.ConfigParser()
|
|
263
|
+
if credentials_file.exists():
|
|
264
|
+
config_parser.read(credentials_file)
|
|
265
|
+
|
|
266
|
+
# Handle case-insensitive section merging
|
|
267
|
+
self._merge_section_case_insensitive(config_parser, target_section)
|
|
268
|
+
|
|
269
|
+
# Update config with API key and secret
|
|
270
|
+
config_parser[target_section]["api_key"] = self.api_key
|
|
271
|
+
if self.secret_key:
|
|
272
|
+
config_parser[target_section]["api_secret_key"] = self.secret_key
|
|
273
|
+
|
|
274
|
+
# Save credentials
|
|
275
|
+
with open(credentials_file, "w", encoding="utf-8") as f:
|
|
276
|
+
config_parser.write(f)
|
|
277
|
+
|
|
278
|
+
def _merge_section_case_insensitive(
|
|
279
|
+
self, config_parser: configparser.ConfigParser, target_section: str
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Merge existing section with different case into target section.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
config_parser: The ConfigParser instance to modify
|
|
285
|
+
target_section: The normalized target section name
|
|
286
|
+
"""
|
|
287
|
+
existing_section = self._find_section_case_insensitive(
|
|
288
|
+
config_parser, target_section
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if existing_section and existing_section != target_section:
|
|
292
|
+
# Copy existing values to preserve them
|
|
293
|
+
existing_values = dict(config_parser[existing_section])
|
|
294
|
+
config_parser.remove_section(existing_section)
|
|
295
|
+
config_parser[target_section] = existing_values
|
|
296
|
+
elif not existing_section:
|
|
297
|
+
# Create new section
|
|
298
|
+
config_parser[target_section] = {}
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def save_credentials_to_section(
|
|
302
|
+
cls,
|
|
303
|
+
api_key: str,
|
|
304
|
+
api_secret_key: Optional[str],
|
|
305
|
+
section_name: str,
|
|
306
|
+
credentials_file_path: Optional[Path] = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Save credentials to a specific section in the credentials file.
|
|
309
|
+
|
|
310
|
+
This is a utility method for auth.py to save credentials without creating a Credentials instance.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
api_key: API key to save
|
|
314
|
+
api_secret_key: API secret key to save (optional)
|
|
315
|
+
section_name: Section name to save to
|
|
316
|
+
credentials_file_path: Optional custom path to credentials file
|
|
317
|
+
"""
|
|
318
|
+
if credentials_file_path:
|
|
319
|
+
credentials_file = credentials_file_path
|
|
320
|
+
credentials_file.parent.mkdir(exist_ok=True)
|
|
321
|
+
else:
|
|
322
|
+
config_dir = Path.home() / ".day2"
|
|
323
|
+
config_dir.mkdir(exist_ok=True)
|
|
324
|
+
credentials_file = config_dir / "credentials"
|
|
325
|
+
|
|
326
|
+
# Normalize section name
|
|
327
|
+
normalized_section = cls.normalize_section_name(section_name)
|
|
328
|
+
|
|
329
|
+
# Load existing credentials
|
|
330
|
+
config_parser = configparser.ConfigParser()
|
|
331
|
+
if credentials_file.exists():
|
|
332
|
+
config_parser.read(credentials_file)
|
|
333
|
+
|
|
334
|
+
# Handle case-insensitive section merging
|
|
335
|
+
cls._merge_section_case_insensitive_static(config_parser, normalized_section)
|
|
336
|
+
|
|
337
|
+
# Update credentials
|
|
338
|
+
config_parser[normalized_section]["api_key"] = api_key or ""
|
|
339
|
+
if api_secret_key:
|
|
340
|
+
config_parser[normalized_section]["api_secret_key"] = api_secret_key
|
|
341
|
+
|
|
342
|
+
# Save credentials
|
|
343
|
+
with open(credentials_file, "w", encoding="utf-8") as f:
|
|
344
|
+
config_parser.write(f)
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def normalize_section_name(section_name: str) -> str:
|
|
348
|
+
"""Normalize section name for consistent storage.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
section_name: The section name to normalize
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Normalized section name (DEFAULT for default, original case for others)
|
|
355
|
+
"""
|
|
356
|
+
if section_name.lower() == "default":
|
|
357
|
+
return "DEFAULT"
|
|
358
|
+
return section_name
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def _find_section_case_insensitive_static(
|
|
362
|
+
config_parser: configparser.ConfigParser, section_name: str
|
|
363
|
+
) -> Optional[str]:
|
|
364
|
+
"""Static version of _find_section_case_insensitive for class methods."""
|
|
365
|
+
# Check exact match first
|
|
366
|
+
if config_parser.has_section(section_name):
|
|
367
|
+
return section_name
|
|
368
|
+
|
|
369
|
+
# Check case-insensitive match
|
|
370
|
+
for actual_section in config_parser.sections():
|
|
371
|
+
if actual_section.lower() == section_name.lower():
|
|
372
|
+
return actual_section
|
|
373
|
+
|
|
374
|
+
# Special handling for DEFAULT section
|
|
375
|
+
if section_name.upper() == "DEFAULT":
|
|
376
|
+
if "DEFAULT" in config_parser:
|
|
377
|
+
return "DEFAULT"
|
|
378
|
+
for actual_section in config_parser.sections():
|
|
379
|
+
if actual_section.lower() == "default":
|
|
380
|
+
return actual_section
|
|
381
|
+
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _merge_section_case_insensitive_static(
|
|
386
|
+
config_parser: configparser.ConfigParser, target_section: str
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Static version of _merge_section_case_insensitive for class methods."""
|
|
389
|
+
existing_section = Credentials._find_section_case_insensitive_static(
|
|
390
|
+
config_parser, target_section
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if existing_section and existing_section != target_section:
|
|
394
|
+
# Copy existing values to preserve them
|
|
395
|
+
existing_values = dict(config_parser[existing_section])
|
|
396
|
+
config_parser.remove_section(existing_section)
|
|
397
|
+
config_parser[target_section] = existing_values
|
|
398
|
+
elif not existing_section:
|
|
399
|
+
# Create new section
|
|
400
|
+
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"
|