python-leak-hunter 0.1.1__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.
@@ -0,0 +1,39 @@
1
+ """Find memory leaks in long-running Python services.
2
+
3
+ - `measure_growth` / `assert_no_leak`: which object types accumulate per unit of work.
4
+ - `why_alive`: what keeps them alive, down to "held by native code".
5
+ - `MemorySampler` / `diagnose`: is it really a leak, or fragmentation, or native memory.
6
+ """
7
+
8
+ from .growth import (
9
+ GrowthReport,
10
+ LeakError,
11
+ TypeGrowth,
12
+ assert_no_leak,
13
+ assert_no_leak_async,
14
+ count_objects,
15
+ measure_growth,
16
+ measure_growth_async,
17
+ )
18
+ from .memory import MemorySample, MemorySampler, diagnose, sample_memory
19
+ from .retention import RetentionPath, RetentionReport, why_alive
20
+
21
+ __all__ = [
22
+ "GrowthReport",
23
+ "LeakError",
24
+ "MemorySample",
25
+ "MemorySampler",
26
+ "RetentionPath",
27
+ "RetentionReport",
28
+ "TypeGrowth",
29
+ "assert_no_leak",
30
+ "assert_no_leak_async",
31
+ "count_objects",
32
+ "diagnose",
33
+ "measure_growth",
34
+ "measure_growth_async",
35
+ "sample_memory",
36
+ "why_alive",
37
+ ]
38
+
39
+ __version__ = "0.1.1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
leak_hunter/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ """Command line: `leak-hunter check` and `leak-hunter why`, aimed at a small harness function.
2
+
3
+ leak-hunter check harness:handle_one_request --iterations 30 --trace
4
+ leak-hunter why harness:handle_many_requests myapp.graph.Graph
5
+
6
+ `check` runs a zero-argument function repeatedly and exits 1 if objects accumulate.
7
+ `why` runs a setup function once, then explains what keeps instances of a type alive.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import gc
15
+ import importlib
16
+ import inspect
17
+ import os
18
+ import sys
19
+ from collections.abc import Callable, Sequence
20
+ from typing import Any
21
+
22
+ from .growth import awaited, measure_growth, type_name
23
+ from .retention import why_alive
24
+
25
+
26
+ def _load(spec: str) -> Callable[[], Any]:
27
+ module_name, _, attribute = spec.partition(":")
28
+ if not module_name or not attribute:
29
+ raise SystemExit(f"expected module:function, got {spec!r}")
30
+ sys.path.insert(0, os.getcwd())
31
+ target: Any = importlib.import_module(module_name)
32
+ for part in attribute.split("."):
33
+ target = getattr(target, part)
34
+ if not callable(target):
35
+ raise SystemExit(f"{spec} is not callable")
36
+ return target # type: ignore[no-any-return] # verified callable above
37
+
38
+
39
+ def main(argv: Sequence[str] | None = None) -> int:
40
+ parser = argparse.ArgumentParser(
41
+ prog="leak-hunter", description="Find memory leaks in long-running Python services."
42
+ )
43
+ commands = parser.add_subparsers(dest="command", required=True)
44
+
45
+ check = commands.add_parser("check", help="find object types that accumulate per call")
46
+ check.add_argument("work", help="module:function taking no arguments (sync or async)")
47
+ check.add_argument("--iterations", type=int, default=20)
48
+ check.add_argument("--warmup", type=int, default=3)
49
+ check.add_argument("--min-per-call", type=float, default=0.5)
50
+ check.add_argument("--include", action="append", help="only report types with this name prefix")
51
+ check.add_argument("--ignore", action="append", default=[], help="skip types with this name prefix")
52
+ check.add_argument("--trace", action="store_true", help="also report allocation sites (tracemalloc)")
53
+
54
+ why = commands.add_parser("why", help="explain what keeps objects of a type alive")
55
+ why.add_argument("setup", help="module:function to run first, e.g. one that handles 50 requests")
56
+ why.add_argument("type_name", help="type name as printed by `check`, e.g. myapp.Graph")
57
+ why.add_argument("--samples", type=int, default=5)
58
+
59
+ args = parser.parse_args(argv)
60
+ if args.command == "check":
61
+ report = measure_growth(
62
+ _load(args.work),
63
+ iterations=args.iterations,
64
+ warmup=args.warmup,
65
+ min_per_call=args.min_per_call,
66
+ include=args.include,
67
+ ignore=args.ignore,
68
+ trace_allocations=args.trace,
69
+ )
70
+ print(report.format())
71
+ return 1 if report.leaked else 0
72
+
73
+ setup = _load(args.setup)
74
+ # Only trace instances the setup created, so `why ... list` follows the leaked lists.
75
+ gc.collect()
76
+ existing = {id(obj) for obj in gc.get_objects() if type_name(obj) == args.type_name}
77
+ result = setup()
78
+ if inspect.isawaitable(result):
79
+ asyncio.run(awaited(result))
80
+ del result
81
+ print(why_alive(args.type_name, samples=args.samples, exclude=existing).format())
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())
leak_hunter/growth.py ADDED
@@ -0,0 +1,305 @@
1
+ """Find object types that keep accumulating when the same unit of work runs repeatedly.
2
+
3
+ The measurement runs the work in two equal windows after a warm-up. A type only counts as
4
+ leaking when its live count grows in *both* windows: one-off growth such as a cache filling
5
+ up or a lazy import shows up in one window, not both.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import gc
12
+ import inspect
13
+ import os
14
+ import tracemalloc
15
+ from collections import Counter
16
+ from collections.abc import Awaitable, Callable, Iterable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ Work = Callable[[], Any]
22
+ AsyncWork = Callable[[], Awaitable[Any]]
23
+
24
+ _PACKAGE_DIR = str(Path(__file__).parent)
25
+
26
+
27
+ def type_name(obj: object) -> str:
28
+ """Fully qualified type name, without the noise of `builtins.`."""
29
+ cls = type(obj)
30
+ module = cls.__module__
31
+ return cls.__qualname__ if module == "builtins" else f"{module}.{cls.__qualname__}"
32
+
33
+
34
+ def count_objects() -> Counter[str]:
35
+ """Count live objects tracked by the garbage collector, by type name.
36
+
37
+ Only container-like objects are tracked (class instances, dicts, lists, closures...).
38
+ Plain strings, ints and bytes are not, so they never appear here; `trace_allocations`
39
+ covers them by allocation site instead.
40
+ """
41
+ gc.collect()
42
+ counts: Counter[str] = Counter()
43
+ for obj in gc.get_objects():
44
+ counts[type_name(obj)] += 1
45
+ return counts
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class TypeGrowth:
50
+ type_name: str
51
+ first_window: int
52
+ second_window: int
53
+ iterations: int
54
+
55
+ @property
56
+ def per_call(self) -> float:
57
+ return self.second_window / self.iterations
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class AllocationGrowth:
62
+ location: str
63
+ size_bytes: int
64
+ blocks: int
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class GrowthReport:
69
+ iterations: int
70
+ growing: tuple[TypeGrowth, ...]
71
+ allocations: tuple[AllocationGrowth, ...] = ()
72
+
73
+ @property
74
+ def leaked(self) -> bool:
75
+ return bool(self.growing)
76
+
77
+ @property
78
+ def suspect(self) -> str | None:
79
+ """The type to trace next: the first one defined in a module, not a builtin like `list`."""
80
+ for growth in self.growing:
81
+ if "." in growth.type_name:
82
+ return growth.type_name
83
+ return self.growing[0].type_name if self.growing else None
84
+
85
+ def format(self, limit: int = 10) -> str:
86
+ lines = []
87
+ if not self.leaked:
88
+ lines.append(f"No object type grew steadily over 2 x {self.iterations} calls.")
89
+ else:
90
+ shown = self.growing[:limit]
91
+ width = max(len(g.type_name) for g in shown)
92
+ lines.append(f"Objects accumulating across calls (2 windows x {self.iterations} calls):")
93
+ for g in shown:
94
+ lines.append(
95
+ f" {g.type_name:<{width}} +{_number(g.per_call)} per call"
96
+ f" (window 1: {g.first_window:+d}, window 2: {g.second_window:+d})"
97
+ )
98
+ if len(self.growing) > limit:
99
+ lines.append(f" ... and {len(self.growing) - limit} more types")
100
+ if self.allocations:
101
+ # Covers what the object count cannot: bytes, str and other untracked objects.
102
+ lines.append(f"Allocation sites still holding memory from the last {self.iterations} calls:")
103
+ for a in self.allocations:
104
+ per_call = a.size_bytes / self.iterations / 1024
105
+ lines.append(f" {a.location} +{_number(per_call)} KiB per call ({a.blocks:+d} blocks)")
106
+ if self.suspect is not None:
107
+ lines.append("Next, see what keeps them alive:")
108
+ lines.append(f" leak-hunter why <module:function that runs many calls> {self.suspect}")
109
+ lines.append(f" or in Python: why_alive({self.suspect!r})")
110
+ return "\n".join(lines)
111
+
112
+
113
+ def _number(value: float) -> str:
114
+ return f"{value:.2f}".rstrip("0").rstrip(".")
115
+
116
+
117
+ def _short_path(path: str) -> str:
118
+ """Relative to the working directory when inside it, so report lines stay readable."""
119
+ relative = os.path.relpath(path)
120
+ return path if relative.startswith("..") else relative
121
+
122
+
123
+ class LeakError(AssertionError):
124
+ """Raised by `assert_no_leak` when objects keep accumulating."""
125
+
126
+ def __init__(self, report: GrowthReport) -> None:
127
+ super().__init__(report.format())
128
+ self.report = report
129
+
130
+
131
+ class _Windows:
132
+ """Takes the three object counts around the two measured windows."""
133
+
134
+ def __init__(
135
+ self,
136
+ *,
137
+ iterations: int,
138
+ min_per_call: float,
139
+ include: Iterable[str] | None,
140
+ ignore: Iterable[str],
141
+ trace_allocations: bool,
142
+ ) -> None:
143
+ if iterations < 1:
144
+ raise ValueError("iterations must be at least 1")
145
+ self._iterations = iterations
146
+ self._threshold = min_per_call * iterations
147
+ self._include = tuple(include) if include is not None else None
148
+ self._ignore = tuple(ignore)
149
+ self._trace = trace_allocations
150
+ self._started_tracing = False
151
+ self._before: Counter[str] = Counter()
152
+ self._middle: Counter[str] = Counter()
153
+ self._middle_snapshot: tracemalloc.Snapshot | None = None
154
+
155
+ def start(self) -> None:
156
+ if self._trace and not tracemalloc.is_tracing():
157
+ tracemalloc.start()
158
+ self._started_tracing = True
159
+ self._before = count_objects()
160
+
161
+ def middle(self) -> None:
162
+ self._middle = count_objects()
163
+ if self._trace:
164
+ self._middle_snapshot = tracemalloc.take_snapshot()
165
+
166
+ def finish(self) -> GrowthReport:
167
+ after = count_objects()
168
+ allocations = self._allocations() if self._trace else ()
169
+ growing = []
170
+ for name, count in after.items():
171
+ first = self._middle[name] - self._before[name]
172
+ second = count - self._middle[name]
173
+ if first >= self._threshold and second >= self._threshold and self._wanted(name):
174
+ growing.append(TypeGrowth(name, first, second, self._iterations))
175
+ growing.sort(key=lambda g: g.second_window, reverse=True)
176
+ return GrowthReport(self._iterations, tuple(growing), allocations)
177
+
178
+ def close(self) -> None:
179
+ if self._started_tracing:
180
+ tracemalloc.stop()
181
+ self._started_tracing = False
182
+
183
+ def _wanted(self, name: str) -> bool:
184
+ if any(name.startswith(prefix) for prefix in self._ignore):
185
+ return False
186
+ return self._include is None or any(name.startswith(prefix) for prefix in self._include)
187
+
188
+ def _allocations(self, limit: int = 5) -> tuple[AllocationGrowth, ...]:
189
+ if self._middle_snapshot is None:
190
+ return ()
191
+ exclude = [
192
+ tracemalloc.Filter(False, tracemalloc.__file__),
193
+ tracemalloc.Filter(False, f"{_PACKAGE_DIR}/*"),
194
+ ]
195
+ after = tracemalloc.take_snapshot().filter_traces(exclude)
196
+ before = self._middle_snapshot.filter_traces(exclude)
197
+ found = []
198
+ for stat in after.compare_to(before, "lineno"):
199
+ if stat.size_diff > 0 and stat.count_diff > 0:
200
+ frame = stat.traceback[0]
201
+ location = f"{_short_path(frame.filename)}:{frame.lineno}"
202
+ found.append(AllocationGrowth(location, stat.size_diff, stat.count_diff))
203
+ if len(found) == limit:
204
+ break
205
+ return tuple(found)
206
+
207
+
208
+ def measure_growth(
209
+ work: Work,
210
+ *,
211
+ iterations: int = 20,
212
+ warmup: int = 3,
213
+ min_per_call: float = 0.5,
214
+ include: Iterable[str] | None = None,
215
+ ignore: Iterable[str] = (),
216
+ trace_allocations: bool = False,
217
+ ) -> GrowthReport:
218
+ """Run `work` repeatedly and report object types that keep accumulating.
219
+
220
+ `work` takes no arguments and may be a plain function or return an awaitable (an
221
+ `async def` works); awaitables run on a private event loop. Inside an already running
222
+ loop, use `measure_growth_async` instead.
223
+
224
+ A type is reported when it gains at least `min_per_call` objects per call in both
225
+ windows. `include` / `ignore` filter by type-name prefix, e.g. `include=["myapp."]`.
226
+ """
227
+ windows = _Windows(
228
+ iterations=iterations,
229
+ min_per_call=min_per_call,
230
+ include=include,
231
+ ignore=ignore,
232
+ trace_allocations=trace_allocations,
233
+ )
234
+ loop = asyncio.new_event_loop()
235
+
236
+ def run(times: int) -> None:
237
+ for _ in range(times):
238
+ result = work()
239
+ if inspect.isawaitable(result):
240
+ loop.run_until_complete(awaited(result))
241
+
242
+ try:
243
+ run(warmup)
244
+ windows.start()
245
+ run(iterations)
246
+ windows.middle()
247
+ run(iterations)
248
+ return windows.finish()
249
+ finally:
250
+ windows.close()
251
+ loop.close()
252
+
253
+
254
+ async def measure_growth_async(
255
+ work: AsyncWork,
256
+ *,
257
+ iterations: int = 20,
258
+ warmup: int = 3,
259
+ min_per_call: float = 0.5,
260
+ include: Iterable[str] | None = None,
261
+ ignore: Iterable[str] = (),
262
+ trace_allocations: bool = False,
263
+ ) -> GrowthReport:
264
+ """`measure_growth` for code that already runs inside an event loop."""
265
+ windows = _Windows(
266
+ iterations=iterations,
267
+ min_per_call=min_per_call,
268
+ include=include,
269
+ ignore=ignore,
270
+ trace_allocations=trace_allocations,
271
+ )
272
+
273
+ async def run(times: int) -> None:
274
+ for _ in range(times):
275
+ await work()
276
+
277
+ try:
278
+ await run(warmup)
279
+ windows.start()
280
+ await run(iterations)
281
+ windows.middle()
282
+ await run(iterations)
283
+ return windows.finish()
284
+ finally:
285
+ windows.close()
286
+
287
+
288
+ def assert_no_leak(work: Work, **options: Any) -> GrowthReport:
289
+ """Fail with a readable report if `work` leaks objects. Takes `measure_growth`'s options."""
290
+ report = measure_growth(work, **options)
291
+ if report.leaked:
292
+ raise LeakError(report)
293
+ return report
294
+
295
+
296
+ async def assert_no_leak_async(work: AsyncWork, **options: Any) -> GrowthReport:
297
+ """`assert_no_leak` for code that already runs inside an event loop."""
298
+ report = await measure_growth_async(work, **options)
299
+ if report.leaked:
300
+ raise LeakError(report)
301
+ return report
302
+
303
+
304
+ async def awaited(awaitable: Awaitable[Any]) -> Any:
305
+ return await awaitable
leak_hunter/memory.py ADDED
@@ -0,0 +1,230 @@
1
+ """Sample process memory over time and tell real retention apart from allocator effects.
2
+
3
+ RSS alone cannot distinguish "objects are being kept" from "freed memory is not returned to
4
+ the OS" (fragmentation) or "a native library is allocating outside malloc". On glibc,
5
+ `mallinfo2()` splits malloc's heap into bytes in use and bytes free, which separates them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import gc
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable, Sequence
19
+ from dataclasses import asdict, dataclass
20
+ from types import TracebackType
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class MemorySample:
25
+ time: float
26
+ rss_bytes: int | None
27
+ heap_in_use_bytes: int | None
28
+ heap_free_bytes: int | None
29
+ gc_objects: int
30
+ open_fds: int | None
31
+
32
+ def to_json(self) -> str:
33
+ return json.dumps(asdict(self))
34
+
35
+
36
+ class _MallInfo2(ctypes.Structure):
37
+ _fields_ = [
38
+ (field, ctypes.c_size_t)
39
+ for field in (
40
+ "arena",
41
+ "ordblks",
42
+ "smblks",
43
+ "hblks",
44
+ "hblkhd",
45
+ "usmblks",
46
+ "fsmblks",
47
+ "uordblks",
48
+ "fordblks",
49
+ "keepcost",
50
+ )
51
+ ]
52
+
53
+
54
+ def _load_mallinfo2() -> Callable[[], _MallInfo2] | None:
55
+ if not sys.platform.startswith("linux"):
56
+ return None
57
+ try:
58
+ fn = ctypes.CDLL(None).mallinfo2
59
+ except (OSError, AttributeError): # musl, or glibc older than 2.33
60
+ return None
61
+ fn.restype = _MallInfo2
62
+ fn.argtypes = []
63
+ return fn
64
+
65
+
66
+ _mallinfo2 = _load_mallinfo2()
67
+
68
+
69
+ def _rss_bytes() -> int | None:
70
+ try:
71
+ import psutil # type: ignore[import-untyped, unused-ignore] # optional dependency
72
+
73
+ return int(psutil.Process().memory_info().rss)
74
+ except ImportError:
75
+ pass
76
+ try:
77
+ with open("/proc/self/statm") as statm:
78
+ return int(statm.read().split()[1]) * os.sysconf("SC_PAGE_SIZE")
79
+ except OSError:
80
+ pass
81
+ try: # macOS and other BSDs without psutil
82
+ out = subprocess.run(
83
+ ["ps", "-o", "rss=", "-p", str(os.getpid())], capture_output=True, text=True, check=True
84
+ )
85
+ return int(out.stdout.strip()) * 1024
86
+ except (OSError, ValueError, subprocess.CalledProcessError):
87
+ return None
88
+
89
+
90
+ def _open_fds() -> int | None:
91
+ for path in ("/proc/self/fd", "/dev/fd"):
92
+ try:
93
+ return len(os.listdir(path))
94
+ except OSError:
95
+ continue
96
+ return None
97
+
98
+
99
+ def sample_memory() -> MemorySample:
100
+ """One reading of this process's memory. Heap fields are None off glibc."""
101
+ heap_in_use = heap_free = None
102
+ if _mallinfo2 is not None:
103
+ info = _mallinfo2()
104
+ heap_in_use = info.uordblks + info.hblkhd
105
+ heap_free = info.fordblks
106
+ return MemorySample(
107
+ time=time.time(),
108
+ rss_bytes=_rss_bytes(),
109
+ heap_in_use_bytes=heap_in_use,
110
+ heap_free_bytes=heap_free,
111
+ gc_objects=len(gc.get_objects()),
112
+ open_fds=_open_fds(),
113
+ )
114
+
115
+
116
+ class MemorySampler:
117
+ """Samples memory on a background thread, e.g. once a minute in a running service.
118
+
119
+ Each sample goes to `on_sample` (log it as one JSON line with `sample.to_json()`) and is
120
+ kept in `samples` for `diagnose()`. Use as a context manager or call start()/stop().
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ interval: float = 60.0,
126
+ on_sample: Callable[[MemorySample], None] | None = None,
127
+ keep: int = 1_440,
128
+ ) -> None:
129
+ self.interval = interval
130
+ self.samples: list[MemorySample] = []
131
+ self._on_sample = on_sample
132
+ self._keep = keep
133
+ self._stop = threading.Event()
134
+ self._thread: threading.Thread | None = None
135
+
136
+ def start(self) -> MemorySampler:
137
+ if self._thread is None:
138
+ self._stop.clear()
139
+ self._thread = threading.Thread(target=self._run, name="leak-hunter-sampler", daemon=True)
140
+ self._thread.start()
141
+ return self
142
+
143
+ def stop(self) -> None:
144
+ self._stop.set()
145
+ if self._thread is not None:
146
+ self._thread.join()
147
+ self._thread = None
148
+
149
+ def __enter__(self) -> MemorySampler:
150
+ return self.start()
151
+
152
+ def __exit__(
153
+ self,
154
+ exc_type: type[BaseException] | None,
155
+ exc: BaseException | None,
156
+ tb: TracebackType | None,
157
+ ) -> None:
158
+ self.stop()
159
+
160
+ def _run(self) -> None:
161
+ while True:
162
+ sample = sample_memory()
163
+ self.samples.append(sample)
164
+ del self.samples[: -self._keep]
165
+ if self._on_sample is not None:
166
+ self._on_sample(sample)
167
+ if self._stop.wait(self.interval):
168
+ return
169
+
170
+
171
+ def slope_per_hour(samples: Sequence[MemorySample], field: str) -> float | None:
172
+ """Least-squares growth of a numeric sample field, in units per hour."""
173
+ points = [(s.time, getattr(s, field)) for s in samples if getattr(s, field) is not None]
174
+ if len(points) < 3:
175
+ return None
176
+ mean_t = sum(t for t, _ in points) / len(points)
177
+ mean_v = sum(v for _, v in points) / len(points)
178
+ spread = sum((t - mean_t) ** 2 for t, _ in points)
179
+ if spread == 0:
180
+ return None
181
+ return float(sum((t - mean_t) * (v - mean_v) for t, v in points) / spread * 3600)
182
+
183
+
184
+ def diagnose(samples: Sequence[MemorySample], min_mb_per_hour: float = 10.0) -> str:
185
+ """Classify memory growth from samples taken over a meaningful stretch of real traffic."""
186
+ mb = 1024 * 1024
187
+ rss = slope_per_hour(samples, "rss_bytes")
188
+ if rss is None:
189
+ return "Not enough samples: take at least 3, spread over time, with RSS available."
190
+ if rss < min_mb_per_hour * mb:
191
+ return f"RSS is flat ({rss / mb:+.1f} MB/h). No leak visible in this window."
192
+
193
+ lines = [f"RSS grows {rss / mb:+.1f} MB/h."]
194
+ in_use = slope_per_hour(samples, "heap_in_use_bytes")
195
+ free = slope_per_hour(samples, "heap_free_bytes")
196
+ objects = slope_per_hour(samples, "gc_objects")
197
+ fds = slope_per_hour(samples, "open_fds")
198
+
199
+ if in_use is not None and free is not None:
200
+ if in_use >= 0.5 * rss:
201
+ lines.append(
202
+ f"malloc heap in use grows {in_use / mb:+.1f} MB/h: memory is being retained, not "
203
+ "fragmented. Find what accumulates (measure_growth), then what holds it (why_alive)."
204
+ )
205
+ elif free >= 0.5 * rss:
206
+ lines.append(
207
+ f"malloc free space grows {free / mb:+.1f} MB/h while in-use is flat: fragmentation. "
208
+ "Try MALLOC_ARENA_MAX=2 or jemalloc before hunting objects."
209
+ )
210
+ else:
211
+ lines.append(
212
+ "malloc's heap explains little of it: memory is allocated outside malloc (mmap by a "
213
+ "native library, thread stacks). Profile natively with memray --native or jemalloc."
214
+ )
215
+ elif objects is not None and objects > 0:
216
+ lines.append(
217
+ f"The live Python object count grows {objects:+.0f}/h: objects are accumulating. "
218
+ "Run measure_growth on one unit of work."
219
+ )
220
+ else:
221
+ lines.append(
222
+ "The Python object count is flat, so the growth is native memory or fragmentation. "
223
+ "No mallinfo2 here (not glibc): use memray --native, or test under Linux."
224
+ )
225
+ if fds is not None and fds > 1:
226
+ lines.append(
227
+ f"Open file descriptors grow {fds:+.0f}/h: connections or files are not being closed "
228
+ "(clients created per request?)."
229
+ )
230
+ return "\n".join(lines)
leak_hunter/py.typed ADDED
File without changes
@@ -0,0 +1,20 @@
1
+ """pytest plugin: the `leak_check` fixture, registered automatically when the package is installed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import pytest
9
+
10
+ from .growth import GrowthReport, Work, assert_no_leak
11
+
12
+
13
+ @pytest.fixture
14
+ def leak_check() -> Callable[..., GrowthReport]:
15
+ """`leak_check(work, **options)` fails the test if `work` leaks. Same options as `measure_growth`."""
16
+
17
+ def check(work: Work, **options: Any) -> GrowthReport:
18
+ return assert_no_leak(work, **options)
19
+
20
+ return check
@@ -0,0 +1,284 @@
1
+ """Explain what keeps an object alive by walking its referrers back to a root.
2
+
3
+ A root is a module global or a variable in a running frame. When the walk instead reaches an
4
+ object that nothing in Python refers to, yet it is still alive, the reference comes from
5
+ somewhere the garbage collector cannot see: usually a C extension (gRPC, a database driver,
6
+ an HTTP client) that stored the object without exposing it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gc
12
+ import re
13
+ import sys
14
+ import types
15
+ from collections import Counter, deque
16
+ from collections.abc import Collection
17
+ from dataclasses import dataclass
18
+
19
+ from .growth import type_name
20
+
21
+ _THIS_FILE = __file__
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class RetentionPath:
26
+ """One chain of references, root first and the object being explained last."""
27
+
28
+ steps: tuple[str, ...]
29
+ root: str
30
+ outside_python: bool
31
+
32
+ def format(self) -> str:
33
+ lines = [f" {self.root}"]
34
+ lines += [f" -> {step}" for step in self.steps]
35
+ return "\n".join(lines)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class RetentionReport:
40
+ type_name: str
41
+ instances: int
42
+ paths: tuple[tuple[RetentionPath, int], ...]
43
+
44
+ @property
45
+ def outside_python(self) -> bool:
46
+ return any(path.outside_python for path, _ in self.paths)
47
+
48
+ def format(self) -> str:
49
+ if not self.paths:
50
+ return f"No live {self.type_name} objects found."
51
+ sampled = sum(count for _, count in self.paths)
52
+ lines = [f"{self.instances} live {self.type_name}; traced {sampled}:"]
53
+ for path, count in self.paths:
54
+ lines.append(f"{count} of {sampled} held by:")
55
+ lines.append(path.format())
56
+ return "\n".join(lines)
57
+
58
+
59
+ def why_alive(
60
+ target: object,
61
+ *,
62
+ samples: int = 5,
63
+ max_nodes: int = 5_000,
64
+ exclude: Collection[int] = (),
65
+ ) -> RetentionReport:
66
+ """Explain what keeps objects alive.
67
+
68
+ `target` is a type, a type name as printed by `measure_growth` (e.g. "myapp.Graph"), or
69
+ a single object. For a type, up to `samples` instances spread across the live ones are
70
+ traced, and identical chains are grouped, so one report covers the common case and the
71
+ exceptions. Only objects tracked by the garbage collector can be found by type.
72
+
73
+ `exclude` is a set of object ids to skip, e.g. the instances that existed before the
74
+ code under test ran. That matters for builtin types: tracing "list" otherwise samples
75
+ arbitrary lists from all over the process.
76
+ """
77
+ gc.collect() # unreachable cycles are garbage, not leaks: free them before looking
78
+ caller = sys._getframe(1)
79
+ skip_frames = {id(caller)} if not isinstance(target, (type, str)) else set()
80
+
81
+ if isinstance(target, (type, str)):
82
+ name = target if isinstance(target, str) else _qualified(target)
83
+ picked, total = _pick_instances(name, samples, exclude)
84
+ else:
85
+ name = type_name(target)
86
+ picked = [target]
87
+ total = 1
88
+
89
+ module_dicts = {id(vars(m)): m_name for m_name, m in list(sys.modules.items()) if m is not None}
90
+ frame_locals = _running_frame_locals(skip_frames)
91
+ del caller
92
+ # Group by the shape of the chain, so list[3] and list[7] count as the same cause.
93
+ first_of_shape: dict[tuple[str, tuple[str, ...]], RetentionPath] = {}
94
+ counts: Counter[tuple[str, tuple[str, ...]]] = Counter()
95
+ for obj in picked:
96
+ path = _trace(obj, {id(picked)}, skip_frames, module_dicts, frame_locals, max_nodes)
97
+ shape = (path.root, tuple(_INDEX.sub("[..]", step) for step in path.steps))
98
+ first_of_shape.setdefault(shape, path)
99
+ counts[shape] += 1
100
+ del picked
101
+ return RetentionReport(name, total, tuple((first_of_shape[s], n) for s, n in counts.most_common()))
102
+
103
+
104
+ _INDEX = re.compile(r"\[[^\]]*\]")
105
+
106
+
107
+ def _running_frame_locals(skip_frames: set[int]) -> dict[int, str]:
108
+ """Map id(value) to a description, for locals of functions still executing on any thread.
109
+
110
+ Since Python 3.11 the garbage collector does not report executing frames as referrers, so
111
+ without this an object held only by a running function's variable would look native.
112
+ """
113
+ found: dict[int, str] = {}
114
+ for top in sys._current_frames().values():
115
+ frame: types.FrameType | None = top
116
+ while frame is not None:
117
+ code = frame.f_code
118
+ if id(frame) not in skip_frames and code.co_filename != _THIS_FILE:
119
+ where = f"in {code.co_name}() at {code.co_filename}:{frame.f_lineno}"
120
+ for var, value in frame.f_locals.items():
121
+ found.setdefault(id(value), f"variable {var} {where}")
122
+ frame = frame.f_back
123
+ return found
124
+
125
+
126
+ def _qualified(cls: type) -> str:
127
+ return cls.__qualname__ if cls.__module__ == "builtins" else f"{cls.__module__}.{cls.__qualname__}"
128
+
129
+
130
+ def _pick_instances(name: str, samples: int, exclude: Collection[int]) -> tuple[list[object], int]:
131
+ """Up to `samples` evenly spaced instances of the named type, and how many are alive."""
132
+ found = [obj for obj in gc.get_objects() if type_name(obj) == name and id(obj) not in exclude]
133
+ total = len(found)
134
+ if total <= samples:
135
+ picked: list[object] = list(found)
136
+ else:
137
+ step = (total - 1) / (samples - 1) if samples > 1 else 0
138
+ picked = [found[round(i * step)] for i in range(samples)]
139
+ del found
140
+ return picked, total
141
+
142
+
143
+ def _trace(
144
+ target: object,
145
+ ignored: set[int],
146
+ skip_frames: set[int],
147
+ module_dicts: dict[int, str],
148
+ frame_locals: dict[int, str],
149
+ max_nodes: int,
150
+ ) -> RetentionPath:
151
+ """Breadth-first search from `target` up its referrers to the nearest root."""
152
+ nodes: dict[int, object] = {id(target): target}
153
+ # link[holder] = (held, how the holder refers to it), pointing back towards the target.
154
+ link: dict[int, tuple[int, str]] = {}
155
+ queue: deque[int] = deque([id(target)])
156
+ ours = {id(nodes), id(link), id(queue), *ignored}
157
+ dead_end: int | None = None
158
+
159
+ while queue and len(nodes) < max_nodes:
160
+ node_id = queue.popleft()
161
+ if node_id in frame_locals:
162
+ return _path(frame_locals[node_id], node_id, nodes, link, outside_python=False)
163
+ holders = [r for r in gc.get_referrers(nodes[node_id]) if not _is_ours(r, ours, skip_frames)]
164
+ ours.add(id(holders))
165
+ try:
166
+ if not holders and dead_end is None:
167
+ dead_end = node_id
168
+ for holder in holders:
169
+ holder_id = id(holder)
170
+ if holder_id in nodes:
171
+ continue
172
+ how = _how_held(holder, nodes[node_id])
173
+ if holder_id in module_dicts:
174
+ root = f"module {module_dicts[holder_id]}, global {how.strip('[]')}"
175
+ return _path(root, node_id, nodes, link, outside_python=False)
176
+ if isinstance(holder, types.FrameType):
177
+ code = holder.f_code
178
+ root = (
179
+ f"variable {how or '?'} in {code.co_name}() at {code.co_filename}:{holder.f_lineno}"
180
+ )
181
+ return _path(root, node_id, nodes, link, outside_python=False)
182
+ nodes[holder_id] = holder
183
+ link[holder_id] = (node_id, how)
184
+ queue.append(holder_id)
185
+ finally:
186
+ ours.discard(id(holders))
187
+ del holders
188
+
189
+ if dead_end is not None:
190
+ kind = type_name(nodes[dead_end])
191
+ root = (
192
+ f"outside Python: nothing the garbage collector can see refers to this {kind}, "
193
+ "yet it is alive (typically a C extension holding it)"
194
+ )
195
+ return _path(root, dead_end, nodes, link, outside_python=True)
196
+ return RetentionPath((type_name(target),), f"no root found within {max_nodes} objects", False)
197
+
198
+
199
+ def _path(
200
+ root: str,
201
+ start: int,
202
+ nodes: dict[int, object],
203
+ link: dict[int, tuple[int, str]],
204
+ *,
205
+ outside_python: bool,
206
+ ) -> RetentionPath:
207
+ """Render the chain from `start` down to the target, merging `obj.__dict__['x']` into `obj.x`."""
208
+ hops: list[tuple[str, str]] = []
209
+ current = start
210
+ while True:
211
+ obj = nodes[current]
212
+ nxt = link.get(current)
213
+ hops.append((_label(obj), nxt[1] if nxt else ""))
214
+ if nxt is None:
215
+ break
216
+ current = nxt[0]
217
+
218
+ steps: list[str] = []
219
+ i = 0
220
+ while i < len(hops):
221
+ label, how = hops[i]
222
+ if how == ".__dict__" and i + 1 < len(hops) and hops[i + 1][1].startswith("["):
223
+ key = hops[i + 1][1][1:-1].strip("'\"")
224
+ steps.append(f"{label}.{key}")
225
+ i += 2
226
+ continue
227
+ steps.append(f"{label}{how}")
228
+ i += 1
229
+ steps[-1] += " <- the object being explained"
230
+ return RetentionPath(tuple(steps), root, outside_python)
231
+
232
+
233
+ def _is_ours(obj: object, ours: set[int], skip_frames: set[int]) -> bool:
234
+ if id(obj) in ours or id(obj) in skip_frames:
235
+ return True
236
+ return isinstance(obj, types.FrameType) and obj.f_code.co_filename == _THIS_FILE
237
+
238
+
239
+ def _label(obj: object) -> str:
240
+ if isinstance(obj, type):
241
+ return f"class {obj.__qualname__}"
242
+ if isinstance(obj, types.MethodType):
243
+ return f"bound method {obj.__qualname__}"
244
+ if isinstance(obj, types.FunctionType):
245
+ return f"function {obj.__qualname__}"
246
+ return type_name(obj)
247
+
248
+
249
+ def _how_held(holder: object, held: object) -> str:
250
+ """How `holder` refers to `held`, e.g. "['key']", "[3]" or ".__dict__"."""
251
+ try:
252
+ if isinstance(holder, dict):
253
+ for key, value in holder.items():
254
+ if value is held:
255
+ return f"[{key!r}]"
256
+ if key is held:
257
+ return " (dict key)"
258
+ return ""
259
+ if isinstance(holder, (list, tuple)):
260
+ for index, value in enumerate(holder):
261
+ if value is held:
262
+ return f"[{index}]"
263
+ return ""
264
+ if isinstance(holder, (set, frozenset)):
265
+ return " (set member)"
266
+ if isinstance(holder, types.FrameType):
267
+ for key, value in holder.f_locals.items():
268
+ if value is held:
269
+ return key
270
+ return ""
271
+ if isinstance(holder, types.MethodType):
272
+ return ".__self__" if holder.__self__ is held else ".__func__"
273
+ if isinstance(holder, types.CellType):
274
+ return " (closure variable)"
275
+ if isinstance(held, dict) and (isinstance(holder, type) or getattr(holder, "__dict__", None) is held):
276
+ return ".__dict__"
277
+ attributes = getattr(holder, "__dict__", None)
278
+ if isinstance(attributes, dict):
279
+ for key, value in attributes.items():
280
+ if value is held:
281
+ return f".{key}"
282
+ except Exception: # an exotic holder (proxy, broken __getattr__) just gets no label
283
+ return ""
284
+ return ""
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-leak-hunter
3
+ Version: 0.1.1
4
+ Summary: Find memory leaks in long-running Python services: what accumulates, what keeps it alive, and a test that keeps it fixed. Ships with an agent skill for Claude Code and Codex.
5
+ Project-URL: Homepage, https://github.com/tariqamarneh/python-leak-hunter
6
+ Project-URL: The leak behind it, https://www.tariqamarneh.com/writing/grpc-contextvars-memory-leak
7
+ Author-email: Tariq Amarneh <tariqs.naser@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent skill,contextvars,debugging,gc,langgraph,memory leak,profiling,pytest
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Pytest
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Debuggers
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: psutil
21
+ Requires-Dist: psutil>=5.9; extra == 'psutil'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # python-leak-hunter
25
+
26
+ [![CI](https://github.com/tariqamarneh/python-leak-hunter/actions/workflows/ci.yml/badge.svg)](https://github.com/tariqamarneh/python-leak-hunter/actions/workflows/ci.yml)
27
+
28
+ Find memory leaks in long-running Python services: **what accumulates**, **what keeps it
29
+ alive** (down to "held by a C extension"), and **a test that keeps it fixed**. It ships with an
30
+ [agent skill](https://github.com/tariqamarneh/python-leak-hunter/blob/main/skills/python-leak-hunter/SKILL.md) that teaches Claude Code, Codex and other
31
+ coding agents the whole investigation, not just the tools.
32
+
33
+ ```text
34
+ $ leak-hunter why harness:many_requests app.RunTrace
35
+
36
+ 43 live app.RunTrace; traced 5:
37
+ 5 of 5 held by:
38
+ outside Python: nothing the garbage collector can see refers to this grpc._plugin_wrapping._Plugin, yet it is alive (typically a C extension holding it)
39
+ -> grpc._plugin_wrapping._Plugin._stored_ctx
40
+ -> _contextvars.Context
41
+ -> hamt
42
+ -> hamt_bitmap_node
43
+ -> app.RunTrace <- the object being explained
44
+ ```
45
+
46
+ That output is from [a real grpcio reproduction](https://github.com/tariqamarneh/python-leak-hunter/blob/main/examples/grpc_cycle.py): a gRPC channel built
47
+ inside a request captures the request's `contextvars` context. When that context can reach
48
+ the channel back, the cycle runs through C code and is never freed. In production this pinned
49
+ whole AI-agent graphs in memory, about 2 GB every 6 hours per worker.
50
+ [The full story](https://www.tariqamarneh.com/writing/grpc-contextvars-memory-leak).
51
+
52
+ ## Use it as an agent skill
53
+
54
+ The skill walks an agent through the method: confirm the growth is real, reproduce one unit
55
+ of work, find what accumulates, trace what holds it, fix it by pattern, then prove it and add a
56
+ regression test. It installs the package below as needed.
57
+
58
+ ```bash
59
+ git clone https://github.com/tariqamarneh/python-leak-hunter
60
+
61
+ # Claude Code: for you, or for one project
62
+ cp -r python-leak-hunter/skills/python-leak-hunter ~/.claude/skills/
63
+ cp -r python-leak-hunter/skills/python-leak-hunter your-project/.claude/skills/
64
+
65
+ # Codex: per repository
66
+ cp -r python-leak-hunter/skills/python-leak-hunter your-project/.agents/skills/
67
+ ```
68
+
69
+ Then ask: *"Memory in our worker keeps growing. Find the leak."*
70
+
71
+ ## Use it directly
72
+
73
+ ```bash
74
+ pip install python-leak-hunter
75
+ ```
76
+
77
+ No dependencies. Python 3.10+.
78
+
79
+ ### 1. What accumulates?
80
+
81
+ Write a function that runs one unit of work (a request, a message, an agent turn), then:
82
+
83
+ ```bash
84
+ leak-hunter check harness:one_request --include myapp. --iterations 30
85
+ ```
86
+
87
+ ```text
88
+ Objects accumulating across calls (2 windows x 30 calls):
89
+ myapp.RunTrace +1 per call (window 1: +30, window 2: +30)
90
+ Next: why_alive('myapp.RunTrace') shows what keeps them alive.
91
+ ```
92
+
93
+ The work runs in two windows after a warm-up, and only types that grow in **both** are
94
+ reported, so a cache filling up once is not a leak. Async functions work too. Add `--trace` to
95
+ also list allocation sites, which catches `bytes` and `str` that the object count cannot see.
96
+
97
+ ### 2. What keeps it alive?
98
+
99
+ ```bash
100
+ leak-hunter why harness:many_requests myapp.RunTrace
101
+ ```
102
+
103
+ It walks referrers back to a root and groups instances that share a chain:
104
+
105
+ - `module X, global 'name'`: a module-level cache or registry.
106
+ - `variable v in f() at file:line`: a long-running function's local.
107
+ - `outside Python: ...`: nothing in Python refers to that object, yet it is alive, so native
108
+ code holds it.
109
+
110
+ ### 3. Keep it fixed
111
+
112
+ The `leak_check` pytest fixture is registered when the package is installed:
113
+
114
+ ```python
115
+ def test_request_does_not_leak(leak_check):
116
+ leak_check(handle_one_request, include=["myapp."], iterations=30)
117
+ ```
118
+
119
+ ```python
120
+ from leak_hunter import assert_no_leak, assert_no_leak_async, measure_growth, why_alive
121
+
122
+ report = measure_growth(handle_one_request, include=["myapp."])
123
+ print(report.format())
124
+ print(why_alive("myapp.RunTrace").format())
125
+ ```
126
+
127
+ ### Is it even a leak?
128
+
129
+ Sample memory in the running service and let `diagnose` separate real retention from
130
+ fragmentation and native allocations:
131
+
132
+ ```python
133
+ import logging
134
+ from leak_hunter import MemorySampler, diagnose
135
+
136
+ sampler = MemorySampler(interval=60, on_sample=lambda s: logging.info("memory %s", s.to_json()))
137
+ sampler.start()
138
+ ...
139
+ print(diagnose(sampler.samples))
140
+ ```
141
+
142
+ ```text
143
+ RSS grows +312.4 MB/h.
144
+ malloc heap in use grows +305.1 MB/h: memory is being retained, not fragmented. Find what accumulates (measure_growth), then what holds it (why_alive).
145
+ ```
146
+
147
+ On glibc it reads `mallinfo2()` to split malloc's heap into bytes in use and bytes free. If
148
+ free space grows while in-use stays flat, the problem is fragmentation, and hunting objects
149
+ would be a waste of time.
150
+
151
+ ## Try the demo
152
+
153
+ ```bash
154
+ git clone https://github.com/tariqamarneh/python-leak-hunter && cd python-leak-hunter
155
+ uv run python examples/contextvars_leak/demo.py # no gRPC needed
156
+ uv run --with grpcio python examples/grpc_cycle.py # the real thing
157
+ ```
158
+
159
+ ## How it works
160
+
161
+ - **Growth:** counts live objects tracked by the garbage collector, by type, after a full
162
+ collection, around two equal windows of calls.
163
+ - **Retention:** breadth-first search over `gc.get_referrers` from an instance to the nearest
164
+ module global or running frame, skipping the tool's own references. Since Python 3.11,
165
+ executing frames are not reported as referrers, so their locals are checked separately; an
166
+ object held by a running function is not mistaken for native memory. A chain that ends at
167
+ an object with no visible referrers is reported as held outside Python.
168
+ - **Memory:** RSS from `psutil` when installed, otherwise `/proc` or `ps`; heap stats from
169
+ glibc's `mallinfo2()`; open file descriptors.
170
+
171
+ ## Limits
172
+
173
+ - Finding instances by type only works for objects the garbage collector tracks: class
174
+ instances and containers, not plain `str`, `bytes` or `int`. Use `--trace` for those.
175
+ - `why_alive` scans the heap at each step. Use it in a test or a staging process, not on a
176
+ hot path in production.
177
+ - The heap split needs glibc 2.33+ (most Linux images). Elsewhere, `diagnose` falls back to the
178
+ object count.
179
+
180
+ ## Development
181
+
182
+ ```bash
183
+ uv sync
184
+ uv run pytest
185
+ uv run ruff check . && uv run mypy
186
+ ```
187
+
188
+ MIT licensed. Built by [Tariq Amarneh](https://www.tariqamarneh.com).
@@ -0,0 +1,13 @@
1
+ leak_hunter/__init__.py,sha256=oHpF7bEtwQ-sWZe8qx3OqpmLE0mE9BAlRaJORntbFQM,980
2
+ leak_hunter/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ leak_hunter/cli.py,sha256=ldQwpFw0bseo7x-98hwUn25Wi_AV83KbfFb3_4MQipc,3438
4
+ leak_hunter/growth.py,sha256=r8CtNPyWj1lA9glUaFB0WJLNSrV4VEIvD4gYlGrCD5Y,10297
5
+ leak_hunter/memory.py,sha256=2sefNlUei3BgUGtBjMyu8OIOD3gXoe79-6LcJBR5ciM,7604
6
+ leak_hunter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ leak_hunter/pytest_plugin.py,sha256=3R6sYS8b4v9y86oPJmmCGcPalVhZQjjrdlKdT9QoQ98,569
8
+ leak_hunter/retention.py,sha256=bjIE2lE0CmgqSLQdFMHzVRdZx2Ye7-VpuSfzHdC17qQ,10893
9
+ python_leak_hunter-0.1.1.dist-info/METADATA,sha256=bVFQulTBooaTL8ke4-MKi1WcUIGe6cb_6QUSccRPHkY,7323
10
+ python_leak_hunter-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ python_leak_hunter-0.1.1.dist-info/entry_points.txt,sha256=y3fhMVxLtEeUQcSEqUKFQQILfall0Pdp7pVAbDRCXyI,105
12
+ python_leak_hunter-0.1.1.dist-info/licenses/LICENSE,sha256=E3TQTPEnoy1kVP3olEtqKd2YRGIp7TWk7xHf2fqMhW8,1070
13
+ python_leak_hunter-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ leak-hunter = leak_hunter.cli:main
3
+
4
+ [pytest11]
5
+ leak_hunter = leak_hunter.pytest_plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tariq Amarneh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.