Skip to content

Commit

Permalink
Add support for Rust Crates
Browse files Browse the repository at this point in the history
- Add a new crate finder
- CLI changes to support it
- Drive-by: Switch vscode formatter to Ruff

The cli.py got attacked by Ruff which normalized all the quotes.
Deal with it.
  • Loading branch information
juledwar committed Oct 1, 2024
1 parent 4bfb2e5 commit f6eeccf
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 28 deletions.
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"[python]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
Expand Down
41 changes: 26 additions & 15 deletions soufi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
except ImportError:
sys.exit("CLI support not installed; please install soufi[cli]")

warnings.formatwarning = lambda msg, *x, **y: f'WARNING: {msg}\n'
warnings.formatwarning = lambda msg, *x, **y: f"WARNING: {msg}\n"

# Configure a small-ish in-memory LRU cache to speed up operations
LRU_CACHE = pylru.lrucache(size=512)
Expand All @@ -37,7 +37,7 @@ def ubuntu(cls, name, version, timeout=None):
name,
version,
finder.SourceType.os,
cache_backend='dogpile.cache.memory',
cache_backend="dogpile.cache.memory",
cache_args=dict(cache_dict=LRU_CACHE),
timeout=timeout,
)
Expand Down Expand Up @@ -80,7 +80,7 @@ def centos(
binary_repos=None,
timeout=None,
):
optimal = 'optimal' in repos
optimal = "optimal" in repos
centos_finder = finder.factory(
"centos",
name=name,
Expand All @@ -90,7 +90,7 @@ def centos(
optimal_repos=optimal,
source_repos=source_repos,
binary_repos=binary_repos,
cache_backend='dogpile.cache.memory',
cache_backend="dogpile.cache.memory",
cache_args=dict(cache_dict=LRU_CACHE),
timeout=timeout,
)
Expand All @@ -104,7 +104,7 @@ def alpine(cls, name, version, aports_dir, timeout=None):
version=version,
s_type=finder.SourceType.os,
aports_dir=aports_dir,
cache_backend='dogpile.cache.memory',
cache_backend="dogpile.cache.memory",
cache_args=dict(cache_dict=LRU_CACHE),
timeout=timeout,
)
Expand Down Expand Up @@ -155,7 +155,7 @@ def photon(
s_type=finder.SourceType.os,
source_repos=source_repos,
binary_repos=binary_repos,
cache_backend='dogpile.cache.memory',
cache_backend="dogpile.cache.memory",
cache_args=dict(cache_dict=LRU_CACHE),
timeout=timeout,
)
Expand All @@ -172,16 +172,27 @@ def rhel(
s_type=finder.SourceType.os,
source_repos=source_repos,
binary_repos=binary_repos,
cache_backend='dogpile.cache.memory',
cache_backend="dogpile.cache.memory",
cache_args=dict(cache_dict=LRU_CACHE),
timeout=timeout,
)
return cls.find(rhel_finder)

@classmethod
def crate(cls, name, version, timeout=None):
crate_finder = finder.factory(
"crate",
name=name,
version=version,
s_type=finder.SourceType.crate,
timeout=timeout,
)
return cls.find(crate_finder)


def make_archive_from_discovery_source(disc_src, fname):
try:
with disc_src.make_archive() as in_fd, open(fname, 'wb') as out_fd:
with disc_src.make_archive() as in_fd, open(fname, "wb") as out_fd:
# copyfileobj copies in chunks, so as not to exhaust memory.
shutil.copyfileobj(in_fd, out_fd)
except exceptions.DownloadError as e:
Expand Down Expand Up @@ -293,23 +304,23 @@ def main(
except AttributeError:
click.echo(f"{distro} not available")
click.get_current_context().exit(255)
if distro == 'alpine' and aports is None:
if distro == "alpine" and aports is None:
click.echo("Must provide --aports for Alpine")
click.get_current_context().exit(255)
try:
if distro == 'python':
if distro == "python":
disc_source = func(pyindex=pyindex)
elif distro == 'alpine':
elif distro == "alpine":
disc_source = func(aports_dir=aports)
elif distro == 'centos':
elif distro == "centos":
disc_source = func(
repos=repo, source_repos=source_repo, binary_repos=binary_repo
)
elif distro in ('photon', 'rhel'):
elif distro in ("photon", "rhel"):
disc_source = func(
source_repos=source_repo, binary_repos=binary_repo
)
elif distro == 'go':
elif distro == "go":
disc_source = func(goproxy=goproxy)
else:
disc_source = func()
Expand All @@ -320,7 +331,7 @@ def main(
if auto_output is not False or output is not None:
fname = output
if auto_output:
name = name.replace(os.sep, '.')
name = name.replace(os.sep, ".")
fname = f"{name}-{version}.{distro}{disc_source.archive_extension}"
make_archive_from_discovery_source(disc_source, fname)

Expand Down
1 change: 1 addition & 0 deletions soufi/finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class SourceType(enum.Enum):
java = "java"
go = "go"
nuget = "nuget"
crate = "crate"


@enum.unique
Expand Down
73 changes: 73 additions & 0 deletions soufi/finders/crate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright (c) 2024 Cisco Systems, Inc. and its affiliates
# All rights reserved.


from soufi import exceptions, finder

DEFAULT_INDEX = "https://index.crates.io/"


class CrateFinder(finder.SourceFinder):
"""Find Rust Crates.
Traverses the supplied index, defaulting to the one at index.crates.io.
:param index: optional index server; defaults to
https://index.crates.io/
"""

distro = finder.SourceType.crate.value

def __init__(self, *args, **kwargs):
self.index = kwargs.pop("index", DEFAULT_INDEX)
if self.index[-1] != "/":
self.index += "/"
super().__init__(*args, **kwargs)

def _find(self):
source_url = self.get_source_url()
return CrateDiscoveredSource([source_url], timeout=self.timeout)

def get_source_url(self):
"""Examine the index to find the source URL for the package.
This is simply a matter of using the index's API to do a package query,
and returning a computed URL for any exact match.
Note: the Create index server's API does not do exact matches itself,
so we need to iterate the results.
"""
dl = self.get_index_dl()
# This is a bit of a short cut and is assuming a particular file format
# for each crate. Nominally this is usually the case but we might
# need to revist.
url = f"{dl}/{self.name}/{self.name}-{self.version}.crate"
if self.test_url(url):
return url
raise exceptions.SourceNotFound

def get_index_dl(self):
"""Return the 'dl' value from the config.json at the index root."""
# 'dl' is the URL prefix from which all downloads are made.
config = self.get_url(f"{self.index}config.json").json()
try:
return config["dl"]
except KeyError:
raise exceptions.DownloadError(
"Index is corrupt: No 'dl' key in index config.json"
)


class CrateDiscoveredSource(finder.DiscoveredSource):
"""A discovered Rust Crate package."""

make_archive = finder.DiscoveredSource.remote_url_is_archive
archive_extension = "" # .crate is part of the URL

def populate_archive(self, *args, **kwargs): # pragma: no cover
# Required by the base class but Crates are already tarballs so
# nothing to do.
pass

def __repr__(self):
return self.urls[0]
94 changes: 94 additions & 0 deletions soufi/tests/finders/test_crate_finder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright (c) 2024 Cisco Systems, Inc. and its affiliates
# All rights reserved.

from unittest import mock

import requests

from soufi import exceptions
from soufi.finder import SourceType
from soufi.finders import crate
from soufi.testing import base


class TestPythonFinder(base.TestCase):
def make_finder(self, name=None, version=None, index=None):
if name is None:
name = self.factory.make_string("name")
if version is None:
version = self.factory.make_string("version")
kwargs = dict(name=name, version=version, s_type=SourceType.crate)
if index is not None:
kwargs["index"] = index
return crate.CrateFinder(**kwargs)

def test_get_source_url(self):
finder = self.make_finder()
get_index_dl = self.patch(finder, "get_index_dl")
index_dl = self.factory.make_url()
get_index_dl.return_value = index_dl

get = self.patch_head_with_response(requests.codes.ok)
found_url = finder.get_source_url()
expected_url = (
f"{index_dl}/{finder.name}/{finder.name}-{finder.version}.crate"
)
self.assertEqual(expected_url, found_url)
call = mock.call(
expected_url,
timeout=30,
)
self.assertIn(call, get.call_args_list)

def test_get_source_url_source_not_found(self):
finder = self.make_finder()
self.patch(
finder, "get_index_dl"
).return_value = self.factory.make_url()
# Crates index returns a 403 when the crate is not found. ¯\_(ツ)_/¯
self.patch_head_with_response(requests.codes.forbidden)
self.assertRaises(exceptions.SourceNotFound, finder.get_source_url)

def test_get_index_dl(self):
index = self.factory.make_url()
finder = self.make_finder(index=index)
get_url = self.patch(finder, "get_url")
index = self.factory.make_url()
get_url.return_value.json.return_value = {"dl": index}

found_index = finder.get_index_dl()
self.assertEqual(index, found_index)
call = mock.call(f"{finder.index}config.json")
self.assertIn(call, get_url.call_args_list)

def test_get_index_dl_no_dl_key(self):
finder = self.make_finder()
get_url = self.patch(finder, "get_url")
get_url.return_value.json.return_value = {}

self.assertRaises(exceptions.DownloadError, finder.get_index_dl)

def test_find(self):
finder = self.make_finder()
url = self.factory.make_url()
self.patch(finder, "get_source_url").return_value = url

disc_source = finder.find()
self.assertIsInstance(disc_source, crate.CrateDiscoveredSource)
self.assertEqual([url], disc_source.urls)


class TestCrateDiscoveredSource(base.TestCase):
def make_discovered_source(self, url=None):
if url is None:
url = self.factory.make_url()
return crate.CrateDiscoveredSource([url])

def test_repr(self):
url = self.factory.make_url()
pds = self.make_discovered_source(url)
self.assertEqual(url, repr(pds))

def test_make_archive(self):
cds = self.make_discovered_source()
self.assertEqual(cds.make_archive, cds.remote_url_is_archive)
25 changes: 13 additions & 12 deletions soufi/tests/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,18 @@ def test_factory_passes_args_when_calling(self):

def test_supported_types(self):
expected = [
'almalinux',
'alpine',
'centos',
'debian',
'gem',
'go',
'java',
'npm',
'photon',
'python',
'rhel',
'ubuntu',
"almalinux",
"alpine",
"centos",
"crate",
"debian",
"gem",
"go",
"java",
"npm",
"photon",
"python",
"rhel",
"ubuntu",
]
self.assertListEqual(expected, factory.supported_types)

0 comments on commit f6eeccf

Please sign in to comment.