dcicutils 8.8.3.1b11__py3-none-any.whl → 8.8.3.1b13__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.
- dcicutils/data_readers.py +11 -0
- dcicutils/datetime_utils.py +110 -1
- dcicutils/misc_utils.py +42 -0
- dcicutils/portal_utils.py +8 -6
- dcicutils/progress_bar.py +16 -12
- {dcicutils-8.8.3.1b11.dist-info → dcicutils-8.8.3.1b13.dist-info}/METADATA +1 -1
- {dcicutils-8.8.3.1b11.dist-info → dcicutils-8.8.3.1b13.dist-info}/RECORD +10 -10
- {dcicutils-8.8.3.1b11.dist-info → dcicutils-8.8.3.1b13.dist-info}/LICENSE.txt +0 -0
- {dcicutils-8.8.3.1b11.dist-info → dcicutils-8.8.3.1b13.dist-info}/WHEEL +0 -0
- {dcicutils-8.8.3.1b11.dist-info → dcicutils-8.8.3.1b13.dist-info}/entry_points.txt +0 -0
dcicutils/data_readers.py
CHANGED
@@ -66,6 +66,13 @@ class RowReader(abc.ABC):
|
|
66
66
|
else:
|
67
67
|
return value
|
68
68
|
|
69
|
+
@property
|
70
|
+
def nrows(self) -> int:
|
71
|
+
nrows = 0
|
72
|
+
for row in self:
|
73
|
+
nrows += 1
|
74
|
+
return nrows
|
75
|
+
|
69
76
|
def open(self) -> None:
|
70
77
|
pass
|
71
78
|
|
@@ -192,6 +199,10 @@ class Excel:
|
|
192
199
|
return True
|
193
200
|
return False
|
194
201
|
|
202
|
+
@property
|
203
|
+
def nsheets(self) -> int:
|
204
|
+
return len(self.sheet_names)
|
205
|
+
|
195
206
|
def __del__(self) -> None:
|
196
207
|
if (workbook := self._workbook) is not None:
|
197
208
|
self._workbook = None
|
dcicutils/datetime_utils.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from dcicutils.misc_utils import normalize_spaces
|
2
2
|
from datetime import datetime, timedelta, timezone
|
3
|
-
from
|
3
|
+
from dateutil import parser as datetime_parser
|
4
|
+
from typing import Optional, Tuple, Union
|
4
5
|
|
5
6
|
|
6
7
|
def parse_datetime_string(value: str) -> Optional[datetime]:
|
@@ -82,6 +83,32 @@ def normalize_date_string(value: str) -> Optional[str]:
|
|
82
83
|
return d.strftime("%Y-%m-%d") if d else None
|
83
84
|
|
84
85
|
|
86
|
+
def get_timezone(hours: int, minutes: Optional[int] = None) -> timezone:
|
87
|
+
try:
|
88
|
+
return timezone(timedelta(hours=hours, minutes=minutes or 0))
|
89
|
+
except Exception:
|
90
|
+
return timezone.utc
|
91
|
+
|
92
|
+
|
93
|
+
def get_timezone_hours_minutes(tz: timezone) -> Tuple[int, int]:
|
94
|
+
"""
|
95
|
+
Returns a tuple with the integer hours and minutes offset for the given timezone.
|
96
|
+
"""
|
97
|
+
tz_minutes = datetime.now(tz).utcoffset().total_seconds() / 60
|
98
|
+
return int(tz_minutes // 60), int(abs(tz_minutes % 60))
|
99
|
+
|
100
|
+
|
101
|
+
def get_utc_timezone() -> timezone:
|
102
|
+
return timezone.utc
|
103
|
+
|
104
|
+
|
105
|
+
def get_local_timezone() -> timezone:
|
106
|
+
"""
|
107
|
+
Returns current/local timezone as a datetime.timezone object.
|
108
|
+
"""
|
109
|
+
return datetime.now().astimezone().tzinfo
|
110
|
+
|
111
|
+
|
85
112
|
def get_local_timezone_string() -> str:
|
86
113
|
"""
|
87
114
|
Returns current/local timezone in format like: "-05:00".
|
@@ -96,3 +123,85 @@ def get_local_timezone_hours_minutes() -> Tuple[int, int]:
|
|
96
123
|
"""
|
97
124
|
tz_minutes = datetime.now(timezone.utc).astimezone().utcoffset().total_seconds() / 60
|
98
125
|
return int(tz_minutes // 60), int(abs(tz_minutes % 60))
|
126
|
+
|
127
|
+
|
128
|
+
def parse_datetime(value: str, utc: bool = False, tz: Optional[timezone] = None) -> Optional[datetime]:
|
129
|
+
"""
|
130
|
+
Parses the given string into a datetime, if possible, and returns that value,
|
131
|
+
or None if not able to parse. The timezone of the returned datetime will be the
|
132
|
+
local timezone; or if the given utc argument is True then it will be UTC; or if the
|
133
|
+
given tz argument is a datetime.timezone then return datetime will be in that timezone.
|
134
|
+
"""
|
135
|
+
if isinstance(value, datetime):
|
136
|
+
return value
|
137
|
+
elif not isinstance(value, str):
|
138
|
+
return None
|
139
|
+
try:
|
140
|
+
# This dateutil.parser handles quite a wide variety of formats and suits our needs.
|
141
|
+
value = datetime_parser.parse(value)
|
142
|
+
if utc is True:
|
143
|
+
# If the given utc argument is True then it trumps any tz argument if given.
|
144
|
+
tz = timezone.utc
|
145
|
+
if value.tzinfo is not None:
|
146
|
+
# The given value had an explicit timezone specified.
|
147
|
+
if isinstance(tz, timezone):
|
148
|
+
return value.astimezone(tz)
|
149
|
+
return value
|
150
|
+
return value.replace(tzinfo=tz if isinstance(tz, timezone) else get_local_timezone())
|
151
|
+
except Exception:
|
152
|
+
return None
|
153
|
+
|
154
|
+
|
155
|
+
def format_datetime(value: datetime,
|
156
|
+
utc: bool = False,
|
157
|
+
iso: bool = False,
|
158
|
+
ms: bool = False,
|
159
|
+
tz: Optional[Union[timezone, bool]] = None,
|
160
|
+
notz: bool = False,
|
161
|
+
noseconds: bool = False,
|
162
|
+
verbose: bool = False,
|
163
|
+
noseparator: bool = False,
|
164
|
+
noday: bool = False) -> Optional[str]:
|
165
|
+
"""
|
166
|
+
Returns the given datetime as a string in "YYYY:MM:DD hh:mm:ss tz" format, for
|
167
|
+
example "2024-04-17 15:42:26 EDT". If the given notz argument is True then omits
|
168
|
+
the timezone; if the noseconds argument is given the omits the seconds. If the given
|
169
|
+
verbose argument is True then returns a really verbose version of the datetime, for
|
170
|
+
example "Wednesday, April 17, 2024 | 15:42:26 EDT"; if the noseparator argument is
|
171
|
+
True then omits the "|" separator; if the noday argument is True then omits the day
|
172
|
+
of week part. The timezone of the returned datetime string will default to the local
|
173
|
+
one; if the given utc argument is True then it will be UTC; or if the given tz
|
174
|
+
argument is a datetime.timezone it will be in that timezone.
|
175
|
+
"""
|
176
|
+
if not isinstance(value, datetime):
|
177
|
+
if not isinstance(value, str) or not (value := parse_datetime(value)):
|
178
|
+
return None
|
179
|
+
try:
|
180
|
+
if utc is True:
|
181
|
+
tz = timezone.utc
|
182
|
+
elif not isinstance(tz, timezone):
|
183
|
+
tz = get_local_timezone()
|
184
|
+
if tz is True:
|
185
|
+
notz = False
|
186
|
+
elif tz is False:
|
187
|
+
notz = True
|
188
|
+
value = value.astimezone(tz)
|
189
|
+
if iso:
|
190
|
+
if notz is True:
|
191
|
+
value = value.replace(tzinfo=None)
|
192
|
+
if not (ms is True):
|
193
|
+
value = value.replace(microsecond=0)
|
194
|
+
return value.isoformat()
|
195
|
+
if noseconds is True:
|
196
|
+
ms = False
|
197
|
+
if verbose:
|
198
|
+
return value.strftime(
|
199
|
+
f"{'' if noday is True else '%A, '}%B %-d, %Y{'' if noseparator is True else ' |'}"
|
200
|
+
f" %-I:%M{'' if noseconds is True else ':%S'}"
|
201
|
+
f"{f'.%f' if ms is True else ''} %p{'' if notz is True else ' %Z'}")
|
202
|
+
else:
|
203
|
+
return value.strftime(
|
204
|
+
f"%Y-%m-%d %H:%M{'' if noseconds is True else ':%S'}"
|
205
|
+
f"{f'.%f' if ms is True else ''}{'' if notz is True else ' %Z'}")
|
206
|
+
except Exception:
|
207
|
+
return None
|
dcicutils/misc_utils.py
CHANGED
@@ -2571,6 +2571,48 @@ def set_nth(string: str, nth: int, replacement: str) -> str:
|
|
2571
2571
|
return string[:nth] + replacement + string[nth + 1:] if 0 <= nth < len(string) else string
|
2572
2572
|
|
2573
2573
|
|
2574
|
+
def format_size(nbytes: Union[int, float], precision: int = 2, nospace: bool = False, terse: bool = False) -> str:
|
2575
|
+
if isinstance(nbytes, str) and nbytes.isdigit():
|
2576
|
+
nbytes = int(nbytes)
|
2577
|
+
elif not isinstance(nbytes, (int, float)):
|
2578
|
+
return ""
|
2579
|
+
UNITS = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
2580
|
+
UNITS_TERSE = ['b', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
|
2581
|
+
MAX_UNITS_INDEX = len(UNITS) - 1
|
2582
|
+
ONE_K = 1024
|
2583
|
+
index = 0
|
2584
|
+
if (precision := max(precision, 0)) and (nbytes <= ONE_K):
|
2585
|
+
precision -= 1
|
2586
|
+
while abs(nbytes) >= ONE_K and index < MAX_UNITS_INDEX:
|
2587
|
+
nbytes /= ONE_K
|
2588
|
+
index += 1
|
2589
|
+
if index == 0:
|
2590
|
+
nbytes = int(nbytes)
|
2591
|
+
return f"{nbytes} byte{'s' if nbytes != 1 else ''}"
|
2592
|
+
unit = (UNITS_TERSE if terse else UNITS)[index]
|
2593
|
+
return f"{nbytes:.{precision}f}{'' if nospace else ' '}{unit}"
|
2594
|
+
|
2595
|
+
|
2596
|
+
def format_duration(seconds: Union[int, float]) -> str:
|
2597
|
+
seconds_actual = seconds
|
2598
|
+
seconds = round(max(seconds, 0))
|
2599
|
+
durations = [("year", 31536000), ("day", 86400), ("hour", 3600), ("minute", 60), ("second", 1)]
|
2600
|
+
parts = []
|
2601
|
+
for name, duration in durations:
|
2602
|
+
if seconds >= duration:
|
2603
|
+
count = seconds // duration
|
2604
|
+
seconds %= duration
|
2605
|
+
if count != 1:
|
2606
|
+
name += "s"
|
2607
|
+
parts.append(f"{count} {name}")
|
2608
|
+
if len(parts) == 0:
|
2609
|
+
return f"{seconds_actual:.1f} seconds"
|
2610
|
+
elif len(parts) == 1:
|
2611
|
+
return f"{seconds_actual:.1f} seconds"
|
2612
|
+
else:
|
2613
|
+
return " ".join(parts[:-1]) + " " + parts[-1]
|
2614
|
+
|
2615
|
+
|
2574
2616
|
class JsonLinesReader:
|
2575
2617
|
|
2576
2618
|
def __init__(self, fp, padded=False, padding=None):
|
dcicutils/portal_utils.py
CHANGED
@@ -282,15 +282,17 @@ class Portal:
|
|
282
282
|
except Exception:
|
283
283
|
return None
|
284
284
|
|
285
|
-
def patch_metadata(self, object_id: str, data: dict) -> Optional[dict]:
|
285
|
+
def patch_metadata(self, object_id: str, data: dict, check_only: bool = False) -> Optional[dict]:
|
286
286
|
if self.key:
|
287
|
-
return patch_metadata(obj_id=object_id, patch_item=data, key=self.key
|
288
|
-
|
287
|
+
return patch_metadata(obj_id=object_id, patch_item=data, key=self.key,
|
288
|
+
add_on="check_only=True" if check_only else "")
|
289
|
+
return self.patch(f"/{object_id}{'?check_only=True' if check_only else ''}", data).json()
|
289
290
|
|
290
|
-
def post_metadata(self, object_type: str, data: dict) -> Optional[dict]:
|
291
|
+
def post_metadata(self, object_type: str, data: dict, check_only: bool = False) -> Optional[dict]:
|
291
292
|
if self.key:
|
292
|
-
return post_metadata(schema_name=object_type, post_item=data, key=self.key
|
293
|
-
|
293
|
+
return post_metadata(schema_name=object_type, post_item=data, key=self.key,
|
294
|
+
add_on="check_only=True" if check_only else "")
|
295
|
+
return self.post(f"/{object_type}{'?check_only=True' if check_only else ''}", data).json()
|
294
296
|
|
295
297
|
def get_health(self) -> OptionalResponse:
|
296
298
|
return self.get("/health")
|
dcicutils/progress_bar.py
CHANGED
@@ -9,7 +9,7 @@ from types import FrameType as frame
|
|
9
9
|
from typing import Callable, List, Optional, Union
|
10
10
|
from contextlib import contextmanager
|
11
11
|
from dcicutils.command_utils import yes_or_no
|
12
|
-
from dcicutils.misc_utils import find_nth_from_end, set_nth
|
12
|
+
from dcicutils.misc_utils import find_nth_from_end, format_size, set_nth
|
13
13
|
|
14
14
|
|
15
15
|
class TQDM(tqdm):
|
@@ -49,6 +49,8 @@ class ProgressBar:
|
|
49
49
|
|
50
50
|
def __init__(self, total: Optional[int] = None,
|
51
51
|
description: Optional[str] = None,
|
52
|
+
use_byte_size_for_rate: bool = False,
|
53
|
+
use_ascii: bool = False,
|
52
54
|
catch_interrupt: bool = True,
|
53
55
|
interrupt: Optional[Callable] = None,
|
54
56
|
interrupt_continue: Optional[Callable] = None,
|
@@ -59,11 +61,13 @@ class ProgressBar:
|
|
59
61
|
tidy_output_hack: bool = True,
|
60
62
|
capture_output_for_testing: bool = False) -> None:
|
61
63
|
self._bar = None
|
64
|
+
self._started = 0
|
62
65
|
self._disabled = False
|
63
66
|
self._done = False
|
64
67
|
self._tidy_output_hack = (tidy_output_hack is True)
|
65
|
-
self._started = time.time()
|
66
68
|
self._stop_requested = False
|
69
|
+
self._use_byte_size_for_rate = (use_byte_size_for_rate is True and self._tidy_output_hack)
|
70
|
+
self._use_ascii = (use_ascii is True)
|
67
71
|
# Interrupt handling. We do not do the actual (signal) interrupt setup
|
68
72
|
# in self._initialize as that could be called from a (sub) thread; and in
|
69
73
|
# Python we can only set a signal (SIGINT in our case) on the main thread.
|
@@ -96,9 +100,13 @@ class ProgressBar:
|
|
96
100
|
def _initialize(self) -> bool:
|
97
101
|
# Do not actually create the tqdm object unless/until we have a positive total.
|
98
102
|
if (self._bar is None) and (self._total > 0):
|
99
|
-
|
103
|
+
if self._use_byte_size_for_rate:
|
104
|
+
bar_format = "{l_bar}{bar}| {n_fmt}/{total_fmt} | [rate] | {elapsed}{postfix} | ETA: {remaining} "
|
105
|
+
else:
|
106
|
+
bar_format = "{l_bar}{bar}| {n_fmt}/{total_fmt} | {rate_fmt} | {elapsed}{postfix} | ETA: {remaining} "
|
100
107
|
self._bar = TQDM(total=self._total, desc=self._description,
|
101
|
-
dynamic_ncols=True, bar_format=bar_format, unit="", file=sys.stdout)
|
108
|
+
dynamic_ncols=True, bar_format=bar_format, unit="", file=sys.stdout, ascii=self._use_ascii)
|
109
|
+
self._started = time.time()
|
102
110
|
if self._disabled:
|
103
111
|
self._bar.disable = True
|
104
112
|
return True
|
@@ -153,6 +161,7 @@ class ProgressBar:
|
|
153
161
|
self.set_total(total, _norefresh=True)
|
154
162
|
self.set_progress(progress, _norefresh=True)
|
155
163
|
self.set_description(description)
|
164
|
+
self._started = time.time()
|
156
165
|
|
157
166
|
def done(self, description: Optional[str] = None) -> None:
|
158
167
|
if self._done or self._bar is None:
|
@@ -198,14 +207,6 @@ class ProgressBar:
|
|
198
207
|
def stop_requested(self) -> bool:
|
199
208
|
return self._stop_requested
|
200
209
|
|
201
|
-
@property
|
202
|
-
def started(self) -> None:
|
203
|
-
return self._started
|
204
|
-
|
205
|
-
@property
|
206
|
-
def duration(self) -> None:
|
207
|
-
return time.time() - self._started
|
208
|
-
|
209
210
|
@property
|
210
211
|
def captured_output_for_testing(self) -> Optional[List[str]]:
|
211
212
|
return self._captured_output_for_testing
|
@@ -302,6 +303,9 @@ class ProgressBar:
|
|
302
303
|
# something like "1.54s/" rather than "1.54/s"; something to do with
|
303
304
|
# the unit we gave, which is empty; idunno; just replace it here.
|
304
305
|
text = replace_first(text, "s/ ", "/s ")
|
306
|
+
if self._use_byte_size_for_rate and self._bar:
|
307
|
+
rate = self._bar.n / (time.time() - self._started)
|
308
|
+
text = text.replace("[rate]", f"{format_size(rate)}/s")
|
305
309
|
sys_stdout_write(text)
|
306
310
|
sys.stdout.flush()
|
307
311
|
if self._captured_output_for_testing is not None:
|
@@ -10,9 +10,9 @@ dcicutils/common.py,sha256=YE8Mt5-vaZWWz4uaChSVhqGFbFtW5QKtnIyOr4zG4vM,3955
|
|
10
10
|
dcicutils/contribution_scripts.py,sha256=0k5Gw1TumcD5SAcXVkDd6-yvuMEw-jUp5Kfb7FJH6XQ,2015
|
11
11
|
dcicutils/contribution_utils.py,sha256=vYLS1JUB3sKd24BUxZ29qUBqYeQBLK9cwo8x3k64uPg,25653
|
12
12
|
dcicutils/creds_utils.py,sha256=xrLekD49Ex0GOpL9n7LlJA4gvNcY7txTVFOSYD7LvEU,11113
|
13
|
-
dcicutils/data_readers.py,sha256=
|
13
|
+
dcicutils/data_readers.py,sha256=6EMrY7TjDE8H7bA_TCWtpLQP7slJ0YTL77_dNh6e7sg,7626
|
14
14
|
dcicutils/data_utils.py,sha256=k2OxOlsx7AJ6jF-YNlMyGus_JqSUBe4_n1s65Mv1gQQ,3098
|
15
|
-
dcicutils/datetime_utils.py,sha256=
|
15
|
+
dcicutils/datetime_utils.py,sha256=OXdNpOKjNL3osTwhmLoJeL3DXoMksp0egExDT1hif_Y,9152
|
16
16
|
dcicutils/deployment_utils.py,sha256=sKv8Jb-_Zw2aH3OAywRlsre-Cqm3a7fEGG3_1PT-r-s,69908
|
17
17
|
dcicutils/diff_utils.py,sha256=sQx-yz56DHAcQWOChYbAG3clXu7TbiZKlw-GggeveO0,8118
|
18
18
|
dcicutils/docker_utils.py,sha256=30gUiqz7X9rJwSPXTPn4ewjQibUgoSJqhP9o9vn5X-A,1747
|
@@ -43,12 +43,12 @@ dcicutils/license_policies/park-lab-gpl-pipeline.jsonc,sha256=vLZkwm3Js-kjV44nug
|
|
43
43
|
dcicutils/license_policies/park-lab-pipeline.jsonc,sha256=9qlY0ASy3iUMQlr3gorVcXrSfRHnVGbLhkS427UaRy4,283
|
44
44
|
dcicutils/license_utils.py,sha256=d1cq6iwv5Ju-VjdoINi6q7CPNNL7Oz6rcJdLMY38RX0,46978
|
45
45
|
dcicutils/log_utils.py,sha256=7pWMc6vyrorUZQf-V-M3YC6zrPgNhuV_fzm9xqTPph0,10883
|
46
|
-
dcicutils/misc_utils.py,sha256=
|
46
|
+
dcicutils/misc_utils.py,sha256=YH_TTmv6ABWeMERwVvA2-rIfdS-CoPYLXJru9TvWxgM,104610
|
47
47
|
dcicutils/obfuscation_utils.py,sha256=fo2jOmDRC6xWpYX49u80bVNisqRRoPskFNX3ymFAmjw,5963
|
48
48
|
dcicutils/opensearch_utils.py,sha256=V2exmFYW8Xl2_pGFixF4I2Cc549Opwe4PhFi5twC0M8,1017
|
49
49
|
dcicutils/portal_object_utils.py,sha256=gDXRgPsRvqCFwbC8WatsuflAxNiigOnqr0Hi93k3AgE,15422
|
50
|
-
dcicutils/portal_utils.py,sha256=
|
51
|
-
dcicutils/progress_bar.py,sha256=
|
50
|
+
dcicutils/portal_utils.py,sha256=DYyE5o15GekDgzpJWas9iS7klAYbjJZUPW0G42McArk,30779
|
51
|
+
dcicutils/progress_bar.py,sha256=th-M7Q1UfkmQWc8M36lfVoRhFAQVRT9fSpdHi5RHqNY,17389
|
52
52
|
dcicutils/project_utils.py,sha256=qPdCaFmWUVBJw4rw342iUytwdQC0P-XKpK4mhyIulMM,31250
|
53
53
|
dcicutils/qa_checkers.py,sha256=cdXjeL0jCDFDLT8VR8Px78aS10hwNISOO5G_Zv2TZ6M,20534
|
54
54
|
dcicutils/qa_utils.py,sha256=TT0SiJWiuxYvbsIyhK9VO4uV_suxhB6CpuC4qPacCzQ,160208
|
@@ -72,8 +72,8 @@ dcicutils/trace_utils.py,sha256=g8kwV4ebEy5kXW6oOrEAUsurBcCROvwtZqz9fczsGRE,1769
|
|
72
72
|
dcicutils/validation_utils.py,sha256=cMZIU2cY98FYtzK52z5WUYck7urH6JcqOuz9jkXpqzg,14797
|
73
73
|
dcicutils/variant_utils.py,sha256=2H9azNx3xAj-MySg-uZ2SFqbWs4kZvf61JnK6b-h4Qw,4343
|
74
74
|
dcicutils/zip_utils.py,sha256=rnjNv_k6L9jT2SjDSgVXp4BEJYLtz9XN6Cl2Fy-tqnM,2027
|
75
|
-
dcicutils-8.8.3.
|
76
|
-
dcicutils-8.8.3.
|
77
|
-
dcicutils-8.8.3.
|
78
|
-
dcicutils-8.8.3.
|
79
|
-
dcicutils-8.8.3.
|
75
|
+
dcicutils-8.8.3.1b13.dist-info/LICENSE.txt,sha256=qnwSmfnEWMl5l78VPDEzAmEbLVrRqQvfUQiHT0ehrOo,1102
|
76
|
+
dcicutils-8.8.3.1b13.dist-info/METADATA,sha256=3mHRqaCku3ZV_Yea9_Na_QZ8LTCscx9sELeZtt1BaUc,3357
|
77
|
+
dcicutils-8.8.3.1b13.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
|
78
|
+
dcicutils-8.8.3.1b13.dist-info/entry_points.txt,sha256=51Q4F_2V10L0282W7HFjP4jdzW4K8lnWDARJQVFy_hw,270
|
79
|
+
dcicutils-8.8.3.1b13.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|