lsst-utils 25.2023.600__py3-none-any.whl → 29.2025.4800__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.
- lsst/utils/__init__.py +0 -3
- lsst/utils/_packaging.py +2 -0
- lsst/utils/argparsing.py +79 -0
- lsst/utils/classes.py +27 -9
- lsst/utils/db_auth.py +339 -0
- lsst/utils/deprecated.py +10 -7
- lsst/utils/doImport.py +8 -9
- lsst/utils/inheritDoc.py +34 -6
- lsst/utils/introspection.py +285 -19
- lsst/utils/iteration.py +193 -7
- lsst/utils/logging.py +155 -105
- lsst/utils/packages.py +324 -82
- lsst/utils/plotting/__init__.py +15 -0
- lsst/utils/plotting/figures.py +159 -0
- lsst/utils/plotting/limits.py +155 -0
- lsst/utils/plotting/publication_plots.py +184 -0
- lsst/utils/plotting/rubin.mplstyle +46 -0
- lsst/utils/tests.py +231 -102
- lsst/utils/threads.py +9 -3
- lsst/utils/timer.py +207 -110
- lsst/utils/usage.py +6 -6
- lsst/utils/version.py +1 -1
- lsst/utils/wrappers.py +74 -29
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/METADATA +19 -15
- lsst_utils-29.2025.4800.dist-info/RECORD +32 -0
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/WHEEL +1 -1
- lsst/utils/_forwarded.py +0 -28
- lsst/utils/backtrace/__init__.py +0 -33
- lsst/utils/ellipsis.py +0 -54
- lsst/utils/get_caller_name.py +0 -45
- lsst_utils-25.2023.600.dist-info/RECORD +0 -29
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/COPYRIGHT +0 -0
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/LICENSE +0 -0
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/top_level.txt +0 -0
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/zip-safe +0 -0
lsst/utils/inheritDoc.py
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
# Use of this source code is governed by a 3-clause BSD-style
|
|
10
10
|
# license that can be found in the LICENSE file.
|
|
11
11
|
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
12
14
|
__all__ = ("inheritDoc",)
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
import inspect
|
|
17
|
+
from collections.abc import Callable
|
|
15
18
|
|
|
16
19
|
|
|
17
|
-
def inheritDoc(klass:
|
|
20
|
+
def inheritDoc(klass: type) -> Callable:
|
|
18
21
|
"""Extend existing documentation for a method that exists in another
|
|
19
22
|
class and extend it with any additional documentation defined.
|
|
20
23
|
|
|
@@ -32,15 +35,40 @@ def inheritDoc(klass: Type) -> Callable:
|
|
|
32
35
|
-------
|
|
33
36
|
decorator : callable
|
|
34
37
|
Intermediate decorator used in the documentation process.
|
|
38
|
+
|
|
39
|
+
Notes
|
|
40
|
+
-----
|
|
41
|
+
This method naively appends the doc string from the decorated method to the
|
|
42
|
+
doc string of the equivalent method from the given class. No attempt
|
|
43
|
+
is made to ensure that duplicated sections are merged together or
|
|
44
|
+
overwritten.
|
|
45
|
+
|
|
46
|
+
This decorator is not necessary to ensure that a parent doc string appears
|
|
47
|
+
in a subclass. Tools like ``pydoc`` and Sphinx handle that automatically.
|
|
48
|
+
This can, though, be used to ensure that a specific docstring from a
|
|
49
|
+
parent class appears if there is ambiguity from multiple inheritance.
|
|
35
50
|
"""
|
|
36
51
|
|
|
37
|
-
def
|
|
52
|
+
def _tmpDecorator(method: type) -> Callable:
|
|
38
53
|
"""Update the documentation from a class with the same method."""
|
|
39
54
|
methodName = method.__name__
|
|
40
55
|
if not hasattr(klass, methodName):
|
|
41
56
|
raise AttributeError(f"{klass} has no method named {methodName} to inherit from")
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
|
|
58
|
+
# To append reliably, the doc strings need to be cleaned to
|
|
59
|
+
# remove indents.
|
|
60
|
+
appendText = inspect.cleandoc(method.__doc__ or "")
|
|
61
|
+
parentText = inspect.cleandoc(getattr(klass, methodName).__doc__ or "")
|
|
62
|
+
|
|
63
|
+
if parentText:
|
|
64
|
+
if appendText:
|
|
65
|
+
# cleandoc() strips leading and trailing space so it is safe
|
|
66
|
+
# to add new lines.
|
|
67
|
+
parentText += "\n\n" + appendText
|
|
68
|
+
method.__doc__ = parentText
|
|
69
|
+
else:
|
|
70
|
+
# Do not update the doc string if there was no parent doc string.
|
|
71
|
+
pass
|
|
44
72
|
return method
|
|
45
73
|
|
|
46
|
-
return
|
|
74
|
+
return _tmpDecorator
|
lsst/utils/introspection.py
CHANGED
|
@@ -9,27 +9,40 @@
|
|
|
9
9
|
# Use of this source code is governed by a 3-clause BSD-style
|
|
10
10
|
# license that can be found in the LICENSE file.
|
|
11
11
|
#
|
|
12
|
+
"""Utilities relating to introspection in python."""
|
|
12
13
|
|
|
13
14
|
from __future__ import annotations
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
__all__ = [
|
|
17
|
+
"find_outside_stacklevel",
|
|
18
|
+
"get_caller_name",
|
|
19
|
+
"get_class_of",
|
|
20
|
+
"get_full_type_name",
|
|
21
|
+
"get_instance_of",
|
|
22
|
+
"take_object_census",
|
|
23
|
+
"trace_object_references",
|
|
24
|
+
]
|
|
18
25
|
|
|
19
26
|
import builtins
|
|
27
|
+
import collections
|
|
28
|
+
import gc
|
|
20
29
|
import inspect
|
|
30
|
+
import itertools
|
|
31
|
+
import sys
|
|
21
32
|
import types
|
|
22
|
-
|
|
33
|
+
import warnings
|
|
34
|
+
from collections.abc import Set
|
|
35
|
+
from typing import Any
|
|
23
36
|
|
|
24
37
|
from .doImport import doImport, doImportType
|
|
25
38
|
|
|
26
39
|
|
|
27
|
-
def get_full_type_name(
|
|
40
|
+
def get_full_type_name(cls_: Any) -> str:
|
|
28
41
|
"""Return full type name of the supplied entity.
|
|
29
42
|
|
|
30
43
|
Parameters
|
|
31
44
|
----------
|
|
32
|
-
|
|
45
|
+
cls_ : `type` or `object`
|
|
33
46
|
Entity from which to obtain the full name. Can be an instance
|
|
34
47
|
or a `type`.
|
|
35
48
|
|
|
@@ -48,16 +61,16 @@ def get_full_type_name(cls: Any) -> str:
|
|
|
48
61
|
"""
|
|
49
62
|
# If we have a module that needs to be converted directly
|
|
50
63
|
# to a name.
|
|
51
|
-
if isinstance(
|
|
52
|
-
return
|
|
64
|
+
if isinstance(cls_, types.ModuleType):
|
|
65
|
+
return cls_.__name__
|
|
53
66
|
# If we have an instance we need to convert to a type
|
|
54
|
-
if not hasattr(
|
|
55
|
-
|
|
56
|
-
if hasattr(builtins,
|
|
67
|
+
if not hasattr(cls_, "__qualname__"):
|
|
68
|
+
cls_ = type(cls_)
|
|
69
|
+
if hasattr(builtins, cls_.__qualname__):
|
|
57
70
|
# Special case builtins such as str and dict
|
|
58
|
-
return
|
|
71
|
+
return cls_.__qualname__
|
|
59
72
|
|
|
60
|
-
real_name =
|
|
73
|
+
real_name = cls_.__module__ + "." + cls_.__qualname__
|
|
61
74
|
|
|
62
75
|
# Remove components with leading underscores
|
|
63
76
|
cleaned_name = ".".join(c for c in real_name.split(".") if not c.startswith("_"))
|
|
@@ -72,13 +85,13 @@ def get_full_type_name(cls: Any) -> str:
|
|
|
72
85
|
|
|
73
86
|
# The thing we imported should match the class we started with
|
|
74
87
|
# despite the clean up. If it does not we return the real name
|
|
75
|
-
if test is not
|
|
88
|
+
if test is not cls_:
|
|
76
89
|
return real_name
|
|
77
90
|
|
|
78
91
|
return cleaned_name
|
|
79
92
|
|
|
80
93
|
|
|
81
|
-
def get_class_of(typeOrName:
|
|
94
|
+
def get_class_of(typeOrName: type | str | types.ModuleType) -> type:
|
|
82
95
|
"""Given the type name or a type, return the python type.
|
|
83
96
|
|
|
84
97
|
If a type name is given, an attempt will be made to import the type.
|
|
@@ -106,13 +119,13 @@ def get_class_of(typeOrName: Union[Type, str]) -> Type:
|
|
|
106
119
|
if isinstance(typeOrName, str):
|
|
107
120
|
cls = doImportType(typeOrName)
|
|
108
121
|
else:
|
|
109
|
-
|
|
110
|
-
if isinstance(cls, types.ModuleType):
|
|
122
|
+
if isinstance(typeOrName, types.ModuleType):
|
|
111
123
|
raise TypeError(f"Can not get class of module {get_full_type_name(typeOrName)}")
|
|
124
|
+
cls = typeOrName
|
|
112
125
|
return cls
|
|
113
126
|
|
|
114
127
|
|
|
115
|
-
def get_instance_of(typeOrName:
|
|
128
|
+
def get_instance_of(typeOrName: type | str, *args: Any, **kwargs: Any) -> Any:
|
|
116
129
|
"""Given the type name or a type, instantiate an object of that type.
|
|
117
130
|
|
|
118
131
|
If a type name is given, an attempt will be made to import the type.
|
|
@@ -121,7 +134,7 @@ def get_instance_of(typeOrName: Union[Type, str], *args: Any, **kwargs: Any) ->
|
|
|
121
134
|
----------
|
|
122
135
|
typeOrName : `str` or Python class
|
|
123
136
|
A string describing the Python class to load or a Python type.
|
|
124
|
-
args : `tuple`
|
|
137
|
+
*args : `tuple`
|
|
125
138
|
Positional arguments to use pass to the object constructor.
|
|
126
139
|
**kwargs
|
|
127
140
|
Keyword arguments to pass to object constructor.
|
|
@@ -184,3 +197,256 @@ def get_caller_name(stacklevel: int = 2) -> str:
|
|
|
184
197
|
if codename != "<module>": # top level usually
|
|
185
198
|
name.append(codename) # function or a method
|
|
186
199
|
return ".".join(name)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def find_outside_stacklevel(
|
|
203
|
+
*module_names: str,
|
|
204
|
+
allow_modules: Set[str] = frozenset(),
|
|
205
|
+
allow_methods: Set[str] = frozenset(),
|
|
206
|
+
stack_info: dict[str, Any] | None = None,
|
|
207
|
+
) -> int:
|
|
208
|
+
"""Find the stacklevel for outside of the given module.
|
|
209
|
+
|
|
210
|
+
This can be used to determine the stacklevel parameter that should be
|
|
211
|
+
passed to log messages or warnings in order to make them appear to
|
|
212
|
+
come from external code and not this package.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
*module_names : `str`
|
|
217
|
+
The names of the modules to skip when calculating the relevant stack
|
|
218
|
+
level.
|
|
219
|
+
allow_modules : `set` [`str`]
|
|
220
|
+
Names that should not be skipped when calculating the stacklevel.
|
|
221
|
+
If the module name starts with any of the names in this set the
|
|
222
|
+
corresponding stacklevel is used.
|
|
223
|
+
allow_methods : `set` [`str`]
|
|
224
|
+
Method names that are allowed to be treated as "outside". Fully
|
|
225
|
+
qualified method names must match exactly. Method names without
|
|
226
|
+
path components will match solely the method name itself. On Python
|
|
227
|
+
3.10 fully qualified names are not supported.
|
|
228
|
+
stack_info : `dict` or `None`, optional
|
|
229
|
+
If given, the dictionary is filled with information from
|
|
230
|
+
the relevant stack frame. This can be used to form your own warning
|
|
231
|
+
message without having to call :func:`inspect.stack` yourself with
|
|
232
|
+
the stack level.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
stacklevel : `int`
|
|
237
|
+
The stacklevel to use matching the first stack frame outside of the
|
|
238
|
+
given module.
|
|
239
|
+
|
|
240
|
+
Examples
|
|
241
|
+
--------
|
|
242
|
+
.. code-block:: python
|
|
243
|
+
|
|
244
|
+
warnings.warn(
|
|
245
|
+
"A warning message", stacklevel=find_outside_stacklevel("lsst.daf")
|
|
246
|
+
)
|
|
247
|
+
"""
|
|
248
|
+
if sys.version_info < (3, 11, 0):
|
|
249
|
+
short_names = {m for m in allow_methods if "." not in m}
|
|
250
|
+
if len(short_names) != len(allow_methods):
|
|
251
|
+
warnings.warn(
|
|
252
|
+
"Python 3.10 does not support fully qualified names in allow_methods. Dropping them.",
|
|
253
|
+
stacklevel=2,
|
|
254
|
+
)
|
|
255
|
+
allow_methods = short_names
|
|
256
|
+
|
|
257
|
+
need_full_names = any("." in m for m in allow_methods)
|
|
258
|
+
|
|
259
|
+
if stack_info is not None:
|
|
260
|
+
# Ensure it is empty when we start.
|
|
261
|
+
stack_info.clear()
|
|
262
|
+
|
|
263
|
+
stacklevel = -1
|
|
264
|
+
for i, s in enumerate(inspect.stack()):
|
|
265
|
+
# This function is never going to be the right answer.
|
|
266
|
+
if i == 0:
|
|
267
|
+
continue
|
|
268
|
+
module = inspect.getmodule(s.frame)
|
|
269
|
+
if module is None:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
if stack_info is not None:
|
|
273
|
+
stack_info["filename"] = s.filename
|
|
274
|
+
stack_info["lineno"] = s.lineno
|
|
275
|
+
stack_info["name"] = s.frame.f_code.co_name
|
|
276
|
+
|
|
277
|
+
if allow_methods:
|
|
278
|
+
code = s.frame.f_code
|
|
279
|
+
names = {code.co_name} # The name of the function itself.
|
|
280
|
+
if need_full_names:
|
|
281
|
+
full_name = f"{module.__name__}.{code.co_qualname}"
|
|
282
|
+
names.add(full_name)
|
|
283
|
+
if names & allow_methods:
|
|
284
|
+
# Method name is allowed so we stop here.
|
|
285
|
+
del s
|
|
286
|
+
stacklevel = i
|
|
287
|
+
break
|
|
288
|
+
|
|
289
|
+
# Stack frames sometimes hang around so explicitly delete.
|
|
290
|
+
del s
|
|
291
|
+
|
|
292
|
+
if (
|
|
293
|
+
# The module does not match any of the skipped names.
|
|
294
|
+
not any(module.__name__.startswith(name) for name in module_names)
|
|
295
|
+
# This match is explicitly allowed to be treated as "outside".
|
|
296
|
+
or any(module.__name__.startswith(name) for name in allow_modules)
|
|
297
|
+
):
|
|
298
|
+
# 0 will be this function.
|
|
299
|
+
# 1 will be the caller
|
|
300
|
+
# and so does not need adjustment.
|
|
301
|
+
stacklevel = i
|
|
302
|
+
break
|
|
303
|
+
else:
|
|
304
|
+
# The top can't be inside the module.
|
|
305
|
+
stacklevel = i
|
|
306
|
+
|
|
307
|
+
return stacklevel
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def take_object_census() -> collections.Counter[type]:
|
|
311
|
+
"""Count the number of existing objects, by type.
|
|
312
|
+
|
|
313
|
+
The census is returned as a `~collections.Counter` object. Expected usage
|
|
314
|
+
involves taking the difference with a different `~collections.Counter` and
|
|
315
|
+
examining any changes.
|
|
316
|
+
|
|
317
|
+
Returns
|
|
318
|
+
-------
|
|
319
|
+
census : `collections.Counter` [`type`]
|
|
320
|
+
The number of objects found of each type.
|
|
321
|
+
|
|
322
|
+
Notes
|
|
323
|
+
-----
|
|
324
|
+
This function counts *all* Python objects in memory. To count only
|
|
325
|
+
reachable objects, run `gc.collect` first.
|
|
326
|
+
"""
|
|
327
|
+
counts: collections.Counter[type] = collections.Counter()
|
|
328
|
+
for obj in gc.get_objects():
|
|
329
|
+
counts[type(obj)] += 1
|
|
330
|
+
return counts
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def trace_object_references(
|
|
334
|
+
target_class: type,
|
|
335
|
+
count: int = 5,
|
|
336
|
+
max_level: int = 10,
|
|
337
|
+
) -> tuple[list[list], bool]:
|
|
338
|
+
"""Find the chain(s) of references that make(s) objects of a class
|
|
339
|
+
reachable.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
target_class : `type`
|
|
344
|
+
The class whose objects need to be traced. This is typically a class
|
|
345
|
+
that is known to be leaking.
|
|
346
|
+
count : `int`, optional
|
|
347
|
+
The number of example objects to trace, if that many exist.
|
|
348
|
+
max_level : `int`, optional
|
|
349
|
+
The number of levels of references to trace. ``max_level=1`` means
|
|
350
|
+
finding only objects that directly refer to the examples.
|
|
351
|
+
|
|
352
|
+
Returns
|
|
353
|
+
-------
|
|
354
|
+
traces : `list` [`list`]
|
|
355
|
+
A sequence whose first element (index 0) is the set of example objects
|
|
356
|
+
of type ``target_class``, whose second element (index 1) is the set of
|
|
357
|
+
objects that refer to the examples, and so on. Contains at most
|
|
358
|
+
``max_level + 1`` elements.
|
|
359
|
+
trace_complete : `bool`
|
|
360
|
+
`True` if the trace for all objects terminated in at most
|
|
361
|
+
``max_level`` references, and `False` if more references exist.
|
|
362
|
+
|
|
363
|
+
Examples
|
|
364
|
+
--------
|
|
365
|
+
An example with two levels of references:
|
|
366
|
+
|
|
367
|
+
>>> from collections import namedtuple
|
|
368
|
+
>>> class Foo:
|
|
369
|
+
... pass
|
|
370
|
+
>>> holder = namedtuple("Holder", ["bar", "baz"])
|
|
371
|
+
>>> myholder = holder(bar={"object": Foo()}, baz=42)
|
|
372
|
+
>>> # In doctest, the trace extends up to the whole global dict
|
|
373
|
+
>>> # if you let it.
|
|
374
|
+
>>> trace_object_references(Foo, max_level=2) # doctest: +ELLIPSIS
|
|
375
|
+
... # doctest: +NORMALIZE_WHITESPACE
|
|
376
|
+
([[<lsst.utils.introspection.Foo object at ...>],
|
|
377
|
+
[{'object': <lsst.utils.introspection.Foo object at ...>}],
|
|
378
|
+
[Holder(bar={'object': <lsst.utils.introspection.Foo object at ...>},
|
|
379
|
+
baz=42)]], False)
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
def class_filter(o: Any) -> bool:
|
|
383
|
+
return isinstance(o, target_class)
|
|
384
|
+
|
|
385
|
+
# set() would be more appropriate, but objects may not be hashable.
|
|
386
|
+
objs = list(itertools.islice(filter(class_filter, gc.get_objects()), count))
|
|
387
|
+
if objs:
|
|
388
|
+
return _recurse_trace(objs, remaining=max_level)
|
|
389
|
+
else:
|
|
390
|
+
return [objs], True
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _recurse_trace(objs: list, remaining: int) -> tuple[list[list], bool]:
|
|
394
|
+
"""Recursively find references to a set of objects.
|
|
395
|
+
|
|
396
|
+
Parameters
|
|
397
|
+
----------
|
|
398
|
+
objs : `list`
|
|
399
|
+
The objects to trace.
|
|
400
|
+
remaining : `int`
|
|
401
|
+
The number of levels of references to trace.
|
|
402
|
+
|
|
403
|
+
Returns
|
|
404
|
+
-------
|
|
405
|
+
traces : `list` [`list`]
|
|
406
|
+
A sequence whose first element (index 0) is ``objs``, whose second
|
|
407
|
+
element (index 1) is the set of objects that refer to those, and so on.
|
|
408
|
+
Contains at most ``remaining + 1``.
|
|
409
|
+
trace_complete : `bool`
|
|
410
|
+
`True` if the trace for all objects terminated in at most
|
|
411
|
+
``remaining`` references, and `False` if more references exist.
|
|
412
|
+
"""
|
|
413
|
+
# Filter out our own references to the objects. This is needed to avoid
|
|
414
|
+
# circular recursion.
|
|
415
|
+
refs = _get_clean_refs(objs)
|
|
416
|
+
|
|
417
|
+
if refs:
|
|
418
|
+
if remaining > 1:
|
|
419
|
+
more_refs, complete = _recurse_trace(refs, remaining=remaining - 1)
|
|
420
|
+
more_refs.insert(0, objs)
|
|
421
|
+
return more_refs, complete
|
|
422
|
+
else:
|
|
423
|
+
more_refs = _get_clean_refs(refs)
|
|
424
|
+
return [objs, refs], (not more_refs)
|
|
425
|
+
else:
|
|
426
|
+
return [objs], True
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _get_clean_refs(objects: list) -> list:
|
|
430
|
+
"""Find references to a set of objects, excluding those needed to query
|
|
431
|
+
for references.
|
|
432
|
+
|
|
433
|
+
Parameters
|
|
434
|
+
----------
|
|
435
|
+
objects : `list`
|
|
436
|
+
The objects to find references for.
|
|
437
|
+
|
|
438
|
+
Returns
|
|
439
|
+
-------
|
|
440
|
+
refs : `list`
|
|
441
|
+
The objects that refer to the elements of ``objects``, not counting
|
|
442
|
+
``objects`` itself.
|
|
443
|
+
"""
|
|
444
|
+
# Pre-create the tuple so we know its id() and can filter it out.
|
|
445
|
+
# This allows for difference in behavior between python 3.12 and 3.13
|
|
446
|
+
# when calling gc.get_referrers with multiple arguments.
|
|
447
|
+
objects_tuple = tuple(objects)
|
|
448
|
+
refs = gc.get_referrers(*objects_tuple)
|
|
449
|
+
ids_to_drop = {id(objects), id(objects_tuple)}
|
|
450
|
+
refs = [ref for ref in refs if id(ref) not in ids_to_drop]
|
|
451
|
+
refs = [ref for ref in refs if not type(ref).__name__.endswith("_iterator")]
|
|
452
|
+
return refs
|
lsst/utils/iteration.py
CHANGED
|
@@ -10,18 +10,18 @@
|
|
|
10
10
|
# license that can be found in the LICENSE file.
|
|
11
11
|
#
|
|
12
12
|
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
13
|
"""Utilities relating to iterators."""
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__all__ = ["chunk_iterable", "ensure_iterable", "isplit", "sequence_to_string"]
|
|
18
18
|
|
|
19
19
|
import itertools
|
|
20
|
-
from collections.abc import Mapping
|
|
21
|
-
from typing import Any,
|
|
20
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
21
|
+
from typing import Any, TypeGuard, TypeVar
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def chunk_iterable(data: Iterable[Any], chunk_size: int = 1_000) -> Iterator[
|
|
24
|
+
def chunk_iterable(data: Iterable[Any], chunk_size: int = 1_000) -> Iterator[tuple[Any, ...]]:
|
|
25
25
|
"""Return smaller chunks of an iterable.
|
|
26
26
|
|
|
27
27
|
Parameters
|
|
@@ -63,7 +63,7 @@ def ensure_iterable(a: Any) -> Iterable[Any]:
|
|
|
63
63
|
|
|
64
64
|
Returns
|
|
65
65
|
-------
|
|
66
|
-
i : `
|
|
66
|
+
i : `~collections.abc.Iterable`
|
|
67
67
|
Iterable version of the input value.
|
|
68
68
|
"""
|
|
69
69
|
if isinstance(a, str):
|
|
@@ -107,3 +107,189 @@ def isplit(string: T, sep: T) -> Iterator[T]:
|
|
|
107
107
|
return
|
|
108
108
|
yield string[begin:end]
|
|
109
109
|
begin = end + 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_numeric_suffix(s: str) -> tuple[str, int | None]:
|
|
113
|
+
"""Extract the numeric suffix from a string.
|
|
114
|
+
|
|
115
|
+
Returns the prefix and the numeric suffix as an integer, if present.
|
|
116
|
+
|
|
117
|
+
For example:
|
|
118
|
+
'node1' -> ('node', 1)
|
|
119
|
+
'node' -> ('node', None)
|
|
120
|
+
'node123abc' -> ('node123abc', None)
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
s : str
|
|
125
|
+
The string to extract the numeric suffix from.
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
suffix : str
|
|
130
|
+
The numeric suffix of the string, if any.
|
|
131
|
+
"""
|
|
132
|
+
index = len(s)
|
|
133
|
+
while index > 0 and s[index - 1].isdigit():
|
|
134
|
+
index -= 1
|
|
135
|
+
prefix = s[:index]
|
|
136
|
+
suffix = s[index:]
|
|
137
|
+
if suffix:
|
|
138
|
+
return prefix, int(suffix)
|
|
139
|
+
else:
|
|
140
|
+
return s, None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _add_pair_to_name(val_name: list[str], val0: int | str, val1: int | str, stride: int = 1) -> None:
|
|
144
|
+
"""Format a pair of values (val0 and val1) and appends the result to
|
|
145
|
+
val_name.
|
|
146
|
+
|
|
147
|
+
This helper function takes the starting and ending values of a sequence
|
|
148
|
+
and formats them into a compact string representation, considering the
|
|
149
|
+
stride and whether the values are integers or strings with common
|
|
150
|
+
prefixes.
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
val_name : List[str]
|
|
155
|
+
The list to append the formatted string to.
|
|
156
|
+
val0 : [int, str]
|
|
157
|
+
The starting value of the sequence.
|
|
158
|
+
val1 : [int, str]
|
|
159
|
+
The ending value of the sequence.
|
|
160
|
+
stride : int, optional
|
|
161
|
+
The stride or difference between consecutive numbers in the
|
|
162
|
+
sequence. Defaults to 1.
|
|
163
|
+
"""
|
|
164
|
+
if isinstance(val0, str) and isinstance(val1, str):
|
|
165
|
+
prefix0, num_suffix0 = _extract_numeric_suffix(val0)
|
|
166
|
+
prefix1, num_suffix1 = _extract_numeric_suffix(val1)
|
|
167
|
+
if prefix0 == prefix1 and num_suffix0 is not None and num_suffix1 is not None:
|
|
168
|
+
if num_suffix0 == num_suffix1:
|
|
169
|
+
dvn = val0
|
|
170
|
+
else:
|
|
171
|
+
dvn = f"{val0}..{val1}"
|
|
172
|
+
if stride > 1:
|
|
173
|
+
dvn += f":{stride}"
|
|
174
|
+
else:
|
|
175
|
+
dvn = val0 if val0 == val1 else f"{val0}^{val1}"
|
|
176
|
+
else:
|
|
177
|
+
sval0 = str(val0)
|
|
178
|
+
sval1 = str(val1)
|
|
179
|
+
if val0 == val1:
|
|
180
|
+
dvn = sval0
|
|
181
|
+
elif isinstance(val0, int) and isinstance(val1, int):
|
|
182
|
+
if val1 == val0 + stride:
|
|
183
|
+
dvn = f"{sval0}^{sval1}"
|
|
184
|
+
else:
|
|
185
|
+
dvn = f"{sval0}..{sval1}"
|
|
186
|
+
if stride > 1:
|
|
187
|
+
dvn += f":{stride}"
|
|
188
|
+
else:
|
|
189
|
+
dvn = f"{sval0}^{sval1}"
|
|
190
|
+
val_name.append(dvn)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_list_of_ints(values: list[int | str]) -> TypeGuard[list[int]]:
|
|
194
|
+
"""Check if a list is composed entirely of integers.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
values : List[int, str]:
|
|
199
|
+
The list of values to check.
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
is_ints : bool
|
|
204
|
+
True if all values are integers, False otherwise.
|
|
205
|
+
"""
|
|
206
|
+
return all(isinstance(v, int) for v in values)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def sequence_to_string(values: list[int | str]) -> str:
|
|
210
|
+
"""Convert a list of integers or strings into a compact string
|
|
211
|
+
representation by merging consecutive values or sequences.
|
|
212
|
+
|
|
213
|
+
This function takes a list of integers or strings, sorts them, identifies
|
|
214
|
+
sequences where consecutive numbers differ by a consistent stride, or
|
|
215
|
+
strings with common prefixes, and returns a string that compactly
|
|
216
|
+
represents these sequences. Consecutive numbers are merged into ranges, and
|
|
217
|
+
strings with common prefixes are handled to produce a concise
|
|
218
|
+
representation.
|
|
219
|
+
|
|
220
|
+
>>> getNameOfSet([1, 2, 3, 5, 7, 8, 9])
|
|
221
|
+
'1..3^5^7..9'
|
|
222
|
+
>>> getNameOfSet(["node1", "node2", "node3"])
|
|
223
|
+
'node1..node3'
|
|
224
|
+
>>> getNameOfSet([10, 20, 30, 40])
|
|
225
|
+
'10..40:10'
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
values : list[int, str]:
|
|
230
|
+
A list of items to be compacted. Must all be of the same type.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
sequence_as_string : str
|
|
235
|
+
A compact string representation of the input list.
|
|
236
|
+
|
|
237
|
+
Notes
|
|
238
|
+
-----
|
|
239
|
+
- The function handles both integers and strings.
|
|
240
|
+
- For strings with common prefixes, only the differing suffixes are
|
|
241
|
+
considered.
|
|
242
|
+
- The stride is determined as the minimum difference between
|
|
243
|
+
consecutive numbers.
|
|
244
|
+
- Strings without common prefixes are listed individually.
|
|
245
|
+
"""
|
|
246
|
+
if not values:
|
|
247
|
+
return ""
|
|
248
|
+
|
|
249
|
+
values = sorted(set(values))
|
|
250
|
+
|
|
251
|
+
pure_ints_or_pure_strings = all(isinstance(item, int) for item in values) or all(
|
|
252
|
+
isinstance(item, str) for item in values
|
|
253
|
+
)
|
|
254
|
+
if not pure_ints_or_pure_strings:
|
|
255
|
+
types = {type(item) for item in values}
|
|
256
|
+
raise TypeError(f"All items in the input list must be either integers or strings, got {types}")
|
|
257
|
+
|
|
258
|
+
# Determine the stride for integers
|
|
259
|
+
stride = 1
|
|
260
|
+
if len(values) > 1 and _is_list_of_ints(values):
|
|
261
|
+
differences = [values[i + 1] - values[i] for i in range(len(values) - 1)]
|
|
262
|
+
stride = min(differences) if differences else 1
|
|
263
|
+
stride = max(stride, 1)
|
|
264
|
+
|
|
265
|
+
val_name: list[str] = []
|
|
266
|
+
val0 = values[0]
|
|
267
|
+
val1 = val0
|
|
268
|
+
for val in values[1:]:
|
|
269
|
+
if isinstance(val, int):
|
|
270
|
+
assert isinstance(val1, int)
|
|
271
|
+
if val == val1 + stride:
|
|
272
|
+
val1 = val
|
|
273
|
+
else:
|
|
274
|
+
_add_pair_to_name(val_name, val0, val1, stride)
|
|
275
|
+
val0 = val
|
|
276
|
+
val1 = val0
|
|
277
|
+
elif isinstance(val, str):
|
|
278
|
+
assert isinstance(val1, str)
|
|
279
|
+
prefix1, num_suffix1 = _extract_numeric_suffix(val1)
|
|
280
|
+
prefix, num_suffix = _extract_numeric_suffix(val)
|
|
281
|
+
if prefix1 == prefix and num_suffix1 is not None and num_suffix is not None:
|
|
282
|
+
if num_suffix == num_suffix1 + stride:
|
|
283
|
+
val1 = val
|
|
284
|
+
else:
|
|
285
|
+
_add_pair_to_name(val_name, val0, val1)
|
|
286
|
+
val0 = val
|
|
287
|
+
val1 = val0
|
|
288
|
+
else:
|
|
289
|
+
_add_pair_to_name(val_name, val0, val1)
|
|
290
|
+
val0 = val
|
|
291
|
+
val1 = val0
|
|
292
|
+
|
|
293
|
+
_add_pair_to_name(val_name, val0, val1, stride)
|
|
294
|
+
|
|
295
|
+
return "^".join(val_name)
|