essreduce 25.11.3__py3-none-any.whl → 25.11.5__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.
@@ -3,14 +3,14 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import dataclasses
6
7
  import hashlib
7
8
  import os
8
9
  from abc import ABC, abstractmethod
9
10
  from collections.abc import Mapping
10
- from dataclasses import dataclass
11
11
  from functools import cache
12
12
  from pathlib import Path
13
- from typing import Any
13
+ from typing import Any, Literal
14
14
 
15
15
  _LOCAL_CACHE_ENV_VAR = "SCIPP_DATA_DIR"
16
16
  _LOCAL_REGISTRY_ENV_VAR = "SCIPP_OVERRIDE_DATA_DIR"
@@ -28,8 +28,8 @@ def make_registry(
28
28
 
29
29
  By default, this function creates a :class:`PoochRegistry` to download files
30
30
  via HTTP from an online file store.
31
- This can be overridden by setting the environment variable `SCIPP_DATA_DIR` to a
32
- path on the local file system.
31
+ This can be overridden by setting the environment variable
32
+ ``SCIPP_OVERRIDE_DATA_DIR`` to a path on the local file system.
33
33
  In this case, a :class:`LocalRegistry` is returned.
34
34
 
35
35
  Files are specified as a dict using either the Pooch string format explicitly
@@ -40,7 +40,11 @@ def make_registry(
40
40
  ... "file1.dat": "md5:1234567890abcdef",
41
41
  ... "file2.csv": Entry(alg="md5", chk="abcdef123456789"),
42
42
  ... "folder/nested.dat": "blake2b:1234567890abcdef",
43
- ... "zipped.zip": Entry(alg="blake2b", chk="abcdef123456789", unzip=True),
43
+ ... "zipped.zip": Entry(
44
+ ... alg="blake2b",
45
+ ... chk="abcdef123456789",
46
+ ... extractor="unzip"
47
+ ... ),
44
48
  ... }
45
49
 
46
50
  In the example above, the specifications for ``file1.dat`` and ``file2.csv`` are
@@ -49,10 +53,11 @@ def make_registry(
49
53
  Paths like this must always use forward slashes (/) even on Windows.
50
54
 
51
55
  As shown above, it is possible to automatically unzip
52
- files by specifying ``unzip=True``.
56
+ files by specifying ``extractor="unzip"``.
53
57
  When calling ``registry.get_path("zipped.zip")`` the file will be unzipped and
54
58
  a path to the content is returned.
55
- This expects that there is only a single file in the zip archive.
59
+ Similarly, ``extractor="untar"`` specifies that a file needs to be untarred
60
+ (and possibly un-gzipped).
56
61
 
57
62
  The complete path to the source file is constructed as follows:
58
63
 
@@ -111,7 +116,7 @@ def _check_local_override_path(override: str) -> Path:
111
116
  return path
112
117
 
113
118
 
114
- @dataclass(frozen=True, slots=True)
119
+ @dataclasses.dataclass(frozen=True, slots=True)
115
120
  class Entry:
116
121
  """An entry in a registry."""
117
122
 
@@ -119,9 +124,18 @@ class Entry:
119
124
  """Checksum."""
120
125
  alg: str
121
126
  """Checksum algorithm."""
122
- unzip: bool = False
127
+ extractor: Literal["unzip", "untar"] | None = None
128
+ """Processor to extract file contents."""
129
+
130
+ unzip: dataclasses.InitVar[bool] = False
123
131
  """Whether to unzip the file."""
124
132
 
133
+ def __post_init__(self, unzip: bool) -> None:
134
+ if self.extractor is not None and unzip:
135
+ raise TypeError("Set either the 'unzip' argument or 'extractor', not both.")
136
+ if self.extractor is None and unzip:
137
+ super().__setattr__("extractor", "unzip")
138
+
125
139
  @classmethod
126
140
  def from_pooch_string(cls, pooch_string: str) -> Entry:
127
141
  alg, chk = pooch_string.split(":")
@@ -132,7 +146,7 @@ class Registry(ABC):
132
146
  def __init__(self, files: Mapping[str, str | Entry]) -> None:
133
147
  self._files = _to_file_entries(files)
134
148
 
135
- @abstractmethod
149
+ @cache # noqa: B019
136
150
  def get_path(self, name: str) -> Path:
137
151
  """Get the path to a file in the registry.
138
152
 
@@ -154,9 +168,60 @@ class Registry(ABC):
154
168
  :
155
169
  The Path to the file.
156
170
  """
171
+ return Path(
172
+ _expect_single(
173
+ self._fetch(name, extractor=self._extractor_processor(name)),
174
+ name,
175
+ )
176
+ )
157
177
 
158
- def _needs_unzip(self, name: str) -> bool:
159
- return self._files[name].unzip
178
+ @cache # noqa: B019
179
+ def get_paths(self, name: str) -> list[Path]:
180
+ """Get the paths to unpacked files from the registry.
181
+
182
+ This method downloads the given file, extracts its contents, and returns
183
+ the paths to all extracted contents.
184
+ Unlike :meth:`get_path`, this method requires an extractor processor
185
+ (unzip or untar).
186
+
187
+ Depending on the implementation, the file is downloaded if necessary.
188
+
189
+ Note that implementations are allowed to cache return values of this method
190
+ to avoid recomputing potentially expensive checksums.
191
+ This usually means that the ``Registry`` object itself gets stored until the
192
+ Python interpreter shuts down.
193
+ However, registries are small and do not own resources.
194
+
195
+ Parameters
196
+ ----------
197
+ name:
198
+ Name of the zipped or tarred file to get the path for.
199
+
200
+ Returns
201
+ -------
202
+ :
203
+ The Paths to the files.
204
+ """
205
+ if (extractor := self._extractor_processor(name)) is None:
206
+ raise ValueError(f"File '{name}' is not zipped or tarred.")
207
+ return [Path(path) for path in self._fetch(name, extractor=extractor)]
208
+
209
+ def _extractor_processor_type(self, name: str) -> Any:
210
+ match self._files[name].extractor:
211
+ case "unzip":
212
+ return _pooch_unzip_processor_class()
213
+ case "untar":
214
+ return _pooch_untar_processor_class()
215
+ case None:
216
+ return None
217
+
218
+ @abstractmethod
219
+ def _extractor_processor(self, name: str) -> Any:
220
+ """Return an instance of a processor for the given file."""
221
+
222
+ @abstractmethod
223
+ def _fetch(self, name: str, extractor: Any) -> list[str] | str:
224
+ """Fetch the given file from the registry."""
160
225
 
161
226
 
162
227
  class PoochRegistry(Registry):
@@ -178,24 +243,15 @@ class PoochRegistry(Registry):
178
243
  )
179
244
  super().__init__(files)
180
245
 
181
- @cache # noqa: B019
182
- def get_path(self, name: str) -> Path:
183
- """Get the path to a file in the registry.
184
-
185
- Downloads the file if necessary.
186
- """
187
- if self._needs_unzip(name):
188
- paths: list[str] = self._registry.fetch( # type: ignore[assignment]
189
- name, processor=self._unzip_processor
190
- )
191
- return Path(_expect_single_unzipped(paths, name))
192
- return Path(self._registry.fetch(name))
246
+ def _fetch(self, name: str, extractor: Any) -> list[str] | str:
247
+ return self._registry.fetch(name, processor=extractor)
193
248
 
194
- @property
195
- def _unzip_processor(self) -> Any:
249
+ def _extractor_processor(self, name: str) -> Any:
196
250
  # Create a new processor on demand because reusing the same processor would
197
251
  # reuse the same output path for every file.
198
- return _import_pooch().Unzip()
252
+ if (cls := self._extractor_processor_type(name=name)) is not None:
253
+ return cls()
254
+ return None
199
255
 
200
256
 
201
257
  class LocalRegistry(Registry):
@@ -217,12 +273,11 @@ class LocalRegistry(Registry):
217
273
  base_url=base_url,
218
274
  retry_if_failed=retry_if_failed,
219
275
  )
220
- self._extract_dir = pooch_registry.path
276
+ self._extract_base_dir = pooch_registry.path
221
277
  self._source_path = source_path.resolve().joinpath(*prefix.split("/"), version)
222
278
  super().__init__(files)
223
279
 
224
- @cache # noqa: B019
225
- def get_path(self, name: str) -> Path:
280
+ def _fetch(self, name: str, extractor: Any) -> list[str] | str:
226
281
  """Get the path to a file in the registry."""
227
282
  try:
228
283
  entry = self._files[name]
@@ -238,24 +293,24 @@ class LocalRegistry(Registry):
238
293
 
239
294
  _check_hash(name, path, entry)
240
295
 
241
- if self._needs_unzip(name):
242
- return Path(
243
- _expect_single_unzipped(
244
- self._unzip_processor(os.fspath(path), "download", None), path
245
- )
246
- )
247
- return path
296
+ if extractor is not None:
297
+ return extractor(os.fspath(path), "download", None)
298
+ return os.fspath(path)
248
299
 
249
300
  def _local_path(self, name: str) -> Path:
250
301
  # Split on "/" because `name` is always a POSIX-style path, but the return
251
302
  # value is a system path, i.e., it can be a Windows-style path.
252
303
  return self._source_path.joinpath(*name.split("/"))
253
304
 
254
- @property
255
- def _unzip_processor(self) -> Any:
305
+ def _extract_dir(self, name: str) -> Path:
306
+ return self._extract_base_dir / name
307
+
308
+ def _extractor_processor(self, name: str) -> Any:
256
309
  # Create a new processor on demand because reusing the same processor would
257
310
  # reuse the same output path for every file.
258
- return _import_pooch().Unzip(self._extract_dir)
311
+ if (cls := self._extractor_processor_type(name=name)) is not None:
312
+ return cls(extract_dir=self._extract_dir(name))
313
+ return None
259
314
 
260
315
 
261
316
  def _import_pooch() -> Any:
@@ -288,19 +343,30 @@ def _create_pooch(
288
343
  )
289
344
 
290
345
 
291
- def _pooch_unzip_processor(extract_dir: Path) -> Any:
346
+ def _pooch_unzip_processor_class() -> Any:
292
347
  try:
293
348
  import pooch
294
349
  except ImportError:
295
350
  raise ImportError("You need to install Pooch to unzip files.") from None
296
351
 
297
- return pooch.processors.Unzip(extract_dir=os.fspath(extract_dir))
352
+ return pooch.processors.Unzip
353
+
354
+
355
+ def _pooch_untar_processor_class() -> Any:
356
+ try:
357
+ import pooch
358
+ except ImportError:
359
+ raise ImportError("You need to install Pooch to untar files.") from None
360
+
361
+ return pooch.processors.Untar
298
362
 
299
363
 
300
- def _expect_single_unzipped(paths: list[str], archive: str | os.PathLike) -> str:
364
+ def _expect_single(paths: list[str] | str, archive: str | os.PathLike) -> str:
365
+ if isinstance(paths, str):
366
+ return paths
301
367
  if len(paths) != 1:
302
368
  raise ValueError(
303
- f"Expected exactly one file to unzip, got {len(paths)} in "
369
+ f"Expected exactly one extracted file, got {len(paths)} in "
304
370
  f"'{os.fspath(archive)}'."
305
371
  )
306
372
  return paths[0]
ess/reduce/live/raw.py CHANGED
@@ -21,7 +21,7 @@ options:
21
21
  from __future__ import annotations
22
22
 
23
23
  from collections.abc import Callable, Sequence
24
- from math import ceil
24
+ from math import ceil, prod
25
25
  from typing import Literal, NewType
26
26
 
27
27
  import numpy as np
@@ -138,6 +138,139 @@ class Histogrammer:
138
138
  return self._hist(replicated, coords=self._coords) / self.replicas
139
139
 
140
140
 
