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.
- cachescope-0.1.0a1/.github/tests/test_release_artifacts.py +116 -0
- cachescope-0.1.0a1/.gitignore +8 -0
- cachescope-0.1.0a1/LICENSE +21 -0
- cachescope-0.1.0a1/PKG-INFO +109 -0
- cachescope-0.1.0a1/README.md +88 -0
- cachescope-0.1.0a1/pyproject.toml +37 -0
- cachescope-0.1.0a1/src/cachescope/__init__.py +1 -0
- cachescope-0.1.0a1/src/cachescope/__main__.py +3 -0
- cachescope-0.1.0a1/src/cachescope/cli.py +74 -0
- cachescope-0.1.0a1/src/cachescope/fingerprint.py +94 -0
- cachescope-0.1.0a1/src/cachescope/risk.py +278 -0
- cachescope-0.1.0a1/src/cachescope/simulate.py +48 -0
- cachescope-0.1.0a1/src/cachescope/tracer.py +394 -0
- cachescope-0.1.0a1/src/cachescope/verdict.py +98 -0
- cachescope-0.1.0a1/tests/test_cli.py +126 -0
- cachescope-0.1.0a1/tests/test_fingerprint.py +78 -0
- cachescope-0.1.0a1/tests/test_regressions.py +560 -0
- cachescope-0.1.0a1/tests/test_risk.py +118 -0
- cachescope-0.1.0a1/tests/test_simulate.py +47 -0
- cachescope-0.1.0a1/tests/test_tracer.py +330 -0
- cachescope-0.1.0a1/tests/test_verdict.py +77 -0
|
@@ -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,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,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
|