day2 0.2.3__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 (51) hide show
  1. {day2-0.2.3 → day2-0.2.4}/PKG-INFO +1 -75
  2. {day2-0.2.3 → day2-0.2.4}/README.md +0 -74
  3. {day2-0.2.3 → day2-0.2.4}/day2/auth/credentials.py +40 -42
  4. {day2-0.2.3 → day2-0.2.4}/day2/models/__init__.py +6 -1
  5. {day2-0.2.3 → day2-0.2.4}/day2/models/assessment.py +4 -2
  6. {day2-0.2.3 → day2-0.2.4}/day2/models/report.py +32 -0
  7. {day2-0.2.3 → day2-0.2.4}/day2/resources/assessment.py +4 -2
  8. {day2-0.2.3 → day2-0.2.4}/day2/resources/report.py +36 -1
  9. {day2-0.2.3 → day2-0.2.4}/day2/resources/tenant.py +1 -1
  10. {day2-0.2.3 → day2-0.2.4}/day2/session.py +3 -27
  11. day2-0.2.4/day2/utils/__init__.py +0 -0
  12. day2-0.2.4/day2/utils/helpers.py +27 -0
  13. {day2-0.2.3 → day2-0.2.4}/day2.egg-info/PKG-INFO +1 -75
  14. {day2-0.2.3 → day2-0.2.4}/day2.egg-info/SOURCES.txt +2 -0
  15. {day2-0.2.3 → day2-0.2.4}/day2_cli/commands/assessment.py +26 -26
  16. {day2-0.2.3 → day2-0.2.4}/day2_cli/commands/auth.py +34 -12
  17. {day2-0.2.3 → day2-0.2.4}/day2_cli/commands/profile.py +94 -54
  18. {day2-0.2.3 → day2-0.2.4}/day2_cli/commands/report.py +79 -8
  19. {day2-0.2.3 → day2-0.2.4}/pyproject.toml +1 -1
  20. {day2-0.2.3 → day2-0.2.4}/LICENSE +0 -0
  21. {day2-0.2.3 → day2-0.2.4}/day2/__init__.py +0 -0
  22. {day2-0.2.3 → day2-0.2.4}/day2/_version.py +0 -0
  23. {day2-0.2.3 → day2-0.2.4}/day2/auth/__init__.py +0 -0
  24. {day2-0.2.3 → day2-0.2.4}/day2/client/__init__.py +0 -0
  25. {day2-0.2.3 → day2-0.2.4}/day2/client/base.py +0 -0
  26. {day2-0.2.3 → day2-0.2.4}/day2/client/config.py +0 -0
  27. {day2-0.2.3 → day2-0.2.4}/day2/client/constants.py +0 -0
  28. {day2-0.2.3 → day2-0.2.4}/day2/client/profile.py +0 -0
  29. {day2-0.2.3 → day2-0.2.4}/day2/client/user_context.py +0 -0
  30. {day2-0.2.3 → day2-0.2.4}/day2/exceptions.py +0 -0
  31. {day2-0.2.3 → day2-0.2.4}/day2/models/cost.py +0 -0
  32. {day2-0.2.3 → day2-0.2.4}/day2/models/tenant.py +0 -0
  33. {day2-0.2.3 → day2-0.2.4}/day2/resources/__init__.py +0 -0
  34. {day2-0.2.3 → day2-0.2.4}/day2/resources/cost.py +0 -0
  35. {day2-0.2.3 → day2-0.2.4}/day2/resources/user.py +0 -0
  36. {day2-0.2.3 → day2-0.2.4}/day2.egg-info/dependency_links.txt +0 -0
  37. {day2-0.2.3 → day2-0.2.4}/day2.egg-info/entry_points.txt +0 -0
  38. {day2-0.2.3 → day2-0.2.4}/day2.egg-info/requires.txt +0 -0
  39. {day2-0.2.3 → day2-0.2.4}/day2.egg-info/top_level.txt +0 -0
  40. {day2-0.2.3 → day2-0.2.4}/day2_cli/__init__.py +0 -0
  41. {day2-0.2.3 → day2-0.2.4}/day2_cli/cli.py +0 -0
  42. {day2-0.2.3 → day2-0.2.4}/day2_cli/commands/__init__.py +0 -0
  43. {day2-0.2.3 → day2-0.2.4}/day2_cli/commands/cost.py +0 -0
  44. {day2-0.2.3 → day2-0.2.4}/day2_cli/commands/tenant.py +0 -0
  45. {day2-0.2.3 → day2-0.2.4}/day2_cli/utils/__init__.py +0 -0
  46. {day2-0.2.3 → day2-0.2.4}/day2_cli/utils/context.py +0 -0
  47. {day2-0.2.3 → day2-0.2.4}/day2_cli/utils/exit_codes.py +0 -0
  48. {day2-0.2.3 → day2-0.2.4}/day2_cli/utils/formatters.py +0 -0
  49. {day2-0.2.3 → day2-0.2.4}/day2_cli/utils/output_formatter.py +0 -0
  50. {day2-0.2.3 → day2-0.2.4}/setup.cfg +0 -0
  51. {day2-0.2.3 → day2-0.2.4}/tests/test_exceptions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: day2