141
+ class LogicalView:
142
+ """
143
+ Logical view for detector data.
144
+
145
+ Implements a view by applying a user-defined transform (e.g., fold or slice
146
+ operations) optionally followed by reduction (summing) over specified dimensions.
147
+ Transformation and reduction must be specified separately for `LogicalView` to
148
+ construct a mapping from output indices to input indices. So `transform` must not
149
+ perform any reductions.
150
+
151
+ This class provides both data transformation (__call__) and index mapping
152
+ (input_indices) using the same transform, ensuring consistency for ROI filtering.
153
+ """
154
+
155
+ def __init__(
156
+ self,
157
+ transform: Callable[[sc.DataArray], sc.DataArray],
158
+ reduction_dim: str | list[str] | None = None,
159
+ input_sizes: dict[str, int] | None = None,
160
+ ):
161
+ """
162
+ Create a logical view.
163
+
164
+ Parameters
165
+ ----------
166
+ transform:
167
+ Callable that transforms input data by reshaping or selecting pixels.
168
+ Examples:
169
+ - Fold: lambda da: da.fold('x', {'x': 512, 'x_bin': 2})
170
+ - Slice: lambda da: da['z', 0] (select front layer of volume)
171
+ - Combined: lambda da: da.fold('x', {'x': 4, 'z': 8})['z', 0]
172
+ reduction_dim:
173
+ Dimension(s) to sum over after applying transform. If None or empty,
174
+ no reduction is performed (pure transform).
175
+ Example: 'x_bin' or ['x_bin', 'y_bin']
176
+ input_sizes:
177
+ Dictionary defining the input dimension sizes.
178
+ Required for input_indices().
179
+ If not provided, input_indices() will raise an error.
180
+ When used with RollingDetectorView, this is automatically
181
+ inferred from detector_number.
182
+ """
183
+ self._transform = transform
184
+ if reduction_dim is None:
185
+ self._reduction_dim = []
186
+ elif isinstance(reduction_dim, str):
187
+ self._reduction_dim = [reduction_dim]
188
+ else:
189
+ self._reduction_dim = reduction_dim
190
+ self._input_sizes = input_sizes
191
+
192
+ @property
193
+ def replicas(self) -> int:
194
+ """Number of replicas. Always 1 for LogicalView."""
195
+ return 1
196
+
197
+ def __call__(self, da: sc.DataArray) -> sc.DataArray:
198
+ """
199
+ Apply transform and optionally sum over reduction dimensions.
200
+
201
+ Parameters
202
+ ----------
203
+ da:
204
+ Data to downsample.
205
+
206
+ Returns
207
+ -------
208
+ :
209
+ Transformed (and optionally reduced) data array.
210
+ """
211
+ transformed = self._transform(da)
212
+ if self._reduction_dim:
213
+ return transformed.sum(self._reduction_dim)
214
+ return transformed
215
+
216
+ def input_indices(self) -> sc.DataArray:
217
+ """
218
+ Create index mapping for ROI filtering.
219
+
220
+ Returns a DataArray mapping output pixels to input indices (as indices into
221
+ the flattened input array). If reduction dimensions are specified, returns
222
+ binned data where each output pixel contains a list of contributing input
223
+ indices. If no reduction, returns dense indices (1:1 mapping).
224
+
225
+ Returns
226
+ -------
227
+ :
228
+ DataArray mapping output pixels to input indices.
229
+
230
+ Raises
231
+ ------
232
+ ValueError:
233
+ If input_sizes was not provided during initialization.
234
+ """
235
+ if self._input_sizes is None:
236
+ raise ValueError(
237
+ "input_sizes is required for input_indices(). "
238
+ "Provide it during LogicalView initialization."
239
+ )
240
+
241
+ # Create sequential indices (0, 1, 2, ...) and fold to input shape
242
+ total_size = prod(self._input_sizes.values())
243
+ indices = sc.arange('_temp', total_size, dtype='int64', unit=None)
244
+ indices = indices.fold(dim='_temp', sizes=self._input_sizes)
245
+
246
+ # Apply transform to get the grouping structure
247
+ transformed = self._transform(sc.DataArray(data=indices))
248
+
249
+ if not self._reduction_dim:
250
+ # No reduction: 1:1 mapping, return dense indices
251
+ return sc.DataArray(data=transformed.data)
252
+
253
+ # Flatten reduction dimensions to a single dimension.
254
+ # First transpose to make reduction dims contiguous at the end.
255
+ output_dims = [d for d in transformed.dims if d not in self._reduction_dim]
256
+ transformed = transformed.transpose(output_dims + self._reduction_dim)
257
+ transformed = transformed.flatten(dims=self._reduction_dim, to='_reduction')
258
+
259
+ # Convert dense array to binned structure where each output pixel
260
+ # contains a bin with the indices of contributing input pixels.
261
+ bin_size = transformed.sizes['_reduction']
262
+ output_shape = [transformed.sizes[d] for d in output_dims]
263
+ data_flat = transformed.data.flatten(to='_flat')
264
+ begin = sc.array(
265
+ dims=output_dims,
266
+ values=np.arange(0, data_flat.size, bin_size, dtype=np.int64).reshape(
267
+ output_shape
268
+ ),
269
+ unit=None,
270
+ )
271
+ return sc.DataArray(sc.bins(begin=begin, dim='_flat', data=data_flat))
272
+
273
+
141
274
  class Detector:
142
275
  def __init__(self, detector_number: sc.Variable):
143
276
  self._data = sc.DataArray(
@@ -220,6 +353,48 @@ class RollingDetectorView(Detector):
220
353
  self._cumulative: sc.DataArray
221
354
  self.clear_counts()
222
355
 
356
+ @staticmethod
357
+ def with_logical_view(
358
+ *,
359
+ detector_number: sc.Variable,
360
+ window: int,
361
+ transform: Callable[[sc.DataArray], sc.DataArray],
362
+ reduction_dim: str | list[str] | None = None,
363
+ ) -> RollingDetectorView:
364
+ """
365
+ Create a RollingDetectorView with a LogicalView projection.
366
+
367
+ This factory method creates a LogicalView with input_sizes
368
+ automatically inferred from detector_number.sizes.
369
+
370
+ Parameters
371
+ ----------
372
+ detector_number:
373
+ Detector number for each pixel.
374
+ window:
375
+ Size of the rolling window.
376
+ transform:
377
+ Transform function for the LogicalView.
378
+ reduction_dim:
379
+ Reduction dimension(s) for the LogicalView. If None or empty,
380
+ no reduction is performed (pure transform).
381
+
382
+ Returns
383
+ -------
384
+ :
385
+ RollingDetectorView with LogicalView projection.
386
+ """
387
+ view = LogicalView(
388
+ transform=transform,
389
+ reduction_dim=reduction_dim,
390
+ input_sizes=dict(detector_number.sizes),
391
+ )
392
+ return RollingDetectorView(
393
+ detector_number=detector_number,
394
+ window=window,
395
+ projection=view,
396
+ )
397
+
223
398
  @property
224
399
  def max_window(self) -> int:
225
400
  return self._window
@@ -249,9 +424,11 @@ class RollingDetectorView(Detector):
249
424
  def make_roi_filter(self) -> roi.ROIFilter:
250
425
  """Return a ROI filter operating via the projection plane of the view."""
251
426
  norm = 1.0
252
- if isinstance(self._projection, Histogrammer):
427
+ # Use duck typing: check if projection has input_indices method
428
+ if hasattr(self._projection, 'input_indices'):
253
429
  indices = self._projection.input_indices()
254
- norm = self._projection.replicas
430
+ # Get replicas property if it exists (Histogrammer has it, default to 1.0)
431
+ norm = getattr(self._projection, 'replicas', 1.0)
255
432
  else:
256
433
  indices = sc.ones(sizes=self.data.sizes, dtype='int32', unit=None)
257
434
  indices = sc.cumsum(indices, mode='exclusive')
@@ -292,10 +469,11 @@ class RollingDetectorView(Detector):
292
469
  if not sc.identical(det_num, self.detector_number):
293
470
  raise sc.CoordError("Mismatching detector numbers in weights.")
294
471
  weights = weights.data
295
- if isinstance(self._projection, Histogrammer):
472
+ # Use duck typing: check for apply_full method (Histogrammer)
473
+ if hasattr(self._projection, 'apply_full'):
296
474
  xs = self._projection.apply_full(weights) # Use all replicas
297
475
  elif self._projection is not None:
298
- xs = self._projection(weights)
476
+ xs = self._projection(weights) # LogicalDownsampler or callable
299
477
  else:
300
478
  xs = weights.copy()
301
479
  nonempty = xs.values[xs.values > 0]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: essreduce
3
- Version: 25.11.3
3
+ Version: 25.11.5
4
4
  Summary: Common data reduction tools for the ESS facility
5
5
  Author: Scipp contributors
6
6
  License-Expression: BSD-3-Clause
@@ -8,9 +8,9 @@ ess/reduce/ui.py,sha256=zmorAbDwX1cU3ygDT--OP58o0qU7OBcmJz03jPeYSLA,10884
8
8
  ess/reduce/uncertainty.py,sha256=LR4O6ApB6Z-W9gC_XW0ajupl8yFG-du0eee1AX_R-gk,6990
9
9
  ess/reduce/workflow.py,sha256=738-lcdgsORYfQ4A0UTk2IgnbVxC3jBdpscpaOFIpdc,3114
10
10
  ess/reduce/data/__init__.py,sha256=uDtqkmKA_Zwtj6II25zntz9T812XhdCn3tktYev4uyY,486
11
- ess/reduce/data/_registry.py,sha256=50qY36y5gd2i3JP0Ks6bXApGcW6l70qA6riO0m9Bz4o,11475
11
+ ess/reduce/data/_registry.py,sha256=dJ4DymZsknFi3F6kHZmPTkEJ774tRXsZgD6R6v6sz0o,13987
12
12
  ess/reduce/live/__init__.py,sha256=jPQVhihRVNtEDrE20PoKkclKV2aBF1lS7cCHootgFgI,204
13
- ess/reduce/live/raw.py,sha256=d86s5fBjdf6tI_FWNMG4ZG67GCY2JADOobrinncNIjE,24367
13
+ ess/reduce/live/raw.py,sha256=CkPqp4VMNvj0IcFPp1J0n7sVt5PNKdIXnDlALCg9W_Q,31031
14
14
  ess/reduce/live/roi.py,sha256=Hs-pW98k41WU6Kl3UQ41kQawk80c2QNOQ_WNctLzDPE,3795
15
15
  ess/reduce/live/workflow.py,sha256=bsbwvTqPhRO6mC__3b7MgU7DWwAnOvGvG-t2n22EKq8,4285
16
16
  ess/reduce/nexus/__init__.py,sha256=xXc982vZqRba4jR4z5hA2iim17Z7niw4KlS1aLFbn1Q,1107
@@ -41,9 +41,9 @@ ess/reduce/widgets/_spinner.py,sha256=2VY4Fhfa7HMXox2O7UbofcdKsYG-AJGrsgGJB85nDX
41
41
  ess/reduce/widgets/_string_widget.py,sha256=iPAdfANyXHf-nkfhgkyH6gQDklia0LebLTmwi3m-iYQ,1482
42
42
  ess/reduce/widgets/_switchable_widget.py,sha256=fjKz99SKLhIF1BLgGVBSKKn3Lu_jYBwDYGeAjbJY3Q8,2390
43
43
  ess/reduce/widgets/_vector_widget.py,sha256=aTaBqCFHZQhrIoX6-sSqFWCPePEW8HQt5kUio8jP1t8,1203
44
- essreduce-25.11.3.dist-info/licenses/LICENSE,sha256=nVEiume4Qj6jMYfSRjHTM2jtJ4FGu0g-5Sdh7osfEYw,1553
45
- essreduce-25.11.3.dist-info/METADATA,sha256=RG8gLrZiQjprpTwz70dd-300cLF2jZeOEIigmwWFBOY,1937
46
- essreduce-25.11.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
47
- essreduce-25.11.3.dist-info/entry_points.txt,sha256=PMZOIYzCifHMTe4pK3HbhxUwxjFaZizYlLD0td4Isb0,66
48
- essreduce-25.11.3.dist-info/top_level.txt,sha256=0JxTCgMKPLKtp14wb1-RKisQPQWX7i96innZNvHBr-s,4
49
- essreduce-25.11.3.dist-info/RECORD,,
44
+ essreduce-25.11.5.dist-info/licenses/LICENSE,sha256=nVEiume4Qj6jMYfSRjHTM2jtJ4FGu0g-5Sdh7osfEYw,1553
45
+ essreduce-25.11.5.dist-info/METADATA,sha256=4FcweJeK7mrl5NtA1cvbTWyem_tw1I6XA_TFNQwL-qc,1937
46
+ essreduce-25.11.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
47
+ essreduce-25.11.5.dist-info/entry_points.txt,sha256=PMZOIYzCifHMTe4pK3HbhxUwxjFaZizYlLD0td4Isb0,66
48
+ essreduce-25.11.5.dist-info/top_level.txt,sha256=0JxTCgMKPLKtp14wb1-RKisQPQWX7i96innZNvHBr-s,4
49
+ essreduce-25.11.5.dist-info/RECORD,,