QuLab 2.8.0__cp311-cp311-macosx_10_9_universal2.whl → 2.9.0__cp311-cp311-macosx_10_9_universal2.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.
- qulab/__init__.py +1 -0
- qulab/executor/storage.py +80 -24
- qulab/executor/template.py +76 -13
- qulab/fun.cpython-311-darwin.so +0 -0
- qulab/version.py +1 -1
- {qulab-2.8.0.dist-info → qulab-2.9.0.dist-info}/METADATA +3 -2
- {qulab-2.8.0.dist-info → qulab-2.9.0.dist-info}/RECORD +11 -11
- {qulab-2.8.0.dist-info → qulab-2.9.0.dist-info}/WHEEL +1 -1
- {qulab-2.8.0.dist-info → qulab-2.9.0.dist-info}/entry_points.txt +0 -0
- {qulab-2.8.0.dist-info → qulab-2.9.0.dist-info/licenses}/LICENSE +0 -0
- {qulab-2.8.0.dist-info → qulab-2.9.0.dist-info}/top_level.txt +0 -0
qulab/__init__.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from .executor.storage import find_report
|
2
2
|
from .executor.storage import get_report_by_index as get_report
|
3
|
+
from .executor.template import VAR
|
3
4
|
from .executor.utils import debug_analyze
|
4
5
|
from .scan import Scan, get_record, load_record, lookup, lookup_list
|
5
6
|
from .version import __version__
|
qulab/executor/storage.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
import hashlib
|
2
2
|
import lzma
|
3
3
|
import pickle
|
4
|
+
import re
|
4
5
|
import uuid
|
5
6
|
import zipfile
|
6
7
|
from dataclasses import dataclass, field
|
@@ -8,6 +9,7 @@ from datetime import datetime, timedelta
|
|
8
9
|
from functools import lru_cache
|
9
10
|
from pathlib import Path
|
10
11
|
from typing import Any, Literal
|
12
|
+
from urllib.parse import parse_qs
|
11
13
|
|
12
14
|
from loguru import logger
|
13
15
|
|
@@ -15,10 +17,15 @@ try:
|
|
15
17
|
from paramiko import SSHClient
|
16
18
|
from paramiko.ssh_exception import SSHException
|
17
19
|
except:
|
20
|
+
import warnings
|
21
|
+
|
22
|
+
warnings.warn("Can't import paramiko, ssh support will be disabled.")
|
23
|
+
|
18
24
|
class SSHClient:
|
19
25
|
|
20
26
|
def __init__(self):
|
21
|
-
raise ImportError(
|
27
|
+
raise ImportError(
|
28
|
+
"Can't import paramiko, ssh support will be disabled.")
|
22
29
|
|
23
30
|
def __enter__(self):
|
24
31
|
return self
|
@@ -26,6 +33,9 @@ except:
|
|
26
33
|
def __exit__(self, exc_type, exc_value, traceback):
|
27
34
|
pass
|
28
35
|
|
36
|
+
class SSHException(Exception):
|
37
|
+
pass
|
38
|
+
|
29
39
|
|
30
40
|
from ..cli.config import get_config_value
|
31
41
|
|
@@ -48,7 +58,7 @@ class Report():
|
|
48
58
|
index: int = -1
|
49
59
|
previous_path: Path | None = field(default=None, repr=False)
|
50
60
|
heads: dict[str, Path] = field(default_factory=dict, repr=False)
|
51
|
-
base_path: Path | None = field(default=None, repr=False)
|
61
|
+
base_path: str | Path | None = field(default=None, repr=False)
|
52
62
|
path: Path | None = field(default=None, repr=False)
|
53
63
|
config_path: Path | None = field(default=None, repr=False)
|
54
64
|
script_path: Path | None = field(default=None, repr=False)
|
@@ -335,12 +345,15 @@ def save_config_key_history(key: str, report: Report,
|
|
335
345
|
|
336
346
|
|
337
347
|
def load_report(path: str | Path, base_path: str | Path) -> Report | None:
|
338
|
-
if isinstance(base_path, str) and base_path.startswith('ssh
|
348
|
+
if isinstance(base_path, str) and base_path.startswith('ssh://'):
|
339
349
|
with SSHClient() as client:
|
340
|
-
cfg
|
350
|
+
cfg = parse_ssh_uri(base_path)
|
351
|
+
remote_base_path = cfg.pop('remote_file_path')
|
341
352
|
client.load_system_host_keys()
|
342
353
|
client.connect(**cfg)
|
343
|
-
|
354
|
+
report = load_report_from_scp(path, remote_base_path, client)
|
355
|
+
report.base_path = base_path
|
356
|
+
return report
|
344
357
|
|
345
358
|
base_path = Path(base_path)
|
346
359
|
if zipfile.is_zipfile(base_path):
|
@@ -357,12 +370,13 @@ def load_report(path: str | Path, base_path: str | Path) -> Report | None:
|
|
357
370
|
|
358
371
|
|
359
372
|
def get_heads(base_path: str | Path) -> Path | None:
|
360
|
-
if isinstance(base_path, str) and base_path.startswith('ssh
|
373
|
+
if isinstance(base_path, str) and base_path.startswith('ssh://'):
|
361
374
|
with SSHClient() as client:
|
362
|
-
cfg
|
375
|
+
cfg = parse_ssh_uri(base_path)
|
376
|
+
remote_base_path = cfg.pop('remote_file_path')
|
363
377
|
client.load_system_host_keys()
|
364
378
|
client.connect(**cfg)
|
365
|
-
return get_heads_from_scp(
|
379
|
+
return get_heads_from_scp(remote_base_path, client)
|
366
380
|
|
367
381
|
base_path = Path(base_path)
|
368
382
|
if zipfile.is_zipfile(base_path):
|
@@ -377,12 +391,13 @@ def get_heads(base_path: str | Path) -> Path | None:
|
|
377
391
|
|
378
392
|
@lru_cache(maxsize=4096)
|
379
393
|
def query_index(name: str, base_path: str | Path, index: int):
|
380
|
-
if isinstance(base_path, str) and base_path.startswith('ssh
|
394
|
+
if isinstance(base_path, str) and base_path.startswith('ssh://'):
|
381
395
|
with SSHClient() as client:
|
382
|
-
cfg
|
396
|
+
cfg = parse_ssh_uri(base_path)
|
397
|
+
remote_base_path = cfg.pop('remote_file_path')
|
383
398
|
client.load_system_host_keys()
|
384
399
|
client.connect(**cfg)
|
385
|
-
return query_index_from_scp(name,
|
400
|
+
return query_index_from_scp(name, remote_base_path, client, index)
|
386
401
|
|
387
402
|
base_path = Path(base_path)
|
388
403
|
if zipfile.is_zipfile(base_path):
|
@@ -398,12 +413,13 @@ def query_index(name: str, base_path: str | Path, index: int):
|
|
398
413
|
|
399
414
|
@lru_cache(maxsize=4096)
|
400
415
|
def load_item(id, base_path):
|
401
|
-
if isinstance(base_path, str) and base_path.startswith('ssh
|
416
|
+
if isinstance(base_path, str) and base_path.startswith('ssh://'):
|
402
417
|
with SSHClient() as client:
|
403
|
-
cfg
|
418
|
+
cfg = parse_ssh_uri(base_path)
|
419
|
+
remote_base_path = cfg.pop('remote_file_path')
|
404
420
|
client.load_system_host_keys()
|
405
421
|
client.connect(**cfg)
|
406
|
-
buf = load_item_buf_from_scp(id,
|
422
|
+
buf = load_item_buf_from_scp(id, remote_base_path, client)
|
407
423
|
else:
|
408
424
|
base_path = Path(base_path)
|
409
425
|
if zipfile.is_zipfile(base_path):
|
@@ -489,15 +505,56 @@ def load_item_buf_from_zipfile(id, base_path):
|
|
489
505
|
#########################################################################
|
490
506
|
|
491
507
|
|
492
|
-
def
|
493
|
-
|
494
|
-
|
508
|
+
def parse_ssh_uri(uri):
|
509
|
+
"""
|
510
|
+
解析 SSH URI 字符串,返回包含连接参数和路径的字典。
|
511
|
+
|
512
|
+
格式:ssh://[{username}[:{password}]@]{host}[:{port}][?key_filename={key_path}][/{remote_file_path}]
|
513
|
+
|
514
|
+
返回示例:
|
515
|
+
{
|
516
|
+
"username": "user",
|
517
|
+
"password": "pass",
|
518
|
+
"host": "example.com",
|
519
|
+
"port": 22,
|
520
|
+
"key_filename": "/path/to/key",
|
521
|
+
"remote_file_path": "/data/file.txt"
|
522
|
+
}
|
523
|
+
"""
|
524
|
+
pattern = re.compile(
|
525
|
+
r"^ssh://" # 协议头
|
526
|
+
r"(?:([^:@/]+))(?::([^@/]+))?@?" # 用户名和密码(可选)
|
527
|
+
r"([^:/?#]+)" # 主机名(必须)
|
528
|
+
r"(?::(\d+))?" # 端口(可选)
|
529
|
+
r"(/?[^?#]*)?" # 远程路径(可选)
|
530
|
+
r"(?:\?([^#]+))?" # 查询参数(如 key_filename)
|
531
|
+
r"$",
|
532
|
+
re.IGNORECASE)
|
533
|
+
|
534
|
+
match = pattern.match(uri)
|
535
|
+
if not match:
|
536
|
+
raise ValueError(f"Invalid SSH URI format: {uri}")
|
537
|
+
|
538
|
+
# 提取分组
|
539
|
+
username, password, host, port, path, query = match.groups()
|
540
|
+
|
541
|
+
# 处理查询参数
|
542
|
+
key_filename = None
|
543
|
+
if query:
|
544
|
+
params = parse_qs(query)
|
545
|
+
key_filename = params.get("key_filename", [None])[0] # 取第一个值
|
546
|
+
|
547
|
+
# 清理路径开头的斜杠
|
548
|
+
remote_file_path = path
|
549
|
+
|
495
550
|
return {
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
551
|
+
"username": username,
|
552
|
+
"password": password,
|
553
|
+
"hostname": host,
|
554
|
+
"port": int(port) if port else 22, # 默认端口 22
|
555
|
+
"key_filename": key_filename,
|
556
|
+
"remote_file_path": remote_file_path
|
557
|
+
}
|
501
558
|
|
502
559
|
|
503
560
|
def load_report_from_scp(path: str | Path, base_path: Path,
|
@@ -508,7 +565,6 @@ def load_report_from_scp(path: str | Path, base_path: Path,
|
|
508
565
|
with sftp.open(str(Path(base_path) / 'reports' / path), 'rb') as f:
|
509
566
|
index = int.from_bytes(f.read(8), 'big')
|
510
567
|
report = pickle.loads(lzma.decompress(f.read()))
|
511
|
-
report.base_path = path
|
512
568
|
report.index = index
|
513
569
|
return report
|
514
570
|
except SSHException:
|
@@ -532,7 +588,7 @@ def query_index_from_scp(name: str, base_path: Path, client: SSHClient,
|
|
532
588
|
s = str(Path(base_path) / 'index' / f'{name}.width')
|
533
589
|
with sftp.open(s, 'rb') as f:
|
534
590
|
width = int(f.read().decode())
|
535
|
-
with sftp.open(str(base_path / 'index' / f'{name}.idx'),
|
591
|
+
with sftp.open(str(Path(base_path) / 'index' / f'{name}.idx'),
|
536
592
|
'rb') as f:
|
537
593
|
f.seek(index * (width + 1))
|
538
594
|
context = f.read(width).decode()
|
qulab/executor/template.py
CHANGED
@@ -8,6 +8,12 @@ import string
|
|
8
8
|
import textwrap
|
9
9
|
from typing import Any
|
10
10
|
|
11
|
+
_notset = object()
|
12
|
+
|
13
|
+
|
14
|
+
def VAR(name: str, /, *, default: Any = _notset) -> Any:
|
15
|
+
return name
|
16
|
+
|
11
17
|
|
12
18
|
def encode_mapping(mapping):
|
13
19
|
mapping_bytes = lzma.compress(pickle.dumps(mapping))
|
@@ -68,26 +74,67 @@ class TemplateVarExtractor(ast.NodeVisitor):
|
|
68
74
|
def visit_Call(self, node):
|
69
75
|
if isinstance(node.func, ast.Name) and node.func.id == 'VAR':
|
70
76
|
arg = node.args[0]
|
71
|
-
if isinstance(arg, ast.Constant)
|
72
|
-
|
73
|
-
|
74
|
-
|
77
|
+
if not isinstance(arg, ast.Constant) or not isinstance(
|
78
|
+
arg.value, str):
|
79
|
+
raise SyntaxError(
|
80
|
+
f"Argument of VAR function must be a string. {self.fname}:{node.lineno}"
|
81
|
+
)
|
82
|
+
if len(node.args) != 1:
|
83
|
+
raise SyntaxError(
|
84
|
+
f"VAR function only accept one argument. {self.fname}:{node.lineno}"
|
85
|
+
)
|
86
|
+
default = _notset
|
87
|
+
for k in node.keywords:
|
88
|
+
if k.arg == 'default':
|
89
|
+
pass
|
90
|
+
# if isinstance(k.value, ast.Constant):
|
91
|
+
# # default = k.value.value
|
92
|
+
# default = k.value
|
93
|
+
# else:
|
94
|
+
# default = k.value
|
95
|
+
# # raise SyntaxError(
|
96
|
+
# # f"Argument of 'default' must be a constant. {self.fname}:{node.lineno}"
|
97
|
+
# # )
|
98
|
+
else:
|
99
|
+
raise SyntaxError(
|
100
|
+
f"VAR function only accept keyword argument 'default'. {self.fname}:{node.lineno}"
|
75
101
|
)
|
76
|
-
|
102
|
+
|
103
|
+
if default is _notset:
|
77
104
|
# new_node = ast.Subscript(value=ast.Name(id="__VAR",
|
78
105
|
# ctx=ast.Load()),
|
79
106
|
# slice=ast.Constant(value=arg.value),
|
80
107
|
# ctx=ast.Load())
|
81
|
-
|
82
|
-
|
108
|
+
if arg.value not in self.mapping:
|
109
|
+
raise TemplateKeyError(
|
110
|
+
f"The variable '{arg.value}' is not provided in mapping. {self.fname}:{node.lineno}"
|
111
|
+
)
|
83
112
|
self.replacements[(node.lineno, node.end_lineno,
|
84
113
|
node.col_offset,
|
85
114
|
node.end_col_offset)] = ('VAR', arg.value,
|
86
115
|
None, None)
|
87
116
|
else:
|
88
|
-
|
89
|
-
|
90
|
-
)
|
117
|
+
# new_node = ast.Call(
|
118
|
+
# func=ast.Attribute(value=ast.Name(id='__VAR',
|
119
|
+
# ctx=ast.Load()),
|
120
|
+
# attr='get',
|
121
|
+
# ctx=ast.Load()),
|
122
|
+
# args=[ast.Constant(value=arg.value)],
|
123
|
+
# keywords=[
|
124
|
+
# ast.keyword(arg='default',
|
125
|
+
# value=value)
|
126
|
+
# ],
|
127
|
+
# ctx=ast.Load())
|
128
|
+
self.replacements[(node.lineno, node.end_lineno,
|
129
|
+
node.col_offset,
|
130
|
+
node.end_col_offset)] = ('VAR', arg.value,
|
131
|
+
None, None)
|
132
|
+
# ast.fix_missing_locations(new_node)
|
133
|
+
# new_source = ast.unparse(new_node)
|
134
|
+
# print(new_source)
|
135
|
+
|
136
|
+
self.variables.add(arg.value)
|
137
|
+
|
91
138
|
self.generic_visit(node)
|
92
139
|
|
93
140
|
def _process_string(self, s: str, lineno: int, col_offset: int,
|
@@ -160,8 +207,24 @@ def inject_mapping(source: str, mapping: dict[str, Any],
|
|
160
207
|
lines_offset[end_lineno - 1] += len(
|
161
208
|
lines[end_lineno - 1]) - length_of_last_line
|
162
209
|
else:
|
163
|
-
pattern = re.compile(
|
164
|
-
|
210
|
+
pattern = re.compile(
|
211
|
+
r'VAR\s*\(\s*' # VAR(
|
212
|
+
r'(["\'])(\w+)\1' # 第一个参数(引号包裹的变量名)
|
213
|
+
r'(?:\s*,\s*(.*?))?' # 可选的其他参数
|
214
|
+
r'\s*\)', # 闭合括号
|
215
|
+
re.DOTALL # 允许.匹配换行符
|
216
|
+
) # yapf: disable
|
217
|
+
|
218
|
+
def replacement(match):
|
219
|
+
quote = match.group(1)
|
220
|
+
var_name = match.group(2)
|
221
|
+
extra_args = match.group(3)
|
222
|
+
base = f'__VAR_{hash_str}'
|
223
|
+
if extra_args is not None:
|
224
|
+
return f'{base}.get({quote}{var_name}{quote}, {extra_args})'
|
225
|
+
else:
|
226
|
+
return f'{base}[{quote}{var_name}{quote}]'
|
227
|
+
|
165
228
|
new_content = re.sub(pattern, replacement, content)
|
166
229
|
|
167
230
|
if lineno == end_lineno:
|
@@ -172,7 +235,7 @@ def inject_mapping(source: str, mapping: dict[str, Any],
|
|
172
235
|
lines[lineno - 1] = head + new_content[:-1]
|
173
236
|
for i in range(lineno, end_lineno - 1):
|
174
237
|
lines[i] = ''
|
175
|
-
lines[end_lineno - 1] =
|
238
|
+
lines[end_lineno - 1] = new_content[-1] + tail
|
176
239
|
lines_offset[end_lineno - 1] += len(
|
177
240
|
lines[end_lineno - 1]) - length_of_last_line
|
178
241
|
|
qulab/fun.cpython-311-darwin.so
CHANGED
Binary file
|
qulab/version.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "2.
|
1
|
+
__version__ = "2.9.0"
|
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: QuLab
|
3
|
-
Version: 2.
|
3
|
+
Version: 2.9.0
|
4
4
|
Summary: contral instruments and manage data
|
5
5
|
Author-email: feihoo87 <feihoo87@gmail.com>
|
6
6
|
Maintainer-email: feihoo87 <feihoo87@gmail.com>
|
@@ -44,6 +44,7 @@ Requires-Dist: watchdog>=4.0.0
|
|
44
44
|
Requires-Dist: waveforms>=1.9.4
|
45
45
|
Provides-Extra: full
|
46
46
|
Requires-Dist: uvloop>=0.19.0; extra == "full"
|
47
|
+
Dynamic: license-file
|
47
48
|
|
48
49
|
# QuLab
|
49
50
|
[](https://travis-ci.org/feihoo87/QuLab)
|
@@ -1,10 +1,10 @@
|
|
1
|
-
qulab/__init__.py,sha256=
|
1
|
+
qulab/__init__.py,sha256=JZ3bn_kTVlnY-P8JXQ5xEdViieFXqfxX8ReLuiiXIpo,321
|
2
2
|
qulab/__main__.py,sha256=fjaRSL_uUjNIzBGNgjlGswb9TJ2VD5qnkZHW3hItrD4,68
|
3
3
|
qulab/dicttree.py,sha256=tRRMpGZYVOLw0TEByE3_2Ss8FdOmzuGL9e1DWbs8qoY,13684
|
4
|
-
qulab/fun.cpython-311-darwin.so,sha256=
|
4
|
+
qulab/fun.cpython-311-darwin.so,sha256=ub1obycqKPeAfCYWqXF_0Nf_tkVwuNzEOmb_UEv-A6c,126848
|
5
5
|
qulab/typing.py,sha256=vg62sGqxuD9CI5677ejlzAmf2fVdAESZCQjAE_xSxPg,69
|
6
6
|
qulab/utils.py,sha256=BdLdlfjpe6m6gSeONYmpAKTTqxDaYHNk4exlz8kZxTg,2982
|
7
|
-
qulab/version.py,sha256=
|
7
|
+
qulab/version.py,sha256=a31Mxt9ZbwomFaywnjMS--InOBZRSD7ltY06SX9P5q0,21
|
8
8
|
qulab/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
9
9
|
qulab/cli/commands.py,sha256=tgDIkkeIoasQXAifJZ6NU8jDgpNgb2a-B0C4nF0evrE,559
|
10
10
|
qulab/cli/config.py,sha256=Ei7eSYnbwPPlluDnm8YmWONYiI4g7WtvlZGQdr1Z6vo,3688
|
@@ -12,8 +12,8 @@ qulab/executor/__init__.py,sha256=LosPzOMaljSZY1thy_Fxtbrgq7uubJszMABEB7oM7tU,10
|
|
12
12
|
qulab/executor/cli.py,sha256=8d-8bRWZ5lmsMtjASsl1zu1rV-syeAESMNVthvIQxlo,10018
|
13
13
|
qulab/executor/load.py,sha256=YndvzagvWR8Sg6WHZ-gP-Of0FrFOyh_E_a3VXsjDf1Q,17502
|
14
14
|
qulab/executor/schedule.py,sha256=0BV5LGxhqdIlGwW6-o5_5mljAtdtL1La8EDNBFi8pzU,18585
|
15
|
-
qulab/executor/storage.py,sha256=
|
16
|
-
qulab/executor/template.py,sha256=
|
15
|
+
qulab/executor/storage.py,sha256=OA_XMDoFDfPZCU89caf9-VZ3D6qaWib8MpJno10KUfc,20770
|
16
|
+
qulab/executor/template.py,sha256=BhNP8GrTk0pCK3GwijI08Q2jmYPbLi_7A5XWZXhCtUo,10123
|
17
17
|
qulab/executor/transform.py,sha256=BDx0c4nqTHMAOLVqju0Ydd91uxNm6EpVIfssjZse0bI,2284
|
18
18
|
qulab/executor/utils.py,sha256=l_b0y2kMwYKyyXeFtoblPYwKNU-wiFQ9PMo9QlWl9wE,6213
|
19
19
|
qulab/monitor/__init__.py,sha256=nTHelnDpxRS_fl_B38TsN0njgq8eVTEz9IAnN3NbDlM,42
|
@@ -97,9 +97,9 @@ qulab/visualization/plot_seq.py,sha256=UWTS6p9nfX_7B8ehcYo6UnSTUCjkBsNU9jiOeW2ca
|
|
97
97
|
qulab/visualization/qdat.py,sha256=ZeevBYWkzbww4xZnsjHhw7wRorJCBzbG0iEu-XQB4EA,5735
|
98
98
|
qulab/visualization/rot3d.py,sha256=lMrEJlRLwYe6NMBlGkKYpp_V9CTipOAuDy6QW_cQK00,734
|
99
99
|
qulab/visualization/widgets.py,sha256=6KkiTyQ8J-ei70LbPQZAK35wjktY47w2IveOa682ftA,3180
|
100
|
-
qulab-2.
|
101
|
-
qulab-2.
|
102
|
-
qulab-2.
|
103
|
-
qulab-2.
|
104
|
-
qulab-2.
|
105
|
-
qulab-2.
|
100
|
+
qulab-2.9.0.dist-info/licenses/LICENSE,sha256=PRzIKxZtpQcH7whTG6Egvzl1A0BvnSf30tmR2X2KrpA,1065
|
101
|
+
qulab-2.9.0.dist-info/METADATA,sha256=gci3kQAblFeB_opSNNDQhNyQAShjlv1NnEWYxGcFlqI,3720
|
102
|
+
qulab-2.9.0.dist-info/WHEEL,sha256=JaRfGby1TTSqNmKjNEsuDj4aQSHVLA9Uc6dzGgAYXuw,114
|
103
|
+
qulab-2.9.0.dist-info/entry_points.txt,sha256=b0v1GXOwmxY-nCCsPN_rHZZvY9CtTbWqrGj8u1m8yHo,45
|
104
|
+
qulab-2.9.0.dist-info/top_level.txt,sha256=3T886LbAsbvjonu_TDdmgxKYUn939BVTRPxPl9r4cEg,6
|
105
|
+
qulab-2.9.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|