day2 0.2.0.dev1__tar.gz → 0.2.1__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 (52) hide show
  1. {day2-0.2.0.dev1 → day2-0.2.1}/PKG-INFO +1 -9
  2. {day2-0.2.0.dev1 → day2-0.2.1}/README.md +0 -8
  3. {day2-0.2.0.dev1 → day2-0.2.1}/day2/client/base.py +56 -50
  4. {day2-0.2.0.dev1 → day2-0.2.1}/day2/client/config.py +2 -33
  5. day2-0.2.1/day2/models/cost.py +54 -0
  6. {day2-0.2.0.dev1 → day2-0.2.1}/day2/models/tenant.py +25 -5
  7. day2-0.2.1/day2/resources/cost.py +62 -0
  8. {day2-0.2.0.dev1 → day2-0.2.1}/day2/resources/tenant.py +37 -1
  9. {day2-0.2.0.dev1 → day2-0.2.1}/day2/session.py +12 -0
  10. {day2-0.2.0.dev1 → day2-0.2.1}/day2.egg-info/PKG-INFO +1 -9
  11. {day2-0.2.0.dev1 → day2-0.2.1}/day2.egg-info/SOURCES.txt +6 -0
  12. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/cli.py +2 -0
  13. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/commands/assessment.py +7 -4
  14. day2-0.2.1/day2_cli/commands/cost.py +129 -0
  15. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/commands/tenant.py +83 -0
  16. {day2-0.2.0.dev1 → day2-0.2.1}/pyproject.toml +1 -1
  17. {day2-0.2.0.dev1 → day2-0.2.1}/tests/test_cli_config.py +1 -1
  18. day2-0.2.1/tests/test_client_base.py +231 -0
  19. {day2-0.2.0.dev1 → day2-0.2.1}/tests/test_config.py +3 -3
  20. day2-0.2.1/tests/test_models_cost.py +112 -0
  21. {day2-0.2.0.dev1 → day2-0.2.1}/tests/test_models_tenant.py +79 -15
  22. day2-0.2.1/tests/test_resources_cost.py +90 -0
  23. day2-0.2.1/tests/test_resources_tenant.py +230 -0
  24. day2-0.2.0.dev1/tests/test_resources_tenant.py +0 -100
  25. {day2-0.2.0.dev1 → day2-0.2.1}/LICENSE +0 -0
  26. {day2-0.2.0.dev1 → day2-0.2.1}/day2/__init__.py +0 -0
  27. {day2-0.2.0.dev1 → day2-0.2.1}/day2/_version.py +0 -0
  28. {day2-0.2.0.dev1 → day2-0.2.1}/day2/auth/__init__.py +0 -0
  29. {day2-0.2.0.dev1 → day2-0.2.1}/day2/auth/credentials.py +0 -0
  30. {day2-0.2.0.dev1 → day2-0.2.1}/day2/client/__init__.py +0 -0
  31. {day2-0.2.0.dev1 → day2-0.2.1}/day2/exceptions.py +0 -0
  32. {day2-0.2.0.dev1 → day2-0.2.1}/day2/models/__init__.py +0 -0
  33. {day2-0.2.0.dev1 → day2-0.2.1}/day2/models/assessment.py +0 -0
  34. {day2-0.2.0.dev1 → day2-0.2.1}/day2/resources/__init__.py +0 -0
  35. {day2-0.2.0.dev1 → day2-0.2.1}/day2/resources/assessment.py +0 -0
  36. {day2-0.2.0.dev1 → day2-0.2.1}/day2.egg-info/dependency_links.txt +0 -0
  37. {day2-0.2.0.dev1 → day2-0.2.1}/day2.egg-info/entry_points.txt +0 -0
  38. {day2-0.2.0.dev1 → day2-0.2.1}/day2.egg-info/requires.txt +0 -0
  39. {day2-0.2.0.dev1 → day2-0.2.1}/day2.egg-info/top_level.txt +0 -0
  40. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/__init__.py +0 -0
  41. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/commands/__init__.py +0 -0
  42. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/commands/auth.py +0 -0
  43. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/commands/config.py +0 -0
  44. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/utils/__init__.py +0 -0
  45. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/utils/config.py +0 -0
  46. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/utils/formatters.py +0 -0
  47. {day2-0.2.0.dev1 → day2-0.2.1}/day2_cli/utils/output_formatter.py +0 -0
  48. {day2-0.2.0.dev1 → day2-0.2.1}/setup.cfg +0 -0
  49. {day2-0.2.0.dev1 → day2-0.2.1}/tests/test_cli_auth.py +0 -0
  50. {day2-0.2.0.dev1 → day2-0.2.1}/tests/test_models_assessment.py +0 -0
  51. {day2-0.2.0.dev1 → day2-0.2.1}/tests/test_resources_assessment.py +0 -0
  52. {day2-0.2.0.dev1 → day2-0.2.1}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: day2
