atlas-ftag-tools 0.2.12__py3-none-any.whl → 0.2.13__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.
- {atlas_ftag_tools-0.2.12.dist-info → atlas_ftag_tools-0.2.13.dist-info}/METADATA +2 -2
- {atlas_ftag_tools-0.2.12.dist-info → atlas_ftag_tools-0.2.13.dist-info}/RECORD +8 -8
- ftag/__init__.py +1 -1
- ftag/vds.py +286 -65
- {atlas_ftag_tools-0.2.12.dist-info → atlas_ftag_tools-0.2.13.dist-info}/WHEEL +0 -0
- {atlas_ftag_tools-0.2.12.dist-info → atlas_ftag_tools-0.2.13.dist-info}/entry_points.txt +0 -0
- {atlas_ftag_tools-0.2.12.dist-info → atlas_ftag_tools-0.2.13.dist-info}/licenses/LICENSE +0 -0
- {atlas_ftag_tools-0.2.12.dist-info → atlas_ftag_tools-0.2.13.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,11 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: atlas-ftag-tools
|
3
|
-
Version: 0.2.
|
3
|
+
Version: 0.2.13
|
4
4
|
Summary: ATLAS Flavour Tagging Tools
|
5
5
|
Author: Sam Van Stroud, Philipp Gadow
|
6
6
|
License: MIT
|
7
7
|
Project-URL: Homepage, https://github.com/umami-hep/atlas-ftag-tools/
|
8
|
-
Requires-Python: <3.12,>=3.
|
8
|
+
Requires-Python: <3.12,>=3.10
|
9
9
|
Description-Content-Type: text/markdown
|
10
10
|
License-File: LICENSE
|
11
11
|
Requires-Dist: h5py>=3.0
|
@@ -1,5 +1,5 @@
|
|
1
|
-
atlas_ftag_tools-0.2.
|
2
|
-
ftag/__init__.py,sha256=
|
1
|
+
atlas_ftag_tools-0.2.13.dist-info/licenses/LICENSE,sha256=R4o6bZfajQ1KxwcIeavTC00qYTdL33YGNe1hzfV53gM,11349
|
2
|
+
ftag/__init__.py,sha256=UdYmO_mROM7jvqpPUMbnaQxdCrlR8O0KLlhatyMnapw,748
|
3
3
|
ftag/cli_utils.py,sha256=w3TtQmUHSyAKChS3ewvOtcSDAUJAZGIIomaNi8f446U,298
|
4
4
|
ftag/cuts.py,sha256=9_ooLZHaO3SnIQBNxwbaPZn-qptGdKnB27FdKQGTiTY,2933
|
5
5
|
ftag/flavours.py,sha256=ShH4M2UjQZpZ_NlCctTm2q1tJbzYxjmGteioQ2GcqEU,114
|
@@ -13,7 +13,7 @@ ftag/region.py,sha256=ANv0dGI2W6NJqD9fp7EfqAUReH4FOjc1gwl_Qn8llcM,360
|
|
13
13
|
ftag/sample.py,sha256=3N0FrRcu9l1sX8ohuGOHuMYGD0See6gMO4--7NzR2tE,2538
|
14
14
|
ftag/track_selector.py,sha256=fJNk_kIBQriBqV4CPT_3ReJbOUnavDDzO-u3EQlRuyk,2654
|
15
15
|
ftag/transform.py,sha256=uEGGJSnqoKOzLYQv650XdK_kDNw4Aw-5dc60z9Dp_y0,3963
|
16
|
-
ftag/vds.py,sha256=
|
16
|
+
ftag/vds.py,sha256=l6b54naOK7z0gZjvvtIAQv2Ky4X1w1yLrisZZZYqvbY,11259
|
17
17
|
ftag/working_points.py,sha256=RJws2jPMEDQDspCbXUZBifS1CCBmlMJ5ax0eMyDzCRA,15949
|
18
18
|
ftag/hdf5/__init__.py,sha256=8yzVQITge-HKkBQQ60eJwWmWDycYZjgVs-qVg4ShVr0,385
|
19
19
|
ftag/hdf5/h5add_col.py,sha256=htS5wn4Tm4S3U6mrJ8s24VUnbI7o28Z6Ll-J_V68xTA,12558
|
@@ -25,8 +25,8 @@ ftag/hdf5/h5writer.py,sha256=SMurvZ8FPvqieZUaYRX2SBu-jIyZ6Fx8IasUrEOxIvM,7185
|
|
25
25
|
ftag/utils/__init__.py,sha256=U3YyLY77-FzxRUbudxciieDoy_mnLlY3OfBquA3PnTE,524
|
26
26
|
ftag/utils/logging.py,sha256=54NaQiC9Bh4vSznSqzoPfR-7tj1PXfmoH7yKgv_ZHZk,3192
|
27
27
|
ftag/utils/metrics.py,sha256=zQI4nPeRDSyzqKpdOPmu0GU560xSWoW1wgL13rrja-I,12664
|
28
|
-
atlas_ftag_tools-0.2.
|
29
|
-
atlas_ftag_tools-0.2.
|
30
|
-
atlas_ftag_tools-0.2.
|
31
|
-
atlas_ftag_tools-0.2.
|
32
|
-
atlas_ftag_tools-0.2.
|
28
|
+
atlas_ftag_tools-0.2.13.dist-info/METADATA,sha256=ZpQ5GggkLyizsv9uHEOvIlzRqPmC-4tNaoaMgV6unF4,2153
|
29
|
+
atlas_ftag_tools-0.2.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
30
|
+
atlas_ftag_tools-0.2.13.dist-info/entry_points.txt,sha256=acr7WwxMIJ3x2I7AheNxNnpWE7sS8XE9MA1eUJGcU5A,169
|
31
|
+
atlas_ftag_tools-0.2.13.dist-info/top_level.txt,sha256=qiYQuKcAvMim-31FwkT3MTQu7WQm0s58tPAia5KKWqs,5
|
32
|
+
atlas_ftag_tools-0.2.13.dist-info/RECORD,,
|
ftag/__init__.py
CHANGED
ftag/vds.py
CHANGED
@@ -8,114 +8,334 @@ import sys
|
|
8
8
|
from pathlib import Path
|
9
9
|
|
10
10
|
import h5py
|
11
|
+
import numpy as np
|
11
12
|
|
12
13
|
|
13
|
-
def parse_args(args):
|
14
|
+
def parse_args(args=None):
|
14
15
|
parser = argparse.ArgumentParser(
|
15
|
-
description="Create a lightweight wrapper
|
16
|
+
description="Create a lightweight HDF5 wrapper (virtual datasets + "
|
17
|
+
"summed cutBookkeeper counts) around a set of .h5 files"
|
18
|
+
)
|
19
|
+
parser.add_argument(
|
20
|
+
"pattern",
|
21
|
+
type=Path,
|
22
|
+
help="quotes-enclosed glob pattern of files to merge, "
|
23
|
+
"or a regex if --use_regex is given",
|
16
24
|
)
|
17
|
-
parser.add_argument("pattern", type=Path, help="quotes-enclosed glob pattern of files to merge")
|
18
25
|
parser.add_argument("output", type=Path, help="path to output virtual file")
|
19
|
-
parser.add_argument(
|
20
|
-
|
26
|
+
parser.add_argument(
|
27
|
+
"--use_regex",
|
28
|
+
action="store_true",
|
29
|
+
help="treat PATTERN as a regular expression instead of a glob",
|
30
|
+
)
|
31
|
+
parser.add_argument(
|
32
|
+
"--regex_path",
|
33
|
+
type=str,
|
34
|
+
required="--use_regex" in (args or sys.argv),
|
35
|
+
default=None,
|
36
|
+
help="directory whose entries the regex is applied to "
|
37
|
+
"(defaults to the current working directory)",
|
38
|
+
)
|
21
39
|
return parser.parse_args(args)
|
22
40
|
|
23
41
|
|
24
|
-
def get_virtual_layout(fnames: list[str], group: str):
|
25
|
-
|
42
|
+
def get_virtual_layout(fnames: list[str], group: str) -> h5py.VirtualLayout:
|
43
|
+
"""Concatenate group from multiple files into a single VirtualDataset.
|
44
|
+
|
45
|
+
Parameters
|
46
|
+
----------
|
47
|
+
fnames : list[str]
|
48
|
+
List with the file names
|
49
|
+
group : str
|
50
|
+
Name of the group that is concatenated
|
51
|
+
|
52
|
+
Returns
|
53
|
+
-------
|
54
|
+
h5py.VirtualLayout
|
55
|
+
Virtual layout of the new virtual dataset
|
56
|
+
"""
|
26
57
|
sources = []
|
27
58
|
total = 0
|
59
|
+
|
60
|
+
# Loop over the input files
|
28
61
|
for fname in fnames:
|
29
|
-
with h5py.File(fname) as f:
|
30
|
-
|
31
|
-
|
32
|
-
|
62
|
+
with h5py.File(fname, "r") as f:
|
63
|
+
# Get the file and append its length
|
64
|
+
vsrc = h5py.VirtualSource(f[group])
|
65
|
+
total += vsrc.shape[0]
|
66
|
+
sources.append(vsrc)
|
33
67
|
|
34
|
-
#
|
35
|
-
with h5py.File(fnames[0]) as f:
|
68
|
+
# Define the layout of the output vds
|
69
|
+
with h5py.File(fnames[0], "r") as f:
|
36
70
|
dtype = f[group].dtype
|
37
71
|
shape = f[group].shape
|
72
|
+
|
73
|
+
# Update the shape finalize the output layout
|
38
74
|
shape = (total,) + shape[1:]
|
39
75
|
layout = h5py.VirtualLayout(shape=shape, dtype=dtype)
|
40
76
|
|
41
|
-
#
|
77
|
+
# Fill the vds
|
42
78
|
idx = 0
|
43
|
-
for
|
44
|
-
length =
|
45
|
-
layout[idx : idx + length] =
|
79
|
+
for vsrc in sources:
|
80
|
+
length = vsrc.shape[0]
|
81
|
+
layout[idx : idx + length] = vsrc
|
46
82
|
idx += length
|
47
83
|
|
48
84
|
return layout
|
49
85
|
|
50
86
|
|
51
|
-
def glob_re(pattern, regex_path):
|
87
|
+
def glob_re(pattern: str | None, regex_path: str | None) -> list[str] | None:
|
88
|
+
"""Return list of filenames that match REGEX pattern inside regex_path.
|
89
|
+
|
90
|
+
Parameters
|
91
|
+
----------
|
92
|
+
pattern : str
|
93
|
+
Pattern for the input files
|
94
|
+
regex_path : str
|
95
|
+
Regex path for the input files
|
96
|
+
|
97
|
+
Returns
|
98
|
+
-------
|
99
|
+
list[str]
|
100
|
+
List of the file basenames that matched the regex pattern
|
101
|
+
"""
|
102
|
+
if pattern is None or regex_path is None:
|
103
|
+
return None
|
104
|
+
|
52
105
|
return list(filter(re.compile(pattern).match, os.listdir(regex_path)))
|
53
106
|
|
54
107
|
|
55
|
-
def regex_files_from_dir(
|
108
|
+
def regex_files_from_dir(
|
109
|
+
reg_matched_fnames: list[str] | None,
|
110
|
+
regex_path: str | None,
|
111
|
+
) -> list[str] | None:
|
112
|
+
"""Turn a list of basenames into full paths; dive into sub-dirs if needed.
|
113
|
+
|
114
|
+
Parameters
|
115
|
+
----------
|
116
|
+
reg_matched_fnames : list[str]
|
117
|
+
List of the regex matched file names
|
118
|
+
regex_path : str
|
119
|
+
Regex path for the input files
|
120
|
+
|
121
|
+
Returns
|
122
|
+
-------
|
123
|
+
list[str]
|
124
|
+
List of file paths (as strings) that matched the regex and any subsequent
|
125
|
+
globbing inside matched directories.
|
126
|
+
"""
|
127
|
+
if reg_matched_fnames is None or regex_path is None:
|
128
|
+
return None
|
129
|
+
|
56
130
|
parent_dir = regex_path or str(Path.cwd())
|
57
|
-
full_paths = [parent_dir
|
58
|
-
paths_to_glob = [
|
59
|
-
nested_fnames = [glob.glob(
|
131
|
+
full_paths = [Path(parent_dir) / fname for fname in reg_matched_fnames]
|
132
|
+
paths_to_glob = [str(fp / "*.h5") if fp.is_dir() else str(fp) for fp in full_paths]
|
133
|
+
nested_fnames = [glob.glob(p) for p in paths_to_glob]
|
60
134
|
return sum(nested_fnames, [])
|
61
135
|
|
62
136
|
|
137
|
+
def sum_counts_once(counts: np.ndarray) -> np.ndarray:
|
138
|
+
"""Reduce the arrays in the counts dataset for one file to a scalar via summation.
|
139
|
+
|
140
|
+
Parameters
|
141
|
+
----------
|
142
|
+
counts : np.ndarray
|
143
|
+
Array from the h5py dataset (counts) from the cutBookkeeper groups
|
144
|
+
|
145
|
+
Returns
|
146
|
+
-------
|
147
|
+
np.ndarray
|
148
|
+
Array with the summed variables for the file
|
149
|
+
"""
|
150
|
+
dtype = counts.dtype
|
151
|
+
summed = np.zeros((), dtype=dtype)
|
152
|
+
for field in dtype.names:
|
153
|
+
summed[field] = counts[field].sum()
|
154
|
+
return summed
|
155
|
+
|
156
|
+
|
157
|
+
def check_subgroups(fnames: list[str], group_name: str = "cutBookkeeper") -> list[str]:
|
158
|
+
"""Check which subgroups are available for the bookkeeper.
|
159
|
+
|
160
|
+
Find the intersection of sub-group names that have a 'counts' dataset
|
161
|
+
in every input file. (Using the intersection makes the script robust
|
162
|
+
even if a few files are missing a variation.)
|
163
|
+
|
164
|
+
Parameters
|
165
|
+
----------
|
166
|
+
fnames : list[str]
|
167
|
+
List of the input files
|
168
|
+
group_name : str, optional
|
169
|
+
Group name in the h5 files of the bookkeeper, by default "cutBookkeeper"
|
170
|
+
|
171
|
+
Returns
|
172
|
+
-------
|
173
|
+
set[str]
|
174
|
+
Returns the files with common sub-groups
|
175
|
+
|
176
|
+
Raises
|
177
|
+
------
|
178
|
+
KeyError
|
179
|
+
When a file does not have a bookkeeper
|
180
|
+
ValueError
|
181
|
+
When no common bookkeeper sub-groups were found
|
182
|
+
"""
|
183
|
+
common: set[str] | None = None
|
184
|
+
for fname in fnames:
|
185
|
+
with h5py.File(fname, "r") as f:
|
186
|
+
if group_name not in f:
|
187
|
+
raise KeyError(f"{fname} has no '{group_name}' group")
|
188
|
+
these = {
|
189
|
+
name
|
190
|
+
for name, item in f[group_name].items()
|
191
|
+
if isinstance(item, h5py.Group) and "counts" in item
|
192
|
+
}
|
193
|
+
common = these if common is None else common & these
|
194
|
+
if not common:
|
195
|
+
raise ValueError("No common cutBookkeeper sub-groups with 'counts' found")
|
196
|
+
return sorted(common)
|
197
|
+
|
198
|
+
|
199
|
+
def aggregate_cutbookkeeper(
|
200
|
+
fnames: list[str],
|
201
|
+
group_name: str = "cutBookkeeper",
|
202
|
+
) -> dict[str, np.ndarray] | None:
|
203
|
+
"""Aggregate the cutBookkeeper in the input files.
|
204
|
+
|
205
|
+
For every input file:
|
206
|
+
For every sub-group (nominal, sysUp, sysDown, …):
|
207
|
+
1. Sum the 4-entry record array inside each file into 1 record
|
208
|
+
1. Add those records from all files together into grand total
|
209
|
+
Returns a dict {subgroup_name: scalar-record-array}
|
210
|
+
|
211
|
+
Parameters
|
212
|
+
----------
|
213
|
+
fnames : list[str]
|
214
|
+
List of the input files
|
215
|
+
|
216
|
+
Returns
|
217
|
+
-------
|
218
|
+
dict[str, np.ndarray] | None
|
219
|
+
Dict with the accumulated cutBookkeeper groups. If the cut bookkeeper
|
220
|
+
is not in the files, return None.
|
221
|
+
"""
|
222
|
+
if any(group_name not in h5py.File(f, "r") for f in fnames):
|
223
|
+
return None
|
224
|
+
|
225
|
+
subgroups = check_subgroups(fnames, group_name=group_name)
|
226
|
+
|
227
|
+
# initialise an accumulator per subgroup (dtype taken from 1st file)
|
228
|
+
accum: dict[str, np.ndarray] = {}
|
229
|
+
with h5py.File(fnames[0], "r") as f0:
|
230
|
+
for sg in subgroups:
|
231
|
+
dtype = f0[f"{group_name}/{sg}/counts"].dtype
|
232
|
+
accum[sg] = np.zeros((), dtype=dtype)
|
233
|
+
|
234
|
+
# add each files contribution field-wise
|
235
|
+
for fname in fnames:
|
236
|
+
with h5py.File(fname, "r") as f:
|
237
|
+
for sg in subgroups:
|
238
|
+
per_file = sum_counts_once(f[f"{group_name}/{sg}/counts"][()])
|
239
|
+
for fld in accum[sg].dtype.names:
|
240
|
+
accum[sg][fld] += per_file[fld]
|
241
|
+
|
242
|
+
return accum
|
243
|
+
|
244
|
+
|
63
245
|
def create_virtual_file(
|
64
246
|
pattern: Path | str,
|
65
|
-
out_fname: Path | None = None,
|
247
|
+
out_fname: Path | str | None = None,
|
66
248
|
use_regex: bool = False,
|
67
249
|
regex_path: str | None = None,
|
68
250
|
overwrite: bool = False,
|
69
|
-
|
70
|
-
|
251
|
+
bookkeeper_name: str = "cutBookkeeper",
|
252
|
+
) -> Path:
|
253
|
+
"""Create the virtual dataset file for the given inputs.
|
254
|
+
|
255
|
+
Parameters
|
256
|
+
----------
|
257
|
+
pattern : Path | str
|
258
|
+
Pattern of the input files used. Wildcard is supported
|
259
|
+
out_fname : Path | str | None, optional
|
260
|
+
Output path to which the virtual dataset file is written. By default None
|
261
|
+
use_regex : bool, optional
|
262
|
+
If you want to use regex instead of glob, by default False
|
263
|
+
regex_path : str | None, optional
|
264
|
+
Regex logic used to define the input files, by default None
|
265
|
+
overwrite : bool, optional
|
266
|
+
Decide, if an existing output file is overwritten, by default False
|
267
|
+
bookkeeper_name : str, optional
|
268
|
+
Name of the cut bookkeeper in the h5 files.
|
269
|
+
|
270
|
+
Returns
|
271
|
+
-------
|
272
|
+
Path
|
273
|
+
Path object of the path to which the output file is written
|
274
|
+
|
275
|
+
Raises
|
276
|
+
------
|
277
|
+
FileNotFoundError
|
278
|
+
If not input files were found for the given pattern
|
279
|
+
ValueError
|
280
|
+
If no output file is given and the input comes from multiple directories
|
281
|
+
"""
|
282
|
+
# Get list of filenames
|
71
283
|
pattern_str = str(pattern)
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
284
|
+
|
285
|
+
# Use regex to find input files else use glob
|
286
|
+
if use_regex is True:
|
287
|
+
matched = glob_re(pattern_str, regex_path)
|
288
|
+
fnames = regex_files_from_dir(matched, regex_path)
|
76
289
|
else:
|
77
290
|
fnames = glob.glob(pattern_str)
|
291
|
+
|
292
|
+
# Throw error if no input files were found
|
78
293
|
if not fnames:
|
79
|
-
raise FileNotFoundError(f"No files matched pattern {pattern}")
|
80
|
-
print("Files to merge to vds: ", fnames)
|
294
|
+
raise FileNotFoundError(f"No files matched pattern {pattern!r}")
|
81
295
|
|
82
|
-
#
|
296
|
+
# Infer output path if not given
|
83
297
|
if out_fname is None:
|
84
|
-
|
298
|
+
if len({Path(f).parent for f in fnames}) != 1:
|
299
|
+
raise ValueError("Give --output when files reside in multiple dirs")
|
85
300
|
out_fname = Path(fnames[0]).parent / "vds" / "vds.h5"
|
86
301
|
else:
|
87
302
|
out_fname = Path(out_fname)
|
88
303
|
|
89
|
-
#
|
304
|
+
# If overwrite is not active and a file exists, stop here
|
90
305
|
if not overwrite and out_fname.is_file():
|
91
306
|
return out_fname
|
92
307
|
|
93
|
-
#
|
308
|
+
# Identify common groups across all files
|
94
309
|
common_groups: set[str] = set()
|
95
310
|
for fname in fnames:
|
96
|
-
with h5py.File(fname) as f:
|
311
|
+
with h5py.File(fname, "r") as f:
|
97
312
|
groups = set(f.keys())
|
98
|
-
common_groups = groups if not common_groups else common_groups
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
#
|
104
|
-
out_fname.parent.mkdir(exist_ok=True)
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
313
|
+
common_groups = groups if not common_groups else common_groups & groups
|
314
|
+
|
315
|
+
# Ditch the bookkeeper. We will process it separately
|
316
|
+
common_groups.discard("cutBookkeeper")
|
317
|
+
|
318
|
+
# Check that the directory of the output file exists
|
319
|
+
out_fname.parent.mkdir(parents=True, exist_ok=True)
|
320
|
+
|
321
|
+
# Build the output file
|
322
|
+
with h5py.File(out_fname, "w") as fout:
|
323
|
+
# Build "standard" groups
|
324
|
+
for gname in sorted(common_groups):
|
325
|
+
layout = get_virtual_layout(fnames, gname)
|
326
|
+
fout.create_virtual_dataset(gname, layout)
|
327
|
+
|
328
|
+
# Copy first-file attributes to VDS root object
|
329
|
+
with h5py.File(fnames[0], "r") as f0:
|
330
|
+
for k, v in f0[gname].attrs.items():
|
331
|
+
fout[gname].attrs[k] = v
|
332
|
+
|
333
|
+
# Build the cutBookkeeper
|
334
|
+
counts_total = aggregate_cutbookkeeper(fnames=fnames, group_name=bookkeeper_name)
|
335
|
+
if counts_total is not None:
|
336
|
+
for sg, record in counts_total.items():
|
337
|
+
grp = fout.require_group(f"{bookkeeper_name}/{sg}")
|
338
|
+
grp.create_dataset("counts", data=record, shape=(), dtype=record.dtype)
|
119
339
|
|
120
340
|
return out_fname
|
121
341
|
|
@@ -123,19 +343,20 @@ def create_virtual_file(
|
|
123
343
|
def main(args=None) -> None:
|
124
344
|
args = parse_args(args)
|
125
345
|
matching_mode = "Applying regex to" if args.use_regex else "Globbing"
|
126
|
-
print(f"{matching_mode} {args.pattern}...")
|
127
|
-
create_virtual_file(
|
128
|
-
args.pattern,
|
129
|
-
args.output,
|
346
|
+
print(f"{matching_mode} {args.pattern} ...")
|
347
|
+
out_path = create_virtual_file(
|
348
|
+
pattern=args.pattern,
|
349
|
+
out_fname=args.output,
|
130
350
|
use_regex=args.use_regex,
|
131
351
|
regex_path=args.regex_path,
|
132
352
|
overwrite=True,
|
133
353
|
)
|
134
|
-
|
354
|
+
|
355
|
+
with h5py.File(out_path, "r") as f:
|
135
356
|
key = next(iter(f.keys()))
|
136
|
-
|
137
|
-
|
138
|
-
print(f"Saved virtual file to {
|
357
|
+
print(f"Virtual dataset '{key}' has {len(f[key]):,} entries")
|
358
|
+
|
359
|
+
print(f"Saved virtual file to {out_path.resolve()}")
|
139
360
|
|
140
361
|
|
141
362
|
if __name__ == "__main__":
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|