diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1bd1349 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Carl George + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..93701e0 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE +recursive-include tests *.py *.xml *.xml.gz diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..109b4ed --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,82 @@ +Metadata-Version: 2.1 +Name: repomd +Version: 0.2.1 +Summary: Library for reading dnf/yum repositories +Home-page: https://github.com/carlwgeorge/repomd +Author: Carl George +Author-email: carl@george.computer +License: MIT +Description: [![build status](https://api.cirrus-ci.com/github/carlwgeorge/repomd.svg)](https://cirrus-ci.com/github/carlwgeorge/repomd/master) + + # repomd + + This library provides an object-oriented interface to get information out of dnf/yum repositories. + + ## Usage + + ```python + >>> import repomd + + >>> repo = repomd.load('https://mirror.rackspace.com/centos/7/updates/x86_64/') + + >>> repo + + ``` + + The length of the `Repo` object indicates the number of packages in the repository. + + ```python + >>> len(repo) + 1602 + ``` + + Find a package by name. + + ```python + >>> repo.find('systemd') + + ``` + + Find all packages of a given name. + + ```python + >>> repo.findall('systemd') + [, ] + ``` + + A `Package` instance has many useful properties. + + ```python + >>> package = repo.find('systemd') + + >>> package.name + 'systemd' + + >>> package.version + '219' + + >>> package.build_time + datetime.datetime(2018, 9, 26, 14, 11, 37) + + >>> package.nevr + 'systemd-219-57.el7_5.3' + ``` + + Iterate through packages in the repository. + + ```python + >>> for package in repo: + ... print(package.nvr) + 389-ds-base-1.3.7.5-19.el7_5 + 389-ds-base-1.3.7.5-21.el7_5 + 389-ds-base-1.3.7.5-24.el7_5 + (and so on) + ``` + +Platform: UNKNOWN +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 :: Only +Requires-Python: >=3.6 +Description-Content-Type: text/markdown +Provides-Extra: test diff --git a/README.md b/README.md new file mode 100644 index 0000000..05efb63 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +[![build status](https://api.cirrus-ci.com/github/carlwgeorge/repomd.svg)](https://cirrus-ci.com/github/carlwgeorge/repomd/master) + +# repomd + +This library provides an object-oriented interface to get information out of dnf/yum repositories. + +## Usage + +```python +>>> import repomd + +>>> repo = repomd.load('https://mirror.rackspace.com/centos/7/updates/x86_64/') + +>>> repo + +``` + +The length of the `Repo` object indicates the number of packages in the repository. + +```python +>>> len(repo) +1602 +``` + +Find a package by name. + +```python +>>> repo.find('systemd') + +``` + +Find all packages of a given name. + +```python +>>> repo.findall('systemd') +[, ] +``` + +A `Package` instance has many useful properties. + +```python +>>> package = repo.find('systemd') + +>>> package.name +'systemd' + +>>> package.version +'219' + +>>> package.build_time +datetime.datetime(2018, 9, 26, 14, 11, 37) + +>>> package.nevr +'systemd-219-57.el7_5.3' +``` + +Iterate through packages in the repository. + +```python +>>> for package in repo: +... print(package.nvr) +389-ds-base-1.3.7.5-19.el7_5 +389-ds-base-1.3.7.5-21.el7_5 +389-ds-base-1.3.7.5-24.el7_5 +(and so on) +``` diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3faba64 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,16 @@ +[metadata] +license_file = LICENSE + +[tool:pytest] +addopts = --verbose --flake8 --cov repomd --cov-fail-under 100 + +[flake8] +max-line-length = 120 + +[coverage:report] +show_missing = true + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bf2e8c7 --- /dev/null +++ b/setup.py @@ -0,0 +1,42 @@ +import pathlib + +import setuptools + + +with pathlib.Path('README.md').open() as f: + long_description = f.read() + + +setuptools.setup( + name='repomd', + version='0.2.1', + author='Carl George', + author_email='carl@george.computer', + description='Library for reading dnf/yum repositories', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/carlwgeorge/repomd', + license='MIT', + package_dir={'': 'source'}, + py_modules=['repomd'], + # f-strings + python_requires='>=3.6', + # markdown content type + setup_requires=['setuptools>=38.6.0'], + install_requires=[ + 'defusedxml', + 'lxml', + ], + extras_require={ + 'test': [ + 'pytest', + 'pytest-cov', + 'pytest-flake8', + ], + }, + classifiers=[ + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3 :: Only', + ], +) diff --git a/source/repomd.egg-info/PKG-INFO b/source/repomd.egg-info/PKG-INFO new file mode 100644 index 0000000..109b4ed --- /dev/null +++ b/source/repomd.egg-info/PKG-INFO @@ -0,0 +1,82 @@ +Metadata-Version: 2.1 +Name: repomd +Version: 0.2.1 +Summary: Library for reading dnf/yum repositories +Home-page: https://github.com/carlwgeorge/repomd +Author: Carl George +Author-email: carl@george.computer +License: MIT +Description: [![build status](https://api.cirrus-ci.com/github/carlwgeorge/repomd.svg)](https://cirrus-ci.com/github/carlwgeorge/repomd/master) + + # repomd + + This library provides an object-oriented interface to get information out of dnf/yum repositories. + + ## Usage + + ```python + >>> import repomd + + >>> repo = repomd.load('https://mirror.rackspace.com/centos/7/updates/x86_64/') + + >>> repo + + ``` + + The length of the `Repo` object indicates the number of packages in the repository. + + ```python + >>> len(repo) + 1602 + ``` + + Find a package by name. + + ```python + >>> repo.find('systemd') + + ``` + + Find all packages of a given name. + + ```python + >>> repo.findall('systemd') + [, ] + ``` + + A `Package` instance has many useful properties. + + ```python + >>> package = repo.find('systemd') + + >>> package.name + 'systemd' + + >>> package.version + '219' + + >>> package.build_time + datetime.datetime(2018, 9, 26, 14, 11, 37) + + >>> package.nevr + 'systemd-219-57.el7_5.3' + ``` + + Iterate through packages in the repository. + + ```python + >>> for package in repo: + ... print(package.nvr) + 389-ds-base-1.3.7.5-19.el7_5 + 389-ds-base-1.3.7.5-21.el7_5 + 389-ds-base-1.3.7.5-24.el7_5 + (and so on) + ``` + +Platform: UNKNOWN +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 :: Only +Requires-Python: >=3.6 +Description-Content-Type: text/markdown +Provides-Extra: test diff --git a/source/repomd.egg-info/SOURCES.txt b/source/repomd.egg-info/SOURCES.txt new file mode 100644 index 0000000..d76e256 --- /dev/null +++ b/source/repomd.egg-info/SOURCES.txt @@ -0,0 +1,16 @@ +LICENSE +MANIFEST.in +README.md +setup.cfg +setup.py +source/repomd.py +source/repomd.egg-info/PKG-INFO +source/repomd.egg-info/SOURCES.txt +source/repomd.egg-info/dependency_links.txt +source/repomd.egg-info/requires.txt +source/repomd.egg-info/top_level.txt +tests/test_repomd.py +tests/data/empty_repo/repodata/primary.xml.gz +tests/data/empty_repo/repodata/repomd.xml +tests/data/repo/repodata/primary.xml.gz +tests/data/repo/repodata/repomd.xml \ No newline at end of file diff --git a/source/repomd.egg-info/dependency_links.txt b/source/repomd.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/source/repomd.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/source/repomd.egg-info/requires.txt b/source/repomd.egg-info/requires.txt new file mode 100644 index 0000000..60f4328 --- /dev/null +++ b/source/repomd.egg-info/requires.txt @@ -0,0 +1,7 @@ +defusedxml +lxml + +[test] +pytest +pytest-cov +pytest-flake8 diff --git a/source/repomd.egg-info/top_level.txt b/source/repomd.egg-info/top_level.txt new file mode 100644 index 0000000..707908e --- /dev/null +++ b/source/repomd.egg-info/top_level.txt @@ -0,0 +1 @@ +repomd diff --git a/source/repomd.py b/source/repomd.py new file mode 100644 index 0000000..18503fb --- /dev/null +++ b/source/repomd.py @@ -0,0 +1,190 @@ +import datetime +import gzip +import io +import defusedxml.lxml +import pathlib +import urllib.request +import urllib.parse + + +_ns = { + 'common': 'http://linux.duke.edu/metadata/common', + 'repo': 'http://linux.duke.edu/metadata/repo', + 'rpm': 'http://linux.duke.edu/metadata/rpm' +} + + +def load(baseurl): + # parse baseurl to allow manipulating the path + base = urllib.parse.urlparse(baseurl) + path = pathlib.PurePosixPath(base.path) + + # first we must get the repomd.xml file + repomd_path = path / 'repodata' / 'repomd.xml' + repomd_url = base._replace(path=str(repomd_path)).geturl() + + # download and parse repomd.xml + with urllib.request.urlopen(repomd_url) as response: + repomd_xml = defusedxml.lxml.fromstring(response.read()) + + # determine the location of *primary.xml.gz + primary_element = repomd_xml.find('repo:data[@type="primary"]/repo:location', namespaces=_ns) + primary_path = path / primary_element.get('href') + primary_url = base._replace(path=str(primary_path)).geturl() + + # download and parse *-primary.xml + with urllib.request.urlopen(primary_url) as response: + with io.BytesIO(response.read()) as compressed: + with gzip.GzipFile(fileobj=compressed) as uncompressed: + metadata = defusedxml.lxml.fromstring(uncompressed.read()) + + return Repo(baseurl, metadata) + + +class Repo: + """A dnf/yum repository.""" + + __slots__ = ['baseurl', '_metadata'] + + def __init__(self, baseurl, metadata): + self.baseurl = baseurl + self._metadata = metadata + + def __repr__(self): + return f'<{self.__class__.__name__}: "{self.baseurl}">' + + def __str__(self): + return self.baseurl + + def __len__(self): + return int(self._metadata.get('packages')) + + def __iter__(self): + for element in self._metadata: + yield Package(element) + + def find(self, name): + results = self._metadata.findall(f'common:package[common:name="{name}"]', namespaces=_ns) + if results: + return Package(results[-1]) + else: + return None + + def findall(self, name): + return [ + Package(element) + for element in self._metadata.findall(f'common:package[common:name="{name}"]', namespaces=_ns) + ] + + +class Package: + """An RPM package from a repository.""" + + __slots__ = ['_element'] + + def __init__(self, element): + self._element = element + + @property + def name(self): + return self._element.findtext('common:name', namespaces=_ns) + + @property + def arch(self): + return self._element.findtext('common:arch', namespaces=_ns) + + @property + def summary(self): + return self._element.findtext('common:summary', namespaces=_ns) + + @property + def description(self): + return self._element.findtext('common:description', namespaces=_ns) + + @property + def packager(self): + return self._element.findtext('common:packager', namespaces=_ns) + + @property + def url(self): + return self._element.findtext('common:url', namespaces=_ns) + + @property + def license(self): + return self._element.findtext('common:format/rpm:license', namespaces=_ns) + + @property + def vendor(self): + return self._element.findtext('common:format/rpm:vendor', namespaces=_ns) + + @property + def sourcerpm(self): + return self._element.findtext('common:format/rpm:sourcerpm', namespaces=_ns) + + @property + def build_time(self): + build_time = self._element.find('common:time', namespaces=_ns).get('build') + return datetime.datetime.fromtimestamp(int(build_time)) + + @property + def location(self): + return self._element.find('common:location', namespaces=_ns).get('href') + + @property + def _version_info(self): + return self._element.find('common:version', namespaces=_ns) + + @property + def epoch(self): + return self._version_info.get('epoch') + + @property + def version(self): + return self._version_info.get('ver') + + @property + def release(self): + return self._version_info.get('rel') + + @property + def vr(self): + version_info = self._version_info + v = version_info.get('ver') + r = version_info.get('rel') + return f'{v}-{r}' + + @property + def nvr(self): + return f'{self.name}-{self.vr}' + + @property + def evr(self): + version_info = self._version_info + e = version_info.get('epoch') + v = version_info.get('ver') + r = version_info.get('rel') + if int(e): + return f'{e}:{v}-{r}' + else: + return f'{v}-{r}' + + @property + def nevr(self): + return f'{self.name}-{self.evr}' + + @property + def nevra(self): + return f'{self.nevr}.{self.arch}' + + @property + def _nevra_tuple(self): + return self.name, self.epoch, self.version, self.release, self.arch + + def __eq__(self, other): + return self._nevra_tuple == other._nevra_tuple + + def __hash__(self): + return hash(self._nevra_tuple) + + def __repr__(self): + return f'<{self.__class__.__name__}: "{self.nevra}">' diff --git a/tests/data/empty_repo/repodata/primary.xml.gz b/tests/data/empty_repo/repodata/primary.xml.gz new file mode 100644 index 0000000..2e7d051 Binary files /dev/null and b/tests/data/empty_repo/repodata/primary.xml.gz differ diff --git a/tests/data/empty_repo/repodata/repomd.xml b/tests/data/empty_repo/repodata/repomd.xml new file mode 100644 index 0000000..bd12f4f --- /dev/null +++ b/tests/data/empty_repo/repodata/repomd.xml @@ -0,0 +1,28 @@ + + + 1525208603 + + 1cb61ea996355add02b1426ed4c1780ea75ce0c04c5d1107c025c3fbd7d8bcae + e1e2ffd2fb1ee76f87b70750d00ca5677a252b397ab6c2389137a0c33e7b359f + + 1525208603 + 134 + 167 + + + 95a4415d859d7120efb6b3cf964c07bebbff9a5275ca673e6e74a97bcbfb2a5f + bf9808b81cb2dbc54b4b8e35adc584ddcaa73bd81f7088d73bf7dbbada961310 + + 1525208603 + 123 + 125 + + + ef3e20691954c3d1318ec3071a982da339f4ed76967ded668b795c9e070aaab6 + e0ed5e0054194df036cf09c1a911e15bf2a4e7f26f2a788b6f47d53e80717ccc + + 1525208603 + 123 + 121 + + diff --git a/tests/data/repo/repodata/primary.xml.gz b/tests/data/repo/repodata/primary.xml.gz new file mode 100644 index 0000000..af94eeb Binary files /dev/null and b/tests/data/repo/repodata/primary.xml.gz differ diff --git a/tests/data/repo/repodata/repomd.xml b/tests/data/repo/repodata/repomd.xml new file mode 100644 index 0000000..b3c6690 --- /dev/null +++ b/tests/data/repo/repodata/repomd.xml @@ -0,0 +1,28 @@ + + + 1525208603 + + b8476f16b24d1beb9869f48cdca924d37320a98448a4d608d3c3b1610b992414 + aa64c4d08db4630466ac9b4574da906f6c604345f76f2a0713c1041b46bf8dac + + 1525208603 + 1071 + 5438 + + + 8ef390dc041a04b21fbe8284017b699f10c47d53c7372009fda1808c1e8c6872 + 533065185a3f1a0fdd19c7a5cdf225fef6038beea27b9b1a5c36f4b22546add2 + + 1525208603 + 491 + 1149 + + + 71dd2257629b85658d0115d100393c56621d80223de63dc668a7dd0ac7030cc6 + 710fea163b05af4164cfe5d67a2734db96401ab252d841fc921c11f4b34ff2c4 + + 1525208603 + 458 + 981 + + diff --git a/tests/test_repomd.py b/tests/test_repomd.py new file mode 100644 index 0000000..4dc221d --- /dev/null +++ b/tests/test_repomd.py @@ -0,0 +1,175 @@ +import copy +import datetime +import pathlib +import unittest.mock + +import lxml.etree +import pytest + +import repomd + + +def load_test_repodata(base): + base = pathlib.Path(base) + with (base / 'repodata' / 'repomd.xml').open(mode='rb') as f: + repomd_xml = f.read() + with (base / 'repodata' / 'primary.xml.gz').open(mode='rb') as f: + primary_xml = f.read() + return (repomd_xml, primary_xml) + + +@pytest.fixture +@unittest.mock.patch('repomd.urllib.request.urlopen') +def repo(mock_urlopen): + mock_urlopen.return_value.__enter__.return_value.read.side_effect = load_test_repodata('tests/data/repo') + return repomd.load('https://example.com') + + +@pytest.fixture +@unittest.mock.patch('repomd.urllib.request.urlopen') +def empty_repo(mock_urlopen): + mock_urlopen.return_value.__enter__.return_value.read.side_effect = load_test_repodata('tests/data/empty_repo') + return repomd.load('https://example.com') + + +@pytest.fixture +def chicken(repo): + return repo.find('chicken') + + +@pytest.fixture +def brisket(repo): + return repo.find('brisket') + + +@pytest.fixture +def pork_ribs(repo): + return repo.find('pork-ribs') + + +def test_repo(repo): + assert repo.baseurl == 'https://example.com' + assert isinstance(repo._metadata, lxml.etree._Element) + + +def test_repo_repr(repo): + assert repr(repo) == '' + + +def test_repo_str(repo): + assert str(repo) == 'https://example.com' + + +def test_repo_len(repo, empty_repo): + assert len(repo) == 5 + assert len(empty_repo) == 0 + + +def test_find(repo): + package = repo.find('non-existent') + assert package is None + package = repo.find('chicken') + assert isinstance(package, repomd.Package) + + +def test_findall(repo): + packages = repo.findall('non-existent') + assert packages == [] + packages = repo.findall('chicken') + assert any(packages) + for package in packages: + assert isinstance(package, repomd.Package) + + +def test_iter(repo): + for package in repo: + assert isinstance(package, repomd.Package) + + +def test_package(chicken): + assert repr(chicken) == '' + assert chicken.name == 'chicken' + assert chicken.arch == 'noarch' + assert chicken.summary == 'Chicken' + assert chicken.description == 'Chicken.' + assert chicken.packager == 'Carl' + assert chicken.url == 'https://example.com/chicken' + assert chicken.license == 'BBQ' + assert chicken.vendor == "Carl's BBQ" + assert chicken.sourcerpm == 'chicken-2.2.10-1.fc27.src.rpm' + assert chicken.build_time == datetime.datetime.fromtimestamp(1525208602) + assert chicken.location == 'chicken-2.2.10-1.fc27.noarch.rpm' + assert chicken.epoch == '0' + assert chicken.version == '2.2.10' + assert chicken.release == '1.fc27' + assert chicken.vr == '2.2.10-1.fc27' + assert chicken.nvr == 'chicken-2.2.10-1.fc27' + assert chicken.evr == '2.2.10-1.fc27' + assert chicken.nevr == 'chicken-2.2.10-1.fc27' + assert chicken.nevra == 'chicken-2.2.10-1.fc27.noarch' + + +def test_package_with_epoch(brisket): + assert repr(brisket) == '' + assert brisket.name == 'brisket' + assert brisket.arch == 'noarch' + assert brisket.summary == 'Brisket' + assert brisket.description == 'Brisket.' + assert brisket.packager == 'Carl' + assert brisket.url == 'https://example.com/brisket' + assert brisket.license == 'BBQ' + assert brisket.vendor == "Carl's BBQ" + assert brisket.sourcerpm == 'brisket-5.1.1-1.fc27.src.rpm' + assert brisket.build_time == datetime.datetime.fromtimestamp(1525208602) + assert brisket.location == 'brisket-5.1.1-1.fc27.noarch.rpm' + assert brisket.epoch == '1' + assert brisket.version == '5.1.1' + assert brisket.release == '1.fc27' + assert brisket.vr == '5.1.1-1.fc27' + assert brisket.nvr == 'brisket-5.1.1-1.fc27' + assert brisket.evr == '1:5.1.1-1.fc27' + assert brisket.nevr == 'brisket-1:5.1.1-1.fc27' + assert brisket.nevra == 'brisket-1:5.1.1-1.fc27.noarch' + + +def test_subpackage(pork_ribs): + assert repr(pork_ribs) == '' + assert pork_ribs.name == 'pork-ribs' + assert pork_ribs.arch == 'noarch' + assert pork_ribs.summary == 'Pork ribs' + assert pork_ribs.description == 'Pork ribs.' + assert pork_ribs.packager == 'Carl' + assert pork_ribs.url == 'https://example.com/ribs' + assert pork_ribs.license == 'BBQ' + assert pork_ribs.vendor == "Carl's BBQ" + assert pork_ribs.sourcerpm == 'ribs-3.2.0-1.fc27.src.rpm' + assert pork_ribs.build_time == datetime.datetime.fromtimestamp(1525208603) + assert pork_ribs.location == 'pork-ribs-3.2.0-1.fc27.noarch.rpm' + assert pork_ribs.epoch == '0' + assert pork_ribs.version == '3.2.0' + assert pork_ribs.release == '1.fc27' + assert pork_ribs.vr == '3.2.0-1.fc27' + assert pork_ribs.nvr == 'pork-ribs-3.2.0-1.fc27' + assert pork_ribs.evr == '3.2.0-1.fc27' + assert pork_ribs.nevr == 'pork-ribs-3.2.0-1.fc27' + assert pork_ribs.nevra == 'pork-ribs-3.2.0-1.fc27.noarch' + + +def test_package_equals_its_copy(chicken): + copied_chicken = copy.copy(chicken) + assert chicken is chicken + assert chicken == chicken + assert chicken is not copied_chicken + assert chicken == copied_chicken + + +def test_packages_can_be_used_as_dict_keys(chicken, brisket): + d = {chicken: 'chicken', brisket: 'brisket'} + copied_chicken = copy.copy(chicken) + assert d[copied_chicken] == 'chicken' + + +def test_equal_packages_work_in_set(chicken, brisket): + copied_chicken = copy.copy(chicken) + copied_brisket = copy.copy(brisket) + assert len({chicken, brisket, copied_chicken, copied_brisket}) == 2