3
- Version: 0.2.0.dev1
3
+ Version: 0.2.1
4
4
  Summary: MontyCloud DAY2 SDK for Python
5
5
  Author-email: MontyCloud <info@montycloud.com>
6
6
  Requires-Python: >=3.11
@@ -276,14 +276,6 @@ except Exception as e:
276
276
  print(f"Unexpected error: {e}")
277
277
  ```
278
278
 
279
- ## Development
280
-
281
- Refer to [DEVELOPER.md](DEVELOPER.md) for development instructions.
282
-
283
- ## Release
284
-
285
- Refer to [RELEASE.md](RELEASE.md) for release instructions.
286
-
287
279
  ## License
288
280
 
289
281
  This project is licensed under the MIT License - see the LICENSE file for details.
@@ -238,14 +238,6 @@ except Exception as e:
238
238
  print(f"Unexpected error: {e}")
239
239
  ```
240
240
 
241
- ## Development
242
-
243
- Refer to [DEVELOPER.md](DEVELOPER.md) for development instructions.
244
-
245
- ## Release
246
-
247
- Refer to [RELEASE.md](RELEASE.md) for release instructions.
248
-
249
241
  ## License
250
242
 
251
243
  This project is licensed under the MIT License - see the LICENSE file for details.
@@ -84,12 +84,6 @@ class BaseClient:
84
84
  if self.session.tenant_id:
85
85
  headers["x-tenant-id"] = str(self.session.tenant_id)
86
86
 
87
- # Add any additional headers from config
88
- if self._config.additional_headers:
89
- # Ensure all header values are strings
90
- for key, value in self._config.additional_headers.items():
91
- headers[key] = str(value)
92
-
93
87
  return headers
94
88
 
95
89
  def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
@@ -112,10 +106,10 @@ class BaseClient:
112
106
 
113
107
  try:
114
108
  data: Dict[str, Any] = response.json()
115
- logger.info("Response data: %s", json.dumps(data, indent=2)[:1000])
109
+ logger.debug("Response data: %s", json.dumps(data, indent=2)[:1000])
116
110
  except ValueError:
117
111
  data = {"Message": response.text}
118
- logger.info("Response text: %s", response.text[:1000])
112
+ logger.debug("Response text: %s", response.text[:1000])
119
113
 
120
114
  if 400 <= response.status_code < 500:
121
115
  message = data.get("Message", "Client error")
@@ -134,63 +128,75 @@ class BaseClient:
134
128
 
135
129
  return dict(data)
136
130
 
137
- @retry(
138
- stop=stop_after_attempt(3),
139
- wait=wait_exponential(multiplier=1, min=2, max=10),
140
- retry=retry_if_exception_type(ServerError),
141
- reraise=True,
142
- )
143
131
  def _request_with_retry(
144
132
  self, method: str, url: str, **kwargs: Any
145
133
  ) -> Dict[str, Any]:
146
- """Make an API request with retry logic.
134
+ """Make an API request with retry logic based on configuration.
147
135
 
148
136
  Args:
149
- method: HTTP method (GET, POST, PUT, DELETE, etc.).
150
- url: Full URL for the request.
151
- **kwargs: Additional arguments to pass to requests.
137
+ method: HTTP method (GET, POST, etc.)
138
+ url: URL to request
139
+ **kwargs: Additional arguments to pass to requests.request
152
140
 
153
141
  Returns:
154
- Response data as dictionary.
142
+ Dict containing the parsed response
155
143
 
156
144
  Raises:
