sanityops-cli 0.1.3__py3-none-any.whl

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 (53) hide show
  1. sanityops_cli/__init__.py +16 -0
  2. sanityops_cli/agents/__init__.py +14 -0
  3. sanityops_cli/agents/repair_agent/__init__.py +19 -0
  4. sanityops_cli/agents/repair_agent/agent.py +230 -0
  5. sanityops_cli/agents/repair_agent/prompts.py +100 -0
  6. sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
  7. sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
  8. sanityops_cli/agents/scanner_agent/agent.py +332 -0
  9. sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
  10. sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
  11. sanityops_cli/agents/scanner_agent/prompts.py +316 -0
  12. sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
  13. sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
  14. sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
  15. sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
  16. sanityops_cli/api/__init__.py +16 -0
  17. sanityops_cli/api/client.py +403 -0
  18. sanityops_cli/commands/__init__.py +14 -0
  19. sanityops_cli/commands/config.py +312 -0
  20. sanityops_cli/commands/init.py +132 -0
  21. sanityops_cli/commands/inspect.py +646 -0
  22. sanityops_cli/constants/__init__.py +14 -0
  23. sanityops_cli/constants/config_defaults.py +22 -0
  24. sanityops_cli/constants/exit_codes.py +19 -0
  25. sanityops_cli/defect_checker/__init__.py +16 -0
  26. sanityops_cli/defect_checker/checker.py +100 -0
  27. sanityops_cli/defect_checker/llm_config.py +112 -0
  28. sanityops_cli/defect_checker/markdown_reporter.py +249 -0
  29. sanityops_cli/defect_checker/renderer.py +203 -0
  30. sanityops_cli/exceptions/__init__.py +14 -0
  31. sanityops_cli/exceptions/api_exceptions.py +60 -0
  32. sanityops_cli/exceptions/base_exceptions.py +25 -0
  33. sanityops_cli/help_panel.py +49 -0
  34. sanityops_cli/logging/__init__.py +18 -0
  35. sanityops_cli/logging/logger.py +108 -0
  36. sanityops_cli/main.py +123 -0
  37. sanityops_cli/progress/__init__.py +18 -0
  38. sanityops_cli/progress/tracker.py +159 -0
  39. sanityops_cli/renderers/__init__.py +14 -0
  40. sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
  41. sanityops_cli/templates/__init__.py +14 -0
  42. sanityops_cli/templates/inspect_config.yaml +55 -0
  43. sanityops_cli/utils/__init__.py +14 -0
  44. sanityops_cli/utils/artifact_packer.py +407 -0
  45. sanityops_cli/utils/config_loader.py +296 -0
  46. sanityops_cli/utils/config_resolver.py +358 -0
  47. sanityops_cli/utils/validators.py +117 -0
  48. sanityops_cli-0.1.3.dist-info/METADATA +213 -0
  49. sanityops_cli-0.1.3.dist-info/RECORD +53 -0
  50. sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
  51. sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
  52. sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
  53. sanityops_cli-0.1.3.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,178 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import ClassVar
