Source code for swh.osv.utils
# Copyright (C) 2025-2026 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import asyncio
from pathlib import Path
import time
from typing import (
Any,
AsyncIterable,
AsyncIterator,
Awaitable,
Callable,
Generic,
Iterable,
TypeVar,
Union,
)
_SENTINEL = object()
"""Object added at the end of a queue to tell workers to stop.
Alternative to ``asyncio.QueueShutDown`` that does not require Python >= 3.13"""
T = TypeVar("T")
U = TypeVar("U")
[docs]
async def iter_queue(queue: asyncio.Queue[T]) -> AsyncIterator[T]:
"""Turns an async queue into an async iterator"""
while True:
item = await queue.get()
if item is _SENTINEL:
return
yield item
[docs]
async def concurrent_async_map(
f: Callable[[T], Awaitable[U]],
it: Union[Iterable[T], AsyncIterable[T]],
max_concurrency: int = 50,
buffer: int = 1000,
) -> AsyncIterator[U]:
"""applies ``f`` to each item in ``it``, with up to ``max_concurrency`` calls
to ``f`` running at the same time."""
work: asyncio.Queue[Any] = asyncio.Queue(maxsize=buffer)
results: asyncio.Queue[Any] = asyncio.Queue(maxsize=buffer)
remaining_workers = max_concurrency
async def worker() -> None:
nonlocal remaining_workers
async for work_item in iter_queue(work):
await results.put(await f(work_item))
remaining_workers -= 1
if remaining_workers == 0:
await results.put(_SENTINEL)
async def feeder():
if hasattr(it, "__aiter__"):
async for work_item in it:
await work.put(work_item)
else:
for work_item in it:
await work.put(work_item)
for _ in range(max_concurrency):
await work.put(_SENTINEL)
async with asyncio.TaskGroup() as tg:
tg.create_task(feeder())
for _ in range(max_concurrency):
tg.create_task(worker())
async for result in iter_queue(results):
yield result
D = TypeVar("D", bound=Union[dict, set])
[docs]
class Cache(Generic[D]):
def __init__(
self,
path: Path,
initial_value: D,
serializer: Callable[[D], str],
deserializer: Callable[[str], D],
):
self._data = initial_value
self._path = path
self._lock = asyncio.Lock()
self._serializer = serializer
self._deserializer = deserializer
self._last_save_ts = 0.0
[docs]
async def load(self) -> None:
async with self._lock:
if self._path.exists():
self._data.update(self._deserializer(self._path.read_text()))
def __contains__(self, key) -> bool:
return key in self._data
def __getitem__(self, key):
return self._data[key]
def __getattr__(self, name):
return getattr(self._data, name)
def __setitem__(self, key, value):
self._data[key] = value
[docs]
async def save(self) -> None:
now = time.time()
if self._last_save_ts + 10.0 > now:
# don't bother saving less than 10s of changes
return
self._last_save_ts = now
data = self._serializer(self._data)
async with self._lock:
self._path.write_text(data)