157
- ClientError: For client errors (4xx).
158
- ServerError: For server errors (5xx).
145
+ ClientError: For client errors (4xx)
146
+ ServerError: For server errors (5xx)
159
147
  """
160
- headers = self._get_headers()
161
-
162
- # Update headers with any provided in kwargs
163
- if "headers" in kwargs:
164
- headers.update(kwargs.pop("headers"))
165
-
166
- # Log request details
167
- logger.debug(
168
- "Making %s request to %s with headers %s and kwargs %s",
169
- method,
170
- url,
171
- headers,
172
- kwargs,
173
- )
174
148
 
175
- try:
176
- response = requests.request(
149
+ # Define the actual request function to retry
150
+ def _do_request() -> Dict[str, Any]:
151
+ headers = self._get_headers()
152
+
153
+ # Update headers with any provided in kwargs
154
+ request_kwargs = kwargs.copy()
155
+ if "headers" in request_kwargs:
156
+ headers.update(request_kwargs.pop("headers"))
157
+
158
+ # Log request details
159
+ logger.debug(
160
+ "Making %s request to %s with headers %s and kwargs %s",
177
161
  method,
178
162
  url,
179
- headers=headers,
180
- timeout=self._config.timeout,
181
- **kwargs,
163
+ headers,
164
+ request_kwargs,
182
165
  )
183
166
 
184
- # Log response details
185
- logger.info("Received response with status %s", response.status_code)
186
- logger.debug("Response headers: %s", response.headers)
187
- logger.debug("Response content: %s", response.text[:1000])
188
-
189
- return self._handle_response(response)
167
+ try:
168
+ response = requests.request(
169
+ method,
170
+ url,
171
+ headers=headers,
172
+ timeout=self._config.timeout,
173
+ **request_kwargs,
174
+ )
175
+
176
+ # Log response details
177
+ logger.debug("Received response with status %s", response.status_code)
178
+ logger.debug("Response headers: %s", response.headers)
179
+ logger.debug("Response content: %s", response.text[:1000])
180
+
181
+ return self._handle_response(response)
182
+ except requests.RequestException as e:
183
+ logger.error("Request failed: %s", str(e))
184
+ raise ServerError(str(e), 0, None) from e
185
+
186
+ # Create a retry object with config values
187
+ r = retry(
188
+ stop=stop_after_attempt(self._config.max_retries),
189
+ wait=wait_exponential(
190
+ multiplier=self._config.retry_backoff_factor,
191
+ min=self._config.retry_min_delay,
192
+ max=self._config.retry_max_delay,
193
+ ),
194
+ retry=retry_if_exception_type(ServerError),
195
+ reraise=True,
196
+ )
190
197
 
191
- except requests.RequestException as e:
192
- logger.error("Request failed: %s", e)
193
- raise ServerError(str(e), 0) from e
198
+ # Apply retry to our request function and call it
199
+ return r(_do_request)()
194
200
 
195
201
  def _make_request(
196
202
  self, method: str, endpoint: str, **kwargs: Any
@@ -210,6 +216,6 @@ class BaseClient:
210
216
  kwargs["json"] = kwargs.pop("json_data")
211
217
 
212
218
  url = self._get_endpoint_url(endpoint)
213
- logger.info("Making request to %s %s", method, url)
219
+ logger.debug("Making request to %s %s", method, url)
214
220
  # Pass the full URL to the request method
215
221
  return self._request_with_retry(method, url, **kwargs)
@@ -1,7 +1,7 @@
1
1
  """Configuration for the MontyCloud SDK."""
2
2
 
3
3
  import configparser
4
- from dataclasses import dataclass, field
4
+ from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
  from typing import Any, Dict, Optional
7
7
 
@@ -11,7 +11,7 @@ class Config:
11
11
  """Configuration for the MontyCloud SDK."""
12
12
 
13
13
  # Base URL for the MontyCloud API
14
- base_url: str = "https://stg1-api.montycloud.com/day2/api"
14
+ base_url: str = "https://api.montycloud.com/day2/api"
15
15
 
16
16
  # API version
17
17
  api_version: str = "v1"
@@ -34,12 +34,6 @@ class Config:
34
34
  # Maximum retry delay in seconds
35
35
  retry_max_delay: float = 10.0
36
36
 
37
- # Status codes to retry on
38
- retry_status_codes: tuple = (500, 502, 503, 504)
39
-
40
- # Additional headers to include in all requests
41
- additional_headers: Dict[str, str] = field(default_factory=dict)
42
-
43
37
  # CLI output format (table or json)
44
38
  output_format: str = "table"
45
39
 
@@ -67,10 +61,6 @@ class Config:
67
61
  "tenant_id": self.tenant_id,
68
62
  "retry_min_delay": self.retry_min_delay,
69
63
  "retry_max_delay": self.retry_max_delay,
70
- "retry_status_codes": ",".join(
71
- str(code) for code in self.retry_status_codes
72
- ),
73
- "additional_headers": self.additional_headers,
74
64
  "output_format": self.output_format,
75
65
  }
76
66
 
@@ -84,16 +74,6 @@ class Config:
84
74
  Returns:
85
75
  Config object initialized with values from dictionary.
86
76
  """
87
- # Handle special case for retry_status_codes if it's a string
88
- if "retry_status_codes" in config_dict and isinstance(
89
- config_dict["retry_status_codes"], str
90
- ):
91
- config_dict["retry_status_codes"] = tuple(
92
- int(code)
93
- for code in config_dict["retry_status_codes"].split(",")
94
- if code.strip().isdigit()
95
- )
96
-
97
77
  return cls(**config_dict)
98
78
 
99
79
  @classmethod
@@ -150,17 +130,6 @@ class Config:
150
130
  fallback=str(cls.retry_max_delay),
151
131
  )
152
132
  ),
153
- # Status codes need special handling as they're a tuple in code
154
- retry_status_codes=tuple(
155
- int(code)
156
- for code in config_parser.get(
157
- "DEFAULT", "retry_status_codes", fallback=""
158
- ).split(",")
159
- if code.strip().isdigit()
160
- )
161
- or cls.retry_status_codes,
162
- # Headers would need to be serialized/deserialized specially
163
- additional_headers={},
164
133
  # Output format
165
134
  output_format=config_parser.get(
166
135
  "DEFAULT", "output_format", fallback=cls.output_format
@@ -0,0 +1,54 @@
1
+ import re
2
+ from typing import Any, Dict, Optional
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field, root_validator
5
+
6
+
7
+ # Utility function to convert camelCase or PascalCase to snake_case
8
+ def to_snake_case(name: str) -> str:
9
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
10
+
11
+
12
+ class GetCostByChargeTypeOutput(BaseModel):
13
+ """Details of cost breakdown by charge type."""
14
+
15
+ total_cost: Optional[float] = Field(None, alias="TotalCost")
16
+ usage: Optional[float] = Field(None, alias="Usage")
17
+ bundled_discount: Optional[float] = Field(None, alias="BundledDiscount")
18
+ credit: Optional[float] = Field(None, alias="Credit")
19
+ discount: Optional[float] = Field(None, alias="Discount")
20
+ discounted_usage: Optional[float] = Field(None, alias="DiscountedUsage")
21
+ fee: Optional[float] = Field(None, alias="Fee")
22
+ refund: Optional[float] = Field(None, alias="Refund")
23
+ ri_fee: Optional[float] = Field(None, alias="RIFee")
24
+ tax: Optional[float] = Field(None, alias="Tax")
25
+ savings_plan_upfront_fee: Optional[float] = Field(
26
+ None, alias="SavingsPlanUpfrontFee"
27
+ )
28
+ savings_plan_recurring_fee: Optional[float] = Field(
29
+ None, alias="SavingsPlanRecurringFee"
30
+ )
31
+ savings_plan_covered_usage: Optional[float] = Field(
32
+ None, alias="SavingsPlanCoveredUsage"
33
+ )
34
+ savings_plan_negation: Optional[float] = Field(None, alias="SavingsPlanNegation")
35
+ spp_discount: Optional[float] = Field(None, alias="SPPDiscount")
36
+ distributor_discount: Optional[float] = Field(None, alias="DistributorDiscount")
37
+
38
+ # Allow extra fields
39
+ model_config = ConfigDict(extra="allow")
40
+
41
+ @root_validator(pre=True)
42
+ @classmethod
43
+ def handle_dynamic_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]:
44
+ """Handle dynamic extra fields by converting them to snake_case."""
45
+ known_fields = {
46
+ field.alias or field_name for field_name, field in cls.model_fields.items()
47
+ }
48
+ # Dynamically handle all extra fields
49
+ for key in list(values.keys()):
50
+ if key not in known_fields:
51
+ # Convert the extra field to snake_case and map it
52
+ snake_case_key = to_snake_case(key)
53
+ values[snake_case_key] = values.pop(key)
54
+ return values
@@ -31,11 +31,6 @@ class ListTenantsOutput(BaseModel):
31
31
 
32
32
  tenants: List[TenantDetails] = Field(alias="Tenants", default=[])
33
33
  next_page_token: Optional[str] = Field(None, alias="NextPageToken")
34
- # Keep these fields for backward compatibility
35
- has_more: Optional[bool] = Field(None, alias="HasMore")
36
- page_number: Optional[int] = Field(None, alias="PageNumber")
37
- page_size: Optional[int] = Field(None, alias="PageSize")
38
- total_count: Optional[int] = Field(None, alias="TotalCount")
39
34
 
40
35
  # Allow extra fields
41
36
  model_config = ConfigDict(extra="allow")
@@ -43,3 +38,28 @@ class ListTenantsOutput(BaseModel):
43
38
 
44
39
  class GetTenantOutput(TenantDetails):
45
40
  """Output of get_tenant operation."""
41
+
42
+
43
+ class Account(BaseModel):
44
+ """Details of an account."""
45
+
46
+ number: str = Field(alias="Number")
47
+ name: str = Field(alias="Name")
48
+ status: str = Field(alias="Status")
49
+ type: str = Field(alias="Type")
50
+ permission_model: str = Field(alias="PermissionModel")
51
+ onboarded_date: str = Field(alias="OnboardedDate")
52
+
53
+ # Allow extra fields
54
+ model_config = ConfigDict(extra="allow")
55
+
56
+
57
+ class ListAccountsOutput(BaseModel):
58
+ """Output of list_accounts operation."""
59
+
60
+ accounts: List[Account] = Field(alias="Accounts", default=[])
61
+ has_more: bool = Field(alias="HasMore", default=False)
62
+ page_number: int = Field(alias="PageNumber")
63
+
64
+ # Allow extra fields
65
+ model_config = ConfigDict(extra="allow")
@@ -0,0 +1,62 @@
1
+ from typing import Dict
2
+
3
+ from day2.client.base import BaseClient
4
+ from day2.models.cost import GetCostByChargeTypeOutput
5
+ from day2.session import Session
6
+
7
+
8
+ class CostClient(BaseClient):
9
+ """Client for interacting with the Cost service."""
10
+
11
+ def __init__(self, session: Session) -> None:
12
+ """Initialize a new CostClient.
13
+
14
+ Args:
15
+ session: MontyCloud session.
16
+ """
17
+ super().__init__(session, "cost")
18
+
19
+ def get_cost_by_charge_type(
20
+ self,
21
+ tenant_id: str,
22
+ cloud_provider: str,
23
+ start_date: str,
24
+ end_date: str,
25
+ ) -> GetCostByChargeTypeOutput:
26
+ """Get cost breakdown by charge type for a tenant.
27
+
28
+ Args:
29
+ tenant_id: The ID of the tenant to fetch cost data for.
30
+ cloud_provider: The cloud provider (e.g., "AWS", "Azure").
31
+ start_date: Start date in YYYY-MM-DD format.
32
+ end_date: End date in YYYY-MM-DD format.
33
+
34
+ Returns:
35
+ GetCostByChargeTypeOutput: Object containing cost breakdown by charge type.
36
+
37
+ Raises:
38
+ ClientError: If the request is invalid.
39
+ ServerError: If an internal server error occurs.
40
+ AuthenticationError: If authentication fails.
41
+
42
+ Examples:
43
+ >>> session = Session(api_key="your-api-key")
44
+ >>> client = session.cost
45
+ >>> response = client.get_cost_by_charge_type(
46
+ ... tenant_id="tenant-123",
47
+ ... cloud_provider="AWS",
48
+ ... start_date="2023-01-01",
49
+ ... end_date="2023-01-31"
50
+ ... )
51
+ >>> print(f"Total Cost: {response.total_cost}")
52
+ >>> print(f"Bundled Discount: {response.bundled_discount}")
53
+ """
54
+ params: Dict[str, str] = {
55
+ "CloudProvider": cloud_provider,
56
+ "StartDate": start_date,
57
+ "EndDate": end_date,
58
+ }
59
+ response = self._make_request(
60
+ "GET", f"tenants/{tenant_id}/cost/cost-by-charge-type", params=params
61
+ )
62
+ return GetCostByChargeTypeOutput.model_validate(response)
@@ -3,7 +3,7 @@
3
3
  from typing import Optional
4
4
 
5
5
  from day2.client.base import BaseClient
6
- from day2.models.tenant import GetTenantOutput, ListTenantsOutput
6
+ from day2.models.tenant import GetTenantOutput, ListAccountsOutput, ListTenantsOutput
7
7
  from day2.session import Session
8
8
 
9
9
 
@@ -79,3 +79,39 @@ class TenantClient(BaseClient):
79
79
  # The endpoint for getting tenant details is directly using the tenant ID in the path
80
80
  response = self._make_request("GET", f"tenants/{tenant_id}")
81
81
  return GetTenantOutput.model_validate(response)
82
+
83
+ def list_accounts(
84
+ self, tenant_id: str, page_size: int = 10, page_number: int = 1
85
+ ) -> ListAccountsOutput:
86
+ """List accounts associated with a specific tenant.
87
+
88
+ Args:
89
+ tenant_id: ID of the tenant to list accounts for.
90
+ page_size: Number of accounts to be fetched in a page (default: 10).
91
+ page_number: Page number for pagination (default: 1).
92
+
93
+ Returns:
94
+ ListAccountsOutput: Object containing list of accounts and pagination info.
95
+
96
+ Raises:
97
+ ResourceNotFoundError: If the tenant does not exist.
98
+ ClientError: If the request is invalid.
99
+ ServerError: If an internal server error occurs.
100
+ AuthenticationError: If authentication fails.
101
+
102
+ Examples:
103
+ >>> session = Session(api_key="your-api-key")
104
+ >>> client = session.tenant
105
+ >>> response = client.list_accounts("tenant-123", page_size=10)
106
+ >>> for account in response.accounts:
107
+ ... print(f"{account.number}: {account.name}")
108
+ """
109
+ params: dict[str, object] = {
110
+ "PageSize": page_size,
111
+ "PageNumber": page_number,
112
+ }
113
+
114
+ response = self._make_request(
115
+ "GET", f"tenants/{tenant_id}/accounts", params=params
116
+ )
117
+ return ListAccountsOutput.model_validate(response)
@@ -8,6 +8,7 @@ from day2.client.config import Config
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from day2.resources.assessment import AssessmentClient
11
+ from day2.resources.cost import CostClient
11
12
  from day2.resources.tenant import TenantClient
12
13
 
13
14
  T = TypeVar("T")
@@ -101,6 +102,15 @@ class Session:
101
102
  """
102
103
  return cast("AssessmentClient", self.client("assessment"))
103
104
 
105
+ @property
106
+ def cost(self) -> "CostClient":
107
+ """Get the cost client.
108
+
109
+ Returns:
110
+ CostClient: The cost client.
111
+ """
112
+ return cast("CostClient", self.client("cost"))
113
+
104
114
  def _create_client(self, service_name: str) -> Any:
105
115
  """Create a client for the specified service.
106
116
 
