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.
@@ -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
- "get_caller_name",
21
- "find_outside_stacklevel",
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(cls: Any) -> str:
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
- cls : `type` or `object`
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(cls, types.ModuleType):
57
- return cls.__name__
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(cls, "__qualname__"):
60
- cls = type(cls)
61
- if hasattr(builtins, cls.__qualname__):
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 cls.__qualname__
71
+ return cls_.__qualname__
64
72
 
65
- real_name = cls.__module__ + "." + cls.__qualname__
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 cls:
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(module_name: str) -> int:
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
- module_name : `str`
204
- The name of the module to base the stack level calculation upon.
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
- this_module = "lsst.utils"
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
- if module_name != this_module and module.__name__.startswith(this_module):
221
- # Should not include this function unless explicitly requested.
222
- continue
223
- if not module.__name__.startswith(module_name):
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)