diff --git a/docs/usage.rst b/docs/usage.rst index 374f4d1..618bc9f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1037,6 +1037,22 @@ Check `Go packages and modules `_ for updates. go The name of Go package or module, e.g. ``github.com/caddyserver/caddy/v2/cmd``. +Check opam repository +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +:: + + source = "opam" + +This enables you to check latest package versions in an arbitrary `opam repository ` without the need for the opam command line tool. + +pkg + Name of the opam package + +repo + URL of the repository (optional, the default ``https://opam.ocaml.org`` repository is used if not specified) + +This source supports :ref:`list options`. + Combine others' results ~~~~~~~~~~~~~~~~~~~~~~~ :: diff --git a/nvchecker_source/opam.py b/nvchecker_source/opam.py new file mode 100644 index 0000000..79b7f49 --- /dev/null +++ b/nvchecker_source/opam.py @@ -0,0 +1,71 @@ +# MIT licensed +# Copyright (c) 2024 Daniel Peukert , et al. + +import asyncio +from io import BytesIO +import tarfile +from typing import List + +from nvchecker.api import ( + session, VersionResult, + Entry, AsyncCache, + KeyManager, RichResult +) + +OPAM_REPO_INDEX_URL = "%s/index.tar.gz" +OPAM_VERSION_PATH_PREFIX = "packages/%s/%s." +OPAM_VERSION_PATH_SUFFIX = "/opam" + +OPAM_DEFAULT_REPO = 'https://opam.ocaml.org' +OPAM_DEFAULT_REPO_VERSION_URL = "%s/packages/%s/%s.%s" + +def _decompress_and_list_files(data: bytes) -> List[str]: + # Convert the bytes to a file object and get a list of files + archive = tarfile.open(mode='r', fileobj=BytesIO(data)) + return archive.getnames() + +async def get_files(url: str) -> List[str]: + # Download the file and get its contents + res = await session.get(url) + data = res.body + + # Get the file list of the archive + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, _decompress_and_list_files, data) + +async def get_package_versions(files: List[str], pkg: str) -> List[str]: + # Prepare the filename prefix based on the package name + prefix = OPAM_VERSION_PATH_PREFIX % (pkg , pkg) + + # Only keep opam files that are relevant to the package we're working with + filtered_files = [] + + for filename in files: + if filename.startswith(prefix) and filename.endswith(OPAM_VERSION_PATH_SUFFIX): + filtered_files.append(filename[len(prefix):-1*len(OPAM_VERSION_PATH_SUFFIX)]) + + return filtered_files + +async def get_version( + name: str, conf: Entry, *, + cache: AsyncCache, keymanager: KeyManager, + **kwargs, +): + pkg = conf.get('pkg', name) + repo = conf.get('repo', OPAM_DEFAULT_REPO).rstrip('/') + + # Get the list of files in the repo index (see https://opam.ocaml.org/doc/Manual.html#Repositories for repo structure) + files = await cache.get(OPAM_REPO_INDEX_URL % repo, get_files) # type: ignore + + # Parse the version strings from the file names + raw_versions = await get_package_versions(files, pkg) + + # Convert the version strings into RichResults () + versions = [] + for version in raw_versions: + versions.append(RichResult( + version = version, + # There is no standardised URL scheme, so we only return an URL for the default registry + url = OPAM_DEFAULT_REPO_VERSION_URL % (repo, pkg, pkg, version) if repo == OPAM_DEFAULT_REPO else None, + )) + return versions diff --git a/tests/test_opam.py b/tests/test_opam.py new file mode 100644 index 0000000..85d2500 --- /dev/null +++ b/tests/test_opam.py @@ -0,0 +1,25 @@ +# MIT licensed +# Copyright (c) 2024 Daniel Peukert , et al. + +import pytest +pytestmark = [pytest.mark.asyncio, pytest.mark.needs_net] + +async def test_opam_official(get_version): + assert await get_version("test", { + "source": "opam", + "pkg": "omigrate", + }) == "0.3.2" + +async def test_opam_coq(get_version): + assert await get_version("test", { + "source": "opam", + "repo": "https://coq.inria.fr/opam/released", + "pkg": "coq-abp", + }) == "8.10.0" + +async def test_opam_coq_trailing_slash(get_version): + assert await get_version("test", { + "source": "opam", + "repo": "https://coq.inria.fr/opam/released/", + "pkg": "coq-abp", + }) == "8.10.0"