3
- Version: 0.2.3
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:
@@ -276,52 +248,6 @@ except Exception as e:
276
248
  print(f"Unexpected error: {e}")
277
249
  ```
278
250
 
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
-
325
251
  ## License
326
252
 
327
253
  This project is licensed under the MIT License - see the LICENSE file for details.
@@ -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:
@@ -238,52 +210,6 @@ except Exception as e:
238
210
  print(f"Unexpected error: {e}")
239
211
  ```
240
212
 
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
-
287
213
  ## License
288
214
 
289
215
  This project is licensed under the MIT License - see the LICENSE file for details.
@@ -30,9 +30,7 @@ class Credentials:
30
30
  self.base_url = base_url
31
31
 
32
32
  # Determine which credential section to use
33
- self.credential_section = self._determine_credential_section(
34
- profile_name, base_url
35
- )
33
+ self.credential_section = self._determine_credential_section(profile_name)
36
34
 
37
35
  self.api_key = api_key or self._load_from_env() or self._load_from_config()
38
36
  self.secret_key = (
@@ -65,52 +63,26 @@ class Credentials:
65
63
 
66
64
  return config_dir / "credentials"
67
65
 
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.
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
72
73
 
73
74
  Args:
74
75
  profile_name: Profile name
75
- base_url: Base URL for the profile
76
76
 
77
77
  Returns:
78
- Credential section name to use ("DEFAULT" or profile name)
78
+ Credential section name to use (profile name or "DEFAULT")
79
79
  """
80
80
  if not profile_name:
81
81
  return "DEFAULT"
82
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
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
114
86
 
115
87
  def _load_from_env(self) -> Optional[str]:
116
88
  """Load API key from environment variables.
@@ -188,7 +160,21 @@ class Credentials:
188
160
  config_parser = configparser.ConfigParser()
189
161
  config_parser.read(credentials_file)
190
162
 
191
- # Try to get from the determined section first (case-insensitive)
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)
192
178
  actual_section = self._find_section_case_insensitive(
193
179
  config_parser, self.credential_section
194
180
  )
@@ -220,7 +206,19 @@ class Credentials:
220
206
  config_parser = configparser.ConfigParser()
221
207
  config_parser.read(credentials_file)
222
208
 
223
- # Try to get from the determined section first (case-insensitive)
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)
224
222
  actual_section = self._find_section_case_insensitive(
225
223
  config_parser, self.credential_section
226
224
  )
@@ -12,7 +12,11 @@ from day2.models.assessment import (
12
12
  RunAssessmentOutput,
13
13
  )
14
14
  from day2.models.cost import GetCostByChargeTypeOutput
15
- from day2.models.report import GetReportDetailsOutput, GetReportOutput
15
+ from day2.models.report import (
16
+ GetReportDetailsOutput,
17
+ GetReportOutput,
18
+ ListReportsOutput,
19
+ )
16
20
  from day2.models.tenant import ListTenantsOutput, TenantDetails
17
21
 
18
22
  __all__ = [
@@ -30,4 +34,5 @@ __all__ = [
30
34
  "GenerateAssessmentReportOutput",
31
35
  "GetReportDetailsOutput",
32
36
  "GetReportOutput",
37
+ "ListReportsOutput",
33
38
  ]
@@ -507,7 +507,8 @@ class RunAssessmentInput(BaseModel):
507
507
 
508
508
  Attributes:
509
509
  lens_name: Name of the lens (default: "AWS Well-Architected Framework").
510
- pillar_id: Optional ID of the pillar.
510
+ pillar_id: Optional ID of the pillar. Allowed values: security, costOptimization,
511
+ reliability, performance, operationalExcellence, sustainability.
511
512
  question_id: Optional ID of the specific question.
512
513
  best_practice_id: Optional ID of the best practice.
513
514
  """
@@ -558,7 +559,8 @@ class GenerateAssessmentReportInput(BaseModel):
558
559
 
559
560
  Attributes:
560
561
  cloud_provider: Cloud provider name (default: AWS).
561
- pillar_ids: List of pillar IDs to filter findings.
562
+ pillar_ids: List of pillar IDs to filter findings. Allowed values: security, costOptimization,
563
+ reliability, performance, operationalExcellence, sustainability.
562
564
  severity: List of severity levels to filter findings.
563
565
  status: List of statuses to filter findings.
564
566
  account_number: List of AWS account numbers to filter findings.
@@ -1,4 +1,5 @@
1
1
  from datetime import datetime
2
+ from typing import List
2
3
 
3
4
  from pydantic import BaseModel, ConfigDict, Field
4
5
 
@@ -39,3 +40,34 @@ class GetReportOutput(BaseModel):
39
40
 
40
41
  # Allow extra fields
41
42
  model_config = ConfigDict(extra="allow")
43
+
44
+
45
+ # Models for handling listing report
46
+ class ReportItem(BaseModel):
47
+ """Details of individual report item.
48
+
49
+ Attributes:
50
+ report_id: Unique identifier for the report.
51
+ report_name: Name of the report.
52
+ report_type: Type of the report.
53
+ status: Current status of the report generation (e.g., "Pending", "Success", "Failed").
54
+ export_format: Format of the report (e.g., "PDF", "CSV", "JSON").
55
+ created_at: Timestamp when the report was created.
56
+ """
57
+
58
+ report_id: str = Field(alias="ReportId")
59
+ report_name: str = Field(alias="ReportName")
60
+ report_type: str = Field(alias="ReportType")
61
+ status: str = Field(alias="Status")
62
+ export_format: str = Field(alias="ExportFormat")
63
+ created_at: datetime = Field(alias="CreatedAt")
64
+
65
+
66
+ class ListReportsOutput(BaseModel):
67
+ """Output model for the ListReports API.
68
+
69
+ Attributes:
70
+ reports: List of report items.
71
+ """
72
+
73
+ reports: List[ReportItem] = Field(alias="Records", default=[])
@@ -186,7 +186,8 @@ class AssessmentClient(BaseClient):
186
186
  Args:
187
187
  tenant_id: ID of the tenant the assessment belongs to.
188
188
  assessment_id: ID of the assessment to get questions for.
189
- pillar_id: ID of the pillar to get questions for.
189
+ pillar_id: ID of the pillar to get questions for. Allowed values: security, costOptimization,
190
+ reliability, performance, operationalExcellence, sustainability.
190
191
 
191
192
  Returns:
192
193
  ListQuestionsOutput: Object containing list of questions for the specified pillar.
@@ -346,7 +347,8 @@ class AssessmentClient(BaseClient):
346
347
  resource_type: Optional list of resource types to filter findings by.
347
348
  question_ids: Optional list of question IDs to filter findings by.
348
349
  resource_ids: Optional list of resource IDs to filter findings by.
349
- pillar_ids: Optional list of pillar IDs to filter findings by.
350
+ pillar_ids: Optional list of pillar IDs to filter findings by. Allowed values: security, costOptimization,
351
+ reliability, performance, operationalExcellence, sustainability.
350
352
  page_size: The number of findings per page (default: 10).
351
353
  page_token: Token for pagination.
352
354
 
@@ -1,7 +1,11 @@
1
1
  """Reports resource implementation for the MontyCloud DAY2 SDK."""
2
2
 
3
3
  from day2.client.base import BaseClient
4
- from day2.models.report import GetReportDetailsOutput, GetReportOutput
4
+ from day2.models.report import (
5
+ GetReportDetailsOutput,
6
+ GetReportOutput,
7
+ ListReportsOutput,
8
+ )
5
9
  from day2.session import Session
6
10
 
7
11
 
@@ -78,3 +82,34 @@ class ReportClient(BaseClient):
78
82
  params=params,
79
83
  )
80
84
  return GetReportOutput.model_validate(response)
85
+
86
+ def list_reports(
87
+ self, tenant_id: str, cloud_provider: str = "AWS"
88
+ ) -> ListReportsOutput:
89
+ """Retrieves up to 100 of the most recent reports for a tenant, ordered by creation time (newest first).
90
+
91
+ Args:
92
+ tenant_id: ID of the tenant that owns the reports.
93
+ cloud_provider: Cloud provider for which to list reports (default is "AWS").
94
+
95
+ Returns:
96
+ ListReportsOutput: Object containing a list of report items (maximum 100).
97
+
98
+ Raises:
99
+ ResourceNotFoundError: If no reports are found.
100
+ ClientError: If the request is invalid.
101
+ ServerError: If an internal server error occurs.
102
+ AuthenticationError: If authentication fails.
103
+
104
+ Examples:
105
+ >>> session = Session(api_key="your-api-key", api_secret_key="your-secret-key")
106
+ >>> client = session.report
107
+ >>> response = client.list_reports(tenant_id="tenant-123", cloud_provider="AWS")
108
+ >>> for report in response.reports:
109
+ >>> print(f"Report Name: {report.report_name}, Status: {report.status}")
110
+ """
111
+ params = {"CloudProvider": cloud_provider}
112
+ response = self._make_request(
113
+ "GET", f"tenants/{tenant_id}/reports/", params=params
114
+ )
115
+ return ListReportsOutput.model_validate(response)
@@ -52,7 +52,7 @@ class TenantClient(BaseClient):
52
52
  if page_token:
53
53
  params["PageToken"] = page_token
54
54
 
55
- response = self._make_request("GET", "tenants", params=params)
55
+ response = self._make_request("GET", "tenants/", params=params)
56
56
  return ListTenantsOutput.model_validate(response)
57
57
 
58
58
  def get_tenant(self, tenant_id: str) -> GetTenantOutput:
@@ -165,36 +165,12 @@ class Session:
165
165
  if user_context and user_context.tenant_id:
166
166
  self.tenant_id = user_context.tenant_id
167
167
  logger.debug("Using tenant_id from user context: %s", self.tenant_id)
168
- else:
169
- # Try to fetch user context from API if not found locally
170
- self._fetch_and_store_user_context(config_dir)
168
+ # Note: We do NOT fetch user context from API during Session init anymore
169
+ # This prevents file creation issues in Lambda environments
170
+ # User context should only be fetched and saved during auth login
171
171
 
172
172
  logger.debug("Initialized session with tenant_id=%s", self.tenant_id)
173
173
 
174
- def _fetch_and_store_user_context(self, config_dir: Optional[Path] = None) -> None:
175
- """Fetch user context from API and store it locally.
176
-
177
- Args:
178
- config_dir: Directory to store the user context file.
179
- """
180
- try:
181
- # Import here to avoid circular imports during initialization
182
- from day2.resources.user import UserClient
183
-
184
- # Create a temporary user client without tenant context
185
- user_client = UserClient(self)
186
- user_context = user_client.get_user()
187
-
188
- if user_context and user_context.tenant_id:
189
- # Store the user context for future use
190
- user_context.save(config_dir)
191
- self.tenant_id = user_context.tenant_id
192
- logger.debug(
193
- "Fetched and stored user context with tenant_id: %s", self.tenant_id
194
- )
195
- except (ImportError, AttributeError, ValueError) as e:
196
- logger.debug("Could not fetch user context: %s", e)
197
-
198
174
  def _save_tenant_to_config(self) -> None:
199
175
  """Save tenant ID to configuration file."""
200
176
  if not self.tenant_id:
File without changes
@@ -0,0 +1,27 @@
1
+ """Helper utilities for the MontyCloud DAY2 SDK."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional, Union
5
+
6
+
7
+ def format_datetime(
8
+ dt: Optional[Union[datetime, str]] = None,
9
+ format_str: str = "%Y-%m-%d %H:%M:%S",
10
+ default: str = "N/A",
11
+ ) -> str:
12
+ """Format a datetime object or string to a standard format.
13
+
14
+ Args:
15
+ dt: Datetime object or string to format. If None, returns the default value.
16
+ format_str: Format string to use for datetime formatting.
17
+ default: Default value to return if dt is None.
18
+
19
+ Returns:
20
+ Formatted datetime string or default value if dt is None.
21
+ """
22
+ if dt is None:
23
+ return default
24
+
25
+ if isinstance(dt, datetime):
26
+ return dt.strftime(format_str)
27
+ return str(dt)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: day2
3
- Version: 0.2.3
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:
@@ -276,52 +248,6 @@ except Exception as e:
276
248
  print(f"Unexpected error: {e}")
277
249
  ```
278
250
 
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
-
325
251
  ## License
326
252
 
327
253
  This project is licensed under the MIT License - see the LICENSE file for details.
@@ -30,6 +30,8 @@ day2/resources/cost.py
30
30
  day2/resources/report.py
31
31
  day2/resources/tenant.py
32
32
  day2/resources/user.py
33
+ day2/utils/__init__.py
34
+ day2/utils/helpers.py
33
35
  day2_cli/__init__.py
34
36
  day2_cli/cli.py
35
37
  day2_cli/commands/__init__.py
@@ -335,7 +335,7 @@ def list_questions(
335
335
  If --tenant-id is not provided, uses the default tenant configured with 'day2 profile create' or 'day2 auth login'.
336
336
 
337
337
  ASSESSMENT-ID: ID of the assessment to list questions for.
338
- PILLAR-ID: ID of the pillar to list questions for (required).
338
+ PILLAR-ID: ID of the pillar to list questions for (required). Allowed values: security, costOptimization, reliability, performance, operationalExcellence, sustainability.
339
339
  """
340
340
  try:
341
341
  # Get enhanced context with tenant resolution
@@ -788,7 +788,7 @@ def answer_question(
788
788
  @click.option(
789
789
  "--pillar-id",
790
790
  multiple=True,
791
- help="Filter by pillar ID (can be used multiple times)",
791
+ help="Filter by pillar ID (can be used multiple times). Allowed values: security, costOptimization, reliability, performance, operationalExcellence, sustainability.",
792
792
  )
793
793
  @click.option("--page-token", help="Page token for pagination")
794
794
  @click.option("--page-size", type=int, default=10, help="Page size")
@@ -912,19 +912,19 @@ def list_findings(
912
912
  @click.option(
913
913
  "--lens-name",
914
914
  default="AWS Well-Architected Framework",
915
- help="Name of the lens to use for the assessment",
915
+ help="The name of the lens to use for the assessment (default: AWS Well-Architected Framework).",
916
916
  )
917
917
  @click.option(
918
918
  "--pillar-id",
919
- help="Optional ID of the pillar to filter questions",
919
+ help="The ID of the pillar to assess. Allowed values: security, costOptimization, reliability, performance, operationalExcellence, sustainability.",
920
920
  )
921
921
  @click.option(
922
922
  "--question-id",
923
- help="Optional ID of the specific question to assess",
923
+ help="The ID of a specific question to assess within the selected pillar.",
924
924
  )
925
925
  @click.option(
926
926
  "--best-practice-id",
927
- help="Optional ID of the best practice to evaluate",
927
+ help="The ID of a best practice to evaluate for the selected question ID.",
928
928
  )
929
929
  @with_common_options(include_tenant_id=True)
930
930
  def run_assessment(
@@ -969,16 +969,16 @@ def run_assessment(
969
969
 
970
970
  # Format and output the result
971
971
  run_data = {
972
- "Run ID": result.run_id,
973
- "Lens Name": lens_name,
974
- "Pillar ID": pillar_id or "N/A",
975
- "Question ID": question_id or "N/A",
976
- "Best Practice ID": best_practice_id or "N/A",
972
+ "id": result.run_id,
973
+ "lens_name": lens_name,
974
+ "pillar_id": pillar_id or "N/A",
975
+ "question_id": question_id or "N/A",
976
+ "best_practice_id": best_practice_id or "N/A",
977
977
  }
978
978
 
979
979
  format_item_output(
980
980
  item=run_data,
981
- title=f"Assessment Run Details: {assessment_id}",
981
+ title=f"Assessment Run Details: {result.run_id}",
982
982
  format_override=output_format,
983
983
  )
984
984
 
@@ -994,9 +994,9 @@ def run_assessment(
994
994
  help="Cloud provider name (default: AWS)",
995
995
  )
996
996
  @click.option(
997
- "--pillar-ids",
997
+ "--pillar-id",
998
998
  multiple=True,
999
- help="Filter findings by pillar IDs (e.g., security, reliability, performance). Can be used multiple times.",
999
+ help="Filter findings by pillar ID (Can be used multiple times). Allowed values: security, costOptimization, reliability, performance, operationalExcellence, sustainability.",
1000
1000
  )
1001
1001
  @click.option(
1002
1002
  "--severity",
@@ -1032,7 +1032,7 @@ def run_assessment(
1032
1032
  def generate_report(
1033
1033
  assessment_id: str,
1034
1034
  cloud_provider: str,
1035
- pillar_ids: Tuple[str, ...],
1035
+ pillar_id: Tuple[str, ...],
1036
1036
  severity: Tuple[str, ...],
1037
1037
  status: Tuple[str, ...],
1038
1038
  account_number: Tuple[str, ...],
@@ -1064,7 +1064,7 @@ def generate_report(
1064
1064
  # Create the input model
1065
1065
  report_input = GenerateAssessmentReportInput(
1066
1066
  CloudProvider=cloud_provider,
1067
- PillarIds=list(pillar_ids) if pillar_ids else None,
1067
+ PillarIds=list(pillar_id) if pillar_id else None,
1068
1068
  Severity=list(severity) if severity else None,
1069
1069
  Status=list(status) if status else None,
1070
1070
  AccountNumber=list(account_number) if account_number else None,
@@ -1082,20 +1082,20 @@ def generate_report(
1082
1082
 
1083
1083
  # Format and output the result
1084
1084
  report_data = {
1085
- "Report ID": result.report_id,
1086
- "Cloud Provider": cloud_provider,
1087
- "Pillar IDs": ", ".join(pillar_ids) if pillar_ids else "N/A",
1088
- "Severity": ", ".join(severity) if severity else "N/A",
1089
- "Status": ", ".join(status) if status else "N/A",
1090
- "Account Numbers": ", ".join(account_number) if account_number else "N/A",
1091
- "Region Codes": ", ".join(region_code) if region_code else "N/A",
1092
- "Unique Report": unique_report,
1093
- "Lenses": ", ".join(lenses_list),
1085
+ "id": result.report_id,
1086
+ "cloud_provider": cloud_provider,
1087
+ "pillar_id": ", ".join(pillar_id) if pillar_id else "N/A",
1088
+ "severity": ", ".join(severity) if severity else "N/A",
1089
+ "status": ", ".join(status) if status else "N/A",
1090
+ "account_number": ", ".join(account_number) if account_number else "N/A",
1091
+ "region_code": ", ".join(region_code) if region_code else "N/A",
1092
+ "unique_report": unique_report,
1093
+ "lenses": ", ".join(lenses_list),
1094
1094
  }
1095
1095
 
1096
1096
  format_item_output(
1097
1097
  item=report_data,
1098
- title=f"Generated Report Details: {assessment_id}",
1098
+ title=f"Generated Report successfully: {result.report_id}",
1099
1099
  format_override=output_format,
1100
1100
  )
1101
1101
 
@@ -557,9 +557,24 @@ def login(
557
557
  # Determine credential storage scope
558
558
  credential_profile_name = profile # Default to the determined profile
559
559
 
560
+ # Check if DEFAULT credentials already exist
561
+ credentials_file = Path.home() / ".day2" / "credentials"
562
+ has_default_credentials = False
563
+ if credentials_file.exists():
564
+ cred_parser = configparser.ConfigParser()
565
+ cred_parser.read(credentials_file)
566
+ # Check if DEFAULT section exists with credentials
567
+ if "DEFAULT" in cred_parser and cred_parser["DEFAULT"].get("api_key"):
568
+ has_default_credentials = True
569
+
560
570
  # Check if we should prompt for credential scope
571
+ # Don't prompt if:
572
+ # 1. This is first time setup (no DEFAULT credentials exist)
573
+ # 2. User explicitly provided --profile
574
+ # 3. Current profile is "default"
561
575
  should_prompt = (
562
- not profile_explicitly_provided # User didn't specify --profile
576
+ has_default_credentials # Only prompt if DEFAULT credentials already exist
577
+ and not profile_explicitly_provided # User didn't specify --profile
563
578
  and profile != "default" # User has an active non-default profile
564
579
  and (
565
580
  os.environ.get("DAY2_PROFILE") or profile_manager.get_default_profile()
@@ -585,9 +600,6 @@ def login(
585
600
  credential_profile_name = "default"
586
601
  console.print(" ✓ Will store shared credentials for all profiles")
587
602
 
588
- # Determine credentials file path
589
- credentials_file = Path.home() / ".day2" / "credentials"
590
-
591
603
  # Determine credential section based on user choice
592
604
  if should_prompt:
593
605
  # User was prompted and made an explicit choice
@@ -598,14 +610,24 @@ def login(
598
610
  # User chose specific profile - use profile name
599
611
  credential_section = credential_profile_name
600
612
  else:
601
- # No choice was made - use smart detection for backward compatibility
602
- temp_credentials = Credentials(
603
- api_key=api_key,
604
- api_secret_key=api_secret_key,
605
- profile_name=credential_profile_name,
606
- base_url=config.base_url,
607
- )
608
- credential_section = temp_credentials.credential_section
613
+ # No prompt was shown
614
+ if not has_default_credentials:
615
+ # First time setup
616
+ if profile_explicitly_provided and profile != "default":
617
+ # User explicitly specified a non-default profile - respect their choice
618
+ credential_section = credential_profile_name
619
+ console.print(
620
+ f"\n[dim]First time setup - storing credentials for profile {profile}[/dim]"
621
+ )
622
+ else:
623
+ # No explicit profile or profile is "default" - use DEFAULT section
624
+ credential_section = "DEFAULT"
625
+ console.print(
626
+ "\n[dim]First time setup - storing credentials for all profiles[/dim]"
627
+ )
628
+ else:
629
+ # Use the profile name (which will check profile first, then DEFAULT)
630
+ credential_section = credential_profile_name
609
631
 
610
632
  if verbose:
611
633
  if credential_section == "DEFAULT":
@@ -23,6 +23,45 @@ def get_profile_manager() -> ProfileManager:
23
23
  return ProfileManager()
24
24
 
25
25
 
26
+ def show_no_profiles_message(output: Optional[str]) -> None:
27
+ """Display a consistent 'no profiles configured' message.
28
+
29
+ Args:
30
+ output: Output format ("json" or None for table format)
31
+ """
32
+ if output and output.lower() == "json":
33
+ console.print(json.dumps({"error": "No profiles configured"}, indent=2))
34
+ else:
35
+ console.print("[yellow]No profiles configured.[/yellow]")
36
+ console.print("\n[dim]To get started:[/dim]")
37
+ console.print(" 1. Run [cyan]day2 auth login[/cyan] to set up authentication")
38
+ console.print(
39
+ " 2. Or create a profile with [cyan]day2 profile create <name>[/cyan]"
40
+ )
41
+
42
+
43
+ def determine_current_profile() -> Optional[str]:
44
+ """Determine the currently active profile.
45
+
46
+ Returns:
47
+ The name of the current profile, or None if no profiles are configured.
48
+ """
49
+ env_profile = os.environ.get("DAY2_PROFILE")
50
+ profile_manager = get_profile_manager()
51
+ default_profile = profile_manager.get_default_profile()
52
+ existing_profiles = profile_manager.list_profiles()
53
+
54
+ # Determine current profile
55
+ if env_profile:
56
+ return env_profile
57
+ if default_profile:
58
+ return default_profile
59
+ if existing_profiles:
60
+ return "default"
61
+ # No profiles configured at all
62
+ return None
63
+
64
+
26
65
  def ensure_default_profile() -> Optional[str]:
27
66
  """Ensure that a default profile exists.
28
67
 
@@ -637,10 +676,13 @@ def list_profiles(output: Optional[str] = None, detailed: bool = False) -> None:
637
676
  profile_details = get_profile_manager().list_profiles_with_details()
638
677
 
639
678
  if output and output.lower() == "json":
640
- console.print(json.dumps({"profiles": profile_details}, indent=2))
679
+ if not profile_details:
680
+ console.print(json.dumps({"error": "No profiles configured"}, indent=2))
681
+ else:
682
+ console.print(json.dumps({"profiles": profile_details}, indent=2))
641
683
  else:
642
684
  if not profile_details:
643
- console.print("[yellow]No profiles found.[/yellow]")
685
+ show_no_profiles_message(output)
644
686
  return
645
687
 
646
688
  table = Table(title="Available Profiles")
@@ -670,11 +712,17 @@ def list_profiles(output: Optional[str] = None, detailed: bool = False) -> None:
670
712
  default_profile = get_profile_manager().get_default_profile()
671
713
 
672
714
  if output and output.lower() == "json":
673
- profile_data = {"profiles": profiles, "default_profile": default_profile}
674
- console.print(json.dumps(profile_data, indent=2))
715
+ if not profiles:
716
+ console.print(json.dumps({"error": "No profiles configured"}, indent=2))
717
+ else:
718
+ profile_data = {
719
+ "profiles": profiles,
720
+ "default_profile": default_profile,
721
+ }
722
+ console.print(json.dumps(profile_data, indent=2))
675
723
  else:
676
724
  if not profiles:
677
- console.print("[yellow]No profiles found.[/yellow]")
725
+ show_no_profiles_message(output)
678
726
  return
679
727
 
680
728
  table = Table(title="Available Profiles")
@@ -697,27 +745,33 @@ def list_profiles(output: Optional[str] = None, detailed: bool = False) -> None:
697
745
  def current_profile(output: Optional[str] = None) -> None:
698
746
  """Show the currently active profile name and essential configuration."""
699
747
  try:
700
- from day2_cli.utils.context import get_enhanced_context
748
+ # Determine current profile using shared logic
749
+ current = determine_current_profile()
701
750
 
702
- env_profile = os.environ.get("DAY2_PROFILE")
703
- default_profile = get_profile_manager().get_default_profile()
704
-
705
- # Determine current profile
706
- current = env_profile or default_profile or "default"
751
+ # Handle case where no profiles are configured
752
+ if current is None:
753
+ show_no_profiles_message(output)
754
+ return
707
755
 
708
- # Get enhanced context to resolve output format properly
756
+ # Get profile configuration without creating a session (no credentials needed)
709
757
  try:
710
- context = get_enhanced_context(output=output, profile=current)
711
- session = context["session"]
712
- output_format = context["output_format"]
713
- sess_config = session.config
758
+ profile_manager = get_profile_manager()
759
+
760
+ # Get profile config
761
+ if current == "default":
762
+ config = profile_manager.get_default_config()
763
+ else:
764
+ config = profile_manager.get_profile_config(current)
765
+
766
+ # Determine output format - use provided output or profile's configured format
767
+ output_format = output if output else config.output_format
714
768
 
715
769
  # Essential profile information only
716
770
  profile_data = {
717
771
  "name": current,
718
- "base_url": sess_config.base_url,
719
- "tenant_id": session.tenant_id or None,
720
- "output_format": sess_config.output_format,
772
+ "base_url": config.base_url,
773
+ "tenant_id": config.tenant_id or None,
774
+ "output_format": config.output_format,
721
775
  }
722
776
 
723
777
  if output_format and output_format.lower() == "json":
@@ -730,9 +784,9 @@ def current_profile(output: Optional[str] = None) -> None:
730
784
 
731
785
  # Add essential information
732
786
  table.add_row("Profile Name", current)
733
- table.add_row("Base URL", sess_config.base_url)
734
- table.add_row("Tenant ID", session.tenant_id or "Not set")
735
- table.add_row("Output Format", sess_config.output_format)
787
+ table.add_row("Base URL", config.base_url)
788
+ table.add_row("Tenant ID", config.tenant_id or "Not set")
789
+ table.add_row("Output Format", config.output_format)
736
790
 
737
791
  console.print(table)
738
792
 
@@ -809,13 +863,15 @@ def show_profile(
809
863
  PROFILE-NAME: Name of the profile to show. If not provided, shows the current/default profile.
810
864
  """
811
865
  try:
812
- from day2_cli.utils.context import get_enhanced_context
813
-
814
866
  # Determine which profile to show
815
867
  if not profile_name:
816
- env_profile = os.environ.get("DAY2_PROFILE")
817
- default_profile = get_profile_manager().get_default_profile()
818
- profile_name = env_profile or default_profile or "default"
868
+ # Use shared logic to determine current profile
869
+ profile_name = determine_current_profile()
870
+
871
+ # Handle case where no profiles are configured
872
+ if profile_name is None:
873
+ show_no_profiles_message(output)
874
+ return
819
875
 
820
876
  # Check if profile exists
821
877
  if profile_name != "default" and not get_profile_manager().profile_exists(
@@ -826,38 +882,22 @@ def show_profile(
826
882
  )
827
883
  return
828
884
 
829
- # Use enhanced context to get resolved output format from current profile
830
- # but get tenant_id from the target profile being shown
831
- try:
832
- context = get_enhanced_context(
833
- output=output
834
- ) # Use current profile for formatting
835
- output_format = context["output_format"]
836
- # Try to get runtime session data for tenant_id from target profile
837
- try:
838
- target_context = get_enhanced_context(
839
- output=output, profile=profile_name
840
- )
841
- target_session = target_context["session"]
842
- runtime_tenant_id = target_session.tenant_id
843
- except (ImportError, AttributeError, ValueError):
844
- runtime_tenant_id = None
845
- except (ImportError, AttributeError, ValueError):
846
- # Fall back to profile config only
847
- output_format = output if output else "table"
848
- runtime_tenant_id = None
849
-
850
- # Get profile config
885
+ # Get profile config without creating a session
886
+ profile_manager = get_profile_manager()
851
887
  if profile_name == "default":
852
- config = get_profile_manager().get_default_config()
888
+ config = profile_manager.get_default_config()
853
889
  is_default = True
854
890
  else:
855
- config = get_profile_manager().get_profile_config(profile_name)
856
- default_profile = get_profile_manager().get_default_profile()
891
+ config = profile_manager.get_profile_config(profile_name)
892
+ default_profile = profile_manager.get_default_profile()
857
893
  is_default = profile_name == default_profile
858
894
 
859
- # Use runtime tenant_id if available, otherwise fall back to config
860
- display_tenant_id = runtime_tenant_id if runtime_tenant_id else config.tenant_id
895
+ # Determine output format - use provided output or profile's configured format
896
+ output_format = output if output else config.output_format
897
+
898
+ # For profile show, we just display the configured tenant_id
899
+ # No need to fetch runtime data which requires credentials
900
+ display_tenant_id = config.tenant_id
861
901
 
862
902
  # Prepare profile data
863
903
  profile_data = {
@@ -7,9 +7,10 @@ from rich.console import Console
7
7
  from rich.table import Table
8
8
 
9
9
  from day2.exceptions import Day2Error
10
+ from day2.utils.helpers import format_datetime
10
11
  from day2_cli.utils.context import get_enhanced_context, with_common_options
11
12
  from day2_cli.utils.exit_codes import handle_error_with_exit
12
- from day2_cli.utils.output_formatter import format_item_output
13
+ from day2_cli.utils.output_formatter import format_item_output, format_list_output
13
14
 
14
15
  console = Console()
15
16
 
@@ -19,6 +20,76 @@ def report() -> None:
19
20
  """Reports commands."""
20
21
 
21
22
 
23
+ @report.command("list")
24
+ @click.option(
25
+ "--cloud-provider",
26
+ default="AWS",
27
+ help="Cloud provider for which to list reports (default is 'AWS').",
28
+ )
29
+ @with_common_options(include_tenant_id=True)
30
+ def list_reports(
31
+ cloud_provider: str,
32
+ output: Optional[str] = None,
33
+ profile: Optional[str] = None,
34
+ tenant_id: Optional[str] = None,
35
+ ) -> None:
36
+ """Retrieves up to 100 of the most recent reports for a tenant, ordered by creation time (newest first).
37
+
38
+ If --tenant-id is not provided, uses the default tenant configured with 'day2 profile create' or 'day2 auth login'.
39
+ """
40
+ try:
41
+ # Get enhanced context with tenant resolution
42
+ context = get_enhanced_context(
43
+ output=output, profile=profile, tenant_id=tenant_id, require_tenant=True
44
+ )
45
+ session = context["session"]
46
+ output_format = context["output_format"]
47
+ resolved_tenant_id = context["tenant_id"]
48
+
49
+ # Call the client method
50
+ result = session.report.list_reports(resolved_tenant_id, cloud_provider)
51
+
52
+ # Convert report objects to dictionaries for the formatter
53
+ reports_data = []
54
+ for report_item in result.reports:
55
+ created_at = (
56
+ format_datetime(report_item.created_at)
57
+ if report_item.created_at
58
+ else "N/A"
59
+ )
60
+ reports_data.append(
61
+ {
62
+ "id": report_item.report_id,
63
+ "name": report_item.report_name,
64
+ "type": report_item.report_type,
65
+ "status": report_item.status,
66
+ "export_format": report_item.export_format,
67
+ "created_at": created_at,
68
+ }
69
+ )
70
+
71
+ # Define column mapping for the formatter
72
+ columns = {
73
+ "id": "ID",
74
+ "name": "Name",
75
+ "type": "Type",
76
+ "status": "Status",
77
+ "export_format": "Export Format",
78
+ "created_at": "Created At",
79
+ }
80
+
81
+ # Format and output the results
82
+ format_list_output(
83
+ items=reports_data,
84
+ title=f"Reports for Tenant: {resolved_tenant_id}",
85
+ columns=columns,
86
+ format_override=output_format,
87
+ )
88
+
89
+ except Day2Error as e:
90
+ handle_error_with_exit(e)
91
+
92
+
22
93
  @report.command("get-details")
23
94
  @click.argument("report-id")
24
95
  @with_common_options(include_tenant_id=True)
@@ -60,13 +131,13 @@ def get_report_details(
60
131
 
61
132
  # Format and output the result
62
133
  report_data = {
63
- "ID": result.report_id,
64
- "Type": result.report_type,
65
- "Status": result.status,
66
- "Export Format": result.export_format,
67
- "Created At": created_at,
68
- "Updated At": updated_at,
69
- "Created By": result.created_by,
134
+ "id": result.report_id,
135
+ "type": result.report_type,
136
+ "status": result.status,
137
+ "export_format": result.export_format,
138
+ "created_at": created_at,
139
+ "updated_at": updated_at,
140
+ "created_by": result.created_by,
70
141
  }
71
142
 
72
143
  format_item_output(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "day2"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "MontyCloud DAY2 SDK for Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes