Source code for swh.osv.map_origins

# Copyright (C) 2025  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
import json
from pathlib import Path

import aiohttp
from google.protobuf.field_mask_pb2 import FieldMask
import grpc.aio

import swh.graph.grpc.swhgraph_pb2 as swhgraph
import swh.graph.grpc.swhgraph_pb2_grpc as swhgraph_grpc
from swh.model.model import Origin

from .utils import Cache

GRAPH_GRPC_URL = "localhost:50091"


SWH_API_SEMAPHORE = asyncio.Semaphore(10)
"""Prevents more than 10 concurrent requests to the SWH API"""


SWH_ORIGIN_URLS_BY_ECOSYSTEM = {
    "Ubuntu": {
        "deb://Ubuntu/packages/{package_name}",
        "deb://Ubuntu-Legacy/packages/{package_name}",
    },
    "npm": {"https://www.npmjs.com/package/{package_name}"},
    "PyPI": {"https://pypi.org/project/{package_name}/"},
}


[docs] class InvalidCommitId(ValueError): pass
[docs] class OriginDenormalizer: KNOWN_ORIGINS_CACHE_PATH = Path("./known_origins_urls.json") UNKNOWN_ORIGINS_CACHE_PATH = Path("./unknown_origins_urls.json") def __init__( self, stub: swhgraph_grpc.TraversalServiceStub, session: aiohttp.ClientSession, ): self._stub = stub self._session = session self._denormalization_cache = Cache( Path("./denormalize_urls.json"), dict[str, str](), json.dumps, json.loads ) self._known_origins_cache = Cache( Path("./known_origins_urls.json"), set[str](), lambda data: json.dumps(list(data)), json.loads, ) self._unknown_origins_cache = Cache( Path("./unknown_origins_urls.json"), set[str](), lambda data: json.dumps(list(data)), json.loads, )
[docs] async def load(self) -> None: await self._denormalization_cache.load() await self._known_origins_cache.load() await self._unknown_origins_cache.load()
[docs] async def is_origin_url_in_graph(self, url: str) -> bool: if ( url not in self._known_origins_cache and url not in self._unknown_origins_cache ): swhid = Origin(url=url).swhid() try: await self._stub.GetNode( swhgraph.GetNodeRequest( swhid=str(swhid), mask=FieldMask(paths=["swhid"]) ) ) except grpc.aio.AioRpcError as e: if e.code() == grpc.StatusCode.NOT_FOUND: self._unknown_origins_cache.add(url) await self._unknown_origins_cache.save() else: raise else: self._known_origins_cache.add(url) await self._known_origins_cache.save() if url in self._known_origins_cache: assert ( url not in self._unknown_origins_cache ), f"{url} is both known and unknown" return True else: assert ( url in self._unknown_origins_cache ), f"{url} is neither known nor unknown" return False
def _normalize_origin_url(self, url: str) -> str: if url.startswith("https://pypi.org/project/"): url = url.replace("_", "-").replace(".", "-") return url.lower().removesuffix(".git")
[docs] async def denormalize_origin_url(self, url: str) -> str | None: # most of the time, this succeeds because the URL in SWH matches the URL in OSV if await self.is_origin_url_in_graph(url): return url # simple heuristic that covers ~20% of the missing origins if url.endswith(".git"): if await self.is_origin_url_in_graph(url.removesuffix(".git")): return url.removesuffix(".git") # OSV URL not found in SWH. This is usually because OSV lowercased the URL # while SWH did not. # Use SWH's URL search to try to find the non-lowercase URL if url in self._denormalization_cache: assert url.lower().removesuffix(".git") == self._denormalization_cache[ url ].lower().removesuffix(".git"), ( url.lower(), self._denormalization_cache[url].lower(), ) return self._denormalization_cache[url] if url in self._unknown_origins_cache: # Skip SWH API request, we already tried and it failed. return None url = f"https://archive.softwareheritage.org/api/1/origin/search/{url}?limit=10" for _ in range(10): # retry at most 10 times on errors async with SWH_API_SEMAPHORE: async with self._session.get(url) as r: if 500 <= r.status < 600: print(f"{r.status} error for {url} ; retrying in 10s") await asyncio.sleep( 10 ) # TODO: release the semaphore while sleeping continue for origin in await r.json(): if self._normalize_origin_url( url ) == self._normalize_origin_url(origin["url"]): break else: self._unknown_origins_cache.add(url) await self._unknown_origins_cache.save() return None break # don't do this in the loop in order to release SWH_API_SEMAPHORE as soon as possible self._denormalization_cache[url] = origin["url"] await self._denormalization_cache.save() return origin["url"]