cloudbeat-common 1.1.2__tar.gz → 1.2.0__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.
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/PKG-INFO +2 -1
- cloudbeat_common-1.2.0/cloudbeat.py +21 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/cloudbeat_common.egg-info/PKG-INFO +2 -1
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/cloudbeat_common.egg-info/SOURCES.txt +1 -1
- cloudbeat_common-1.2.0/cloudbeat_common.egg-info/requires.txt +2 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/setup.py +1 -0
- cloudbeat_common-1.2.0/src/client.py +246 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/src/models.py +1 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/src/reporter.py +46 -1
- cloudbeat_common-1.1.2/cloudbeat.py +0 -8
- cloudbeat_common-1.1.2/cloudbeat_common.egg-info/requires.txt +0 -1
- cloudbeat_common-1.1.2/requirements.txt +0 -1
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/cloudbeat_common.egg-info/dependency_links.txt +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/cloudbeat_common.egg-info/top_level.txt +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/setup.cfg +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/src/__init__.py +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/src/cb.py +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/src/helpers.py +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/src/json_util.py +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/tests/__init__.py +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/tests/conftest.py +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/tests/test_reporter.py +0 -0
- {cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/tests/test_step.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudbeat-common
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Contains the common types and API client for CloudBeat
|
|
5
5
|
Home-page: https://cloudbeat.io
|
|
6
6
|
Author: CBNR Cloud Solutions LTD
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
18
18
|
Requires-Python: >=3.8
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
Requires-Dist: attrs>=16.0.0
|
|
21
|
+
Requires-Dist: requests>=2.20.0
|
|
21
22
|
Dynamic: author
|
|
22
23
|
Dynamic: author-email
|
|
23
24
|
Dynamic: classifier
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from cloudbeat_common.models import TestStatus
|
|
2
|
+
from cloudbeat_common.reporter import CbTestReporter
|
|
3
|
+
from cloudbeat_common.client import (
|
|
4
|
+
CbApiError,
|
|
5
|
+
RunStatusInfo,
|
|
6
|
+
CaseStatusUpdateReq,
|
|
7
|
+
SuiteStatusUpdateReq,
|
|
8
|
+
RuntimeApiV2,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'TestStatus',
|
|
14
|
+
'CbTestReporter',
|
|
15
|
+
# Client API
|
|
16
|
+
'CbApiError',
|
|
17
|
+
'RunStatusInfo',
|
|
18
|
+
'CaseStatusUpdateReq',
|
|
19
|
+
'SuiteStatusUpdateReq',
|
|
20
|
+
'RuntimeApiV2',
|
|
21
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudbeat-common
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Contains the common types and API client for CloudBeat
|
|
5
5
|
Home-page: https://cloudbeat.io
|
|
6
6
|
Author: CBNR Cloud Solutions LTD
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
18
18
|
Requires-Python: >=3.8
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
Requires-Dist: attrs>=16.0.0
|
|
21
|
+
Requires-Dist: requests>=2.20.0
|
|
21
22
|
Dynamic: author
|
|
22
23
|
Dynamic: author-email
|
|
23
24
|
Dynamic: classifier
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
cloudbeat.py
|
|
2
|
-
requirements.txt
|
|
3
2
|
setup.py
|
|
4
3
|
cloudbeat_common.egg-info/PKG-INFO
|
|
5
4
|
cloudbeat_common.egg-info/SOURCES.txt
|
|
@@ -8,6 +7,7 @@ cloudbeat_common.egg-info/requires.txt
|
|
|
8
7
|
cloudbeat_common.egg-info/top_level.txt
|
|
9
8
|
src/__init__.py
|
|
10
9
|
src/cb.py
|
|
10
|
+
src/client.py
|
|
11
11
|
src/helpers.py
|
|
12
12
|
src/json_util.py
|
|
13
13
|
src/models.py
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CloudBeat API client
|
|
3
|
+
|
|
4
|
+
V2 API: authenticated via Bearer token (Authorization header).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Error
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CbApiError(Exception):
|
|
24
|
+
"""Raised when a CloudBeat API call fails."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message_or_exc: Any = "", response: Optional[requests.Response] = None):
|
|
27
|
+
msg = message_or_exc if isinstance(message_or_exc, str) else str(message_or_exc)
|
|
28
|
+
|
|
29
|
+
if response is not None:
|
|
30
|
+
status = response.status_code
|
|
31
|
+
if status == 500:
|
|
32
|
+
msg = "Internal server error, please try again later."
|
|
33
|
+
elif status == 401:
|
|
34
|
+
msg = "Authentication failed, invalid API key."
|
|
35
|
+
elif status == 404:
|
|
36
|
+
msg = "A record or an endpoint does not exist."
|
|
37
|
+
elif status == 204:
|
|
38
|
+
msg = "A record or an endpoint does not have content."
|
|
39
|
+
elif status == 422:
|
|
40
|
+
try:
|
|
41
|
+
data = response.json() or {}
|
|
42
|
+
err_msg = data.get("errorMessage", "")
|
|
43
|
+
errors: List[str] = data.get("errors") or []
|
|
44
|
+
if err_msg:
|
|
45
|
+
if errors:
|
|
46
|
+
err_msg += ": " + " ".join(errors)
|
|
47
|
+
msg = err_msg
|
|
48
|
+
else:
|
|
49
|
+
msg = "Validation Failed"
|
|
50
|
+
except Exception:
|
|
51
|
+
msg = "Validation Failed"
|
|
52
|
+
else:
|
|
53
|
+
msg = response.reason or str(status)
|
|
54
|
+
|
|
55
|
+
super().__init__(msg)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Request data models
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class RunStatusInfo:
|
|
65
|
+
"""V2: payload for updating run/instance status."""
|
|
66
|
+
|
|
67
|
+
run_id: Optional[str] = None
|
|
68
|
+
instance_id: Optional[str] = None
|
|
69
|
+
status: Optional[str] = None
|
|
70
|
+
progress: Optional[int] = None
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
73
|
+
d: Dict[str, Any] = {}
|
|
74
|
+
if self.run_id is not None:
|
|
75
|
+
d["runId"] = self.run_id
|
|
76
|
+
if self.instance_id is not None:
|
|
77
|
+
d["instanceId"] = self.instance_id
|
|
78
|
+
if self.status is not None:
|
|
79
|
+
d["status"] = self.status
|
|
80
|
+
if self.progress is not None:
|
|
81
|
+
d["progress"] = self.progress
|
|
82
|
+
return d
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class CaseStatusUpdateReq:
|
|
87
|
+
"""V2: payload for updating a test case's runtime status."""
|
|
88
|
+
|
|
89
|
+
# Used in the request path
|
|
90
|
+
run_id: Optional[str] = None
|
|
91
|
+
instance_id: Optional[str] = None
|
|
92
|
+
# Request body fields
|
|
93
|
+
id: Optional[str] = None # internal case UUID
|
|
94
|
+
fqn: Optional[str] = None
|
|
95
|
+
parent_fqn: Optional[str] = None # parent suite fqn
|
|
96
|
+
parent_id: Optional[str] = None # parent suite id
|
|
97
|
+
name: Optional[str] = None
|
|
98
|
+
start_time: Optional[int] = None
|
|
99
|
+
end_time: Optional[int] = None
|
|
100
|
+
run_status: Optional[str] = None # e.g. "Running" / "Finished"
|
|
101
|
+
test_status: Optional[str] = None # e.g. "passed" / "failed"
|
|
102
|
+
framework: Optional[str] = None
|
|
103
|
+
language: Optional[str] = None
|
|
104
|
+
capabilities: Optional[Dict[str, Any]] = None
|
|
105
|
+
timestamp: Optional[int] = None
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
108
|
+
d: Dict[str, Any] = {}
|
|
109
|
+
if self.run_id is not None:
|
|
110
|
+
d["runId"] = self.run_id
|
|
111
|
+
if self.instance_id is not None:
|
|
112
|
+
d["instanceId"] = self.instance_id
|
|
113
|
+
if self.id is not None:
|
|
114
|
+
d["id"] = self.id
|
|
115
|
+
if self.fqn is not None:
|
|
116
|
+
d["fqn"] = self.fqn
|
|
117
|
+
if self.parent_fqn is not None:
|
|
118
|
+
d["parentFqn"] = self.parent_fqn
|
|
119
|
+
if self.parent_id is not None:
|
|
120
|
+
d["parentId"] = self.parent_id
|
|
121
|
+
if self.name is not None:
|
|
122
|
+
d["name"] = self.name
|
|
123
|
+
if self.start_time is not None:
|
|
124
|
+
d["startTime"] = self.start_time
|
|
125
|
+
if self.end_time is not None:
|
|
126
|
+
d["endTime"] = self.end_time
|
|
127
|
+
if self.run_status is not None:
|
|
128
|
+
d["runStatus"] = self.run_status
|
|
129
|
+
if self.test_status is not None:
|
|
130
|
+
d["testStatus"] = self.test_status
|
|
131
|
+
if self.framework is not None:
|
|
132
|
+
d["framework"] = self.framework
|
|
133
|
+
if self.language is not None:
|
|
134
|
+
d["language"] = self.language
|
|
135
|
+
if self.capabilities is not None:
|
|
136
|
+
d["capabilities"] = self.capabilities
|
|
137
|
+
if self.timestamp is not None:
|
|
138
|
+
d["timestamp"] = self.timestamp
|
|
139
|
+
return d
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class SuiteStatusUpdateReq:
|
|
144
|
+
"""V2: payload for updating a test suite's runtime status."""
|
|
145
|
+
|
|
146
|
+
run_id: Optional[str] = None
|
|
147
|
+
instance_id: Optional[str] = None
|
|
148
|
+
suite_id: Optional[str] = None
|
|
149
|
+
status: Optional[str] = None
|
|
150
|
+
progress: Optional[int] = None
|
|
151
|
+
|
|
152
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
153
|
+
d: Dict[str, Any] = {}
|
|
154
|
+
if self.run_id is not None:
|
|
155
|
+
d["runId"] = self.run_id
|
|
156
|
+
if self.instance_id is not None:
|
|
157
|
+
d["instanceId"] = self.instance_id
|
|
158
|
+
if self.suite_id is not None:
|
|
159
|
+
d["suiteId"] = self.suite_id
|
|
160
|
+
if self.status is not None:
|
|
161
|
+
d["status"] = self.status
|
|
162
|
+
if self.progress is not None:
|
|
163
|
+
d["progress"] = self.progress
|
|
164
|
+
return d
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Base HTTP client (internal)
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _CbRestApiClient:
|
|
173
|
+
"""Shared HTTP client base — authenticates via Bearer token."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, base_url: str, auth_token: str) -> None:
|
|
176
|
+
self._base_url = base_url.rstrip("/")
|
|
177
|
+
self._session = requests.Session()
|
|
178
|
+
self._session.headers["Content-Type"] = "application/json"
|
|
179
|
+
self._session.headers["Authorization"] = f"Bearer {auth_token}"
|
|
180
|
+
|
|
181
|
+
def _post(self, path: str, json_data: Any = None) -> requests.Response:
|
|
182
|
+
url = self._base_url + path
|
|
183
|
+
logger.debug("REQ: POST %s", url)
|
|
184
|
+
response = self._session.post(url, json=json_data)
|
|
185
|
+
logger.debug("RES: HTTP %s", response.status_code)
|
|
186
|
+
return response
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# V2 API
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class RuntimeApiV2(_CbRestApiClient):
|
|
195
|
+
"""
|
|
196
|
+
V2 API for posting test results and updating runtime status.
|
|
197
|
+
|
|
198
|
+
All methods follow a fire-and-forget pattern: errors are logged but not
|
|
199
|
+
re-raised, matching the behaviour of the Node.js implementation.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, api_host_url: str, api_token: str) -> None:
|
|
203
|
+
super().__init__(base_url=api_host_url, auth_token=api_token)
|
|
204
|
+
|
|
205
|
+
def add_instance_result(self, run_id: str, instance_id: str, result: Any) -> None:
|
|
206
|
+
"""
|
|
207
|
+
Post the full test result for a run instance.
|
|
208
|
+
|
|
209
|
+
*result* may be a plain ``dict`` or a
|
|
210
|
+
:class:`~cloudbeat_common.models.TestResult` instance (serialised via
|
|
211
|
+
:func:`~cloudbeat_common.json_util.to_json`).
|
|
212
|
+
"""
|
|
213
|
+
path = f"/testresult/run/{run_id}/instance/{instance_id}"
|
|
214
|
+
try:
|
|
215
|
+
if isinstance(result, dict):
|
|
216
|
+
payload = result
|
|
217
|
+
else:
|
|
218
|
+
import json
|
|
219
|
+
from cloudbeat_common.json_util import to_json
|
|
220
|
+
payload = json.loads(to_json(result))
|
|
221
|
+
self._post(path, json_data=payload)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
logger.error("Failed to post new test results: %s", exc)
|
|
224
|
+
|
|
225
|
+
def update_instance_status(self, status: RunStatusInfo) -> None:
|
|
226
|
+
"""Update the status of a run instance."""
|
|
227
|
+
try:
|
|
228
|
+
self._post("/status", json_data=status.to_dict())
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
logger.error("Failed to update run status: %s", exc)
|
|
231
|
+
|
|
232
|
+
def update_case_status(self, status: CaseStatusUpdateReq) -> None:
|
|
233
|
+
"""Update the runtime status of a specific test case."""
|
|
234
|
+
path = f"/runtime/run/{status.run_id}/instance/{status.instance_id}/case/status"
|
|
235
|
+
try:
|
|
236
|
+
self._post(path, json_data=status.to_dict())
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
logger.error("Failed to update case runtime status: %s", exc)
|
|
239
|
+
|
|
240
|
+
def update_suite_status(self, status: SuiteStatusUpdateReq) -> None:
|
|
241
|
+
"""Update the runtime status of a specific test suite."""
|
|
242
|
+
path = f"/runtime/run/{status.run_id}/instance/{status.instance_id}/suite/status"
|
|
243
|
+
try:
|
|
244
|
+
self._post(path, json_data=status.to_dict())
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
logger.error("Failed to update suite runtime status: %s", exc)
|
|
@@ -36,6 +36,7 @@ class CbConfig:
|
|
|
36
36
|
api_endpoint_url = attrib(default=None)
|
|
37
37
|
selenium_url = attrib(default=None)
|
|
38
38
|
appium_url = attrib(default=None)
|
|
39
|
+
framework = attrib(default=None)
|
|
39
40
|
capabilities = attrib(default=defaultdict(OrderedDict))
|
|
40
41
|
options = attrib(default=defaultdict(OrderedDict))
|
|
41
42
|
metadata = attrib(default=defaultdict(OrderedDict))
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import threading
|
|
2
|
+
import time
|
|
2
3
|
from collections import OrderedDict, defaultdict
|
|
3
4
|
import platform
|
|
5
|
+
from typing import Optional
|
|
4
6
|
|
|
5
7
|
from cloudbeat_common.models import TestResult, CbConfig, SuiteResult, CaseResult, StepResult
|
|
6
8
|
from cloudbeat_common.json_util import to_json
|
|
9
|
+
from cloudbeat_common.client import CaseStatusUpdateReq, RuntimeApiV2
|
|
10
|
+
|
|
11
|
+
_LANGUAGE_NAME = "python"
|
|
7
12
|
|
|
8
13
|
|
|
9
14
|
class ThreadContext:
|
|
@@ -57,6 +62,10 @@ class CbTestReporter:
|
|
|
57
62
|
def __init__(self, config: CbConfig):
|
|
58
63
|
self._context = ThreadContext()
|
|
59
64
|
self._config = config
|
|
65
|
+
self._api_client: Optional[RuntimeApiV2] = None
|
|
66
|
+
|
|
67
|
+
if config.api_endpoint_url and config.api_token:
|
|
68
|
+
self._api_client = RuntimeApiV2(config.api_endpoint_url, config.api_token)
|
|
60
69
|
|
|
61
70
|
@classmethod
|
|
62
71
|
def get_instance(cls) -> 'CbTestReporter':
|
|
@@ -111,14 +120,50 @@ class CbTestReporter:
|
|
|
111
120
|
case_result.start(name, fqn)
|
|
112
121
|
suite_result.add_case(case_result)
|
|
113
122
|
self._context["case"] = case_result
|
|
123
|
+
if self._api_client:
|
|
124
|
+
caps = None
|
|
125
|
+
if case_result.context and case_result.context.get("browserName"):
|
|
126
|
+
caps = {"browserName": case_result.context["browserName"]}
|
|
127
|
+
self._api_client.update_case_status(CaseStatusUpdateReq(
|
|
128
|
+
timestamp=int(time.time() * 1000),
|
|
129
|
+
run_id=self._config.run_id,
|
|
130
|
+
instance_id=self._config.instance_id,
|
|
131
|
+
id=case_result.id,
|
|
132
|
+
fqn=case_result.fqn,
|
|
133
|
+
parent_fqn=suite_result.fqn,
|
|
134
|
+
parent_id=suite_result.id,
|
|
135
|
+
name=case_result.name,
|
|
136
|
+
start_time=case_result.start_time,
|
|
137
|
+
run_status="Running",
|
|
138
|
+
framework=self._config.framework,
|
|
139
|
+
language=_LANGUAGE_NAME,
|
|
140
|
+
capabilities=caps,
|
|
141
|
+
))
|
|
114
142
|
return case_result
|
|
115
143
|
|
|
116
144
|
def end_case(self, status=None, failure=None):
|
|
117
145
|
case_result: CaseResult = self._context.get("case")
|
|
118
146
|
if case_result is None:
|
|
119
147
|
return None
|
|
120
|
-
# TODO: end started steps of the case
|
|
121
148
|
case_result.end(status, failure)
|
|
149
|
+
if self._api_client:
|
|
150
|
+
suite_result: SuiteResult = self._context.get("suite")
|
|
151
|
+
self._api_client.update_case_status(CaseStatusUpdateReq(
|
|
152
|
+
timestamp=int(time.time() * 1000),
|
|
153
|
+
run_id=self._config.run_id,
|
|
154
|
+
instance_id=self._config.instance_id,
|
|
155
|
+
id=case_result.id,
|
|
156
|
+
fqn=case_result.fqn,
|
|
157
|
+
parent_fqn=suite_result.fqn if suite_result else None,
|
|
158
|
+
parent_id=suite_result.id if suite_result else None,
|
|
159
|
+
name=case_result.name,
|
|
160
|
+
start_time=case_result.start_time,
|
|
161
|
+
end_time=case_result.end_time,
|
|
162
|
+
run_status="Finished",
|
|
163
|
+
test_status=case_result.status,
|
|
164
|
+
framework=self._config.framework,
|
|
165
|
+
language=_LANGUAGE_NAME,
|
|
166
|
+
))
|
|
122
167
|
return case_result
|
|
123
168
|
|
|
124
169
|
def start_case_hook(self, name):
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
attrs>=16.0.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
attr==0.3.2
|
{cloudbeat_common-1.1.2 → cloudbeat_common-1.2.0}/cloudbeat_common.egg-info/dependency_links.txt
RENAMED
|
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
|