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.
Files changed (35) hide show
  1. lsst/utils/__init__.py +0 -3
  2. lsst/utils/_packaging.py +2 -0
  3. lsst/utils/argparsing.py +79 -0
  4. lsst/utils/classes.py +27 -9
  5. lsst/utils/db_auth.py +339 -0
  6. lsst/utils/deprecated.py +10 -7
  7. lsst/utils/doImport.py +8 -9
  8. lsst/utils/inheritDoc.py +34 -6
  9. lsst/utils/introspection.py +285 -19
  10. lsst/utils/iteration.py +193 -7
  11. lsst/utils/logging.py +155 -105
  12. lsst/utils/packages.py +324 -82
  13. lsst/utils/plotting/__init__.py +15 -0
  14. lsst/utils/plotting/figures.py +159 -0
  15. lsst/utils/plotting/limits.py +155 -0
  16. lsst/utils/plotting/publication_plots.py +184 -0
  17. lsst/utils/plotting/rubin.mplstyle +46 -0
  18. lsst/utils/tests.py +231 -102
  19. lsst/utils/threads.py +9 -3
  20. lsst/utils/timer.py +207 -110
  21. lsst/utils/usage.py +6 -6
  22. lsst/utils/version.py +1 -1
  23. lsst/utils/wrappers.py +74 -29
  24. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/METADATA +19 -15
  25. lsst_utils-29.2025.4800.dist-info/RECORD +32 -0
  26. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/WHEEL +1 -1
  27. lsst/utils/_forwarded.py +0 -28
  28. lsst/utils/backtrace/__init__.py +0 -33
  29. lsst/utils/ellipsis.py +0 -54
  30. lsst/utils/get_caller_name.py +0 -45
  31. lsst_utils-25.2023.600.dist-info/RECORD +0 -29
  32. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/COPYRIGHT +0 -0
  33. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/LICENSE +0 -0
  34. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/top_level.txt +0 -0
  35. {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
- from typing import Callable, Type
16
+ import inspect
17
+ from collections.abc import Callable
15
18
 
16
19
 
17
- def inheritDoc(klass: Type) -> Callable:
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 tmpDecorator(method: Type) -> Callable:
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
- appendText = method.__doc__ or ""
43
- method.__doc__ = getattr(klass, methodName).__doc__ + appendText
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 tmpDecorator
74
+ return _tmpDecorator
@@ -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
- """Utilities relating to introspection in python."""
16
-
17
- __all__ = ["get_class_of", "get_full_type_name", "get_instance_of", "get_caller_name"]
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
- from typing import Any, Type, Union
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(cls: Any) -> str:
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
- cls : `type` or `object`
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(cls, types.ModuleType):
52
- return cls.__name__
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(cls, "__qualname__"):
55
- cls = type(cls)
56
- if hasattr(builtins, cls.__qualname__):
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 cls.__qualname__
71
+ return cls_.__qualname__
59
72
 
60
- real_name = cls.__module__ + "." + cls.__qualname__
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 cls:
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: Union[Type, str]) -> Type:
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
- cls = typeOrName
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: Union[Type, str], *args: Any, **kwargs: Any) -> Any:
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
- __all__ = ["chunk_iterable", "ensure_iterable", "isplit"]
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, Iterable, Iterator, Tuple, TypeVar
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[Tuple[Any, ...]]:
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 : `generator`
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)