From eda00142c38cddb6418909e5094af45eb8e076b2 Mon Sep 17 00:00:00 2001 From: Mahmoud Hashemi Date: Thu, 6 Aug 2026 12:53:37 -0700 Subject: [PATCH] modern-CPython traversal coverage, leak-scenario suite, agent CLI, CI revamp Exporter (objex/exporter.py): - named edge handlers for coroutine (.cr_frame/.cr_code/.cr_await), async_generator, traceback (.tb_frame/.tb_next), cell (.cell_contents), slice, super, weakref.ref (.__callback__ only; referent is a weak edge), BaseException (.args/.__traceback__/.__cause__/.__context__/.__notes__), generator .gi_yieldfrom, function .__kwdefaults__ - generic gc.get_referents (tp_traverse) fallback labeled '' for every type outside _GC_COMPLETE_TYPES: covers C iterators, asyncio.Task, ContextVar, functools.partial, lru_cache caches, and future CPython types. Perf: bare dump 0.855s -> 0.97s (+13%), within budget - fix: on CPython 3.11+ running stacks materialize frame objects lazily (after the gc.get_objects() snapshot), so live frames had zero outbound edges and thread/coroutine locals were unattributable; frames are now queued when first seen and traversed at the end of add_all Tests: - tests/scenarios/leak_cases.py + tests/test_scenarios.py: 7 subprocess leak scenarios (module_global, exception_traceback, closure_cell, suspended_coroutine, thread_frame, lru_cache, iterator_pin), each proving path-to-module/frame attribution through its tricky edge - tests/test_gc_coverage.py: gc.get_referents ground-truth audit; fails with a readable per-type miss list when a Python upgrade adds untraversed types - non-slow suite 629s -> ~35s: raw-dump Reader opens repointed at the indexed analysis DB (unindexed attributed-size build is pathological; TODO.md note), same for the forked dump in test_spawn_dump_and_wait_dump; deleted vacuous test_dump_graph_survives_getfullargspec_failures (exporter no longer calls getfullargspec) CLI (objex/__main__.py): - 'q' subcommand: one-shot JSON queries wrapping web.dispatch_request (summary, root-summary, path-to-module, go, ...) plus a read-only 'sql' escape hatch (sqlite mode=ro URI); exit 0 on 200, 1 otherwise CI (.github/workflows/python-tests.yml): - pytest with markers honored (was unittest, which ran slow tests on every push); test + integration (slow) jobs, uv-managed 3.10-3.14 matrix, concurrency cancellation. Verified locally: non-slow green on 3.10-3.14, slow green on 3.12 (2m49s), 3.13 (3m04s), 3.14 (6m01s) Docs: docs/2026-08-06_leak_tool_landscape.md (tool landscape + positioning table), README when-to-use + agent/scripting sections, pyproject dev group restored, 3.14 classifier, scenario marker --- .github/workflows/python-tests.yml | 32 +- README.md | 37 ++ TODO.md | 22 +- docs/2026-08-06_leak_tool_landscape.md | 78 +++ objex/__main__.py | 78 ++- objex/exporter.py | 794 ++++++++++++++----------- pyproject.toml | 7 + tests/scenarios/__init__.py | 0 tests/scenarios/leak_cases.py | 182 ++++++ tests/test_gc_coverage.py | 76 +++ tests/test_objex.py | 64 +- tests/test_scenarios.py | 79 +++ uv.lock | 207 +++---- 13 files changed, 1131 insertions(+), 525 deletions(-) create mode 100644 docs/2026-08-06_leak_tool_landscape.md create mode 100644 tests/scenarios/__init__.py create mode 100644 tests/scenarios/leak_cases.py create mode 100644 tests/test_gc_coverage.py create mode 100644 tests/test_scenarios.py diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 84d8793..98da981 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -2,7 +2,13 @@ name: Python tests on: push: + branches: [master] pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: test: @@ -10,16 +16,32 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Run tests (non-slow, includes leak scenarios) + run: uv run --python ${{ matrix.python-version }} pytest -q + + integration: + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + + steps: + - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: astral-sh/setup-uv@v6 with: - python-version: ${{ matrix.python-version }} + enable-cache: true - - name: Run tests - run: uv run python -m unittest discover -s tests + - name: Run full in-process dump smoke tests + run: uv run --python ${{ matrix.python-version }} pytest -q -m slow --no-header -p no:cacheprovider diff --git a/README.md b/README.md index cb84ae4..38c3c1e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,18 @@ CLI extras for the interactive analysis UI: uv add '.[cli]' ``` +## when to use objex + +objex answers retention questions: "this object count keeps growing -- what is +still holding these objects, and from which module/frame root?" It captures via +fork-and-dump (near-zero pause for the target) into a portable single-file +SQLite artifact you analyze offline, with labeled path-to-root attribution. +For allocation-site questions ("what code path is allocating all this +memory?"), use [memray](https://github.com/bloomberg/memray) or stdlib +`tracemalloc` instead; see +[docs/2026-08-06_leak_tool_landscape.md](docs/2026-08-06_leak_tool_landscape.md) +for the full tool landscape. + # workflow 1- collect data from a running process (this comines well with `os.fork`) @@ -41,6 +53,31 @@ RSS memory was 190.43MiB; 151.0MiB (79.8%) found in 622,108 python objects (Type "help" for options.) ``` +# agent/scripting usage + +Every explorer query is available as a one-shot, pipeable JSON invocation via +the `q` subcommand -- no REPL needed: + +```bash +python -m objex q analysis.db summary # {"object_count": ..., ...} +python -m objex q analysis.db root-summary sample_size=200 +python -m objex q analysis.db go q=SomeType # resolve a type/path query +python -m objex q analysis.db path-to-module id=4211 +``` + +Endpoints mirror the web API: `summary`, `object`, `random`, `referents`, +`referrers`, `type-search`, `top-types`, `largest-objects`, `root-summary`, +`path-to-module`, `path-to-frame`, `go`, `marks`, `mark`. Parameters are +`key=value` tokens. Exit code is 0 on success, 1 otherwise (error payloads are +JSON too). + +There is also a read-only SQL escape hatch (schema documented in +`objex/schema.py`): + +```bash +python -m objex q analysis.db sql "SELECT name, count(*) FROM pytype GROUP BY name ORDER BY 2 DESC LIMIT 10" +``` + # how to debug a memory leak Assuming the process has leaked significantly (e.g. doubled or more in memory footprint since it started), diff --git a/TODO.md b/TODO.md index 04d8ede..9c94a6b 100644 --- a/TODO.md +++ b/TODO.md @@ -3,18 +3,11 @@ ## ROADMAP Extensions -- collections.deque, collections.defaultdict -- frame, func, code etc - old style classes? Tests -- CI - "close the loop" on dump -- check that DB was written CORRECTLY not just that it didn't crash -Py3 Support -- briefly worked, atrophied w/out tests -- old-style classes - CLI - better commands / sub-commands / flags /etc (face interface?) - help @@ -22,6 +15,11 @@ CLI Library Meta-Data in dump - detect version mismatch between export and exploration +Known warts +- opening `Reader` directly on an unindexed collection DB is pathological + (the one-time attributed-size build runs without indices and can take + minutes); users are directed through make-analysis-db instead. + ## Extra export data - Truncated repr for user classes and strings (dicts, lists, etc. can be reconstructed already) @@ -72,16 +70,6 @@ Other: * convenience function for fork-and-export * support fork() equivalent for Windows (it borks on sockets, but that's fine for us) - -## Traversal - -* Iterators are an example of a group of "builtin" (aka implemented in - C) types that can hold onto a reference that's not accessible from - Python. There are listerators dictkey iterators, dictvalue - iterators, set iterators, etc. Luckily, like dictproxy they only - hold onto one reference and it's the relevant container. Might be - able to develop a heuristic around these kinds of types. - ## CLI * Subcommands (possibly separate commands once export/explore apps are separated) diff --git a/docs/2026-08-06_leak_tool_landscape.md b/docs/2026-08-06_leak_tool_landscape.md new file mode 100644 index 0000000..fdbb71f --- /dev/null +++ b/docs/2026-08-06_leak_tool_landscape.md @@ -0,0 +1,78 @@ +# Python memory-leak tooling landscape and objex's niche + +Two tool families exist for hunting Python memory leaks; objex is in the +second, thinner one. + +## Allocation-site profilers + +These answer "*where* is memory being allocated": they find growth sites but +cannot answer "who is still holding this object" -- the retention question. + +- [tracemalloc](https://docs.python.org/3/library/tracemalloc.html) -- stdlib + since 3.4, maintained with CPython. Line-level allocation attribution; + Python allocations only, blind to C extensions. +- [memray](https://github.com/bloomberg/memray) -- Bloomberg; actively + maintained. Traces native + Python allocations, flamegraphs, <5% overhead, + Linux/macOS. The current default recommendation for allocation questions. +- [memory_profiler](https://github.com/pythonprofilers/memory_profiler) -- + line-by-line RSS deltas; effectively unmaintained (last release 2022), + mostly superseded by memray. + +## Retention/object-graph tools + +These answer "*why* is this object still alive": + +- [objgraph](https://github.com/mgedmin/objgraph) -- maintained (slow + cadence). In-process, `gc.get_referrers`-based backref chains, graphviz + output. Requires running code inside the target process; no snapshot + artifact. +- [guppy3 / heapy](https://github.com/zhuyifei1999/guppy3) -- actively + maintained, supports 3.10-3.14. In-process heap analysis with its own C + traversal; powerful, but analysis competes with the target process for + memory. +- [pympler](https://github.com/pympler/pympler) -- sporadically maintained. + In-process summaries plus class lifetime tracking. +- [meliae](https://pypi.org/project/meliae/) -- dump-to-JSON then offline + analysis; the closest architectural cousin to objex. Effectively + unmaintained. +- [PyHeap](https://github.com/ivanyu/pyheap) -- GDB-injected dumper plus + offline analyzer with a browser UI. CPython 3.8-3.12, Linux only. +- [dump-heap](https://github.com/lhchavez/dump-heap) -- ptrace-injected + whole-heap dumper, snapshot-diff oriented. x86_64 Linux only. + +## objex's niche + +objex's distinctive combination, shared by no maintained tool: +**fork-and-dump (near-zero pause for the target), a portable single-file +SQLite artifact, offline SQL-queryable analysis, and path-to-root attribution +against module/frame/thread roots with human-legible labels** (`.attr`, +`[i]`, `@key`, `.locals['x']`). + +Notably, the gc-based competitors (objgraph, heapy) traverse via +`tp_traverse` and therefore see coroutines/iterators/tracebacks natively; +objex's labeled-edge scraping is its UX advantage and was historically its +completeness liability. The hybrid traversal (named labels where known, +`gc.get_referents`/`tp_traverse` fallback labeled `` everywhere else) +closes the completeness gap while keeping the label advantage, and the +gc-coverage audit in `tests/test_gc_coverage.py` keeps it closed across +CPython upgrades. + +## Positioning + +| Tool | Retention paths | Offline artifact | Prod-safe capture | Native allocs | Maintained | +|---|---|---|---|---|---| +| tracemalloc | no | snapshots (in-process) | overhead while tracing | no | stdlib | +| memray | no | yes (capture file) | yes (<5% overhead) | yes | yes | +| memory_profiler | no | no | no | no | no | +| objgraph | yes (in-process) | no | no (runs in target) | no | yes | +| guppy3/heapy | yes (in-process) | partial | no (runs in target) | no | yes | +| pympler | partial | no | no (runs in target) | no | sporadic | +| meliae | partial | yes (JSON dump) | dump pause | no | no | +| PyHeap | yes (offline) | yes | GDB stop-the-world | no | yes | +| dump-heap | diff-oriented | yes | ptrace pause | no | yes | +| **objex** | **yes (offline, labeled)** | **yes (single SQLite file)** | **yes (fork-and-dump)** | no | yes | + +Rule of thumb: reach for memray/tracemalloc when the question is "what code +path is allocating all this memory"; reach for objex when the question is +"this object count/RSS keeps growing -- what is still holding these objects, +and from which module/frame root". diff --git a/objex/__main__.py b/objex/__main__.py index b7f5e94..5562161 100644 --- a/objex/__main__.py +++ b/objex/__main__.py @@ -5,13 +5,26 @@ from . import web -_COMMANDS = {'explore', 'make-analysis-db', 'web'} +_COMMANDS = {'explore', 'make-analysis-db', 'web', 'q'} + +_Q_ENDPOINTS = ( + 'summary', 'object', 'random', 'referents', 'referrers', 'type-search', + 'top-types', 'largest-objects', 'root-summary', 'path-to-module', + 'path-to-frame', 'go', 'marks', 'mark', 'sql') def build_parser(): parser = argparse.ArgumentParser( prog='python -m objex', description='Export and explore Python object graphs stored in SQLite databases.', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + 'agent workflow example:\n' + ' python -m objex q analysis.db summary\n' + ' python -m objex q analysis.db root-summary sample_size=200\n' + ' python -m objex q analysis.db go q=SomeType\n' + ' python -m objex q analysis.db path-to-module id=4211\n' + ), ) subparsers = parser.add_subparsers(dest='command') @@ -35,6 +48,24 @@ def build_parser(): web_parser.add_argument('analysis_db', help='Path to an objex analysis database.') web_parser.add_argument('--host', default='127.0.0.1', help='Host interface to bind. Default: 127.0.0.1') web_parser.add_argument('--port', type=int, default=8000, help='Port to bind. Default: 8000') + + q_parser = subparsers.add_parser( + 'q', + help='Run a one-shot JSON query against an analysis database (agent/scripting interface).', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + 'endpoints: {}\n' + 'examples:\n' + ' python -m objex q analysis.db summary\n' + ' python -m objex q analysis.db path-to-module id=4211 limit=5\n' + ' python -m objex q analysis.db sql "SELECT name, count(*) FROM pytype GROUP BY name"\n' + ).format(', '.join(_Q_ENDPOINTS)), + ) + q_parser.add_argument('analysis_db', help='Path to an objex analysis database.') + q_parser.add_argument('endpoint', help='Query endpoint name (see below).') + q_parser.add_argument( + 'params', nargs='*', + help='key=value query parameters, or one SQL string for the sql endpoint.') return parser @@ -54,6 +85,9 @@ def main(argv=None): explorer.make_analysis_db(args.collection_db, args.analysis_db) return 0 + if args.command == 'q': + return _run_query(args) + if args.command == 'web': server = web.serve(args.analysis_db, host=args.host, port=args.port) actual_host, actual_port = server.server_address[:2] @@ -90,5 +124,47 @@ def main(argv=None): return 0 +def _run_query(args): + import json + import sqlite3 + import sys + from urllib.parse import urlencode + + if args.endpoint == 'sql': + if len(args.params) != 1: + print(json.dumps({'error': "the sql endpoint takes exactly one SQL string"})) + return 1 + # read-only enforced at the sqlite level; this is the escape hatch, + # never exposed over HTTP (schema documented in objex/schema.py) + conn = sqlite3.connect('file:{}?mode=ro'.format(args.analysis_db), uri=True) + conn.text_factory = str + try: + cursor = conn.execute(args.params[0]) + columns = [d[0] for d in cursor.description or []] + rows = [list(row) for row in cursor.fetchall()] + except sqlite3.Error as exc: + print(json.dumps({'error': str(exc)})) + return 1 + finally: + conn.close() + print(json.dumps({'columns': columns, 'rows': rows}, sort_keys=True, default=repr)) + return 0 + + params = [] + for token in args.params: + key, sep, value = token.partition('=') + if not sep: + print(json.dumps({'error': 'expected key=value parameter, got {!r}'.format(token)})) + return 1 + params.append((key, value)) + path = '/api/{}'.format(args.endpoint) + if params: + path = '{}?{}'.format(path, urlencode(params)) + status, _content_type, body = web.dispatch_request(args.analysis_db, path) + sys.stdout.write(body.decode('utf-8')) + sys.stdout.write('\n') + return 0 if status == 200 else 1 + + if __name__ == '__main__': raise SystemExit(main()) diff --git a/objex/exporter.py b/objex/exporter.py index bd231e2..69e8d30 100644 --- a/objex/exporter.py +++ b/objex/exporter.py @@ -1,52 +1,53 @@ import collections -import gc -import inspect -import os -try: - import resource -except ImportError: # windows - resource = None -import shutil -import sys -from socket import getfqdn +import gc +import inspect +import os +try: + import resource +except ImportError: # windows + resource = None +import shutil +import sys +from socket import getfqdn import sqlite3 import time import types +import weakref from .schema import _SCHEMA from .dbutils import _run_ddl _DICT_PROXY_TYPE = type(type.__dict__) - -# MAINTENANCE NOTE: why are some python types "special" and get broken out as -# their own table type whereas others are not? -# the guiding principle is that objects which tend to need special display -# and/or collection logic get separate tables - -# TODO: refactor foo to foo_obj_id when it refers to a row in the object table - - -def _get_memory_mb(): - if resource: - return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0 - # windows, fall back to psutil - import psutil - return psutil.Process().memory_info()[0] / 1024.0 / 1024 - - + +# MAINTENANCE NOTE: why are some python types "special" and get broken out as +# their own table type whereas others are not? +# the guiding principle is that objects which tend to need special display +# and/or collection logic get separate tables + +# TODO: refactor foo to foo_obj_id when it refers to a row in the object table + + +def _get_memory_mb(): + if resource: + return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0 + # windows, fall back to psutil + import psutil + return psutil.Process().memory_info()[0] / 1024.0 / 1024 + + def _gc_prep(): - ''' - turn off GC, set flags so they will populate gc.garbage, - then run the collector - returns the number of objects collected - ''' - gc.disable() - flags = gc.get_debug() - gc.set_debug(gc.DEBUG_UNCOLLECTABLE) # make gc.garbage useful - num_collected = gc.collect() - gc.set_debug(flags) - gc.enable() + ''' + turn off GC, set flags so they will populate gc.garbage, + then run the collector + returns the number of objects collected + ''' + gc.disable() + flags = gc.get_debug() + gc.set_debug(gc.DEBUG_UNCOLLECTABLE) # make gc.garbage useful + num_collected = gc.collect() + gc.set_debug(flags) + gc.enable() return num_collected @@ -73,13 +74,13 @@ def _finalize_wal(conn): class _Writer: - ''' - responsible for dumping objects - - use_gc -- flag whether to call gc.get_referrers and gc.get_referrents - on every object and record in addition to other information - (default False because this is ~20x slower) - + ''' + responsible for dumping objects + + use_gc -- flag whether to call gc.get_referrers and gc.get_referrents + on every object and record in addition to other information + (default False because this is ~20x slower) + NOTE: this object tends to get really huge; it is meant to be disposable (ideally, the whole process is disposable when the export process is run) ''' @@ -89,23 +90,28 @@ class _Writer: def __init__(self, conn, use_gc=False): self.conn = conn self.use_gc = use_gc - gc.collect() # try to minimize garbage - # track these to separate out "extra" objects that are generated as part of - # the object walking process - self.all_objects = gc.get_objects() - self.all_object_ids = {id(e) for e in self.all_objects} - # tracking objects by id gives two benefits: - # 1- avoids calls to __eq__ which may execute arbitrary code - # 2- avoids changing refcount on objects - self.type_id_map = {} # map of type ids to pytype rowids - self.object_id_map = {} # map of object ids to object rowids - self.tracked_t_id_map = {t: {} for t in self._TRACKED_TYPES} - self.type_slots_map = {} # map of type ids to __slots__ - self.type_is_type_map = {} # map of whether a type is a type - self.modules_map = dict(sys.modules) # map of __module__ to fake modules when no entry in sys.modules - self.started = time.time() - # ignore ids not just to avoid analysis noise, but because these can - # get pretty big over time, don't want to waste DB space + gc.collect() # try to minimize garbage + # track these to separate out "extra" objects that are generated as part of + # the object walking process + self.all_objects = gc.get_objects() + self.all_object_ids = {id(e) for e in self.all_objects} + # tracking objects by id gives two benefits: + # 1- avoids calls to __eq__ which may execute arbitrary code + # 2- avoids changing refcount on objects + self.type_id_map = {} # map of type ids to pytype rowids + self.object_id_map = {} # map of object ids to object rowids + self.tracked_t_id_map = {t: {} for t in self._TRACKED_TYPES} + self.type_slots_map = {} # map of type ids to __slots__ + self.type_is_type_map = {} # map of whether a type is a type + self.modules_map = dict(sys.modules) # map of __module__ to fake modules when no entry in sys.modules + # frame objects materialize lazily on modern CPython (3.11+): running + # stacks only yield frame objects when requested (sys._current_frames(), + # f_back, cr_frame, ...), so they can be absent from the all_objects + # snapshot; queue them as they're encountered and traverse at the end + self._pending_frames = [] + self.started = time.time() + # ignore ids not just to avoid analysis noise, but because these can + # get pretty big over time, don't want to waste DB space ignored = list(self.__dict__.values()) + list(self.tracked_t_id_map.values()) self.ignore_ids = {id(e) for e in ignored} self.ignore_ids.add(id(self.ignore_ids)) @@ -113,10 +119,10 @@ def __init__(self, conn, use_gc=False): self.ignore_ids.add(id(self)) self._objects_since_commit = 0 # commented out tracing code - # TODO how to put it in w/out hurting perf if off? - # (maybe optional decorators?) - # self.times = [] - + # TODO how to put it in w/out hurting perf if off? + # (maybe optional decorators?) + # self.times = [] + @classmethod def write_to_path(cls, path, use_gc=False, use_wal=True): '''create a new instance that will dump state to path (which shouldn't exist)''' @@ -140,12 +146,12 @@ def write_to_path(cls, path, use_gc=False, use_wal=True): except Exception: conn.close() raise - + def execute(self, sql, params): # start = time.time() self.conn.execute(sql, params) # self.times.append(time.time() - start) - + def executemany(self, sql, params): # start = time.time() self.conn.executemany(sql, params) @@ -156,83 +162,84 @@ def maybe_commit(self): if self._objects_since_commit >= self._COMMIT_INTERVAL_OBJECTS: self.conn.commit() self._objects_since_commit = 0 - - def _ensure_db_id(self, obj, is_type=False, refs=0): - ''' - DB id creation is separate from full object creation - as a kind of "forward declaration" step to avoid loops - (e.g. an object that refers to itself) - - refs is the number of "extra" references generated by the export process - ''' - if id(obj) in self.object_id_map: - return self.object_id_map[id(obj)] - # this is a quick idiom for assigning integers to objects - # e.g. first thing in object_id_map gets assigned 0, second -> 1, etc... - obj_id = self.object_id_map[id(obj)] = len(self.object_id_map) - obj_type = type(obj) + + def _ensure_db_id(self, obj, is_type=False, refs=0): + ''' + DB id creation is separate from full object creation + as a kind of "forward declaration" step to avoid loops + (e.g. an object that refers to itself) + + refs is the number of "extra" references generated by the export process + ''' + if id(obj) in self.object_id_map: + return self.object_id_map[id(obj)] + # this is a quick idiom for assigning integers to objects + # e.g. first thing in object_id_map gets assigned 0, second -> 1, etc... + obj_id = self.object_id_map[id(obj)] = len(self.object_id_map) + obj_type = type(obj) type_obj_id = self._ensure_db_id(obj_type, is_type=True, refs=1) - try: # very hard to forward detect if this will works - length = len(obj) - except Exception: - length = None - refcount = sys.getrefcount(obj) - (refs + 1) - in_gc_objects = id(obj) in self.all_object_ids - is_gc_tracked = in_gc_objects or gc.is_tracked(obj) - self.execute( - """ - INSERT INTO object (id, pytype, size, len, refcount, in_gc_objects, is_gc_tracked) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - obj_id, - type_obj_id, - sys.getsizeof(obj), - length, - refcount, - in_gc_objects, - is_gc_tracked) - ) - # all of these are pretty rare (maybe optimize?) - if id(obj) not in self.type_id_map and (is_type or isinstance(obj, type)): - obj_type_id = self.type_id_map[id(obj)] = len(self.type_id_map) - module_obj_id = self._module_name2obj_id(obj.__module__) - self.execute( - "INSERT INTO pytype (id, object, module, name) VALUES (?, ?, ?, ?)", - (obj_type_id, obj_id, module_obj_id, obj.__name__)) - bases = getattr(obj, '__bases__', []) - for base in bases: - base_obj_id = self._ensure_db_id(base, is_type=True) - self.execute("INSERT INTO pytype_bases (obj_id, base_obj_id) VALUES (?, ?)", - (obj_id, base_obj_id)) - elif type(obj) in self.tracked_t_id_map: - # ^ expected to be False > 99% of time - self._handle_tracked_type(obj, obj_id) - return obj_id - - def _handle_tracked_type(self, obj, obj_id): - ''' - after creating the row in the object table, handle creating another - row in the corresponding special talbe (module, pyframe, pycode, function) - ''' - t_id_map = self.tracked_t_id_map[type(obj)] - if id(obj) in t_id_map: - return - obj_t_id = t_id_map[id(obj)] = len(t_id_map) + try: # very hard to forward detect if this will works + length = len(obj) + except Exception: + length = None + refcount = sys.getrefcount(obj) - (refs + 1) + in_gc_objects = id(obj) in self.all_object_ids + is_gc_tracked = in_gc_objects or gc.is_tracked(obj) + self.execute( + """ + INSERT INTO object (id, pytype, size, len, refcount, in_gc_objects, is_gc_tracked) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + obj_id, + type_obj_id, + sys.getsizeof(obj), + length, + refcount, + in_gc_objects, + is_gc_tracked) + ) + # all of these are pretty rare (maybe optimize?) + if id(obj) not in self.type_id_map and (is_type or isinstance(obj, type)): + obj_type_id = self.type_id_map[id(obj)] = len(self.type_id_map) + module_obj_id = self._module_name2obj_id(obj.__module__) + self.execute( + "INSERT INTO pytype (id, object, module, name) VALUES (?, ?, ?, ?)", + (obj_type_id, obj_id, module_obj_id, obj.__name__)) + bases = getattr(obj, '__bases__', []) + for base in bases: + base_obj_id = self._ensure_db_id(base, is_type=True) + self.execute("INSERT INTO pytype_bases (obj_id, base_obj_id) VALUES (?, ?)", + (obj_id, base_obj_id)) + elif type(obj) in self.tracked_t_id_map: + # ^ expected to be False > 99% of time + self._handle_tracked_type(obj, obj_id) + return obj_id + + def _handle_tracked_type(self, obj, obj_id): + ''' + after creating the row in the object table, handle creating another + row in the corresponding special talbe (module, pyframe, pycode, function) + ''' + t_id_map = self.tracked_t_id_map[type(obj)] + if id(obj) in t_id_map: + return + obj_t_id = t_id_map[id(obj)] = len(t_id_map) if type(obj) is types.ModuleType: module_file = getattr(obj, "__file__", None) or "(none)" self.execute( "INSERT INTO module (id, object, file, name) VALUES (?, ?, ?, ?)", (obj_t_id, obj_id, module_file, obj.__name__)) - elif type(obj) is types.FrameType: - if obj.f_back: - f_back_obj_id = self._ensure_db_id(obj.f_back) - else: - f_back_obj_id = None - self.conn.execute( - "INSERT INTO pyframe (id, object, f_back_obj_id, f_code_obj_id, f_lasti, f_lineno, trace)" - " VALUES (?, ?, ?, ?, ?, ?, ?)", - ( + elif type(obj) is types.FrameType: + self._pending_frames.append(obj) + if obj.f_back: + f_back_obj_id = self._ensure_db_id(obj.f_back) + else: + f_back_obj_id = None + self.conn.execute( + "INSERT INTO pyframe (id, object, f_back_obj_id, f_code_obj_id, f_lasti, f_lineno, trace)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + ( obj_t_id, obj_id, f_back_obj_id, @@ -242,101 +249,112 @@ def _handle_tracked_type(self, obj, obj_id): _format_frame_trace(obj), ) ) - elif type(obj) is types.CodeType: - self.conn.execute( - "INSERT INTO pycode (id, object, co_name) VALUES (?, ?, ?)", - (obj_t_id, obj_id, obj.co_name)) - elif type(obj) is types.FunctionType: - self.conn.execute( - "INSERT INTO function (id, object, func_name, func_code_obj_id, module_obj_id)" - " VALUES (?, ?, ?, ?, ?)", - ( - obj_t_id, - obj_id, + elif type(obj) is types.CodeType: + self.conn.execute( + "INSERT INTO pycode (id, object, co_name) VALUES (?, ?, ?)", + (obj_t_id, obj_id, obj.co_name)) + elif type(obj) is types.FunctionType: + self.conn.execute( + "INSERT INTO function (id, object, func_name, func_code_obj_id, module_obj_id)" + " VALUES (?, ?, ?, ?, ?)", + ( + obj_t_id, + obj_id, obj.__name__, self._ensure_db_id(obj.__code__), self._module_name2obj_id(obj.__module__) ) ) - - def _module_name2obj_id(self, name): - if not isinstance(name, str): - # TODO: sometimes None, sometimes random stuff (property?) - # is there a better heuristic for this? - return None - if name not in self.modules_map: - self.modules_map[name] = types.ModuleType(name) - return self._ensure_db_id(self.modules_map[name], refs=2) - + + def _module_name2obj_id(self, name): + if not isinstance(name, str): + # TODO: sometimes None, sometimes random stuff (property?) + # is there a better heuristic for this? + return None + if name not in self.modules_map: + self.modules_map[name] = types.ModuleType(name) + return self._ensure_db_id(self.modules_map[name], refs=2) + def add_obj(self, obj, refs=0): - ''' - add an object and references to this graph - - this funcion is idempotent; i.e. calling it 100x - on the same object is the same as calling it once - ''' - obj_id = id(obj) - if obj_id in self.ignore_ids: - return self.object_id_map.get(obj_id) - refs = refs + 1 # take into account current frame + ''' + add an object and references to this graph + + this funcion is idempotent; i.e. calling it 100x + on the same object is the same as calling it once + ''' + obj_id = id(obj) + if obj_id in self.ignore_ids: + return self.object_id_map.get(obj_id) + refs = refs + 1 # take into account current frame self.ignore_ids.add(obj_id) db_id = self._ensure_db_id(obj, refs=refs) self._objects_since_commit += 1 self.maybe_commit() key_dst = [] - check_dict, check_slots = False, False # whether to scrape the __dict__ and __slots__ - extra_relationship = None # which special built-in to scrape as (dict, list, etc) - t = type(obj) - # STEP 1 - FIGURE OUT WHICH MODE TO USE - if t in _SPECIAL_TYPES: - extra_relationship = t - else: - check_dict, check_slots = True, True - # check for possible sub-classes - # TODO: cache the sub-class info based on type-id, same as slots and type-is-type - if isinstance(obj, tuple): # namedtuple assume these will be most common - extra_relationship = tuple - elif isinstance(obj, dict): - extra_relationship = dict - elif isinstance(obj, list): - extra_relationship = list - elif isinstance(obj, set): - extra_relationship = set - elif isinstance(obj, frozenset): - extra_relationship = frozenset - # STEP 2 - GET KEYS - if extra_relationship is dict: - keys = obj.keys() - key_dst += [('@{}'.format(self._ensure_db_id(key, refs=2)), dict.__getitem__(obj, key)) - for key in keys] - elif extra_relationship in (list, tuple, collections.deque): - key_dst += enumerate(obj) - elif extra_relationship in (set, frozenset): - key_dst += zip(['*'] * len(obj), obj) - elif extra_relationship is types.FrameType: # expensive to handle, but pretty rare - key_dst += [(".locals[{!r}]".format(key), val) for key, val in obj.f_locals.items()] - key_dst += [(".f_globals[{!r}]".format(key), val) for key, val in obj.f_globals.items()] - key_dst += [ - (".f_globals", obj.f_globals), - (".f_back", obj.f_back), - (".f_code", obj.f_code), - (".f_builtins", obj.f_builtins), - ] - elif extra_relationship is types.FunctionType: - ''' - >>> a = 1 - >>> def b(): - ... c = 2 - ... def d(): - ... e = 3 - ... return a + c + 3 - ... return d - ... - >>> b().func_code.co_freevars - ('c',) - >>> b().func_closure[0].cell_contents - 2 - ''' + check_dict, check_slots = False, False # whether to scrape the __dict__ and __slots__ + extra_relationship = None # which special built-in to scrape as (dict, list, etc) + t = type(obj) + # STEP 1 - FIGURE OUT WHICH MODE TO USE + if t in _SPECIAL_TYPES: + extra_relationship = t + else: + check_dict, check_slots = True, True + # check for possible sub-classes + # TODO: cache the sub-class info based on type-id, same as slots and type-is-type + if isinstance(obj, BaseException): + # C-level exception slots are invisible to __dict__/__slots__ scraping + key_dst += [ + ('.args', obj.args), + ('.__traceback__', obj.__traceback__), + ('.__cause__', obj.__cause__), + ('.__context__', obj.__context__), + ] + notes = getattr(obj, '__notes__', None) # 3.11+, only when notes were added + if notes is not None: + key_dst.append(('.__notes__', notes)) + elif isinstance(obj, tuple): # namedtuple assume these will be most common + extra_relationship = tuple + elif isinstance(obj, dict): + extra_relationship = dict + elif isinstance(obj, list): + extra_relationship = list + elif isinstance(obj, set): + extra_relationship = set + elif isinstance(obj, frozenset): + extra_relationship = frozenset + # STEP 2 - GET KEYS + if extra_relationship is dict: + keys = obj.keys() + key_dst += [('@{}'.format(self._ensure_db_id(key, refs=2)), dict.__getitem__(obj, key)) + for key in keys] + elif extra_relationship in (list, tuple, collections.deque): + key_dst += enumerate(obj) + elif extra_relationship in (set, frozenset): + key_dst += zip(['*'] * len(obj), obj) + elif extra_relationship is types.FrameType: # expensive to handle, but pretty rare + key_dst += [(".locals[{!r}]".format(key), val) for key, val in obj.f_locals.items()] + key_dst += [(".f_globals[{!r}]".format(key), val) for key, val in obj.f_globals.items()] + key_dst += [ + (".f_globals", obj.f_globals), + (".f_back", obj.f_back), + (".f_code", obj.f_code), + (".f_builtins", obj.f_builtins), + ] + elif extra_relationship is types.FunctionType: + ''' + >>> a = 1 + >>> def b(): + ... c = 2 + ... def d(): + ... e = 3 + ... return a + c + 3 + ... return d + ... + >>> b().func_code.co_freevars + ('c',) + >>> b().func_closure[0].cell_contents + 2 + ''' closure = getattr(obj, '__closure__', None) code = obj.__code__ if closure: # (maybe) grab function closure @@ -354,47 +372,80 @@ def add_obj(self, obj, refs=0): for name, default in zip(reversed(positional_argnames), reversed(defaults)): key_dst.append((".defaults[{!r}]".format(name), default)) key_dst.append(('.__defaults__', obj.__defaults__)) + if obj.__kwdefaults__ is not None: + key_dst.append(('.__kwdefaults__', obj.__kwdefaults__)) key_dst.append((".__code__", code)) key_dst.append((".__globals__", obj.__globals__)) key_dst.append((".__doc__", obj.__doc__)) - # __module__ is a special case b/c unlike other dst values, we don't - # want to call _ensure_db_id on the module; so it gets its own insert - module = self._module_name2obj_id(obj.__module__) - if module: - self.conn.execute( - "INSERT INTO reference (src, dst, ref) VALUES (?, ?, ?)", - (db_id, module, ".__module__")) - check_dict = True - elif extra_relationship is types.GeneratorType: - key_dst.append(('.gi_code', obj.gi_code)) - key_dst.append(('.gi_frame', obj.gi_frame)) + # __module__ is a special case b/c unlike other dst values, we don't + # want to call _ensure_db_id on the module; so it gets its own insert + module = self._module_name2obj_id(obj.__module__) + if module: + self.conn.execute( + "INSERT INTO reference (src, dst, ref) VALUES (?, ?, ?)", + (db_id, module, ".__module__")) + check_dict = True + elif extra_relationship is types.GeneratorType: + key_dst.append(('.gi_code', obj.gi_code)) + key_dst.append(('.gi_frame', obj.gi_frame)) + key_dst.append(('.gi_yieldfrom', obj.gi_yieldfrom)) + elif extra_relationship is types.CoroutineType: + key_dst.append(('.cr_code', obj.cr_code)) + key_dst.append(('.cr_frame', obj.cr_frame)) + key_dst.append(('.cr_await', obj.cr_await)) + elif extra_relationship is types.AsyncGeneratorType: + key_dst.append(('.ag_code', obj.ag_code)) + key_dst.append(('.ag_frame', obj.ag_frame)) + elif extra_relationship is types.TracebackType: + key_dst.append(('.tb_frame', obj.tb_frame)) + key_dst.append(('.tb_next', obj.tb_next)) + elif extra_relationship is types.CellType: + try: + key_dst.append(('.cell_contents', obj.cell_contents)) + except ValueError: + pass # empty cell + elif extra_relationship is slice: + key_dst += [ + ('.start', obj.start), + ('.stop', obj.stop), + ('.step', obj.step), + ] + elif extra_relationship is super: + key_dst += [ + ('.__self__', obj.__self__), + ('.__self_class__', obj.__self_class__), + ('.__thisclass__', obj.__thisclass__), + ] + elif extra_relationship is weakref.ref: + # only the callback -- the referent is a weak edge (gc tp_traverse agrees) + key_dst.append(('.__callback__', obj.__callback__)) elif extra_relationship is types.MethodType: key_dst += [ ('.__func__', obj.__func__), ('.__self__', obj.__self__), ('.__doc__', obj.__doc__), ] - elif extra_relationship is types.BuiltinMethodType: - try: - key_dst.append(('.__self__', obj.__self__)) - except AttributeError: - pass - key_dst.append(('.__doc__', obj.__doc__)) - elif extra_relationship in (classmethod, staticmethod): - key_dst.append(('.__func__', obj.__func__)) - key_dst.append(('.__doc__', obj.__doc__)) - elif extra_relationship is property: - key_dst += [ - ('.fget', obj.fget), - ('.fset', obj.fset), - ('.fdel', obj.fdel), - ('.__doc__', obj.__doc__), - ] + elif extra_relationship is types.BuiltinMethodType: + try: + key_dst.append(('.__self__', obj.__self__)) + except AttributeError: + pass + key_dst.append(('.__doc__', obj.__doc__)) + elif extra_relationship in (classmethod, staticmethod): + key_dst.append(('.__func__', obj.__func__)) + key_dst.append(('.__doc__', obj.__doc__)) + elif extra_relationship is property: + key_dst += [ + ('.fget', obj.fget), + ('.fset', obj.fset), + ('.fdel', obj.fdel), + ('.__doc__', obj.__doc__), + ] elif extra_relationship is _DICT_PROXY_TYPE: key_dst.append(('.', gc.get_referents(obj)[0])) - elif extra_relationship is types.ModuleType: - key_dst.append(('.__doc__', obj.__doc__)) - check_dict = True + elif extra_relationship is types.ModuleType: + key_dst.append(('.__doc__', obj.__doc__)) + check_dict = True elif extra_relationship is collections.defaultdict: keys = obj.keys() key_dst += [('@{}'.format(self._ensure_db_id(key, refs=2)), dict.__getitem__(obj, key)) @@ -412,126 +463,143 @@ def add_obj(self, obj, refs=0): key_dst.append(('.__dict__', gc.get_referents(__dict__)[0])) else: key_dst.append(('.__dict__', __dict__)) - if check_slots: - if id(type(obj)) not in self.type_slots_map: - slot_names = set() - try: - mro = type(obj).mro() - except TypeError: - pass - else: - for type_ in mro: - try: # object.__getattribute__ to avoid any custom __getattr__ etc - slot_names.update(object.__getattribute__(type_, '__slots__')) - except AttributeError: - pass - self.type_slots_map[id(type(obj))] = slot_names or () - self.type_is_type_map[id(type(obj))] = issubclass(type(obj), type) - # () is a singleton which creates less object noise than set() - for key in self.type_slots_map[id(type(obj))]: - if key in ('__dict__', '__weakref__'): - # see https://docs.python.org/3/reference/datamodel.html#slots - continue - if key.startswith('__'): # private slots name mangling - key = "_" + obj.__class__.__name__ + key - try: - key_dst.append(('.' + key, object.__getattribute__(obj, key))) - except AttributeError: - pass # just because a slot exists doesn't mean it has a value - if self.type_is_type_map[id(type(obj))]: - key_dst.append(('.__bases__', obj.__bases__)) - try: - key_dst.append(('.__slots__', obj.__slots__)) - except AttributeError: - pass - try: - key_dst.append(('.__mro__', obj.__mro__)) - except AttributeError: - pass - key_dst.append(('.__doc__', obj.__doc__)) - self.conn.executemany( - "INSERT INTO reference (src, dst, ref) VALUES (?, ?, ?)", - [(db_id, self._ensure_db_id(dst, refs=2), key) for key, dst in key_dst]) - return db_id - - def add_frames(self): - ''' - add all of the current frames - ''' - cur_frames = sys._current_frames() - ignore_cur = sys._getframe() - for thread_id, frame in cur_frames.items(): - if frame is ignore_cur: - continue # don't log the stack that is taking the snapshot - self.conn.execute( - "INSERT INTO thread (stack_obj_id, thread_id) VALUES (?, ?)", - (self._ensure_db_id(frame, refs=2), thread_id)) - - def add_all(self): - # ignore this frame to avoid a bunch of spurious data - self.ignore_ids.add(id(sys._getframe())) - self.add_obj(type) - self.add_frames() - for obj in self.all_objects: - self.add_obj(obj, refs=2) - if self.use_gc: - for obj in self.all_objects: - db_id = self._ensure_db_id(obj) - for referrer in gc.get_referrers(obj): - if id(referrer) in self.ignore_ids: - continue - self.conn.execute( - "INSERT INTO gc_referrer (src, dst) VALUES (?, ?)", - (self._ensure_db_id(referrer, refs=1), db_id)) - for referent in gc.get_referents(obj): - if id(referent) in self.ignore_ids: - continue - self.conn.execute( - "INSERT INTO gc_referent (src, dst) VALUES (?, ?)", - (db_id, self._ensure_db_id(referent, refs=1))) - self.ignore_ids.remove(id(sys._getframe())) - + if check_slots: + if id(type(obj)) not in self.type_slots_map: + slot_names = set() + try: + mro = type(obj).mro() + except TypeError: + pass + else: + for type_ in mro: + try: # object.__getattribute__ to avoid any custom __getattr__ etc + slot_names.update(object.__getattribute__(type_, '__slots__')) + except AttributeError: + pass + self.type_slots_map[id(type(obj))] = slot_names or () + self.type_is_type_map[id(type(obj))] = issubclass(type(obj), type) + # () is a singleton which creates less object noise than set() + for key in self.type_slots_map[id(type(obj))]: + if key in ('__dict__', '__weakref__'): + # see https://docs.python.org/3/reference/datamodel.html#slots + continue + if key.startswith('__'): # private slots name mangling + key = "_" + obj.__class__.__name__ + key + try: + key_dst.append(('.' + key, object.__getattribute__(obj, key))) + except AttributeError: + pass # just because a slot exists doesn't mean it has a value + if self.type_is_type_map[id(type(obj))]: + key_dst.append(('.__bases__', obj.__bases__)) + try: + key_dst.append(('.__slots__', obj.__slots__)) + except AttributeError: + pass + try: + key_dst.append(('.__mro__', obj.__mro__)) + except AttributeError: + pass + key_dst.append(('.__doc__', obj.__doc__)) + if t not in _GC_COMPLETE_TYPES: + # tp_traverse ground truth for anything the named handlers missed + # (C iterators, asyncio.Task, ContextVar, functools.partial, ...) + covered = {id(dst) for _, dst in key_dst} + covered.add(id(t)) + for referent in gc.get_referents(obj): + if id(referent) not in covered: + key_dst.append(('', referent)) + self.conn.executemany( + "INSERT INTO reference (src, dst, ref) VALUES (?, ?, ?)", + [(db_id, self._ensure_db_id(dst, refs=2), key) for key, dst in key_dst]) + return db_id + + def add_frames(self): + ''' + add all of the current frames + ''' + cur_frames = sys._current_frames() + ignore_cur = sys._getframe() + for thread_id, frame in cur_frames.items(): + if frame is ignore_cur: + continue # don't log the stack that is taking the snapshot + self.conn.execute( + "INSERT INTO thread (stack_obj_id, thread_id) VALUES (?, ?)", + (self._ensure_db_id(frame, refs=2), thread_id)) + + def add_all(self): + # ignore this frame to avoid a bunch of spurious data + self.ignore_ids.add(id(sys._getframe())) + self.add_obj(type) + self.add_frames() + for obj in self.all_objects: + self.add_obj(obj, refs=2) + while self._pending_frames: + self.add_obj(self._pending_frames.pop(), refs=2) + if self.use_gc: + for obj in self.all_objects: + db_id = self._ensure_db_id(obj) + for referrer in gc.get_referrers(obj): + if id(referrer) in self.ignore_ids: + continue + self.conn.execute( + "INSERT INTO gc_referrer (src, dst) VALUES (?, ?)", + (self._ensure_db_id(referrer, refs=1), db_id)) + for referent in gc.get_referents(obj): + if id(referent) in self.ignore_ids: + continue + self.conn.execute( + "INSERT INTO gc_referent (src, dst) VALUES (?, ?)", + (db_id, self._ensure_db_id(referent, refs=1))) + self.ignore_ids.remove(id(sys._getframe())) + def finish(self): self.conn.execute( "UPDATE meta SET duration_s = ?", (time.time() - self.started,)) _finalize_wal(self.conn) self.conn.close() - - -# special types that have special-handling code for discovering contents + + +# special types that have special-handling code for discovering contents _SPECIAL_TYPES = set([ dict, list, tuple, set, frozenset, types.FrameType, types.FunctionType, - types.GeneratorType, types.MethodType, + types.GeneratorType, types.CoroutineType, types.AsyncGeneratorType, + types.TracebackType, types.CellType, slice, super, weakref.ref, + types.MethodType, _DICT_PROXY_TYPE, classmethod, staticmethod, property, types.BuiltinFunctionType, types.BuiltinMethodType, types.ModuleType, collections.deque, collections.defaultdict]) - - + +# exact builtin containers whose named traversal is definitionally complete; +# exempting them keeps the hot path from building a second id-set +_GC_COMPLETE_TYPES = frozenset([ + dict, list, tuple, set, frozenset, collections.deque, collections.defaultdict]) + + def dump_graph(path, print_info=False, use_gc=False): - ''' - dump a collection db to path; - the collection db is designed to be small - and write fast, so it needs post-processing - to e.g. add indices and compute values - before analysis - ''' - start = time.time() - _Writer.write_to_path(path, use_gc=use_gc) - if print_info: - duration = time.time() - start - memory = _get_memory_mb() - dumpsize = os.stat(path).st_size / 1024.0 / 1024 # MiB - objects = len(gc.get_objects()) + ''' + dump a collection db to path; + the collection db is designed to be small + and write fast, so it needs post-processing + to e.g. add indices and compute values + before analysis + ''' + start = time.time() + _Writer.write_to_path(path, use_gc=use_gc) + if print_info: + duration = time.time() - start + memory = _get_memory_mb() + dumpsize = os.stat(path).st_size / 1024.0 / 1024 # MiB + objects = len(gc.get_objects()) print("process memory usage: {:0.3f}MiB".format(memory)) print("total gc objects:", objects) - # print "wrote {} rows in {}".format( - # len(grapher.times), duration) - # print "db perf: {:0.3f}us/row".format( - # 1e6 * sum(grapher.times) / len(grapher.times)) - # print "overall perf: {:0.3f} s/GiB, {:0.03f} ms/object".format( - # 1024 * duration / memory, 1000 * duration / objects) - # print "duration - db time:", duration - sum(grapher.times) + # print "wrote {} rows in {}".format( + # len(grapher.times), duration) + # print "db perf: {:0.3f}us/row".format( + # 1e6 * sum(grapher.times) / len(grapher.times)) + # print "overall perf: {:0.3f} s/GiB, {:0.03f} ms/object".format( + # 1024 * duration / memory, 1000 * duration / objects) + # print "duration - db time:", duration - sum(grapher.times) print("duration: {0:0.1f}".format(duration)) print("compression - {:0.02f}MiB -> {:0.02f}MiB ({:0.01f}%)".format( memory, dumpsize, 100 * (1 - dumpsize / memory))) @@ -566,7 +634,7 @@ def wait_dump(pid): if os.WIFSIGNALED(status): raise RuntimeError('objex dump process {} terminated by signal {}'.format(pid, os.WTERMSIG(status))) raise RuntimeError('objex dump process {} ended unexpectedly'.format(pid)) - - -#f_globals INTEGER, -- object (a dict instance) OR should this be a ref type e.g. locals["foo"], globals["bar"] -#f_locals INTEGER, -- object (a dict instance) + + +#f_globals INTEGER, -- object (a dict instance) OR should this be a ref type e.g. locals["foo"], globals["bar"] +#f_locals INTEGER, -- object (a dict instance) diff --git a/pyproject.toml b/pyproject.toml index 56b1ddc..a32f4d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] keywords = ["memory", "graph", "sqlite", "debugging"] @@ -30,6 +31,11 @@ cli = [ "termcolor>=2.5", ] +[dependency-groups] +dev = [ + "pytest", +] + [project.urls] Homepage = "https://github.com/kurtbrose/objex" @@ -40,4 +46,5 @@ packages = ["objex"] addopts = "-m 'not slow'" markers = [ "slow: end-to-end dump creation smoke tests", + "scenario: subprocess leak-scenario integration tests", ] diff --git a/tests/scenarios/__init__.py b/tests/scenarios/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scenarios/leak_cases.py b/tests/scenarios/leak_cases.py new file mode 100644 index 0000000..baa762d --- /dev/null +++ b/tests/scenarios/leak_cases.py @@ -0,0 +1,182 @@ +''' +Leak-scenario fixtures: each scenario constructs a leak whose payload objects +are reachable only through a "tricky" edge (traceback, closure cell, suspended +coroutine frame, C-internal cache, ...), then dumps the process object graph. + +Run standalone in a bare subprocess: + + python -m tests.scenarios.leak_cases + +Each scenario defines a uniquely named payload class (``Payload_``) +so tests can locate instances by type name with zero ambiguity, and keeps the +leak alive via module globals or live threads/loops as the scenario dictates. +''' + +import argparse +import asyncio +import functools +import threading + +from objex import dump_graph + + +def _payload_class(name): + cls = type('Payload_' + name, (), {}) + cls.__module__ = __name__ + return cls + + +def module_global(dump_path): + '''baseline sanity: payloads held by a module-level list''' + cls = _payload_class('module_global') + globals()['LEAK_module_global'] = [cls() for _ in range(12)] + del cls + dump_graph(dump_path) + + +def exception_traceback(dump_path): + ''' + an exception raised deep in a 10-frame call chain with a payload in the + locals of every frame, caught and stored on a module global; only the + exception object is stored -- payloads are reachable solely via + __traceback__ -> tb_frame -> locals + ''' + cls = _payload_class('exception_traceback') + + def deep(n): + payload = cls() # noqa: F841 -- pinned by the frame in the traceback + if n <= 1: + raise RuntimeError('leak anchor') + deep(n - 1) + + try: + deep(10) + except RuntimeError as exc: + globals()['LEAK_exception_traceback'] = exc + del cls + dump_graph(dump_path) + + +def _make_closure_callback(cls): + # built in a factory: `del payloads` in the caller would clear the shared + # closure cell (deleting a cellvar empties the cell), killing the leak + payloads = [cls() for _ in range(12)] + + def callback(): + return payloads + + return callback + + +def closure_cell(dump_path): + '''a module-global callback whose closure cell is the only path to payloads''' + cls = _payload_class('closure_cell') + globals()['LEAK_closure_cell'] = _make_closure_callback(cls) + del cls + dump_graph(dump_path) + + +def suspended_coroutine(dump_path): + ''' + a sibling asyncio.Task suspended at an await with payloads in its frame + locals; the dump happens while the loop is running, so payloads are + reachable only via Task -> coroutine -> cr_frame -> locals + ''' + cls = _payload_class('suspended_coroutine') + + async def holder(gate): + payloads = [cls() for _ in range(12)] # noqa: F841 -- pinned by the suspended frame + await gate.wait() + + async def main(): + gate = asyncio.Event() + task = asyncio.ensure_future(holder(gate)) + globals()['LEAK_suspended_coroutine'] = task + await asyncio.sleep(0) # let holder run up to its await + dump_graph(dump_path) + gate.set() + await task + + asyncio.run(main()) + + +def thread_frame(dump_path): + '''a daemon thread parked in a deep call with payloads in its locals''' + cls = _payload_class('thread_frame') + stop_event = threading.Event() + started = threading.Event() + + def parked(payloads, n=10): + if n: + return parked(payloads, n - 1) + started.set() + while not stop_event.wait(0.01): + pass + + thread = threading.Thread( + target=parked, args=([cls() for _ in range(12)],), daemon=True) + thread.start() + assert started.wait(timeout=5) + del cls + dump_graph(dump_path) + stop_event.set() + thread.join(timeout=5) + + +def lru_cache(dump_path): + '''payload containers held only by a C-internal lru_cache dict''' + cls = _payload_class('lru_cache') + + @functools.lru_cache(maxsize=None) + def cached(n): + return [cls() for _ in range(3)] + + for i in range(4): + cached(i) + globals()['LEAK_lru_cache'] = cached + del cls, cached + dump_graph(dump_path) + + +def iterator_pin(dump_path): + '''a module-global dict_keyiterator is the only strong path to a dict of payloads''' + cls = _payload_class('iterator_pin') + payload_dict = {cls(): i for i in range(12)} + globals()['LEAK_iterator_pin'] = iter(payload_dict.keys()) + del cls, payload_dict + dump_graph(dump_path) + + +SCENARIOS = { + 'module_global': module_global, + 'exception_traceback': exception_traceback, + 'closure_cell': closure_cell, + 'suspended_coroutine': suspended_coroutine, + 'thread_frame': thread_frame, + 'lru_cache': lru_cache, + 'iterator_pin': iterator_pin, +} + +# minimum payload instances each scenario guarantees at dump time +PAYLOAD_COUNTS = { + 'module_global': 12, + 'exception_traceback': 10, + 'closure_cell': 12, + 'suspended_coroutine': 12, + 'thread_frame': 12, + 'lru_cache': 12, + 'iterator_pin': 12, +} + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument('scenario', choices=sorted(SCENARIOS)) + parser.add_argument('dump_path') + args = parser.parse_args(argv) + SCENARIOS[args.scenario](args.dump_path) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_gc_coverage.py b/tests/test_gc_coverage.py new file mode 100644 index 0000000..40c0c55 --- /dev/null +++ b/tests/test_gc_coverage.py @@ -0,0 +1,76 @@ +''' +Ground-truth traversal coverage audit: for every gc-tracked object the +exporter saw, every `gc.get_referents` (tp_traverse) edge to another seen +object must have a corresponding row in the reference table. + +This is the Python-upgrade regression guard: a new CPython version that +introduces an object type the exporter silently fails to traverse shows up +here as a readable list of missing type names. +''' + +import gc +import sqlite3 + +import pytest + +from objex.dbutils import _run_ddl +from objex.exporter import _Writer +from objex.schema import _SCHEMA + + +# types with a known, justified traversal gap; every entry needs a one-line +# reason. Expected ~empty thanks to the fallback in _Writer.add_obj -- +# the residue is objects mutated AFTER traversal (this audit re-runs +# gc.get_referents once the dump is complete, so anything the audit itself +# touches can grow referents the exporter never saw). +ALLOWED_GAPS = frozenset({ + 'frame', # live stacks (this test's own) mutate between traversal and audit + 'dict', # module/instance dicts gain entries after traversal (e.g. __main__) + 'Connection', # this audit's sqlite3 statement cache grows as it queries + '_lru_cache_wrapper', # C-internal caches gain entries after traversal +}) + + +@pytest.mark.scenario +def test_gc_referents_are_traversed(tmp_path): + db_path = tmp_path / 'coverage.db' + conn = sqlite3.connect(str(db_path)) + conn.text_factory = str + try: + _run_ddl(conn, _SCHEMA) + writer = _Writer(conn) + writer.add_all() + conn.commit() + + edge_set = set(conn.execute('SELECT src, dst FROM reference')) + # dict keys are encoded in the ref string ('@'), not as dst + edge_set.update(conn.execute( + "SELECT src, CAST(substr(ref, 2) AS INTEGER) FROM reference" + " WHERE ref LIKE '@%'")) + finally: + conn.close() + + object_id_map = writer.object_id_map + misses = {} # type name -> [(src_db_id, dst_db_id, dst_type_name), ...] + for obj in writer.all_objects: + src_db_id = object_id_map.get(id(obj)) + if src_db_id is None: + continue + for referent in gc.get_referents(obj): + if referent is type(obj): + continue # instance -> type edge lives in object.pytype, not reference + dst_db_id = object_id_map.get(id(referent)) + if dst_db_id is None: + continue + if (src_db_id, dst_db_id) not in edge_set: + misses.setdefault(type(obj).__name__, []).append( + (src_db_id, dst_db_id, type(referent).__name__)) + + unexpected = {name: len(examples) for name, examples in misses.items() + if name not in ALLOWED_GAPS} + assert not unexpected, ( + 'exporter missed tp_traverse edges for types (type -> miss count): ' + '%r; examples: %r' % ( + unexpected, + {name: misses[name][:3] for name in unexpected}, + )) diff --git a/tests/test_objex.py b/tests/test_objex.py index c303c98..397365c 100644 --- a/tests/test_objex.py +++ b/tests/test_objex.py @@ -306,31 +306,18 @@ def test_dump_graph_reconciles_wal_into_main_db(self): finally: conn.close() - @pytest.mark.slow - def test_dump_graph_survives_getfullargspec_failures(self): - dump_path = Path(self.temp_dir.name) / 'getfullargspec-failure.db' - - def raising_getfullargspec(obj): - raise TypeError('unsupported callable') - - with patch('objex.exporter.inspect.getfullargspec', side_effect=raising_getfullargspec): - dump_graph(str(dump_path), use_gc=False) - - with Reader(str(dump_path)) as reader: - assert reader.object_count() > 0 - def test_dump_graph_survives_empty_closure_cells(self): - with Reader(str(self.shared_dump_path)) as reader: + with Reader(str(self.shared_analysis_path)) as reader: assert reader.object_count() > 0 assert reader.sql_val("SELECT COUNT(*) FROM function WHERE func_name = ?", ('inner',)) > 0 def test_dump_graph_survives_non_attributeerror_dunder_dict_access(self): - with Reader(str(self.shared_dump_path)) as reader: + with Reader(str(self.shared_analysis_path)) as reader: assert reader.find_type_by_name('ExplodingGetattr') def test_dump_graph_uses_resolved_dunder_dict_without_reaccess(self): - with Reader(str(self.shared_dump_path)) as reader: + with Reader(str(self.shared_analysis_path)) as reader: assert reader.find_type_by_name('MisdirectingGetattribute') @pytest.mark.slow @@ -347,7 +334,11 @@ def test_spawn_dump_and_wait_dump(self): assert dump_path.exists() assert not dump_path.with_name(dump_path.name + '-wal').exists() - with Reader(str(dump_path)) as reader: + # Reader on a raw unindexed collection DB is pathological (see TODO.md); + # build the analysis DB first, as users are directed to + analysis_path = dump_path.with_name('forked-analysis.db') + make_analysis_db(str(dump_path), str(analysis_path)) + with Reader(str(analysis_path)) as reader: assert reader.object_count() > 0 def test_module_help_does_not_start_console(self): @@ -364,6 +355,45 @@ def test_module_help_does_not_start_console(self): assert 'explore' in result.stdout assert 'WELCOME TO OBJEX EXPLORER' not in result.stdout + def _run_q(self, *args): + return subprocess.run( + [sys.executable, '-m', 'objex', 'q', str(self.shared_analysis_path)] + list(args), + capture_output=True, + text=True, + cwd=Path(__file__).resolve().parents[1], + ) + + def test_q_summary_prints_json(self): + result = self._run_q('summary') + assert result.returncode == 0 + assert json.loads(result.stdout)['object_count'] > 0 + + def test_q_path_to_module_shape(self): + with Reader(str(self.shared_analysis_path)) as reader: + # the deque held at module-global GO_NESTED.level1.level2.target + instance_id = reader.sql_val( + "SELECT dst FROM reference WHERE ref = '.target' LIMIT 1") + result = self._run_q('path-to-module', 'id={}'.format(instance_id)) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert payload['items'] + for path in payload['items']: + assert path # non-empty list of path segments + + def test_q_sql_reads(self): + result = self._run_q('sql', 'SELECT count(*) FROM object') + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert payload['rows'] and payload['rows'][0][0] > 0 + + def test_q_sql_is_read_only(self): + result = self._run_q('sql', 'DROP TABLE object') + assert result.returncode != 0 + assert 'error' in json.loads(result.stdout) + # the table survived + with Reader(str(self.shared_analysis_path)) as reader: + assert reader.object_count() > 0 + def test_module_cli_parser_supports_legacy_explore_and_analysis_command(self): result = subprocess.run( [sys.executable, '-m', 'objex', 'make-analysis-db', '--help'], diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py new file mode 100644 index 0000000..95452aa --- /dev/null +++ b/tests/test_scenarios.py @@ -0,0 +1,79 @@ +''' +Leak-scenario integration tests: each scenario builds a leak in a bare +subprocess whose payloads are reachable only through a "tricky" edge, dumps +the graph, and this side proves the leak is *attributable* -- the payloads +are found by type name and a path to a module/frame root exists that passes +through the scenario's anchor edge. +''' + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from objex import make_analysis_db +from objex.explorer import Reader + +from tests.scenarios.leak_cases import SCENARIOS, PAYLOAD_COUNTS + + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# scenarios whose leak anchor is a live frame rather than a module global +FRAME_SCENARIOS = frozenset({'suspended_coroutine', 'thread_frame'}) + +# every marker must appear in some ref of the returned path +PATH_MARKERS = { + 'module_global': ('LEAK_module_global',), + 'exception_traceback': ('.__traceback__', '.tb_frame'), + 'closure_cell': (".locals['payloads']",), + 'suspended_coroutine': (".locals['payloads']",), + 'thread_frame': (".locals['payloads']",), + 'lru_cache': ('',), + 'iterator_pin': ('',), +} + + +@pytest.mark.scenario +@pytest.mark.parametrize('name', sorted(SCENARIOS)) +def test_scenario_leak_is_attributable(name, tmp_path): + dump_path = tmp_path / (name + '.db') + analysis_path = tmp_path / (name + '-analysis.db') + env = dict(os.environ) + env['PYTHONPATH'] = str(REPO_ROOT) + subprocess.run( + [sys.executable, '-m', 'tests.scenarios.leak_cases', name, str(dump_path)], + check=True, + cwd=REPO_ROOT, + env=env, + ) + make_analysis_db(str(dump_path), str(analysis_path)) + + with Reader(str(analysis_path)) as reader: + type_ids = reader.find_type_by_name('Payload_' + name) + assert len(type_ids) == 1 + type_id = type_ids[0] + assert reader.obj_instance_count(type_id) >= PAYLOAD_COUNTS[name] + + instance_id = reader.random_instances(type_id, limit=1)[0] + if name in FRAME_SCENARIOS: + paths = reader.find_path_to_frame(instance_id) + else: + paths = reader.find_path_to_module(instance_id) + assert paths and paths[0], 'no root path found for scenario %r' % name + + refs = [str(ref) for _, ref in paths[0]] + for marker in PATH_MARKERS[name]: + assert any(marker in ref for ref in refs), ( + 'marker %r not in path refs %r' % (marker, refs)) + + if name == 'suspended_coroutine': + # the path roots at the suspended frame; prove the frame itself is + # pinned through the coroutine chain (Task -> coroutine -> cr_frame) + root_frame_id = paths[0][0][0] + assert reader.sql_val( + "SELECT COUNT(*) FROM reference WHERE ref = '.cr_frame' AND dst = ?", + (root_frame_id,), + ) >= 1 diff --git a/uv.lock b/uv.lock index 8f87873..c249838 100644 --- a/uv.lock +++ b/uv.lock @@ -1,19 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.9" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version < '3.10'", -] - -[[package]] -name = "boltons" -version = "25.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" }, -] +requires-python = ">=3.10" [[package]] name = "colorama" @@ -29,32 +16,17 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -64,29 +36,35 @@ wheels = [ name = "objex" version = "0.15.dev0" source = { editable = "." } -dependencies = [ - { name = "boltons" }, + +[package.optional-dependencies] +cli = [ + { name = "colorama" }, + { name = "termcolor" }, ] [package.dev-dependencies] dev = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] [package.metadata] -requires-dist = [{ name = "boltons" }] +requires-dist = [ + { name = "colorama", marker = "extra == 'cli'", specifier = ">=0.4.6" }, + { name = "termcolor", marker = "extra == 'cli'", specifier = ">=2.5" }, +] +provides-extras = ["cli"] [package.metadata.requires-dev] dev = [{ name = "pytest" }] [[package]] name = "packaging" -version = "26.0" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -100,114 +78,99 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" -version = "8.4.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] -name = "pytest" -version = "9.0.2" +name = "termcolor" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] [[package]] name = "tomli" -version = "2.4.0" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ]