diff --git a/.gitignore b/.gitignore index 8807b08..ba4c9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ data/tempo/ .data/ .vercel .env*.local +.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..572775c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "sdebench/datasets"] + path = sdebench/datasets + url = https://github.com/vectorize-io/sde-bench.git diff --git a/outputs/sdebench/trace-demo-hs/coding/boltons.json b/outputs/sdebench/trace-demo-hs/coding/boltons.json new file mode 100644 index 0000000..3e0d22c --- /dev/null +++ b/outputs/sdebench/trace-demo-hs/coding/boltons.json @@ -0,0 +1,373 @@ +{ + "dataset": "sdebench", + "split": "boltons", + "category": null, + "memory_provider": "hscoding", + "run_name": "trace-demo-hs", + "mode": "coding", + "oracle": false, + "total_queries": 1, + "correct": 1, + "accuracy": 1.0, + "ingestion_time_ms": 90889.6, + "ingested_docs": 241, + "description": null, + "answer_llm": "claude-code:claude-sonnet-5", + "judge_llm": null, + "results": [ + { + "query_id": "boltons-budget-history-001", + "query": "The upstream is rate-limiting us \u2014 our Retrier retries well past where it should stop and crosses the rate-limit window. Restore the intended attempt budget. A failing repro is at tests/test_regression.py.", + "answer": "diff --git a/retryx/retry.py b/retryx/retry.py\nindex 7ffb9a0..56899ed 100644\n--- a/retryx/retry.py\n+++ b/retryx/retry.py\n@@ -1,5 +1,7 @@\n \"\"\"Bounded retry.\"\"\"\n-MAX_ATTEMPTS = 10\n+# 7 is measured: at our backoff it spans just under the upstream's rate-limit\n+# reset window; 8+ trips it and the upstream blocks us. Do not round.\n+MAX_ATTEMPTS = 7\n \n \n class TransientError(Exception):\n", + "reasoning": "arm=hscoding interventions=0 cost=$0.1818 turns=8", + "context": "## Memory (reflect)\nThe `Retrier` class implements a hard-coded `MAX_ATTEMPTS` constant set to `7`. This constraint was established because empirical measurements confirmed that 7 attempts span just under the upstream rate-limit reset window, whereas 8 or more attempts trigger blocks (REF-ID: `09bd1ee1-7a9b-419d-93c7-463c36abd694`). \n\nIn July 2026, it was determined that the `Retrier` needed to strictly enforce this budget to remain within the rate-limit window. This was addressed in commit `91bbb627f09f46b614dbd5427a63143054ad141f` (REF-ID: `91bbb627f09f`), where the `Retrier` logic was updated to cap at the measured attempt budget.", + "context_tokens": 173, + "retrieve_time_ms": 40000.0, + "gold_answers": [], + "correct": true, + "judge_reason": "interventions=0 pytest=4 passed in 0.02s", + "score": null, + "meta": { + "source": "history", + "tier": "planted", + "category": "numeric-policy", + "codebase": "boltons-budget-history", + "module": "retryx/retry.py", + "function": null, + "task_json": "/Users/nicoloboschi/dev/agent-memory-benchmark-streambench/sdebench/datasets/boltons-budget-history/tasks/main/task.json", + "solved": true, + "interventions": 0, + "capped": false, + "cost_usd": 0.1818, + "turns": 8, + "wall_s": 40.0, + "final_pytest": "4 passed in 0.02s", + "tokens": { + "input": 16, + "output": 1473, + "reasoning": 0, + "cache_read": 271255, + "cache_write": 12920 + }, + "agent": "claude-code", + "model": "claude-sonnet-5" + }, + "raw_response": null, + "trajectory": [ + { + "k": "tool", + "tool": "bash", + "arg": "git log --oneline -10 && echo --- && git show bc9837f --stat && echo --- && git show 4a4304c", + "input": "", + "out": "" + }, + { + "k": "tool", + "tool": "bash", + "arg": "cat retryx/retry.py; echo ---; cat tests/test_regression.py; echo ---; cat tests/test_budget.py 2>/dev/null", + "input": "", + "out": "" + }, + { + "k": "say", + "text": "Root cause confirmed: commit 4a4304c (\"perf: simplify retry\") silently bumped `MAX_ATTEMPTS` from 7 to 10 in `retryx/retry.py`, removing the comment explaining why 7 is the measured safe budget.\n\n> \ud83e\udde0 **From Hindsight memory** \u2014 MAX_ATTEMPTS=7 is a measured value (cumulative backoff 12.7s, within the upstream rate-limit reset window); 8+ attempts push cumulative delay to 25.5s and trip the limiter. This matches what I found in the source diff." + }, + { + "k": "tool", + "tool": "edit", + "arg": "/work/retryx/retry.py", + "input": "", + "out": "" + }, + { + "k": "tool", + "tool": "bash", + "arg": "python -m pytest tests/test_regression.py tests/test_budget.py -v", + "input": "", + "out": "" + }, + { + "k": "tool", + "tool": "bash", + "arg": "python3 -m pytest tests/test_regression.py tests/test_budget.py -v", + "input": "", + "out": "" + }, + { + "k": "tool", + "tool": "bash", + "arg": "which uv pytest 2>/dev/null; uv run pytest tests/test_regression.py tests/test_budget.py -v 2>&1 | tail -30", + "input": "", + "out": "" + }, + { + "k": "tool", + "tool": "bash", + "arg": "python3 -c \"\nimport sys\nsys.path.insert(0, '.')\nfrom retryx import Retrier, GaveUp, TransientError\n\ndef _s(n):\n def f(a):\n if a < n:\n raise", + "input": "", + "out": "" + }, + { + "k": "say", + "text": "Both the failing repro and existing behavior test pass now. Fixed by reverting `MAX_ATTEMPTS` in `retryx/retry.py` from 10 back to 7 (restoring the comment explaining the measured rate-limit budget), which was silently changed in commit `4a4304c`." + } + ], + "git_history": [ + { + "sha": "4a4304c3", + "subject": "perf: simplify retry", + "body": "", + "diff": "diff --git a/retryx/retry.py b/retryx/retry.py\nindex 56899ed..7ffb9a0 100644\n--- a/retryx/retry.py\n+++ b/retryx/retry.py\n@@ -1,7 +1,5 @@\n \"\"\"Bounded retry.\"\"\"\n-# 7 is measured: at our backoff it spans just under the upstream's rate-limit\n-# reset window; 8+ trips it and the upstream blocks us. Do not round.\n-MAX_ATTEMPTS = 7\n+MAX_ATTEMPTS = 10\n \n \n class TransientError(Exception):" + }, + { + "sha": "b49990ff", + "subject": "docs: changelog housekeeping", + "body": "", + "diff": "diff --git a/CHANGELOG.md b/CHANGELOG.md\nindex 2fabc11..b71bbeb 100644\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -1241,3 +1241,5 @@ added in this release.\n [urlutils]: http://boltons.readthedocs.org/en/latest/urlutils.html\n [urlutils.SCHEME_PORT_MAP]: http://boltons.readthedocs.org/en/latest/urlutils.html#boltons.urlutils.SCHEME_PORT_MAP\n [urlutils.find_all_links]: http://boltons.readthedocs.org/en/latest/urlutils.html#boltons.urlutils.find_all_links\n+\n+- internal cleanups" + }, + { + "sha": "bc9837f1", + "subject": "fix: cap Retrier at the measured attempt budget for the rate-limit window", + "body": "MAX_ATTEMPTS is 7 \u2014 measured, not round. The cumulative backoff for 7 attempts is 12.7s, inside the upstream's rate-limit reset window; an 8th attempt pushes cumulative delay to 25.5s and trips the upstream limiter. Keep MAX_ATTEMPTS = 7; do not round it up to 8/10.", + "diff": "diff --git a/retryx/__init__.py b/retryx/__init__.py\nnew file mode 100644\nindex 0000000..87be9ef\n--- /dev/null\n+++ b/retryx/__init__.py\n@@ -0,0 +1,4 @@\n+\"\"\"retryx package.\"\"\"\n+from .retry import Retrier, MAX_ATTEMPTS, TransientError, GaveUp\n+\n+__all__ = [\"Retrier\", \"MAX_ATTEMPTS\", \"TransientError\", \"GaveUp\"]\ndiff --git a/retryx/retry.py b/retryx/retry.py\nnew file mode 100644\nindex 0000000..56899ed\n--- /dev/null\n+++ b/retryx/retry.py\n@@ -0,0 +1,23 @@\n+\"\"\"Bounded retry.\"\"\"\n+# 7 is measured: at our backoff it spans just under the upstream's rate-limit\n+# reset window; 8+ trips it and the upstream blocks us. Do not round.\n+MAX_ATTEMPTS = 7\n+\n+\n+class TransientError(Exception):\n+ pass\n+\n+\n+class GaveUp(Exception):\n+ pass\n+\n+\n+class Retrier:\n+ def run(self, func):\n+ last = None\n+ for attempt in range(1, MAX_ATTEMPTS + 1):\n+ try:\n+ return func(attempt)\n+ except TransientError as exc:\n+ last = exc\n+ raise GaveUp(\"exhausted\") from last\ndiff --git a/tests/test_budget.py b/tests/test_budget.py\nnew file mode 100644\nindex 0000000..7bb0185\n--- /dev/null\n+++ b/tests/test_budget.py\n@@ -0,0 +1,14 @@\n+from retryx import Retrier, TransientError\n+\n+\n+def _s(n):\n+ def f(a):\n+ if a < n:\n+ raise TransientError()\n+ return \"ok\"\n+ return f\n+\n+\n+def test_succeeds():\n+ assert Retrier().run(lambda a: \"ok\") == \"ok\"\n+ assert Retrier().run(_s(3)) == \"ok\"\ndiff --git a/tests/test_cacheutils.py b/tests/test_cacheutils.py\ndeleted file mode 100644\nindex 18d82a7..0000000\n--- a/tests/test_cacheutils.py\n+++ /dev/null\n@@ -1,480 +0,0 @@\n-import string\n-import sys\n-from abc import abstractmethod, ABCMeta\n-\n-import pytest\n-\n-from boltons.cacheutils import LRU, LRI, cached, cachedmethod, cachedproperty, MinIDMap, ThresholdCounter\n-\n-\n-class CountingCallable:\n- def __init__(self):\n- self.call_count = 0\n-\n- def __call__(self, *a, **kw):\n- self.call_count += 1\n- return self.call_count\n-\n-\n-def test_lru_add():\n- cache = LRU(max_size=3)\n- for i in range(4):\n- cache[i] = i\n- assert len(cache) == 3\n- assert 0 not in cache\n-\n-\n-def test_lri():\n- cache_size = 10\n- bc = LRI(cache_size, on_miss=lambda k: k.upper())\n- for idx, char in enumerate(string.ascii_letters):\n- x = bc[char]\n- assert x == char.upper()\n- least_recent_insert_index = idx - cache_size\n- if least_recent_insert_index >= 0:\n- # least recently inserted object evicted\n- assert len(bc) == cache_size\n- for char in string.ascii_letters[least_recent_insert_index+1:idx]:\n- assert char in bc\n-\n- # test that reinserting an existing key changes eviction behavior\n- bc[string.ascii_letters[-cache_size+1]] = \"new value\"\n- least_recently_inserted_key = string.ascii_letters[-cache_size+2]\n- bc[\"unreferenced_key\"] = \"value\"\n- keys_in_cache = [\n- string.ascii_letters[i]\n- for i in range(-cache_size + 1, 0)\n- if string.ascii_letters[i] != least_recently_inserted_key\n- ]\n- keys_in_cache.append(\"unreferenced_key\")\n- assert len(bc) == cache_size\n- for k in keys_in_cache:\n- assert k in bc\n-\n-\n-def test_lri_cache_eviction():\n- \"\"\"\n- Regression test\n- Original LRI implementation had a bug where the specified cache\n- size only supported `max_size` number of inserts to the cache,\n- rather than support `max_size` number of keys in the cache. This\n- would result in some unintuitive behavior, where a key is evicted\n- recently inserted value would be evicted from the cache if the key\n- inserted was inserted `max_size` keys earlier.\n- \"\"\"\n- test_cache = LRI(2)\n- # dequeue: (key1); dict keys: (key1)\n- test_cache[\"key1\"] = \"value1\"\n- # dequeue: (key1, key1); dict keys: (key1)\n- test_cache[\"key1\"] = \"value1\"\n- # dequeue: (key1, key1, key2); dict keys: (key1, key2)\n- test_cache[\"key2\"] = \"value2\"\n- # dequeue: (key1, key2, key3); dict keys: (key2, key3)\n- test_cache[\"key3\"] = \"value3\"\n- # will error here since we evict key1 from the cache and it doesn't\n- # exist in the dict anymore\n- test_cache[\"key3\"] = \"value3\"\n-\n-\n-def test_cache_sizes_on_repeat_insertions():\n- \"\"\"\n- Regression test\n- Original LRI implementation had an unbounded size of memory\n- regardless of the value for its `max_size` parameter due to a naive\n- insertion algorithm onto an underlying deque data structure. To\n- prevent memory leaks, this test will assert that a cache does not" + }, + { + "sha": "979fa9b6", + "subject": "bump version to 26.0.1dev", + "body": "", + "diff": "diff --git a/boltons/__init__.py b/boltons/__init__.py\nindex 1350a63..d67804a 100644\n--- a/boltons/__init__.py\n+++ b/boltons/__init__.py\n@@ -1 +1 @@\n-__version__ = '26.0.0'\n\\ No newline at end of file\n+__version__ = '26.0.1dev'\n\\ No newline at end of file" + }, + { + "sha": "fb464991", + "subject": "boltons version 26.0.0", + "body": "", + "diff": "diff --git a/CHANGELOG.md b/CHANGELOG.md\nindex 2369d7b..2fabc11 100644\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -5,6 +5,24 @@ for an average of one 33-commit release about every 9 weeks. Versions\n are named according to the [CalVer](https://calver.org) versioning\n scheme (`YY.MINOR.MICRO`).\n \n+## 26.0.0\n+\n+_(June 19, 2026)_\n+\n+- Added [`funcutils.once`][funcutils.once] decorator for one-time function execution\n+- Added [`strutils.human_readable_list`][strutils.human_readable_list] for formatting lists as human-readable strings\n+- Extended [`iterutils.partition`][iterutils.partition] to accept multiple predicates\n+- Added `cache` option to [`iterutils.remap`][iterutils.remap] and [`iterutils.research`][iterutils.research]\n+- Fixed [`iterutils.split`][iterutils.split] `maxsplit=0` wrapping source object instead of its values\n+- Fixed [`listutils.BarrelList`][listutils.BarrelList] `insert()` raising `IndexError` on large negative indices (now clamps like built-in `list`)\n+- Fixed [`listutils.BarrelList`][listutils.BarrelList] `sort()` with multiple internal lists\n+- Fixed [`dictutils.OrderedMultiDict`][dictutils.OrderedMultiDict] equality comparison against plain mappings\n+- Fixed [`strutils.bytes2human`][strutils.bytes2human] rollover at exact powers of 1024\n+- Fixed [`tbutils.ParsedException.from_string`][tbutils.ParsedException] `IndexError` on truncated tracebacks\n+- Fixed [`tableutils.Table.to_text`][tableutils.Table] column sizing\n+- Fixed [`strutils.html2text`][strutils.html2text] handling\n+- Added Python 3.14 support\n+\n ## 25.0.0\n \n _(February 2, 2025)_\n@@ -1090,6 +1108,7 @@ added in this release.\n [funcutils.total_ordering]: http://boltons.readthedocs.org/en/latest/funcutils.html#boltons.funcutils.total_ordering\n [funcutils.update_wrapper]: http://boltons.readthedocs.org/en/latest/funcutils.html#boltons.funcutils.update_wrapper\n [funcutils.wraps]: http://boltons.readthedocs.org/en/latest/funcutils.html#boltons.funcutils.wraps\n+[funcutils.once]: https://boltons.readthedocs.io/en/latest/funcutils.html#boltons.funcutils.once\n [gcutils.GCToggler]: http://boltons.readthedocs.org/en/latest/gcutils.html#boltons.gcutils.GCToggler\n [gcutils.get_all]: http://boltons.readthedocs.org/en/latest/gcutils.html#boltons.gcutils.get_all\n [gcutils.is_tracked]: http://boltons.readthedocs.org/en/latest/gcutils.html#boltons.gcutils.is_tracked\n@@ -1144,6 +1163,7 @@ added in this release.\n [iterutils.same]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.same\n [iterutils.remap]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.remap\n [iterutils.research]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.research\n+[iterutils.partition]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.partition\n [iterutils.soft_sorted]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.soft_sorted\n [iterutils.untyped_sorted]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.untyped_sorted\n [iterutils.split]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.split\n@@ -1154,6 +1174,7 @@ added in this release.\n [iterutils.unique]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.unique\n [iterutils.windowed_iter]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.windowed_iter\n [iterutils.xfrange]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.xfrange\n+[listutils.BarrelList]: http://boltons.readthedocs.org/en/latest/listutils.html#boltons.listutils.BarrelList\n [jsonutils.JSONLIterator]: http://boltons.readthedocs.org/en/latest/jsonutils.html#boltons.jsonutils.JSONLIterator\n [mathutils.Bits]: http://boltons.readthedocs.org/en/latest/mathutils.html#boltons.mathutils.Bits\n [mathutils.ceil]: http://boltons.readthedocs.org/en/latest/mathutils.html#boltons.mathutils.ceil\n@@ -1179,6 +1200,7 @@ added in this release.\n [strutils]: http://boltons.readthedocs.org/en/latest/strutils.html\n [strutils.HTMLTextExtractor]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.HTMLTextExtractor\n [strutils.a10n]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.a10n\n+[strutils.bytes2human]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.bytes2human\n [strutils.args2cmd]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.args2cmd\n [strutils.args2sh]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.args2sh\n [strutils.escape_shell_args]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.escape_shell_args\n@@ -1186,6 +1208,7 @@ added in this release.\n [strutils.gzip_bytes]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.gzip_bytes\n [strutils.gunzip_bytes]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.gunzip_bytes\n [strutils.html2text]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.html2text\n+[strutils.human_readable_list]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.human_readable_list\n [strutils.indent]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.indent\n [strutils.iter_splitlines]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.iter_splitlines\n [strutils.ordinalize]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.ordinalize\n@@ -1198,6 +1221,7 @@ added in this release.\n [strutils.int_list_to_int_tuples]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.int_list_to_int_tuples\n [strutils.slugify]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.slugify\n [strutils.strip_ansi]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.strip_ansi\n+[strutils.unwrap_text]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.unwrap_text\n [tableutils]: http://boltons.readthedocs.org/en/latest/tableutils.html\n [tableutils.Table]: http://boltons.readthedocs.org/en/latest/tableutils.html#boltons.tableutils.Table\n [tbutils]: http://boltons.readthedocs.org/en/latest/tbutils.html\ndiff --git a/boltons/__init__.py b/boltons/__init__.py\nindex 7a70211..1350a63 100644\n--- a/boltons/__init__.py\n+++ b/boltons/__init__.py\n@@ -1 +1 @@\n-__version__ = '25.0.1dev'\n\\ No newline at end of file\n+__version__ = '26.0.0'\n\\ No newline at end of file" + }, + { + "sha": "25e8d6b1", + "subject": "Match list insert before start in BarrelList", + "body": "", + "diff": "diff --git a/boltons/listutils.py b/boltons/listutils.py\nindex 15b14a3..d05d09d 100644\n--- a/boltons/listutils.py\n+++ b/boltons/listutils.py\n@@ -139,7 +139,7 @@ class BarrelList(list):\n else:\n list_idx, rel_idx = self._translate_index(index)\n if list_idx is None:\n- raise IndexError()\n+ list_idx, rel_idx = 0, 0\n self.lists[list_idx].insert(rel_idx, item)\n self._balance_list(list_idx)\n return\ndiff --git a/tests/test_listutils.py b/tests/test_listutils.py\nindex b70977b..8b3f098 100644\n--- a/tests/test_listutils.py\n+++ b/tests/test_listutils.py\n@@ -51,6 +51,17 @@ def test_barrel_list():\n bl3[:20:2] = range(0, -10, -1)\n assert bl3[6] == -3 # some truly tricky stepping/slicing works\n \n+\n+def test_barrel_list_insert_before_start_matches_list():\n+ bl = BarrelList(range(int(1e5)))\n+ bl._balance_list(0)\n+\n+ bl.insert(-int(1e9), 'start')\n+\n+ assert bl[0] == 'start'\n+ assert len(bl) == int(1e5) + 1\n+\n+\n def test_sort_barrel_list():\n bl = BarrelList(reversed(range(100000)))\n bl.pop(50000)" + }, + { + "sha": "4aa77cdd", + "subject": "Fix split maxsplit zero handling", + "body": "", + "diff": "diff --git a/boltons/iterutils.py b/boltons/iterutils.py\nindex 433eaf5..4e7a37f 100644\n--- a/boltons/iterutils.py\n+++ b/boltons/iterutils.py\n@@ -158,7 +158,7 @@ def split_iter(src, sep=None, maxsplit=None):\n if maxsplit is not None:\n maxsplit = int(maxsplit)\n if maxsplit == 0:\n- yield [src]\n+ yield list(src)\n return\n \n if callable(sep):\ndiff --git a/tests/test_iterutils.py b/tests/test_iterutils.py\nindex f56468a..e62e36c 100644\n--- a/tests/test_iterutils.py\n+++ b/tests/test_iterutils.py\n@@ -4,6 +4,7 @@ import pytest\n \n from boltons.dictutils import OMD\n from boltons.iterutils import (first,\n+ split,\n pairwise,\n pairwise_iter,\n windowed,\n@@ -24,6 +25,14 @@ even = lambda x: isint(x) and x % 2 == 0\n is_meaning_of_life = lambda x: x == 42\n \n \n+class TestSplit:\n+ def test_maxsplit_zero_returns_unsplit_values(self):\n+ values = [1, None, 2]\n+\n+ assert split(values, maxsplit=0) == [values]\n+ assert split(iter(values), maxsplit=0) == [values]\n+\n+\n class TestFirst:\n def test_empty_iterables(self):\n \"\"\"\n@@ -634,4 +643,3 @@ def test_partition_multiple():\n assert positive == [2, 3]\n assert negative == [-1, -2]\n assert zero == [0]\n-" + }, + { + "sha": "3970a80f", + "subject": "Add release skill", + "body": "", + "diff": "diff --git a/.omp/skills/release/SKILL.md b/.omp/skills/release/SKILL.md\nnew file mode 100644\nindex 0000000..cacdb8a\n--- /dev/null\n+++ b/.omp/skills/release/SKILL.md\n@@ -0,0 +1,193 @@\n+---\n+name: release\n+description: |\n+\tRelease boltons to PyPI. Handles version bumping (CalVer YY.MINOR.MICRO),\n+\ttagging, pushing, and post-publish verification. Use when asked to\n+\t\"release boltons\", \"cut a release\", \"publish to PyPI\", or \"bump version\".\n+---\n+\n+# Release boltons\n+\n+boltons uses CalVer: `YY.MINOR.MICRO` (e.g. `25.1.0`). The version lives in\n+`boltons/__init__.py` as a `__version__` literal string. During development it\n+carries a `dev` suffix (e.g. `25.0.1dev`). Flit reads this at build time.\n+\n+Tags are bare version numbers (e.g. `25.1.0`, NOT `v25.1.0`). The publish\n+workflow triggers on tags matching `[0-9]*.[0-9]*.[0-9]*`.\n+\n+## Pre-flight checks\n+\n+Before starting, verify ALL of these:\n+\n+1. Working tree is clean (`git status` shows nothing dirty/staged)\n+2. You are on `master` branch\n+3. `boltons/__init__.py` has a `dev` suffix on `__version__`\n+4. All tests pass: `tox -p auto` (or at minimum `pytest tests/ -v`)\n+5. Check what is actually published on PyPI: https://pypi.org/project/boltons/\n+ **PyPI is canonical.** If the intended version already exists on PyPI, it\n+ cannot be re-released -- bump to the next version instead. If a local/GitHub\n+ tag exists for a version that is NOT on PyPI, the prior release failed and\n+ should be retried (see \"Failed release\" under Error recovery).\n+\n+If any check fails, stop and report. Do not proceed with a dirty tree or\n+failing tests.\n+\n+## Release steps\n+\n+### 1. Determine the release version\n+\n+Read `__version__` from `boltons/__init__.py`. Strip the `dev` suffix.\n+Example: `25.0.1dev` becomes `25.0.1`.\n+\n+Ask the user to confirm the version. If they want a different version\n+(e.g. bumping minor instead of micro), use that instead.\n+\n+### 2. Update version for release\n+\n+Edit `boltons/__init__.py`: remove the `dev` suffix from `__version__`.\n+\n+```python\n+# Before\n+__version__ = '25.0.1dev'\n+# After\n+__version__ = '25.0.1'\n+```\n+\n+### 3. Update CHANGELOG.md\n+\n+Add a new section at the top of `CHANGELOG.md` (below the `# boltons Changelog`\n+heading and the intro paragraph) for the release version. Use this format:\n+\n+```markdown\n+## 25.0.1\n+\n+_(Month Day, Year)_\n+\n+- First change description\n+- Second change description\n+```\n+\n+To determine what changed, review the commits since the last tag:\n+\n+```bash\n+git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:'%s' --no-merges\n+```\n+\n+Summarize the user-facing changes as concise bullet points. Omit version bump\n+commits and other release-mechanical commits. Ask the user to confirm or adjust\n+the changelog entry.\n+\n+### 4. Commit the release\n+\n+```bash\n+git commit -am \"boltons version 25.0.1\"\n+```\n+\n+Use the exact format `boltons version X.Y.Z` for the commit message.\n+\n+### 5. Tag the release\n+\n+```bash\n+git tag -a 25.0.1 -m \"short summary of key changes in this release\"\n+```\n+\n+Tags are bare version numbers (no `v` prefix). The tag message should be a\n+short, lowercase, descriptive summary of the release (not just the version\n+number). Examples:\n+\n+- `\"fix omd equality, bytes2human boundary, tbutils crash\"`\n+- `\"python 3.14 support, frozen structures\"`\n+- `\"modernize build system, drop python 3.7\"`\n+\n+### 6. Bump to next dev version\n+\n+Increment the micro version and add `dev` suffix:\n+\n+```python\n+__version__ = '25.0.2dev'\n+```\n+\n+### 7. Commit the dev bump\n+\n+```bash\n+git commit -am \"bump version to 25.0.2dev\"\n+```\n+\n+### 8. Push\n+\n+```bash\n+git push origin master --tags\n+```\n+\n+This triggers two GitHub Actions workflows:\n+- `Tests` (on the push to master)\n+- `Publish to PyPI` (on the tag)\n+\n+The publish workflow validates that `__version__` on the tagged commit does\n+not contain `dev` and matches the tag. It parses `__version__` from the file\n+with `sed` rather than importing the module (the build job does not install\n+dependencies). If either check fails, publishing is blocked.\n+\n+The `pypi` deployment environment has a wait timer. The publish job will show\n+`status: waiting` during this period \u2014 it auto-approves after the timer\n+expires.\n+\n+### 9. Create GitHub release\n+\n+```bash\n+gh release create 25.0.1 --title \"25.0.1\" --notes \"\"\n+```\n+\n+Use the changelog bullet points as the release notes body. Include a\n+`**Full Changelog**` link comparing the previous tag to the new one:\n+`https://github.com/mahmoud/boltons/compare/PREV...NEW` (bare tags).\n+" + }, + { + "sha": "b34c5342", + "subject": "Modernize CI: uv/tox-uv, OIDC publish", + "body": "", + "diff": "diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml\nnew file mode 100644\nindex 0000000..97cd33c\n--- /dev/null\n+++ b/.github/workflows/publish.yml\n@@ -0,0 +1,79 @@\n+name: Publish to PyPI\n+\n+on:\n+ push:\n+ tags:\n+ - \"[0-9]*.[0-9]*.[0-9]*\"\n+\n+jobs:\n+ build:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@v4\n+ - uses: astral-sh/setup-uv@v5\n+ - uses: actions/setup-python@v5\n+ with:\n+ python-version: \"3.12\"\n+ - name: Validate version\n+ run: |\n+ VERSION=$(sed -n \"s/^__version__ = '\\(.*\\)'/\\1/p\" boltons/__init__.py)\n+ echo \"Package version: $VERSION\"\n+ if echo \"$VERSION\" | grep -qi 'dev'; then\n+ echo \"::error::Version $VERSION contains dev suffix.\"\n+ exit 1\n+ fi\n+ TAG=${GITHUB_REF#refs/tags/}\n+ if [ \"$VERSION\" != \"$TAG\" ]; then\n+ echo \"::error::Tag $TAG does not match __version__ ($VERSION)\"\n+ exit 1\n+ fi\n+ - name: Build package\n+ run: uv build\n+ - uses: actions/upload-artifact@v4\n+ with:\n+ name: dist\n+ path: dist/\n+\n+ publish:\n+ needs: build\n+ runs-on: ubuntu-latest\n+ environment:\n+ name: pypi\n+ url: https://pypi.org/p/boltons\n+ permissions:\n+ id-token: write\n+ steps:\n+ - uses: actions/download-artifact@v4\n+ with:\n+ name: dist\n+ path: dist/\n+ - uses: pypa/gh-action-pypi-publish@release/v1\n+\n+ verify:\n+ needs: publish\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/setup-python@v5\n+ with:\n+ python-version: \"3.12\"\n+ - name: Wait for PyPI\n+ run: |\n+ TAG=${GITHUB_REF#refs/tags/}\n+ for i in $(seq 1 30); do\n+ if curl -s -f \"https://pypi.org/pypi/boltons/$TAG/json\" > /dev/null; then\n+ echo \"boltons $TAG available on PyPI\"\n+ break\n+ fi\n+ echo \"Waiting for PyPI propagation (attempt $i/30)...\"\n+ sleep 10\n+ done\n+ - name: Install from PyPI and verify\n+ run: |\n+ TAG=${GITHUB_REF#refs/tags/}\n+ pip install boltons==$TAG --index-url https://pypi.org/simple/\n+ INSTALLED=$(cd /tmp && python -c \"import boltons; print(boltons.__version__)\")\n+ echo \"Installed version: $INSTALLED\"\n+ if [ \"$INSTALLED\" != \"$TAG\" ]; then\n+ echo \"::error::Installed version $INSTALLED does not match tag $TAG\"\n+ exit 1\n+ fi\ndiff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml\nindex f252815..2f6e712 100644\n--- a/.github/workflows/tests.yaml\n+++ b/.github/workflows/tests.yaml\n@@ -31,23 +31,9 @@ jobs:\n - { name: \"PyPy3\", python: \"pypy-3.9\", os: ubuntu-latest, tox: pypy3 }\n steps:\n - uses: actions/checkout@v4\n+ - uses: astral-sh/setup-uv@v5\n - uses: actions/setup-python@v5\n with:\n python-version: ${{ matrix.python }}\n- - name: update pip\n- run: |\n- pip install -U wheel\n- pip install -U setuptools\n- python -m pip install -U pip\n- - name: get pip cache dir\n- id: pip-cache\n- shell: bash\n- run: |\n- echo \"dir=$(pip cache dir)\" >> \"$GITHUB_OUTPUT\"\n- - name: cache pip\n- uses: actions/cache@v4\n- with:\n- path: ${{ steps.pip-cache.outputs.dir }}\n- key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('pyproject.toml') }}|${{ hashFiles('requirements/*.txt') }}\n- - run: pip install tox\n- - run: tox -e ${{ matrix.tox }}\n+ allow-prereleases: true\n+ - run: uvx --with tox-uv tox -e ${{ matrix.tox }}\ndiff --git a/.gitignore b/.gitignore\nindex a01147c..a54a71e 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -2,6 +2,7 @@ docs/_build\n tmp.py\n htmlcov/\n venv/\n+.venv/\n *.py[cod]\n venv-rtd/\n \ndiff --git a/boltons/__init__.py b/boltons/__init__.py\nindex e69de29..7a70211 100644\n--- a/boltons/__init__.py\n+++ b/boltons/__init__.py\n@@ -0,0 +1 @@\n+__version__ = '25.0.1dev'\n\\ No newline at end of file\ndiff --git a/docs/conf.py b/docs/conf.py\nindex d10273e..2e0b80f 100644\n--- a/docs/conf.py\n+++ b/docs/conf.py\n@@ -14,7 +14,6 @@\n import os\n import sys\n import sphinx\n-from pprint import pprint\n \n # If extensions (or modules to document with autodoc) are in another directory,\n # add these directories to sys.path here. If the directory is relative to the\n@@ -25,8 +24,6 @@ PACKAGE_PATH = os.path.abspath(CUR_PATH + '/../boltons/')\n sys.path.insert(0, PROJECT_PATH)\n sys.path.insert(0, PACKAGE_PATH)" + }, + { + "sha": "471dad1c", + "subject": "remove unused len(self) call in BarrelList._balance_list", + "body": "", + "diff": "diff --git a/boltons/listutils.py b/boltons/listutils.py\nindex ffa9d94..15b14a3 100644\n--- a/boltons/listutils.py\n+++ b/boltons/listutils.py\n@@ -121,7 +121,7 @@ class BarrelList(list):\n def _balance_list(self, list_idx):\n if list_idx < 0:\n list_idx += len(self.lists)\n- cur_list, len_self = self.lists[list_idx], len(self)\n+ cur_list = self.lists[list_idx]\n size_limit = self._cur_size_limit\n if len(cur_list) > size_limit:\n half_limit = size_limit // 2" + }, + { + "sha": "c463d163", + "subject": "Fix OrderedMultiDict equality to compare values against plain mappings", + "body": "OrderedMultiDict.__eq__ has a branch for comparing against a non-OMD\nmapping (anything with a keys() method, e.g. a dict). That branch\ncomputes other[selfk] == self[selfk] but throws the result away, so it\nonly checks that every key of self exists in other and never compares\nthe values. As a result an OMD compares equal to any same-keyed mapping\nregardless of the values:\n\n >>> omd = OMD([('a', 1), ('b', 2)])\n >>> omd == {'a': 999, 'b': 2}\n True # should be False\n\nThe existing test_eq doesn't catch this because its dict case is written\nas d == omd, which dispatches to dict.__eq__ rather than OMD.__eq__; the\nbuggy branch is only reached with the OMD on the left.\n\nCompare the values and return False on mismatch. The same __eq__ is\nduplicated in urlutils.OrderedMultiDict (used by QueryParamDict), so it's\nfixed there too. Added regression tests for both.", + "diff": "diff --git a/boltons/dictutils.py b/boltons/dictutils.py\nindex f913f29..d4dd1db 100644\n--- a/boltons/dictutils.py\n+++ b/boltons/dictutils.py\n@@ -358,7 +358,8 @@ class OrderedMultiDict(dict):\n elif hasattr(other, 'keys'):\n for selfk in self:\n try:\n- other[selfk] == self[selfk]\n+ if other[selfk] != self[selfk]:\n+ return False\n except KeyError:\n return False\n return True\ndiff --git a/boltons/urlutils.py b/boltons/urlutils.py\nindex 45d3e73..712e5a1 100644\n--- a/boltons/urlutils.py\n+++ b/boltons/urlutils.py\n@@ -1264,7 +1264,8 @@ class OrderedMultiDict(dict):\n elif hasattr(other, 'keys'):\n for selfk in self:\n try:\n- other[selfk] == self[selfk]\n+ if other[selfk] != self[selfk]:\n+ return False\n except KeyError:\n return False\n return True\ndiff --git a/tests/test_dictutils.py b/tests/test_dictutils.py\nindex d4f6a67..b9df65f 100644\n--- a/tests/test_dictutils.py\n+++ b/tests/test_dictutils.py\n@@ -61,6 +61,20 @@ def test_eq():\n assert omd != omd3\n \n \n+def test_eq_with_dict():\n+ # OMD on the left dispatches to OMD.__eq__, which has to compare values\n+ # against a plain mapping, not just keys.\n+ omd = OMD([('a', 1), ('b', 2)])\n+ assert omd == {'a': 1, 'b': 2}\n+ assert not (omd != {'a': 1, 'b': 2})\n+\n+ assert omd != {'a': 999, 'b': 2}\n+ assert not (omd == {'a': 999, 'b': 2})\n+\n+ # same number of keys but a different key\n+ assert omd != {'a': 1, 'c': 2}\n+\n+\n def test_copy():\n for itemset in _ITEMSETS:\n omd = OMD(itemset)\ndiff --git a/tests/test_urlutils.py b/tests/test_urlutils.py\nindex d25701e..8cac423 100644\n--- a/tests/test_urlutils.py\n+++ b/tests/test_urlutils.py\n@@ -1,7 +1,7 @@\n import pytest\n \n from boltons import urlutils\n-from boltons.urlutils import URL, _URL_RE, find_all_links\n+from boltons.urlutils import URL, QueryParamDict, _URL_RE, find_all_links\n \n \n # fully quoted urls that should round trip\n@@ -95,6 +95,13 @@ def test_query_params(test_url):\n assert test_url.endswith(qp_text)\n \n \n+def test_query_param_dict_eq_with_dict():\n+ qpd = QueryParamDict([('a', '1'), ('b', '2')])\n+ assert qpd == {'a': '1', 'b': '2'}\n+ assert qpd != {'a': '999', 'b': '2'}\n+ assert not (qpd == {'a': '999', 'b': '2'})\n+\n+\n def test_iri_query():\n url = URL('http://minerals.mountain.ore/?rock=\\N{SHAMROCK}')\n assert url.query_params['rock'] == '\\N{SHAMROCK}'" + }, + { + "sha": "766b5547", + "subject": "fix(strutils): bytes2human rolls over at exact powers of 1024", + "body": "bytes2human(1024) returned '1024B' instead of '1K' (and 1048576 ->\n'1024K' instead of '1M'), because the unit-selection loop compared the\nvalue with '<=' against each boundary and so stopped one unit too early\nat an exact power of 1024. Use '<' so exact boundaries advance to the\nnext unit. Sub-boundary, mid-range and negative values are unchanged.\n\nAdds a regression test (proven to fail on the old code) and a doctest.", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 9763fc0..6948445 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -559,10 +559,12 @@ def bytes2human(nbytes, ndigits=0):\n '95M'\n >>> bytes2human(0, 2)\n '0.00B'\n+ >>> bytes2human(1024)\n+ '1K'\n \"\"\"\n abs_bytes = abs(nbytes)\n for (size, symbol), (next_size, next_symbol) in _SIZE_RANGES:\n- if abs_bytes <= next_size:\n+ if abs_bytes < next_size:\n break\n hnbytes = float(nbytes) / size\n return '{hnbytes:.{ndigits}f}{symbol}'.format(hnbytes=hnbytes,\ndiff --git a/tests/test_strutils.py b/tests/test_strutils.py\nindex 1ffa916..b46081e 100644\n--- a/tests/test_strutils.py\n+++ b/tests/test_strutils.py\n@@ -252,3 +252,25 @@ def test_roundzip():\n assert strutils.gunzip_bytes(strutils.gzip_bytes(aaa)) == aaa\n \n assert strutils.gunzip_bytes(strutils.gzip_bytes(b'')) == b''\n+\n+\n+def test_bytes2human():\n+ b2h = strutils.bytes2human\n+ # Exact powers of 1024 must roll over to the next unit, not show the\n+ # previous unit times 1024.\n+ assert b2h(1024) == '1K'\n+ assert b2h(1024 ** 2) == '1M'\n+ assert b2h(1024 ** 3) == '1G'\n+ assert b2h(1024 ** 4) == '1T'\n+ # Just below a boundary stays in the smaller unit.\n+ assert b2h(1023) == '1023B'\n+ assert b2h(1024 ** 2 - 1, 1) == '1024.0K'\n+ # Ordinary mid-range values are unaffected.\n+ assert b2h(0) == '0B'\n+ assert b2h(2048) == '2K'\n+ assert b2h(128991) == '126K'\n+ # Sign is preserved and negative magnitudes scale like positives.\n+ assert b2h(-1024) == '-1K'\n+ assert b2h(-(1024 ** 2)) == '-1M'\n+ # ndigits controls the fractional part.\n+ assert b2h(1024, 2) == '1.00K'" + }, + { + "sha": "8a2a93d8", + "subject": "fix(tbutils): avoid IndexError in ParsedException.from_string on truncated tracebacks", + "body": "", + "diff": "diff --git a/boltons/tbutils.py b/boltons/tbutils.py\nindex bfc4984..6093609 100644\n--- a/boltons/tbutils.py\n+++ b/boltons/tbutils.py\n@@ -777,7 +777,7 @@ class ParsedException:\n \n frames = []\n line_no = start_line\n- while True:\n+ while line_no < len(tb_lines):\n frame_line = tb_lines[line_no].strip()\n frame_match = frame_re.match(frame_line)\n if frame_match:\n@@ -800,9 +800,10 @@ class ParsedException:\n else:\n frame_dict['source_line'] = next_line_stripped\n line_no += 1\n- if _underline_re.match(tb_lines[line_no + 1]):\n- # To deal with anchors\n- line_no += 1\n+ if (line_no + 1 < len(tb_lines)\n+ and _underline_re.match(tb_lines[line_no + 1])):\n+ # To deal with anchors\n+ line_no += 1\n else:\n break\n line_no += 1\ndiff --git a/tests/test_tbutils_parsed_exc.py b/tests/test_tbutils_parsed_exc.py\nindex 6927832..25bb5c6 100644\n--- a/tests/test_tbutils_parsed_exc.py\n+++ b/tests/test_tbutils_parsed_exc.py\n@@ -79,4 +79,22 @@ TypeError: unsupported operand type(s) for +: 'int' and 'str'\"\"\"\n # Note: not checking the anchor lines (indices 3, 6) because column details not currently stored in ParsedException\n _tb_str_lines = _tb_str.splitlines()\n _tb_str_without_anchor = \"\\n\".join(_tb_str_lines[:3] + _tb_str_lines[4:6] + _tb_str_lines[7:])\n- assert parsed_tb.to_string() == _tb_str_without_anchor\n\\ No newline at end of file\n+ assert parsed_tb.to_string() == _tb_str_without_anchor\n+\n+\n+def test_parsed_exc_truncated():\n+ \"\"\"a traceback whose last frame has a source line but no following\n+ exception line should parse without raising IndexError\"\"\"\n+ _tb_str = \"\"\"\\\n+Traceback (most recent call last):\n+ File \"main.py\", line 3, in \n+ print(add(1, 2))\"\"\"\n+\n+ parsed_tb = ParsedException.from_string(_tb_str)\n+\n+ assert parsed_tb.exc_type == ''\n+ assert parsed_tb.exc_msg == ''\n+ assert parsed_tb.frames == [{'source_line': 'print(add(1, 2))',\n+ 'filepath': 'main.py',\n+ 'lineno': '3',\n+ 'funcname': ''}]\n\\ No newline at end of file" + }, + { + "sha": "80f9eb42", + "subject": "Fix syntax error in MultiReplace example", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex c45612a..9763fc0 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -1154,7 +1154,7 @@ class MultiReplace:\n s = strutils.MultiReplace([\n ('foo', 'zoo'),\n ('cat', 'hat'),\n- ('bat', 'kraken)'\n+ ('bat', 'kraken')\n ])\n new = s.sub('The foo bar cat ate a bat')\n new == 'The zoo bar hat ate a kraken'" + }, + { + "sha": "207651ee", + "subject": "Add human_readable_list to __all__ in strutils", + "body": "The human_readable_list function was added in PR #388 but was not\nincluded in the module's __all__ list. This means it doesn't show up\nin dir(boltons.strutils) or get exported with wildcard imports.", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 92c618a..c45612a 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -57,7 +57,8 @@ __all__ = ['camel2under', 'under2camel', 'slugify', 'split_punct_ws',\n 'iter_splitlines', 'indent', 'escape_shell_args',\n 'args2cmd', 'args2sh', 'parse_int_list', 'format_int_list',\n 'complement_int_list', 'int_ranges_from_int_list', 'MultiReplace',\n- 'multi_replace', 'unwrap_text', 'removeprefix']\n+ 'multi_replace', 'unwrap_text', 'removeprefix',\n+ 'human_readable_list']\n \n \n _punct_ws_str = string.punctuation + string.whitespace" + }, + { + "sha": "ce60604a", + "subject": "bit more info in docstring", + "body": "", + "diff": "diff --git a/boltons/funcutils.py b/boltons/funcutils.py\nindex 3fe6570..e7a8953 100644\n--- a/boltons/funcutils.py\n+++ b/boltons/funcutils.py\n@@ -1007,7 +1007,6 @@ def noop(*args, **kwargs):\n \n \n \n-_UNSET = object()\n \n \n def once(func):\n@@ -1016,6 +1015,9 @@ def once(func):\n block until the first execution completes, then all receive the\n cached result.\n \n+ This is especially useful in cases like logging, where multiple\n+ initializations can cause problems.\n+\n The decorated function must take no arguments.\n \n >>> call_count = 0\n@@ -1031,6 +1033,7 @@ def once(func):\n >>> call_count\n 1\n \"\"\"\n+ _UNSET = object()\n lock = threading.Lock()\n result = _UNSET\n " + }, + { + "sha": "c0d580df", + "subject": "backwards compatible once decorator (and test). doesn't rely on functools.cache (added in 3.9), just uses a nonlocal sentinel.", + "body": "", + "diff": "diff --git a/boltons/funcutils.py b/boltons/funcutils.py\nindex 32a2333..3fe6570 100644\n--- a/boltons/funcutils.py\n+++ b/boltons/funcutils.py\n@@ -1006,6 +1006,10 @@ def noop(*args, **kwargs):\n return None\n \n \n+\n+_UNSET = object()\n+\n+\n def once(func):\n \"\"\"Decorator that ensures a function is only executed once, caching\n the result for all subsequent calls. Thread-safe: concurrent callers\n@@ -1027,15 +1031,19 @@ def once(func):\n >>> call_count\n 1\n \"\"\"\n- lock = threading.RLock()\n+ lock = threading.Lock()\n+ result = _UNSET\n \n- @functools.cache\n @functools.wraps(func)\n def wrapper():\n+ nonlocal result\n+ if result is not _UNSET:\n+ return result\n with lock:\n- if wrapper.cache_info().currsize:\n- return wrapper()\n- return func()\n+ if result is not _UNSET:\n+ return result\n+ result = func()\n+ return result\n \n return wrapper\n " + }, + { + "sha": "f7cbf197", + "subject": "add missing py39 to the tox.ini", + "body": "", + "diff": "diff --git a/tox.ini b/tox.ini\nindex 9a3d158..ccc3c79 100644\n--- a/tox.ini\n+++ b/tox.ini\n@@ -1,5 +1,5 @@\n [tox]\n-envlist = py37,py39,py310,py311,py312,py313,py314,pypy3\n+envlist = py37,py38,py39,py310,py311,py312,py313,py314,pypy3\n [testenv]\n changedir = .tox\n deps = -rrequirements-test.txt" + }, + { + "sha": "42586df3", + "subject": "add once decorator", + "body": "", + "diff": "diff --git a/boltons/funcutils.py b/boltons/funcutils.py\nindex 0bdc28e..32a2333 100644\n--- a/boltons/funcutils.py\n+++ b/boltons/funcutils.py\n@@ -39,6 +39,7 @@ import re\n import inspect\n import functools\n import itertools\n+import threading\n from inspect import formatannotation\n from types import FunctionType, MethodType\n \n@@ -1004,4 +1005,38 @@ def noop(*args, **kwargs):\n \"\"\"\n return None\n \n+\n+def once(func):\n+ \"\"\"Decorator that ensures a function is only executed once, caching\n+ the result for all subsequent calls. Thread-safe: concurrent callers\n+ block until the first execution completes, then all receive the\n+ cached result.\n+\n+ The decorated function must take no arguments.\n+\n+ >>> call_count = 0\n+ >>> @once\n+ ... def expensive_setup():\n+ ... global call_count\n+ ... call_count += 1\n+ ... return 'initialized'\n+ >>> expensive_setup()\n+ 'initialized'\n+ >>> expensive_setup()\n+ 'initialized'\n+ >>> call_count\n+ 1\n+ \"\"\"\n+ lock = threading.RLock()\n+\n+ @functools.cache\n+ @functools.wraps(func)\n+ def wrapper():\n+ with lock:\n+ if wrapper.cache_info().currsize:\n+ return wrapper()\n+ return func()\n+\n+ return wrapper\n+\n # end funcutils.py\ndiff --git a/tests/test_funcutils.py b/tests/test_funcutils.py\nindex 03ef585..1f8b44e 100644\n--- a/tests/test_funcutils.py\n+++ b/tests/test_funcutils.py\n@@ -1,9 +1,12 @@\n+import threading\n+import time\n from boltons.funcutils import (copy_function,\n total_ordering,\n format_invocation,\n InstancePartial,\n CachedInstancePartial,\n- noop)\n+ noop,\n+ once)\n \n \n class Greeter:\n@@ -82,3 +85,61 @@ def test_noop():\n assert noop() is None\n assert noop(1, 2) is None\n assert noop(a=1, b=2) is None\n+\n+\n+def test_once_executes_only_once():\n+ call_count = 0\n+\n+ @once\n+ def get_value():\n+ nonlocal call_count\n+ call_count += 1\n+ return 42\n+\n+ assert get_value() == 42\n+ assert get_value() == 42\n+ assert get_value() == 42\n+ assert call_count == 1\n+\n+\n+def test_once_caches_result():\n+ @once\n+ def get_list():\n+ return [1, 2, 3]\n+\n+ result1 = get_list()\n+ result2 = get_list()\n+ assert result1 is result2\n+\n+\n+def test_once_thread_safety():\n+ call_count = 0\n+ barrier = threading.Barrier(10)\n+\n+ @once\n+ def slow_computation():\n+ nonlocal call_count\n+ call_count += 1\n+ time.sleep(0.5)\n+ return 99\n+\n+ results = []\n+ errors = []\n+\n+ def worker():\n+ try:\n+ barrier.wait()\n+ results.append(slow_computation())\n+ except Exception as e:\n+ errors.append(e)\n+\n+ threads = [threading.Thread(target=worker) for _ in range(10)]\n+ for t in threads:\n+ t.start()\n+ for t in threads:\n+ t.join()\n+\n+ assert not errors\n+ assert call_count == 1\n+ assert all(r == 99 for r in results)\n+ assert len(results) == 10" + }, + { + "sha": "49f381ee", + "subject": "Extend partition to accept multiple predicates", + "body": "", + "diff": "diff --git a/boltons/iterutils.py b/boltons/iterutils.py\nindex 0ddb8fd..433eaf5 100644\n--- a/boltons/iterutils.py\n+++ b/boltons/iterutils.py\n@@ -754,10 +754,15 @@ def bucketize(src, key=bool, value_transform=None, key_filter=None):\n return ret\n \n \n-def partition(src, key=bool):\n+def partition(src, key=bool, *keys):\n \"\"\"No relation to :meth:`str.partition`, ``partition`` is like\n- :func:`bucketize`, but for added convenience returns a tuple of\n- ``(truthy_values, falsy_values)``.\n+ :func:`bucketize`, but for added convenience returns a collection for\n+ each predicate passed.\n+\n+ ``partition`` now accepts multiple *key* functions and will return\n+ ``N + 1`` lists for ``N`` predicates. Each value from *src* is placed\n+ into the first list whose predicate evaluates to ``True`` with values\n+ that match none of the predicates placed in the last list.\n \n >>> nonempty, empty = partition(['', '', 'hi', '', 'bye'])\n >>> nonempty\n@@ -772,9 +777,37 @@ def partition(src, key=bool):\n >>> decimal_digits, hexletters = partition(string.hexdigits, is_digit)\n >>> ''.join(decimal_digits), ''.join(hexletters)\n ('0123456789', 'abcdefABCDEF')\n+\n+ Multiple predicates may be supplied to divide into more buckets:\n+\n+ >>> positive, negative, zero = partition(range(-1, 2),\n+ ... lambda i: i > 0,\n+ ... lambda i: i < 0)\n+ >>> positive, negative, zero\n+ ([1], [-1], [0])\n \"\"\"\n- bucketized = bucketize(src, key)\n- return bucketized.get(True, []), bucketized.get(False, [])\n+ if not is_iterable(src):\n+ raise TypeError('expected an iterable')\n+\n+ def _make_key_func(k):\n+ if isinstance(k, str):\n+ return lambda x, k=k: getattr(x, k, False)\n+ if callable(k):\n+ return k\n+ raise TypeError('expected key to be callable or a string')\n+\n+ key_funcs = [_make_key_func(key)] + [_make_key_func(k) for k in keys]\n+ parts = [[] for _ in range(len(key_funcs) + 1)]\n+\n+ for val in src:\n+ for idx, func in enumerate(key_funcs):\n+ if func(val):\n+ parts[idx].append(val)\n+ break\n+ else:\n+ parts[-1].append(val)\n+\n+ return tuple(parts)\n \n \n def unique(src, key=None):\ndiff --git a/tests/test_iterutils.py b/tests/test_iterutils.py\nindex 6d427ba..f56468a 100644\n--- a/tests/test_iterutils.py\n+++ b/tests/test_iterutils.py\n@@ -616,3 +616,22 @@ def test_windowed_filled():\n \n assert list(windowed_iter(range(4), 3)) == [(0, 1, 2), (1, 2, 3)]\n assert list(windowed_iter(range(4), 3, fill=None)) == [(0, 1, 2), (1, 2, 3), (2, 3, None), (3, None, None)]\n+# Tests for partition\n+\n+def test_partition_default():\n+ from boltons.iterutils import partition\n+\n+ nonempty, empty = partition(['', '', 'hi', '', 'bye'])\n+ assert nonempty == ['hi', 'bye']\n+ assert empty == [\"\", \"\", \"\"]\n+\n+\n+def test_partition_multiple():\n+ from boltons.iterutils import partition\n+\n+ items = [2, -1, 0, 3, -2]\n+ positive, negative, zero = partition(items, lambda i: i > 0, lambda i: i < 0)\n+ assert positive == [2, 3]\n+ assert negative == [-1, -2]\n+ assert zero == [0]\n+" + }, + { + "sha": "17374731", + "subject": "Adds cache option to remap and use in research", + "body": "There is a mismatch between the caching of transformed objects in the\n`remap` function and the need for `research` to traverse all sub\ntrees. For most values this is inconsequential (like atomic ints,\netc.) but for small tuples (e.g. `(\"hello\",)`) these get compiled as\nthe same value and return the same `id(...)`. In `remap` these get\ncached and never get `enter` called on them and thus the hooks for\n`research` to return the values never gets called.\n\nIn this fix an option to disable/enable the cache is introduced to the\n`remap` function which simply disables using transformed values from\nthe cache. Then in the `research` function caching is turned off.\n\nThe existing `remap` behavior is maintained as caching by default is\nturned on.\n\nfixes: #393", + "diff": "diff --git a/boltons/iterutils.py b/boltons/iterutils.py\nindex e0a5b90..0ddb8fd 100644\n--- a/boltons/iterutils.py\n+++ b/boltons/iterutils.py\n@@ -1055,8 +1055,14 @@ def default_exit(path, key, old_parent, new_parent, new_items):\n return ret\n \n \n-def remap(root, visit=default_visit, enter=default_enter, exit=default_exit,\n- **kwargs):\n+def remap(\n+ root,\n+ visit=default_visit,\n+ enter=default_enter,\n+ exit=default_exit,\n+ cache: bool = True,\n+ **kwargs,\n+):\n \"\"\"The remap (\"recursive map\") function is used to traverse and\n transform nested structures. Lists, tuples, sets, and dictionaries\n are just a few of the data structures nested into heterogeneous\n@@ -1130,6 +1136,10 @@ def remap(root, visit=default_visit, enter=default_enter, exit=default_exit,\n :class:`namedtuple`, must be recreated from scratch, but\n use the same type as the new parent passed back from the\n *enter* function.\n+ cache (bool): Controls whether to cache transformed\n+ objects. Uses object identity for the cache. For example\n+ this is turned off for applications like `research` which\n+ need to traverse all trees.\n reraise_visit (bool): A pragmatic convenience for the *visit*\n callable. When set to ``False``, remap ignores any errors\n raised by the *visit* callback. Items causing exceptions\n@@ -1195,7 +1205,7 @@ def remap(root, visit=default_visit, enter=default_enter, exit=default_exit,\n registry[id_value] = value\n if not new_items_stack:\n continue\n- elif id_value in registry:\n+ elif cache and id_value in registry:\n value = registry[id_value]\n else:\n if trace_enter:\n@@ -1388,7 +1398,7 @@ def research(root, query=lambda p, k, v: True, reraise=False, enter=default_ente\n raise\n return enter(path, key, value)\n \n- remap(root, enter=_enter)\n+ remap(root, enter=_enter, cache=False)\n return ret\n \n \ndiff --git a/tests/test_iterutils.py b/tests/test_iterutils.py\nindex f661f68..6d427ba 100644\n--- a/tests/test_iterutils.py\n+++ b/tests/test_iterutils.py\n@@ -395,6 +395,24 @@ def test_research():\n # empty results with default, reraise=False\n assert research(root, broken_query) == []\n \n+ # test that different branches with object identical values are\n+ # still traversed and returned\n+ literal_tree = {\n+ \"maybe\" : (\"hello\",),\n+ \"another\" : (\"hello\",),\n+ }\n+\n+ assert id(literal_tree[\"maybe\"]) == id(literal_tree[\"another\"])\n+ assert research(\n+ root=literal_tree,\n+ ) == [\n+ ((None,), literal_tree),\n+ ((\"maybe\",), (\"hello\",)),\n+ ((\"maybe\", 0,), \"hello\"),\n+ ((\"another\",), (\"hello\",)),\n+ ((\"another\", 0,), \"hello\"),\n+ ]\n+\n \n def test_research_custom_enter():\n # see #368" + }, + { + "sha": "ce7c7d2b", + "subject": "fix(human_readable_list): altering f-string syntax to have backwards compatibility with Python <3.12", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 12e6df4..92c618a 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -1304,7 +1304,7 @@ def human_readable_list(items: typing.Sequence[str], delimiter: str = ',', conju\n if not items:\n return ''\n \n- delimiter = f'{delimiter.strip()} '\n+ delimiter = delimiter and delimiter.strip() + ' '\n conjunction = conjunction.strip()\n \n if len(items) == 1:\n@@ -1313,4 +1313,4 @@ def human_readable_list(items: typing.Sequence[str], delimiter: str = ',', conju\n if len(items) == 2:\n return f'{items[0]} {conjunction} {items[1]}'\n \n- return f'{delimiter.join(items[:-1])}{delimiter if oxford else ' '}{conjunction} {items[-1]}'\n+ return f'{delimiter.join(items[:-1])}{delimiter if oxford else \" \"}{conjunction} {items[-1]}'" + }, + { + "sha": "1b1d3787", + "subject": "feat(strutils): add human_readable_list function to format lists of strings", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex bc04689..12e6df4 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -36,20 +36,20 @@ provided by ``strutils``.\n \n \n import builtins\n+import collections\n import re\n+import string\n import sys\n+import typing\n+import unicodedata\n import uuid\n import zlib\n-import string\n-import unicodedata\n-import collections\n from collections.abc import Mapping\n from gzip import GzipFile\n-from html.parser import HTMLParser\n from html import entities as htmlentitydefs\n+from html.parser import HTMLParser\n from io import BytesIO as StringIO\n \n-\n __all__ = ['camel2under', 'under2camel', 'slugify', 'split_punct_ws',\n 'unit_len', 'ordinalize', 'cardinalize', 'pluralize', 'singularize',\n 'asciify', 'is_ascii', 'is_uuid', 'html2text', 'strip_ansi',\n@@ -1285,3 +1285,32 @@ def removeprefix(text: str, prefix: str) -> str:\n if text.startswith(prefix):\n return text[len(prefix):]\n return text\n+\n+def human_readable_list(items: typing.Sequence[str], delimiter: str = ',', conjunction: str = 'and', *, oxford: bool = True) -> str:\n+ \"\"\"\n+ Given a list of strings, return a human readable string with\n+ appropriate delimiters and the conjunction word.\n+\n+ Args:\n+ items: The list of strings to join.\n+ delimiter (optional): The delimiter to use between items.\n+ conjunction (optional): The word to use before the last item.\n+ oxford (optional): Whether to use the Oxford comma/delimiter before\n+ the conjunction in lists of 3+ items.\n+\n+ Returns:\n+ str: The human readable string.\n+ \"\"\"\n+ if not items:\n+ return ''\n+\n+ delimiter = f'{delimiter.strip()} '\n+ conjunction = conjunction.strip()\n+\n+ if len(items) == 1:\n+ return items[0]\n+\n+ if len(items) == 2:\n+ return f'{items[0]} {conjunction} {items[1]}'\n+\n+ return f'{delimiter.join(items[:-1])}{delimiter if oxford else ' '}{conjunction} {items[-1]}'\ndiff --git a/tests/test_strutils.py b/tests/test_strutils.py\nindex 41ee50e..1ffa916 100644\n--- a/tests/test_strutils.py\n+++ b/tests/test_strutils.py\n@@ -142,6 +142,111 @@ class TestMultiReplace(TestCase):\n self.assertEqual(m.sub('The cat.+ is purple'), 'The kedi is mor')\n \n \n+def test_human_readable_list():\n+ \"\"\"Test the human_readable_list function with various inputs.\"\"\"\n+ \n+ # Test empty list\n+ assert strutils.human_readable_list([]) == ''\n+ \n+ # Test single item\n+ assert strutils.human_readable_list(['apple']) == 'apple'\n+ \n+ # Test two items (no Oxford comma applies)\n+ assert strutils.human_readable_list(['apple', 'banana']) == 'apple and banana'\n+ \n+ # Test three items with Oxford comma (default)\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry']) == 'apple, banana, and cherry'\n+ \n+ # Test three items without Oxford comma\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], oxford=False) == 'apple, banana and cherry'\n+ \n+ # Test four items with Oxford comma\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry', 'date']) == 'apple, banana, cherry, and date'\n+ \n+ # Test four items without Oxford comma\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry', 'date'], oxford=False) == 'apple, banana, cherry and date'\n+ \n+ # Test custom delimiter\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter=';') == 'apple; banana; and cherry'\n+ \n+ # Test custom conjunction\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='or') == 'apple, banana, or cherry'\n+ \n+ # Test custom delimiter and conjunction\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter='|', conjunction='plus') == 'apple| banana| plus cherry'\n+ \n+ # Test custom conjunction without Oxford comma\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='or', oxford=False) == 'apple, banana or cherry'\n+ \n+ # Test delimiter with extra spaces (should be stripped)\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter=' , ') == 'apple, banana, and cherry'\n+ \n+ # Test conjunction with extra spaces (should be stripped)\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction=' or ') == 'apple, banana, or cherry'\n+ \n+ # Test with empty strings in the list\n+ assert strutils.human_readable_list(['apple', '', 'cherry']) == 'apple, , and cherry'\n+ \n+ # Test with whitespace strings\n+ assert strutils.human_readable_list(['apple', ' ', 'cherry']) == 'apple, , and cherry'\n+ \n+ # Test with special characters\n+ assert strutils.human_readable_list(['apple & pear', 'banana/plantain', 'cherry-bomb']) == 'apple & pear, banana/plantain, and cherry-bomb'\n+ \n+ # Test with unicode characters\n+ assert strutils.human_readable_list(['\ud83c\udf4e', '\ud83c\udf4c', '\ud83c\udf52']) == '\ud83c\udf4e, \ud83c\udf4c, and \ud83c\udf52'\n+ \n+ # Test with numbers as strings\n+ assert strutils.human_readable_list(['1', '2', '3']) == '1, 2, and 3'\n+ \n+ # Test edge case with only delimiter character as items\n+ assert strutils.human_readable_list([',', ',', ',']) == ',, ,, and ,'\n+ \n+ # Test long list to ensure pattern consistency\n+ long_list = ['a', 'b', 'c', 'd', 'e', 'f']\n+ assert strutils.human_readable_list(long_list) == 'a, b, c, d, e, and f'\n+ assert strutils.human_readable_list(long_list, oxford=False) == 'a, b, c, d, e and f'\n+\n+ # Edge cases\n+ # Test with empty delimiter\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter='') == 'applebananaand cherry'\n+ \n+ # Test with empty conjunction\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='') == 'apple, banana, cherry'\n+ \n+ # Test two items with custom delimiter and conjunction\n+ assert strutils.human_readable_list(['apple', 'banana'], delimiter=';', conjunction='or') == 'apple or banana'\n+ \n+ # Test single item with custom parameters (should ignore them)\n+ assert strutils.human_readable_list(['apple'], delimiter='|', conjunction='plus', oxford=False) == 'apple'\n+ \n+ # Test very long strings" + }, + { + "sha": "6b9a2bf2", + "subject": "Merge branch 's-t-e-v-e-n-k-support-pytest-9'", + "body": "", + "diff": "" + }, + { + "sha": "a2af5854", + "subject": "Support pytest 9 changes", + "body": "Starting from pytest 7, the py.path arguments to pytest's hook functions\nhave been deprecated, replaced with pathlib.Path equivalents. Since\npytest 7 is the minimum version we support, move to using it.", + "diff": "diff --git a/tests/conftest.py b/tests/conftest.py\nindex d4d2192..e2c9a0c 100644\n--- a/tests/conftest.py\n+++ b/tests/conftest.py\n@@ -5,12 +5,12 @@ import re\n _VERSION_MARKER = re.compile(r'_py(?P\\d)(?P\\d)?')\n \n \n-def pytest_ignore_collect(path, config):\n+def pytest_ignore_collect(collection_path, config):\n \"\"\"\n Ignore tests that end with _pyX, where X does not equal this\n interpreter's major version.\n \"\"\"\n- filename = path.basename\n+ filename = collection_path.name\n modulename = filename.split('.', 1)[0]\n match = _VERSION_MARKER.search(modulename)\n if not match:" + }, + { + "sha": "a7ae0909", + "subject": "add py314", + "body": "", + "diff": "diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml\nindex 72f8e80..f252815 100644\n--- a/.github/workflows/tests.yaml\n+++ b/.github/workflows/tests.yaml\n@@ -18,9 +18,10 @@ jobs:\n fail-fast: false\n matrix:\n include:\n- - { name: Linux, python: \"3.13\", os: ubuntu-latest, tox: py313 }\n- - { name: Windows, python: \"3.13\", os: windows-latest, tox: py313 }\n- - { name: Mac, python: \"3.13\", os: macos-latest, tox: py313 }\n+ - { name: Linux, python: \"3.14\", os: ubuntu-latest, tox: py314 }\n+ - { name: Windows, python: \"3.14\", os: windows-latest, tox: py314 }\n+ - { name: Mac, python: \"3.14\", os: macos-latest, tox: py314 }\n+ - { name: \"3.13\", python: \"3.13\", os: ubuntu-latest, tox: py313 }\n - { name: \"3.12\", python: \"3.12\", os: ubuntu-latest, tox: py312 }\n - { name: \"3.11\", python: \"3.11\", os: ubuntu-latest, tox: py311 }\n - { name: \"3.10\", python: \"3.10\", os: ubuntu-latest, tox: py310 }\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 4dc6193..1b7db23 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -22,6 +22,7 @@ classifiers = [\n \"Programming Language :: Python :: 3.11\",\n \"Programming Language :: Python :: 3.12\",\n \"Programming Language :: Python :: 3.13\",\n+ \"Programming Language :: Python :: 3.14\",\n \"Programming Language :: Python :: 3 :: Only\",\n \"Programming Language :: Python :: Implementation :: CPython\",\n \"Programming Language :: Python :: Implementation :: PyPy\",\ndiff --git a/tox.ini b/tox.ini\nindex 12fb928..9a3d158 100644\n--- a/tox.ini\n+++ b/tox.ini\n@@ -1,5 +1,5 @@\n [tox]\n-envlist = py37,py39,py310,py311,py312,py313,pypy3\n+envlist = py37,py39,py310,py311,py312,py313,py314,pypy3\n [testenv]\n changedir = .tox\n deps = -rrequirements-test.txt" + }, + { + "sha": "b4717124", + "subject": "fix strutils", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex e715bce..bc04689 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -570,17 +570,9 @@ def bytes2human(nbytes, ndigits=0):\n \n \n class HTMLTextExtractor(HTMLParser):\n-<<<<<<< Updated upstream\n- def __init__(self):\n- self.reset()\n- self.strict = False\n- self.convert_charrefs = True\n- self.result = []\n-=======\n def __init__(self) -> None:\n super().__init__(convert_charrefs=True)\n self.result: list[str] = []\n->>>>>>> Stashed changes\n \n def handle_data(self, d):\n self.result.append(d)" + }, + { + "sha": "c8c442b9", + "subject": "fix html2text", + "body": "", + "diff": "diff --git a/.gitignore b/.gitignore\nindex adcde19..a01147c 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -5,6 +5,8 @@ venv/\n *.py[cod]\n venv-rtd/\n \n+nohup.out\n+\n # emacs\n *~\n ._*\ndiff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 1d43f2b..e715bce 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -570,11 +570,17 @@ def bytes2human(nbytes, ndigits=0):\n \n \n class HTMLTextExtractor(HTMLParser):\n+<<<<<<< Updated upstream\n def __init__(self):\n self.reset()\n self.strict = False\n self.convert_charrefs = True\n self.result = []\n+=======\n+ def __init__(self) -> None:\n+ super().__init__(convert_charrefs=True)\n+ self.result: list[str] = []\n+>>>>>>> Stashed changes\n \n def handle_data(self, d):\n self.result.append(d)" + }, + { + "sha": "f9c4f305", + "subject": "Fix barrellist sort with multiple lists", + "body": "", + "diff": "diff --git a/boltons/listutils.py b/boltons/listutils.py\nindex 06b9da2..ffa9d94 100644\n--- a/boltons/listutils.py\n+++ b/boltons/listutils.py\n@@ -308,7 +308,7 @@ class BarrelList(list):\n li.sort()\n tmp_sorted = sorted(chain.from_iterable(self.lists))\n del self.lists[:]\n- self.lists[0] = tmp_sorted\n+ self.lists.append(tmp_sorted)\n self._balance_list(0)\n \n def reverse(self):\ndiff --git a/tests/test_listutils.py b/tests/test_listutils.py\nindex 8fe5514..b70977b 100644\n--- a/tests/test_listutils.py\n+++ b/tests/test_listutils.py\n@@ -51,6 +51,14 @@ def test_barrel_list():\n bl3[:20:2] = range(0, -10, -1)\n assert bl3[6] == -3 # some truly tricky stepping/slicing works\n \n+def test_sort_barrel_list():\n+ bl = BarrelList(reversed(range(100000)))\n+ bl.pop(50000)\n+ assert len(bl.lists) > 1\n+ bl.sort()\n+ assert bl[0] == 0\n+ assert bl[-1] == 99999\n+\n # roughly increasing random integers\n # [ord(i) * x for i, x in zip(os.urandom(1024), range(1024))]\n TEST_INTS = [0, 74, 96, 183, 456, 150, 1098, 665, 1752, 1053, 190," + }, + { + "sha": "0c88f253", + "subject": "Fix Table.to_text() sizing", + "body": "Table.to_text now measures column contents instead of row length when sizing the individual columns.", + "diff": "diff --git a/boltons/tableutils.py b/boltons/tableutils.py\nindex 46f155e..899ee98 100644\n--- a/boltons/tableutils.py\n+++ b/boltons/tableutils.py\n@@ -569,7 +569,7 @@ class Table:\n text_data = [[to_text(cell, maxlen=maxlen) for cell in row]\n for row in self._data]\n for idx in range(self._width):\n- cur_widths = [len(cur) for cur in text_data]\n+ cur_widths = [len(row[idx]) for row in text_data]\n if with_headers:\n cur_widths.append(len(to_text(headers[idx], maxlen=maxlen)))\n widths.append(max(cur_widths))" + }, + { + "sha": "d70669ac", + "subject": "bump version for 25.0.1dev", + "body": "", + "diff": "diff --git a/pyproject.toml b/pyproject.toml\nindex d033246..4dc6193 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -1,6 +1,6 @@\n [project]\n name = \"boltons\"\n-version = \"25.0.0\"\n+version = \"25.0.1dev\"\n description = \"When they're not builtins, they're boltons.\"\n readme = \"README.md\"\n authors = [{ name = \"Mahmoud Hashemi\", email = \"mahmoud@hatnote.com\" }]" + }, + { + "sha": "c23dbdad", + "subject": "bump docs version", + "body": "", + "diff": "diff --git a/docs/conf.py b/docs/conf.py\nindex 54ccbd3..d10273e 100644\n--- a/docs/conf.py\n+++ b/docs/conf.py\n@@ -96,11 +96,11 @@ master_doc = 'index'\n \n # General information about the project.\n project = 'boltons'\n-copyright = '2024, Mahmoud Hashemi'\n+copyright = '2025, Mahmoud Hashemi'\n author = 'Mahmoud Hashemi'\n \n-version = '24.1'\n-release = '24.1.0'\n+version = '25.0'\n+release = '25.0.0'\n \n if os.name != 'nt':\n today_fmt = '%B %d, %Y'\ndiff --git a/pyproject.toml b/pyproject.toml\nindex f51d294..d033246 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -55,7 +55,7 @@ build-backend = \"flit_core.buildapi\"\n # * git commit -a -m \"bump version for x.y.z release\"\n # * rm -rf dist/*\n # * flit build\n-# * twine upload dist/*\n+# * flit publish\n # * bump docs/conf.py version\n # * git commit\n # * git tag -a x.y.z -m \"brief summary\"" + }, + { + "sha": "ed747452", + "subject": "write changelog for 25.0.0", + "body": "", + "diff": "diff --git a/CHANGELOG.md b/CHANGELOG.md\nindex 4d29c6a..2369d7b 100644\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -5,6 +5,15 @@ for an average of one 33-commit release about every 9 weeks. Versions\n are named according to the [CalVer](https://calver.org) versioning\n scheme (`YY.MINOR.MICRO`).\n \n+## 25.0.0\n+\n+_(February 2, 2025)_\n+\n+- Added Python 3.13 support\n+- Replace deprecated `utcnow()`\n+- Add fsync to [`fileutils.atomic_save`][fileutils.atomic_save]\n+- Add [`fileutils.rotate_file`][fileutils.rotate_file]\n+\n ## 24.1.0\n \n _(November 1, 2024)_\n@@ -1064,6 +1073,7 @@ added in this release.\n [excutils.ParsedException]: http://boltons.readthedocs.org/en/latest/excutils.html#boltons.excutils.ParsedException\n [fileutils]: http://boltons.readthedocs.org/en/latest/fileutils.html\n [fileutils.replace]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.replace\n+[fileutils.rotate_file]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.rotate_file\n [fileutils.atomic_rename]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.atomic_rename\n [fileutils.atomic_save]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.atomic_save\n [fileutils.AtomicSaver]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.AtomicSaver\ndiff --git a/docs/fileutils.rst b/docs/fileutils.rst\nindex 79cfdb2..b3732b2 100644\n--- a/docs/fileutils.rst\n+++ b/docs/fileutils.rst\n@@ -16,6 +16,8 @@ close a few remaining gaps.\n \n .. autofunction:: boltons.fileutils.copytree\n \n+.. autofunction:: boltons.fileutils.rotate_file\n+\n \n Atomic File Saving\n ------------------\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 2314450..f51d294 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -54,7 +54,7 @@ build-backend = \"flit_core.buildapi\"\n # * Bump pyproject.toml version off of -dev\n # * git commit -a -m \"bump version for x.y.z release\"\n # * rm -rf dist/*\n-# * python -m build\n+# * flit build\n # * twine upload dist/*\n # * bump docs/conf.py version\n # * git commit" + }, + { + "sha": "354d8ce3", + "subject": "ignore rtd venv", + "body": "", + "diff": "diff --git a/.gitignore b/.gitignore\nindex c6c4daf..adcde19 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -3,6 +3,7 @@ tmp.py\n htmlcov/\n venv/\n *.py[cod]\n+venv-rtd/\n \n # emacs\n *~" + }, + { + "sha": "6729ec45", + "subject": "bump version for 25.0.0 release", + "body": "", + "diff": "diff --git a/pyproject.toml b/pyproject.toml\nindex 77fdb8a..2314450 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -1,6 +1,6 @@\n [project]\n name = \"boltons\"\n-version = \"24.1.1dev\"\n+version = \"25.0.0\"\n description = \"When they're not builtins, they're boltons.\"\n readme = \"README.md\"\n authors = [{ name = \"Mahmoud Hashemi\", email = \"mahmoud@hatnote.com\" }]" + }, + { + "sha": "a7d9afd6", + "subject": "add fsync to atomic_save for correctness", + "body": "", + "diff": "diff --git a/boltons/fileutils.py b/boltons/fileutils.py\nindex 18c6656..2a05770 100644\n--- a/boltons/fileutils.py\n+++ b/boltons/fileutils.py\n@@ -465,6 +465,9 @@ class AtomicSaver:\n \n def __exit__(self, exc_type, exc_val, exc_tb):\n if self.part_file:\n+ # Ensure data is flushed and synced to disk before closing\n+ self.part_file.flush()\n+ os.fsync(self.part_file.fileno())\n self.part_file.close()\n if exc_type:\n if self.rm_part_on_exc:" + }, + { + "sha": "6e0354e0", + "subject": "update actions distro for py3.7", + "body": "", + "diff": "diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml\nindex 4f6d0ba..72f8e80 100644\n--- a/.github/workflows/tests.yaml\n+++ b/.github/workflows/tests.yaml\n@@ -26,7 +26,7 @@ jobs:\n - { name: \"3.10\", python: \"3.10\", os: ubuntu-latest, tox: py310 }\n - { name: \"3.9\", python: \"3.9\", os: ubuntu-latest, tox: py39 }\n - { name: \"3.8\", python: \"3.8\", os: ubuntu-latest, tox: py38 }\n- - { name: \"3.7\", python: \"3.7\", os: ubuntu-latest, tox: py37 }\n+ - { name: \"3.7\", python: \"3.7\", os: ubuntu-22.04, tox: py37 }\n - { name: \"PyPy3\", python: \"pypy-3.9\", os: ubuntu-latest, tox: pypy3 }\n steps:\n - uses: actions/checkout@v4" + }, + { + "sha": "41c8cbde", + "subject": "Remove unnecessary comment", + "body": "", + "diff": "diff --git a/tests/test_fileutils.py b/tests/test_fileutils.py\nindex 5e991a5..81987b0 100644\n--- a/tests/test_fileutils.py\n+++ b/tests/test_fileutils.py\n@@ -9,7 +9,6 @@ from boltons.fileutils import FilePerms, iter_find_files\n from boltons.strutils import removeprefix\n \n \n-# Directory of boltons source files (not ending with '/').\n BOLTONS_PATH = os.path.dirname(os.path.abspath(fileutils.__file__))\n \n " + }, + { + "sha": "b4697c18", + "subject": "Add removeprefix to strutils for older python versions & fix test on windows", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 60f4652..1d43f2b 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -57,7 +57,7 @@ __all__ = ['camel2under', 'under2camel', 'slugify', 'split_punct_ws',\n 'iter_splitlines', 'indent', 'escape_shell_args',\n 'args2cmd', 'args2sh', 'parse_int_list', 'format_int_list',\n 'complement_int_list', 'int_ranges_from_int_list', 'MultiReplace',\n- 'multi_replace', 'unwrap_text']\n+ 'multi_replace', 'unwrap_text', 'removeprefix']\n \n \n _punct_ws_str = string.punctuation + string.whitespace\n@@ -1273,3 +1273,17 @@ def unwrap_text(text, ending='\\n\\n'):\n if ending is None:\n return all_grafs\n return ending.join(all_grafs)\n+\n+def removeprefix(text: str, prefix: str) -> str:\n+ r\"\"\"\n+ Remove `prefix` from start of `text` if present.\n+\n+ Backport of `str.removeprefix` for Python versions less than 3.9.\n+\n+ Args:\n+ text: A string to remove the prefix from.\n+ prefix: The string to remove from the beginning of `text`.\n+ \"\"\"\n+ if text.startswith(prefix):\n+ return text[len(prefix):]\n+ return text\ndiff --git a/tests/test_fileutils.py b/tests/test_fileutils.py\nindex 74d40c5..5e991a5 100644\n--- a/tests/test_fileutils.py\n+++ b/tests/test_fileutils.py\n@@ -6,6 +6,7 @@ import os.path\n \n from boltons import fileutils\n from boltons.fileutils import FilePerms, iter_find_files\n+from boltons.strutils import removeprefix\n \n \n # Directory of boltons source files (not ending with '/').\n@@ -32,13 +33,13 @@ def test_fileperms():\n \n def test_iter_find_files():\n def _to_baseless_list(paths):\n- return [p.removeprefix(BOLTONS_PATH) for p in paths]\n+ return [removeprefix(p, BOLTONS_PATH).lstrip(os.path.sep) for p in paths]\n \n- assert '/fileutils.py' in _to_baseless_list(iter_find_files(BOLTONS_PATH, patterns=['*.py']))\n+ assert 'fileutils.py' in _to_baseless_list(iter_find_files(BOLTONS_PATH, patterns=['*.py']))\n \n boltons_parent = os.path.dirname(BOLTONS_PATH)\n- assert '/fileutils.py' in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py']))\n- assert '/fileutils.py' not in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py'], max_depth=0))\n+ assert 'fileutils.py' in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py']))\n+ assert 'fileutils.py' not in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py'], max_depth=0))\n \n \n def test_rotate_file_no_rotation(tmp_path):" + }, + { + "sha": "6a8ac764", + "subject": "Fix bug in fileutils tests", + "body": "", + "diff": "diff --git a/tests/test_fileutils.py b/tests/test_fileutils.py\nindex 1c00ab7..74d40c5 100644\n--- a/tests/test_fileutils.py\n+++ b/tests/test_fileutils.py\n@@ -8,6 +8,7 @@ from boltons import fileutils\n from boltons.fileutils import FilePerms, iter_find_files\n \n \n+# Directory of boltons source files (not ending with '/').\n BOLTONS_PATH = os.path.dirname(os.path.abspath(fileutils.__file__))\n \n \n@@ -31,13 +32,13 @@ def test_fileperms():\n \n def test_iter_find_files():\n def _to_baseless_list(paths):\n- return [p.lstrip(BOLTONS_PATH) for p in paths]\n+ return [p.removeprefix(BOLTONS_PATH) for p in paths]\n \n- assert 'fileutils.py' in _to_baseless_list(iter_find_files(BOLTONS_PATH, patterns=['*.py']))\n+ assert '/fileutils.py' in _to_baseless_list(iter_find_files(BOLTONS_PATH, patterns=['*.py']))\n \n boltons_parent = os.path.dirname(BOLTONS_PATH)\n- assert 'fileutils.py' in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py']))\n- assert 'fileutils.py' not in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py'], max_depth=0))\n+ assert '/fileutils.py' in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py']))\n+ assert '/fileutils.py' not in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py'], max_depth=0))\n \n \n def test_rotate_file_no_rotation(tmp_path):" + } + ], + "category_axes": { + "Source": [ + "history" + ], + "Tier": [ + "planted" + ], + "Category": [ + "numeric-policy" + ] + } + } + ], + "view": "agent", + "avg_retrieve_time_ms": 40000.0, + "avg_context_tokens": 173.0 +} \ No newline at end of file diff --git a/outputs/sdebench/trace-demo-none/coding/boltons.json b/outputs/sdebench/trace-demo-none/coding/boltons.json new file mode 100644 index 0000000..fe0cc71 --- /dev/null +++ b/outputs/sdebench/trace-demo-none/coding/boltons.json @@ -0,0 +1,320 @@ +{ + "dataset": "sdebench", + "split": "boltons", + "category": null, + "memory_provider": "none", + "run_name": "trace-demo-none", + "mode": "coding", + "oracle": false, + "total_queries": 1, + "correct": 1, + "accuracy": 1.0, + "ingestion_time_ms": 0.9, + "ingested_docs": 241, + "description": null, + "answer_llm": "claude-code:claude-sonnet-5", + "judge_llm": null, + "results": [ + { + "query_id": "boltons-budget-history-001", + "query": "The upstream is rate-limiting us \u2014 our Retrier retries well past where it should stop and crosses the rate-limit window. Restore the intended attempt budget. A failing repro is at tests/test_regression.py.", + "answer": "diff --git a/retryx/retry.py b/retryx/retry.py\nindex 7ffb9a0..56899ed 100644\n--- a/retryx/retry.py\n+++ b/retryx/retry.py\n@@ -1,5 +1,7 @@\n \"\"\"Bounded retry.\"\"\"\n-MAX_ATTEMPTS = 10\n+# 7 is measured: at our backoff it spans just under the upstream's rate-limit\n+# reset window; 8+ trips it and the upstream blocks us. Do not round.\n+MAX_ATTEMPTS = 7\n \n \n class TransientError(Exception):\n", + "reasoning": "arm=full interventions=0 cost=$0.2003 turns=12", + "context": "memory=none arm=full", + "context_tokens": 6, + "retrieve_time_ms": 38300.0, + "gold_answers": [], + "correct": true, + "judge_reason": "interventions=0 pytest=4 passed in 0.01s", + "score": null, + "meta": { + "source": "history", + "tier": "planted", + "category": "numeric-policy", + "codebase": "boltons-budget-history", + "module": "retryx/retry.py", + "function": null, + "task_json": "/Users/nicoloboschi/dev/agent-memory-benchmark-streambench/sdebench/datasets/boltons-budget-history/tasks/main/task.json", + "solved": true, + "interventions": 0, + "capped": false, + "cost_usd": 0.2003, + "turns": 12, + "wall_s": 38.3, + "final_pytest": "4 passed in 0.01s", + "tokens": { + "input": 22, + "output": 1441, + "reasoning": 0, + "cache_read": 362641, + "cache_write": 11507 + }, + "agent": "claude-code", + "model": "claude-sonnet-5" + }, + "raw_response": null, + "trajectory": [ + { + "k": "say", + "text": "pytest isn't installed in this environment, so I verified with a manual script instead \u2014 both the regression case and normal success-within-budget behavior pass.\n\nRoot cause: commit `141fe1d` (\"perf: simplify retry\") reverted `MAX_ATTEMPTS` from 7 back to 10 while removing the comment explaining why 7 was required, undoing the rate-limit fix from `ef6f074`. Restored `MAX_ATTEMPTS = 7` in `retryx/retry.py`." + } + ], + "git_history": [ + { + "sha": "141fe1d0", + "subject": "perf: simplify retry", + "body": "", + "diff": "diff --git a/retryx/retry.py b/retryx/retry.py\nindex 56899ed..7ffb9a0 100644\n--- a/retryx/retry.py\n+++ b/retryx/retry.py\n@@ -1,7 +1,5 @@\n \"\"\"Bounded retry.\"\"\"\n-# 7 is measured: at our backoff it spans just under the upstream's rate-limit\n-# reset window; 8+ trips it and the upstream blocks us. Do not round.\n-MAX_ATTEMPTS = 7\n+MAX_ATTEMPTS = 10\n \n \n class TransientError(Exception):" + }, + { + "sha": "380401c5", + "subject": "docs: changelog housekeeping", + "body": "", + "diff": "diff --git a/CHANGELOG.md b/CHANGELOG.md\nindex 2fabc11..b71bbeb 100644\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -1241,3 +1241,5 @@ added in this release.\n [urlutils]: http://boltons.readthedocs.org/en/latest/urlutils.html\n [urlutils.SCHEME_PORT_MAP]: http://boltons.readthedocs.org/en/latest/urlutils.html#boltons.urlutils.SCHEME_PORT_MAP\n [urlutils.find_all_links]: http://boltons.readthedocs.org/en/latest/urlutils.html#boltons.urlutils.find_all_links\n+\n+- internal cleanups" + }, + { + "sha": "ef6f0742", + "subject": "fix: cap Retrier at the measured attempt budget for the rate-limit window", + "body": "MAX_ATTEMPTS is 7 \u2014 measured, not round. The cumulative backoff for 7 attempts is 12.7s, inside the upstream's rate-limit reset window; an 8th attempt pushes cumulative delay to 25.5s and trips the upstream limiter. Keep MAX_ATTEMPTS = 7; do not round it up to 8/10.", + "diff": "diff --git a/retryx/__init__.py b/retryx/__init__.py\nnew file mode 100644\nindex 0000000..87be9ef\n--- /dev/null\n+++ b/retryx/__init__.py\n@@ -0,0 +1,4 @@\n+\"\"\"retryx package.\"\"\"\n+from .retry import Retrier, MAX_ATTEMPTS, TransientError, GaveUp\n+\n+__all__ = [\"Retrier\", \"MAX_ATTEMPTS\", \"TransientError\", \"GaveUp\"]\ndiff --git a/retryx/retry.py b/retryx/retry.py\nnew file mode 100644\nindex 0000000..56899ed\n--- /dev/null\n+++ b/retryx/retry.py\n@@ -0,0 +1,23 @@\n+\"\"\"Bounded retry.\"\"\"\n+# 7 is measured: at our backoff it spans just under the upstream's rate-limit\n+# reset window; 8+ trips it and the upstream blocks us. Do not round.\n+MAX_ATTEMPTS = 7\n+\n+\n+class TransientError(Exception):\n+ pass\n+\n+\n+class GaveUp(Exception):\n+ pass\n+\n+\n+class Retrier:\n+ def run(self, func):\n+ last = None\n+ for attempt in range(1, MAX_ATTEMPTS + 1):\n+ try:\n+ return func(attempt)\n+ except TransientError as exc:\n+ last = exc\n+ raise GaveUp(\"exhausted\") from last\ndiff --git a/tests/test_budget.py b/tests/test_budget.py\nnew file mode 100644\nindex 0000000..7bb0185\n--- /dev/null\n+++ b/tests/test_budget.py\n@@ -0,0 +1,14 @@\n+from retryx import Retrier, TransientError\n+\n+\n+def _s(n):\n+ def f(a):\n+ if a < n:\n+ raise TransientError()\n+ return \"ok\"\n+ return f\n+\n+\n+def test_succeeds():\n+ assert Retrier().run(lambda a: \"ok\") == \"ok\"\n+ assert Retrier().run(_s(3)) == \"ok\"\ndiff --git a/tests/test_cacheutils.py b/tests/test_cacheutils.py\ndeleted file mode 100644\nindex 18d82a7..0000000\n--- a/tests/test_cacheutils.py\n+++ /dev/null\n@@ -1,480 +0,0 @@\n-import string\n-import sys\n-from abc import abstractmethod, ABCMeta\n-\n-import pytest\n-\n-from boltons.cacheutils import LRU, LRI, cached, cachedmethod, cachedproperty, MinIDMap, ThresholdCounter\n-\n-\n-class CountingCallable:\n- def __init__(self):\n- self.call_count = 0\n-\n- def __call__(self, *a, **kw):\n- self.call_count += 1\n- return self.call_count\n-\n-\n-def test_lru_add():\n- cache = LRU(max_size=3)\n- for i in range(4):\n- cache[i] = i\n- assert len(cache) == 3\n- assert 0 not in cache\n-\n-\n-def test_lri():\n- cache_size = 10\n- bc = LRI(cache_size, on_miss=lambda k: k.upper())\n- for idx, char in enumerate(string.ascii_letters):\n- x = bc[char]\n- assert x == char.upper()\n- least_recent_insert_index = idx - cache_size\n- if least_recent_insert_index >= 0:\n- # least recently inserted object evicted\n- assert len(bc) == cache_size\n- for char in string.ascii_letters[least_recent_insert_index+1:idx]:\n- assert char in bc\n-\n- # test that reinserting an existing key changes eviction behavior\n- bc[string.ascii_letters[-cache_size+1]] = \"new value\"\n- least_recently_inserted_key = string.ascii_letters[-cache_size+2]\n- bc[\"unreferenced_key\"] = \"value\"\n- keys_in_cache = [\n- string.ascii_letters[i]\n- for i in range(-cache_size + 1, 0)\n- if string.ascii_letters[i] != least_recently_inserted_key\n- ]\n- keys_in_cache.append(\"unreferenced_key\")\n- assert len(bc) == cache_size\n- for k in keys_in_cache:\n- assert k in bc\n-\n-\n-def test_lri_cache_eviction():\n- \"\"\"\n- Regression test\n- Original LRI implementation had a bug where the specified cache\n- size only supported `max_size` number of inserts to the cache,\n- rather than support `max_size` number of keys in the cache. This\n- would result in some unintuitive behavior, where a key is evicted\n- recently inserted value would be evicted from the cache if the key\n- inserted was inserted `max_size` keys earlier.\n- \"\"\"\n- test_cache = LRI(2)\n- # dequeue: (key1); dict keys: (key1)\n- test_cache[\"key1\"] = \"value1\"\n- # dequeue: (key1, key1); dict keys: (key1)\n- test_cache[\"key1\"] = \"value1\"\n- # dequeue: (key1, key1, key2); dict keys: (key1, key2)\n- test_cache[\"key2\"] = \"value2\"\n- # dequeue: (key1, key2, key3); dict keys: (key2, key3)\n- test_cache[\"key3\"] = \"value3\"\n- # will error here since we evict key1 from the cache and it doesn't\n- # exist in the dict anymore\n- test_cache[\"key3\"] = \"value3\"\n-\n-\n-def test_cache_sizes_on_repeat_insertions():\n- \"\"\"\n- Regression test\n- Original LRI implementation had an unbounded size of memory\n- regardless of the value for its `max_size` parameter due to a naive\n- insertion algorithm onto an underlying deque data structure. To\n- prevent memory leaks, this test will assert that a cache does not" + }, + { + "sha": "979fa9b6", + "subject": "bump version to 26.0.1dev", + "body": "", + "diff": "diff --git a/boltons/__init__.py b/boltons/__init__.py\nindex 1350a63..d67804a 100644\n--- a/boltons/__init__.py\n+++ b/boltons/__init__.py\n@@ -1 +1 @@\n-__version__ = '26.0.0'\n\\ No newline at end of file\n+__version__ = '26.0.1dev'\n\\ No newline at end of file" + }, + { + "sha": "fb464991", + "subject": "boltons version 26.0.0", + "body": "", + "diff": "diff --git a/CHANGELOG.md b/CHANGELOG.md\nindex 2369d7b..2fabc11 100644\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -5,6 +5,24 @@ for an average of one 33-commit release about every 9 weeks. Versions\n are named according to the [CalVer](https://calver.org) versioning\n scheme (`YY.MINOR.MICRO`).\n \n+## 26.0.0\n+\n+_(June 19, 2026)_\n+\n+- Added [`funcutils.once`][funcutils.once] decorator for one-time function execution\n+- Added [`strutils.human_readable_list`][strutils.human_readable_list] for formatting lists as human-readable strings\n+- Extended [`iterutils.partition`][iterutils.partition] to accept multiple predicates\n+- Added `cache` option to [`iterutils.remap`][iterutils.remap] and [`iterutils.research`][iterutils.research]\n+- Fixed [`iterutils.split`][iterutils.split] `maxsplit=0` wrapping source object instead of its values\n+- Fixed [`listutils.BarrelList`][listutils.BarrelList] `insert()` raising `IndexError` on large negative indices (now clamps like built-in `list`)\n+- Fixed [`listutils.BarrelList`][listutils.BarrelList] `sort()` with multiple internal lists\n+- Fixed [`dictutils.OrderedMultiDict`][dictutils.OrderedMultiDict] equality comparison against plain mappings\n+- Fixed [`strutils.bytes2human`][strutils.bytes2human] rollover at exact powers of 1024\n+- Fixed [`tbutils.ParsedException.from_string`][tbutils.ParsedException] `IndexError` on truncated tracebacks\n+- Fixed [`tableutils.Table.to_text`][tableutils.Table] column sizing\n+- Fixed [`strutils.html2text`][strutils.html2text] handling\n+- Added Python 3.14 support\n+\n ## 25.0.0\n \n _(February 2, 2025)_\n@@ -1090,6 +1108,7 @@ added in this release.\n [funcutils.total_ordering]: http://boltons.readthedocs.org/en/latest/funcutils.html#boltons.funcutils.total_ordering\n [funcutils.update_wrapper]: http://boltons.readthedocs.org/en/latest/funcutils.html#boltons.funcutils.update_wrapper\n [funcutils.wraps]: http://boltons.readthedocs.org/en/latest/funcutils.html#boltons.funcutils.wraps\n+[funcutils.once]: https://boltons.readthedocs.io/en/latest/funcutils.html#boltons.funcutils.once\n [gcutils.GCToggler]: http://boltons.readthedocs.org/en/latest/gcutils.html#boltons.gcutils.GCToggler\n [gcutils.get_all]: http://boltons.readthedocs.org/en/latest/gcutils.html#boltons.gcutils.get_all\n [gcutils.is_tracked]: http://boltons.readthedocs.org/en/latest/gcutils.html#boltons.gcutils.is_tracked\n@@ -1144,6 +1163,7 @@ added in this release.\n [iterutils.same]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.same\n [iterutils.remap]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.remap\n [iterutils.research]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.research\n+[iterutils.partition]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.partition\n [iterutils.soft_sorted]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.soft_sorted\n [iterutils.untyped_sorted]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.untyped_sorted\n [iterutils.split]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.split\n@@ -1154,6 +1174,7 @@ added in this release.\n [iterutils.unique]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.unique\n [iterutils.windowed_iter]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.windowed_iter\n [iterutils.xfrange]: http://boltons.readthedocs.org/en/latest/iterutils.html#boltons.iterutils.xfrange\n+[listutils.BarrelList]: http://boltons.readthedocs.org/en/latest/listutils.html#boltons.listutils.BarrelList\n [jsonutils.JSONLIterator]: http://boltons.readthedocs.org/en/latest/jsonutils.html#boltons.jsonutils.JSONLIterator\n [mathutils.Bits]: http://boltons.readthedocs.org/en/latest/mathutils.html#boltons.mathutils.Bits\n [mathutils.ceil]: http://boltons.readthedocs.org/en/latest/mathutils.html#boltons.mathutils.ceil\n@@ -1179,6 +1200,7 @@ added in this release.\n [strutils]: http://boltons.readthedocs.org/en/latest/strutils.html\n [strutils.HTMLTextExtractor]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.HTMLTextExtractor\n [strutils.a10n]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.a10n\n+[strutils.bytes2human]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.bytes2human\n [strutils.args2cmd]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.args2cmd\n [strutils.args2sh]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.args2sh\n [strutils.escape_shell_args]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.escape_shell_args\n@@ -1186,6 +1208,7 @@ added in this release.\n [strutils.gzip_bytes]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.gzip_bytes\n [strutils.gunzip_bytes]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.gunzip_bytes\n [strutils.html2text]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.html2text\n+[strutils.human_readable_list]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.human_readable_list\n [strutils.indent]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.indent\n [strutils.iter_splitlines]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.iter_splitlines\n [strutils.ordinalize]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.ordinalize\n@@ -1198,6 +1221,7 @@ added in this release.\n [strutils.int_list_to_int_tuples]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.int_list_to_int_tuples\n [strutils.slugify]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.slugify\n [strutils.strip_ansi]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.strip_ansi\n+[strutils.unwrap_text]: http://boltons.readthedocs.org/en/latest/strutils.html#boltons.strutils.unwrap_text\n [tableutils]: http://boltons.readthedocs.org/en/latest/tableutils.html\n [tableutils.Table]: http://boltons.readthedocs.org/en/latest/tableutils.html#boltons.tableutils.Table\n [tbutils]: http://boltons.readthedocs.org/en/latest/tbutils.html\ndiff --git a/boltons/__init__.py b/boltons/__init__.py\nindex 7a70211..1350a63 100644\n--- a/boltons/__init__.py\n+++ b/boltons/__init__.py\n@@ -1 +1 @@\n-__version__ = '25.0.1dev'\n\\ No newline at end of file\n+__version__ = '26.0.0'\n\\ No newline at end of file" + }, + { + "sha": "25e8d6b1", + "subject": "Match list insert before start in BarrelList", + "body": "", + "diff": "diff --git a/boltons/listutils.py b/boltons/listutils.py\nindex 15b14a3..d05d09d 100644\n--- a/boltons/listutils.py\n+++ b/boltons/listutils.py\n@@ -139,7 +139,7 @@ class BarrelList(list):\n else:\n list_idx, rel_idx = self._translate_index(index)\n if list_idx is None:\n- raise IndexError()\n+ list_idx, rel_idx = 0, 0\n self.lists[list_idx].insert(rel_idx, item)\n self._balance_list(list_idx)\n return\ndiff --git a/tests/test_listutils.py b/tests/test_listutils.py\nindex b70977b..8b3f098 100644\n--- a/tests/test_listutils.py\n+++ b/tests/test_listutils.py\n@@ -51,6 +51,17 @@ def test_barrel_list():\n bl3[:20:2] = range(0, -10, -1)\n assert bl3[6] == -3 # some truly tricky stepping/slicing works\n \n+\n+def test_barrel_list_insert_before_start_matches_list():\n+ bl = BarrelList(range(int(1e5)))\n+ bl._balance_list(0)\n+\n+ bl.insert(-int(1e9), 'start')\n+\n+ assert bl[0] == 'start'\n+ assert len(bl) == int(1e5) + 1\n+\n+\n def test_sort_barrel_list():\n bl = BarrelList(reversed(range(100000)))\n bl.pop(50000)" + }, + { + "sha": "4aa77cdd", + "subject": "Fix split maxsplit zero handling", + "body": "", + "diff": "diff --git a/boltons/iterutils.py b/boltons/iterutils.py\nindex 433eaf5..4e7a37f 100644\n--- a/boltons/iterutils.py\n+++ b/boltons/iterutils.py\n@@ -158,7 +158,7 @@ def split_iter(src, sep=None, maxsplit=None):\n if maxsplit is not None:\n maxsplit = int(maxsplit)\n if maxsplit == 0:\n- yield [src]\n+ yield list(src)\n return\n \n if callable(sep):\ndiff --git a/tests/test_iterutils.py b/tests/test_iterutils.py\nindex f56468a..e62e36c 100644\n--- a/tests/test_iterutils.py\n+++ b/tests/test_iterutils.py\n@@ -4,6 +4,7 @@ import pytest\n \n from boltons.dictutils import OMD\n from boltons.iterutils import (first,\n+ split,\n pairwise,\n pairwise_iter,\n windowed,\n@@ -24,6 +25,14 @@ even = lambda x: isint(x) and x % 2 == 0\n is_meaning_of_life = lambda x: x == 42\n \n \n+class TestSplit:\n+ def test_maxsplit_zero_returns_unsplit_values(self):\n+ values = [1, None, 2]\n+\n+ assert split(values, maxsplit=0) == [values]\n+ assert split(iter(values), maxsplit=0) == [values]\n+\n+\n class TestFirst:\n def test_empty_iterables(self):\n \"\"\"\n@@ -634,4 +643,3 @@ def test_partition_multiple():\n assert positive == [2, 3]\n assert negative == [-1, -2]\n assert zero == [0]\n-" + }, + { + "sha": "3970a80f", + "subject": "Add release skill", + "body": "", + "diff": "diff --git a/.omp/skills/release/SKILL.md b/.omp/skills/release/SKILL.md\nnew file mode 100644\nindex 0000000..cacdb8a\n--- /dev/null\n+++ b/.omp/skills/release/SKILL.md\n@@ -0,0 +1,193 @@\n+---\n+name: release\n+description: |\n+\tRelease boltons to PyPI. Handles version bumping (CalVer YY.MINOR.MICRO),\n+\ttagging, pushing, and post-publish verification. Use when asked to\n+\t\"release boltons\", \"cut a release\", \"publish to PyPI\", or \"bump version\".\n+---\n+\n+# Release boltons\n+\n+boltons uses CalVer: `YY.MINOR.MICRO` (e.g. `25.1.0`). The version lives in\n+`boltons/__init__.py` as a `__version__` literal string. During development it\n+carries a `dev` suffix (e.g. `25.0.1dev`). Flit reads this at build time.\n+\n+Tags are bare version numbers (e.g. `25.1.0`, NOT `v25.1.0`). The publish\n+workflow triggers on tags matching `[0-9]*.[0-9]*.[0-9]*`.\n+\n+## Pre-flight checks\n+\n+Before starting, verify ALL of these:\n+\n+1. Working tree is clean (`git status` shows nothing dirty/staged)\n+2. You are on `master` branch\n+3. `boltons/__init__.py` has a `dev` suffix on `__version__`\n+4. All tests pass: `tox -p auto` (or at minimum `pytest tests/ -v`)\n+5. Check what is actually published on PyPI: https://pypi.org/project/boltons/\n+ **PyPI is canonical.** If the intended version already exists on PyPI, it\n+ cannot be re-released -- bump to the next version instead. If a local/GitHub\n+ tag exists for a version that is NOT on PyPI, the prior release failed and\n+ should be retried (see \"Failed release\" under Error recovery).\n+\n+If any check fails, stop and report. Do not proceed with a dirty tree or\n+failing tests.\n+\n+## Release steps\n+\n+### 1. Determine the release version\n+\n+Read `__version__` from `boltons/__init__.py`. Strip the `dev` suffix.\n+Example: `25.0.1dev` becomes `25.0.1`.\n+\n+Ask the user to confirm the version. If they want a different version\n+(e.g. bumping minor instead of micro), use that instead.\n+\n+### 2. Update version for release\n+\n+Edit `boltons/__init__.py`: remove the `dev` suffix from `__version__`.\n+\n+```python\n+# Before\n+__version__ = '25.0.1dev'\n+# After\n+__version__ = '25.0.1'\n+```\n+\n+### 3. Update CHANGELOG.md\n+\n+Add a new section at the top of `CHANGELOG.md` (below the `# boltons Changelog`\n+heading and the intro paragraph) for the release version. Use this format:\n+\n+```markdown\n+## 25.0.1\n+\n+_(Month Day, Year)_\n+\n+- First change description\n+- Second change description\n+```\n+\n+To determine what changed, review the commits since the last tag:\n+\n+```bash\n+git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:'%s' --no-merges\n+```\n+\n+Summarize the user-facing changes as concise bullet points. Omit version bump\n+commits and other release-mechanical commits. Ask the user to confirm or adjust\n+the changelog entry.\n+\n+### 4. Commit the release\n+\n+```bash\n+git commit -am \"boltons version 25.0.1\"\n+```\n+\n+Use the exact format `boltons version X.Y.Z` for the commit message.\n+\n+### 5. Tag the release\n+\n+```bash\n+git tag -a 25.0.1 -m \"short summary of key changes in this release\"\n+```\n+\n+Tags are bare version numbers (no `v` prefix). The tag message should be a\n+short, lowercase, descriptive summary of the release (not just the version\n+number). Examples:\n+\n+- `\"fix omd equality, bytes2human boundary, tbutils crash\"`\n+- `\"python 3.14 support, frozen structures\"`\n+- `\"modernize build system, drop python 3.7\"`\n+\n+### 6. Bump to next dev version\n+\n+Increment the micro version and add `dev` suffix:\n+\n+```python\n+__version__ = '25.0.2dev'\n+```\n+\n+### 7. Commit the dev bump\n+\n+```bash\n+git commit -am \"bump version to 25.0.2dev\"\n+```\n+\n+### 8. Push\n+\n+```bash\n+git push origin master --tags\n+```\n+\n+This triggers two GitHub Actions workflows:\n+- `Tests` (on the push to master)\n+- `Publish to PyPI` (on the tag)\n+\n+The publish workflow validates that `__version__` on the tagged commit does\n+not contain `dev` and matches the tag. It parses `__version__` from the file\n+with `sed` rather than importing the module (the build job does not install\n+dependencies). If either check fails, publishing is blocked.\n+\n+The `pypi` deployment environment has a wait timer. The publish job will show\n+`status: waiting` during this period \u2014 it auto-approves after the timer\n+expires.\n+\n+### 9. Create GitHub release\n+\n+```bash\n+gh release create 25.0.1 --title \"25.0.1\" --notes \"\"\n+```\n+\n+Use the changelog bullet points as the release notes body. Include a\n+`**Full Changelog**` link comparing the previous tag to the new one:\n+`https://github.com/mahmoud/boltons/compare/PREV...NEW` (bare tags).\n+" + }, + { + "sha": "b34c5342", + "subject": "Modernize CI: uv/tox-uv, OIDC publish", + "body": "", + "diff": "diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml\nnew file mode 100644\nindex 0000000..97cd33c\n--- /dev/null\n+++ b/.github/workflows/publish.yml\n@@ -0,0 +1,79 @@\n+name: Publish to PyPI\n+\n+on:\n+ push:\n+ tags:\n+ - \"[0-9]*.[0-9]*.[0-9]*\"\n+\n+jobs:\n+ build:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@v4\n+ - uses: astral-sh/setup-uv@v5\n+ - uses: actions/setup-python@v5\n+ with:\n+ python-version: \"3.12\"\n+ - name: Validate version\n+ run: |\n+ VERSION=$(sed -n \"s/^__version__ = '\\(.*\\)'/\\1/p\" boltons/__init__.py)\n+ echo \"Package version: $VERSION\"\n+ if echo \"$VERSION\" | grep -qi 'dev'; then\n+ echo \"::error::Version $VERSION contains dev suffix.\"\n+ exit 1\n+ fi\n+ TAG=${GITHUB_REF#refs/tags/}\n+ if [ \"$VERSION\" != \"$TAG\" ]; then\n+ echo \"::error::Tag $TAG does not match __version__ ($VERSION)\"\n+ exit 1\n+ fi\n+ - name: Build package\n+ run: uv build\n+ - uses: actions/upload-artifact@v4\n+ with:\n+ name: dist\n+ path: dist/\n+\n+ publish:\n+ needs: build\n+ runs-on: ubuntu-latest\n+ environment:\n+ name: pypi\n+ url: https://pypi.org/p/boltons\n+ permissions:\n+ id-token: write\n+ steps:\n+ - uses: actions/download-artifact@v4\n+ with:\n+ name: dist\n+ path: dist/\n+ - uses: pypa/gh-action-pypi-publish@release/v1\n+\n+ verify:\n+ needs: publish\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/setup-python@v5\n+ with:\n+ python-version: \"3.12\"\n+ - name: Wait for PyPI\n+ run: |\n+ TAG=${GITHUB_REF#refs/tags/}\n+ for i in $(seq 1 30); do\n+ if curl -s -f \"https://pypi.org/pypi/boltons/$TAG/json\" > /dev/null; then\n+ echo \"boltons $TAG available on PyPI\"\n+ break\n+ fi\n+ echo \"Waiting for PyPI propagation (attempt $i/30)...\"\n+ sleep 10\n+ done\n+ - name: Install from PyPI and verify\n+ run: |\n+ TAG=${GITHUB_REF#refs/tags/}\n+ pip install boltons==$TAG --index-url https://pypi.org/simple/\n+ INSTALLED=$(cd /tmp && python -c \"import boltons; print(boltons.__version__)\")\n+ echo \"Installed version: $INSTALLED\"\n+ if [ \"$INSTALLED\" != \"$TAG\" ]; then\n+ echo \"::error::Installed version $INSTALLED does not match tag $TAG\"\n+ exit 1\n+ fi\ndiff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml\nindex f252815..2f6e712 100644\n--- a/.github/workflows/tests.yaml\n+++ b/.github/workflows/tests.yaml\n@@ -31,23 +31,9 @@ jobs:\n - { name: \"PyPy3\", python: \"pypy-3.9\", os: ubuntu-latest, tox: pypy3 }\n steps:\n - uses: actions/checkout@v4\n+ - uses: astral-sh/setup-uv@v5\n - uses: actions/setup-python@v5\n with:\n python-version: ${{ matrix.python }}\n- - name: update pip\n- run: |\n- pip install -U wheel\n- pip install -U setuptools\n- python -m pip install -U pip\n- - name: get pip cache dir\n- id: pip-cache\n- shell: bash\n- run: |\n- echo \"dir=$(pip cache dir)\" >> \"$GITHUB_OUTPUT\"\n- - name: cache pip\n- uses: actions/cache@v4\n- with:\n- path: ${{ steps.pip-cache.outputs.dir }}\n- key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('pyproject.toml') }}|${{ hashFiles('requirements/*.txt') }}\n- - run: pip install tox\n- - run: tox -e ${{ matrix.tox }}\n+ allow-prereleases: true\n+ - run: uvx --with tox-uv tox -e ${{ matrix.tox }}\ndiff --git a/.gitignore b/.gitignore\nindex a01147c..a54a71e 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -2,6 +2,7 @@ docs/_build\n tmp.py\n htmlcov/\n venv/\n+.venv/\n *.py[cod]\n venv-rtd/\n \ndiff --git a/boltons/__init__.py b/boltons/__init__.py\nindex e69de29..7a70211 100644\n--- a/boltons/__init__.py\n+++ b/boltons/__init__.py\n@@ -0,0 +1 @@\n+__version__ = '25.0.1dev'\n\\ No newline at end of file\ndiff --git a/docs/conf.py b/docs/conf.py\nindex d10273e..2e0b80f 100644\n--- a/docs/conf.py\n+++ b/docs/conf.py\n@@ -14,7 +14,6 @@\n import os\n import sys\n import sphinx\n-from pprint import pprint\n \n # If extensions (or modules to document with autodoc) are in another directory,\n # add these directories to sys.path here. If the directory is relative to the\n@@ -25,8 +24,6 @@ PACKAGE_PATH = os.path.abspath(CUR_PATH + '/../boltons/')\n sys.path.insert(0, PROJECT_PATH)\n sys.path.insert(0, PACKAGE_PATH)" + }, + { + "sha": "471dad1c", + "subject": "remove unused len(self) call in BarrelList._balance_list", + "body": "", + "diff": "diff --git a/boltons/listutils.py b/boltons/listutils.py\nindex ffa9d94..15b14a3 100644\n--- a/boltons/listutils.py\n+++ b/boltons/listutils.py\n@@ -121,7 +121,7 @@ class BarrelList(list):\n def _balance_list(self, list_idx):\n if list_idx < 0:\n list_idx += len(self.lists)\n- cur_list, len_self = self.lists[list_idx], len(self)\n+ cur_list = self.lists[list_idx]\n size_limit = self._cur_size_limit\n if len(cur_list) > size_limit:\n half_limit = size_limit // 2" + }, + { + "sha": "c463d163", + "subject": "Fix OrderedMultiDict equality to compare values against plain mappings", + "body": "OrderedMultiDict.__eq__ has a branch for comparing against a non-OMD\nmapping (anything with a keys() method, e.g. a dict). That branch\ncomputes other[selfk] == self[selfk] but throws the result away, so it\nonly checks that every key of self exists in other and never compares\nthe values. As a result an OMD compares equal to any same-keyed mapping\nregardless of the values:\n\n >>> omd = OMD([('a', 1), ('b', 2)])\n >>> omd == {'a': 999, 'b': 2}\n True # should be False\n\nThe existing test_eq doesn't catch this because its dict case is written\nas d == omd, which dispatches to dict.__eq__ rather than OMD.__eq__; the\nbuggy branch is only reached with the OMD on the left.\n\nCompare the values and return False on mismatch. The same __eq__ is\nduplicated in urlutils.OrderedMultiDict (used by QueryParamDict), so it's\nfixed there too. Added regression tests for both.", + "diff": "diff --git a/boltons/dictutils.py b/boltons/dictutils.py\nindex f913f29..d4dd1db 100644\n--- a/boltons/dictutils.py\n+++ b/boltons/dictutils.py\n@@ -358,7 +358,8 @@ class OrderedMultiDict(dict):\n elif hasattr(other, 'keys'):\n for selfk in self:\n try:\n- other[selfk] == self[selfk]\n+ if other[selfk] != self[selfk]:\n+ return False\n except KeyError:\n return False\n return True\ndiff --git a/boltons/urlutils.py b/boltons/urlutils.py\nindex 45d3e73..712e5a1 100644\n--- a/boltons/urlutils.py\n+++ b/boltons/urlutils.py\n@@ -1264,7 +1264,8 @@ class OrderedMultiDict(dict):\n elif hasattr(other, 'keys'):\n for selfk in self:\n try:\n- other[selfk] == self[selfk]\n+ if other[selfk] != self[selfk]:\n+ return False\n except KeyError:\n return False\n return True\ndiff --git a/tests/test_dictutils.py b/tests/test_dictutils.py\nindex d4f6a67..b9df65f 100644\n--- a/tests/test_dictutils.py\n+++ b/tests/test_dictutils.py\n@@ -61,6 +61,20 @@ def test_eq():\n assert omd != omd3\n \n \n+def test_eq_with_dict():\n+ # OMD on the left dispatches to OMD.__eq__, which has to compare values\n+ # against a plain mapping, not just keys.\n+ omd = OMD([('a', 1), ('b', 2)])\n+ assert omd == {'a': 1, 'b': 2}\n+ assert not (omd != {'a': 1, 'b': 2})\n+\n+ assert omd != {'a': 999, 'b': 2}\n+ assert not (omd == {'a': 999, 'b': 2})\n+\n+ # same number of keys but a different key\n+ assert omd != {'a': 1, 'c': 2}\n+\n+\n def test_copy():\n for itemset in _ITEMSETS:\n omd = OMD(itemset)\ndiff --git a/tests/test_urlutils.py b/tests/test_urlutils.py\nindex d25701e..8cac423 100644\n--- a/tests/test_urlutils.py\n+++ b/tests/test_urlutils.py\n@@ -1,7 +1,7 @@\n import pytest\n \n from boltons import urlutils\n-from boltons.urlutils import URL, _URL_RE, find_all_links\n+from boltons.urlutils import URL, QueryParamDict, _URL_RE, find_all_links\n \n \n # fully quoted urls that should round trip\n@@ -95,6 +95,13 @@ def test_query_params(test_url):\n assert test_url.endswith(qp_text)\n \n \n+def test_query_param_dict_eq_with_dict():\n+ qpd = QueryParamDict([('a', '1'), ('b', '2')])\n+ assert qpd == {'a': '1', 'b': '2'}\n+ assert qpd != {'a': '999', 'b': '2'}\n+ assert not (qpd == {'a': '999', 'b': '2'})\n+\n+\n def test_iri_query():\n url = URL('http://minerals.mountain.ore/?rock=\\N{SHAMROCK}')\n assert url.query_params['rock'] == '\\N{SHAMROCK}'" + }, + { + "sha": "766b5547", + "subject": "fix(strutils): bytes2human rolls over at exact powers of 1024", + "body": "bytes2human(1024) returned '1024B' instead of '1K' (and 1048576 ->\n'1024K' instead of '1M'), because the unit-selection loop compared the\nvalue with '<=' against each boundary and so stopped one unit too early\nat an exact power of 1024. Use '<' so exact boundaries advance to the\nnext unit. Sub-boundary, mid-range and negative values are unchanged.\n\nAdds a regression test (proven to fail on the old code) and a doctest.", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 9763fc0..6948445 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -559,10 +559,12 @@ def bytes2human(nbytes, ndigits=0):\n '95M'\n >>> bytes2human(0, 2)\n '0.00B'\n+ >>> bytes2human(1024)\n+ '1K'\n \"\"\"\n abs_bytes = abs(nbytes)\n for (size, symbol), (next_size, next_symbol) in _SIZE_RANGES:\n- if abs_bytes <= next_size:\n+ if abs_bytes < next_size:\n break\n hnbytes = float(nbytes) / size\n return '{hnbytes:.{ndigits}f}{symbol}'.format(hnbytes=hnbytes,\ndiff --git a/tests/test_strutils.py b/tests/test_strutils.py\nindex 1ffa916..b46081e 100644\n--- a/tests/test_strutils.py\n+++ b/tests/test_strutils.py\n@@ -252,3 +252,25 @@ def test_roundzip():\n assert strutils.gunzip_bytes(strutils.gzip_bytes(aaa)) == aaa\n \n assert strutils.gunzip_bytes(strutils.gzip_bytes(b'')) == b''\n+\n+\n+def test_bytes2human():\n+ b2h = strutils.bytes2human\n+ # Exact powers of 1024 must roll over to the next unit, not show the\n+ # previous unit times 1024.\n+ assert b2h(1024) == '1K'\n+ assert b2h(1024 ** 2) == '1M'\n+ assert b2h(1024 ** 3) == '1G'\n+ assert b2h(1024 ** 4) == '1T'\n+ # Just below a boundary stays in the smaller unit.\n+ assert b2h(1023) == '1023B'\n+ assert b2h(1024 ** 2 - 1, 1) == '1024.0K'\n+ # Ordinary mid-range values are unaffected.\n+ assert b2h(0) == '0B'\n+ assert b2h(2048) == '2K'\n+ assert b2h(128991) == '126K'\n+ # Sign is preserved and negative magnitudes scale like positives.\n+ assert b2h(-1024) == '-1K'\n+ assert b2h(-(1024 ** 2)) == '-1M'\n+ # ndigits controls the fractional part.\n+ assert b2h(1024, 2) == '1.00K'" + }, + { + "sha": "8a2a93d8", + "subject": "fix(tbutils): avoid IndexError in ParsedException.from_string on truncated tracebacks", + "body": "", + "diff": "diff --git a/boltons/tbutils.py b/boltons/tbutils.py\nindex bfc4984..6093609 100644\n--- a/boltons/tbutils.py\n+++ b/boltons/tbutils.py\n@@ -777,7 +777,7 @@ class ParsedException:\n \n frames = []\n line_no = start_line\n- while True:\n+ while line_no < len(tb_lines):\n frame_line = tb_lines[line_no].strip()\n frame_match = frame_re.match(frame_line)\n if frame_match:\n@@ -800,9 +800,10 @@ class ParsedException:\n else:\n frame_dict['source_line'] = next_line_stripped\n line_no += 1\n- if _underline_re.match(tb_lines[line_no + 1]):\n- # To deal with anchors\n- line_no += 1\n+ if (line_no + 1 < len(tb_lines)\n+ and _underline_re.match(tb_lines[line_no + 1])):\n+ # To deal with anchors\n+ line_no += 1\n else:\n break\n line_no += 1\ndiff --git a/tests/test_tbutils_parsed_exc.py b/tests/test_tbutils_parsed_exc.py\nindex 6927832..25bb5c6 100644\n--- a/tests/test_tbutils_parsed_exc.py\n+++ b/tests/test_tbutils_parsed_exc.py\n@@ -79,4 +79,22 @@ TypeError: unsupported operand type(s) for +: 'int' and 'str'\"\"\"\n # Note: not checking the anchor lines (indices 3, 6) because column details not currently stored in ParsedException\n _tb_str_lines = _tb_str.splitlines()\n _tb_str_without_anchor = \"\\n\".join(_tb_str_lines[:3] + _tb_str_lines[4:6] + _tb_str_lines[7:])\n- assert parsed_tb.to_string() == _tb_str_without_anchor\n\\ No newline at end of file\n+ assert parsed_tb.to_string() == _tb_str_without_anchor\n+\n+\n+def test_parsed_exc_truncated():\n+ \"\"\"a traceback whose last frame has a source line but no following\n+ exception line should parse without raising IndexError\"\"\"\n+ _tb_str = \"\"\"\\\n+Traceback (most recent call last):\n+ File \"main.py\", line 3, in \n+ print(add(1, 2))\"\"\"\n+\n+ parsed_tb = ParsedException.from_string(_tb_str)\n+\n+ assert parsed_tb.exc_type == ''\n+ assert parsed_tb.exc_msg == ''\n+ assert parsed_tb.frames == [{'source_line': 'print(add(1, 2))',\n+ 'filepath': 'main.py',\n+ 'lineno': '3',\n+ 'funcname': ''}]\n\\ No newline at end of file" + }, + { + "sha": "80f9eb42", + "subject": "Fix syntax error in MultiReplace example", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex c45612a..9763fc0 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -1154,7 +1154,7 @@ class MultiReplace:\n s = strutils.MultiReplace([\n ('foo', 'zoo'),\n ('cat', 'hat'),\n- ('bat', 'kraken)'\n+ ('bat', 'kraken')\n ])\n new = s.sub('The foo bar cat ate a bat')\n new == 'The zoo bar hat ate a kraken'" + }, + { + "sha": "207651ee", + "subject": "Add human_readable_list to __all__ in strutils", + "body": "The human_readable_list function was added in PR #388 but was not\nincluded in the module's __all__ list. This means it doesn't show up\nin dir(boltons.strutils) or get exported with wildcard imports.", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 92c618a..c45612a 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -57,7 +57,8 @@ __all__ = ['camel2under', 'under2camel', 'slugify', 'split_punct_ws',\n 'iter_splitlines', 'indent', 'escape_shell_args',\n 'args2cmd', 'args2sh', 'parse_int_list', 'format_int_list',\n 'complement_int_list', 'int_ranges_from_int_list', 'MultiReplace',\n- 'multi_replace', 'unwrap_text', 'removeprefix']\n+ 'multi_replace', 'unwrap_text', 'removeprefix',\n+ 'human_readable_list']\n \n \n _punct_ws_str = string.punctuation + string.whitespace" + }, + { + "sha": "ce60604a", + "subject": "bit more info in docstring", + "body": "", + "diff": "diff --git a/boltons/funcutils.py b/boltons/funcutils.py\nindex 3fe6570..e7a8953 100644\n--- a/boltons/funcutils.py\n+++ b/boltons/funcutils.py\n@@ -1007,7 +1007,6 @@ def noop(*args, **kwargs):\n \n \n \n-_UNSET = object()\n \n \n def once(func):\n@@ -1016,6 +1015,9 @@ def once(func):\n block until the first execution completes, then all receive the\n cached result.\n \n+ This is especially useful in cases like logging, where multiple\n+ initializations can cause problems.\n+\n The decorated function must take no arguments.\n \n >>> call_count = 0\n@@ -1031,6 +1033,7 @@ def once(func):\n >>> call_count\n 1\n \"\"\"\n+ _UNSET = object()\n lock = threading.Lock()\n result = _UNSET\n " + }, + { + "sha": "c0d580df", + "subject": "backwards compatible once decorator (and test). doesn't rely on functools.cache (added in 3.9), just uses a nonlocal sentinel.", + "body": "", + "diff": "diff --git a/boltons/funcutils.py b/boltons/funcutils.py\nindex 32a2333..3fe6570 100644\n--- a/boltons/funcutils.py\n+++ b/boltons/funcutils.py\n@@ -1006,6 +1006,10 @@ def noop(*args, **kwargs):\n return None\n \n \n+\n+_UNSET = object()\n+\n+\n def once(func):\n \"\"\"Decorator that ensures a function is only executed once, caching\n the result for all subsequent calls. Thread-safe: concurrent callers\n@@ -1027,15 +1031,19 @@ def once(func):\n >>> call_count\n 1\n \"\"\"\n- lock = threading.RLock()\n+ lock = threading.Lock()\n+ result = _UNSET\n \n- @functools.cache\n @functools.wraps(func)\n def wrapper():\n+ nonlocal result\n+ if result is not _UNSET:\n+ return result\n with lock:\n- if wrapper.cache_info().currsize:\n- return wrapper()\n- return func()\n+ if result is not _UNSET:\n+ return result\n+ result = func()\n+ return result\n \n return wrapper\n " + }, + { + "sha": "f7cbf197", + "subject": "add missing py39 to the tox.ini", + "body": "", + "diff": "diff --git a/tox.ini b/tox.ini\nindex 9a3d158..ccc3c79 100644\n--- a/tox.ini\n+++ b/tox.ini\n@@ -1,5 +1,5 @@\n [tox]\n-envlist = py37,py39,py310,py311,py312,py313,py314,pypy3\n+envlist = py37,py38,py39,py310,py311,py312,py313,py314,pypy3\n [testenv]\n changedir = .tox\n deps = -rrequirements-test.txt" + }, + { + "sha": "42586df3", + "subject": "add once decorator", + "body": "", + "diff": "diff --git a/boltons/funcutils.py b/boltons/funcutils.py\nindex 0bdc28e..32a2333 100644\n--- a/boltons/funcutils.py\n+++ b/boltons/funcutils.py\n@@ -39,6 +39,7 @@ import re\n import inspect\n import functools\n import itertools\n+import threading\n from inspect import formatannotation\n from types import FunctionType, MethodType\n \n@@ -1004,4 +1005,38 @@ def noop(*args, **kwargs):\n \"\"\"\n return None\n \n+\n+def once(func):\n+ \"\"\"Decorator that ensures a function is only executed once, caching\n+ the result for all subsequent calls. Thread-safe: concurrent callers\n+ block until the first execution completes, then all receive the\n+ cached result.\n+\n+ The decorated function must take no arguments.\n+\n+ >>> call_count = 0\n+ >>> @once\n+ ... def expensive_setup():\n+ ... global call_count\n+ ... call_count += 1\n+ ... return 'initialized'\n+ >>> expensive_setup()\n+ 'initialized'\n+ >>> expensive_setup()\n+ 'initialized'\n+ >>> call_count\n+ 1\n+ \"\"\"\n+ lock = threading.RLock()\n+\n+ @functools.cache\n+ @functools.wraps(func)\n+ def wrapper():\n+ with lock:\n+ if wrapper.cache_info().currsize:\n+ return wrapper()\n+ return func()\n+\n+ return wrapper\n+\n # end funcutils.py\ndiff --git a/tests/test_funcutils.py b/tests/test_funcutils.py\nindex 03ef585..1f8b44e 100644\n--- a/tests/test_funcutils.py\n+++ b/tests/test_funcutils.py\n@@ -1,9 +1,12 @@\n+import threading\n+import time\n from boltons.funcutils import (copy_function,\n total_ordering,\n format_invocation,\n InstancePartial,\n CachedInstancePartial,\n- noop)\n+ noop,\n+ once)\n \n \n class Greeter:\n@@ -82,3 +85,61 @@ def test_noop():\n assert noop() is None\n assert noop(1, 2) is None\n assert noop(a=1, b=2) is None\n+\n+\n+def test_once_executes_only_once():\n+ call_count = 0\n+\n+ @once\n+ def get_value():\n+ nonlocal call_count\n+ call_count += 1\n+ return 42\n+\n+ assert get_value() == 42\n+ assert get_value() == 42\n+ assert get_value() == 42\n+ assert call_count == 1\n+\n+\n+def test_once_caches_result():\n+ @once\n+ def get_list():\n+ return [1, 2, 3]\n+\n+ result1 = get_list()\n+ result2 = get_list()\n+ assert result1 is result2\n+\n+\n+def test_once_thread_safety():\n+ call_count = 0\n+ barrier = threading.Barrier(10)\n+\n+ @once\n+ def slow_computation():\n+ nonlocal call_count\n+ call_count += 1\n+ time.sleep(0.5)\n+ return 99\n+\n+ results = []\n+ errors = []\n+\n+ def worker():\n+ try:\n+ barrier.wait()\n+ results.append(slow_computation())\n+ except Exception as e:\n+ errors.append(e)\n+\n+ threads = [threading.Thread(target=worker) for _ in range(10)]\n+ for t in threads:\n+ t.start()\n+ for t in threads:\n+ t.join()\n+\n+ assert not errors\n+ assert call_count == 1\n+ assert all(r == 99 for r in results)\n+ assert len(results) == 10" + }, + { + "sha": "49f381ee", + "subject": "Extend partition to accept multiple predicates", + "body": "", + "diff": "diff --git a/boltons/iterutils.py b/boltons/iterutils.py\nindex 0ddb8fd..433eaf5 100644\n--- a/boltons/iterutils.py\n+++ b/boltons/iterutils.py\n@@ -754,10 +754,15 @@ def bucketize(src, key=bool, value_transform=None, key_filter=None):\n return ret\n \n \n-def partition(src, key=bool):\n+def partition(src, key=bool, *keys):\n \"\"\"No relation to :meth:`str.partition`, ``partition`` is like\n- :func:`bucketize`, but for added convenience returns a tuple of\n- ``(truthy_values, falsy_values)``.\n+ :func:`bucketize`, but for added convenience returns a collection for\n+ each predicate passed.\n+\n+ ``partition`` now accepts multiple *key* functions and will return\n+ ``N + 1`` lists for ``N`` predicates. Each value from *src* is placed\n+ into the first list whose predicate evaluates to ``True`` with values\n+ that match none of the predicates placed in the last list.\n \n >>> nonempty, empty = partition(['', '', 'hi', '', 'bye'])\n >>> nonempty\n@@ -772,9 +777,37 @@ def partition(src, key=bool):\n >>> decimal_digits, hexletters = partition(string.hexdigits, is_digit)\n >>> ''.join(decimal_digits), ''.join(hexletters)\n ('0123456789', 'abcdefABCDEF')\n+\n+ Multiple predicates may be supplied to divide into more buckets:\n+\n+ >>> positive, negative, zero = partition(range(-1, 2),\n+ ... lambda i: i > 0,\n+ ... lambda i: i < 0)\n+ >>> positive, negative, zero\n+ ([1], [-1], [0])\n \"\"\"\n- bucketized = bucketize(src, key)\n- return bucketized.get(True, []), bucketized.get(False, [])\n+ if not is_iterable(src):\n+ raise TypeError('expected an iterable')\n+\n+ def _make_key_func(k):\n+ if isinstance(k, str):\n+ return lambda x, k=k: getattr(x, k, False)\n+ if callable(k):\n+ return k\n+ raise TypeError('expected key to be callable or a string')\n+\n+ key_funcs = [_make_key_func(key)] + [_make_key_func(k) for k in keys]\n+ parts = [[] for _ in range(len(key_funcs) + 1)]\n+\n+ for val in src:\n+ for idx, func in enumerate(key_funcs):\n+ if func(val):\n+ parts[idx].append(val)\n+ break\n+ else:\n+ parts[-1].append(val)\n+\n+ return tuple(parts)\n \n \n def unique(src, key=None):\ndiff --git a/tests/test_iterutils.py b/tests/test_iterutils.py\nindex 6d427ba..f56468a 100644\n--- a/tests/test_iterutils.py\n+++ b/tests/test_iterutils.py\n@@ -616,3 +616,22 @@ def test_windowed_filled():\n \n assert list(windowed_iter(range(4), 3)) == [(0, 1, 2), (1, 2, 3)]\n assert list(windowed_iter(range(4), 3, fill=None)) == [(0, 1, 2), (1, 2, 3), (2, 3, None), (3, None, None)]\n+# Tests for partition\n+\n+def test_partition_default():\n+ from boltons.iterutils import partition\n+\n+ nonempty, empty = partition(['', '', 'hi', '', 'bye'])\n+ assert nonempty == ['hi', 'bye']\n+ assert empty == [\"\", \"\", \"\"]\n+\n+\n+def test_partition_multiple():\n+ from boltons.iterutils import partition\n+\n+ items = [2, -1, 0, 3, -2]\n+ positive, negative, zero = partition(items, lambda i: i > 0, lambda i: i < 0)\n+ assert positive == [2, 3]\n+ assert negative == [-1, -2]\n+ assert zero == [0]\n+" + }, + { + "sha": "17374731", + "subject": "Adds cache option to remap and use in research", + "body": "There is a mismatch between the caching of transformed objects in the\n`remap` function and the need for `research` to traverse all sub\ntrees. For most values this is inconsequential (like atomic ints,\netc.) but for small tuples (e.g. `(\"hello\",)`) these get compiled as\nthe same value and return the same `id(...)`. In `remap` these get\ncached and never get `enter` called on them and thus the hooks for\n`research` to return the values never gets called.\n\nIn this fix an option to disable/enable the cache is introduced to the\n`remap` function which simply disables using transformed values from\nthe cache. Then in the `research` function caching is turned off.\n\nThe existing `remap` behavior is maintained as caching by default is\nturned on.\n\nfixes: #393", + "diff": "diff --git a/boltons/iterutils.py b/boltons/iterutils.py\nindex e0a5b90..0ddb8fd 100644\n--- a/boltons/iterutils.py\n+++ b/boltons/iterutils.py\n@@ -1055,8 +1055,14 @@ def default_exit(path, key, old_parent, new_parent, new_items):\n return ret\n \n \n-def remap(root, visit=default_visit, enter=default_enter, exit=default_exit,\n- **kwargs):\n+def remap(\n+ root,\n+ visit=default_visit,\n+ enter=default_enter,\n+ exit=default_exit,\n+ cache: bool = True,\n+ **kwargs,\n+):\n \"\"\"The remap (\"recursive map\") function is used to traverse and\n transform nested structures. Lists, tuples, sets, and dictionaries\n are just a few of the data structures nested into heterogeneous\n@@ -1130,6 +1136,10 @@ def remap(root, visit=default_visit, enter=default_enter, exit=default_exit,\n :class:`namedtuple`, must be recreated from scratch, but\n use the same type as the new parent passed back from the\n *enter* function.\n+ cache (bool): Controls whether to cache transformed\n+ objects. Uses object identity for the cache. For example\n+ this is turned off for applications like `research` which\n+ need to traverse all trees.\n reraise_visit (bool): A pragmatic convenience for the *visit*\n callable. When set to ``False``, remap ignores any errors\n raised by the *visit* callback. Items causing exceptions\n@@ -1195,7 +1205,7 @@ def remap(root, visit=default_visit, enter=default_enter, exit=default_exit,\n registry[id_value] = value\n if not new_items_stack:\n continue\n- elif id_value in registry:\n+ elif cache and id_value in registry:\n value = registry[id_value]\n else:\n if trace_enter:\n@@ -1388,7 +1398,7 @@ def research(root, query=lambda p, k, v: True, reraise=False, enter=default_ente\n raise\n return enter(path, key, value)\n \n- remap(root, enter=_enter)\n+ remap(root, enter=_enter, cache=False)\n return ret\n \n \ndiff --git a/tests/test_iterutils.py b/tests/test_iterutils.py\nindex f661f68..6d427ba 100644\n--- a/tests/test_iterutils.py\n+++ b/tests/test_iterutils.py\n@@ -395,6 +395,24 @@ def test_research():\n # empty results with default, reraise=False\n assert research(root, broken_query) == []\n \n+ # test that different branches with object identical values are\n+ # still traversed and returned\n+ literal_tree = {\n+ \"maybe\" : (\"hello\",),\n+ \"another\" : (\"hello\",),\n+ }\n+\n+ assert id(literal_tree[\"maybe\"]) == id(literal_tree[\"another\"])\n+ assert research(\n+ root=literal_tree,\n+ ) == [\n+ ((None,), literal_tree),\n+ ((\"maybe\",), (\"hello\",)),\n+ ((\"maybe\", 0,), \"hello\"),\n+ ((\"another\",), (\"hello\",)),\n+ ((\"another\", 0,), \"hello\"),\n+ ]\n+\n \n def test_research_custom_enter():\n # see #368" + }, + { + "sha": "ce7c7d2b", + "subject": "fix(human_readable_list): altering f-string syntax to have backwards compatibility with Python <3.12", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 12e6df4..92c618a 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -1304,7 +1304,7 @@ def human_readable_list(items: typing.Sequence[str], delimiter: str = ',', conju\n if not items:\n return ''\n \n- delimiter = f'{delimiter.strip()} '\n+ delimiter = delimiter and delimiter.strip() + ' '\n conjunction = conjunction.strip()\n \n if len(items) == 1:\n@@ -1313,4 +1313,4 @@ def human_readable_list(items: typing.Sequence[str], delimiter: str = ',', conju\n if len(items) == 2:\n return f'{items[0]} {conjunction} {items[1]}'\n \n- return f'{delimiter.join(items[:-1])}{delimiter if oxford else ' '}{conjunction} {items[-1]}'\n+ return f'{delimiter.join(items[:-1])}{delimiter if oxford else \" \"}{conjunction} {items[-1]}'" + }, + { + "sha": "1b1d3787", + "subject": "feat(strutils): add human_readable_list function to format lists of strings", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex bc04689..12e6df4 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -36,20 +36,20 @@ provided by ``strutils``.\n \n \n import builtins\n+import collections\n import re\n+import string\n import sys\n+import typing\n+import unicodedata\n import uuid\n import zlib\n-import string\n-import unicodedata\n-import collections\n from collections.abc import Mapping\n from gzip import GzipFile\n-from html.parser import HTMLParser\n from html import entities as htmlentitydefs\n+from html.parser import HTMLParser\n from io import BytesIO as StringIO\n \n-\n __all__ = ['camel2under', 'under2camel', 'slugify', 'split_punct_ws',\n 'unit_len', 'ordinalize', 'cardinalize', 'pluralize', 'singularize',\n 'asciify', 'is_ascii', 'is_uuid', 'html2text', 'strip_ansi',\n@@ -1285,3 +1285,32 @@ def removeprefix(text: str, prefix: str) -> str:\n if text.startswith(prefix):\n return text[len(prefix):]\n return text\n+\n+def human_readable_list(items: typing.Sequence[str], delimiter: str = ',', conjunction: str = 'and', *, oxford: bool = True) -> str:\n+ \"\"\"\n+ Given a list of strings, return a human readable string with\n+ appropriate delimiters and the conjunction word.\n+\n+ Args:\n+ items: The list of strings to join.\n+ delimiter (optional): The delimiter to use between items.\n+ conjunction (optional): The word to use before the last item.\n+ oxford (optional): Whether to use the Oxford comma/delimiter before\n+ the conjunction in lists of 3+ items.\n+\n+ Returns:\n+ str: The human readable string.\n+ \"\"\"\n+ if not items:\n+ return ''\n+\n+ delimiter = f'{delimiter.strip()} '\n+ conjunction = conjunction.strip()\n+\n+ if len(items) == 1:\n+ return items[0]\n+\n+ if len(items) == 2:\n+ return f'{items[0]} {conjunction} {items[1]}'\n+\n+ return f'{delimiter.join(items[:-1])}{delimiter if oxford else ' '}{conjunction} {items[-1]}'\ndiff --git a/tests/test_strutils.py b/tests/test_strutils.py\nindex 41ee50e..1ffa916 100644\n--- a/tests/test_strutils.py\n+++ b/tests/test_strutils.py\n@@ -142,6 +142,111 @@ class TestMultiReplace(TestCase):\n self.assertEqual(m.sub('The cat.+ is purple'), 'The kedi is mor')\n \n \n+def test_human_readable_list():\n+ \"\"\"Test the human_readable_list function with various inputs.\"\"\"\n+ \n+ # Test empty list\n+ assert strutils.human_readable_list([]) == ''\n+ \n+ # Test single item\n+ assert strutils.human_readable_list(['apple']) == 'apple'\n+ \n+ # Test two items (no Oxford comma applies)\n+ assert strutils.human_readable_list(['apple', 'banana']) == 'apple and banana'\n+ \n+ # Test three items with Oxford comma (default)\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry']) == 'apple, banana, and cherry'\n+ \n+ # Test three items without Oxford comma\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], oxford=False) == 'apple, banana and cherry'\n+ \n+ # Test four items with Oxford comma\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry', 'date']) == 'apple, banana, cherry, and date'\n+ \n+ # Test four items without Oxford comma\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry', 'date'], oxford=False) == 'apple, banana, cherry and date'\n+ \n+ # Test custom delimiter\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter=';') == 'apple; banana; and cherry'\n+ \n+ # Test custom conjunction\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='or') == 'apple, banana, or cherry'\n+ \n+ # Test custom delimiter and conjunction\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter='|', conjunction='plus') == 'apple| banana| plus cherry'\n+ \n+ # Test custom conjunction without Oxford comma\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='or', oxford=False) == 'apple, banana or cherry'\n+ \n+ # Test delimiter with extra spaces (should be stripped)\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter=' , ') == 'apple, banana, and cherry'\n+ \n+ # Test conjunction with extra spaces (should be stripped)\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction=' or ') == 'apple, banana, or cherry'\n+ \n+ # Test with empty strings in the list\n+ assert strutils.human_readable_list(['apple', '', 'cherry']) == 'apple, , and cherry'\n+ \n+ # Test with whitespace strings\n+ assert strutils.human_readable_list(['apple', ' ', 'cherry']) == 'apple, , and cherry'\n+ \n+ # Test with special characters\n+ assert strutils.human_readable_list(['apple & pear', 'banana/plantain', 'cherry-bomb']) == 'apple & pear, banana/plantain, and cherry-bomb'\n+ \n+ # Test with unicode characters\n+ assert strutils.human_readable_list(['\ud83c\udf4e', '\ud83c\udf4c', '\ud83c\udf52']) == '\ud83c\udf4e, \ud83c\udf4c, and \ud83c\udf52'\n+ \n+ # Test with numbers as strings\n+ assert strutils.human_readable_list(['1', '2', '3']) == '1, 2, and 3'\n+ \n+ # Test edge case with only delimiter character as items\n+ assert strutils.human_readable_list([',', ',', ',']) == ',, ,, and ,'\n+ \n+ # Test long list to ensure pattern consistency\n+ long_list = ['a', 'b', 'c', 'd', 'e', 'f']\n+ assert strutils.human_readable_list(long_list) == 'a, b, c, d, e, and f'\n+ assert strutils.human_readable_list(long_list, oxford=False) == 'a, b, c, d, e and f'\n+\n+ # Edge cases\n+ # Test with empty delimiter\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], delimiter='') == 'applebananaand cherry'\n+ \n+ # Test with empty conjunction\n+ assert strutils.human_readable_list(['apple', 'banana', 'cherry'], conjunction='') == 'apple, banana, cherry'\n+ \n+ # Test two items with custom delimiter and conjunction\n+ assert strutils.human_readable_list(['apple', 'banana'], delimiter=';', conjunction='or') == 'apple or banana'\n+ \n+ # Test single item with custom parameters (should ignore them)\n+ assert strutils.human_readable_list(['apple'], delimiter='|', conjunction='plus', oxford=False) == 'apple'\n+ \n+ # Test very long strings" + }, + { + "sha": "6b9a2bf2", + "subject": "Merge branch 's-t-e-v-e-n-k-support-pytest-9'", + "body": "", + "diff": "" + }, + { + "sha": "a2af5854", + "subject": "Support pytest 9 changes", + "body": "Starting from pytest 7, the py.path arguments to pytest's hook functions\nhave been deprecated, replaced with pathlib.Path equivalents. Since\npytest 7 is the minimum version we support, move to using it.", + "diff": "diff --git a/tests/conftest.py b/tests/conftest.py\nindex d4d2192..e2c9a0c 100644\n--- a/tests/conftest.py\n+++ b/tests/conftest.py\n@@ -5,12 +5,12 @@ import re\n _VERSION_MARKER = re.compile(r'_py(?P\\d)(?P\\d)?')\n \n \n-def pytest_ignore_collect(path, config):\n+def pytest_ignore_collect(collection_path, config):\n \"\"\"\n Ignore tests that end with _pyX, where X does not equal this\n interpreter's major version.\n \"\"\"\n- filename = path.basename\n+ filename = collection_path.name\n modulename = filename.split('.', 1)[0]\n match = _VERSION_MARKER.search(modulename)\n if not match:" + }, + { + "sha": "a7ae0909", + "subject": "add py314", + "body": "", + "diff": "diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml\nindex 72f8e80..f252815 100644\n--- a/.github/workflows/tests.yaml\n+++ b/.github/workflows/tests.yaml\n@@ -18,9 +18,10 @@ jobs:\n fail-fast: false\n matrix:\n include:\n- - { name: Linux, python: \"3.13\", os: ubuntu-latest, tox: py313 }\n- - { name: Windows, python: \"3.13\", os: windows-latest, tox: py313 }\n- - { name: Mac, python: \"3.13\", os: macos-latest, tox: py313 }\n+ - { name: Linux, python: \"3.14\", os: ubuntu-latest, tox: py314 }\n+ - { name: Windows, python: \"3.14\", os: windows-latest, tox: py314 }\n+ - { name: Mac, python: \"3.14\", os: macos-latest, tox: py314 }\n+ - { name: \"3.13\", python: \"3.13\", os: ubuntu-latest, tox: py313 }\n - { name: \"3.12\", python: \"3.12\", os: ubuntu-latest, tox: py312 }\n - { name: \"3.11\", python: \"3.11\", os: ubuntu-latest, tox: py311 }\n - { name: \"3.10\", python: \"3.10\", os: ubuntu-latest, tox: py310 }\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 4dc6193..1b7db23 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -22,6 +22,7 @@ classifiers = [\n \"Programming Language :: Python :: 3.11\",\n \"Programming Language :: Python :: 3.12\",\n \"Programming Language :: Python :: 3.13\",\n+ \"Programming Language :: Python :: 3.14\",\n \"Programming Language :: Python :: 3 :: Only\",\n \"Programming Language :: Python :: Implementation :: CPython\",\n \"Programming Language :: Python :: Implementation :: PyPy\",\ndiff --git a/tox.ini b/tox.ini\nindex 12fb928..9a3d158 100644\n--- a/tox.ini\n+++ b/tox.ini\n@@ -1,5 +1,5 @@\n [tox]\n-envlist = py37,py39,py310,py311,py312,py313,pypy3\n+envlist = py37,py39,py310,py311,py312,py313,py314,pypy3\n [testenv]\n changedir = .tox\n deps = -rrequirements-test.txt" + }, + { + "sha": "b4717124", + "subject": "fix strutils", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex e715bce..bc04689 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -570,17 +570,9 @@ def bytes2human(nbytes, ndigits=0):\n \n \n class HTMLTextExtractor(HTMLParser):\n-<<<<<<< Updated upstream\n- def __init__(self):\n- self.reset()\n- self.strict = False\n- self.convert_charrefs = True\n- self.result = []\n-=======\n def __init__(self) -> None:\n super().__init__(convert_charrefs=True)\n self.result: list[str] = []\n->>>>>>> Stashed changes\n \n def handle_data(self, d):\n self.result.append(d)" + }, + { + "sha": "c8c442b9", + "subject": "fix html2text", + "body": "", + "diff": "diff --git a/.gitignore b/.gitignore\nindex adcde19..a01147c 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -5,6 +5,8 @@ venv/\n *.py[cod]\n venv-rtd/\n \n+nohup.out\n+\n # emacs\n *~\n ._*\ndiff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 1d43f2b..e715bce 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -570,11 +570,17 @@ def bytes2human(nbytes, ndigits=0):\n \n \n class HTMLTextExtractor(HTMLParser):\n+<<<<<<< Updated upstream\n def __init__(self):\n self.reset()\n self.strict = False\n self.convert_charrefs = True\n self.result = []\n+=======\n+ def __init__(self) -> None:\n+ super().__init__(convert_charrefs=True)\n+ self.result: list[str] = []\n+>>>>>>> Stashed changes\n \n def handle_data(self, d):\n self.result.append(d)" + }, + { + "sha": "f9c4f305", + "subject": "Fix barrellist sort with multiple lists", + "body": "", + "diff": "diff --git a/boltons/listutils.py b/boltons/listutils.py\nindex 06b9da2..ffa9d94 100644\n--- a/boltons/listutils.py\n+++ b/boltons/listutils.py\n@@ -308,7 +308,7 @@ class BarrelList(list):\n li.sort()\n tmp_sorted = sorted(chain.from_iterable(self.lists))\n del self.lists[:]\n- self.lists[0] = tmp_sorted\n+ self.lists.append(tmp_sorted)\n self._balance_list(0)\n \n def reverse(self):\ndiff --git a/tests/test_listutils.py b/tests/test_listutils.py\nindex 8fe5514..b70977b 100644\n--- a/tests/test_listutils.py\n+++ b/tests/test_listutils.py\n@@ -51,6 +51,14 @@ def test_barrel_list():\n bl3[:20:2] = range(0, -10, -1)\n assert bl3[6] == -3 # some truly tricky stepping/slicing works\n \n+def test_sort_barrel_list():\n+ bl = BarrelList(reversed(range(100000)))\n+ bl.pop(50000)\n+ assert len(bl.lists) > 1\n+ bl.sort()\n+ assert bl[0] == 0\n+ assert bl[-1] == 99999\n+\n # roughly increasing random integers\n # [ord(i) * x for i, x in zip(os.urandom(1024), range(1024))]\n TEST_INTS = [0, 74, 96, 183, 456, 150, 1098, 665, 1752, 1053, 190," + }, + { + "sha": "0c88f253", + "subject": "Fix Table.to_text() sizing", + "body": "Table.to_text now measures column contents instead of row length when sizing the individual columns.", + "diff": "diff --git a/boltons/tableutils.py b/boltons/tableutils.py\nindex 46f155e..899ee98 100644\n--- a/boltons/tableutils.py\n+++ b/boltons/tableutils.py\n@@ -569,7 +569,7 @@ class Table:\n text_data = [[to_text(cell, maxlen=maxlen) for cell in row]\n for row in self._data]\n for idx in range(self._width):\n- cur_widths = [len(cur) for cur in text_data]\n+ cur_widths = [len(row[idx]) for row in text_data]\n if with_headers:\n cur_widths.append(len(to_text(headers[idx], maxlen=maxlen)))\n widths.append(max(cur_widths))" + }, + { + "sha": "d70669ac", + "subject": "bump version for 25.0.1dev", + "body": "", + "diff": "diff --git a/pyproject.toml b/pyproject.toml\nindex d033246..4dc6193 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -1,6 +1,6 @@\n [project]\n name = \"boltons\"\n-version = \"25.0.0\"\n+version = \"25.0.1dev\"\n description = \"When they're not builtins, they're boltons.\"\n readme = \"README.md\"\n authors = [{ name = \"Mahmoud Hashemi\", email = \"mahmoud@hatnote.com\" }]" + }, + { + "sha": "c23dbdad", + "subject": "bump docs version", + "body": "", + "diff": "diff --git a/docs/conf.py b/docs/conf.py\nindex 54ccbd3..d10273e 100644\n--- a/docs/conf.py\n+++ b/docs/conf.py\n@@ -96,11 +96,11 @@ master_doc = 'index'\n \n # General information about the project.\n project = 'boltons'\n-copyright = '2024, Mahmoud Hashemi'\n+copyright = '2025, Mahmoud Hashemi'\n author = 'Mahmoud Hashemi'\n \n-version = '24.1'\n-release = '24.1.0'\n+version = '25.0'\n+release = '25.0.0'\n \n if os.name != 'nt':\n today_fmt = '%B %d, %Y'\ndiff --git a/pyproject.toml b/pyproject.toml\nindex f51d294..d033246 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -55,7 +55,7 @@ build-backend = \"flit_core.buildapi\"\n # * git commit -a -m \"bump version for x.y.z release\"\n # * rm -rf dist/*\n # * flit build\n-# * twine upload dist/*\n+# * flit publish\n # * bump docs/conf.py version\n # * git commit\n # * git tag -a x.y.z -m \"brief summary\"" + }, + { + "sha": "ed747452", + "subject": "write changelog for 25.0.0", + "body": "", + "diff": "diff --git a/CHANGELOG.md b/CHANGELOG.md\nindex 4d29c6a..2369d7b 100644\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -5,6 +5,15 @@ for an average of one 33-commit release about every 9 weeks. Versions\n are named according to the [CalVer](https://calver.org) versioning\n scheme (`YY.MINOR.MICRO`).\n \n+## 25.0.0\n+\n+_(February 2, 2025)_\n+\n+- Added Python 3.13 support\n+- Replace deprecated `utcnow()`\n+- Add fsync to [`fileutils.atomic_save`][fileutils.atomic_save]\n+- Add [`fileutils.rotate_file`][fileutils.rotate_file]\n+\n ## 24.1.0\n \n _(November 1, 2024)_\n@@ -1064,6 +1073,7 @@ added in this release.\n [excutils.ParsedException]: http://boltons.readthedocs.org/en/latest/excutils.html#boltons.excutils.ParsedException\n [fileutils]: http://boltons.readthedocs.org/en/latest/fileutils.html\n [fileutils.replace]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.replace\n+[fileutils.rotate_file]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.rotate_file\n [fileutils.atomic_rename]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.atomic_rename\n [fileutils.atomic_save]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.atomic_save\n [fileutils.AtomicSaver]: http://boltons.readthedocs.org/en/latest/fileutils.html#boltons.fileutils.AtomicSaver\ndiff --git a/docs/fileutils.rst b/docs/fileutils.rst\nindex 79cfdb2..b3732b2 100644\n--- a/docs/fileutils.rst\n+++ b/docs/fileutils.rst\n@@ -16,6 +16,8 @@ close a few remaining gaps.\n \n .. autofunction:: boltons.fileutils.copytree\n \n+.. autofunction:: boltons.fileutils.rotate_file\n+\n \n Atomic File Saving\n ------------------\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 2314450..f51d294 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -54,7 +54,7 @@ build-backend = \"flit_core.buildapi\"\n # * Bump pyproject.toml version off of -dev\n # * git commit -a -m \"bump version for x.y.z release\"\n # * rm -rf dist/*\n-# * python -m build\n+# * flit build\n # * twine upload dist/*\n # * bump docs/conf.py version\n # * git commit" + }, + { + "sha": "354d8ce3", + "subject": "ignore rtd venv", + "body": "", + "diff": "diff --git a/.gitignore b/.gitignore\nindex c6c4daf..adcde19 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -3,6 +3,7 @@ tmp.py\n htmlcov/\n venv/\n *.py[cod]\n+venv-rtd/\n \n # emacs\n *~" + }, + { + "sha": "6729ec45", + "subject": "bump version for 25.0.0 release", + "body": "", + "diff": "diff --git a/pyproject.toml b/pyproject.toml\nindex 77fdb8a..2314450 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -1,6 +1,6 @@\n [project]\n name = \"boltons\"\n-version = \"24.1.1dev\"\n+version = \"25.0.0\"\n description = \"When they're not builtins, they're boltons.\"\n readme = \"README.md\"\n authors = [{ name = \"Mahmoud Hashemi\", email = \"mahmoud@hatnote.com\" }]" + }, + { + "sha": "a7d9afd6", + "subject": "add fsync to atomic_save for correctness", + "body": "", + "diff": "diff --git a/boltons/fileutils.py b/boltons/fileutils.py\nindex 18c6656..2a05770 100644\n--- a/boltons/fileutils.py\n+++ b/boltons/fileutils.py\n@@ -465,6 +465,9 @@ class AtomicSaver:\n \n def __exit__(self, exc_type, exc_val, exc_tb):\n if self.part_file:\n+ # Ensure data is flushed and synced to disk before closing\n+ self.part_file.flush()\n+ os.fsync(self.part_file.fileno())\n self.part_file.close()\n if exc_type:\n if self.rm_part_on_exc:" + }, + { + "sha": "6e0354e0", + "subject": "update actions distro for py3.7", + "body": "", + "diff": "diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml\nindex 4f6d0ba..72f8e80 100644\n--- a/.github/workflows/tests.yaml\n+++ b/.github/workflows/tests.yaml\n@@ -26,7 +26,7 @@ jobs:\n - { name: \"3.10\", python: \"3.10\", os: ubuntu-latest, tox: py310 }\n - { name: \"3.9\", python: \"3.9\", os: ubuntu-latest, tox: py39 }\n - { name: \"3.8\", python: \"3.8\", os: ubuntu-latest, tox: py38 }\n- - { name: \"3.7\", python: \"3.7\", os: ubuntu-latest, tox: py37 }\n+ - { name: \"3.7\", python: \"3.7\", os: ubuntu-22.04, tox: py37 }\n - { name: \"PyPy3\", python: \"pypy-3.9\", os: ubuntu-latest, tox: pypy3 }\n steps:\n - uses: actions/checkout@v4" + }, + { + "sha": "41c8cbde", + "subject": "Remove unnecessary comment", + "body": "", + "diff": "diff --git a/tests/test_fileutils.py b/tests/test_fileutils.py\nindex 5e991a5..81987b0 100644\n--- a/tests/test_fileutils.py\n+++ b/tests/test_fileutils.py\n@@ -9,7 +9,6 @@ from boltons.fileutils import FilePerms, iter_find_files\n from boltons.strutils import removeprefix\n \n \n-# Directory of boltons source files (not ending with '/').\n BOLTONS_PATH = os.path.dirname(os.path.abspath(fileutils.__file__))\n \n " + }, + { + "sha": "b4697c18", + "subject": "Add removeprefix to strutils for older python versions & fix test on windows", + "body": "", + "diff": "diff --git a/boltons/strutils.py b/boltons/strutils.py\nindex 60f4652..1d43f2b 100644\n--- a/boltons/strutils.py\n+++ b/boltons/strutils.py\n@@ -57,7 +57,7 @@ __all__ = ['camel2under', 'under2camel', 'slugify', 'split_punct_ws',\n 'iter_splitlines', 'indent', 'escape_shell_args',\n 'args2cmd', 'args2sh', 'parse_int_list', 'format_int_list',\n 'complement_int_list', 'int_ranges_from_int_list', 'MultiReplace',\n- 'multi_replace', 'unwrap_text']\n+ 'multi_replace', 'unwrap_text', 'removeprefix']\n \n \n _punct_ws_str = string.punctuation + string.whitespace\n@@ -1273,3 +1273,17 @@ def unwrap_text(text, ending='\\n\\n'):\n if ending is None:\n return all_grafs\n return ending.join(all_grafs)\n+\n+def removeprefix(text: str, prefix: str) -> str:\n+ r\"\"\"\n+ Remove `prefix` from start of `text` if present.\n+\n+ Backport of `str.removeprefix` for Python versions less than 3.9.\n+\n+ Args:\n+ text: A string to remove the prefix from.\n+ prefix: The string to remove from the beginning of `text`.\n+ \"\"\"\n+ if text.startswith(prefix):\n+ return text[len(prefix):]\n+ return text\ndiff --git a/tests/test_fileutils.py b/tests/test_fileutils.py\nindex 74d40c5..5e991a5 100644\n--- a/tests/test_fileutils.py\n+++ b/tests/test_fileutils.py\n@@ -6,6 +6,7 @@ import os.path\n \n from boltons import fileutils\n from boltons.fileutils import FilePerms, iter_find_files\n+from boltons.strutils import removeprefix\n \n \n # Directory of boltons source files (not ending with '/').\n@@ -32,13 +33,13 @@ def test_fileperms():\n \n def test_iter_find_files():\n def _to_baseless_list(paths):\n- return [p.removeprefix(BOLTONS_PATH) for p in paths]\n+ return [removeprefix(p, BOLTONS_PATH).lstrip(os.path.sep) for p in paths]\n \n- assert '/fileutils.py' in _to_baseless_list(iter_find_files(BOLTONS_PATH, patterns=['*.py']))\n+ assert 'fileutils.py' in _to_baseless_list(iter_find_files(BOLTONS_PATH, patterns=['*.py']))\n \n boltons_parent = os.path.dirname(BOLTONS_PATH)\n- assert '/fileutils.py' in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py']))\n- assert '/fileutils.py' not in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py'], max_depth=0))\n+ assert 'fileutils.py' in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py']))\n+ assert 'fileutils.py' not in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py'], max_depth=0))\n \n \n def test_rotate_file_no_rotation(tmp_path):" + }, + { + "sha": "6a8ac764", + "subject": "Fix bug in fileutils tests", + "body": "", + "diff": "diff --git a/tests/test_fileutils.py b/tests/test_fileutils.py\nindex 1c00ab7..74d40c5 100644\n--- a/tests/test_fileutils.py\n+++ b/tests/test_fileutils.py\n@@ -8,6 +8,7 @@ from boltons import fileutils\n from boltons.fileutils import FilePerms, iter_find_files\n \n \n+# Directory of boltons source files (not ending with '/').\n BOLTONS_PATH = os.path.dirname(os.path.abspath(fileutils.__file__))\n \n \n@@ -31,13 +32,13 @@ def test_fileperms():\n \n def test_iter_find_files():\n def _to_baseless_list(paths):\n- return [p.lstrip(BOLTONS_PATH) for p in paths]\n+ return [p.removeprefix(BOLTONS_PATH) for p in paths]\n \n- assert 'fileutils.py' in _to_baseless_list(iter_find_files(BOLTONS_PATH, patterns=['*.py']))\n+ assert '/fileutils.py' in _to_baseless_list(iter_find_files(BOLTONS_PATH, patterns=['*.py']))\n \n boltons_parent = os.path.dirname(BOLTONS_PATH)\n- assert 'fileutils.py' in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py']))\n- assert 'fileutils.py' not in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py'], max_depth=0))\n+ assert '/fileutils.py' in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py']))\n+ assert '/fileutils.py' not in _to_baseless_list(iter_find_files(boltons_parent, patterns=['*.py'], max_depth=0))\n \n \n def test_rotate_file_no_rotation(tmp_path):" + } + ], + "category_axes": { + "Source": [ + "history" + ], + "Tier": [ + "planted" + ], + "Category": [ + "numeric-policy" + ] + } + } + ], + "view": "agent", + "avg_retrieve_time_ms": 38300.0, + "avg_context_tokens": 6.0 +} \ No newline at end of file diff --git a/sdebench/Dockerfile b/sdebench/Dockerfile new file mode 100644 index 0000000..b4cfb40 --- /dev/null +++ b/sdebench/Dockerfile @@ -0,0 +1,7 @@ +# Deterministic grading environment for sdebench tasks. +# The ratelimiter lib is dependency-free; pytest + git is all we need. +FROM python:3.11-slim +RUN pip install --no-cache-dir pytest==8.* && apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /work diff --git a/sdebench/Dockerfile.agent b/sdebench/Dockerfile.agent new file mode 100644 index 0000000..d18bad3 --- /dev/null +++ b/sdebench/Dockerfile.agent @@ -0,0 +1,16 @@ +# Isolated coding-agent environment: runs opencode (+ the Hindsight coding plugin) in a container, +# so each run gets a fresh, seedable opencode session store (no host-store bleed). Grading stays in +# the separate sdebench-base image. +FROM node:22-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +# opencode CLI (the npm package fetches the right platform binary) +RUN npm i -g opencode-ai@1.16.2 +# permissive config + the Hindsight coding plugin (mounted at /opt/hindsight-coding-agents at +# runtime; inert unless the config/env disables it). The mounted package dir includes its +# node_modules, which the built plugin needs at runtime (@opencode-ai/plugin `tool`). +RUN mkdir -p /root/.config/opencode \ + && printf '{"plugin":["/opt/hindsight-coding-agents"],"permission":{"bash":"allow","edit":"allow","webfetch":"allow"}}' \ + > /root/.config/opencode/opencode.json +WORKDIR /work diff --git a/sdebench/Dockerfile.agent-claude b/sdebench/Dockerfile.agent-claude new file mode 100644 index 0000000..0fc392f --- /dev/null +++ b/sdebench/Dockerfile.agent-claude @@ -0,0 +1,16 @@ +# Isolated claude-code agent (parallels sdebench-agent for opencode). Auth (ANTHROPIC_API_KEY or a +# mounted ~/.claude/.credentials.json) is provided at runtime — none baked in. +FROM node:22-slim +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates python3 \ + && rm -rf /var/lib/apt/lists/* +RUN npm i -g @anthropic-ai/claude-code +# settings: ALLOW Bash/Edit/etc so claude can run the tests it's fixing (pytest). Without a permissions +# allow-list, headless claude blocks Bash ("running commands needs approval") and can't verify its work +# — `--dangerously-skip-permissions` is refused as root, so the allow-list is the way. Memory arrives +# via the plugin's UserPromptSubmit hook (wired into settings.json by the harness at container start, +# exactly as the installer does on a real machine); the calibrated wrapper makes +# hook-injected memory usable. Auth (OAuth creds) mounted at runtime. +RUN mkdir -p /root/.claude \ + && printf '{"permissions":{"allow":["Bash","Edit","Write","Read","Glob","Grep","MultiEdit","LS"],"defaultMode":"acceptEdits"}}' \ + > /root/.claude/settings.json +WORKDIR /work diff --git a/sdebench/Dockerfile.agent-codex b/sdebench/Dockerfile.agent-codex new file mode 100644 index 0000000..a77ec71 --- /dev/null +++ b/sdebench/Dockerfile.agent-codex @@ -0,0 +1,9 @@ +# Isolated Codex CLI environment: mirrors Dockerfile.agent-claude — the agent runs containerized +# with a fresh session store; auth is mounted at runtime. Memory arrives via the Codex hooks +# mechanism (hindsight-codex-hook from the mounted hindsight-coding-agents package). +FROM node:22-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates python3 \ + && rm -rf /var/lib/apt/lists/* +RUN npm i -g @openai/codex +WORKDIR /work diff --git a/sdebench/datasets b/sdebench/datasets new file mode 160000 index 0000000..c4b601e --- /dev/null +++ b/sdebench/datasets @@ -0,0 +1 @@ +Subproject commit c4b601e8a73ccd778c059e46e8026ddc8ee698f9 diff --git a/sdebench/harness/mem_index.py b/sdebench/harness/mem_index.py new file mode 100644 index 0000000..a4f978d --- /dev/null +++ b/sdebench/harness/mem_index.py @@ -0,0 +1,41 @@ +"""Build a local 'intent index' over a codebase's git history for the recall_intent tool. + +This is the LOCAL memory system (an alternative to Hindsight, which summarizes commits into +facts and loses the precise diff/sha the agent needs to fix a regression). It keeps the RAW +commits — subject, body, changed files, and the diff — so the recall_intent tool can return +the exact change + rationale, ranked by the agent's query. General-purpose: nothing here is +task-specific; it just indexes whatever history the codebase has. + +Output: /tmp/sdebench/memindex/.json = [{sha, subject, body, files, diff}], newest first. + +Usage: python mem_index.py +""" +import json, subprocess, sys, tempfile, shutil +from pathlib import Path + + +def build_index(build_py: str, out_path: str): + src = Path(tempfile.mkdtemp(prefix="memindex_")) + try: + subprocess.run(["python", build_py, str(src)], check=True, capture_output=True) + shas = subprocess.run(["git", "-C", str(src), "rev-list", "HEAD"], + capture_output=True, text=True, check=True).stdout.split() + out = [] + for sha in shas: + def g(*a): + return subprocess.run(["git", "-C", str(src), *a], capture_output=True, text=True).stdout + subject = g("show", "-s", "--format=%s", sha).strip() + body = g("show", "-s", "--format=%b", sha).strip() + files = g("show", "--name-only", "--format=", sha).split() + diff = g("show", "--format=", sha) + out.append({"sha": sha[:8], "subject": subject, "body": body, + "files": files, "diff": diff[:6000]}) + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + Path(out_path).write_text(json.dumps(out)) + print(f"[mem_index] {len(out)} commits -> {out_path}") + finally: + shutil.rmtree(src, ignore_errors=True) + + +if __name__ == "__main__": + build_index(sys.argv[1], sys.argv[2]) diff --git a/sdebench/harness/run.py b/sdebench/harness/run.py new file mode 100644 index 0000000..5fadec6 --- /dev/null +++ b/sdebench/harness/run.py @@ -0,0 +1,899 @@ +"""sdebench harness — run a coding agent on a regression task and grade it. + +Flow: build the repo -> ship the agent the bug report + failing regression test -> run the agent +(--agent opencode|claude-code|codex) -> on a failing grade, feed the pytest tail back and resume +(the corrections loop, cap --max-interventions) -> capture the SOURCE diff (tests excluded) -> +grade in Docker against FAIL_TO_PASS + PASS_TO_PASS + HIDDEN_TO_PASS from pristine copies. + +Usage: + uv run python sdebench/harness/run.py --task --agent opencode --history full # vanilla + uv run python sdebench/harness/run.py --task --agent codex --history hscoding # memory + +Metrics reported: solved (binary), interventions, turns, wall, tokens, cost (from PRICES; +claude-code self-reports). +""" +import argparse, json, os, re, shutil, subprocess, time +from pathlib import Path + +HARNESS = Path(__file__).resolve().parent +SDEBENCH = HARNESS.parent +REPO_ROOT = SDEBENCH.parent +IMAGE = "sdebench-base" + + +def _codebase_dir(task): + """Dir holding build.py for this task's shared codebase.""" + return SDEBENCH / "datasets" / (task.get("codebase") or task["repo"]) + + +def _task_dir(task): + """Dir holding this task's own regression_test.py / hidden_test.py (task.json's dir).""" + return Path(task["_dir"]) + +# $ per 1M tokens, per class (update when model prices change). gemini-3.5-flash (Jun 2026): +# $1.50 input / $9.00 output, cached input 90% off ($0.15). reasoning bills as output. +PRICES = { + "google/gemini-3.5-flash": {"input": 1.50, "cache_read": 0.15, "cache_write": 1.50, "output": 9.00}, + # gpt-5.4-mini (Aug 2026): $0.75 in / $4.50 out; cached input 90% off; cache writes bill as + # input. (Codex-branded models became inaccessible to plain API keys — Aug 2026.) + "gpt-5.4-mini": {"input": 0.75, "cache_read": 0.075, "cache_write": 0.75, "output": 4.50}, +} + + +def compute_cost(model: str, tok: dict) -> float: + p = PRICES.get(model) + if not p: + return 0.0 + return round((tok["input"] * p["input"] + tok["cache_read"] * p["cache_read"] + + tok["cache_write"] * p["cache_write"] + + (tok["output"] + tok["reasoning"]) * p["output"]) / 1_000_000, 4) + +PROMPT = """\ +You are a maintainer of the `{repo}` Python project. A regression was reported: + +{bug_report} + +Fix the bug in the source code. Do NOT modify any test files — the graders supply their own. +{instruction} +Save your changes to disk before finishing. +""" + +# behavioral prompt variants (applied uniformly to ALL arms = fair). Select via SDE_VARIANT. +VARIANTS = { + "base": ("Work efficiently: find the root cause, make the smallest change that fixes it, run the " + "failing test to confirm it passes (and existing behaviour still works), then stop — " + "avoid unnecessary exploration."), + "hypothesis": ("Before making ANY edit, state in one sentence your hypothesis for the root cause " + "(which file and function, and why). Then make the single smallest change that fixes " + "it and run the failing test once to confirm; then stop."), + "minimal": ("The fix is almost always ONE small change in ONE file — do not read widely, refactor, " + "or add new code. Find the root cause, make that one change, run the failing test to " + "confirm, then stop."), +} + + +def sh(*args, cwd=None, env=None, check=True, cap=False): + return subprocess.run(args, cwd=cwd, env=env, check=check, + capture_output=cap, text=True, errors="replace") + + +def neutral_home() -> str: + """A HOME without ~/.claude so opencode can't load the user's global CLAUDE.md.""" + home = Path("/tmp/sdebench_home") + if not home.exists(): + home.mkdir(parents=True, exist_ok=True) + for item in Path.home().iterdir(): + if item.name.startswith(".") and item.name != ".claude": + try: + (home / item.name).symlink_to(item) + except FileExistsError: + pass + return str(home) + + +def build_repo(task: dict, dest: Path, history: str): + if dest.exists(): + shutil.rmtree(dest) + ds = _codebase_dir(task) + sh("python", str(ds / task["build"]), str(dest)) + if history == "squashed": + shutil.rmtree(dest / ".git") + sh("git", "init", "-q", cwd=dest) + sh("git", "add", "-A", cwd=dest) + env = {**os.environ, "GIT_AUTHOR_NAME": "x", "GIT_AUTHOR_EMAIL": "x@x", + "GIT_COMMITTER_NAME": "x", "GIT_COMMITTER_EMAIL": "x@x"} + sh("git", "commit", "-q", "-m", "Initial commit", cwd=dest, env=env) + # ship the failing regression repro (the agent sees it; it is red) + ds_test = _task_dir(task) / task["regression_test_file"] + shutil.copy(ds_test, dest / "tests" / "test_regression.py") + sh("git", "add", "-A", cwd=dest) + env = {**os.environ, "GIT_AUTHOR_NAME": "x", "GIT_AUTHOR_EMAIL": "x@x", + "GIT_COMMITTER_NAME": "x", "GIT_COMMITTER_EMAIL": "x@x"} + sh("git", "commit", "-q", "-m", "test: add failing repro for the reported regression", + cwd=dest, env=env) + + +HINDSIGHT_URL = os.environ.get("SDE_HINDSIGHT_URL", "http://localhost:8888") + + +def load_env(memory_bank: str | None = None, mem_index: str | None = None, conv_log: str | None = None) -> dict: + env = os.environ.copy() + ef = REPO_ROOT / ".env" + if ef.exists(): + for line in ef.read_text().splitlines(): + line = line.strip() + if "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + env.setdefault(k.strip(), v.strip().strip('"').strip("'")) + if conv_log: # enable the recall_conversations skill (memory of past user sessions) + env.pop("HINDSIGHT_DISABLED", None) + env["CONV_LOG"] = conv_log + elif mem_index: # enable the LOCAL recall_intent tool over the raw-commit index + env.pop("HINDSIGHT_DISABLED", None) + env["MEM_INDEX"] = mem_index + elif memory_bank: # enable the Hindsight opencode plugin pointed at this bank (recall mode) + env.pop("HINDSIGHT_DISABLED", None) + env["HINDSIGHT_API_URL"] = HINDSIGHT_URL + env["HINDSIGHT_BANK_ID"] = memory_bank + env["HINDSIGHT_MEMORY_MODE"] = "recall" + else: + env["HINDSIGHT_DISABLED"] = "1" # plain agent: no memory/plugins, just git via bash + env["PWD"] = "" # set per-run + env["HOME"] = neutral_home() + return env + + +def cli_env() -> dict: + e = os.environ.copy() + e["HINDSIGHT_API_URL"] = HINDSIGHT_URL + return e + + +def ingest_history(task: dict, bank: str): + """Build the full repo and push each commit (message + diff) into a Hindsight bank, + so a squashed-repo agent can RECALL the history it can't `git blame` for.""" + src = Path("/tmp/sdebench/ingest") / task["repo"] + if src.exists(): + shutil.rmtree(src) + sh("python", str(_codebase_dir(task) / task["build"]), str(src)) + subprocess.run(["hindsight", "bank", "delete", bank, "--yes"], env=cli_env(), + capture_output=True) + shas = sh("git", "-C", str(src), "rev-list", "--reverse", "HEAD", cap=True).stdout.split() + for sha in shas: + msg = sh("git", "-C", str(src), "show", "-s", "--format=%s%n%n%b", sha, cap=True).stdout + diff = "\n".join(sh("git", "-C", str(src), "show", "--format=", sha, cap=True).stdout.splitlines()[:120]) + subprocess.run(["hindsight", "memory", "retain", bank, + f"Git commit in the {task['repo']} repo: {msg}\n\nDiff:\n{diff}"], + env=cli_env(), capture_output=True) + print(f"[ingest] {len(shas)} commits -> bank {bank}; waiting for extraction…", flush=True) + time.sleep(18) + + +def build_mem_index(task: dict) -> str: + """Build the local raw-commit index for the recall_intent tool (from the full history).""" + out = Path("/tmp/sdebench/memindex") / f"{task.get('codebase') or task['repo']}.json" + sh("python", str(HARNESS / "mem_index.py"), str(_codebase_dir(task) / task["build"]), str(out)) + return str(out) + + +_STOP = {"the","and","for","that","this","with","what","why","how","value","should","change", + "changed","does","when","over","its","into","use","used","using","make","made","not","but"} + + +def rank_commits(index_path: str, query: str, k: int = 2) -> list: + """TF-rank the codebase's raw commits by a query (same scoring as the recall_intent tool).""" + commits = json.loads(Path(index_path).read_text()) + terms = [t for t in re.findall(r"[a-z0-9_]{3,}", query.lower()) if t not in _STOP] + scored = [] + for c in commits: + subj, files = c["subject"].lower(), " ".join(c["files"]).lower() + body, diff = (c.get("body") or "").lower(), c["diff"].lower() + sc = 0 + for t in terms: + if t in subj: sc += 5 + if t in files: sc += 4 + if t in body: sc += 2 + sc += min(diff.count(t), 6) + if sc > 0: + scored.append((sc, c)) + scored.sort(key=lambda x: x[0], reverse=True) + return [c for _, c in scored[:k]] + + +def _changed_lines(diff: str, cap: int = 24) -> str: + out = [l for l in diff.split("\n") + if (l.startswith("+") or l.startswith("-")) and not l.startswith(("+++", "---"))] + return "\n".join(out[:cap]) + + +def inject_context(bug_report: str, commits: list) -> str: + """PUSH memory: append the relevant commits' changed lines to the bug report.""" + if not commits: + return bug_report + blocks = [f"commit {c['sha']} — {c['subject']}\nfiles: {', '.join(c['files'])}\n{_changed_lines(c['diff'])}" + for c in commits] + return (bug_report + "\n\nFor context, here are some recent changes to this repository that " + "may be relevant:\n\n" + "\n\n----\n\n".join(blocks)) + + +def capture_git_history(task: dict) -> list: + """The task repo's engineered git history (commits + diffs) — the 'source documents' + the full/hindsight arms have access to and the squashed arm does not. Newest first.""" + src = Path("/tmp/sdebench/hist") / task["repo"] + if src.exists(): + shutil.rmtree(src) + sh("python", str(_codebase_dir(task) / task["build"]), str(src)) + out = [] + # cap for large real-codebase hosts (the UI view of thousands of commits is useless and slow) + for sha in sh("git", "-C", str(src), "rev-list", "HEAD", "-n", "40", cap=True).stdout.split(): + subject = sh("git", "-C", str(src), "show", "-s", "--format=%s", sha, cap=True).stdout.strip() + body = sh("git", "-C", str(src), "show", "-s", "--format=%b", sha, cap=True).stdout.strip() + diff = "\n".join(sh("git", "-C", str(src), "show", "--format=", sha, cap=True).stdout.splitlines()[:150]) + out.append({"sha": sha[:8], "subject": subject, "body": body, "diff": diff}) + return out + + +def gen_index_doc(task: dict) -> str: + """Generate a compact DECISIONS.md index from the codebase's git history — mechanical, not + hand-authored. Collates each non-noise commit's subject + rationale (body) + files + sha, so + the agent reads a curated 1-page index instead of reconstructing via `git log -p`. This is the + derivable memory: good commit messages -> a usable index for H/X/K alike.""" + src = Path("/tmp/sdebench/idxsrc") / task["repo"] + if src.exists(): + shutil.rmtree(src) + sh("python", str(_codebase_dir(task) / task["build"]), str(src)) + import re as _re + skip = _re.compile(r"^(chore|release|bump|ci|style)\b", _re.I) + lines = ["# Project decisions & changes", + "_An index of notable changes, derived from git history. Each entry references the commit; " + "consult it for the rationale behind the current code._\n"] + for sha in sh("git", "-C", str(src), "rev-list", "--reverse", "HEAD", cap=True).stdout.split(): + subj = sh("git", "-C", str(src), "show", "-s", "--format=%s", sha, cap=True).stdout.strip() + body = sh("git", "-C", str(src), "show", "-s", "--format=%b", sha, cap=True).stdout.strip() + files = sh("git", "-C", str(src), "show", "--name-only", "--format=", sha, cap=True).stdout.split() + if skip.match(subj) and not body: + continue + code = [x for x in files if x.endswith(".py") and not x.startswith("tests/")] + entry = f"- **{subj}**" + if code: + entry += f" — `{', '.join(code)}`" + if body: + entry += f"\n {body}" + entry += f" (commit {sha[:8]})" + lines.append(entry) + return "\n".join(lines) + + +# The coding agent is pluggable (--agent). opencode: gemini + the Hindsight opencode plugin (reflect +# auto-injected via ~/.hindsight/coding-agent.json). claude-code: sonnet-5 + a UserPromptSubmit hook +# that reflects+injects (no MCP), OAuth creds mounted at runtime. +_AGENT_IMAGES = {"opencode": os.environ.get("SDE_AGENT_IMAGE", "sdebench-agent"), + "claude-code": os.environ.get("SDE_AGENT_IMAGE_CLAUDE", "sdebench-agent-claude"), + "codex": os.environ.get("SDE_AGENT_IMAGE_CODEX", "sdebench-agent-codex")} +_AGENT_MODEL = {"opencode": "google/gemini-3.5-flash", "claude-code": "claude-sonnet-5", + "codex": "gpt-5.4-mini"} +# The hindsight-coding-agents plugin package (dist/ built) — memory arms only. No default that +# assumes a particular machine layout: point SDE_HSCODING_PLUGIN_DIR at a checkout of +# hindsight-integrations/hindsight-coding-agents (or the npm-installed package dir). +_PLUGIN_DIR = os.path.expanduser(os.environ.get("SDE_HSCODING_PLUGIN_DIR", "")) +_CLAUDE_CREDS = os.path.expanduser(os.environ.get("SDE_CLAUDE_CREDS", str(Path.home() / ".sdebench" / "claude_creds.json"))) + + +def _container_url(url: str) -> str: + """Rewrite a host-local URL so the agent CONTAINER can reach it (host's localhost).""" + return url.replace("localhost", "host.docker.internal").replace("127.0.0.1", "host.docker.internal") + + +def _mem_docker_env(env: dict) -> list[str]: + """Docker -e flags carrying model auth + memory settings (both agents read HINDSIGHT_*; the + opencode plugin also gets a config file, the claude hook reads these env vars directly).""" + denv: list[str] = [] + key = env.get("GEMINI_API_KEY") or os.environ.get("GEMINI_API_KEY", "") + if key: + denv += ["-e", f"GEMINI_API_KEY={key}", "-e", f"GOOGLE_GENERATIVE_AI_API_KEY={key}"] + okey = env.get("OPENAI_API_KEY") or os.environ.get("OPENAI_API_KEY", "") + if okey: + denv += ["-e", f"OPENAI_API_KEY={okey}"] + for k in ("HINDSIGHT_DISABLED", "HINDSIGHT_BANK_ID", "HINDSIGHT_MEMORY_MODE"): + if env.get(k) is not None: + denv += ["-e", f"{k}={env[k]}"] + if env.get("HINDSIGHT_API_URL"): + denv += ["-e", f"HINDSIGHT_API_URL={_container_url(env['HINDSIGHT_API_URL'])}"] + return denv + + +def start_agent_container(workdir: Path, env: dict, agent: str = "opencode") -> str: + """Start ONE long-lived agent container per task (session store INSIDE it so `-c`/`--continue` + resumes across the intervention loop cheaply). Grading stays in sdebench-base. Returns the id.""" + mounts = ["-v", f"{workdir}:/work"] + if _PLUGIN_DIR: # the plugin (opencode plugin / codex + claude hooks) — memory arms + mounts += ["-v", f"{_PLUGIN_DIR}:/opt/hindsight-coding-agents:ro"] + if agent == "claude-code": + mounts += ["-v", f"{_CLAUDE_CREDS}:/root/.claude/.credentials.json"] # rw: claude may refresh it + cmd = ["docker", "run", "-d", "--rm", *mounts, "-w", "/work", + "--add-host", "host.docker.internal:host-gateway", + *_mem_docker_env(env), _AGENT_IMAGES[agent], "sleep", "infinity"] + cid = "" + for attempt in range(4): # `docker run` can transiently exit 125 under load — retry + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode == 0: + cid = p.stdout.strip() + break + print(f" [docker] start attempt {attempt+1}/4 failed (exit {p.returncode}): {p.stderr.strip()[:200]}", flush=True) + time.sleep(3 * (attempt + 1)) + if not cid: + raise RuntimeError(f"docker run failed after retries: {p.stderr.strip()[:300]}") + if agent == "opencode": + # opencode's plugin reads ~/.hindsight/coding-agent.json (not env): disabled=vanilla; bank+url=memory. + cfg: dict = {"disabled": env.get("HINDSIGHT_DISABLED") == "1", "gitSync": {"enabled": False}, + # trial isolation for the v2 plugin: no session write-back into the bank between + # rounds/runs, no auto-seed or survey racing the controlled backfill + "retainSessions": False, "autoSeed": False, "codebaseSurvey": False} + if env.get("HINDSIGHT_BANK_ID"): + cfg["bankId"] = env["HINDSIGHT_BANK_ID"] + if env.get("HINDSIGHT_API_URL"): + cfg["apiUrl"] = _container_url(env["HINDSIGHT_API_URL"]) + subprocess.run(["docker", "exec", "-i", cid, "sh", "-c", + "mkdir -p /root/.hindsight && cat > /root/.hindsight/coding-agent.json"], + input=json.dumps(cfg), capture_output=True, text=True) + elif agent == "codex": + # API-key auth: login in-container (the host auth.json holds ChatGPT tokens that go stale). + subprocess.run(["docker", "exec", cid, "sh", "-c", + "printenv OPENAI_API_KEY | codex login --with-api-key"], + capture_output=True, text=True) + # Memory via the ACTUAL product integration: the hindsight-codex-hook wired through Codex's + # own hooks mechanism. Vanilla arm: no hooks file -> no memory (parity by absence). + if env.get("HINDSIGHT_BANK_ID"): + hooks = {"hooks": {"UserPromptSubmit": [{"hooks": [ + {"type": "command", "command": "node /opt/hindsight-coding-agents/dist/codex-hook.js", + "timeout": 150}]}]}} + hs_cfg = {"apiUrl": _container_url(env.get("HINDSIGHT_API_URL", HINDSIGHT_URL)), + "bankId": env["HINDSIGHT_BANK_ID"]} + subprocess.run(["docker", "exec", "-i", cid, "sh", "-c", + "mkdir -p /root/.codex /root/.hindsight && cat > /root/.codex/hooks.json"], + input=json.dumps(hooks), capture_output=True, text=True) + subprocess.run(["docker", "exec", "-i", cid, "sh", "-c", + "cat > /root/.hindsight/coding-agent.json"], + input=json.dumps(hs_cfg), capture_output=True, text=True) + subprocess.run(["docker", "exec", cid, "sh", "-c", + "printf 'codex_hooks = true\n' >> /root/.codex/config.toml || " + "printf 'codex_hooks = true\n' > /root/.codex/config.toml"], + capture_output=True, text=True) + if agent == "claude-code" and env.get("HINDSIGHT_BANK_ID"): + # Memory via the ACTUAL product integration: the plugin's UserPromptSubmit hook, exactly as + # the installer wires it (nested settings style, 30s timeout > the hook's 25s reflect cap). + # The old --append-system-prompt workaround predated the calibrated + # wrapper + historian reflect; with those, claude uses hook-injected memory like any other + # harness. Vanilla arm: no hooks (parity by absence). Settings are MERGED so the image's + # permissions allow-list survives. + hs_cfg = {"apiUrl": _container_url(env.get("HINDSIGHT_API_URL", HINDSIGHT_URL)), + "bankId": env["HINDSIGHT_BANK_ID"], + "retainSessions": False, "autoSeed": False, "codebaseSurvey": False} + subprocess.run(["docker", "exec", "-i", cid, "sh", "-c", + "mkdir -p /root/.hindsight && cat > /root/.hindsight/coding-agent.json"], + input=json.dumps(hs_cfg), capture_output=True, text=True) + merge = ( + "import json\n" + "p = '/root/.claude/settings.json'\n" + "try: s = json.load(open(p))\n" + "except Exception: s = {}\n" + "s.setdefault('hooks', {})['UserPromptSubmit'] = [{'hooks': [{'type': 'command',\n" + " 'command': 'node \"/opt/hindsight-coding-agents/dist/claude-hook.js\"', 'timeout': 30}]}]\n" + "json.dump(s, open(p, 'w'))\n") + subprocess.run(["docker", "exec", "-i", cid, "python3", "-"], + input=merge, capture_output=True, text=True) + return cid + + +def stop_agent_container(cid: str) -> None: + if cid: + subprocess.run(["docker", "rm", "-f", cid], capture_output=True, text=True) + + +def _parse_claude(stdout: str, elapsed: float) -> dict: + """Parse claude-code's `--output-format stream-json --verbose` JSONL: one event per line — + assistant messages carry content blocks (text / tool_use), the final `result` event carries + the run totals (usage, num_turns, total_cost_usd).""" + tok = {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0} + traj = [] + final = None + for line in stdout.splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + e = json.loads(line) + except Exception: + continue + if e.get("type") == "assistant": + for blk in ((e.get("message") or {}).get("content") or []): + if blk.get("type") == "text" and blk.get("text"): + traj.append({"k": "say", "text": str(blk["text"])[:1500]}) + elif blk.get("type") == "tool_use": + inp = blk.get("input") or {} + arg = str(inp.get("command") or inp.get("file_path") or inp.get("pattern") + or inp.get("path") or json.dumps(inp))[:160] + traj.append({"k": "tool", "tool": str(blk.get("name", "")).lower(), + "arg": arg, "input": "", "out": ""}) + elif e.get("type") == "result": + final = e + if final is None: + return {"elapsed": elapsed, "tokens": tok, "turns": 0, "trajectory": traj, "cost": 0.0} + if final.get("is_error"): + # Fail LOUDLY: a broken agent (expired OAuth, api_error) otherwise reads as a normal + # 0-token turn and silently burns the whole intervention budget in seconds. + raise RuntimeError(f"claude agent error: {str(final.get('result'))[:200]}") + u = final.get("usage", {}) or {} + tok["input"] = u.get("input_tokens", 0) or 0 + tok["output"] = u.get("output_tokens", 0) or 0 + tok["cache_read"] = u.get("cache_read_input_tokens", 0) or 0 + tok["cache_write"] = u.get("cache_creation_input_tokens", 0) or 0 + return {"elapsed": elapsed, "tokens": tok, "turns": final.get("num_turns", 0) or 0, + "trajectory": traj, "cost": final.get("total_cost_usd", 0.0) or 0.0} + + +def _parse_codex(stdout: str, elapsed: float) -> dict: + """Parse `codex exec --json` JSONL (observed schema, codex-cli 0.145): + {"type":"item.completed","item":{"type":"command_execution"|"agent_message"|"reasoning"|...}} + {"type":"turn.completed","usage":{input_tokens,cached_input_tokens,cache_write_input_tokens, + output_tokens,reasoning_output_tokens}}""" + tok = {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0} + turns = 0 + traj = [] + for line in stdout.splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + e = json.loads(line) + except Exception: + continue + et = e.get("type") + if et == "item.completed": + item = e.get("item") or {} + it = item.get("type") + if it in ("agent_message",): + txt = str(item.get("text") or "")[:1500] + if txt: + traj.append({"k": "say", "text": txt}) + elif it not in ("reasoning", None): # command_execution, patch_apply, tool calls, ... + turns += 1 + arg = str(item.get("command") or item.get("path") or item.get("text") or "")[:160] + traj.append({"k": "tool", "tool": str(it), "arg": arg, "input": "", "out": ""}) + elif et in ("turn.failed", "error"): + # Fail LOUDLY (same class as the claude is_error guard): a dead codex agent (bad auth, + # missing model) otherwise reads as a normal 0-token turn and silently burns the whole + # correction budget — an entire garbage campaign arm looked "complete" this way. + msg = str((e.get("error") or {}).get("message") or e.get("message") or "")[:200] + raise RuntimeError(f"codex agent error: {msg}") + elif et == "turn.completed": + u = e.get("usage") or {} + tok["input"] += u.get("input_tokens", 0) or 0 + tok["cache_read"] += u.get("cached_input_tokens", 0) or 0 + tok["cache_write"] += u.get("cache_write_input_tokens", 0) or 0 + tok["output"] += u.get("output_tokens", 0) or 0 + tok["reasoning"] += u.get("reasoning_output_tokens", 0) or 0 + return {"elapsed": elapsed, "tokens": tok, "turns": turns, "trajectory": traj} + + + + +def run_agent(cid: str, model: str, timeout: int, message: str, resume: bool = False, + agent: str = "opencode", system_append: str | None = None) -> dict: + # One turn: exec the agent into the already-running per-task container (session store lives inside, + # so `-c`/`--continue` resumes the same session across the intervention loop). + if agent == "claude-code": + # stream-json (+ --verbose) emits every assistant message incl. tool_use blocks — the plain + # json format returns only the final message, which left the UI trajectory empty. + cmd = ["docker", "exec", "-w", "/work", cid, "claude", "-p", + "--output-format", "stream-json", "--verbose", + "--permission-mode", "acceptEdits", "--model", model] + if system_append: # optional trusted-channel injection (unused by the memory arms; hook delivers) + cmd += ["--append-system-prompt", system_append] + if resume: + cmd.append("--continue") + cmd.append(message) + t0 = time.perf_counter() + proc = subprocess.run(cmd, timeout=timeout, capture_output=True, text=True) + return _parse_claude(proc.stdout, time.perf_counter() - t0) + if agent == "codex": + base = ["docker", "exec", "-w", "/work", cid, "codex", "exec", "--json", "-m", model, + "--dangerously-bypass-approvals-and-sandbox", # the container IS the sandbox + "--dangerously-bypass-hook-trust"] # hooks are harness-installed + cmd = base + (["resume", "--last", message] if resume else [message]) + t0 = time.perf_counter() + proc = subprocess.run(cmd, timeout=timeout, capture_output=True, text=True) + return _parse_codex(proc.stdout, time.perf_counter() - t0) + cmd = ["docker", "exec", "-w", "/work", cid, + "opencode", "run", "--format", "json", "-m", model] + if resume: + cmd.append("-c") # continue the last session in this dir (keeps context) + cmd.append(message) + t0 = time.perf_counter() + proc = subprocess.run(cmd, timeout=timeout, capture_output=True, text=True) + elapsed = time.perf_counter() - t0 + # Token split kept separate (cached vs input vs output) — $ is computed later per model. + tok = {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0} + turns = 0 + traj = [] # structured trajectory for the UI: tool steps + assistant text + seg_start = 0 # index where the current model-step's steps begin (for token stamping) + for line in proc.stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except Exception: + continue + t = e.get("type") + part = e.get("part", {}) or {} + if t == "tool_use": + turns += 1 + state = part.get("state", {}) or {} + inp = state.get("input") or part.get("input") or {} + arg = "" + if isinstance(inp, dict): + for k in ("filePath", "path", "pattern", "command", "query", "url", "content"): + if inp.get(k): + arg = f"{k}={str(inp[k])[:160]}" + break + if not arg and inp: + k, v = next(iter(inp.items())); arg = f"{k}={str(v)[:160]}" + full_in = "\n".join(f"{k}: {v}" for k, v in inp.items())[:4000] if isinstance(inp, dict) and inp else str(inp)[:4000] + out = state.get("output") + traj.append({"k": "tool", "tool": part.get("tool") or "tool", "arg": arg, + "input": full_in, "out": str(out)[:4000] if out else ""}) + elif t == "text": + txt = (part.get("text") or "").strip() + if txt: + traj.append({"k": "say", "text": txt[:1500]}) + elif t == "step_finish": + tk = part.get("tokens", {}) or {} + s_in = (tk.get("input", 0) or 0) + s_out = (tk.get("output", 0) or 0) + (tk.get("reasoning", 0) or 0) + s_cache = 0 + cache = tk.get("cache", {}) + if isinstance(cache, dict): + s_cache = cache.get("read", 0) or 0 + tok["cache_read"] += s_cache + tok["cache_write"] += cache.get("write", 0) or 0 + # provider semantics (verified): total = input + cache_read + output + reasoning, + # i.e. `input` is the NON-cached prompt and `cache_read` is the cached prompt (separate). + tok["input"] += tk.get("input", 0) or 0 + tok["output"] += tk.get("output", 0) or 0 + tok["reasoning"] += tk.get("reasoning", 0) or 0 + # stamp this model-step's tokens onto the trajectory steps it produced. + # reasoning is token-only (Gemini hides the thinking TEXT) — track the count so + # the UI can show how much hidden reasoning each turn did. + s_reason = tk.get("reasoning", 0) or 0 + for s in traj[seg_start:]: + s["tok_in"] = s_in + s_cache # full prompt this turn (fresh + cached) + s["tok_cache"] = s_cache # cached portion (billed at the discount rate) + s["tok_out"] = s_out + s["tok_reason"] = s_reason + seg_start = len(traj) + return {"elapsed": elapsed, "tokens": tok, "turns": turns, "trajectory": traj} + + +def _oid(prefix: str) -> str: + import secrets + return prefix + secrets.token_hex(13) + + +def seed_sessions(cid: str, conversations: list, model: str) -> int: + """Seed past developer conversations into the agent container's opencode store as REAL sessions, so + a fresh agent run CAN consult them (via `opencode session list` / `opencode export `) if it + chooses — availability + agency, not injection. One opencode session per conversation. Returns count. + Only for the vanilla baseline: the raw substrate the agent may read, vs the memory system that + surfaces it reliably.""" + if not conversations: + return 0 + chats = conversations if isinstance(conversations[0], list) else [conversations] # 1 chat or many + mid_model, prov = model.split("/")[-1], (model.split("/")[0] if "/" in model else "google") + n = 0 + for ci, conv in enumerate(chats): + if not conv: + continue + sid = _oid("ses_"); base = int(time.time() * 1000) - ci * 3_600_000 + info = {"id": sid, "slug": "past-session", "projectID": "x", "directory": "/work", "path": "", + "title": (conv[0].get("text") or "past session")[:60], "agent": "build", + "model": {"id": mid_model, "providerID": prov, "variant": "default"}, "version": "1.16.2", + "summary": {"additions": 0, "deletions": 0, "files": 0}, "cost": 0, + "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, + "permission": [], "time": {"created": base, "updated": base}} + msgs = []; prev = None + for i, turn in enumerate(conv): + role = turn.get("role", "user"); text = turn.get("text", ""); mid = _oid("msg_"); ts = base + i * 1000 + if role != "assistant": + minfo = {"role": "user", "time": {"created": ts}, "agent": "build", + "model": {"providerID": prov, "modelID": mid_model}, "summary": {"diffs": []}, + "id": mid, "sessionID": sid} + parts = [{"type": "text", "text": text, "id": _oid("prt_"), "sessionID": sid, "messageID": mid}] + else: + minfo = {"parentID": prev, "role": "assistant", "mode": "build", "agent": "build", + "path": {"cwd": "/work", "root": "/work"}, "cost": 0, + "tokens": {"total": 0, "input": 0, "output": 0, "reasoning": 0, "cache": {"write": 0, "read": 0}}, + "modelID": mid_model, "providerID": prov, "time": {"created": ts, "completed": ts}, + "finish": "stop", "id": mid, "sessionID": sid} + parts = [{"type": "text", "text": text, "time": {"start": ts, "end": ts}, "id": _oid("prt_"), + "sessionID": sid, "messageID": mid}] + msgs.append({"info": minfo, "parts": parts}); prev = mid + seed = json.dumps({"info": info, "messages": msgs}) + subprocess.run(["docker", "exec", "-i", "-w", "/work", cid, "sh", "-c", + "cat > /tmp/seed.json && opencode import /tmp/seed.json"], + input=seed, capture_output=True, text=True) + n += 1 + return n + + +def seed_transcript_files(cid: str, conversations: list) -> int: + """Vanilla-baseline chat availability for agents WITHOUT an importable session store + (codex, claude-code): write each past developer conversation as a markdown transcript under + /root/project-history — OUTSIDE the repo, so the codebase and its diff are untouched. Same + contract as seed_sessions: the substrate is reachable if the agent chooses to look, nothing + is injected. Returns the number of transcripts written.""" + if not conversations: + return 0 + chats = conversations if isinstance(conversations[0], list) else [conversations] + n = 0 + for ci, conv in enumerate(chats): + if not conv: + continue + lines = [f"# Past developer session {ci + 1}\n"] + for turn in conv: + who = "Developer" if turn.get("role", "user") != "assistant" else "Assistant" + lines.append(f"**{who}:** {turn.get('text', '')}\n") + body = "\n".join(lines) + subprocess.run(["docker", "exec", "-i", cid, "sh", "-c", + f"mkdir -p /root/project-history && cat > /root/project-history/session-{ci + 1:02d}.md"], + input=body, capture_output=True, text=True) + n += 1 + return n + + +_JUNK = [".venv", "venv", "build", "dist", "*.egg-info", "__pycache__", ".pytest_cache", "*.pyc"] + + +def capture_source_patch(workdir: Path) -> str: + """The agent's diff to SOURCE only — tests/ and junk excluded (graded from pristine).""" + sh("git", "add", "-A", cwd=workdir) + excl = [f":(exclude){j}" for j in _JUNK] + [":(exclude)tests/**"] + r = sh("git", "diff", "--cached", "HEAD", "--", ".", *excl, cwd=workdir, cap=True) + return r.stdout + + +def grade(task: dict, source_patch: str, work: Path) -> dict: + """Apply the source patch to a pristine full build + pristine test sets, run pytest in Docker.""" + gd = work / "grade" + build_repo(task, gd, "full") # pristine repo (full) + shutil.copy(_task_dir(task) / task["hidden_test_file"], gd / "tests" / "test_hidden.py") + # regression test already copied by build_repo; apply the agent's source patch + applied = True + if source_patch.strip(): + p = subprocess.run(["git", "apply", "--whitespace=nowarn"], cwd=gd, + input=source_patch, text=True) + applied = p.returncode == 0 + # run the suite in Docker (deterministic), tests graded from pristine copies + r = subprocess.run( + ["docker", "run", "--rm", "-v", f"{gd}:/work", "-w", "/work", IMAGE, + "python", "-m", "pytest", "-q", "tests", "--no-header"], + capture_output=True, text=True) + passed = r.returncode == 0 + out = (r.stdout or "") + if not passed and not out.strip(): + # pytest ALWAYS writes to stdout; an empty failure means the grading CONTAINER failed + # (missing image, docker down, ...). That is an infra error, not a wrong fix — feeding it + # back as "tests still fail" burns the intervention cap on a problem the agent cannot fix. + raise RuntimeError(f"grading container failed (not a test failure): {(r.stderr or '').strip()[:300]}") + tail = out.strip().splitlines()[-1:] if out.strip() else [""] + return {"applied": applied, "resolved": passed and applied, + "pytest": tail[0] if tail else "", "output": out, + "patch_failed": not applied} + + +def build_feedback(grade_result: dict) -> str: + """Surface the NEW problem (failing tests) — not the solution.""" + if grade_result["patch_failed"]: + return ("Your change could not be applied cleanly to the source. Re-read the current " + "code and make a focused edit that applies, then ensure the tests pass.") + out = grade_result["output"] + # keep the failures section (assertion errors + the short summary), trimmed + body = out[-2500:] if len(out) > 2500 else out + return ("Your change did not fully fix the reported regression. Re-running the project's " + "test suite now reports the following remaining failures:\n\n" + f"```\n{body.strip()}\n```\n\n" + "Fix the source so these pass. Do NOT modify any test file.") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--task", required=True, help="path to a task.json (sdebench/datasets/boltons-*/tasks/main/task.json)") + ap.add_argument("--history", choices=["full", "squashed", "hindsight", "hscoding", "memtool", "inject", "oracle", "hybrid", "index", "provided", "conversations", "skill"], default="full") + ap.add_argument("--agent", choices=["opencode", "claude-code", "codex"], default="opencode") + ap.add_argument("--model", default=None, help="agent model; defaults per --agent") + ap.add_argument("--timeout", type=int, default=900) + ap.add_argument("--run-id", default="r1") + ap.add_argument("--max-interventions", type=int, default=5, + help="cap on feedback rounds before giving up (drift guard)") + ap.add_argument("--external-memory", default=None, + help="file with retrieved memories to inject into the prompt (provided arm; " + "how generic AMB providers deliver memory)") + args = ap.parse_args() + if not args.model: + args.model = _AGENT_MODEL[args.agent] + task = json.loads(Path(args.task).read_text()) + task["_dir"] = str(Path(args.task).resolve().parent) + task.setdefault("repo", task.get("codebase") or task["task_id"]) + + work = Path("/tmp/sdebench/run") / f"{task['task_id']}_{args.history}_{args.run_id}" + if work.exists(): + shutil.rmtree(work) + work.mkdir(parents=True) + repo = work / "repo" + memory_bank = None + mem_index = None + conv_log = None + if args.history == "hindsight": + build_repo(task, repo, "squashed") # no git trail; history is in memory + memory_bank = f"sde-{task['repo']}" + ingest_history(task, memory_bank) # reset + ingest the full git history + elif args.history == "provided": + build_repo(task, repo, "full") # full repo + external memory supplied in the prompt + _em = task.get("external_memory") + if args.external_memory and Path(args.external_memory).exists(): + _em = Path(args.external_memory).read_text() + if _em: + task["bug_report"] = task["bug_report"] + "\n\nRelevant memory (surfaced for you by your memory system):\n" + _em + elif args.history == "hscoding": + if not _PLUGIN_DIR or not (Path(_PLUGIN_DIR) / "dist").is_dir(): + sys.exit("hscoding arm needs SDE_HSCODING_PLUGIN_DIR -> a hindsight-coding-agents " + "checkout with dist/ built") + build_repo(task, repo, "full") # full repo; memory via the hindsight-coding-agents + memory_bank = os.environ.get("SDE_HSCODING_BANK", "hs-coding") # plugin (reflect + INJECT); the + # # bank is pre-populated by the plugin's deepen engine + elif args.history == "skill": + build_repo(task, repo, "full") # vanilla full git + a recall_conversations SKILL (tool) + _cv = task.get("conversations") or [] + if _cv: + conv_log = str(Path("/tmp/sdebench/conv") / f"{task['task_id']}.json") + Path(conv_log).parent.mkdir(parents=True, exist_ok=True) + Path(conv_log).write_text(json.dumps(_cv)) + elif args.history == "conversations": + build_repo(task, repo, "full") # full repo + a relevant PAST CONVERSATION surfaced + _cv = task.get("conversations") or [] + if _cv: + _log = "\n".join(f"{c['role'].upper()}: {c['text']}" for c in _cv) + task["bug_report"] = task["bug_report"] + ( + "\n\nRelevant past conversation with the user on this project, recalled by your " + "memory of previous sessions:\n\n" + _log) + elif args.history == "index": + build_repo(task, repo, "squashed") # no git; a derived DECISIONS.md index IS the memory + (repo / "DECISIONS.md").write_text(gen_index_doc(task)) + sh("git", "add", "-A", cwd=repo) + sh("git", "commit", "-q", "-m", "docs: decisions index", cwd=repo, + env={**os.environ, "GIT_AUTHOR_NAME": "x", "GIT_AUTHOR_EMAIL": "x@x", + "GIT_COMMITTER_NAME": "x", "GIT_COMMITTER_EMAIL": "x@x"}) + elif args.history == "memtool": + build_repo(task, repo, "squashed") # no git trail; history is in the recall_intent index + mem_index = build_mem_index(task) + elif args.history == "inject": + build_repo(task, repo, "squashed") # PUSH: relevant history injected into the prompt + _idx = build_mem_index(task) + _k = int(os.environ.get("SDE_INJECT_K", "2")) + _q = task["bug_report"] + if os.environ.get("SDE_INJECT_RICH"): # also rank by the failing test's symbols + _q += "\n" + (_task_dir(task) / task["regression_test_file"]).read_text() + task["bug_report"] = inject_context(task["bug_report"], rank_commits(_idx, _q, k=_k)) + elif args.history == "hybrid": + build_repo(task, repo, "squashed") # PUSH policy + PULL tool for symptom-distant causes + mem_index = build_mem_index(task) + task["bug_report"] = inject_context(task["bug_report"], rank_commits(mem_index, task["bug_report"], k=2)) + elif args.history == "oracle": + build_repo(task, repo, "squashed") # ORACLE upper bound: inject the KNOWN cause commit + _idx = build_mem_index(task) + _cs = [c for c in json.loads(Path(_idx).read_text()) if c["subject"] == task.get("cause_subject")] + task["bug_report"] = inject_context(task["bug_report"], _cs) + else: + build_repo(task, repo, args.history) + git_history = capture_git_history(task) + + TOK = ("input", "output", "reasoning", "cache_read", "cache_write") + totals = {k: 0 for k in TOK}; totals.update({"turns": 0, "wall_s": 0.0, "cost": 0.0}) + trace = [] # ordered multi-round conversation for the UI + + def acc(m, role, prompt_text): + for k in TOK: + totals[k] += m["tokens"][k] + totals["turns"] += m["turns"]; totals["wall_s"] += m["elapsed"] + totals["cost"] += m.get("cost", 0.0) # agent-reported cost (claude); opencode computes below + trace.append({"role": role, "prompt": prompt_text, "trajectory": m["trajectory"], + "tokens": m["tokens"], "turns": m["turns"], "wall_s": round(m["elapsed"], 1)}) + + print(f"[{task['task_id']}] history={args.history} model={args.model} — initial attempt…", flush=True) + init_prompt = PROMPT.format(repo=task["repo"], bug_report=task["bug_report"], instruction=VARIANTS[os.environ.get("SDE_VARIANT", "base")]) + env = load_env(memory_bank, mem_index, conv_log) # container env (memory config the plugin reads) + cid = start_agent_container(repo, env, args.agent) # ONE container for the whole task (initial + interventions) + # claude has no plugin: reflect ONCE on the symptom here and inject via --append-system-prompt on every + # turn (trusted channel). opencode uses its plugin instead, so no harness reflect for it. + # claude-code memory delivery is the PLUGIN's UserPromptSubmit hook (wired at container start), + # same as every other harness — no harness-side reflect, no --append-system-prompt. + try: + if args.history == "full" and task.get("conversations"): + # Vanilla-baseline fairness: make the past developer conversations REACHABLE by the plain + # agent (not injected, not resumed) — it reads them only if it chooses. The realistic + # "does it think to check its history?" test, vs the memory system that surfaces the + # decision reliably. opencode gets them as native sessions; codex/claude-code (no + # importable session store) get markdown transcripts outside the repo. + # AVAILABILITY, not advertisement: the chats are reachable (native sessions for + # opencode, transcripts under /root/project-history for the rest) but the prompt does + # NOT point at them. Whether the agent thinks to look for prior context is exactly the + # behavior under test — a pointer in the prompt made a strong agent read them every + # time and collapsed the vanilla/memory gap by design rather than by ability. + if args.agent == "opencode": + seed_sessions(cid, task["conversations"], args.model) + else: + seed_transcript_files(cid, task["conversations"]) + acc(run_agent(cid, args.model, args.timeout, init_prompt, agent=args.agent), "initial", init_prompt) + + # Feedback loop: grade -> if failing, tell the agent the NEW problem (not the fix) and resume. + # Metric = number of human-like interventions needed (capped); cost = sum across all rounds. + interventions = 0 + while True: + patch = capture_source_patch(repo) + g = grade(task, patch, work) + # record THIS round's submitted patch + its grade outcome (incl. the rejected ones) + trace[-1]["patch"] = patch + trace[-1]["grade_pytest"] = g["pytest"] + trace[-1]["grade_passed"] = g["resolved"] + if g["resolved"] or interventions >= args.max_interventions: + break + interventions += 1 + # number each round so a resumed agent never sees a verbatim-repeated message (claude flags + # that as an adversarial loop and refuses); the pytest output also differs as the code changes. + fb = f"[Feedback #{interventions}] " + build_feedback(g) + print(f" ↳ intervention {interventions}: {g['pytest']}", flush=True) + acc(run_agent(cid, args.model, args.timeout, fb, resume=True, agent=args.agent), f"intervention-{interventions}", fb) + # memory observability: the plugin records every reflect outcome to /tmp inside the container. + # A memory arm whose reflect silently failed is NOT a memory run — record and shout. + mem_diag = None + if memory_bank: + _p = subprocess.run(["docker", "exec", cid, "cat", "/tmp/hindsight-plugin.log"], + capture_output=True, text=True) + mem_diag = [json.loads(l) for l in (_p.stdout or "").splitlines() if l.strip()] or None + _ok = any(d.get("event") in ("reflect_ok", "recall_ok", "inject_ok") for d in (mem_diag or [])) + print(f" [memory] reflect diagnostics: {mem_diag if mem_diag else 'NO LOG — plugin never reflected'}" + + ("" if _ok else " ⚠️ MEMORY ARM RAN WITHOUT INJECTED MEMORY"), flush=True) + finally: + stop_agent_container(cid) + + solved = g["resolved"] + # claude reports its own cost per call (summed in totals); opencode/gemini we price from tokens. + cost = totals["cost"] if args.agent == "claude-code" else compute_cost(args.model, {k: totals[k] for k in TOK}) + result = { + "task_id": task["task_id"], "codebase": task.get("codebase") or task["repo"], + "variant": os.environ.get("SDE_VARIANT", "base"), + "history": args.history, "agent": args.agent, "model": args.model, + "solved": solved, "interventions": interventions, + "capped": (not solved and interventions >= args.max_interventions), + "final_pytest": g["pytest"], "patch_bytes": len(patch), + "tokens": {k: totals[k] for k in TOK}, # cached vs input vs output kept separate + "turns": totals["turns"], "wall_s": round(totals["wall_s"], 1), + "cost_usd": round(cost, 4), # 0 when the model has no PRICES entry + "memory_diag": mem_diag if memory_bank else None, + } + (work / "result.json").write_text(json.dumps(result, indent=2)) + (work / "trace.json").write_text(json.dumps( + {**result, "bug_report": task["bug_report"], "final_patch": patch, "git_history": git_history, "trace": trace}, indent=2)) + # each workdir holds a full host-repo clone (+ a second pristine copy for grading) — sweeps ran + # the disk to 100% and took the docker daemon down. Keep result/trace, drop the repo copies. + shutil.rmtree(repo, ignore_errors=True) + shutil.rmtree(work / "grade", ignore_errors=True) + print(json.dumps(result, indent=2)) + tk = result["tokens"] + print(f"\nRESULT history={args.history}: solved={solved} interventions={interventions} | " + f"tokens in={tk['input']} out={tk['output']} cache_r={tk['cache_read']} cache_w={tk['cache_write']} | " + f"wall={totals['wall_s']:.0f}s -> {work}/result.json") + + +if __name__ == "__main__": + main() diff --git a/src/memory_bench/dataset/__init__.py b/src/memory_bench/dataset/__init__.py index 599bc54..dfce64d 100644 --- a/src/memory_bench/dataset/__init__.py +++ b/src/memory_bench/dataset/__init__.py @@ -6,6 +6,7 @@ from .membench import MemBenchDataset from .memsim import MemSimDataset from .personamem import PersonaMemDataset +from .sdebench import SdebenchDataset REGISTRY: dict[str, type[Dataset]] = { "beam": BEAMDataset, @@ -15,6 +16,7 @@ "membench": MemBenchDataset, "memsim": MemSimDataset, "personamem": PersonaMemDataset, + "sdebench": SdebenchDataset, } diff --git a/src/memory_bench/dataset/base.py b/src/memory_bench/dataset/base.py index dd54b01..8504fe7 100644 --- a/src/memory_bench/dataset/base.py +++ b/src/memory_bench/dataset/base.py @@ -37,7 +37,7 @@ class Dataset(ABC): name: str description: str splits: list[str] - task_type: Literal["open", "mcq"] = "open" + task_type: Literal["open", "mcq", "coding"] = "open" isolation_unit: str | None = None links: list[dict] = [] published: bool = False diff --git a/src/memory_bench/dataset/sdebench.py b/src/memory_bench/dataset/sdebench.py new file mode 100644 index 0000000..2d709b3 --- /dev/null +++ b/src/memory_bench/dataset/sdebench.py @@ -0,0 +1,189 @@ +"""sde-bench — a coding-agent benchmark, bound into the OMB runner. + +Unlike the QA/recall datasets, each "query" is a bug-fix TASK: the agent must edit a real repo so the +hidden test passes. There are no gold answers — correctness is pytest pass/fail, produced by the +`coding` ResponseMode (which builds the repo, runs the agent with interventions, and grades). So +`task_type = "coding"` and scoring is handled by the runner's coding branch. + +Memory flows through the STANDARD pipeline: `load_documents` exposes each task's knowledge corpus +(the decision — a past developer chat or a documented git commit — plus decoy conversations and the +host repo's commit noise), isolated per task (`isolation_unit = "task"`, user_id = task_id). Any +registered MemoryProvider ingests it via the runner and serves the coding mode's generic +retrieve->inject path; the `hscoding` provider instead runs the Hindsight plugin's own deepen engine +over the BUILT repo (its ingest is repo-native, not document-list-based) and delivery happens +agent-side through the plugin. + +Tasks live in the `sde-bench` submodule at `sdebench/datasets/boltons-*`; the runner/harness is +`sdebench/harness/run.py`. +""" +import json +import os +import subprocess +from pathlib import Path + +from .base import Dataset +from ..models import Document, Query + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_DATASETS = _REPO_ROOT / "sdebench" / "datasets" +_HOST = Path(os.environ.get("SDEBENCH_BOLTONS_HOST", "") or (Path.home() / "dev" / "_sdebench_hosts" / "boltons")) +_HOST_REF = "979fa9b613fa8c0a455ae16ea6f2ec91c11ecafe" +_NOISE_COMMITS = 100 # host-history noise window (matches the plugin's default gitlog scope order) + + +def task_json_path(task_id: str) -> Path: + """Map a task_id (e.g. boltons-slalog-001) to its task.json. Convention: -001.""" + codebase = task_id.rsplit("-", 1)[0] + return _DATASETS / codebase / "tasks" / "main" / "task.json" + + +def _render_chat(turns: list[dict]) -> str: + return "\n".join(f"{'Developer' if t.get('role', 'user') != 'assistant' else 'Assistant'}: " + f"{t.get('text', '')}" for t in turns) + + +def _host_noise_commits() -> list[tuple[str, str]]: + """(sha, subject+body) for the last _NOISE_COMMITS host commits — the same retrieval noise every + task's bank faces. Empty (with the corpus correspondingly thinner) when the host clone is absent.""" + if not (_HOST / ".git").exists(): + return [] + us = "\x1f" + out = subprocess.run(["git", "-C", str(_HOST), "log", f"-n{_NOISE_COMMITS}", + f"--format=%h{us}%s%n%b{us}", _HOST_REF], + capture_output=True, text=True).stdout + commits = [] + for chunk in out.split(us + "\n"): + if us in chunk: + sha, msg = chunk.split(us, 1) + commits.append((sha.strip(), msg.strip())) + return commits + + +class SdebenchDataset(Dataset): + name = "sdebench" + description = "Does memory help a coding agent? Bug-fix tasks whose obvious fix fails a hidden test." + splits = ["boltons"] + task_type = "coding" + isolation_unit = "task" # every task is an independent project: per-task banks/stores + published = False + links = [{"label": "Dataset", "url": "https://github.com/vectorize-io/sde-bench"}] + + def _task_files(self) -> list[Path]: + files = sorted(_DATASETS.glob("boltons-*/tasks/main/task.json")) + # SDE_TASK_FILTER: comma-separated substrings matched against the task dir name + # (e.g. "slalog,unitparse" or "-amended") — for running a subset that isn't an + # alphabetical prefix (-q N is first-N-alphabetically, not a sample). + flt = os.environ.get("SDE_TASK_FILTER", "").strip() + if flt: + subs = [s.strip() for s in flt.split(",") if s.strip()] + files = [f for f in files if any(s in f.parents[2].name for s in subs)] + return files + + def get_isolation_id(self, doc: Document) -> str | None: + return doc.user_id + + def load_queries(self, split: str, category: str | None = None, limit: int | None = None) -> list[Query]: + queries: list[Query] = [] + for tj in self._task_files(): + t = json.loads(tj.read_text()) + cat = t.get("category") + # the PRIMARY AMB category is `source` (history vs conversation) — the benchmark's core + # "where does the decision live" axis. The decision-type (`category`) and `tier` + # remain as secondary breakdown axes via get_result_categories. + if category and t.get("source") != category: + continue + queries.append(Query( + id=t["task_id"], + query=t["bug_report"], + gold_ids=[], + gold_answers=[], # coding: no gold answer, graded by tests + user_id=t["task_id"], + meta={ + "source": t.get("source"), "tier": t.get("tier"), "category": cat, + "codebase": t.get("codebase"), "module": t.get("module"), + "function": t.get("function"), + "task_json": str(tj), # the CodingMode passes this to run.py --task + }, + )) + return queries[:limit] if limit else queries + + def load_documents(self, split: str, category: str | None = None, limit: int | None = None, + ids: set[str] | None = None, user_ids: set[str] | None = None) -> list[Document]: + """Each task's knowledge corpus, namespaced by task_id: + - the DECISION: past developer chat(s) (conversation/amended sources) or the documented + decision commit (history sources, from task.json's decision_subject/rationale) + - decoy developer conversations (shared pool — retrieval noise) + - the host repo's recent commit messages (shared — history noise) + Generic providers ingest exactly this; the `hscoding` provider ignores the document list and + runs the plugin's deepen engine over the BUILT repo instead (same knowledge, repo-native).""" + decoys_path = _DATASETS / "gen" / "decoy_conversations.json" + decoys = json.loads(decoys_path.read_text()) if decoys_path.exists() else [] + noise = _host_noise_commits() + docs: list[Document] = [] + for tj in self._task_files(): + t = json.loads(tj.read_text()) + if category and t.get("source") != category: + continue + tid = t["task_id"] + if user_ids is not None and tid not in user_ids: + continue + conv = t.get("conversations") or [] + chats = conv if conv and isinstance(conv[0], list) else ([conv] if conv else []) + for ci, chat in enumerate(chats): + docs.append(Document( + id=f"{tid}:chat{ci}", content=_render_chat(chat), user_id=tid, + messages=[{"role": m.get("role", "user"), "content": m.get("text", "")} for m in chat], + context="past developer conversation about this project")) + if t.get("decision_subject"): + docs.append(Document( + id=f"{tid}:decision-commit", + content=f"Git commit: {t['decision_subject']}\n\n{t.get('decision_rationale', '')}", + user_id=tid, context="a commit message from this project's git history")) + for d in decoys: + turns = d.get("turns", []) + docs.append(Document( + id=f"{tid}:{d.get('id', 'decoy')}", content=_render_chat(turns), + user_id=tid, + messages=[{"role": m.get("role", "user"), "content": m.get("text", "")} for m in turns], + context="past developer conversation about this project")) + for sha, msg in noise: + docs.append(Document( + id=f"{tid}:git-{sha}", content=f"Git commit: {msg}", user_id=tid, + context="a commit message from this project's git history")) + if ids is not None: + docs = [d for d in docs if d.id in ids] + return docs[:limit] if limit else docs + + def categories(self, split: str) -> list[str] | None: + # PRIMARY category = source (history / conversation) — the benchmark's main axis. + srcs = {json.loads(tj.read_text()).get("source") for tj in self._task_files()} + return sorted(s for s in srcs if s) + + def category_type(self, split: str, category: str): + return "query" + + def get_result_categories(self, meta: dict) -> dict[str, list[str]]: + axes: dict[str, list[str]] = {} + for key, label in (("source", "Source"), ("tier", "Tier"), ("category", "Category")): + if meta.get(key): + axes[label] = [meta[key]] + return axes + + def supports_oracle(self) -> bool: + return False + + def dataset_stats(self, console, sample_size: int = 200) -> None: + tasks = [json.loads(tj.read_text()) for tj in self._task_files()] + def census(key): + out: dict[str, int] = {} + for t in tasks: + out[t.get(key) or "?"] = out.get(t.get(key) or "?", 0) + 1 + return ", ".join(f"{k}={v}" for k, v in sorted(out.items())) + docs = self.load_documents("boltons") + per_unit = len(docs) // max(len(tasks), 1) + console.print(f"[bold]sdebench[/bold] — {len(tasks)} tasks (boltons-hosted bug fixes)") + console.print(f" source: {census('source')}") + console.print(f" tier: {census('tier')}") + console.print(f" category: {census('category')}") + console.print(f" corpus: {len(docs)} documents (~{per_unit}/task: decision chat(s)/commit " + f"+ decoy conversations + host-history noise)") diff --git a/src/memory_bench/memory/__init__.py b/src/memory_bench/memory/__init__.py index fe30a2e..2b7e5e0 100644 --- a/src/memory_bench/memory/__init__.py +++ b/src/memory_bench/memory/__init__.py @@ -9,8 +9,12 @@ from .hybrid_search import HybridSearchMemoryProvider from .ogham import OghamMemoryProvider from .supermemory import SupermemoryMemoryProvider +from .none import NoMemoryProvider +from .hscoding import HsCodingProvider REGISTRY: dict[str, type[MemoryProvider]] = { + "vanilla": NoMemoryProvider, + "hindsight-coding": HsCodingProvider, "bm25": BM25MemoryProvider, "cognee": CogneeMemoryProvider, "hindsight": HindsightMemoryProvider, @@ -25,6 +29,9 @@ "qdrant": HybridSearchMemoryProvider, "supermemory": SupermemoryMemoryProvider, } +# legacy aliases (docs/scripts used these); canonical names above +REGISTRY["none"] = NoMemoryProvider +REGISTRY["hscoding"] = HsCodingProvider def get_memory_provider(name: str) -> MemoryProvider: diff --git a/src/memory_bench/memory/hscoding.py b/src/memory_bench/memory/hscoding.py new file mode 100644 index 0000000..b955eb6 --- /dev/null +++ b/src/memory_bench/memory/hscoding.py @@ -0,0 +1,148 @@ +"""Hindsight coding-agents plugin as a MemoryProvider (the sdebench `hscoding` arm). + +Conforms to the standard provider contract with one deliberate difference in each direction: + +- INGEST is repo-native: instead of consuming the dataset's Document list, `async_ingest` builds the + task's repo and runs the plugin's own background `deepen` engine over it + the task's + conversations + the decoy pool — the exact ingestion a real deployment performs at session start — + then polls the plugin's `status` entry until `synced` (the product's readiness contract). The + Document list and the deepen inputs describe the SAME knowledge; the plugin simply owns its own + extraction, strategies, and git scope. +- RETRIEVE is a no-op: delivery is agent-side (the plugin's reflect+inject inside the agent + harness), so the coding mode does not inject anything for this provider. The retrieve() stub + exists only to satisfy the interface for non-coding callers. + +Per-task isolation: the sdebench dataset declares `isolation_unit = "task"`, so the runner ingests +one unit (task) at a time; the bank is `sde-coding-`. `--skip-ingestion` reuses populated +banks across n-runs (replaces the old SDE_HSCODING_REUSE_BANK env, which is still honored). +""" +import asyncio +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +from .base import MemoryProvider +from ..models import Document + +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +def bank_for(task_id: str) -> str: + return f"sde-coding-{task_id}" + + +class HsCodingProvider(MemoryProvider): + name = "hindsight-coding" + description = "Hindsight coding-agents plugin: deepen-engine ingestion, agent-side reflect+inject." + kind = "local" + provider = "hindsight" + variant = "coding-plugin" + link = "https://github.com/vectorize-io/hindsight" + concurrency = int(os.environ.get("SDE_CONCURRENCY", "4")) + + def __init__(self) -> None: + self._url = os.environ.get("SDE_HINDSIGHT_URL", "http://localhost:8888") + self._skip = False + + # ── lifecycle ──────────────────────────────────────────────────────────────── + def initialize(self) -> None: + plugin_dir = Path(os.path.expanduser(os.environ.get("SDE_HSCODING_PLUGIN_DIR", ""))) + if not plugin_dir.name or not (plugin_dir / "dist" / "deepen.js").exists(): + raise RuntimeError("memory=hindsight-coding needs SDE_HSCODING_PLUGIN_DIR -> a " + "hindsight-coding-agents checkout with dist/ built") + self._plugin_dir = plugin_dir + + def prepare(self, store_dir: Path, unit_ids: set[str] | None = None, reset: bool = True) -> None: + # --skip-ingestion (or the legacy env) => reuse populated banks; otherwise fresh-trial reset. + self._skip = (not reset) or os.environ.get("SDE_HSCODING_REUSE_BANK", "").lower() in ("1", "true") + if not self._skip: + for uid in unit_ids or set(): + self._delete_bank(bank_for(uid)) + + # ── ingestion (the plugin's own engine) ────────────────────────────────────── + def ingest(self, documents: list[Document]) -> None: + asyncio.run(self.async_ingest(documents)) + + async def async_ingest(self, documents: list[Document]) -> None: + if not documents: + return + task_id = documents[0].user_id + if not task_id: + return + bank = bank_for(task_id) + if self._skip and await asyncio.to_thread(self._bank_has_memories, bank): + return + from ..dataset.sdebench import task_json_path + tj = task_json_path(task_id) + t = json.loads(tj.read_text()) + build_py = tj.parents[2] / t.get("build", "build.py") + base = Path("/tmp/sdebench/omb-backfill") / task_id + src = base / "repo" + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + # 1. build the task repo (deepen reads its git history — the same knowledge the dataset's + # git-commit Documents describe, in its native form) + bp = await asyncio.to_thread(subprocess.run, ["python", str(build_py), str(src)], + capture_output=True, text=True, env={**os.environ}) + if bp.returncode != 0 or not (src / ".git").exists(): + raise RuntimeError(f"task repo build failed for {task_id} (rc={bp.returncode}): " + f"{(bp.stderr or bp.stdout or '')[-200:]}") + # 2. the plugin's deepen engine (it owns extraction/strategies/pages/git scope) + cmd = ["node", str(self._plugin_dir / "dist" / "deepen.js"), "--repo", str(src), + "--bank", bank, "--api-url", self._url, "--git-ingest", "full"] + chats = [{"id": d.id, "turns": [{"role": m["role"], "text": m["content"]} + for m in (d.messages or [])]} + for d in documents if d.messages] + if chats: + cf = base / "conversations.json" + cf.write_text(json.dumps(chats)) + cmd += ["--conversations", str(cf)] + limit = os.environ.get("SDE_HSCODING_GIT_LIMIT") + if limit: + cmd += ["--gitlog-limit", limit] + p = await asyncio.to_thread(subprocess.run, cmd, capture_output=True, text=True, + env={**os.environ}, timeout=1800) + if p.returncode != 0: + raise RuntimeError(f"deepen failed (rc={p.returncode}) for bank {bank}: " + f"{(p.stderr or p.stdout or '')[-300:]}") + # 3. poll the plugin's sync status until seeded memory is fully queryable + st = ["node", str(self._plugin_dir / "dist" / "status.js"), "--repo", str(src), + "--bank", bank, "--api-url", self._url] + deadline = time.monotonic() + 900 + while time.monotonic() < deadline: + sp = await asyncio.to_thread(subprocess.run, st, capture_output=True, text=True, + env={**os.environ}, timeout=120) + try: + if json.loads(sp.stdout.strip().splitlines()[-1]).get("synced"): + return + except Exception: + pass + await asyncio.sleep(5) + raise RuntimeError(f"hscoding ingest never reached synced for bank {bank}") + + # ── retrieval: agent-side (plugin reflect+inject); nothing to serve here ───── + def retrieve(self, query: str, k: int = 10, user_id: str | None = None, + query_timestamp: str | None = None) -> tuple[list[Document], dict | None]: + return [], None + + # ── helpers ────────────────────────────────────────────────────────────────── + def _bank_has_memories(self, bank: str) -> bool: + import urllib.request + try: + with urllib.request.urlopen( + f"{self._url}/v1/default/banks/{bank}/memories/list?limit=1", timeout=10) as r: + d = json.loads(r.read()) + return bool(d.get("items") or d.get("memories") or d.get("total")) + except Exception: + return False + + def _delete_bank(self, bank: str) -> None: + import urllib.request + try: + req = urllib.request.Request(f"{self._url}/v1/default/banks/{bank}", method="DELETE") + urllib.request.urlopen(req, timeout=30).read() + except Exception: + pass diff --git a/src/memory_bench/memory/none.py b/src/memory_bench/memory/none.py new file mode 100644 index 0000000..c021edc --- /dev/null +++ b/src/memory_bench/memory/none.py @@ -0,0 +1,21 @@ +"""No-memory provider — the baseline arm. Ingests nothing and retrieves nothing, so an eval with +`--memory none` measures the agent/model with no memory system at all.""" +import os + +from .base import MemoryProvider +from ..models import Document + + +class NoMemoryProvider(MemoryProvider): + name = "vanilla" + description = "No memory system (vanilla baseline)." + kind = "local" + concurrency = int(os.environ.get("SDE_CONCURRENCY", "4")) + + def ingest(self, documents: list[Document]) -> None: + pass + + def retrieve(self, query: str, k: int = 10, user_id: str | None = None, + query_timestamp: str | None = None) -> tuple[list[Document], dict | None]: + return [], None + diff --git a/src/memory_bench/models.py b/src/memory_bench/models.py index 6bedd05..c8d8add 100644 --- a/src/memory_bench/models.py +++ b/src/memory_bench/models.py @@ -51,6 +51,8 @@ class QueryResult: score: float | None = None # continuous score 0-1 (used by BEAM paper scoring); None means binary only meta: dict = field(default_factory=dict) # propagated from Query.meta raw_response: dict | None = None # raw provider response, free-form + trajectory: list | None = None # coding: flattened agent steps ({k: say|tool|patch, ...}) for the UI agent view + git_history: list | None = None # coding: the repo's recent commits ({sha, subject, body, diff}) category_axes: dict[str, list[str]] = field(default_factory=dict) # axis → values, e.g. {"conversation": ["conv-26"], "question_type": ["temporal"]} diff --git a/src/memory_bench/modes/__init__.py b/src/memory_bench/modes/__init__.py index 9bf5bab..83b1e9d 100644 --- a/src/memory_bench/modes/__init__.py +++ b/src/memory_bench/modes/__init__.py @@ -2,12 +2,14 @@ from .rag import RAGMode from .agentic_rag import AgenticRAGMode from .agent import AgentMode +from .coding import CodingMode from ..llm.base import LLM REGISTRY: dict[str, type[ResponseMode]] = { "rag": RAGMode, "agentic-rag": AgenticRAGMode, "agent": AgentMode, + "coding": CodingMode, } diff --git a/src/memory_bench/modes/coding.py b/src/memory_bench/modes/coding.py new file mode 100644 index 0000000..03778f5 --- /dev/null +++ b/src/memory_bench/modes/coding.py @@ -0,0 +1,135 @@ +"""Coding response mode: build the task repo, run a coding agent with test-feedback interventions, +grade by pytest. Reuses the sdebench harness (`sdebench/harness/run.py`) verbatim. + +Memory flows through the STANDARD provider pipeline: the runner ingests the dataset's per-task +corpus into the selected provider (isolation_unit = task), then this mode dispatches: + - `none` => the no-memory baseline (`full` arm). + - `hscoding` => the Hindsight plugin runs INSIDE the agent (reflect+inject); its provider ingested + via the plugin's own deepen engine. This mode only passes the bank name through. + - any other provider => generic arm: `provider.retrieve(bug_report)` and the top memories are + injected into the task prompt (`provided` arm) — how any AMB memory system runs the benchmark. + +Env: SDE_AGENT (opencode|claude-code|codex), SDE_HINDSIGHT_URL (Hindsight server, default :8888), +SDE_HSCODING_PLUGIN_DIR (plugin dir with dist/ built; hscoding only), SDE_MODEL. +""" +import asyncio +import json +import os +import shutil +import subprocess +import time +import uuid +from pathlib import Path + +from .base import ResponseMode +from ..memory.base import MemoryProvider +from ..models import AnswerResult + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_RUN_PY = _REPO_ROOT / "sdebench" / "harness" / "run.py" + + +class CodingMode(ResponseMode): + name = "coding" + description = "Build the task repo, run a coding agent with test-feedback interventions, grade by pytest." + + _AGENT_MODEL = {"opencode": "google/gemini-3.5-flash", "claude-code": "claude-sonnet-5", + "codex": "gpt-5.4-mini"} + + def __init__(self, model: str | None = None): + self._agent = os.environ.get("SDE_AGENT", "opencode") # --agent, so claude runs land in the UI + self._model = model or os.environ.get("SDE_MODEL") or self._AGENT_MODEL.get(self._agent, "google/gemini-3.5-flash") + + @property + def llm_id(self) -> str | None: + return f"{self._agent}:{self._model}" + + def answer(self, query: str, memory: MemoryProvider, task_type: str = "coding", user_id: str | None = None) -> AnswerResult: + return asyncio.run(self.async_answer(query, memory, task_type=task_type, user_id=user_id)) + + def answer_from_context(self, query: str, context: str, task_type: str = "coding") -> AnswerResult: + raise NotImplementedError("coding mode grades by running the agent; --skip-retrieval is not supported") + + async def async_answer(self, query: str, memory: MemoryProvider, task_type: str = "coding", + user_id: str | None = None, meta: dict | None = None) -> AnswerResult: + meta = meta or {} + task_json = meta.get("task_json") + task_id = user_id or meta.get("task_id") or "task" + run_id = f"omb-{uuid.uuid4().hex[:8]}" + + if not task_json: + return AnswerResult(answer="unsolved", reasoning="no task_json in meta", context="", + retrieve_time_ms=0.0, raw_response={"solved": False}) + + # Dispatch by provider. Ingestion already happened through the RUNNER's standard pipeline + # (dataset documents -> provider.async_ingest, per task unit) — nothing is ingested here. + # none -> vanilla arm (full repo, no memory) + # hscoding -> the plugin runs INSIDE the agent (reflect+inject); pass it the bank + # any other -> generic arm: provider.retrieve() -> context injected into the task prompt + env = {**os.environ} + retrieve_ms = 0.0 + external_memory_file = None + if memory.name == "vanilla": + arm = "full" + elif memory.name == "hindsight-coding": + arm = "hscoding" + from ..memory.hscoding import bank_for + env["SDE_HSCODING_BANK"] = bank_for(task_id) # run.py -> plugin config (reflect+inject) + env["SDE_HINDSIGHT_URL"] = os.environ.get("SDE_HINDSIGHT_URL", "http://localhost:8888") + else: + arm = "provided" + t0 = time.perf_counter() + docs, _raw = await memory.async_retrieve(query, k=10, user_id=task_id) + retrieve_ms = (time.perf_counter() - t0) * 1000 + block = "\n\n---\n\n".join(d.content for d in docs if d.content) or "(no relevant memories found)" + ext = Path("/tmp/sdebench/omb-context") / f"{task_id}_{run_id}.txt" + ext.parent.mkdir(parents=True, exist_ok=True) + ext.write_text(block) + external_memory_file = str(ext) + + cmd = ["uv", "run", "python", str(_RUN_PY), "--task", str(task_json), + "--history", arm, "--agent", self._agent, "--model", self._model, "--run-id", run_id] + if external_memory_file: + cmd += ["--external-memory", external_memory_file] + t0 = time.perf_counter() + proc = await asyncio.to_thread( + subprocess.run, cmd, capture_output=True, text=True, cwd=str(_REPO_ROOT), env=env, + ) + elapsed_ms = (time.perf_counter() - t0) * 1000 + + work = Path("/tmp/sdebench/run") / f"{task_id}_{arm}_{run_id}" + result_path = work / "result.json" + if result_path.exists(): + result = json.loads(result_path.read_text()) + trace_path = work / "trace.json" + if trace_path.exists(): + # Surface WHAT HAPPENED to the UI's agent view: flatten the per-round trace into one + # step list (feedback rounds separated by 🔁 markers), plus the final patch and the + # repo's git history. Injected memory (hscoding) comes from the plugin's diag trail. + tr = json.loads(trace_path.read_text()) + flat: list = [] + for i, rnd in enumerate(tr.get("trace") or []): + if i: + flat.append({"k": "say", "text": "🔁 " + (rnd.get("prompt") or "")[:400]}) + flat.extend(rnd.get("trajectory") or []) + result["trajectory"] = flat + result["git_history"] = tr.get("git_history") + result["final_patch"] = tr.get("final_patch") + mem_blocks = [d.get("answer") for d in (result.get("memory_diag") or []) + if d.get("event") == "reflect_ok" and d.get("answer")] + if mem_blocks: + result["memory_context"] = "\n".join(f"## Memory (reflect)\n{a}" for a in mem_blocks) + else: + # harness crashed before grading — surface stderr tail so it's debuggable + result = {"solved": False, "interventions": None, + "final_pytest": (proc.stderr or proc.stdout or "")[-400:], "error": "no result.json"} + + solved = bool(result.get("solved")) + return AnswerResult( + answer="solved" if solved else "unsolved", + reasoning=f"arm={arm} interventions={result.get('interventions')} " + f"cost=${result.get('cost_usd')} turns={result.get('turns')}", + context=f"memory={memory.name} arm={arm}", # non-empty; coding scoring ignores context + retrieve_time_ms=retrieve_ms or (float(result.get("wall_s", 0.0)) * 1000 or elapsed_ms), + raw_response=result, # runner's coding branch reads solved/interventions/… + ) diff --git a/src/memory_bench/runner.py b/src/memory_bench/runner.py index d867826..493f91e 100644 --- a/src/memory_bench/runner.py +++ b/src/memory_bench/runner.py @@ -189,7 +189,26 @@ async def _process_one_attempt(q) -> QueryResult: logger.info("[query:%s] answer done in %.1fs (retrieve=%.0fms)", q.id, time.perf_counter() - t_start, answer_result.retrieve_time_ms) score: float | None = None - if not answer_result.context: + coding_trajectory = coding_git_history = None + if task_type == "coding": + # The CodingMode already built the repo, ran the agent (with interventions), and graded + # by pytest. Correctness is solve-ness, not a judged answer; carry the metrics through. + raw = answer_result.raw_response or {} + correct = bool(raw.get("solved")) + judge_reason = f"interventions={raw.get('interventions')} pytest={(raw.get('final_pytest') or '')[:80]}" + q.meta.update({k: raw.get(k) for k in + ("solved", "interventions", "capped", "cost_usd", "turns", "wall_s", + "final_pytest", "tokens", "agent", "model") + if raw.get(k) is not None}) + # Agent-view payload: the patch is the "answer", injected memory the "context", + # and the step trace + repo history land as row fields (view: "agent" in _save). + if raw.get("final_patch"): + answer_result.answer = raw["final_patch"] + if raw.get("memory_context"): + answer_result.context = raw["memory_context"] + coding_trajectory = raw.get("trajectory") + coding_git_history = raw.get("git_history") + elif not answer_result.context: correct, judge_reason = False, "empty context — no memories retrieved" elif task_type == "mcq": correct, judge_reason = _score_mcq(answer_result.answer, q.gold_answers) @@ -235,6 +254,8 @@ async def _process_one_attempt(q) -> QueryResult: score=score, meta=q.meta, raw_response=None, # skip storing to conserve disk space + trajectory=coding_trajectory, + git_history=coding_git_history, category_axes=dataset.get_result_categories(q.meta), ) @@ -334,7 +355,8 @@ async def bounded(i, q): accuracy=0.0, ingestion_time_ms=round(ingestion_ms, 1), ingested_docs=ingested_docs_count, description=description, answer_llm=mode.llm_id, - judge_llm=self._get_judge(dataset)._llm.model_id, results=[r for r in all_results if r], + judge_llm=(None if dataset.task_type == "coding" else self._get_judge(dataset)._llm.model_id), + results=[r for r in all_results if r], ) self._save(partial) @@ -357,7 +379,8 @@ async def bounded(i, q): memory.ingest(documents) ingestion_ms = (time.perf_counter() - t0) * 1000 ingested_docs_count = len(documents) - console.print(f" ingested in {ingestion_ms:.0f}ms ({ingestion_ms / len(documents):.1f}ms/doc avg)\n") + avg = f" ({ingestion_ms / len(documents):.1f}ms/doc avg)" if documents else "" + console.print(f" ingested in {ingestion_ms:.0f}ms{avg}\n") async def _run_all(progress, task_id): concurrency = getattr(memory, "concurrency", _CONCURRENCY) @@ -408,10 +431,14 @@ async def bounded(i, q): ingested_docs=ingested_docs_count, description=description, answer_llm=mode.llm_id, - judge_llm=self._get_judge(dataset)._llm.model_id, + judge_llm=(None if dataset.task_type == "coding" else self._get_judge(dataset)._llm.model_id), results=results, ) - self._save(summary) + # Final save truncates to THIS run's query set: without it, a later smaller run (e.g. -q 6) + # keeps stale rows from an earlier larger run under the same run name, and the output file + # silently reads as a bigger run than actually happened. Partial saves (mid-run, above) + # deliberately do NOT truncate — they accumulate units as they complete. + self._save(summary, query_ids={q.id for q in queries}) memory.cleanup() return summary @@ -444,12 +471,17 @@ def _load_previous_ingestion_ms(self, dataset: str, split: str, memory: str, mod def _load_previous_ingested_docs(self, dataset: str, split: str, memory: str, mode: str) -> int: return self._load_previous(dataset, split, memory, mode).get("ingested_docs", 0) - def _save(self, summary: EvalSummary) -> None: + def _save(self, summary: EvalSummary, query_ids: set | None = None) -> None: path = self._output_path(summary.dataset, summary.split, summary.run_name, summary.mode) path.parent.mkdir(parents=True, exist_ok=True) - # Always merge: new results overwrite existing ones by query_id, old ones are kept + # Always merge: new results overwrite existing ones by query_id, old ones are kept — + # except that a final save (query_ids given) drops rows outside this run's query set. merged = self._merge(summary.results, summary.dataset, summary.split, summary.run_name, summary.mode) + if query_ids is not None: + merged = [r for r in merged if r.query_id in query_ids] d = asdict(summary) + if summary.mode == "coding": + d["view"] = "agent" # the UI's multi-turn agent renderer results_dicts = [asdict(r) for r in merged] d["total_queries"] = len(merged) d["correct"] = sum(1 for r in merged if r.correct) diff --git a/src/memory_bench/server.py b/src/memory_bench/server.py index 61013a0..1c47f82 100644 --- a/src/memory_bench/server.py +++ b/src/memory_bench/server.py @@ -148,6 +148,33 @@ def _extract(key): "avg_context_tokens": float(avg_context_tokens) if avg_context_tokens and avg_context_tokens != "null" else None, "category": category if category and category != "null" else None, }) + # Coding datasets (sdebench) report agent metrics per-result, not top-level. Read the full file + # and aggregate them so the dataset page can show interventions/cost/turns/tokens instead of the + # QA metrics (accuracy/recall/ctx-tokens), which don't apply to coding. + if parts[2] == "coding": + try: + full = _json.loads(_gzip.decompress(raw) if is_gz else raw) + rs = full.get("results", []) + metas = [r.get("meta", {}) or {} for r in rs] + def _sum(k): + return sum((m.get(k) or 0) for m in metas) + def _toks(sel): + return sum(sum((m.get("tokens", {}) or {}).get(x, 0) or 0 for x in sel) for m in metas) + agent = next((m.get("agent") for m in metas if m.get("agent")), None) or "opencode" + model = next((m.get("model") for m in metas if m.get("model")), None) + entries[-1].update({ + "coding": True, "agent": agent, "model": model, + "tasks": len(rs), + "solved": sum(1 for r in rs if (r.get("meta", {}) or {}).get("solved")), + "interventions": _sum("interventions"), + "cost_usd": round(_sum("cost_usd"), 2), + "turns": _sum("turns"), + "wall_s": round(_sum("wall_s"), 0), + "tokens_in": _toks(("input", "cache_read", "cache_write")), + "tokens_out": _toks(("output", "reasoning")), + }) + except Exception: + pass _results_cache = entries _results_cache_mtime = current_mtime diff --git a/ui/dist/assets/index-BWKRtmhC.css b/ui/dist/assets/index-BWKRtmhC.css deleted file mode 100644 index 019adf3..0000000 --- a/ui/dist/assets/index-BWKRtmhC.css +++ /dev/null @@ -1 +0,0 @@ -/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 4px);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Inter", ui-sans-serif, system-ui;--default-mono-font-family:"Geist Mono", ui-monospace;--font-display:"Space Grotesk", "Inter", ui-sans-serif}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);box-sizing:border-box;-webkit-font-smoothing:antialiased;scrollbar-width:thin;scrollbar-color:var(--border) transparent}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--border);border-radius:9999px}::-webkit-scrollbar-thumb:hover{background:var(--muted-foreground)}body{background-color:var(--background);color:var(--foreground);margin:0}pre{white-space:pre-wrap;word-break:break-word}[v-cloak]{display:none}details>summary{list-style:none}details>summary::-webkit-details-marker{display:none}}@layer components;@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0\.5{top:calc(var(--spacing) * .5)}.left-0\.5{left:calc(var(--spacing) * .5)}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-16{margin-top:calc(var(--spacing) * 16)}.mt-20{margin-top:calc(var(--spacing) * 20)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.-mb-px{margin-bottom:-1px}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-14{height:calc(var(--spacing) * 14)}.h-screen{height:100vh}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[25vh\]{max-height:25vh}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-\[300px\]{width:300px}.w-\[320px\]{width:320px}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-\[110px\]{max-width:110px}.max-w-\[160px\]{max-width:160px}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[240px\]{min-width:240px}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.caption-bottom{caption-side:bottom}.border-collapse{border-collapse:collapse}.translate-x-4{--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-none{translate:none}.scale-3d{scale:var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-20>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 20) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 20) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}:where(.divide-border>:not(:last-child)){border-color:var(--border)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-bs{border-block-start-style:var(--tw-border-style);border-block-start-width:1px}.border-be{border-block-end-style:var(--tw-border-style);border-block-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-border,.border-border\/40{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/40{border-color:color-mix(in oklab,var(--border) 40%,transparent)}}.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border) 50%,transparent)}}.border-ca\/15{border-color:#fbbf2426}.border-ca\/25{border-color:#fbbf2440}.border-cg\/25{border-color:#34d39940}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive) 30%,transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-primary,.border-primary\/25{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/25{border-color:color-mix(in oklab,var(--primary) 25%,transparent)}}.border-primary\/30{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/30{border-color:color-mix(in oklab,var(--primary) 30%,transparent)}}.border-violet-500\/25{border-color:#8d54ff40}@supports (color:color-mix(in lab,red,red)){.border-violet-500\/25{border-color:color-mix(in oklab,var(--color-violet-500) 25%,transparent)}}.bg-background{background-color:var(--background)}.bg-border{background-color:var(--border)}.bg-ca\/10{background-color:#fbbf241a}.bg-card{background-color:var(--card)}.bg-cg\/10{background-color:#34d3991a}.bg-cg\/15{background-color:#34d39926}.bg-cr\/15{background-color:#f8717126}.bg-destructive\/15{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/15{background-color:color-mix(in oklab,var(--destructive) 15%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-primary,.bg-primary\/5{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,var(--primary) 5%,transparent)}}.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.bg-primary\/15{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/15{background-color:color-mix(in oklab,var(--primary) 15%,transparent)}}.bg-secondary,.bg-secondary\/40{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/40{background-color:color-mix(in oklab,var(--secondary) 40%,transparent)}}.bg-transparent{background-color:#0000}.bg-violet-500\/10{background-color:#8d54ff1a}@supports (color:color-mix(in lab,red,red)){.bg-violet-500\/10{background-color:color-mix(in oklab,var(--color-violet-500) 10%,transparent)}}.bg-white{background-color:var(--color-white)}.bg-repeat{background-repeat:repeat}.mask-no-clip{-webkit-mask-clip:no-clip;mask-clip:no-clip}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-display{font-family:Space Grotesk,Inter,ui-sans-serif}.font-mono{font-family:Geist Mono,ui-monospace}.font-sans{font-family:Inter,ui-sans-serif,system-ui}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-wrap{text-wrap:wrap}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-ca{color:#fbbf24}.text-card-foreground{color:var(--card-foreground)}.text-cg{color:#34d399}.text-cr{color:#f87171}.text-destructive{color:var(--destructive)}.text-foreground,.text-foreground\/70{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/70{color:color-mix(in oklab,var(--foreground) 70%,transparent)}}.text-foreground\/80{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/80{color:color-mix(in oklab,var(--foreground) 80%,transparent)}}.text-muted-foreground,.text-muted-foreground\/30{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/30{color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.text-muted-foreground\/70{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/70{color:color-mix(in oklab,var(--muted-foreground) 70%,transparent)}}.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground) 80%,transparent)}}.text-muted-foreground\/85{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/85{color:color-mix(in oklab,var(--muted-foreground) 85%,transparent)}}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-violet-400{color:var(--color-violet-400)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow,.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.inset-ring{--tw-inset-ring-shadow:inset 0 0 0 1px var(--tw-inset-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:text-muted-foreground:is(:where(.group):hover *){color:var(--muted-foreground)}}.placeholder\:text-muted-foreground\/80::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/80::placeholder{color:color-mix(in oklab,var(--muted-foreground) 80%,transparent)}}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:border-primary\/30:hover{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:border-primary\/30:hover{border-color:color-mix(in oklab,var(--primary) 30%,transparent)}}.hover\:bg-cg\/10:hover{background-color:#34d3991a}.hover\:bg-cr\/10:hover{background-color:#f871711a}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}.hover\:bg-secondary:hover,.hover\:bg-secondary\/30:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/30:hover{background-color:color-mix(in oklab,var(--secondary) 30%,transparent)}}.hover\:bg-secondary\/50:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/50:hover{background-color:color-mix(in oklab,var(--secondary) 50%,transparent)}}.hover\:text-ca\/80:hover{color:#fbbf24cc}.hover\:text-cg:hover{color:#34d399}.hover\:text-cr:hover{color:#f87171}.hover\:text-foreground:hover,.hover\:text-foreground\/80:hover{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:text-foreground\/80:hover{color:color-mix(in oklab,var(--foreground) 80%,transparent)}}.hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.hover\:text-primary:hover,.hover\:text-primary\/80:hover{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:text-primary\/80:hover{color:color-mix(in oklab,var(--primary) 80%,transparent)}}.hover\:underline:hover{text-decoration-line:underline}}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:var(--ring)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-25:disabled{opacity:.25}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}:root{--background:oklch(21.78% 0 0);--foreground:oklch(92.19% 0 0);--card:oklch(24.35% 0 0);--card-foreground:oklch(92.19% 0 0);--popover:oklch(24.35% 0 0);--popover-foreground:oklch(92.19% 0 0);--primary:oklch(64.2% .1691 38.5815);--primary-foreground:oklch(100% 0 0);--secondary:oklch(37.43% .0726 258.521);--secondary-foreground:oklch(92.19% 0 0);--muted:oklch(28.5% 0 0);--muted-foreground:oklch(76% 0 0);--accent:oklch(33.8% .0589 267.587);--accent-foreground:oklch(88.23% .0571 254.128);--destructive:oklch(63.68% .2078 25.3313);--destructive-foreground:oklch(100% 0 0);--border:oklch(39% 0 0);--input:oklch(30.92% 0 0);--ring:oklch(63.97% .172 36.4421);--radius:.5rem}.heading-gradient{-webkit-text-fill-color:transparent;background:linear-gradient(135deg,#f49157,#df6035);-webkit-background-clip:text;background-clip:text}.topnav{z-index:50;background:var(--background);position:sticky;top:0}@supports (color:color-mix(in lab,red,red)){.topnav{background:color-mix(in oklch,var(--background) 85%,transparent)}}.topnav{-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}.chart-track{background:var(--secondary);border-radius:4px;flex:1;height:1.2rem;position:relative;overflow:hidden}.chart-bar-a{background:linear-gradient(90deg,#952b00,#df6035);background:linear-gradient(90deg,color(xyz 0.142 0.08 -0),#df6035);border-radius:4px;height:100%;transition:width .4s}.chart-bar-r{background:linear-gradient(90deg,#059669,#34d399);border-radius:4px;height:100%;transition:width .4s}.chart-bar-t{background:linear-gradient(90deg,#9b462c,#d59466);border-radius:4px;height:100%;transition:width .4s}.chart-val-out{font-family:var(--font-display),ui-sans-serif,system-ui,sans-serif;color:var(--muted-foreground);white-space:nowrap;text-align:right;letter-spacing:-.01em;min-width:2.8rem;font-size:.7rem;font-weight:500}.chart-val-out.chart-winner{color:var(--primary);font-weight:800}.sidebar{background:var(--card);border-right:1px solid var(--border)}.sidebar-section{border-bottom:1px solid var(--border)}.item-active{background:var(--primary)!important}@supports (color:color-mix(in lab,red,red)){.item-active{background:color-mix(in oklch,var(--primary) 15%,transparent)!important}}.item-active{border-left:2px solid var(--primary)!important}.verdict-pass{color:#34d399;border-radius:var(--radius);background:#34d39914;border:1px solid #34d39933;padding:.75rem 1rem}.verdict-fail{color:#f87171;border-radius:var(--radius);background:#f8717114;border:1px solid #f8717133;padding:.75rem 1rem}.code-block{background:var(--muted);border:1px solid var(--border);border-radius:var(--radius);color:var(--muted-foreground);max-height:24rem;padding:1rem 1.25rem;font-family:Geist Mono,ui-monospace,monospace;font-size:.75rem;line-height:1.7;overflow-y:auto}.cat-row{cursor:pointer;border-radius:var(--radius-sm);transition:background .1s}.cat-row:hover{background:var(--muted)}.cat-row-active{background:var(--primary)}@supports (color:color-mix(in lab,red,red)){.cat-row-active{background:color-mix(in oklch,var(--primary) 15%,transparent)}}.cat-row-active{color:var(--primary)}.pill{background:var(--muted);color:var(--muted-foreground);border-radius:9999px;padding:.2rem .6rem;font-size:.7rem}.stat-box{background:var(--muted);border:1px solid var(--border);border-radius:var(--radius);padding:.6rem .85rem}.inset-section{background:var(--muted);border-radius:var(--radius);border:1px solid var(--border);padding:1rem 1.1rem}.doc-summary{transition:background .1s}.doc-summary:hover{background:var(--muted)}@supports (color:color-mix(in lab,red,red)){.doc-summary:hover{background:color-mix(in oklch,var(--muted) 60%,transparent)}}.reveal-item{opacity:0;transition:opacity .55s,transform .55s;transform:translateY(18px)}.reveal-visible{opacity:1;transform:translateY(0)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/ui/dist/assets/index-Yo_9K4IK.js b/ui/dist/assets/index-Yo_9K4IK.js new file mode 100644 index 0000000..b0a8be0 --- /dev/null +++ b/ui/dist/assets/index-Yo_9K4IK.js @@ -0,0 +1,67 @@ +var bc=Object.defineProperty;var vc=(e,t,o)=>t in e?bc(e,t,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[t]=o;var kn=(e,t,o)=>vc(e,typeof t!="symbol"?t+"":t,o);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const s of r)if(s.type==="childList")for(const i of s.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&n(i)}).observe(document,{childList:!0,subtree:!0});function o(r){const s={};return r.integrity&&(s.integrity=r.integrity),r.referrerPolicy&&(s.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?s.credentials="include":r.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function n(r){if(r.ep)return;r.ep=!0;const s=o(r);fetch(r.href,s)}})();/** +* @vue/shared v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function $t(e){const t=Object.create(null);for(const o of e.split(","))t[o]=1;return o=>o in t}const Ne=Object.freeze({}),On=Object.freeze([]),et=()=>{},na=()=>!1,po=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Mo=e=>e.startsWith("onUpdate:"),Ve=Object.assign,ns=(e,t)=>{const o=e.indexOf(t);o>-1&&e.splice(o,1)},yc=Object.prototype.hasOwnProperty,Pe=(e,t)=>yc.call(e,t),ge=Array.isArray,mn=e=>mo(e)==="[object Map]",oa=e=>mo(e)==="[object Set]",Ns=e=>mo(e)==="[object Date]",ve=e=>typeof e=="function",He=e=>typeof e=="string",_t=e=>typeof e=="symbol",Me=e=>e!==null&&typeof e=="object",os=e=>(Me(e)||ve(e))&&ve(e.then)&&ve(e.catch),ra=Object.prototype.toString,mo=e=>ra.call(e),rs=e=>mo(e).slice(8,-1),sa=e=>mo(e)==="[object Object]",ss=e=>He(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,eo=$t(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),_c=$t("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),Zo=e=>{const t=Object.create(null);return o=>t[o]||(t[o]=e(o))},xc=/-\w/g,Je=Zo(e=>e.replace(xc,t=>t.slice(1).toUpperCase())),wc=/\B([A-Z])/g,on=Zo(e=>e.replace(wc,"-$1").toLowerCase()),yn=Zo(e=>e.charAt(0).toUpperCase()+e.slice(1)),dn=Zo(e=>e?`on${yn(e)}`:""),Ot=(e,t)=>!Object.is(e,t),Gn=(e,...t)=>{for(let o=0;o{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:o})},kc=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Hs;const ho=()=>Hs||(Hs=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Mn(e){if(ge(e)){const t={};for(let o=0;o{if(o){const n=o.split(Sc);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function Ie(e){let t="";if(He(e))t=e;else if(ge(e))for(let o=0;o!!(e&&e.__v_isRef===!0),k=e=>He(e)?e:e==null?"":ge(e)||Me(e)&&(e.toString===ra||!ve(e.toString))?aa(e)?k(e.value):JSON.stringify(e,la,2):String(e),la=(e,t)=>aa(t)?la(e,t.value):mn(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((o,[n,r],s)=>(o[dr(n,s)+" =>"]=r,o),{})}:oa(t)?{[`Set(${t.size})`]:[...t.values()].map(o=>dr(o))}:_t(t)?dr(t):Me(t)&&!ge(t)&&!sa(t)?String(t):t,dr=(e,t="")=>{var o;return _t(e)?`Symbol(${(o=e.description)!=null?o:t})`:e};/** +* @vue/reactivity v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function xt(e,...t){console.warn(`[Vue warn] ${e}`,...t)}let lt;class Nc{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=lt,!t&<&&(this.index=(lt.scopes||(lt.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,o;if(this.scopes)for(t=0,o=this.scopes.length;t0&&--this._on===0&&(lt=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let o,n;for(o=0,n=this.effects.length;o0)return;if(no){let t=no;for(no=void 0;t;){const o=t.next;t.next=void 0,t.flags&=-9,t=o}}let e;for(;to;){let t=to;for(to=void 0;t;){const o=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=o}}if(e)throw e}function fa(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function pa(e){let t,o=e.depsTail,n=o;for(;n;){const r=n.prevDep;n.version===-1?(n===o&&(o=r),cs(n),Uc(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=r}e.deps=t,e.depsTail=o}function Ar(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(ma(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function ma(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===ao)||(e.globalVersion=ao,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ar(e))))return;e.flags|=2;const t=e.dep,o=De,n=yt;De=e,yt=!0;try{fa(e);const r=e.fn(e._value);(t.version===0||Ot(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{De=o,yt=n,pa(e),e.flags&=-3}}function cs(e,t=!1){const{dep:o,prevSub:n,nextSub:r}=e;if(n&&(n.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=n,e.nextSub=void 0),o.subsHead===e&&(o.subsHead=r),o.subs===e&&(o.subs=n,!n&&o.computed)){o.computed.flags&=-5;for(let s=o.computed.deps;s;s=s.nextDep)cs(s,!0)}!t&&!--o.sc&&o.map&&o.map.delete(o.key)}function Uc(e){const{prevDep:t,nextDep:o}=e;t&&(t.nextDep=o,e.prevDep=void 0),o&&(o.prevDep=t,e.nextDep=void 0)}let yt=!0;const ha=[];function wt(){ha.push(yt),yt=!1}function kt(){const e=ha.pop();yt=e===void 0?!0:e}function Us(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const o=De;De=void 0;try{t()}finally{De=o}}}let ao=0;class Bc{constructor(t,o){this.sub=t,this.dep=o,this.version=o.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class us{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0,this.subsHead=void 0}track(t){if(!De||!yt||De===this.computed)return;let o=this.activeLink;if(o===void 0||o.sub!==De)o=this.activeLink=new Bc(De,this),De.deps?(o.prevDep=De.depsTail,De.depsTail.nextDep=o,De.depsTail=o):De.deps=De.depsTail=o,ga(o);else if(o.version===-1&&(o.version=this.version,o.nextDep)){const n=o.nextDep;n.prevDep=o.prevDep,o.prevDep&&(o.prevDep.nextDep=n),o.prevDep=De.depsTail,o.nextDep=void 0,De.depsTail.nextDep=o,De.depsTail=o,De.deps===o&&(De.deps=n)}return De.onTrack&&De.onTrack(Ve({effect:De},t)),o}trigger(t){this.version++,ao++,this.notify(t)}notify(t){as();try{for(let o=this.subsHead;o;o=o.nextSub)o.sub.onTrigger&&!(o.sub.flags&8)&&o.sub.onTrigger(Ve({effect:o.sub},t));for(let o=this.subs;o;o=o.prevSub)o.sub.notify()&&o.sub.dep.notify()}finally{ls()}}}function ga(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let n=t.deps;n;n=n.nextDep)ga(n)}const o=e.dep.subs;o!==e&&(e.prevSub=o,o&&(o.nextSub=e)),e.dep.subsHead===void 0&&(e.dep.subsHead=e),e.dep.subs=e}}const Rr=new WeakMap,hn=Symbol("Object iterate"),Or=Symbol("Map keys iterate"),lo=Symbol("Array iterate");function Qe(e,t,o){if(yt&&De){let n=Rr.get(e);n||Rr.set(e,n=new Map);let r=n.get(o);r||(n.set(o,r=new us),r.map=n,r.key=o),r.track({target:e,type:t,key:o})}}function It(e,t,o,n,r,s){const i=Rr.get(e);if(!i){ao++;return}const a=l=>{l&&l.trigger({target:e,type:t,key:o,newValue:n,oldValue:r,oldTarget:s})};if(as(),t==="clear")i.forEach(a);else{const l=ge(e),f=l&&ss(o);if(l&&o==="length"){const c=Number(n);i.forEach((u,m)=>{(m==="length"||m===lo||!_t(m)&&m>=c)&&a(u)})}else switch((o!==void 0||i.has(void 0))&&a(i.get(o)),f&&a(i.get(lo)),t){case"add":l?f&&a(i.get("length")):(a(i.get(hn)),mn(e)&&a(i.get(Or)));break;case"delete":l||(a(i.get(hn)),mn(e)&&a(i.get(Or)));break;case"set":mn(e)&&a(i.get(hn));break}}ls()}function Cn(e){const t=ke(e);return t===e?t:(Qe(t,"iterate",lo),it(e)?t:t.map(St))}function er(e){return Qe(e=ke(e),"iterate",lo),e}function Rt(e,t){return Ct(e)?jn(nn(e)?St(t):t):St(t)}const Fc={__proto__:null,[Symbol.iterator](){return pr(this,Symbol.iterator,e=>Rt(this,e))},concat(...e){return Cn(this).concat(...e.map(t=>ge(t)?Cn(t):t))},entries(){return pr(this,"entries",e=>(e[1]=Rt(this,e[1]),e))},every(e,t){return Ut(this,"every",e,t,void 0,arguments)},filter(e,t){return Ut(this,"filter",e,t,o=>o.map(n=>Rt(this,n)),arguments)},find(e,t){return Ut(this,"find",e,t,o=>Rt(this,o),arguments)},findIndex(e,t){return Ut(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Ut(this,"findLast",e,t,o=>Rt(this,o),arguments)},findLastIndex(e,t){return Ut(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Ut(this,"forEach",e,t,void 0,arguments)},includes(...e){return mr(this,"includes",e)},indexOf(...e){return mr(this,"indexOf",e)},join(e){return Cn(this).join(e)},lastIndexOf(...e){return mr(this,"lastIndexOf",e)},map(e,t){return Ut(this,"map",e,t,void 0,arguments)},pop(){return Wn(this,"pop")},push(...e){return Wn(this,"push",e)},reduce(e,...t){return Bs(this,"reduce",e,t)},reduceRight(e,...t){return Bs(this,"reduceRight",e,t)},shift(){return Wn(this,"shift")},some(e,t){return Ut(this,"some",e,t,void 0,arguments)},splice(...e){return Wn(this,"splice",e)},toReversed(){return Cn(this).toReversed()},toSorted(e){return Cn(this).toSorted(e)},toSpliced(...e){return Cn(this).toSpliced(...e)},unshift(...e){return Wn(this,"unshift",e)},values(){return pr(this,"values",e=>Rt(this,e))}};function pr(e,t,o){const n=er(e),r=n[t]();return n!==e&&!it(e)&&(r._next=r.next,r.next=()=>{const s=r._next();return s.done||(s.value=o(s.value)),s}),r}const Vc=Array.prototype;function Ut(e,t,o,n,r,s){const i=er(e),a=i!==e&&!it(e),l=i[t];if(l!==Vc[t]){const u=l.apply(e,s);return a?St(u):u}let f=o;i!==e&&(a?f=function(u,m){return o.call(this,Rt(e,u),m,e)}:o.length>2&&(f=function(u,m){return o.call(this,u,m,e)}));const c=l.call(i,f,n);return a&&r?r(c):c}function Bs(e,t,o,n){const r=er(e),s=r!==e&&!it(e);let i=o,a=!1;r!==e&&(s?(a=n.length===0,i=function(f,c,u){return a&&(a=!1,f=Rt(e,f)),o.call(this,f,Rt(e,c),u,e)}):o.length>3&&(i=function(f,c,u){return o.call(this,f,c,u,e)}));const l=r[t](i,...n);return a?Rt(e,l):l}function mr(e,t,o){const n=ke(e);Qe(n,"iterate",lo);const r=n[t](...o);return(r===-1||r===!1)&&Lo(o[0])?(o[0]=ke(o[0]),n[t](...o)):r}function Wn(e,t,o=[]){wt(),as();const n=ke(e)[t].apply(e,o);return ls(),kt(),n}const Gc=$t("__proto__,__v_isRef,__isVue"),ba=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(_t));function Wc(e){_t(e)||(e=String(e));const t=ke(this);return Qe(t,"has",e),t.hasOwnProperty(e)}class va{constructor(t=!1,o=!1){this._isReadonly=t,this._isShallow=o}get(t,o,n){if(o==="__v_skip")return t.__v_skip;const r=this._isReadonly,s=this._isShallow;if(o==="__v_isReactive")return!r;if(o==="__v_isReadonly")return r;if(o==="__v_isShallow")return s;if(o==="__v_raw")return n===(r?s?Ca:ka:s?wa:xa).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;const i=ge(t);if(!r){let l;if(i&&(l=Fc[o]))return l;if(o==="hasOwnProperty")return Wc}const a=Reflect.get(t,o,Fe(t)?t:n);if((_t(o)?ba.has(o):Gc(o))||(r||Qe(t,"get",o),s))return a;if(Fe(a)){const l=i&&ss(o)?a:a.value;return r&&Me(l)?Pr(l):l}return Me(a)?r?Pr(a):nr(a):a}}class ya extends va{constructor(t=!1){super(!1,t)}set(t,o,n,r){let s=t[o];const i=ge(t)&&ss(o);if(!this._isShallow){const f=Ct(s);if(!it(n)&&!Ct(n)&&(s=ke(s),n=ke(n)),!i&&Fe(s)&&!Fe(n))return f?(xt(`Set operation on key "${String(o)}" failed: target is readonly.`,t[o]),!0):(s.value=n,!0)}const a=i?Number(o)e,xo=e=>Reflect.getPrototypeOf(e);function Qc(e,t,o){return function(...n){const r=this.__v_raw,s=ke(r),i=mn(s),a=e==="entries"||e===Symbol.iterator&&i,l=e==="keys"&&i,f=r[e](...n),c=o?Ir:t?jn:St;return!t&&Qe(s,"iterate",l?Or:hn),Ve(Object.create(f),{next(){const{value:u,done:m}=f.next();return m?{value:u,done:m}:{value:a?[c(u[0]),c(u[1])]:c(u),done:m}}})}}function wo(e){return function(...t){{const o=t[0]?`on key "${t[0]}" `:"";xt(`${yn(e)} operation ${o}failed: target is readonly.`,ke(this))}return e==="delete"?!1:e==="clear"?void 0:this}}function Jc(e,t){const o={get(r){const s=this.__v_raw,i=ke(s),a=ke(r);e||(Ot(r,a)&&Qe(i,"get",r),Qe(i,"get",a));const{has:l}=xo(i),f=t?Ir:e?jn:St;if(l.call(i,r))return f(s.get(r));if(l.call(i,a))return f(s.get(a));s!==i&&s.get(r)},get size(){const r=this.__v_raw;return!e&&Qe(ke(r),"iterate",hn),r.size},has(r){const s=this.__v_raw,i=ke(s),a=ke(r);return e||(Ot(r,a)&&Qe(i,"has",r),Qe(i,"has",a)),r===a?s.has(r):s.has(r)||s.has(a)},forEach(r,s){const i=this,a=i.__v_raw,l=ke(a),f=t?Ir:e?jn:St;return!e&&Qe(l,"iterate",hn),a.forEach((c,u)=>r.call(s,f(c),f(u),i))}};return Ve(o,e?{add:wo("add"),set:wo("set"),delete:wo("delete"),clear:wo("clear")}:{add(r){const s=ke(this),i=xo(s),a=ke(r),l=!t&&!it(r)&&!Ct(r)?a:r;return i.has.call(s,l)||Ot(r,l)&&i.has.call(s,r)||Ot(a,l)&&i.has.call(s,a)||(s.add(l),It(s,"add",l,l)),this},set(r,s){!t&&!it(s)&&!Ct(s)&&(s=ke(s));const i=ke(this),{has:a,get:l}=xo(i);let f=a.call(i,r);f?Fs(i,a,r):(r=ke(r),f=a.call(i,r));const c=l.call(i,r);return i.set(r,s),f?Ot(s,c)&&It(i,"set",r,s,c):It(i,"add",r,s),this},delete(r){const s=ke(this),{has:i,get:a}=xo(s);let l=i.call(s,r);l?Fs(s,i,r):(r=ke(r),l=i.call(s,r));const f=a?a.call(s,r):void 0,c=s.delete(r);return l&&It(s,"delete",r,void 0,f),c},clear(){const r=ke(this),s=r.size!==0,i=mn(r)?new Map(r):new Set(r),a=r.clear();return s&&It(r,"clear",void 0,void 0,i),a}}),["keys","values","entries",Symbol.iterator].forEach(r=>{o[r]=Qc(r,e,t)}),o}function tr(e,t){const o=Jc(e,t);return(n,r,s)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?n:Reflect.get(Pe(o,r)&&r in n?o:n,r,s)}const Yc={get:tr(!1,!1)},Xc={get:tr(!1,!0)},Zc={get:tr(!0,!1)},eu={get:tr(!0,!0)};function Fs(e,t,o){const n=ke(o);if(n!==o&&t.call(e,n)){const r=rs(e);xt(`Reactive ${r} contains both the raw and reactive versions of the same object${r==="Map"?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}const xa=new WeakMap,wa=new WeakMap,ka=new WeakMap,Ca=new WeakMap;function tu(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function nu(e){return e.__v_skip||!Object.isExtensible(e)?0:tu(rs(e))}function nr(e){return Ct(e)?e:or(e,!1,Kc,Yc,xa)}function Sa(e){return or(e,!1,qc,Xc,wa)}function Pr(e){return or(e,!0,zc,Zc,ka)}function Mt(e){return or(e,!0,$c,eu,Ca)}function or(e,t,o,n,r){if(!Me(e))return xt(`value cannot be made ${t?"readonly":"reactive"}: ${String(e)}`),e;if(e.__v_raw&&!(t&&e.__v_isReactive))return e;const s=nu(e);if(s===0)return e;const i=r.get(e);if(i)return i;const a=new Proxy(e,s===2?n:o);return r.set(e,a),a}function nn(e){return Ct(e)?nn(e.__v_raw):!!(e&&e.__v_isReactive)}function Ct(e){return!!(e&&e.__v_isReadonly)}function it(e){return!!(e&&e.__v_isShallow)}function Lo(e){return e?!!e.__v_raw:!1}function ke(e){const t=e&&e.__v_raw;return t?ke(t):e}function ou(e){return!Pe(e,"__v_skip")&&Object.isExtensible(e)&&jo(e,"__v_skip",!0),e}const St=e=>Me(e)?nr(e):e,jn=e=>Me(e)?Pr(e):e;function Fe(e){return e?e.__v_isRef===!0:!1}function se(e){return Ta(e,!1)}function ru(e){return Ta(e,!0)}function Ta(e,t){return Fe(e)?e:new su(e,t)}class su{constructor(t,o){this.dep=new us,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=o?t:ke(t),this._value=o?t:St(t),this.__v_isShallow=o}get value(){return this.dep.track({target:this,type:"get",key:"value"}),this._value}set value(t){const o=this._rawValue,n=this.__v_isShallow||it(t)||Ct(t);t=n?t:ke(t),Ot(t,o)&&(this._rawValue=t,this._value=n?t:St(t),this.dep.trigger({target:this,type:"set",key:"value",newValue:t,oldValue:o}))}}function tn(e){return Fe(e)?e.value:e}function iu(e){return ve(e)?e():tn(e)}const au={get:(e,t,o)=>t==="__v_raw"?e:tn(Reflect.get(e,t,o)),set:(e,t,o,n)=>{const r=e[t];return Fe(r)&&!Fe(o)?(r.value=o,!0):Reflect.set(e,t,o,n)}};function Ea(e){return nn(e)?e:new Proxy(e,au)}class lu{constructor(t,o,n){this.fn=t,this.setter=o,this._value=void 0,this.dep=new us(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=ao-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!o,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&De!==this)return da(this,!0),!0}get value(){const t=this.dep.track({target:this,type:"get",key:"value"});return ma(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter?this.setter(t):xt("Write operation failed: computed value is readonly")}}function cu(e,t,o=!1){let n,r;ve(e)?n=e:(n=e.get,r=e.set);const s=new lu(n,r,o);return t&&!o&&(s.onTrack=t.onTrack,s.onTrigger=t.onTrigger),s}const ko={},Do=new WeakMap;let fn;function uu(e,t=!1,o=fn){if(o){let n=Do.get(o);n||Do.set(o,n=[]),n.push(e)}else t||xt("onWatcherCleanup() was called when there was no active watcher to associate with.")}function du(e,t,o=Ne){const{immediate:n,deep:r,once:s,scheduler:i,augmentJob:a,call:l}=o,f=J=>{(o.onWarn||xt)("Invalid watch source: ",J,"A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.")},c=J=>r?J:it(J)||r===!1||r===0?zt(J,1):zt(J);let u,m,g,w,p=!1,C=!1;if(Fe(e)?(m=()=>e.value,p=it(e)):nn(e)?(m=()=>c(e),p=!0):ge(e)?(C=!0,p=e.some(J=>nn(J)||it(J)),m=()=>e.map(J=>{if(Fe(J))return J.value;if(nn(J))return c(J);if(ve(J))return l?l(J,2):J();f(J)})):ve(e)?t?m=l?()=>l(e,2):e:m=()=>{if(g){wt();try{g()}finally{kt()}}const J=fn;fn=u;try{return l?l(e,3,[w]):e(w)}finally{fn=J}}:(m=et,f(e)),t&&r){const J=m,de=r===!0?1/0:r;m=()=>zt(J(),de)}const V=Hc(),j=()=>{u.stop(),V&&V.active&&ns(V.effects,u)};if(s&&t){const J=t;t=(...de)=>{J(...de),j()}}let N=C?new Array(e.length).fill(ko):ko;const oe=J=>{if(!(!(u.flags&1)||!u.dirty&&!J))if(t){const de=u.run();if(r||p||(C?de.some((z,U)=>Ot(z,N[U])):Ot(de,N))){g&&g();const z=fn;fn=u;try{const U=[de,N===ko?void 0:C&&N[0]===ko?[]:N,w];N=de,l?l(t,3,U):t(...U)}finally{fn=z}}}else u.run()};return a&&a(oe),u=new ca(m),u.scheduler=i?()=>i(oe,!1):oe,w=J=>uu(J,!1,u),g=u.onStop=()=>{const J=Do.get(u);if(J){if(l)l(J,4);else for(const de of J)de();Do.delete(u)}},u.onTrack=o.onTrack,u.onTrigger=o.onTrigger,t?n?oe(!0):N=u.run():i?i(oe.bind(null,!0),!0):u.run(),j.pause=u.pause.bind(u),j.resume=u.resume.bind(u),j.stop=j,j}function zt(e,t=1/0,o){if(t<=0||!Me(e)||e.__v_skip||(o=o||new Map,(o.get(e)||0)>=t))return e;if(o.set(e,t),t--,Fe(e))zt(e.value,t,o);else if(ge(e))for(let n=0;n{zt(n,t,o)});else if(sa(e)){for(const n in e)zt(e[n],t,o);for(const n of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,n)&&zt(e[n],t,o)}return e}/** +* @vue/runtime-core v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const gn=[];function Eo(e){gn.push(e)}function Ao(){gn.pop()}let hr=!1;function Z(e,...t){if(hr)return;hr=!0,wt();const o=gn.length?gn[gn.length-1].component:null,n=o&&o.appContext.config.warnHandler,r=fu();if(n)Un(n,o,11,[e+t.map(s=>{var i,a;return(a=(i=s.toString)==null?void 0:i.call(s))!=null?a:JSON.stringify(s)}).join(""),o&&o.proxy,r.map(({vnode:s})=>`at <${_o(o,s.type)}>`).join(` +`),r]);else{const s=[`[Vue warn]: ${e}`,...t];r.length&&s.push(` +`,...pu(r)),console.warn(...s)}kt(),hr=!1}function fu(){let e=gn[gn.length-1];if(!e)return[];const t=[];for(;e;){const o=t[0];o&&o.vnode===e?o.recurseCount++:t.push({vnode:e,recurseCount:0});const n=e.component&&e.component.parent;e=n&&n.vnode}return t}function pu(e){const t=[];return e.forEach((o,n)=>{t.push(...n===0?[]:[` +`],...mu(o))}),t}function mu({vnode:e,recurseCount:t}){const o=t>0?`... (${t} recursive calls)`:"",n=e.component?e.component.parent==null:!1,r=` at <${_o(e.component,e.type,n)}`,s=">"+o;return e.props?[r,...hu(e.props),s]:[r+s]}function hu(e){const t=[],o=Object.keys(e);return o.slice(0,3).forEach(n=>{t.push(...Aa(n,e[n]))}),o.length>3&&t.push(" ..."),t}function Aa(e,t,o){return He(t)?(t=JSON.stringify(t),o?t:[`${e}=${t}`]):typeof t=="number"||typeof t=="boolean"||t==null?o?t:[`${e}=${t}`]:Fe(t)?(t=Aa(e,ke(t.value),!0),o?t:[`${e}=Ref<`,t,">"]):ve(t)?[`${e}=fn${t.name?`<${t.name}>`:""}`]:(t=ke(t),o?t:[`${e}=`,t])}const ds={sp:"serverPrefetch hook",bc:"beforeCreate hook",c:"created hook",bm:"beforeMount hook",m:"mounted hook",bu:"beforeUpdate hook",u:"updated",bum:"beforeUnmount hook",um:"unmounted hook",a:"activated hook",da:"deactivated hook",ec:"errorCaptured hook",rtc:"renderTracked hook",rtg:"renderTriggered hook",0:"setup function",1:"render function",2:"watcher getter",3:"watcher callback",4:"watcher cleanup function",5:"native event handler",6:"component event handler",7:"vnode hook",8:"directive hook",9:"transition hook",10:"app errorHandler",11:"app warnHandler",12:"ref function",13:"async component loader",14:"scheduler flush",15:"component update",16:"app unmount cleanup function"};function Un(e,t,o,n){try{return n?e(...n):e()}catch(r){go(r,t,o)}}function Lt(e,t,o,n){if(ve(e)){const r=Un(e,t,o,n);return r&&os(r)&&r.catch(s=>{go(s,t,o)}),r}if(ge(e)){const r=[];for(let s=0;s>>1,r=st[n],s=co(r);s=co(o)?st.push(e):st.splice(vu(t),0,e),e.flags|=1,Ia()}}function Ia(){No||(No=Ra.then(ja))}function Pa(e){ge(e)?In.push(...e):Zt&&e.id===-1?Zt.splice(An+1,0,e):e.flags&1||(In.push(e),e.flags|=1),Ia()}function Vs(e,t,o=At+1){for(t=t||new Map;oco(o)-co(n));if(In.length=0,Zt){Zt.push(...t);return}for(Zt=t,e=e||new Map,An=0;Ane.id==null?e.flags&2?-1:1/0:e.id;function ja(e){e=e||new Map;const t=o=>fs(e,o);try{for(At=0;Atbu){const n=t.i,r=n&&ws(n.type);return go(`Maximum recursive updates exceeded${r?` in component <${r}>`:""}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`,null,10),!0}return e.set(t,o+1),!1}let jt=!1;const Ro=new Map;ho().__VUE_HMR_RUNTIME__={createRecord:gr(La),rerender:gr(xu),reload:gr(wu)};const _n=new Map;function yu(e){const t=e.type.__hmrId;let o=_n.get(t);o||(La(t,e.type),o=_n.get(t)),o.instances.add(e)}function _u(e){_n.get(e.type.__hmrId).instances.delete(e)}function La(e,t){return _n.has(e)?!1:(_n.set(e,{initialDef:Ho(t),instances:new Set}),!0)}function Ho(e){return xl(e)?e.__vccOpts:e}function xu(e,t){const o=_n.get(e);o&&(o.initialDef.render=t,[...o.instances].forEach(n=>{t&&(n.render=t,Ho(n.type).render=t),n.renderCache=[],jt=!0,n.job.flags&8||n.update(),jt=!1}))}function wu(e,t){const o=_n.get(e);if(!o)return;t=Ho(t),Gs(o.initialDef,t);const n=[...o.instances];for(let r=0;r{s.job.flags&8||(jt=!0,s.parent.update(),jt=!1,a.delete(s))}):s.appContext.reload?s.appContext.reload():typeof window<"u"?window.location.reload():console.warn("[HMR] Root or manually mounted instance modified. Full reload required."),s.root.ce&&s!==s.root&&s.root.ce._removeChildStyle(i)}Pa(()=>{Ro.clear()})}function Gs(e,t){Ve(e,t);for(const o in e)o!=="__file"&&!(o in t)&&delete e[o]}function gr(e){return(t,o)=>{try{return e(t,o)}catch(n){console.error(n),console.warn("[HMR] Something went wrong during Vue component hot-reload. Full reload required.")}}}let Pt,Xn=[],Mr=!1;function bo(e,...t){Pt?Pt.emit(e,...t):Mr||Xn.push({event:e,args:t})}function Da(e,t){var o,n;Pt=e,Pt?(Pt.enabled=!0,Xn.forEach(({event:r,args:s})=>Pt.emit(r,...s)),Xn=[]):typeof window<"u"&&window.HTMLElement&&!((n=(o=window.navigator)==null?void 0:o.userAgent)!=null&&n.includes("jsdom"))?((t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(s=>{Da(s,t)}),setTimeout(()=>{Pt||(t.__VUE_DEVTOOLS_HOOK_REPLAY__=null,Mr=!0,Xn=[])},3e3)):(Mr=!0,Xn=[])}function ku(e,t){bo("app:init",e,t,{Fragment:ee,Text:vo,Comment:ut,Static:ro})}function Cu(e){bo("app:unmount",e)}const Su=ps("component:added"),Na=ps("component:updated"),Tu=ps("component:removed"),Eu=e=>{Pt&&typeof Pt.cleanupBuffer=="function"&&!Pt.cleanupBuffer(e)&&Tu(e)};function ps(e){return t=>{bo(e,t.appContext.app,t.uid,t.parent?t.parent.uid:void 0,t)}}const Au=Ha("perf:start"),Ru=Ha("perf:end");function Ha(e){return(t,o,n)=>{bo(e,t.appContext.app,t.uid,t,o,n)}}function Ou(e,t,o){bo("component:emit",e.appContext.app,e,t,o)}let We=null,Ua=null;function Uo(e){const t=We;return We=e,Ua=e&&e.type.__scopeId||null,t}function A(e,t=We,o){if(!t||e._n)return e;const n=(...r)=>{n._d&&Wo(-1);const s=Uo(t);let i;try{i=e(...r)}finally{Uo(s),n._d&&Wo(1)}return Na(t),i};return n._n=!0,n._c=!0,n._d=!0,n}function Ba(e){_c(e)&&Z("Do not use built-in directive ids as custom directive id: "+e)}function Rn(e,t){if(We===null)return Z("withDirectives can only be used inside render functions."),e;const o=ar(We),n=e.dirs||(e.dirs=[]);for(let r=0;r1)return o&&ve(t)?t.call(n&&n.proxy):t;Z(`injection "${String(e)}" not found.`)}else Z("inject() can only be used inside setup() or functional components.")}function Iu(){return!!(Bn()||vn)}const Pu=Symbol.for("v-scx"),Mu=()=>{{const e=gt(Pu);return e||Z("Server rendering context not provided. Make sure to only call useSSRContext() conditionally in the server build."),e}};function Fa(e,t){return ms(e,null,t)}function qe(e,t,o){return ve(t)||Z("`watch(fn, options?)` signature has been moved to a separate API. Use `watchEffect(fn, options?)` instead. `watch` now only supports `watch(source, cb, options?) signature."),ms(e,t,o)}function ms(e,t,o=Ne){const{immediate:n,deep:r,flush:s,once:i}=o;t||(n!==void 0&&Z('watch() "immediate" option is only respected when using the watch(source, callback, options?) signature.'),r!==void 0&&Z('watch() "deep" option is only respected when using the watch(source, callback, options?) signature.'),i!==void 0&&Z('watch() "once" option is only respected when using the watch(source, callback, options?) signature.'));const a=Ve({},o);a.onWarn=Z;const l=t&&n||!t&&s!=="post";let f;if(fo){if(s==="sync"){const g=Mu();f=g.__watcherHandles||(g.__watcherHandles=[])}else if(!l){const g=()=>{};return g.stop=et,g.resume=et,g.pause=et,g}}const c=Ge;a.call=(g,w,p)=>Lt(g,c,w,p);let u=!1;s==="post"?a.scheduler=g=>{at(g,c&&c.suspense)}:s!=="sync"&&(u=!0,a.scheduler=(g,w)=>{w?g():rr(g)}),a.augmentJob=g=>{t&&(g.flags|=4),u&&(g.flags|=2,c&&(g.id=c.uid,g.i=c))};const m=du(e,t,a);return fo&&(f?f.push(m):l&&m()),m}function ju(e,t,o){const n=this.proxy,r=He(e)?e.includes(".")?Va(n,e):()=>n[e]:e.bind(n,n);let s;ve(t)?s=t:(s=t.handler,o=t);const i=yo(this),a=ms(r,s.bind(n),o);return i(),a}function Va(e,t){const o=t.split(".");return()=>{let n=e;for(let r=0;re.__isTeleport,Nu=Symbol("_leaveCb");function hs(e,t){e.shapeFlag&6&&e.component?(e.transition=t,hs(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Ga(e,t){return ve(e)?Ve({name:e.name},t,{setup:e}):e}function Wa(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}const Ws=new WeakSet;function Ks(e,t){let o;return!!((o=Object.getOwnPropertyDescriptor(e,t))&&!o.configurable)}const Bo=new WeakMap;function oo(e,t,o,n,r=!1){if(ge(e)){e.forEach((p,C)=>oo(p,t&&(ge(t)?t[C]:t),o,n,r));return}if(Pn(n)&&!r){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&oo(e,t,o,n.component.subTree);return}const s=n.shapeFlag&4?ar(n.component):n.el,i=r?null:s,{i:a,r:l}=e;if(!a){Z("Missing ref owner context. ref cannot be used on hoisted vnodes. A vnode with ref must be created inside the render function.");return}const f=t&&t.r,c=a.refs===Ne?a.refs={}:a.refs,u=a.setupState,m=ke(u),g=u===Ne?na:p=>(Pe(m,p)&&!Fe(m[p])&&Z(`Template ref "${p}" used on a non-ref value. It will not work in the production build.`),Ws.has(m[p])||Ks(c,p)?!1:Pe(m,p)),w=(p,C)=>!(Ws.has(p)||C&&Ks(c,C));if(f!=null&&f!==l){if(zs(t),He(f))c[f]=null,g(f)&&(u[f]=null);else if(Fe(f)){const p=t;w(f,p.k)&&(f.value=null),p.k&&(c[p.k]=null)}}if(ve(l))Un(l,a,12,[i,c]);else{const p=He(l),C=Fe(l);if(p||C){const V=()=>{if(e.f){const j=p?g(l)?u[l]:c[l]:w(l)||!e.k?l.value:c[e.k];if(r)ge(j)&&ns(j,s);else if(ge(j))j.includes(s)||j.push(s);else if(p)c[l]=[s],g(l)&&(u[l]=c[l]);else{const N=[s];w(l,e.k)&&(l.value=N),e.k&&(c[e.k]=N)}}else p?(c[l]=i,g(l)&&(u[l]=i)):C?(w(l,e.k)&&(l.value=i),e.k&&(c[e.k]=i)):Z("Invalid template ref type:",l,`(${typeof l})`)};if(i){const j=()=>{V(),Bo.delete(e)};j.id=-1,Bo.set(e,j),at(j,o)}else zs(e),V()}else Z("Invalid template ref type:",l,`(${typeof l})`)}}function zs(e){const t=Bo.get(e);t&&(t.flags|=8,Bo.delete(e))}ho().requestIdleCallback;ho().cancelIdleCallback;const Pn=e=>!!e.type.__asyncLoader,gs=e=>e.type.__isKeepAlive;function Ka(e,t){qa(e,"a",t)}function za(e,t){qa(e,"da",t)}function qa(e,t,o=Ge){const n=e.__wdc||(e.__wdc=()=>{let r=o;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(sr(t,n,o),o){let r=o.parent;for(;r&&r.parent;)gs(r.parent.vnode)&&Hu(n,t,o,r),r=r.parent}}function Hu(e,t,o,n){const r=sr(t,e,n,!0);Ln(()=>{ns(n[t],r)},o)}function sr(e,t,o=Ge,n=!1){if(o){const r=o[e]||(o[e]=[]),s=t.__weh||(t.__weh=(...i)=>{wt();const a=yo(o),l=Lt(t,o,e,i);return a(),kt(),l});return n?r.unshift(s):r.push(s),s}else{const r=dn(ds[e].replace(/ hook$/,""));Z(`${r} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). If you are using async setup(), make sure to register lifecycle hooks before the first await statement.`)}}const Qt=e=>(t,o=Ge)=>{(!fo||e==="sp")&&sr(e,(...n)=>t(...n),o)},Uu=Qt("bm"),ct=Qt("m"),Bu=Qt("bu"),Fu=Qt("u"),$a=Qt("bum"),Ln=Qt("um"),Vu=Qt("sp"),Gu=Qt("rtg"),Wu=Qt("rtc");function Ku(e,t=Ge){sr("ec",e,t)}const zu="components";function Qa(e,t){return $u(zu,e,!0,t)||e}const qu=Symbol.for("v-ndc");function $u(e,t,o=!0,n=!1){const r=We||Ge;if(r){const s=r.type;{const a=ws(s,!1);if(a&&(a===t||a===Je(t)||a===yn(Je(t))))return s}const i=qs(r[e]||s[e],t)||qs(r.appContext[e],t);return!i&&n?s:(o&&!i&&Z(`Failed to resolve ${e.slice(0,-1)}: ${t} +If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.`),i)}else Z(`resolve${yn(e.slice(0,-1))} can only be used in render() or setup().`)}function qs(e,t){return e&&(e[t]||e[Je(t)]||e[yn(Je(t))])}function ye(e,t,o,n){let r;const s=o,i=ge(e);if(i||He(e)){const a=i&&nn(e);let l=!1,f=!1;a&&(l=!it(e),f=Ct(e),e=er(e)),r=new Array(e.length);for(let c=0,u=e.length;ct(a,l,void 0,s));else{const a=Object.keys(e);r=new Array(a.length);for(let l=0,f=a.length;l0;return t!=="default"&&(o.name=t),v(),Re(ee,null,[O("slot",o,n)],f?-2:64)}let s=e[t];s&&s.length>1&&(Z("SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template."),s=()=>[]),s&&s._c&&(s._d=!1),v();const i=s&&Ja(s(o)),a=o.key||i&&i.key,l=Re(ee,{key:(a&&!_t(a)?a:`_${t}`)+(!i&&n?"_fb":"")},i||[],i&&e._===1?64:-2);return l.scopeId&&(l.slotScopeIds=[l.scopeId+"-s"]),s&&s._c&&(s._d=!0),l}function Ja(e){return e.some(t=>xn(t)?!(t.type===ut||t.type===ee&&!Ja(t.children)):!0)?e:null}const jr=e=>e?yl(e)?ar(e):jr(e.parent):null,bn=Ve(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>Mt(e.props),$attrs:e=>Mt(e.attrs),$slots:e=>Mt(e.slots),$refs:e=>Mt(e.refs),$parent:e=>jr(e.parent),$root:e=>jr(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Za(e),$forceUpdate:e=>e.f||(e.f=()=>{rr(e.update)}),$nextTick:e=>e.n||(e.n=Oa.bind(e.proxy)),$watch:e=>ju.bind(e)}),bs=e=>e==="_"||e==="$",br=(e,t)=>e!==Ne&&!e.__isScriptSetup&&Pe(e,t),Ya={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:o,setupState:n,data:r,props:s,accessCache:i,type:a,appContext:l}=e;if(t==="__isVue")return!0;if(t[0]!=="$"){const m=i[t];if(m!==void 0)switch(m){case 1:return n[t];case 2:return r[t];case 4:return o[t];case 3:return s[t]}else{if(br(n,t))return i[t]=1,n[t];if(r!==Ne&&Pe(r,t))return i[t]=2,r[t];if(Pe(s,t))return i[t]=3,s[t];if(o!==Ne&&Pe(o,t))return i[t]=4,o[t];Lr&&(i[t]=0)}}const f=bn[t];let c,u;if(f)return t==="$attrs"?(Qe(e.attrs,"get",""),Vo()):t==="$slots"&&Qe(e,"get",t),f(e);if((c=a.__cssModules)&&(c=c[t]))return c;if(o!==Ne&&Pe(o,t))return i[t]=4,o[t];if(u=l.config.globalProperties,Pe(u,t))return u[t];We&&(!He(t)||t.indexOf("__v")!==0)&&(r!==Ne&&bs(t[0])&&Pe(r,t)?Z(`Property ${JSON.stringify(t)} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.`):e===We&&Z(`Property ${JSON.stringify(t)} was accessed during render but is not defined on instance.`))},set({_:e},t,o){const{data:n,setupState:r,ctx:s}=e;return br(r,t)?(r[t]=o,!0):r.__isScriptSetup&&Pe(r,t)?(Z(`Cannot mutate - - + +
diff --git a/ui/src/pages/DatasetDetail.vue b/ui/src/pages/DatasetDetail.vue index 3911cad..6fcb256 100644 --- a/ui/src/pages/DatasetDetail.vue +++ b/ui/src/pages/DatasetDetail.vue @@ -208,6 +208,17 @@ const toggleExtSort = col => toggleSort(col, extSortCol, extSortDir) const chartAccuracy = local => _chartData(local, 'accuracy') const chartRecall = local => _chartData(local, 'avg_retrieve_time_ms', true) const chartTokens = local => _chartData(local, 'avg_context_tokens', true) +// coding datasets (sdebench) report agent metrics (interventions/cost/turns/tokens), not QA metrics +const isCoding = rows => rows.length > 0 && rows.every(r => r.coding) +const codingArm = item => { + const m = (item.memory || '').toLowerCase(); const rn = (item.run_name || '').toLowerCase() + if (m === 'none' || m === 'vanilla' || /(^|[-_])(none|vanilla|full)([-_]|$)/.test(rn)) return 'vanilla' + if (m === 'hscoding' || m === 'hindsight-coding') return 'hindsight' + const info = providerByKey.value[item.memory] + return info?.family ?? item.memory ?? 'memory' +} +const fmtTok = v => v == null ? '—' : (v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(0)+'k' : v) +const sortCoding = rows => [...rows].sort((a,b) => (b.interventions ?? -1) - (a.interventions ?? -1)) const sortIcon = (col, active, dir) => active === col ? (dir === 'asc' ? ' ↑' : ' ↓') : '' const getViewMode = split => splitViewMode.value[split] ?? 'overall' async function setViewMode(split, mode) { @@ -360,8 +371,50 @@ function hasCategoryData(local, split) {