@@ -115,11 +125,13 @@ class Session:
115
125
  """
116
126
  # Import the actual classes at runtime to avoid circular imports
117
127
  from day2.resources.assessment import AssessmentClient
128
+ from day2.resources.cost import CostClient
118
129
  from day2.resources.tenant import TenantClient
119
130
 
120
131
  service_map = {
121
132
  "tenant": TenantClient,
122
133
  "assessment": AssessmentClient,
134
+ "cost": CostClient,
123
135
  }
124
136
 
125
137
  if service_name not in service_map:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: day2
3
- Version: 0.2.0.dev1
3
+ Version: 0.2.1
4
4
  Summary: MontyCloud DAY2 SDK for Python
5
5
  Author-email: MontyCloud <info@montycloud.com>
6
6
  Requires-Python: >=3.11
@@ -276,14 +276,6 @@ except Exception as e:
276
276
  print(f"Unexpected error: {e}")
277
277
  ```
278
278
 
279
- ## Development
280
-
281
- Refer to [DEVELOPER.md](DEVELOPER.md) for development instructions.
282
-
283
- ## Release
284
-
285
- Refer to [RELEASE.md](RELEASE.md) for release instructions.
286
-
287
279
  ## License
288
280
 
289
281
  This project is licensed under the MIT License - see the LICENSE file for details.
@@ -18,9 +18,11 @@ day2/client/base.py
18
18
  day2/client/config.py
19
19
  day2/models/__init__.py
20
20
  day2/models/assessment.py
21
+ day2/models/cost.py
21
22
  day2/models/tenant.py
22
23
  day2/resources/__init__.py
23
24
  day2/resources/assessment.py
25
+ day2/resources/cost.py
24
26
  day2/resources/tenant.py
25
27
  day2_cli/__init__.py
26
28
  day2_cli/cli.py
@@ -28,6 +30,7 @@ day2_cli/commands/__init__.py
28
30
  day2_cli/commands/assessment.py
29
31
  day2_cli/commands/auth.py
30
32
  day2_cli/commands/config.py
33
+ day2_cli/commands/cost.py
31
34
  day2_cli/commands/tenant.py
32
35
  day2_cli/utils/__init__.py
33
36
  day2_cli/utils/config.py
@@ -35,9 +38,12 @@ day2_cli/utils/formatters.py
35
38
  day2_cli/utils/output_formatter.py
36
39
  tests/test_cli_auth.py
37
40
  tests/test_cli_config.py
41
+ tests/test_client_base.py
38
42
  tests/test_config.py
39
43
  tests/test_models_assessment.py
44
+ tests/test_models_cost.py
40
45
  tests/test_models_tenant.py
41
46
  tests/test_resources_assessment.py
47
+ tests/test_resources_cost.py
42
48
  tests/test_resources_tenant.py
43
49
  tests/test_session.py
@@ -13,6 +13,7 @@ from day2_cli.commands.assessment import assessment
13
13
  # Import from day2_cli package
14
14
  from day2_cli.commands.auth import auth
15
15
  from day2_cli.commands.config import config
16
+ from day2_cli.commands.cost import cost
16
17
  from day2_cli.commands.tenant import tenant
17
18
  from day2_cli.utils.formatters import format_error
18
19
 
@@ -41,6 +42,7 @@ cli.add_command(auth)
41
42
  cli.add_command(config)
42
43
  cli.add_command(tenant)
43
44
  cli.add_command(assessment)
45
+ cli.add_command(cost)
44
46
 
45
47
 
46
48
  def main() -> None:
@@ -38,7 +38,7 @@ def get_tenant_id(session: Session, tenant_id: Optional[str]) -> Optional[str]:
38
38
  "[red]Error: No tenant ID provided and no default tenant configured.[/red]"
39
39
  )
40
40
  console.print(
41
- "[yellow]Tip: Configure a default tenant with 'day2 auth configure --tenant-id YOUR_TENANT_ID'[/yellow]"
41
+ "[yellow]Tip: Configure a default tenant with 'day2 config set tenant-id YOUR_TENANT_ID'[/yellow]"
42
42
  )
43
43
  return None
44
44
 
@@ -71,7 +71,7 @@ def list_assessments(
71
71
  ) -> None:
72
72
  """List assessments for a tenant.
73
73
 
74
- If --tenant-id is not provided, uses the default tenant configured with 'auth configure'.
74
+ If --tenant-id is not provided, uses the default tenant configured with 'day2 config set tenant-id YOUR_TENANT_ID'.
75
75
  """
76
76
  try:
77
77
  # Get output format from context if set via global option
@@ -267,8 +267,11 @@ def create_assessment(
267
267
 
268
268
  try:
269
269
  scope_data = json.loads(scope)
270
- if not isinstance(scope_data, list):
271
- scope_data = [scope_data]
270
+ if not isinstance(scope_data, dict):
271
+ console.print(
272
+ "[red]Error: Scope must be a JSON object (dictionary), not a list or primitive value[/red]"
273
+ )
274
+ return
272
275
  except json.JSONDecodeError:
273
276
  console.print("[red]Error: Scope must be a valid JSON string[/red]")
274
277
  return