lsst-utils 25.2023.2800__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 -1
- lsst/utils/argparsing.py +79 -0
- lsst/utils/classes.py +26 -9
- lsst/utils/db_auth.py +339 -0
- lsst/utils/deprecated.py +4 -4
- lsst/utils/inheritDoc.py +30 -4
- lsst/utils/introspection.py +242 -23
- lsst/utils/iteration.py +188 -2
- lsst/utils/logging.py +78 -12
- lsst/utils/packages.py +211 -55
- lsst/utils/plotting/__init__.py +15 -0
- lsst/utils/plotting/figures.py +159 -0
- lsst/utils/plotting/limits.py +12 -1
- lsst/utils/plotting/publication_plots.py +184 -0
- lsst/utils/plotting/rubin.mplstyle +46 -0
- lsst/utils/tests.py +112 -57
- lsst/utils/threads.py +1 -1
- lsst/utils/timer.py +189 -45
- lsst/utils/usage.py +2 -2
- lsst/utils/version.py +1 -1
- lsst/utils/wrappers.py +65 -22
- {lsst_utils-25.2023.2800.dist-info → lsst_utils-29.2025.4800.dist-info}/METADATA +18 -12
- lsst_utils-29.2025.4800.dist-info/RECORD +32 -0
- {lsst_utils-25.2023.2800.dist-info → lsst_utils-29.2025.4800.dist-info}/WHEEL +1 -1
- lsst/utils/ellipsis.py +0 -66
- lsst/utils/get_caller_name.py +0 -47
- lsst_utils-25.2023.2800.dist-info/RECORD +0 -28
- {lsst_utils-25.2023.2800.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/COPYRIGHT +0 -0
- {lsst_utils-25.2023.2800.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/LICENSE +0 -0
- {lsst_utils-25.2023.2800.dist-info → lsst_utils-29.2025.4800.dist-info}/top_level.txt +0 -0
- {lsst_utils-25.2023.2800.dist-info → lsst_utils-29.2025.4800.dist-info}/zip-safe +0 -0
lsst/utils/introspection.py
CHANGED
|
@@ -14,27 +14,35 @@
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
__all__ = [
|
|
17
|
+
"find_outside_stacklevel",
|
|
18
|
+
"get_caller_name",
|
|
17
19
|
"get_class_of",
|
|
18
20
|
"get_full_type_name",
|
|
19
21
|
"get_instance_of",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
+
"take_object_census",
|
|
23
|
+
"trace_object_references",
|
|
22
24
|
]
|
|
23
25
|
|
|
24
26
|
import builtins
|
|
27
|
+
import collections
|
|
28
|
+
import gc
|
|
25
29
|
import inspect
|
|
30
|
+
import itertools
|
|
31
|
+
import sys
|
|
26
32
|
import types
|
|
33
|
+
import warnings
|
|
34
|
+
from collections.abc import Set
|
|
27
35
|
from typing import Any
|
|
28
36
|
|
|
29
37
|
from .doImport import doImport, doImportType
|
|
30
38
|
|
|
31
39
|
|
|
32
|
-
def get_full_type_name(
|
|
40
|
+
def get_full_type_name(cls_: Any) -> str:
|
|
33
41
|
"""Return full type name of the supplied entity.
|
|
34
42
|
|
|
35
43
|
Parameters
|
|
36
44
|
----------
|
|
37
|
-
|
|
45
|
+
cls_ : `type` or `object`
|
|
38
46
|
Entity from which to obtain the full name. Can be an instance
|
|
39
47
|
or a `type`.
|
|
40
48
|
|
|
@@ -53,16 +61,16 @@ def get_full_type_name(cls: Any) -> str:
|
|
|
53
61
|
"""
|
|
54
62
|
# If we have a module that needs to be converted directly
|
|
55
63
|
# to a name.
|
|
56
|
-
if isinstance(
|
|
57
|
-
return
|
|
64
|
+
if isinstance(cls_, types.ModuleType):
|
|
65
|
+
return cls_.__name__
|
|
58
66
|
# If we have an instance we need to convert to a type
|
|
59
|
-
if not hasattr(
|
|
60
|
-
|
|
61
|
-
if hasattr(builtins,
|
|
67
|
+
if not hasattr(cls_, "__qualname__"):
|
|
68
|
+
cls_ = type(cls_)
|
|
69
|
+
if hasattr(builtins, cls_.__qualname__):
|
|
62
70
|
# Special case builtins such as str and dict
|
|
63
|
-
return
|
|
71
|
+
return cls_.__qualname__
|
|
64
72
|
|
|
65
|
-
real_name =
|
|
73
|
+
real_name = cls_.__module__ + "." + cls_.__qualname__
|
|
66
74
|
|
|
67
75
|
# Remove components with leading underscores
|
|
68
76
|
cleaned_name = ".".join(c for c in real_name.split(".") if not c.startswith("_"))
|
|
@@ -77,7 +85,7 @@ def get_full_type_name(cls: Any) -> str:
|
|
|
77
85
|
|
|
78
86
|
# The thing we imported should match the class we started with
|
|
79
87
|
# despite the clean up. If it does not we return the real name
|
|
80
|
-
if test is not
|
|
88
|
+
if test is not cls_:
|
|
81
89
|
return real_name
|
|
82
90
|
|
|
83
91
|
return cleaned_name
|
|
@@ -126,7 +134,7 @@ def get_instance_of(typeOrName: type | str, *args: Any, **kwargs: Any) -> Any:
|
|
|
126
134
|
----------
|
|
127
135
|
typeOrName : `str` or Python class
|
|
128
136
|
A string describing the Python class to load or a Python type.
|
|
129
|
-
args : `tuple`
|
|
137
|
+
*args : `tuple`
|
|
130
138
|
Positional arguments to use pass to the object constructor.
|
|
131
139
|
**kwargs
|
|
132
140
|
Keyword arguments to pass to object constructor.
|
|
@@ -191,7 +199,12 @@ def get_caller_name(stacklevel: int = 2) -> str:
|
|
|
191
199
|
return ".".join(name)
|
|
192
200
|
|
|
193
201
|
|
|
194
|
-
def find_outside_stacklevel(
|
|
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:
|
|
195
208
|
"""Find the stacklevel for outside of the given module.
|
|
196
209
|
|
|
197
210
|
This can be used to determine the stacklevel parameter that should be
|
|
@@ -200,27 +213,88 @@ def find_outside_stacklevel(module_name: str) -> int:
|
|
|
200
213
|
|
|
201
214
|
Parameters
|
|
202
215
|
----------
|
|
203
|
-
|
|
204
|
-
The
|
|
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.
|
|
205
233
|
|
|
206
234
|
Returns
|
|
207
235
|
-------
|
|
208
236
|
stacklevel : `int`
|
|
209
237
|
The stacklevel to use matching the first stack frame outside of the
|
|
210
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
|
+
)
|
|
211
247
|
"""
|
|
212
|
-
|
|
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
|
+
|
|
213
263
|
stacklevel = -1
|
|
214
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
|
|
215
268
|
module = inspect.getmodule(s.frame)
|
|
216
|
-
# Stack frames sometimes hang around so explicitly delete.
|
|
217
|
-
del s
|
|
218
269
|
if module is None:
|
|
219
270
|
continue
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
):
|
|
224
298
|
# 0 will be this function.
|
|
225
299
|
# 1 will be the caller
|
|
226
300
|
# and so does not need adjustment.
|
|
@@ -231,3 +305,148 @@ def find_outside_stacklevel(module_name: str) -> int:
|
|
|
231
305
|
stacklevel = i
|
|
232
306
|
|
|
233
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
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
__all__ = ["chunk_iterable", "ensure_iterable", "isplit"]
|
|
17
|
+
__all__ = ["chunk_iterable", "ensure_iterable", "isplit", "sequence_to_string"]
|
|
18
18
|
|
|
19
19
|
import itertools
|
|
20
20
|
from collections.abc import Iterable, Iterator, Mapping
|
|
21
|
-
from typing import Any, TypeVar
|
|
21
|
+
from typing import Any, TypeGuard, TypeVar
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def chunk_iterable(data: Iterable[Any], chunk_size: int = 1_000) -> Iterator[tuple[Any, ...]]:
|
|
@@ -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)
|