19
+
20
+ from sanityops_agent.tools.base import Tool, ToolResult
21
+
22
+ from ..models.finding import Finding, FindingType
23
+
24
+
25
+ class StoreFindingsTool(Tool):
26
+ """
27
+ maintain a shared memory list of findings (skills/tools/prompts) and export to JSON file.
28
+ """
29
+
30
+ name = "store_findings"
31
+ description = "collect and store skills/tools/prompts"
32
+ parameters = {
33
+ "type": "object",
34
+ "properties": {
35
+ "operation": {
36
+ "type": "string",
37
+ "enum": ["add", "flush", "clear", "count"],
38
+ "description": "operation to perform: add (add a finding), flush (export to JSON), clear (clear memory), count (count findings)",
39
+ },
40
+ "finding": {
41
+ "type": "object",
42
+ "description": "the finding object to add (required for add operation)",
43
+ "properties": {
44
+ "type": {"type": "string", "enum": ["skill", "tool", "prompt"]},
45
+ "relative": {"type": "string", "description": "absolute path"},
46
+ "content": {"type": "object", "description": "optional content object"},
47
+ },
48
+ "required": ["type", "relative"],
49
+ },
50
+ "output_path": {
51
+ "type": "string",
52
+ "description": "path to export JSON file (required for flush operation)",
53
+ },
54
+ },
55
+ "required": ["operation"],
56
+ }
57
+ tags = ["memory"]
58
+
59
+ _findings: ClassVar[list[Finding]] = []
60
+
61
+ async def execute(
62
+ self,
63
+ operation: str,
64
+ finding: Finding | None = None,
65
+ output_path: str | None = None,
66
+ ) -> ToolResult:
67
+ if operation == "add":
68
+ return self._add_finding(finding)
69
+ elif operation == "flush":
70
+ return self._flush(output_path)
71
+ elif operation == "clear":
72
+ return self._clear()
73
+ elif operation == "count":
74
+ return self._count()
75
+ else:
76
+ return ToolResult(
77
+ content="",
78
+ success=False,
79
+ error=f"unknown operation: {operation}",
80
+ )
81
+
82
+ def _add_finding(self, finding_dict: Finding | None) -> ToolResult:
83
+ if finding_dict is None:
84
+ return ToolResult(
85
+ content="",
86
+ success=False,
87
+ error="missing finding parameter",
88
+ )
89
+
90
+ try:
91
+ finding = Finding.model_validate(finding_dict)
92
+ except Exception as e:
93
+ return ToolResult(
94
+ content="",
95
+ success=False,
96
+ error=f"Finding validation failed: {e}",
97
+ )
98
+
99
+ self._findings.append(finding)
100
+ return ToolResult(
101
+ content=json.dumps({
102
+ "success": True,
103
+ "message": f"added {finding.type.value}: {finding.relative}",
104
+ "count": len(self._findings),
105
+ }),
106
+ success=True,
107
+ )
108
+
109
+ def _flush(self, output_path: str | None) -> ToolResult:
110
+ if not output_path:
111
+ return ToolResult(
112
+ content="",
113
+ success=False,
114
+ error="missing output_path parameter",
115
+ )
116
+
117
+ result = {
118
+ "skills": [
119
+ f.model_dump()
120
+ for f in self._findings
121
+ if f.type == FindingType.SKILL
122
+ ],
123
+ "tools": [
124
+ f.model_dump()
125
+ for f in self._findings
126
+ if f.type == FindingType.TOOL
127
+ ],
128
+ "prompts": [
129
+ f.model_dump()
130
+ for f in self._findings
131
+ if f.type == FindingType.PROMPT
132
+ ],
133
+ }
134
+
135
+ path = Path(output_path)
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ path.write_text(
138
+ json.dumps(result, indent=2, ensure_ascii=False),
139
+ encoding="utf-8"
140
+ )
141
+
142
+ return ToolResult(
143
+ content=json.dumps({
144
+ "success": True,
145
+ "message": f"exported {len(self._findings)} findings to {output_path}",
146
+ "counts": {
147
+ "skills": len(result["skills"]),
148
+ "tools": len(result["tools"]),
149
+ "prompts": len(result["prompts"]),
150
+ },
151
+ }),
152
+ success=True,
153
+ )
154
+
155
+ def _clear(self) -> ToolResult:
156
+ count = len(self._findings)
157
+ self._findings.clear()
158
+ return ToolResult(
159
+ content=json.dumps({
160
+ "success": True,
161
+ "message": f"cleared {count} findings",
162
+ }),
163
+ success=True,
164
+ )
165
+
166
+ def _count(self) -> ToolResult:
167
+ return ToolResult(
168
+ content=json.dumps({
169
+ "success": True,
170
+ "count": len(self._findings),
171
+ "by_type": {
172
+ "skills": sum(1 for f in self._findings if f.type == FindingType.SKILL),
173
+ "tools": sum(1 for f in self._findings if f.type == FindingType.TOOL),
174
+ "prompts": sum(1 for f in self._findings if f.type == FindingType.PROMPT),
175
+ },
176
+ }),
177
+ success=True,
178
+ )
@@ -0,0 +1,16 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """Sanityops API client package."""
@@ -0,0 +1,403 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """HTTP client for sanityops SaaS API."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ from sanityops_cli.exceptions.api_exceptions import (
26
+ APIError,
27
+ APIValidationError,
28
+ AuthenticationError,
29
+ NetworkError,
30
+ ProjectNotFoundError,
31
+ )
32
+
33
+
34
+ class SanityopsClient:
35
+ """HTTP client for sanityops SaaS API.
36
+
37
+ Handles authentication, request formatting, and error handling for all
38
+ sanityops API operations.
39
+
40
+ Attributes:
41
+ base_url: The base URL for the API (e.g., "https://api.sanityops.org").
42
+ api_key: The API key for Bearer token authentication.
43
+ timeout: Request timeout in seconds.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ base_url: str,
49
+ api_key: str,
50
+ timeout: float = 30.0,
51
+ ):
52
+ """Initialize the API client.
53
+
54
+ Args:
55
+ base_url: Base URL for the API (without trailing slash).
56
+ api_key: API key for authentication (will be prefixed with "Bearer").
57
+ timeout: Request timeout in seconds.
58
+ """
59
+ self.base_url = base_url.rstrip("/")
60
+ self.api_key = api_key
61
+ self.timeout = timeout
62
+
63
+ def _get_headers(self) -> dict[str, str]:
64
+ """Get common headers for API requests."""
65
+ return {
66
+ "Authorization": f"Bearer {self.api_key}",
67
+ "Accept": "application/json",
68
+ }
69
+
70
+ def _unwrap_response(self, response_data: dict[str, Any]) -> dict[str, Any]:
71
+ """Unwrap the standard API response envelope.
72
+
73
+ The server returns responses in format: {code, data, message}
74
+ This method extracts the 'data' field for easier consumption.
75
+
76
+ Args:
77
+ response_data: Raw response from the API.
78
+
79
+ Returns:
80
+ The 'data' field if present, otherwise the raw response.
81
+ """
82
+ if "data" in response_data and "code" in response_data:
83
+ return response_data["data"] or {}
84
+ return response_data
85
+
86
+ def _handle_error(self, response: httpx.Response, operation: str) -> None:
87
+ """Handle HTTP error responses and raise appropriate exceptions.
88
+
89
+ Args:
90
+ response: The HTTP response object.
91
+ operation: Description of the operation for error messages.
92
+
93
+ Raises:
94
+ AuthenticationError: For 401/403 responses.
95
+ ProjectNotFoundError: For 404 responses.
96
+ APIValidationError: For 422 responses.
97
+ APIError: For other HTTP errors.
98
+ """
99
+ status_code = response.status_code
100
+
101
+ try:
102
+ error_data = response.json()
103
+ detail = error_data.get("detail", response.text)
104
+ except json.JSONDecodeError:
105
+ detail = response.text
106
+
107
+ if status_code in (401, 403):
108
+ raise AuthenticationError(
109
+ f"Authentication failed during {operation}. "
110
+ "Please check your API key is valid and not expired."
111
+ )
112
+ elif status_code == 404:
113
+ raise ProjectNotFoundError(f"Resource not found during {operation}: {detail}")
114
+ elif status_code == 422:
115
+ raise APIValidationError(f"Validation error during {operation}: {detail}")
116
+ else:
117
+ raise APIError(f"API error during {operation} (HTTP {status_code}): {detail}")
118
+
119
+ # ---------------------------------------------------------------------
120
+ # Project Operations
121
+ # ---------------------------------------------------------------------
122
+
123
+ def create_project(
124
+ self,
125
+ name: str,
126
+ description: str | None = None,
127
+ ) -> dict[str, Any]:
128
+ """Create a new project.
129
+
130
+ Args:
131
+ name: Project name (required, 1-200 chars).
132
+ description: Optional project description.
133
+
134
+ Returns:
135
+ API response with project details including 'project_id'.
136
+
137
+ Raises:
138
+ AuthenticationError: If API key is invalid.
139
+ APIValidationError: If request validation fails.
140
+ NetworkError: If connection fails.
141
+ """
142
+ url = f"{self.base_url}/api/v1/projects/"
143
+ payload: dict[str, Any] = {"name": name}
144
+ if description:
145
+ payload["description"] = description
146
+
147
+ try:
148
+ response = httpx.post(
149
+ url,
150
+ json=payload,
151
+ headers=self._get_headers(),
152
+ timeout=self.timeout,
153
+ )
154
+ except httpx.ConnectError as e:
155
+ raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
156
+ except httpx.TimeoutException as e:
157
+ raise NetworkError(f"Request timed out: {e}") from e
158
+
159
+ if response.status_code not in (200, 201):
160
+ self._handle_error(response, "project creation")
161
+
162
+ return self._unwrap_response(response.json())
163
+
164
+ def get_project(
165
+ self,
166
+ project_id: str,
167
+ ) -> dict[str, Any]:
168
+ """Get a single project's details.
169
+
170
+ Args:
171
+ project_id: Project UUID.
172
+
173
+ Returns:
174
+ API response with project details including 'name' and 'project_id'.
175
+
176
+ Raises:
177
+ AuthenticationError: If API key is invalid.
178
+ ProjectNotFoundError: If project doesn't exist.
179
+ NetworkError: If connection fails.
180
+ """
181
+ url = f"{self.base_url}/api/v1/projects/{project_id}"
182
+
183
+ try:
184
+ response = httpx.get(
185
+ url,
186
+ headers=self._get_headers(),
187
+ timeout=self.timeout,
188
+ )
189
+ except httpx.ConnectError as e:
190
+ raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
191
+ except httpx.TimeoutException as e:
192
+ raise NetworkError(f"Request timed out: {e}") from e
193
+
194
+ if response.status_code != 200:
195
+ self._handle_error(response, "getting project")
196
+
197
+ return self._unwrap_response(response.json())
198
+
199
+ # ---------------------------------------------------------------------
200
+ # Artifact Upload
201
+ # ---------------------------------------------------------------------
202
+
203
+ def upload_artifacts(
204
+ self,
205
+ project_id: str | None = None,
206
+ name: str | None = None,
207
+ description: str | None = None,
208
+ prompt_content: str | None = None,
209
+ tools_schema: str | None = None,
210
+ skill_file: tuple[str, bytes] | None = None,
211
+ message: str | None = None,
212
+ ) -> dict[str, Any]:
213
+ """Upload artifacts and create/update a project with automatic version management.
214
+
215
+ If project_id is NOT provided: creates a new project with the uploaded artifacts.
216
+ If project_id IS provided: checks if artifacts have changed, creates new versions
217
+ for changed artifacts, reuses existing versions for unchanged artifacts.
218
+
219
+ Args:
220
+ project_id: Existing project ID (for updates). If None, creates new project.
221
+ name: Project name (required for new project).
222
+ description: Project description.
223
+ prompt_content: Merged prompt text content.
224
+ tools_schema: JSON string of tools schema.
225
+ skill_file: Tuple of (filename, bytes) for ZIP file.
226
+ message: Version commit message.
227
+
228
+ Returns:
229
+ API response with project and version details.
230
+
231
+ Raises:
232
+ AuthenticationError: If API key is invalid.
233
+ APIValidationError: If request validation fails.
234
+ NetworkError: If connection fails.
235
+ """
236
+ url = f"{self.base_url}/api/v1/projects/upload"
237
+
238
+ # Build multipart form data
239
+ data: dict[str, Any] = {}
240
+ if project_id:
241
+ data["project_id"] = project_id
242
+ if name:
243
+ data["name"] = name
244
+ if description:
245
+ data["description"] = description
246
+ if prompt_content:
247
+ data["prompt_content"] = prompt_content
248
+ if tools_schema:
249
+ data["tools_schema"] = tools_schema
250
+ if message:
251
+ data["message"] = message
252
+
253
+ files: dict[str, tuple[str, bytes, str]] | None = None
254
+ if skill_file:
255
+ filename, content = skill_file
256
+ files = {"skill_file": (filename, content, "application/zip")}
257
+
258
+ try:
259
+ response = httpx.post(
260
+ url,
261
+ data=data,
262
+ files=files,
263
+ headers=self._get_headers(),
264
+ timeout=self.timeout,
265
+ )
266
+ except httpx.ConnectError as e:
267
+ raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
268
+ except httpx.TimeoutException as e:
269
+ raise NetworkError(f"Request timed out: {e}") from e
270
+
271
+ if response.status_code not in (200, 201):
272
+ self._handle_error(response, "artifact upload")
273
+
274
+ return self._unwrap_response(response.json())
275
+
276
+ # ---------------------------------------------------------------------
277
+ # Version Operations
278
+ # ---------------------------------------------------------------------
279
+
280
+ def list_versions(
281
+ self,
282
+ project_id: str,
283
+ page: int = 1,
284
+ size: int = 10,
285
+ ) -> dict[str, Any]:
286
+ """List versions for a project.
287
+
288
+ Args:
289
+ project_id: Project UUID.
290
+ page: Page number (1-indexed).
291
+ size: Page size (1-100).
292
+
293
+ Returns:
294
+ API response with version list.
295
+
296
+ Raises:
297
+ AuthenticationError: If API key is invalid.
298
+ ProjectNotFoundError: If project doesn't exist.
299
+ NetworkError: If connection fails.
300
+ """
301
+ url = f"{self.base_url}/api/v1/projects/{project_id}/versions"
302
+ params = {"page": page, "size": min(size, 100)}
303
+
304
+ try:
305
+ response = httpx.get(
306
+ url,
307
+ params=params,
308
+ headers=self._get_headers(),
309
+ timeout=self.timeout,
310
+ )
311
+ except httpx.ConnectError as e:
312
+ raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
313
+ except httpx.TimeoutException as e:
314
+ raise NetworkError(f"Request timed out: {e}") from e
315
+
316
+ if response.status_code != 200:
317
+ self._handle_error(response, "listing versions")
318
+
319
+ return self._unwrap_response(response.json())
320
+
321
+ def get_version(
322
+ self,
323
+ project_id: str,
324
+ version_id: str,
325
+ ) -> dict[str, Any]:
326
+ """Get a specific project version with its artifacts.
327
+
328
+ Args:
329
+ project_id: Project UUID.
330
+ version_id: Version UUID.
331
+
332
+ Returns:
333
+ API response with version details including artifacts.
334
+
335
+ Raises:
336
+ AuthenticationError: If API key is invalid.
337
+ ProjectNotFoundError: If project or version doesn't exist.
338
+ NetworkError: If connection fails.
339
+ """
340
+ url = f"{self.base_url}/api/v1/projects/{project_id}/versions/{version_id}"
341
+
342
+ try:
343
+ response = httpx.get(
344
+ url,
345
+ headers=self._get_headers(),
346
+ timeout=self.timeout,
347
+ )
348
+ except httpx.ConnectError as e:
349
+ raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
350
+ except httpx.TimeoutException as e:
351
+ raise NetworkError(f"Request timed out: {e}") from e
352
+
353
+ if response.status_code != 200:
354
+ self._handle_error(response, "getting version")
355
+
356
+ return self._unwrap_response(response.json())
357
+
358
+ def get_project_artifacts(
359
+ self,
360
+ project_id: str,
361
+ version_id: str | None = None,
362
+ ) -> dict[str, Any]:
363
+ """Get project artifacts with full content (prompts, skills, tools).
364
+
365
+ This endpoint returns the actual content of each artifact, including
366
+ schema_content for tools, content for prompts, and metadata for skills.
367
+
368
+ Args:
369
+ project_id: Project UUID.
370
+ version_id: Optional version UUID. If None, returns latest version's artifacts.
371
+
372
+ Returns:
373
+ API response with:
374
+ - prompts: list of prompt objects with 'content' field
375
+ - skills: list of skill objects with metadata
376
+ - tools: list of tools_schema objects with 'schema_content' field
377
+
378
+ Raises:
379
+ AuthenticationError: If API key is invalid.
380
+ ProjectNotFoundError: If project doesn't exist.
381
+ NetworkError: If connection fails.
382
+ """
383
+ url = f"{self.base_url}/api/v1/projects/{project_id}/artifacts"
384
+ params = {}
385
+ if version_id:
386
+ params["version_id"] = version_id
387
+
388
+ try:
389
+ response = httpx.get(
390
+ url,
391
+ params=params,
392
+ headers=self._get_headers(),
393
+ timeout=self.timeout,
394
+ )
395
+ except httpx.ConnectError as e:
396
+ raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
397
+ except httpx.TimeoutException as e:
398
+ raise NetworkError(f"Request timed out: {e}") from e
399
+
400
+ if response.status_code != 200:
401
+ self._handle_error(response, "getting project artifacts")
402
+
403
+ return self._unwrap_response(response.json())
@@ -0,0 +1,14 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #