pygazpar 1.3.0a2__py39-none-any.whl → 1.3.0a9__py39-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.
- pygazpar/datasource.py +119 -47
- pygazpar/version.py +1 -1
- {pygazpar-1.3.0a2.dist-info → pygazpar-1.3.0a9.dist-info}/METADATA +16 -4
- {pygazpar-1.3.0a2.dist-info → pygazpar-1.3.0a9.dist-info}/RECORD +10 -10
- {pygazpar-1.3.0a2.dist-info → pygazpar-1.3.0a9.dist-info}/WHEEL +1 -1
- tests/test_client.py +2 -2
- tests/test_datasource.py +1 -1
- {pygazpar-1.3.0a2.dist-info → pygazpar-1.3.0a9.dist-info}/LICENSE.md +0 -0
- {pygazpar-1.3.0a2.dist-info → pygazpar-1.3.0a9.dist-info}/entry_points.txt +0 -0
- {pygazpar-1.3.0a2.dist-info → pygazpar-1.3.0a9.dist-info}/top_level.txt +0 -0
pygazpar/datasource.py
CHANGED
@@ -2,7 +2,9 @@ import logging
|
|
2
2
|
import glob
|
3
3
|
import os
|
4
4
|
import json
|
5
|
+
import time
|
5
6
|
import pandas as pd
|
7
|
+
import http.cookiejar
|
6
8
|
from abc import ABC, abstractmethod
|
7
9
|
from typing import Any, List, Dict, cast, Optional
|
8
10
|
from requests import Session
|
@@ -11,15 +13,22 @@ from pygazpar.enum import Frequency, PropertyName
|
|
11
13
|
from pygazpar.excelparser import ExcelParser
|
12
14
|
from pygazpar.jsonparser import JsonParser
|
13
15
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
LOGIN_PAYLOAD = """{{
|
18
|
-
"email": "{0}",
|
16
|
+
SESSION_TOKEN_URL = "https://connexion.grdf.fr/api/v1/authn"
|
17
|
+
SESSION_TOKEN_PAYLOAD = """{{
|
18
|
+
"username": "{0}",
|
19
19
|
"password": "{1}",
|
20
|
-
"
|
21
|
-
|
22
|
-
|
20
|
+
"options": {{
|
21
|
+
"multiOptionalFactorEnroll": "false",
|
22
|
+
"warnBeforePasswordExpired": "false"
|
23
|
+
}}
|
24
|
+
}}"""
|
25
|
+
|
26
|
+
AUTH_TOKEN_URL = "https://connexion.grdf.fr/login/sessionCookieRedirect"
|
27
|
+
AUTH_TOKEN_PARAMS = """{{
|
28
|
+
"checkAccountSetupComplete": "true",
|
29
|
+
"token": "{0}",
|
30
|
+
"redirectUrl": "https://monespace.grdf.fr"
|
31
|
+
}}"""
|
23
32
|
|
24
33
|
Logger = logging.getLogger(__name__)
|
25
34
|
|
@@ -50,56 +59,59 @@ class WebDataSource(IDataSource):
|
|
50
59
|
# ------------------------------------------------------
|
51
60
|
def load(self, pceIdentifier: str, startDate: date, endDate: date, frequencies: Optional[List[Frequency]] = None) -> MeterReadingsByFrequency:
|
52
61
|
|
53
|
-
|
54
|
-
|
55
|
-
session.headers.update(LOGIN_HEADER)
|
56
|
-
|
57
|
-
self._login(session, self.__username, self.__password)
|
62
|
+
auth_token = self._login(self.__username, self.__password)
|
58
63
|
|
59
|
-
res = self._loadFromSession(
|
64
|
+
res = self._loadFromSession(auth_token, pceIdentifier, startDate, endDate, frequencies)
|
60
65
|
|
61
66
|
Logger.debug("The data update terminates normally")
|
62
67
|
|
63
68
|
return res
|
64
69
|
|
65
70
|
# ------------------------------------------------------
|
66
|
-
def _login(self,
|
71
|
+
def _login(self, username: str, password: str) -> str:
|
67
72
|
|
68
|
-
|
69
|
-
session.
|
70
|
-
|
71
|
-
|
72
|
-
auth_nonce = session.cookies.get("auth_nonce")
|
73
|
+
session = Session()
|
74
|
+
session.headers.update({"domain": "grdf.fr"})
|
75
|
+
session.headers.update({"Content-Type": "application/json"})
|
76
|
+
session.headers.update({"X-Requested-With": "XMLHttpRequest"})
|
73
77
|
|
74
|
-
|
75
|
-
payload = LOGIN_PAYLOAD.format(username, password, auth_nonce)
|
78
|
+
payload = SESSION_TOKEN_PAYLOAD.format(username, password)
|
76
79
|
|
77
|
-
|
78
|
-
data = json.loads(payload)
|
80
|
+
response = session.post(SESSION_TOKEN_URL, data=payload)
|
79
81
|
|
80
|
-
|
81
|
-
|
82
|
+
if response.status_code != 200:
|
83
|
+
raise Exception(f"An error occurred while logging in. Status code: {response.status_code} - {response.text}")
|
82
84
|
|
83
|
-
|
84
|
-
loginData = response.json()
|
85
|
+
session_token = response.json().get("sessionToken")
|
85
86
|
|
86
|
-
|
87
|
+
Logger.debug("Session token: %s", session_token)
|
87
88
|
|
88
|
-
|
89
|
-
raise Exception(f"{loginData['error']} ({loginData['status']})")
|
89
|
+
jar = http.cookiejar.CookieJar()
|
90
90
|
|
91
|
-
|
92
|
-
|
91
|
+
session = Session()
|
92
|
+
session.headers.update({"Content-Type": "application/json"})
|
93
|
+
session.headers.update({"X-Requested-With": "XMLHttpRequest"})
|
94
|
+
|
95
|
+
params = json.loads(AUTH_TOKEN_PARAMS.format(session_token))
|
96
|
+
|
97
|
+
response = session.get(AUTH_TOKEN_URL, params=params, allow_redirects=True, cookies=jar)
|
98
|
+
|
99
|
+
if response.status_code != 200:
|
100
|
+
raise Exception(f"An error occurred while getting the auth token. Status code: {response.status_code} - {response.text}")
|
101
|
+
|
102
|
+
auth_token = session.cookies.get("auth_token", domain="monespace.grdf.fr")
|
103
|
+
|
104
|
+
return auth_token
|
93
105
|
|
94
106
|
@abstractmethod
|
95
|
-
def _loadFromSession(self,
|
107
|
+
def _loadFromSession(self, auth_token: str, pceIdentifier: str, startDate: date, endDate: date, frequencies: Optional[List[Frequency]] = None) -> MeterReadingsByFrequency:
|
96
108
|
pass
|
97
109
|
|
98
110
|
|
99
111
|
# ------------------------------------------------------------------------------------------------------------
|
100
112
|
class ExcelWebDataSource(WebDataSource):
|
101
113
|
|
102
|
-
DATA_URL = "https://monespace.grdf.fr/api/e-conso/pce/consommation/informatives/telecharger?dateDebut={0}&dateFin={1}&frequence={3}&pceList
|
114
|
+
DATA_URL = "https://monespace.grdf.fr/api/e-conso/pce/consommation/informatives/telecharger?dateDebut={0}&dateFin={1}&frequence={3}&pceList[]={2}"
|
103
115
|
|
104
116
|
DATE_FORMAT = "%Y-%m-%d"
|
105
117
|
|
@@ -121,7 +133,7 @@ class ExcelWebDataSource(WebDataSource):
|
|
121
133
|
self.__tmpDirectory = tmpDirectory
|
122
134
|
|
123
135
|
# ------------------------------------------------------
|
124
|
-
def _loadFromSession(self,
|
136
|
+
def _loadFromSession(self, auth_token: str, pceIdentifier: str, startDate: date, endDate: date, frequencies: Optional[List[Frequency]] = None) -> MeterReadingsByFrequency:
|
125
137
|
|
126
138
|
res = {}
|
127
139
|
|
@@ -132,7 +144,10 @@ class ExcelWebDataSource(WebDataSource):
|
|
132
144
|
file_list = glob.glob(data_file_path_pattern)
|
133
145
|
for filename in file_list:
|
134
146
|
if os.path.isfile(filename):
|
135
|
-
|
147
|
+
try:
|
148
|
+
os.remove(filename)
|
149
|
+
except PermissionError:
|
150
|
+
pass
|
136
151
|
|
137
152
|
if frequencies is None:
|
138
153
|
# Transform Enum in List.
|
@@ -145,11 +160,31 @@ class ExcelWebDataSource(WebDataSource):
|
|
145
160
|
# Inject parameters.
|
146
161
|
downloadUrl = ExcelWebDataSource.DATA_URL.format(startDate.strftime(ExcelWebDataSource.DATE_FORMAT), endDate.strftime(ExcelWebDataSource.DATE_FORMAT), pceIdentifier, ExcelWebDataSource.FREQUENCY_VALUES[frequency])
|
147
162
|
|
148
|
-
session.get(downloadUrl) # First request does not return anything : strange...
|
149
|
-
|
150
163
|
Logger.debug(f"Loading data of frequency {ExcelWebDataSource.FREQUENCY_VALUES[frequency]} from {startDate.strftime(ExcelWebDataSource.DATE_FORMAT)} to {endDate.strftime(ExcelWebDataSource.DATE_FORMAT)}")
|
151
164
|
|
152
|
-
|
165
|
+
# Retry mechanism.
|
166
|
+
retry = 10
|
167
|
+
while retry > 0:
|
168
|
+
|
169
|
+
# Create a session.
|
170
|
+
session = Session()
|
171
|
+
session.headers.update({"Host": "monespace.grdf.fr"})
|
172
|
+
session.headers.update({"Domain": "grdf.fr"})
|
173
|
+
session.headers.update({"X-Requested-With": "XMLHttpRequest"})
|
174
|
+
session.headers.update({"Accept": "application/json"})
|
175
|
+
session.cookies.set("auth_token", auth_token, domain="monespace.grdf.fr")
|
176
|
+
|
177
|
+
try:
|
178
|
+
self.__downloadFile(session, downloadUrl, self.__tmpDirectory)
|
179
|
+
break
|
180
|
+
except Exception as e:
|
181
|
+
|
182
|
+
if retry == 1:
|
183
|
+
raise e
|
184
|
+
|
185
|
+
Logger.error("An error occurred while loading data. Retry in 3 seconds.")
|
186
|
+
time.sleep(3)
|
187
|
+
retry -= 1
|
153
188
|
|
154
189
|
# Load the XLSX file into the data structure
|
155
190
|
file_list = glob.glob(data_file_path_pattern)
|
@@ -159,7 +194,11 @@ class ExcelWebDataSource(WebDataSource):
|
|
159
194
|
|
160
195
|
for filename in file_list:
|
161
196
|
res[frequency.value] = ExcelParser.parse(filename, frequency if frequency != Frequency.YEARLY else Frequency.DAILY)
|
162
|
-
|
197
|
+
try:
|
198
|
+
# openpyxl does not close the file properly.
|
199
|
+
os.remove(filename)
|
200
|
+
except PermissionError:
|
201
|
+
pass
|
163
202
|
|
164
203
|
# We compute yearly from daily data.
|
165
204
|
if frequency == Frequency.YEARLY:
|
@@ -172,6 +211,12 @@ class ExcelWebDataSource(WebDataSource):
|
|
172
211
|
|
173
212
|
response = session.get(url)
|
174
213
|
|
214
|
+
if "text/html" in response.headers.get("Content-Type"):
|
215
|
+
raise Exception("An error occurred while loading data. Please check your credentials.")
|
216
|
+
|
217
|
+
if response.status_code != 200:
|
218
|
+
raise Exception(f"An error occurred while loading data. Status code: {response.status_code} - {response.text}")
|
219
|
+
|
175
220
|
response.raise_for_status()
|
176
221
|
|
177
222
|
filename = response.headers["Content-Disposition"].split("filename=")[1]
|
@@ -210,7 +255,7 @@ class ExcelFileDataSource(IDataSource):
|
|
210
255
|
# ------------------------------------------------------------------------------------------------------------
|
211
256
|
class JsonWebDataSource(WebDataSource):
|
212
257
|
|
213
|
-
DATA_URL = "https://monespace.grdf.fr/api/e-conso/pce/consommation/informatives?dateDebut={0}&dateFin={1}&pceList
|
258
|
+
DATA_URL = "https://monespace.grdf.fr/api/e-conso/pce/consommation/informatives?dateDebut={0}&dateFin={1}&pceList[]={2}"
|
214
259
|
|
215
260
|
TEMPERATURES_URL = "https://monespace.grdf.fr/api/e-conso/pce/{0}/meteo?dateFinPeriode={1}&nbJours={2}"
|
216
261
|
|
@@ -222,7 +267,7 @@ class JsonWebDataSource(WebDataSource):
|
|
222
267
|
|
223
268
|
super().__init__(username, password)
|
224
269
|
|
225
|
-
def _loadFromSession(self,
|
270
|
+
def _loadFromSession(self, auth_token: str, pceIdentifier: str, startDate: date, endDate: date, frequencies: Optional[List[Frequency]] = None) -> MeterReadingsByFrequency:
|
226
271
|
|
227
272
|
res = {}
|
228
273
|
|
@@ -237,11 +282,38 @@ class JsonWebDataSource(WebDataSource):
|
|
237
282
|
# Data URL: Inject parameters.
|
238
283
|
downloadUrl = JsonWebDataSource.DATA_URL.format(startDate.strftime(JsonWebDataSource.INPUT_DATE_FORMAT), endDate.strftime(JsonWebDataSource.INPUT_DATE_FORMAT), pceIdentifier)
|
239
284
|
|
240
|
-
#
|
241
|
-
|
285
|
+
# Retry mechanism.
|
286
|
+
retry = 10
|
287
|
+
while retry > 0:
|
288
|
+
|
289
|
+
# Create a session.
|
290
|
+
session = Session()
|
291
|
+
session.headers.update({"Host": "monespace.grdf.fr"})
|
292
|
+
session.headers.update({"Domain": "grdf.fr"})
|
293
|
+
session.headers.update({"X-Requested-With": "XMLHttpRequest"})
|
294
|
+
session.headers.update({"Accept": "application/json"})
|
295
|
+
session.cookies.set("auth_token", auth_token, domain="monespace.grdf.fr")
|
296
|
+
|
297
|
+
try:
|
298
|
+
response = session.get(downloadUrl)
|
299
|
+
|
300
|
+
if "text/html" in response.headers.get("Content-Type"):
|
301
|
+
raise Exception("An error occurred while loading data. Please check your credentials.")
|
302
|
+
|
303
|
+
if response.status_code != 200:
|
304
|
+
raise Exception(f"An error occurred while loading data. Status code: {response.status_code} - {response.text}")
|
305
|
+
|
306
|
+
break
|
307
|
+
except Exception as e:
|
308
|
+
|
309
|
+
if retry == 1:
|
310
|
+
raise e
|
311
|
+
|
312
|
+
Logger.error("An error occurred while loading data. Retry in 3 seconds.")
|
313
|
+
time.sleep(3)
|
314
|
+
retry -= 1
|
242
315
|
|
243
|
-
|
244
|
-
data = session.get(downloadUrl).text
|
316
|
+
data = response.text
|
245
317
|
|
246
318
|
# Temperatures URL: Inject parameters.
|
247
319
|
endDate = date.today() - timedelta(days=1) if endDate >= date.today() else endDate
|
pygazpar/version.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "1.3.
|
1
|
+
__version__ = "1.3.0a9"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: pygazpar
|
3
|
-
Version: 1.3.
|
3
|
+
Version: 1.3.0a9
|
4
4
|
Summary: Retrieve gas consumption from GrDF web site (French Gas Company)
|
5
5
|
Home-page: https://github.com/ssenart/pygazpar
|
6
6
|
Author: Stephane Senart
|
@@ -25,11 +25,16 @@ Classifier: Programming Language :: Python :: 3.11
|
|
25
25
|
Requires-Python: >=3.7
|
26
26
|
Description-Content-Type: text/markdown
|
27
27
|
License-File: LICENSE.md
|
28
|
-
Requires-Dist: openpyxl
|
29
|
-
Requires-Dist: requests
|
28
|
+
Requires-Dist: openpyxl >=2.6.3
|
29
|
+
Requires-Dist: requests >=2.26.0
|
30
30
|
Requires-Dist: pandas
|
31
31
|
|
32
32
|
# PyGazpar
|
33
|
+
|
34
|
+
## <span style="color:green">!!! This library is working again. CAPTCHA has been removed !!!</span>
|
35
|
+
|
36
|
+
## <span style="color:red">~~!!! This library is broken since CAPTCHA is mandatory on GrDF site !!!~~</span>
|
37
|
+
|
33
38
|
PyGazpar is a Python library for getting natural gas consumption from GrDF French provider.
|
34
39
|
|
35
40
|
Their natural gas meter is called Gazpar. It is wireless and transmit the gas consumption once per day.
|
@@ -214,9 +219,16 @@ All notable changes to this project will be documented in this file.
|
|
214
219
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
215
220
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
216
221
|
|
217
|
-
## [1.2.
|
222
|
+
## [1.2.2](https://github.com/ssenart/PyGazpar/compare/1.2.1...1.2.2) - 2024-05-08
|
218
223
|
|
219
224
|
### Fixed
|
225
|
+
- [#65](https://github.com/ssenart/PyGazpar/issues/65): [Bug] PermissionError happens when loading data from Excel file.
|
226
|
+
|
227
|
+
## [1.2.1](https://github.com/ssenart/PyGazpar/compare/1.2.0...1.2.1) - 2024-05-04
|
228
|
+
|
229
|
+
### Fixed
|
230
|
+
- [#64](https://github.com/ssenart/PyGazpar/issues/64): [Issue] Captcha failed issue.
|
231
|
+
|
220
232
|
- [#63](https://github.com/ssenart/PyGazpar/issues/63): [Bug] If the latest received consumption is Sunday, then the last weekly period is duplicated.
|
221
233
|
|
222
234
|
## [1.2.0](https://github.com/ssenart/PyGazpar/compare/1.1.6...1.2.0) - 2022-12-16
|
@@ -1,11 +1,11 @@
|
|
1
1
|
pygazpar/__init__.py,sha256=qshO_XZbDA2Wrt80ABDs0MoScqJytClAuIJjAnILglk,309
|
2
2
|
pygazpar/__main__.py,sha256=Pt3PInX7QiWcs0aBKZN90NTaU8KFnrQiZ5Hsow1eR5U,3177
|
3
3
|
pygazpar/client.py,sha256=JdVm0jZbeibwtTumcRbUSFadfXnCUClPMjL95_J6p5Y,2595
|
4
|
-
pygazpar/datasource.py,sha256=
|
4
|
+
pygazpar/datasource.py,sha256=nlIWxZ6SNSHf09BVBFIChSHf4dN05lC3LCxUUDRTylg,21470
|
5
5
|
pygazpar/enum.py,sha256=3ZCk4SziXF6pxgP3MuQ1qxYfqB3X5DOV8Rtd0GHsK9w,898
|
6
6
|
pygazpar/excelparser.py,sha256=glWlbj22pxYjHGKurOFmhzcVAoWCvfOHn7_Y6GgHUPo,5915
|
7
7
|
pygazpar/jsonparser.py,sha256=AWdU3h7UohsOov8HpeP8GNuqcnDmM4r3I7-CI_crDvA,1804
|
8
|
-
pygazpar/version.py,sha256=
|
8
|
+
pygazpar/version.py,sha256=M68dz_mPRENIQzrCD_i1UgDy4ON37VxutlMfPrsxFpY,24
|
9
9
|
pygazpar/resources/daily_data_sample.json,sha256=YJovtrNUMs257magTfyxiewLmecySFypcelbGFUUeT8,199583
|
10
10
|
pygazpar/resources/hourly_data_sample.json,sha256=N1F-Xz3GaBn2H1p7uKzhkhKCQV8QVR0t76XD6wmFtXA,3
|
11
11
|
pygazpar/resources/monthly_data_sample.json,sha256=yrr4SqrB2MubeVU2HX_FRDZKHIhC0LXCqkO1iqnFWcg,3351
|
@@ -16,12 +16,12 @@ samples/excelSample.py,sha256=ltAl-bBz9-U9YI802JpcIswra-vDS7tR_KL5VNdxJ5c,765
|
|
16
16
|
samples/jsonSample.py,sha256=sYAIusdEJhZdwDAMgHqoWcwDR0FA2eWhSt_2gL_mJRk,736
|
17
17
|
samples/testSample.py,sha256=UeirdEtezHwfZDv_75oxul17YzGWn5yZuHfJYTF3Ez0,387
|
18
18
|
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
19
|
-
tests/test_client.py,sha256=
|
19
|
+
tests/test_client.py,sha256=DVGGubMPMM56LvQW-4_pCouvG9qTCbVZVPoRTBGuCU4,5111
|
20
20
|
tests/test_datafileparser.py,sha256=nAeUpOHtelblMpmbrrnf-2GuMjK5ai65veDoymceprE,818
|
21
|
-
tests/test_datasource.py,sha256=
|
22
|
-
pygazpar-1.3.
|
23
|
-
pygazpar-1.3.
|
24
|
-
pygazpar-1.3.
|
25
|
-
pygazpar-1.3.
|
26
|
-
pygazpar-1.3.
|
27
|
-
pygazpar-1.3.
|
21
|
+
tests/test_datasource.py,sha256=Fkn9BOGVKITAgrx9XipR1_ykT7rdvPyt_PAg3ftjfSU,5983
|
22
|
+
pygazpar-1.3.0a9.dist-info/LICENSE.md,sha256=XsCJx_7_BC9tvmE0ZxS1cTNR7ekurog_ea9ybdZ-8tc,1073
|
23
|
+
pygazpar-1.3.0a9.dist-info/METADATA,sha256=BAd0ZNGUkpdCRe0CxbqHc-9X-1wa5MwrBFLVy6AEjvg,18193
|
24
|
+
pygazpar-1.3.0a9.dist-info/WHEEL,sha256=kG0f_S63jJ569yg8d_OdUrqQSrnKsZBVKN5Kb1RSnpA,93
|
25
|
+
pygazpar-1.3.0a9.dist-info/entry_points.txt,sha256=c_FMZPYlRv1w9EqfgWhlkdJOoje7FcglI0UMm2oRLoI,53
|
26
|
+
pygazpar-1.3.0a9.dist-info/top_level.txt,sha256=P7qn-XtanDPBLQsTvjvLV71wH8RK0DYbx8tzN_rDS70,23
|
27
|
+
pygazpar-1.3.0a9.dist-info/RECORD,,
|
tests/test_client.py
CHANGED
@@ -72,10 +72,10 @@ class TestClient:
|
|
72
72
|
|
73
73
|
data = client.loadSince(self.__pceIdentifier, 365, [Frequency.MONTHLY])
|
74
74
|
|
75
|
-
assert (len(data[Frequency.MONTHLY.value]) >=
|
75
|
+
assert (len(data[Frequency.MONTHLY.value]) >= 11 and len(data[Frequency.MONTHLY.value]) <= 13)
|
76
76
|
|
77
77
|
def test_yearly_jsonweb(self):
|
78
|
-
client = Client(
|
78
|
+
client = Client(JsonWebDataSource(self.__username, self.__password))
|
79
79
|
|
80
80
|
data = client.loadSince(self.__pceIdentifier, 365, [Frequency.YEARLY])
|
81
81
|
|
tests/test_datasource.py
CHANGED
@@ -143,7 +143,7 @@ class TestAllDataSource:
|
|
143
143
|
|
144
144
|
assert (len(data[Frequency.WEEKLY.value]) >= 51 and len(data[Frequency.WEEKLY.value]) <= 54)
|
145
145
|
|
146
|
-
assert (len(data[Frequency.MONTHLY.value]) >=
|
146
|
+
assert (len(data[Frequency.MONTHLY.value]) >= 11 and len(data[Frequency.MONTHLY.value]) <= 13)
|
147
147
|
|
148
148
|
assert (len(data[Frequency.YEARLY.value]) == 1)
|
149
149
|
|
File without changes
|
File without changes
|
File without changes
|