Initial benchmark suite: 8 graded models + cyberpunk dashboard generator
- prompts/: LFU cache exam + 5-pillar grading rubric - outputs/: 8 model .py outputs (local + cloud baseline) - data/benchmark_history.json: graded results (scores, metrics, bugs, patches) - generate_dashboard.py: builds dashboard.html + pages/*.html from JSON - Dockerfile + DEPLOY.md: Gitea→Coolify deploy (build-step, nginx static) - .gitignore: generated HTML excluded (built on deploy) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
In-Memory Concurrent LFU Cache with Async TTL Eviction & Atomic Transactions.
|
||||
|
||||
Pure Python 3.11+, no external dependencies.
|
||||
|
||||
O(1) average-time get/put via frequency buckets with doubly-linked lists.
|
||||
Dual-layer TTL: lazy on access + background async sweep.
|
||||
Atomic transactions with read-your-own-writes, commit, and rollback.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import (Dict, Generic, Hashable, Optional, Set, TypeVar)
|
||||
|
||||
|
||||
KT = TypeVar('KT', bound=Hashable)
|
||||
VT = TypeVar('VT')
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
class LFUCacheError(Exception):
|
||||
"""Base exception for LFU cache errors."""
|
||||
|
||||
|
||||
class _Node(Generic[KT, VT]):
|
||||
"""Doubly-linked list node holding key, value, frequency, and expiry."""
|
||||
|
||||
__slots__ = ('key', 'value', 'freq', 'expires_at', 'prev', 'next')
|
||||
|
||||
def __init__(self, key: KT, value: VT) -> None:
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.freq: int = 0
|
||||
self.expires_at: Optional[float] = None
|
||||
self.prev: Optional['_Node[KT, VT]'] = None
|
||||
self.next: Optional['_Node[KT, VT]'] = None
|
||||
|
||||
|
||||
class _DLL(Generic[KT, VT]):
|
||||
"""
|
||||
Doubly-linked list with sentinel head/tail.
|
||||
O(1) append (tail/MRU), remove (by reference), and pop_left (head/LRU).
|
||||
"""
|
||||
|
||||
__slots__ = ('_head', '_tail', 'size')
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._head = _Node[KT, VT](_SENTINEL, None) # type: ignore[arg-type]
|
||||
self._tail = _Node[KT, VT](_SENTINEL, None) # type: ignore[arg-type]
|
||||
self._head.next = self._tail
|
||||
self._tail.prev = self._head
|
||||
self.size: int = 0
|
||||
|
||||
def append(self, node: _Node[KT, VT]) -> None:
|
||||
"""Add node to tail (most-recently-used position). O(1)."""
|
||||
last = self._tail.prev
|
||||
last.next = node
|
||||
node.prev = last
|
||||
node.next = self._tail
|
||||
self._tail.prev = node
|
||||
self.size += 1
|
||||
|
||||
def remove(self, node: _Node[KT, VT]) -> None:
|
||||
"""Remove node that must be in this list. O(1)."""
|
||||
node.prev.next = node.next
|
||||
node.next.prev = node.prev
|
||||
node.prev = None
|
||||
node.next = None
|
||||
self.size -= 1
|
||||
|
||||
def pop_left(self) -> Optional[_Node[KT, VT]]:
|
||||
"""Remove and return the head node (LRU position). O(1)."""
|
||||
if self.size == 0:
|
||||
return None
|
||||
node = self._head.next
|
||||
self.remove(node)
|
||||
return node
|
||||
|
||||
|
||||
class Transaction(Generic[KT, VT]):
|
||||
"""
|
||||
Atomic transaction with read-your-own-writes isolation.
|
||||
|
||||
Uncommitted writes are invisible to global cache readers.
|
||||
All local state is discarded on rollback without touching global structures.
|
||||
"""
|
||||
|
||||
def __init__(self, cache: 'LFUCache[KT, VT]') -> None:
|
||||
self._cache = cache
|
||||
self._writes: Dict[KT, VT] = {}
|
||||
self._deletes: Set[KT] = set()
|
||||
self._snapshot: Dict[KT, VT] = {}
|
||||
self._freq_deltas: Dict[KT, int] = {}
|
||||
self._ttls: Dict[KT, Optional[float]] = {}
|
||||
self._committed: bool = False
|
||||
self._rolled_back: bool = False
|
||||
|
||||
def _check_active(self) -> None:
|
||||
if self._committed:
|
||||
raise LFUCacheError("Transaction already committed")
|
||||
if self._rolled_back:
|
||||
raise LFUCacheError("Transaction already rolled back")
|
||||
|
||||
async def get(self, key: KT) -> Optional[VT]:
|
||||
"""Read key with read-your-own-writes and snapshot isolation."""
|
||||
self._check_active()
|
||||
|
||||
if key in self._deletes:
|
||||
return None
|
||||
if key in self._writes:
|
||||
self._freq_deltas[key] = self._freq_deltas.get(key, 0) + 1
|
||||
return self._writes[key]
|
||||
if key in self._snapshot:
|
||||
self._freq_deltas[key] = self._freq_deltas.get(key, 0) + 1
|
||||
return self._snapshot[key]
|
||||
|
||||
async with self._cache._lock:
|
||||
node = self._cache._key_to_node.get(key)
|
||||
if node is None:
|
||||
return None
|
||||
if node.expires_at is not None and node.expires_at <= time.monotonic():
|
||||
self._cache._remove_key(key)
|
||||
return None
|
||||
self._snapshot[key] = node.value
|
||||
|
||||
self._freq_deltas[key] = self._freq_deltas.get(key, 0) + 1
|
||||
return self._snapshot[key]
|
||||
|
||||
async def put(self, key: KT, value: VT,
|
||||
ttl_seconds: Optional[float] = None) -> None:
|
||||
"""Write key within transaction."""
|
||||
self._check_active()
|
||||
self._writes[key] = value
|
||||
self._deletes.discard(key)
|
||||
if ttl_seconds is not None:
|
||||
self._ttls[key] = ttl_seconds if ttl_seconds > 0 else None
|
||||
self._freq_deltas[key] = self._freq_deltas.get(key, 0) + 1
|
||||
|
||||
async def delete(self, key: KT) -> None:
|
||||
"""Mark key for deletion on commit."""
|
||||
self._check_active()
|
||||
self._deletes.add(key)
|
||||
self._writes.pop(key, None)
|
||||
self._snapshot.pop(key, None)
|
||||
self._freq_deltas.pop(key, None)
|
||||
self._ttls.pop(key, None)
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""Atomically apply all pending changes to the global cache."""
|
||||
if self._rolled_back:
|
||||
raise LFUCacheError("Transaction already rolled back")
|
||||
if self._committed:
|
||||
return
|
||||
|
||||
async with self._cache._lock:
|
||||
try:
|
||||
for key in self._deletes:
|
||||
self._cache._remove_key(key)
|
||||
|
||||
for key, value in self._writes.items():
|
||||
node = self._cache._key_to_node.get(key)
|
||||
ttl = self._ttls.get(key)
|
||||
delta = self._freq_deltas.get(key, 1)
|
||||
|
||||
if node is not None:
|
||||
node.value = value
|
||||
if ttl is not None:
|
||||
node.expires_at = (
|
||||
time.monotonic() + ttl if ttl is not None else None
|
||||
)
|
||||
self._cache._change_freq(node, delta)
|
||||
else:
|
||||
while len(self._cache._key_to_node) >= self._cache._capacity:
|
||||
self._cache._evict_one()
|
||||
node = _Node(key, value)
|
||||
node.freq = delta
|
||||
if ttl is not None:
|
||||
node.expires_at = time.monotonic() + ttl
|
||||
self._cache._key_to_node[key] = node
|
||||
self._cache._add_to_freq_list(node)
|
||||
|
||||
for key, delta in self._freq_deltas.items():
|
||||
if key not in self._writes and key not in self._deletes:
|
||||
node = self._cache._key_to_node.get(key)
|
||||
if node is not None:
|
||||
self._cache._change_freq(node, delta)
|
||||
|
||||
while len(self._cache._key_to_node) > self._cache._capacity:
|
||||
self._cache._evict_one()
|
||||
|
||||
self._committed = True
|
||||
except BaseException:
|
||||
self._rolled_back = True
|
||||
raise
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""Discard all pending changes. Global cache is untouched."""
|
||||
if self._committed:
|
||||
raise LFUCacheError("Transaction already committed")
|
||||
self._rolled_back = True
|
||||
|
||||
|
||||
class LFUCache(Generic[KT, VT]):
|
||||
"""
|
||||
In-Memory Concurrent LFU Cache with Async TTL Eviction.
|
||||
|
||||
O(1) average-time get/put using frequency buckets with doubly-linked lists.
|
||||
Dual-layer TTL eviction: lazy on access + background async sweep.
|
||||
"""
|
||||
|
||||
def __init__(self, capacity: int = 1000,
|
||||
evictor_interval: float = 1.0,
|
||||
evictor_batch_size: int = 10,
|
||||
evictor_scan_budget: int = 100) -> None:
|
||||
if capacity < 1:
|
||||
raise ValueError("Capacity must be >= 1")
|
||||
|
||||
self._capacity = capacity
|
||||
self._evictor_interval = evictor_interval
|
||||
self._evictor_batch_size = evictor_batch_size
|
||||
self._evictor_scan_budget = evictor_scan_budget
|
||||
|
||||
self._key_to_node: Dict[KT, _Node[KT, VT]] = {}
|
||||
self._freq_to_list: Dict[int, _DLL[KT, VT]] = {}
|
||||
self._min_freq: int = 0
|
||||
self._lock = asyncio.Lock()
|
||||
self._evictor_running: bool = False
|
||||
self._evictor_task: Optional[asyncio.Task] = None
|
||||
|
||||
# ---- Public API ---------------------------------------------------------
|
||||
|
||||
async def get(self, key: KT) -> Optional[VT]:
|
||||
"""Retrieve value by key. Returns None if missing or expired.
|
||||
|
||||
Accesses increment the key's frequency (LFU tracking).
|
||||
Expired keys are lazily evicted on access.
|
||||
"""
|
||||
async with self._lock:
|
||||
node = self._key_to_node.get(key)
|
||||
if node is None:
|
||||
return None
|
||||
if node.expires_at is not None and node.expires_at <= time.monotonic():
|
||||
self._remove_key(key)
|
||||
return None
|
||||
self._change_freq(node, 1)
|
||||
return node.value
|
||||
|
||||
async def put(self, key: KT, value: VT,
|
||||
ttl_seconds: Optional[float] = None) -> None:
|
||||
"""Insert or update a key-value pair.
|
||||
|
||||
If *ttl_seconds* is None (default) the entry never expires.
|
||||
If *ttl_seconds* is <= 0 it is treated as no expiration.
|
||||
If the cache is at capacity the least-frequently-used item is evicted
|
||||
(tie-broken by least-recently-used within the minimum frequency tier).
|
||||
"""
|
||||
async with self._lock:
|
||||
node = self._key_to_node.get(key)
|
||||
|
||||
if node is not None:
|
||||
if node.expires_at is not None and node.expires_at <= time.monotonic():
|
||||
self._remove_key(key)
|
||||
node = None
|
||||
else:
|
||||
node.value = value
|
||||
if ttl_seconds is not None:
|
||||
node.expires_at = (
|
||||
time.monotonic() + ttl_seconds if ttl_seconds > 0 else None
|
||||
)
|
||||
self._change_freq(node, 1)
|
||||
return
|
||||
|
||||
while len(self._key_to_node) >= self._capacity:
|
||||
self._evict_one()
|
||||
|
||||
node = _Node(key, value)
|
||||
node.freq = 1
|
||||
if ttl_seconds is not None and ttl_seconds > 0:
|
||||
node.expires_at = time.monotonic() + ttl_seconds
|
||||
self._key_to_node[key] = node
|
||||
self._add_to_freq_list(node)
|
||||
|
||||
async def delete(self, key: KT) -> bool:
|
||||
"""Remove *key* from the cache. Returns True if the key existed."""
|
||||
async with self._lock:
|
||||
return self._remove_key(key)
|
||||
|
||||
def begin_transaction(self) -> Transaction[KT, VT]:
|
||||
"""Open an atomic transaction for batched reads/writes."""
|
||||
return Transaction(self)
|
||||
|
||||
@property
|
||||
def capacity(self) -> int:
|
||||
return self._capacity
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self._key_to_node)
|
||||
|
||||
# ---- Background TTL Evictor --------------------------------------------
|
||||
|
||||
def start_evictor(self) -> None:
|
||||
"""Launch the background TTL eviction loop as an asyncio task."""
|
||||
if self._evictor_running:
|
||||
return
|
||||
self._evictor_running = True
|
||||
self._evictor_task = asyncio.create_task(self._evictor_loop())
|
||||
|
||||
async def stop_evictor(self) -> None:
|
||||
"""Cancel and wait for the background eviction task to finish."""
|
||||
if not self._evictor_running:
|
||||
return
|
||||
self._evictor_running = False
|
||||
if self._evictor_task is not None:
|
||||
self._evictor_task.cancel()
|
||||
try:
|
||||
await self._evictor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._evictor_task = None
|
||||
|
||||
async def _evictor_loop(self) -> None:
|
||||
"""
|
||||
Background loop: periodically scan a limited batch of keys and remove
|
||||
expired entries. Releases the lock between batches so concurrent
|
||||
reads/writes are not blocked for extended periods.
|
||||
"""
|
||||
cursor = 0
|
||||
keys: list = []
|
||||
|
||||
try:
|
||||
while self._evictor_running:
|
||||
await asyncio.sleep(self._evictor_interval)
|
||||
|
||||
async with self._lock:
|
||||
if cursor >= len(keys):
|
||||
keys = list(self._key_to_node.keys())
|
||||
cursor = 0
|
||||
if not keys:
|
||||
continue
|
||||
|
||||
now = time.monotonic()
|
||||
removed = 0
|
||||
scan = self._evictor_scan_budget
|
||||
|
||||
while scan > 0 and cursor < len(keys):
|
||||
key = keys[cursor]
|
||||
cursor += 1
|
||||
scan -= 1
|
||||
|
||||
node = self._key_to_node.get(key)
|
||||
if (node is not None and
|
||||
node.expires_at is not None and
|
||||
node.expires_at <= now):
|
||||
self._remove_key(key)
|
||||
removed += 1
|
||||
if removed >= self._evictor_batch_size:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ---- O(1) Internal Helpers ---------------------------------------------
|
||||
|
||||
def _add_to_freq_list(self, node: _Node[KT, VT]) -> None:
|
||||
"""Insert *node* into its frequency bucket and update *min_freq*."""
|
||||
freq = node.freq
|
||||
if freq not in self._freq_to_list:
|
||||
self._freq_to_list[freq] = _DLL()
|
||||
self._freq_to_list[freq].append(node)
|
||||
if self._min_freq not in self._freq_to_list or freq < self._min_freq:
|
||||
self._min_freq = freq
|
||||
|
||||
def _remove_from_freq_list(self, node: _Node[KT, VT]) -> None:
|
||||
"""Remove *node* from its frequency bucket. No-op if not linked."""
|
||||
if node.prev is None or node.next is None:
|
||||
return
|
||||
freq = node.freq
|
||||
lst = self._freq_to_list.get(freq)
|
||||
if lst is None:
|
||||
return
|
||||
lst.remove(node)
|
||||
if lst.size == 0:
|
||||
del self._freq_to_list[freq]
|
||||
|
||||
def _change_freq(self, node: _Node[KT, VT], delta: int) -> None:
|
||||
"""Atomically increase *node*'s frequency by *delta* and
|
||||
move it to the corresponding bucket. O(1)."""
|
||||
if delta <= 0:
|
||||
return
|
||||
self._remove_from_freq_list(node)
|
||||
node.freq += delta
|
||||
self._add_to_freq_list(node)
|
||||
|
||||
def _remove_key(self, key: KT) -> bool:
|
||||
"""Remove *key* from all internal structures. Returns True if existed."""
|
||||
node = self._key_to_node.pop(key, None)
|
||||
if node is None:
|
||||
return False
|
||||
self._remove_from_freq_list(node)
|
||||
return True
|
||||
|
||||
def _evict_one(self) -> Optional[KT]:
|
||||
"""
|
||||
Evict one item: LRU among the minimum-frequency bucket.
|
||||
Returns the evicted key, or None if the cache is empty.
|
||||
"""
|
||||
if not self._key_to_node:
|
||||
return None
|
||||
|
||||
if self._min_freq not in self._freq_to_list:
|
||||
if not self._freq_to_list:
|
||||
return None
|
||||
self._min_freq = min(self._freq_to_list)
|
||||
|
||||
lst = self._freq_to_list.get(self._min_freq)
|
||||
if lst is None or lst.size == 0:
|
||||
return None
|
||||
|
||||
node = lst.pop_left()
|
||||
if node is None:
|
||||
return None
|
||||
|
||||
del self._key_to_node[node.key]
|
||||
if lst.size == 0:
|
||||
del self._freq_to_list[self._min_freq]
|
||||
if self._freq_to_list:
|
||||
self._min_freq = min(self._freq_to_list)
|
||||
|
||||
return node.key
|
||||
|
||||
# ---- Async Context Manager ---------------------------------------------
|
||||
|
||||
async def __aenter__(self) -> 'LFUCache[KT, VT]':
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info) -> None:
|
||||
await self.stop_evictor()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests
|
||||
# ============================================================================
|
||||
|
||||
async def _run_tests() -> None:
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
def check(cond: bool, msg: str):
|
||||
nonlocal passed, total
|
||||
total += 1
|
||||
if cond:
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {msg}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. O(1) LFU eviction order
|
||||
# ------------------------------------------------------------------
|
||||
print("1. LFU eviction order ... ", end="", flush=True)
|
||||
|
||||
cache = LFUCache(capacity=3)
|
||||
|
||||
await cache.put("a", 1)
|
||||
await cache.put("b", 2)
|
||||
await cache.put("c", 3)
|
||||
|
||||
await cache.get("a")
|
||||
await cache.get("a")
|
||||
await cache.get("b")
|
||||
# freqs: a=3, b=2, c=1
|
||||
|
||||
await cache.put("d", 4) # evicts c (freq=1)
|
||||
check(await cache.get("c") is None, "c was evicted (freq=1)")
|
||||
check(await cache.get("d") == 4, "d is present")
|
||||
check(await cache.get("a") == 1, "a is present")
|
||||
check(await cache.get("b") == 2, "b is present")
|
||||
|
||||
# a=4, b=3, d=1 after the gets above
|
||||
await cache.put("e", 5) # evicts d (freq=1)
|
||||
check(await cache.get("d") is None, "d was evicted")
|
||||
check(await cache.get("e") == 5, "e is present")
|
||||
check(cache.size == 3, "cache size == 3")
|
||||
|
||||
print("OK")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. TTL eviction
|
||||
# ------------------------------------------------------------------
|
||||
print("2. TTL eviction ... ", end="", flush=True)
|
||||
|
||||
# 2a. Lazy eviction
|
||||
cache2 = LFUCache(capacity=10)
|
||||
await cache2.put("lazy", "alive", ttl_seconds=0.02)
|
||||
await asyncio.sleep(0.03)
|
||||
check(await cache2.get("lazy") is None, "lazy TTL eviction on get")
|
||||
|
||||
await cache2.put("lazy2", "alive", ttl_seconds=0.02)
|
||||
await asyncio.sleep(0.03)
|
||||
check(await cache2.get("lazy2") is None, "lazy TTL eviction on second get")
|
||||
|
||||
# 2b. Background eviction
|
||||
cache3 = LFUCache(capacity=10, evictor_interval=0.02, evictor_batch_size=5)
|
||||
cache3.start_evictor()
|
||||
await cache3.put("bg", "data", ttl_seconds=0.01)
|
||||
await asyncio.sleep(0.10)
|
||||
check(await cache3.get("bg") is None, "background evictor removes expired key")
|
||||
await cache3.stop_evictor()
|
||||
|
||||
print("OK")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Transactions
|
||||
# ------------------------------------------------------------------
|
||||
print("3. Transactions ... ", end="", flush=True)
|
||||
|
||||
# 3a. Commit
|
||||
cache4 = LFUCache(capacity=10)
|
||||
await cache4.put("x", 10)
|
||||
|
||||
tx = cache4.begin_transaction()
|
||||
await tx.put("x", 20)
|
||||
check(await tx.get("x") == 20, "tx reads its own write")
|
||||
check(await cache4.get("x") == 10, "global does NOT see uncommitted write")
|
||||
await tx.commit()
|
||||
check(await cache4.get("x") == 20, "global sees committed value")
|
||||
|
||||
# 3b. Rollback
|
||||
await cache4.put("y", 100)
|
||||
tx2 = cache4.begin_transaction()
|
||||
await tx2.put("y", 200)
|
||||
await tx2.rollback()
|
||||
check(await cache4.get("y") == 100, "rollback discards writes")
|
||||
|
||||
# 3c. Delete in transaction
|
||||
await cache4.put("z", 300)
|
||||
tx3 = cache4.begin_transaction()
|
||||
await tx3.delete("z")
|
||||
check(await tx3.get("z") is None, "tx sees its own delete")
|
||||
check(await cache4.get("z") == 300, "global not affected before commit")
|
||||
await tx3.commit()
|
||||
check(await cache4.get("z") is None, "global sees committed delete")
|
||||
|
||||
# 3d. Rollback after delete
|
||||
await cache4.put("w", 400)
|
||||
tx4 = cache4.begin_transaction()
|
||||
await tx4.delete("w")
|
||||
await tx4.rollback()
|
||||
check(await cache4.get("w") == 400, "rollback restores deleted key")
|
||||
|
||||
# 3e. Read own writes after rollback raises
|
||||
tx5 = cache4.begin_transaction()
|
||||
await tx5.put("n", 1)
|
||||
await tx5.rollback()
|
||||
try:
|
||||
await tx5.get("n")
|
||||
check(False, "get on rolled-back tx should raise")
|
||||
except LFUCacheError:
|
||||
check(True, "rolled-back tx raises on get")
|
||||
|
||||
print("OK")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Concurrent stress
|
||||
# ------------------------------------------------------------------
|
||||
print("4. Concurrent stress (50 workers) ... ", end="", flush=True)
|
||||
|
||||
cache5 = LFUCache(capacity=50, evictor_interval=0.05, evictor_batch_size=5)
|
||||
cache5.start_evictor()
|
||||
|
||||
async def worker(uid: int):
|
||||
for i in range(30):
|
||||
key = f"k_{(uid + i) % 40}"
|
||||
await cache5.put(key, uid * 1000 + i, ttl_seconds=0.5)
|
||||
_ = await cache5.get(key)
|
||||
await cache5.get(f"k_{(uid + i + 1) % 40}")
|
||||
if i % 5 == 0:
|
||||
await cache5.delete(f"k_{(uid + i + 2) % 40}")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
workers = [worker(i) for i in range(50)]
|
||||
await asyncio.gather(*workers)
|
||||
await cache5.stop_evictor()
|
||||
|
||||
check(cache5.size <= 50, f"cache size {cache5.size} <= capacity 50")
|
||||
print("OK")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
print(f"\nResults: {passed}/{total} checks passed")
|
||||
if passed < total:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await _run_tests()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user