cachescope 0.1.0a1__tar.gz

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,116 @@
1
+ """Reject stale, altered or mismatched distributions before promotion."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ from pathlib import Path
7
+ import tarfile
8
+ import zipfile
9
+ from urllib.error import HTTPError
10
+
11
+ import pytest
12
+
13
+ SCRIPT = Path(__file__).resolve().parents[1] / "scripts/release_artifacts.py"
14
+ spec = importlib.util.spec_from_file_location("release_artifacts", SCRIPT)
15
+ release = importlib.util.module_from_spec(spec)
16
+ spec.loader.exec_module(release)
17
+ VERSION = "0.1.0a1"
18
+
19
+
20
+ def artifacts(directory):
21
+ dist = directory / "dist"
22
+ dist.mkdir()
23
+ metadata = f"Metadata-Version: 2.4\nName: cachescope\nVersion: {VERSION}\n".encode()
24
+ wheel = dist / f"cachescope-{VERSION}-py3-none-any.whl"
25
+ with zipfile.ZipFile(wheel, "w") as archive:
26
+ archive.writestr(f"cachescope-{VERSION}.dist-info/METADATA", metadata)
27
+ with tarfile.open(dist / f"cachescope-{VERSION}.tar.gz", "w:gz") as archive:
28
+ member = tarfile.TarInfo(f"cachescope-{VERSION}/PKG-INFO")
29
+ member.size = len(metadata)
30
+ archive.addfile(member, io.BytesIO(metadata))
31
+ return release.make_manifest(directory, VERSION)
32
+
33
+
34
+ def test_matching_release_is_accepted(tmp_path):
35
+ manifest = artifacts(tmp_path)
36
+ release.verify_local(tmp_path, manifest)
37
+ payload = {
38
+ "info": {"name": "cachescope", "version": VERSION},
39
+ "urls": [{"filename": name, "digests": {"sha256": digest}} for name, digest in manifest["files"].items()],
40
+ }
41
+ release.verify_index(manifest, payload)
42
+
43
+
44
+ def test_stale_version_is_rejected(tmp_path):
45
+ artifacts(tmp_path)
46
+ with pytest.raises(ValueError, match="name/version"):
47
+ release.make_manifest(tmp_path, "0.1.0a2")
48
+
49
+
50
+ def test_extra_old_distribution_is_rejected(tmp_path):
51
+ artifacts(tmp_path)
52
+ (tmp_path / "dist/old.whl").write_bytes(b"old")
53
+ with pytest.raises(ValueError, match="exactly one"):
54
+ release.make_manifest(tmp_path, VERSION)
55
+
56
+
57
+ def test_artifact_modified_after_testing_is_rejected(tmp_path):
58
+ manifest = artifacts(tmp_path)
59
+ wheel = next((tmp_path / "dist").glob("*.whl"))
60
+ with wheel.open("ab") as stream:
61
+ stream.write(b"modified after test")
62
+ with pytest.raises(ValueError, match="changed"):
63
+ release.verify_local(tmp_path, manifest)
64
+
65
+
66
+ def test_index_cannot_substitute_different_artifacts(tmp_path):
67
+ manifest = artifacts(tmp_path)
68
+ payload = {"info": {"name": "cachescope", "version": VERSION}, "urls": []}
69
+ with pytest.raises(ValueError, match="Published artifacts"):
70
+ release.verify_index(manifest, payload)
71
+
72
+
73
+ def test_index_cannot_substitute_another_version(tmp_path):
74
+ manifest = artifacts(tmp_path)
75
+ payload = {"info": {"name": "cachescope", "version": "0.1.0a2"}, "urls": []}
76
+ with pytest.raises(ValueError, match="different project/version"):
77
+ release.verify_index(manifest, payload)
78
+
79
+
80
+ def test_index_propagation_is_retried_without_installing_other_versions(tmp_path, monkeypatch):
81
+ manifest = artifacts(tmp_path)
82
+ payload = {
83
+ "info": {"name": "cachescope", "version": VERSION},
84
+ "urls": [{"filename": name, "digests": {"sha256": digest}} for name, digest in manifest["files"].items()],
85
+ }
86
+ calls, delays = [], []
87
+
88
+ def fetch(request, timeout):
89
+ calls.append(request.full_url)
90
+ if len(calls) == 1:
91
+ raise HTTPError(request.full_url, 404, "not propagated", {}, None)
92
+ return io.StringIO(json.dumps(payload))
93
+
94
+ monkeypatch.setattr(release.urllib.request, "urlopen", fetch)
95
+ monkeypatch.setattr(release.time, "sleep", delays.append)
96
+ release.wait_for_index(manifest, "https://test.pypi.org", attempts=2)
97
+ assert calls == [f"https://test.pypi.org/pypi/cachescope/{VERSION}/json"] * 2
98
+ assert delays == [5]
99
+
100
+
101
+ def test_mismatched_published_hashes_fail_without_retrying(tmp_path, monkeypatch):
102
+ manifest = artifacts(tmp_path)
103
+ payload = {
104
+ "info": {"name": "cachescope", "version": VERSION},
105
+ "urls": [{"filename": name, "digests": {"sha256": "0" * 64}} for name in manifest["files"]],
106
+ }
107
+ calls = []
108
+
109
+ def fetch(request, timeout):
110
+ calls.append(request.full_url)
111
+ return io.StringIO(json.dumps(payload))
112
+
113
+ monkeypatch.setattr(release.urllib.request, "urlopen", fetch)
114
+ with pytest.raises(ValueError, match="Published artifacts"):
115
+ release.wait_for_index(manifest, "https://test.pypi.org")
116
+ assert len(calls) == 1
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ scratch/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dulkar Saman V N
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.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: cachescope
3
+ Version: 0.1.0a1
4
+ Summary: Find which Python functions repeat slow work, and how big a cache they need.
5
+ Project-URL: Homepage, https://github.com/dulkarsamanvn/cachescope
6
+ Project-URL: Issues, https://github.com/dulkarsamanvn/cachescope/issues
7
+ Author: Dulkar Saman V N
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cache,caching,lru_cache,performance,profiler
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Debuggers
15
+ Requires-Python: >=3.9
16
+ Provides-Extra: dev
17
+ Requires-Dist: build; extra == 'dev'
18
+ Requires-Dist: pytest>=7; extra == 'dev'
19
+ Requires-Dist: twine; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # cachescope
23
+
24
+ Find which Python functions repeat slow work.
25
+
26
+ Install the alpha release from PyPI:
27
+
28
+ ```console
29
+ python -m pip install --pre cachescope
30
+ ```
31
+
32
+ Or install from this checkout and run a script:
33
+
34
+ ```console
35
+ python -m pip install .
36
+ cachescope run app.py [script arguments]
37
+ python -m cachescope run --root . scripts/app.py [script arguments]
38
+ ```
39
+
40
+ `--root` selects the directory whose functions are eligible for analysis. It
41
+ defaults to the script's directory and does not change Python's import paths.
42
+ Place this option before the script name; options after the script are passed
43
+ to the script. Use `cachescope --help` for the command syntax.
44
+
45
+ The report sorts functions by observed repeated work:
46
+
47
+ - **CANDIDATE**: repeated successful calls were slow enough to investigate,
48
+ their comparable results were stable, and the checks found no known risk.
49
+ - **MAYBE**: evidence is incomplete or caching needs a human check, such as
50
+ unknown/mutable results, factory-created functions, captured state, detected
51
+ effects, or observation limits.
52
+ - **UNSAFE**: comparable calls with the same observed input returned different
53
+ results, without ambiguous closure context.
54
+ - **SKIP**: too few useful repeats or too little repeated work was observed.
55
+
56
+ “Repeat work” is the measured time of repeated successful evaluations, not a
57
+ promise of elapsed time saved. Times exclude work inside the profiling callback
58
+ and recursive overlap, but interpreter profiling overhead remains. Function
59
+ times include callees, so rows are not additive. Recursion receives no independent
60
+ cache savings estimate. Call-heavy programs can slow down substantially.
61
+
62
+ Size estimates model normalized argument values. Python has already bound
63
+ defaults and positional/keyword arguments when these values are observed.
64
+ Consequently, `f()`, `f(1)`, and `f(n=1)` can look identical here while occupying
65
+ different real cache entries. Estimates assume consistent call spelling; verify
66
+ the decorated function with `cache_info()` on a representative workload.
67
+ The report does not prescribe an `@lru_cache` decorator. Observed numeric-type
68
+ key collisions block size advice, and `typed=True` does not distinguish types
69
+ inside tuple arguments.
70
+
71
+ Only the thread that starts the tracer is observed. Generator, coroutine, and
72
+ async-generator functions are skipped. Comparisons support exact built-in int,
73
+ float, bool, str, bytes, None, list, tuple, and dict values, preserving dictionary
74
+ order. Custom objects, including most method `self` arguments, are unknown.
75
+ Mutable return values require review because caching would share them. Failed
76
+ and unfinished calls are excluded from cache-hit and repeated-work estimates.
77
+
78
+ Observation is bounded: values have depth, item, and byte budgets; each function
79
+ retains at most 4,096 distinct inputs and 100,000 sequence entries. Retention is
80
+ budgeted at approximately 4 MiB per function and 32 MiB across records, with a
81
+ function-count limit as well. These are accounting budgets, not a process RSS
82
+ limit; active snapshots and interpreter overhead also use memory. Reaching a
83
+ retention limit is reported. Truncated sequences receive no size/hit-rate estimate.
84
+
85
+ Source checks recognize common risky calls, import aliases, direct helper calls,
86
+ external-variable reads, and argument mutations. They are a heuristic: dynamic
87
+ dispatch, imported helper implementations, and changes in external state can
88
+ escape detection. A CANDIDATE is evidence from one run, not a proof of purity.
89
+
90
+ For programmatic use, `Tracer` supports a context manager and restores a prior
91
+ Python profile hook when stopped. That prior hook is suspended during tracing.
92
+ Nested tracers and incompatible native hooks are rejected; independent
93
+ `sys.monitoring` tools can coexist. Start and stop a tracer in the same thread.
94
+
95
+ To develop and check distributions:
96
+
97
+ ```console
98
+ python -m pip install -e ".[dev]"
99
+ python -m pytest
100
+ python -m build
101
+ python -m twine check dist/*
102
+ ```
103
+
104
+ CI runs the suite on Python 3.9 through 3.14 on Linux and Windows, and checks
105
+ the built wheel and source distribution. The Linux Python 3.9 job uses Ubuntu
106
+ 22.04, which has a supported `setup-python` binary for that version.
107
+
108
+ Maintainers: see the [release guide](https://github.com/dulkarsamanvn/cachescope/blob/main/RELEASING.md)
109
+ for the TestPyPI rehearsal and Trusted Publishing setup.
@@ -0,0 +1,88 @@
1
+ # cachescope
2
+
3
+ Find which Python functions repeat slow work.
4
+
5
+ Install the alpha release from PyPI:
6
+
7
+ ```console
8
+ python -m pip install --pre cachescope
9
+ ```
10
+
11
+ Or install from this checkout and run a script:
12
+
13
+ ```console
14
+ python -m pip install .
15
+ cachescope run app.py [script arguments]
16
+ python -m cachescope run --root . scripts/app.py [script arguments]
17
+ ```
18
+
19
+ `--root` selects the directory whose functions are eligible for analysis. It
20
+ defaults to the script's directory and does not change Python's import paths.
21
+ Place this option before the script name; options after the script are passed
22
+ to the script. Use `cachescope --help` for the command syntax.
23
+
24
+ The report sorts functions by observed repeated work:
25
+
26
+ - **CANDIDATE**: repeated successful calls were slow enough to investigate,
27
+ their comparable results were stable, and the checks found no known risk.
28
+ - **MAYBE**: evidence is incomplete or caching needs a human check, such as
29
+ unknown/mutable results, factory-created functions, captured state, detected
30
+ effects, or observation limits.
31
+ - **UNSAFE**: comparable calls with the same observed input returned different
32
+ results, without ambiguous closure context.
33
+ - **SKIP**: too few useful repeats or too little repeated work was observed.
34
+
35
+ “Repeat work” is the measured time of repeated successful evaluations, not a
36
+ promise of elapsed time saved. Times exclude work inside the profiling callback
37
+ and recursive overlap, but interpreter profiling overhead remains. Function
38
+ times include callees, so rows are not additive. Recursion receives no independent
39
+ cache savings estimate. Call-heavy programs can slow down substantially.
40
+
41
+ Size estimates model normalized argument values. Python has already bound
42
+ defaults and positional/keyword arguments when these values are observed.
43
+ Consequently, `f()`, `f(1)`, and `f(n=1)` can look identical here while occupying
44
+ different real cache entries. Estimates assume consistent call spelling; verify
45
+ the decorated function with `cache_info()` on a representative workload.
46
+ The report does not prescribe an `@lru_cache` decorator. Observed numeric-type
47
+ key collisions block size advice, and `typed=True` does not distinguish types
48
+ inside tuple arguments.
49
+
50
+ Only the thread that starts the tracer is observed. Generator, coroutine, and
51
+ async-generator functions are skipped. Comparisons support exact built-in int,
52
+ float, bool, str, bytes, None, list, tuple, and dict values, preserving dictionary
53
+ order. Custom objects, including most method `self` arguments, are unknown.
54
+ Mutable return values require review because caching would share them. Failed
55
+ and unfinished calls are excluded from cache-hit and repeated-work estimates.
56
+
57
+ Observation is bounded: values have depth, item, and byte budgets; each function
58
+ retains at most 4,096 distinct inputs and 100,000 sequence entries. Retention is
59
+ budgeted at approximately 4 MiB per function and 32 MiB across records, with a
60
+ function-count limit as well. These are accounting budgets, not a process RSS
61
+ limit; active snapshots and interpreter overhead also use memory. Reaching a
62
+ retention limit is reported. Truncated sequences receive no size/hit-rate estimate.
63
+
64
+ Source checks recognize common risky calls, import aliases, direct helper calls,
65
+ external-variable reads, and argument mutations. They are a heuristic: dynamic
66
+ dispatch, imported helper implementations, and changes in external state can
67
+ escape detection. A CANDIDATE is evidence from one run, not a proof of purity.
68
+
69
+ For programmatic use, `Tracer` supports a context manager and restores a prior
70
+ Python profile hook when stopped. That prior hook is suspended during tracing.
71
+ Nested tracers and incompatible native hooks are rejected; independent
72
+ `sys.monitoring` tools can coexist. Start and stop a tracer in the same thread.
73
+
74
+ To develop and check distributions:
75
+
76
+ ```console
77
+ python -m pip install -e ".[dev]"
78
+ python -m pytest
79
+ python -m build
80
+ python -m twine check dist/*
81
+ ```
82
+
83
+ CI runs the suite on Python 3.9 through 3.14 on Linux and Windows, and checks
84
+ the built wheel and source distribution. The Linux Python 3.9 job uses Ubuntu
85
+ 22.04, which has a supported `setup-python` binary for that version.
86
+
87
+ Maintainers: see the [release guide](https://github.com/dulkarsamanvn/cachescope/blob/main/RELEASING.md)
88
+ for the TestPyPI rehearsal and Trusted Publishing setup.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cachescope"
7
+ version = "0.1.0a1"
8
+ description = "Find which Python functions repeat slow work, and how big a cache they need."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Dulkar Saman V N" }]
14
+ keywords = ["cache", "caching", "lru_cache", "performance", "profiler"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Debuggers",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7", "build", "twine"]
25
+
26
+ [project.scripts]
27
+ cachescope = "cachescope.cli:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/dulkarsamanvn/cachescope"
31
+ Issues = "https://github.com/dulkarsamanvn/cachescope/issues"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/cachescope"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["src/cachescope", "tests", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1 @@
1
+ """cachescope: find which Python functions repeat slow work."""
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,74 @@
1
+ """The command line: cachescope run script.py"""
2
+
3
+ import os
4
+ import runpy
5
+ import sys
6
+ import traceback
7
+
8
+ from .tracer import Tracer
9
+
10
+ USAGE = "usage: cachescope run [--root DIRECTORY] script.py [script arguments]"
11
+
12
+
13
+ def main(argv=None):
14
+ args = sys.argv[1:] if argv is None else list(argv)
15
+
16
+ if args in (["--help"], ["-h"], ["run", "--help"]):
17
+ print(USAGE)
18
+ return 0
19
+ if not args or args[0] != "run":
20
+ print(USAGE, file=sys.stderr)
21
+ return 2
22
+
23
+ remaining, root = args[1:], None
24
+ if remaining[:1] == ["--root"]:
25
+ if len(remaining) < 3:
26
+ print(USAGE, file=sys.stderr)
27
+ return 2
28
+ root, remaining = remaining[1], remaining[2:]
29
+ if not os.path.isdir(root):
30
+ print(f"cachescope: cannot find root directory: {root}", file=sys.stderr)
31
+ return 2
32
+ if remaining[:1] == ["--"]:
33
+ remaining = remaining[1:]
34
+ if not remaining:
35
+ print(USAGE, file=sys.stderr)
36
+ return 2
37
+ script, script_args = remaining[0], remaining[1:]
38
+
39
+ if not os.path.isfile(script):
40
+ print(f"cachescope: cannot find script: {script}", file=sys.stderr)
41
+ return 2
42
+
43
+ script_path = os.path.abspath(script)
44
+ script_folder = os.path.dirname(script_path)
45
+
46
+ tracer = Tracer(root=root or script_folder)
47
+ exit_code = 0
48
+ previous_argv, previous_path = sys.argv, sys.path
49
+ previous_path_values = previous_path[:]
50
+
51
+ try:
52
+ # Make the script believe it was started normally, then restore the host.
53
+ sys.argv = [script, *script_args]
54
+ sys.path = [script_folder, *previous_path_values]
55
+ tracer.start()
56
+ runpy.run_path(script_path, run_name="__main__")
57
+ except SystemExit as exc: # the script called exit() or sys.exit()
58
+ code = exc.code
59
+ exit_code = code if isinstance(code, int) else (0 if code is None else 1)
60
+ if code is not None and not isinstance(code, int):
61
+ print(code, file=sys.stderr)
62
+ except Exception: # the script crashed: show the error, but still give the report
63
+ traceback.print_exc()
64
+ exit_code = 1
65
+ finally:
66
+ try:
67
+ tracer.stop()
68
+ finally:
69
+ sys.argv, sys.path = previous_argv, previous_path
70
+ previous_path[:] = previous_path_values
71
+
72
+ print()
73
+ tracer.report()
74
+ return exit_code
@@ -0,0 +1,94 @@
1
+ """Turn values into labels so we can tell if two calls are the same."""
2
+
3
+ import sys
4
+
5
+
6
+ class _Unknown:
7
+ """A marker that means: we are not sure about this value."""
8
+
9
+ def __repr__(self):
10
+ return "UNKNOWN"
11
+
12
+
13
+ UNKNOWN = _Unknown()
14
+
15
+
16
+ def immutable_label(label):
17
+ """Whether a known fingerprint describes a value safe to share by value."""
18
+ kind, value = label
19
+ if kind == "tuple":
20
+ return all(immutable_label(part) for part in value)
21
+ return kind not in ("list", "dict")
22
+
23
+ # The simple kinds of values we trust.
24
+ SAFE_TYPES = (int, float, str, bytes, bool, type(None))
25
+ TYPE_NAMES = {kind: kind.__name__ for kind in (*SAFE_TYPES, list, tuple)}
26
+
27
+ # How many levels of "list inside a list inside..." we are willing to look into.
28
+ MAX_DEPTH = 4
29
+ MAX_ITEMS = 1024
30
+ MAX_VALUE_BYTES = 64 * 1024
31
+
32
+
33
+ def fingerprint(value, depth=0):
34
+ """Best-effort observation: a changing container must not break its caller."""
35
+ try:
36
+ return _fingerprint(value, depth, [MAX_ITEMS, MAX_VALUE_BYTES])
37
+ except Exception:
38
+ return UNKNOWN
39
+
40
+
41
+ def _fingerprint(value, depth, budget):
42
+ t = type(value)
43
+ budget[0] -= 1
44
+ budget[1] -= 64
45
+ if t is str:
46
+ budget[1] -= len(value) * 4
47
+ elif t is bytes:
48
+ budget[1] -= len(value)
49
+ elif t is int:
50
+ budget[1] -= value.bit_length() // 8
51
+ if budget[0] < 0 or budget[1] < 0:
52
+ return UNKNOWN
53
+
54
+ if t in SAFE_TYPES:
55
+ return (TYPE_NAMES[t], value)
56
+
57
+ # Too deep (or a value that contains itself): give up instead of crashing.
58
+ if depth >= MAX_DEPTH:
59
+ return UNKNOWN
60
+
61
+ if t is list or t is tuple:
62
+ parts = []
63
+ for item in value:
64
+ item_label = _fingerprint(item, depth + 1, budget)
65
+ if item_label is UNKNOWN:
66
+ return UNKNOWN
67
+ parts.append(item_label)
68
+ return (TYPE_NAMES[t], tuple(parts))
69
+
70
+ if t is dict:
71
+ pairs = []
72
+ for key, val in value.items():
73
+ key_label = _fingerprint(key, depth + 1, budget)
74
+ val_label = _fingerprint(val, depth + 1, budget)
75
+ if key_label is UNKNOWN or val_label is UNKNOWN:
76
+ return UNKNOWN
77
+ pairs.append((key_label, val_label))
78
+ return ("dict", tuple(pairs))
79
+
80
+ return UNKNOWN
81
+
82
+
83
+ def fingerprint_size(label):
84
+ """Conservative retention estimate for an already bounded, known label."""
85
+ pending, seen, size = [label], set(), 0
86
+ while pending:
87
+ value = pending.pop()
88
+ if id(value) in seen:
89
+ continue
90
+ seen.add(id(value))
91
+ size += sys.getsizeof(value)
92
+ if type(value) is tuple:
93
+ pending.extend(value)
94
+ return size