diff --git a/PKG-INFO b/PKG-INFO index 05e1ca2..3906c60 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,257 +1,271 @@ Metadata-Version: 2.1 Name: swh.lister -Version: 0.0.32 +Version: 0.0.33 Summary: Software Heritage lister Home-page: https://forge.softwareheritage.org/diffusion/DLSGH/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Project-URL: Funding, https://www.softwareheritage.org/donate -Project-URL: Source, https://forge.softwareheritage.org/source/swh-lister Project-URL: Bug Reports, https://forge.softwareheritage.org/maniphest +Project-URL: Source, https://forge.softwareheritage.org/source/swh-lister Description: swh-lister ========== This component from the Software Heritage stack aims to produce listings of software origins and their urls hosted on various public developer platforms or package managers. As these operations are quite similar, it provides a set of Python modules abstracting common software origins listing behaviors. It also provides several lister implementations, contained in the following Python modules: - `swh.lister.bitbucket` - `swh.lister.debian` - `swh.lister.github` - `swh.lister.gitlab` - `swh.lister.gnu` - `swh.lister.pypi` - `swh.lister.npm` - `swh.lister.phabricator` - `swh.lister.cran` - `swh.lister.cgit` + - `swh.lister.packagist` Dependencies ------------ All required dependencies can be found in the `requirements*.txt` files located at the root of the repository. Local deployment ---------------- ## lister configuration Each lister implemented so far by Software Heritage (`github`, `gitlab`, `debian`, `pypi`, `npm`) must be configured by following the instructions below (please note that you have to replace `` by one of the lister name introduced above). ### Preparation steps 1. `mkdir ~/.config/swh/ ~/.cache/swh/lister//` 2. create configuration file `~/.config/swh/lister_.yml` 3. Bootstrap the db instance schema ```lang=bash $ createdb lister- $ python3 -m swh.lister.cli --db-url postgres:///lister- ``` Note: This bootstraps a minimum data set needed for the lister to run. ### Configuration file sample Minimalistic configuration shared by all listers to add in file `~/.config/swh/lister_.yml`: ```lang=yml storage: cls: 'remote' args: url: 'http://localhost:5002/' scheduler: cls: 'remote' args: url: 'http://localhost:5008/' lister: cls: 'local' args: # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls db: 'postgresql:///lister-' credentials: [] cache_responses: True cache_dir: /home/user/.cache/swh/lister// ``` Note: This expects storage (5002) and scheduler (5008) services to run locally ## lister-github Once configured, you can execute a GitHub lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.github.tasks import range_github_lister logging.basicConfig(level=logging.DEBUG) range_github_lister(364, 365) ... ``` ## lister-gitlab Once configured, you can execute a GitLab lister using the instructions detailed in the `python3` scripts below: ```lang=python import logging from swh.lister.gitlab.tasks import range_gitlab_lister logging.basicConfig(level=logging.DEBUG) range_gitlab_lister(1, 2, { 'instance': 'debian', 'api_baseurl': 'https://salsa.debian.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ```lang=python import logging from swh.lister.gitlab.tasks import full_gitlab_relister logging.basicConfig(level=logging.DEBUG) full_gitlab_relister({ 'instance': '0xacab', 'api_baseurl': 'https://0xacab.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ```lang=python import logging from swh.lister.gitlab.tasks import incremental_gitlab_lister logging.basicConfig(level=logging.DEBUG) incremental_gitlab_lister({ 'instance': 'freedesktop.org', 'api_baseurl': 'https://gitlab.freedesktop.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ## lister-debian Once configured, you can execute a Debian lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.debian.tasks import debian_lister logging.basicConfig(level=logging.DEBUG) debian_lister('Debian') ``` ## lister-pypi Once configured, you can execute a PyPI lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.pypi.tasks import pypi_lister logging.basicConfig(level=logging.DEBUG) pypi_lister() ``` ## lister-npm Once configured, you can execute a npm lister using the following instructions in a `python3` REPL: ```lang=python import logging from swh.lister.npm.tasks import npm_lister logging.basicConfig(level=logging.DEBUG) npm_lister() ``` ## lister-phabricator Once configured, you can execute a Phabricator lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.phabricator.tasks import incremental_phabricator_lister logging.basicConfig(level=logging.DEBUG) incremental_phabricator_lister(forge_url='https://forge.softwareheritage.org', api_token='XXXX') ``` ## lister-gnu Once configured, you can execute a PyPI lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.gnu.tasks import gnu_lister logging.basicConfig(level=logging.DEBUG) gnu_lister() ``` ## lister-cran Once configured, you can execute a CRAN lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.cran.tasks import cran_lister logging.basicConfig(level=logging.DEBUG) cran_lister() ``` ## lister-cgit Once configured, you can execute a cgit lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.cgit.tasks import cgit_lister logging.basicConfig(level=logging.DEBUG) # simple cgit instance cgit_lister(url='https://git.kernel.org/') # cgit instance whose listed repositories differ from the base url cgit_lister(url='https://cgit.kde.org/', url_prefix='https://anongit.kde.org/') ``` + ## lister-packagist + + Once configured, you can execute a Packagist lister using the following instructions + in a `python3` script: + + ```lang=python + import logging + from swh.lister.packagist.tasks import packagist_lister + + logging.basicConfig(level=logging.DEBUG) + packagist_lister() + ``` + Licensing --------- This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. See top-level LICENSE file for the full text of the GNU General Public License along with this program. Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Development Status :: 5 - Production/Stable Description-Content-Type: text/markdown Provides-Extra: testing diff --git a/README.md b/README.md index b6ee69e..b54e486 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,251 @@ swh-lister ========== This component from the Software Heritage stack aims to produce listings of software origins and their urls hosted on various public developer platforms or package managers. As these operations are quite similar, it provides a set of Python modules abstracting common software origins listing behaviors. It also provides several lister implementations, contained in the following Python modules: - `swh.lister.bitbucket` - `swh.lister.debian` - `swh.lister.github` - `swh.lister.gitlab` - `swh.lister.gnu` - `swh.lister.pypi` - `swh.lister.npm` - `swh.lister.phabricator` - `swh.lister.cran` - `swh.lister.cgit` +- `swh.lister.packagist` Dependencies ------------ All required dependencies can be found in the `requirements*.txt` files located at the root of the repository. Local deployment ---------------- ## lister configuration Each lister implemented so far by Software Heritage (`github`, `gitlab`, `debian`, `pypi`, `npm`) must be configured by following the instructions below (please note that you have to replace `` by one of the lister name introduced above). ### Preparation steps 1. `mkdir ~/.config/swh/ ~/.cache/swh/lister//` 2. create configuration file `~/.config/swh/lister_.yml` 3. Bootstrap the db instance schema ```lang=bash $ createdb lister- $ python3 -m swh.lister.cli --db-url postgres:///lister- ``` Note: This bootstraps a minimum data set needed for the lister to run. ### Configuration file sample Minimalistic configuration shared by all listers to add in file `~/.config/swh/lister_.yml`: ```lang=yml storage: cls: 'remote' args: url: 'http://localhost:5002/' scheduler: cls: 'remote' args: url: 'http://localhost:5008/' lister: cls: 'local' args: # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls db: 'postgresql:///lister-' credentials: [] cache_responses: True cache_dir: /home/user/.cache/swh/lister// ``` Note: This expects storage (5002) and scheduler (5008) services to run locally ## lister-github Once configured, you can execute a GitHub lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.github.tasks import range_github_lister logging.basicConfig(level=logging.DEBUG) range_github_lister(364, 365) ... ``` ## lister-gitlab Once configured, you can execute a GitLab lister using the instructions detailed in the `python3` scripts below: ```lang=python import logging from swh.lister.gitlab.tasks import range_gitlab_lister logging.basicConfig(level=logging.DEBUG) range_gitlab_lister(1, 2, { 'instance': 'debian', 'api_baseurl': 'https://salsa.debian.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ```lang=python import logging from swh.lister.gitlab.tasks import full_gitlab_relister logging.basicConfig(level=logging.DEBUG) full_gitlab_relister({ 'instance': '0xacab', 'api_baseurl': 'https://0xacab.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ```lang=python import logging from swh.lister.gitlab.tasks import incremental_gitlab_lister logging.basicConfig(level=logging.DEBUG) incremental_gitlab_lister({ 'instance': 'freedesktop.org', 'api_baseurl': 'https://gitlab.freedesktop.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ## lister-debian Once configured, you can execute a Debian lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.debian.tasks import debian_lister logging.basicConfig(level=logging.DEBUG) debian_lister('Debian') ``` ## lister-pypi Once configured, you can execute a PyPI lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.pypi.tasks import pypi_lister logging.basicConfig(level=logging.DEBUG) pypi_lister() ``` ## lister-npm Once configured, you can execute a npm lister using the following instructions in a `python3` REPL: ```lang=python import logging from swh.lister.npm.tasks import npm_lister logging.basicConfig(level=logging.DEBUG) npm_lister() ``` ## lister-phabricator Once configured, you can execute a Phabricator lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.phabricator.tasks import incremental_phabricator_lister logging.basicConfig(level=logging.DEBUG) incremental_phabricator_lister(forge_url='https://forge.softwareheritage.org', api_token='XXXX') ``` ## lister-gnu Once configured, you can execute a PyPI lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.gnu.tasks import gnu_lister logging.basicConfig(level=logging.DEBUG) gnu_lister() ``` ## lister-cran Once configured, you can execute a CRAN lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.cran.tasks import cran_lister logging.basicConfig(level=logging.DEBUG) cran_lister() ``` ## lister-cgit Once configured, you can execute a cgit lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.cgit.tasks import cgit_lister logging.basicConfig(level=logging.DEBUG) # simple cgit instance cgit_lister(url='https://git.kernel.org/') # cgit instance whose listed repositories differ from the base url cgit_lister(url='https://cgit.kde.org/', url_prefix='https://anongit.kde.org/') ``` +## lister-packagist + +Once configured, you can execute a Packagist lister using the following instructions +in a `python3` script: + +```lang=python +import logging +from swh.lister.packagist.tasks import packagist_lister + +logging.basicConfig(level=logging.DEBUG) +packagist_lister() +``` + Licensing --------- This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. See top-level LICENSE file for the full text of the GNU General Public License along with this program. diff --git a/swh.lister.egg-info/PKG-INFO b/swh.lister.egg-info/PKG-INFO index 05e1ca2..3906c60 100644 --- a/swh.lister.egg-info/PKG-INFO +++ b/swh.lister.egg-info/PKG-INFO @@ -1,257 +1,271 @@ Metadata-Version: 2.1 Name: swh.lister -Version: 0.0.32 +Version: 0.0.33 Summary: Software Heritage lister Home-page: https://forge.softwareheritage.org/diffusion/DLSGH/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Project-URL: Funding, https://www.softwareheritage.org/donate -Project-URL: Source, https://forge.softwareheritage.org/source/swh-lister Project-URL: Bug Reports, https://forge.softwareheritage.org/maniphest +Project-URL: Source, https://forge.softwareheritage.org/source/swh-lister Description: swh-lister ========== This component from the Software Heritage stack aims to produce listings of software origins and their urls hosted on various public developer platforms or package managers. As these operations are quite similar, it provides a set of Python modules abstracting common software origins listing behaviors. It also provides several lister implementations, contained in the following Python modules: - `swh.lister.bitbucket` - `swh.lister.debian` - `swh.lister.github` - `swh.lister.gitlab` - `swh.lister.gnu` - `swh.lister.pypi` - `swh.lister.npm` - `swh.lister.phabricator` - `swh.lister.cran` - `swh.lister.cgit` + - `swh.lister.packagist` Dependencies ------------ All required dependencies can be found in the `requirements*.txt` files located at the root of the repository. Local deployment ---------------- ## lister configuration Each lister implemented so far by Software Heritage (`github`, `gitlab`, `debian`, `pypi`, `npm`) must be configured by following the instructions below (please note that you have to replace `` by one of the lister name introduced above). ### Preparation steps 1. `mkdir ~/.config/swh/ ~/.cache/swh/lister//` 2. create configuration file `~/.config/swh/lister_.yml` 3. Bootstrap the db instance schema ```lang=bash $ createdb lister- $ python3 -m swh.lister.cli --db-url postgres:///lister- ``` Note: This bootstraps a minimum data set needed for the lister to run. ### Configuration file sample Minimalistic configuration shared by all listers to add in file `~/.config/swh/lister_.yml`: ```lang=yml storage: cls: 'remote' args: url: 'http://localhost:5002/' scheduler: cls: 'remote' args: url: 'http://localhost:5008/' lister: cls: 'local' args: # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls db: 'postgresql:///lister-' credentials: [] cache_responses: True cache_dir: /home/user/.cache/swh/lister// ``` Note: This expects storage (5002) and scheduler (5008) services to run locally ## lister-github Once configured, you can execute a GitHub lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.github.tasks import range_github_lister logging.basicConfig(level=logging.DEBUG) range_github_lister(364, 365) ... ``` ## lister-gitlab Once configured, you can execute a GitLab lister using the instructions detailed in the `python3` scripts below: ```lang=python import logging from swh.lister.gitlab.tasks import range_gitlab_lister logging.basicConfig(level=logging.DEBUG) range_gitlab_lister(1, 2, { 'instance': 'debian', 'api_baseurl': 'https://salsa.debian.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ```lang=python import logging from swh.lister.gitlab.tasks import full_gitlab_relister logging.basicConfig(level=logging.DEBUG) full_gitlab_relister({ 'instance': '0xacab', 'api_baseurl': 'https://0xacab.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ```lang=python import logging from swh.lister.gitlab.tasks import incremental_gitlab_lister logging.basicConfig(level=logging.DEBUG) incremental_gitlab_lister({ 'instance': 'freedesktop.org', 'api_baseurl': 'https://gitlab.freedesktop.org/api/v4', 'sort': 'asc', 'per_page': 20 }) ``` ## lister-debian Once configured, you can execute a Debian lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.debian.tasks import debian_lister logging.basicConfig(level=logging.DEBUG) debian_lister('Debian') ``` ## lister-pypi Once configured, you can execute a PyPI lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.pypi.tasks import pypi_lister logging.basicConfig(level=logging.DEBUG) pypi_lister() ``` ## lister-npm Once configured, you can execute a npm lister using the following instructions in a `python3` REPL: ```lang=python import logging from swh.lister.npm.tasks import npm_lister logging.basicConfig(level=logging.DEBUG) npm_lister() ``` ## lister-phabricator Once configured, you can execute a Phabricator lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.phabricator.tasks import incremental_phabricator_lister logging.basicConfig(level=logging.DEBUG) incremental_phabricator_lister(forge_url='https://forge.softwareheritage.org', api_token='XXXX') ``` ## lister-gnu Once configured, you can execute a PyPI lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.gnu.tasks import gnu_lister logging.basicConfig(level=logging.DEBUG) gnu_lister() ``` ## lister-cran Once configured, you can execute a CRAN lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.cran.tasks import cran_lister logging.basicConfig(level=logging.DEBUG) cran_lister() ``` ## lister-cgit Once configured, you can execute a cgit lister using the following instructions in a `python3` script: ```lang=python import logging from swh.lister.cgit.tasks import cgit_lister logging.basicConfig(level=logging.DEBUG) # simple cgit instance cgit_lister(url='https://git.kernel.org/') # cgit instance whose listed repositories differ from the base url cgit_lister(url='https://cgit.kde.org/', url_prefix='https://anongit.kde.org/') ``` + ## lister-packagist + + Once configured, you can execute a Packagist lister using the following instructions + in a `python3` script: + + ```lang=python + import logging + from swh.lister.packagist.tasks import packagist_lister + + logging.basicConfig(level=logging.DEBUG) + packagist_lister() + ``` + Licensing --------- This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. See top-level LICENSE file for the full text of the GNU General Public License along with this program. Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Development Status :: 5 - Production/Stable Description-Content-Type: text/markdown Provides-Extra: testing diff --git a/swh.lister.egg-info/SOURCES.txt b/swh.lister.egg-info/SOURCES.txt index 8e59868..3d67d6d 100644 --- a/swh.lister.egg-info/SOURCES.txt +++ b/swh.lister.egg-info/SOURCES.txt @@ -1,128 +1,140 @@ MANIFEST.in Makefile README.md requirements-swh.txt requirements-test.txt requirements.txt setup.py version.txt swh/__init__.py swh.lister.egg-info/PKG-INFO swh.lister.egg-info/SOURCES.txt swh.lister.egg-info/dependency_links.txt swh.lister.egg-info/entry_points.txt swh.lister.egg-info/requires.txt swh.lister.egg-info/top_level.txt swh/lister/__init__.py swh/lister/_version.py swh/lister/cli.py swh/lister/utils.py swh/lister/bitbucket/__init__.py swh/lister/bitbucket/lister.py swh/lister/bitbucket/models.py swh/lister/bitbucket/tasks.py swh/lister/bitbucket/tests/__init__.py swh/lister/bitbucket/tests/api_empty_response.json swh/lister/bitbucket/tests/api_response.json swh/lister/bitbucket/tests/conftest.py swh/lister/bitbucket/tests/test_bb_lister.py swh/lister/bitbucket/tests/test_tasks.py swh/lister/cgit/__init__.py swh/lister/cgit/lister.py swh/lister/cgit/models.py swh/lister/cgit/tasks.py swh/lister/cgit/tests/__init__.py swh/lister/cgit/tests/conftest.py swh/lister/cgit/tests/repo_list.txt swh/lister/cgit/tests/response.html swh/lister/cgit/tests/test_lister.py swh/lister/cgit/tests/test_tasks.py swh/lister/core/__init__.py swh/lister/core/abstractattribute.py swh/lister/core/db_utils.py swh/lister/core/indexing_lister.py swh/lister/core/lister_base.py swh/lister/core/lister_transports.py swh/lister/core/models.py swh/lister/core/page_by_page_lister.py swh/lister/core/simple_lister.py swh/lister/core/tests/__init__.py swh/lister/core/tests/conftest.py swh/lister/core/tests/test_abstractattribute.py swh/lister/core/tests/test_lister.py swh/lister/core/tests/test_model.py swh/lister/cran/__init__.py swh/lister/cran/list_all_packages.R swh/lister/cran/lister.py swh/lister/cran/models.py swh/lister/cran/tasks.py swh/lister/cran/tests/__init__.py swh/lister/cran/tests/conftest.py swh/lister/cran/tests/test_lister.py swh/lister/cran/tests/test_tasks.py swh/lister/debian/__init__.py swh/lister/debian/lister.py swh/lister/debian/tasks.py swh/lister/debian/utils.py swh/lister/debian/tests/__init__.py swh/lister/debian/tests/conftest.py swh/lister/debian/tests/test_tasks.py swh/lister/github/__init__.py swh/lister/github/lister.py swh/lister/github/models.py swh/lister/github/tasks.py swh/lister/github/tests/__init__.py swh/lister/github/tests/api_empty_response.json swh/lister/github/tests/api_response.json swh/lister/github/tests/conftest.py swh/lister/github/tests/test_gh_lister.py swh/lister/github/tests/test_tasks.py swh/lister/gitlab/__init__.py swh/lister/gitlab/lister.py swh/lister/gitlab/models.py swh/lister/gitlab/tasks.py swh/lister/gitlab/tests/__init__.py swh/lister/gitlab/tests/api_empty_response.json swh/lister/gitlab/tests/api_response.json swh/lister/gitlab/tests/conftest.py swh/lister/gitlab/tests/test_gitlab_lister.py swh/lister/gitlab/tests/test_tasks.py swh/lister/gnu/__init__.py swh/lister/gnu/lister.py swh/lister/gnu/models.py swh/lister/gnu/tasks.py swh/lister/gnu/tests/__init__.py swh/lister/gnu/tests/api_response.json swh/lister/gnu/tests/conftest.py swh/lister/gnu/tests/file_structure.json swh/lister/gnu/tests/find_tarballs_output.json swh/lister/gnu/tests/test_lister.py swh/lister/gnu/tests/test_tasks.py swh/lister/npm/__init__.py swh/lister/npm/lister.py swh/lister/npm/models.py swh/lister/npm/tasks.py swh/lister/npm/tests/api_empty_response.json swh/lister/npm/tests/api_inc_empty_response.json swh/lister/npm/tests/api_inc_response.json swh/lister/npm/tests/api_response.json +swh/lister/packagist/__init__.py +swh/lister/packagist/lister.py +swh/lister/packagist/models.py +swh/lister/packagist/tasks.py +swh/lister/packagist/tests/__init__.py +swh/lister/packagist/tests/api_response.json +swh/lister/packagist/tests/conftest.py +swh/lister/packagist/tests/test_lister.py +swh/lister/packagist/tests/test_tasks.py swh/lister/phabricator/__init__.py swh/lister/phabricator/lister.py swh/lister/phabricator/models.py swh/lister/phabricator/tasks.py swh/lister/phabricator/tests/__init__.py swh/lister/phabricator/tests/api_empty_response.json swh/lister/phabricator/tests/api_response.json swh/lister/phabricator/tests/api_response_undefined_protocol.json swh/lister/phabricator/tests/conftest.py swh/lister/phabricator/tests/test_lister.py swh/lister/phabricator/tests/test_tasks.py swh/lister/pypi/__init__.py swh/lister/pypi/lister.py swh/lister/pypi/models.py swh/lister/pypi/tasks.py swh/lister/pypi/tests/__init__.py +swh/lister/pypi/tests/api_response.html swh/lister/pypi/tests/conftest.py +swh/lister/pypi/tests/test_lister.py swh/lister/pypi/tests/test_tasks.py swh/lister/tests/__init__.py +swh/lister/tests/test_cli.py swh/lister/tests/test_utils.py \ No newline at end of file diff --git a/swh/lister/_version.py b/swh/lister/_version.py index a4a02a6..c9aff30 100644 --- a/swh/lister/_version.py +++ b/swh/lister/_version.py @@ -1,5 +1,5 @@ # This file is automatically generated by setup.py. -__version__ = '0.0.32' -__sha__ = 'g6bd5cca' -__revision__ = 'g6bd5cca' +__version__ = '0.0.33' +__sha__ = 'g09f3605' +__revision__ = 'g09f3605' diff --git a/swh/lister/bitbucket/lister.py b/swh/lister/bitbucket/lister.py index 5877c8d..30787b1 100644 --- a/swh/lister/bitbucket/lister.py +++ b/swh/lister/bitbucket/lister.py @@ -1,83 +1,83 @@ # Copyright (C) 2017-2019 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import logging import iso8601 from datetime import datetime from urllib import parse from swh.lister.bitbucket.models import BitBucketModel from swh.lister.core.indexing_lister import IndexingHttpLister logger = logging.getLogger(__name__) DEFAULT_BITBUCKET_PAGE = 10 class BitBucketLister(IndexingHttpLister): PATH_TEMPLATE = '/repositories?after=%s' MODEL = BitBucketModel LISTER_NAME = 'bitbucket' instance = 'bitbucket' default_min_bound = datetime.utcfromtimestamp(0) def __init__(self, api_baseurl, override_config=None, per_page=100): super().__init__( api_baseurl=api_baseurl, override_config=override_config) if per_page != DEFAULT_BITBUCKET_PAGE: self.PATH_TEMPLATE = '%s&pagelen=%s' % ( self.PATH_TEMPLATE, per_page) # to stay consistent with prior behavior (20 * 10 repositories then) self.flush_packet_db = int( (self.flush_packet_db * DEFAULT_BITBUCKET_PAGE) / per_page) def get_model_from_repo(self, repo): return { 'uid': repo['uuid'], 'indexable': iso8601.parse_date(repo['created_on']), 'name': repo['name'], 'full_name': repo['full_name'], 'html_url': repo['links']['html']['href'], 'origin_url': repo['links']['clone'][0]['href'], 'origin_type': repo['scm'], } def get_next_target_from_response(self, response): """This will read the 'next' link from the api response if any and return it as a datetime. Args: - reponse (Response): requests' response from api call + response (Response): requests' response from api call Returns: next date as a datetime """ body = response.json() next_ = body.get('next') if next_ is not None: next_ = parse.urlparse(next_) return iso8601.parse_date(parse.parse_qs(next_.query)['after'][0]) def transport_response_simplified(self, response): repos = response.json()['values'] return [self.get_model_from_repo(repo) for repo in repos] def request_uri(self, identifier): identifier = parse.quote(identifier.isoformat()) return super().request_uri(identifier or '1970-01-01') def is_within_bounds(self, inner, lower=None, upper=None): # values are expected to be datetimes if lower is None and upper is None: ret = True elif lower is None: ret = inner <= upper elif upper is None: ret = inner >= lower else: ret = lower <= inner <= upper return ret diff --git a/swh/lister/cli.py b/swh/lister/cli.py index 3a6f38f..2445140 100644 --- a/swh/lister/cli.py +++ b/swh/lister/cli.py @@ -1,158 +1,237 @@ -# Copyright (C) 2018 The Software Heritage developers +# Copyright (C) 2018-2019 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import logging import click from swh.core.cli import CONTEXT_SETTINGS logger = logging.getLogger(__name__) SUPPORTED_LISTERS = ['github', 'gitlab', 'bitbucket', 'debian', 'pypi', - 'npm', 'phabricator', 'gnu', 'cran', 'cgit'] + 'npm', 'phabricator', 'gnu', 'cran', 'cgit', 'packagist'] + + +# Base urls for most listers +DEFAULT_BASEURLS = { + 'gitlab': 'https://gitlab.com/api/v4/', + 'phabricator': 'https://forge.softwareheritage.org', + 'cgit': ( + 'http://git.savannah.gnu.org/cgit/', + 'http://git.savannah.gnu.org/git/' + ), +} + + +def get_lister(lister_name, db_url, drop_tables=False, **conf): + """Instantiate a lister given its name. + + Args: + lister_name (str): Lister's name + db_url (str): Db's service url access + conf (dict): Extra configuration (policy, priority for example) + + Returns: + Tuple (instantiated lister, drop_tables function, init schema function, + insert minimum data function) + + """ + override_conf = { + 'lister': { + 'cls': 'local', + 'args': {'db': db_url} + }, + **conf, + } + + # To allow api_baseurl override per lister + if 'api_baseurl' in override_conf: + api_baseurl = override_conf.pop('api_baseurl') + else: + api_baseurl = DEFAULT_BASEURLS.get(lister_name) + + insert_minimum_data_fn = None + if lister_name == 'github': + from .github.models import IndexingModelBase as ModelBase + from .github.lister import GitHubLister + + _lister = GitHubLister(api_baseurl='https://api.github.com', + override_config=override_conf) + elif lister_name == 'bitbucket': + from .bitbucket.models import IndexingModelBase as ModelBase + from .bitbucket.lister import BitBucketLister + _lister = BitBucketLister(api_baseurl='https://api.bitbucket.org/2.0', + override_config=override_conf) + + elif lister_name == 'gitlab': + from .gitlab.models import ModelBase + from .gitlab.lister import GitLabLister + _lister = GitLabLister(api_baseurl=api_baseurl, + override_config=override_conf) + elif lister_name == 'debian': + from .debian.lister import DebianLister + ModelBase = DebianLister.MODEL # noqa + _lister = DebianLister(override_config=override_conf) + + def insert_minimum_data_fn(lister_name, lister): + logger.info('Inserting minimal data for %s', lister_name) + from swh.storage.schemata.distribution import ( + Distribution, Area) + d = Distribution( + name='Debian', + type='deb', + mirror_uri='http://deb.debian.org/debian/') + lister.db_session.add(d) + + areas = [] + for distribution_name in ['stretch']: + for area_name in ['main', 'contrib', 'non-free']: + areas.append(Area( + name='%s/%s' % (distribution_name, area_name), + distribution=d, + )) + lister.db_session.add_all(areas) + lister.db_session.commit() + + elif lister_name == 'pypi': + from .pypi.models import ModelBase + from .pypi.lister import PyPILister + _lister = PyPILister(override_config=override_conf) + + elif lister_name == 'npm': + from .npm.models import IndexingModelBase as ModelBase + from .npm.models import NpmVisitModel + from .npm.lister import NpmLister + _lister = NpmLister(override_config=override_conf) + + def insert_minimum_data_fn(lister_name, lister): + logger.info('Inserting minimal data for %s', lister_name) + if drop_tables: + NpmVisitModel.metadata.drop_all(lister.db_engine) + NpmVisitModel.metadata.create_all(lister.db_engine) + + elif lister_name == 'phabricator': + from .phabricator.models import IndexingModelBase as ModelBase + from .phabricator.lister import PhabricatorLister + _lister = PhabricatorLister(forge_url=api_baseurl, + override_config=override_conf) + + elif lister_name == 'gnu': + from .gnu.models import ModelBase + from .gnu.lister import GNULister + _lister = GNULister(override_config=override_conf) + + elif lister_name == 'cran': + from .cran.models import ModelBase + from .cran.lister import CRANLister + _lister = CRANLister(override_config=override_conf) + + elif lister_name == 'cgit': + from .cgit.models import ModelBase + from .cgit.lister import CGitLister + if isinstance(api_baseurl, str): + _lister = CGitLister(url=api_baseurl, + override_config=override_conf) + else: # tuple + _lister = CGitLister(url=api_baseurl[0], + url_prefix=api_baseurl[1], + override_config=override_conf) + + elif lister_name == 'packagist': + from .packagist.models import ModelBase # noqa + from .packagist.lister import PackagistLister + _lister = PackagistLister(override_config=override_conf) + + else: + raise ValueError( + 'Invalid lister %s: only supported listers are %s' % + (lister_name, SUPPORTED_LISTERS)) + + drop_table_fn = None + if drop_tables: + def drop_table_fn(lister_name, lister): + logger.info('Dropping tables for %s', lister_name) + ModelBase.metadata.drop_all(lister.db_engine) + + def init_schema_fn(lister_name, lister): + logger.info('Creating tables for %s', lister_name) + ModelBase.metadata.create_all(lister.db_engine) + + return _lister, drop_table_fn, init_schema_fn, insert_minimum_data_fn @click.group(name='lister', context_settings=CONTEXT_SETTINGS) @click.pass_context def lister(ctx): '''Software Heritage Lister tools.''' pass @lister.command(name='db-init', context_settings=CONTEXT_SETTINGS) -@click.option( - '--db-url', '-d', default='postgres:///lister-gitlab.com', - help='SQLAlchemy DB URL; see ' - '') # noqa +@click.option('--db-url', '-d', default='postgres:///lister', + help='SQLAlchemy DB URL; see ' + '') # noqa @click.argument('listers', required=1, nargs=-1, type=click.Choice(SUPPORTED_LISTERS + ['all'])) @click.option('--drop-tables', '-D', is_flag=True, default=False, help='Drop tables before creating the database schema') @click.pass_context def cli(ctx, db_url, listers, drop_tables): """Initialize the database model for given listers. """ - override_conf = { - 'lister': { - 'cls': 'local', - 'args': {'db': db_url} - } - } - if 'all' in listers: listers = SUPPORTED_LISTERS - for lister in listers: - logger.info('Initializing lister %s', lister) - insert_minimum_data = None - if lister == 'github': - from .github.models import IndexingModelBase as ModelBase - from .github.lister import GitHubLister - - _lister = GitHubLister( - api_baseurl='https://api.github.com', - override_config=override_conf) - elif lister == 'bitbucket': - from .bitbucket.models import IndexingModelBase as ModelBase - from .bitbucket.lister import BitBucketLister - _lister = BitBucketLister( - api_baseurl='https://api.bitbucket.org/2.0', - override_config=override_conf) - - elif lister == 'gitlab': - from .gitlab.models import ModelBase - from .gitlab.lister import GitLabLister - _lister = GitLabLister( - api_baseurl='https://gitlab.com/api/v4/', - override_config=override_conf) - elif lister == 'debian': - from .debian.lister import DebianLister - ModelBase = DebianLister.MODEL # noqa - _lister = DebianLister(override_config=override_conf) - - def insert_minimum_data(lister): - from swh.storage.schemata.distribution import ( - Distribution, Area) - d = Distribution( - name='Debian', - type='deb', - mirror_uri='http://deb.debian.org/debian/') - lister.db_session.add(d) - - areas = [] - for distribution_name in ['stretch']: - for area_name in ['main', 'contrib', 'non-free']: - areas.append(Area( - name='%s/%s' % (distribution_name, area_name), - distribution=d, - )) - lister.db_session.add_all(areas) - lister.db_session.commit() - - elif lister == 'pypi': - from .pypi.models import ModelBase - from .pypi.lister import PyPILister - _lister = PyPILister(override_config=override_conf) - - elif lister == 'npm': - from .npm.models import IndexingModelBase as ModelBase - from .npm.models import NpmVisitModel - from .npm.lister import NpmLister - _lister = NpmLister(override_config=override_conf) - if drop_tables: - NpmVisitModel.metadata.drop_all(_lister.db_engine) - NpmVisitModel.metadata.create_all(_lister.db_engine) - - elif lister == 'phabricator': - from .phabricator.models import IndexingModelBase as ModelBase - from .phabricator.lister import PhabricatorLister - _lister = PhabricatorLister( - forge_url='https://forge.softwareheritage.org', - api_token='', - override_config=override_conf) - - elif lister == 'gnu': - from .gnu.models import ModelBase - from .gnu.lister import GNULister - _lister = GNULister(override_config=override_conf) - - elif lister == 'cran': - from .cran.models import ModelBase - from .cran.lister import CRANLister - _lister = CRANLister(override_config=override_conf) - - elif lister == 'cgit': - from .cgit.models import ModelBase - from .cgit.lister import CGitLister - _lister = CGitLister( - url='http://git.savannah.gnu.org/cgit/', - url_prefix='http://git.savannah.gnu.org/git/', - override_config=override_conf) - - else: - raise ValueError( - 'Invalid lister %s: only supported listers are %s' % - (lister, SUPPORTED_LISTERS)) - - if drop_tables: - logger.info('Dropping tables for %s', lister) - ModelBase.metadata.drop_all(_lister.db_engine) - - logger.info('Creating tables for %s', lister) - ModelBase.metadata.create_all(_lister.db_engine) - - if insert_minimum_data: - logger.info('Inserting minimal data for %s', lister) - try: - insert_minimum_data(_lister) - except Exception: - logger.warning( - 'Failed to insert minimum data in %s', lister) + for lister_name in listers: + logger.info('Initializing lister %s', lister_name) + lister, drop_schema_fn, init_schema_fn, insert_minimum_data_fn = \ + get_lister(lister_name, db_url, drop_tables=drop_tables) + + if drop_schema_fn: + drop_schema_fn(lister_name, lister) + + init_schema_fn(lister_name, lister) + + if insert_minimum_data_fn: + insert_minimum_data_fn(lister_name, lister) + + +@lister.command(name='run', context_settings=CONTEXT_SETTINGS, + help='Trigger a full listing run for a particular forge ' + 'instance. The output of this listing results in ' + '"oneshot" tasks in the scheduler db with a priority ' + 'defined by the user') +@click.option('--db-url', '-d', default='postgres:///lister', + help='SQLAlchemy DB URL; see ' + '') # noqa +@click.option('--lister', '-l', help='Lister to run', + type=click.Choice(SUPPORTED_LISTERS)) +@click.option('--priority', '-p', default='high', + type=click.Choice(['high', 'medium', 'low']), + help='Task priority for the listed repositories to ingest') +@click.argument('options', nargs=-1) +@click.pass_context +def run(ctx, db_url, lister, priority, options): + from swh.scheduler.cli.utils import parse_options + + if options: + _, kwargs = parse_options(options) + else: + kwargs = {} + + override_config = { + 'priority': priority, + 'policy': 'oneshot', + **kwargs, + } + + lister, _, _, _ = get_lister(lister, db_url, **override_config) + lister.run() if __name__ == '__main__': cli() diff --git a/swh/lister/core/lister_base.py b/swh/lister/core/lister_base.py index 66bfd0e..fe23c5a 100644 --- a/swh/lister/core/lister_base.py +++ b/swh/lister/core/lister_base.py @@ -1,517 +1,525 @@ # Copyright (C) 2015-2018 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import abc import datetime import gzip import json import logging import os import re import time from sqlalchemy import create_engine, func from sqlalchemy.orm import sessionmaker from swh.core import config from swh.scheduler import get_scheduler, utils from swh.storage import get_storage from .abstractattribute import AbstractAttribute logger = logging.getLogger(__name__) def utcnow(): return datetime.datetime.now(tz=datetime.timezone.utc) class FetchError(RuntimeError): def __init__(self, response): self.response = response def __str__(self): return repr(self.response) class ListerBase(abc.ABC, config.SWHConfig): """Lister core base class. Generally a source code hosting service provides an API endpoint for listing the set of stored repositories. A Lister is the discovery service responsible for finding this list, all at once or sequentially by parts, and queueing local tasks to fetch and ingest the referenced repositories. The core method in this class is ingest_data. Any subclasses should be calling this method one or more times to fetch and ingest data from API endpoints. See swh.lister.core.lister_base.IndexingLister for example usage. This class cannot be instantiated. Any instantiable Lister descending from ListerBase must provide at least the required overrides. (see member docstrings for details): Required Overrides: MODEL def transport_request def transport_response_to_string def transport_response_simplified def transport_quota_check Optional Overrides: def filter_before_inject def is_within_bounds """ MODEL = AbstractAttribute('Subclass type (not instance)' ' of swh.lister.core.models.ModelBase' ' customized for a specific service.') LISTER_NAME = AbstractAttribute("Lister's name") def transport_request(self, identifier): """Given a target endpoint identifier to query, try once to request it. Implementation of this method determines the network request protocol. Args: identifier (string): unique identifier for an endpoint query. e.g. If the service indexes lists of repositories by date and time of creation, this might be that as a formatted string. Or it might be an integer UID. Or it might be nothing. It depends on what the service needs. Returns: the entire request response Raises: Will catch internal transport-dependent connection exceptions and raise swh.lister.core.lister_base.FetchError instead. Other non-connection exceptions should propagate unchanged. """ pass def transport_response_to_string(self, response): """Convert the server response into a formatted string for logging. Implementation of this method depends on the shape of the network response object returned by the transport_request method. Args: response: the server response Returns: a pretty string of the response """ pass def transport_response_simplified(self, response): """Convert the server response into list of a dict for each repo in the response, mapping columns in the lister's MODEL class to repo data. Implementation of this method depends on the server API spec and the shape of the network response object returned by the transport_request method. Args: response: response object from the server. Returns: list of repo MODEL dicts ( eg. [{'uid': r['id'], etc.} for r in response.json()] ) """ pass def transport_quota_check(self, response): """Check server response to see if we're hitting request rate limits. Implementation of this method depends on the server communication protocol and API spec and the shape of the network response object returned by the transport_request method. Args: response (session response): complete API query response Returns: 1) must retry request? True/False 2) seconds to delay if True """ pass def filter_before_inject(self, models_list): """Filter models_list entries prior to injection in the db. This is ran directly after `transport_response_simplified`. Default implementation is to have no filtering. Args: models_list: list of dicts returned by transport_response_simplified. Returns: models_list with entries changed according to custom logic. """ return models_list def do_additional_checks(self, models_list): """Execute some additional checks on the model list (after the filtering). Default implementation is to run no check at all and to return the input as is. Args: models_list: list of dicts returned by transport_response_simplified. Returns: models_list with entries if checks ok, False otherwise """ return models_list def is_within_bounds(self, inner, lower=None, upper=None): """See if a sortable value is inside the range [lower,upper]. MAY BE OVERRIDDEN, for example if the server indexable* key is technically sortable but not automatically so. * - ( see: swh.lister.core.indexing_lister.IndexingLister ) Args: inner (sortable type): the value being checked lower (sortable type): optional lower bound upper (sortable type): optional upper bound Returns: whether inner is confined by the optional lower and upper bounds """ try: if lower is None and upper is None: return True elif lower is None: ret = inner <= upper elif upper is None: ret = inner >= lower else: ret = lower <= inner <= upper self.string_pattern_check(inner, lower, upper) except Exception as e: logger.error(str(e) + ': %s, %s, %s' % (('inner=%s%s' % (type(inner), inner)), ('lower=%s%s' % (type(lower), lower)), ('upper=%s%s' % (type(upper), upper))) ) raise return ret # You probably don't need to override anything below this line. DEFAULT_CONFIG = { 'storage': ('dict', { 'cls': 'remote', 'args': { 'url': 'http://localhost:5002/' }, }), 'scheduler': ('dict', { 'cls': 'remote', 'args': { 'url': 'http://localhost:5008/' }, }), 'lister': ('dict', { 'cls': 'local', 'args': { 'db': 'postgresql:///lister', }, }), } @property def CONFIG_BASE_FILENAME(self): # noqa: N802 return 'lister_%s' % self.LISTER_NAME @property def ADDITIONAL_CONFIG(self): # noqa: N802 return { 'credentials': ('dict', {}), 'cache_responses': ('bool', False), 'cache_dir': ('str', '~/.cache/swh/lister/%s' % self.LISTER_NAME), } INITIAL_BACKOFF = 10 MAX_RETRIES = 7 CONN_SLEEP = 10 def __init__(self, override_config=None): self.backoff = self.INITIAL_BACKOFF logger.debug('Loading config from %s' % self.CONFIG_BASE_FILENAME) self.config = self.parse_config_file( base_filename=self.CONFIG_BASE_FILENAME, additional_configs=[self.ADDITIONAL_CONFIG] ) self.config['cache_dir'] = os.path.expanduser(self.config['cache_dir']) if self.config['cache_responses']: config.prepare_folders(self.config, 'cache_dir') if override_config: self.config.update(override_config) logger.debug('%s CONFIG=%s' % (self, self.config)) self.storage = get_storage(**self.config['storage']) self.scheduler = get_scheduler(**self.config['scheduler']) self.db_engine = create_engine(self.config['lister']['args']['db']) self.mk_session = sessionmaker(bind=self.db_engine) self.db_session = self.mk_session() def reset_backoff(self): """Reset exponential backoff timeout to initial level.""" self.backoff = self.INITIAL_BACKOFF def back_off(self): """Get next exponential backoff timeout.""" ret = self.backoff self.backoff *= 10 return ret def safely_issue_request(self, identifier): """Make network request with retries, rate quotas, and response logs. Protocol is handled by the implementation of the transport_request method. Args: identifier: resource identifier Returns: server response """ retries_left = self.MAX_RETRIES do_cache = self.config['cache_responses'] r = None while retries_left > 0: try: r = self.transport_request(identifier) except FetchError: # network-level connection error, try again logger.warning( 'connection error on %s: sleep for %d seconds' % (identifier, self.CONN_SLEEP)) time.sleep(self.CONN_SLEEP) retries_left -= 1 continue if do_cache: self.save_response(r) # detect throttling must_retry, delay = self.transport_quota_check(r) if must_retry: logger.warning( 'rate limited on %s: sleep for %f seconds' % (identifier, delay)) time.sleep(delay) else: # request ok break retries_left -= 1 if not retries_left: logger.warning( 'giving up on %s: max retries exceeded' % identifier) return r def db_query_equal(self, key, value): """Look in the db for a row with key == value Args: key: column key to look at value: value to look for in that column Returns: sqlalchemy.ext.declarative.declarative_base object with the given key == value """ if isinstance(key, str): key = self.MODEL.__dict__[key] return self.db_session.query(self.MODEL) \ .filter(key == value).first() def winnow_models(self, mlist, key, to_remove): """Given a list of models, remove any with matching some member of a list of values. Args: mlist (list of model rows): the initial list of models key (column): the column to filter on to_remove (list): if anything in mlist has column equal to one of the values in to_remove, it will be removed from the result Returns: A list of model rows starting from mlist minus any matching rows """ if isinstance(key, str): key = self.MODEL.__dict__[key] if to_remove: return mlist.filter(~key.in_(to_remove)).all() else: return mlist.all() def db_num_entries(self): """Return the known number of entries in the lister db""" return self.db_session.query(func.count('*')).select_from(self.MODEL) \ .scalar() def db_inject_repo(self, model_dict): """Add/update a new repo to the db and mark it last_seen now. Args: model_dict: dictionary mapping model keys to values Returns: new or updated sqlalchemy.ext.declarative.declarative_base object associated with the injection """ sql_repo = self.db_query_equal('uid', model_dict['uid']) if not sql_repo: sql_repo = self.MODEL(**model_dict) self.db_session.add(sql_repo) else: for k in model_dict: setattr(sql_repo, k, model_dict[k]) sql_repo.last_seen = utcnow() return sql_repo def task_dict(self, origin_type, origin_url, **kwargs): """Return special dict format for the tasks list Args: origin_type (string) origin_url (string) Returns: the same information in a different form """ _type = 'load-%s' % origin_type - _policy = 'recurring' - return utils.create_task_dict(_type, _policy, origin_url) + _policy = kwargs.get('policy', 'recurring') + priority = kwargs.get('priority') + kw = {'priority': priority} if priority else {} + return utils.create_task_dict(_type, _policy, origin_url, **kw) def string_pattern_check(self, a, b, c=None): """When comparing indexable types in is_within_bounds, complex strings may not be allowed to differ in basic structure. If they do, it could be a sign of not understanding the data well. For instance, an ISO 8601 time string cannot be compared against its urlencoded equivalent, but this is an easy mistake to accidentally make. This method acts as a friendly sanity check. Args: a (string): inner component of the is_within_bounds method b (string): lower component of the is_within_bounds method c (string): upper component of the is_within_bounds method Returns: nothing Raises: TypeError if strings a, b, and c don't conform to the same basic pattern. """ if isinstance(a, str): a_pattern = re.sub('[a-zA-Z0-9]', '[a-zA-Z0-9]', re.escape(a)) if (isinstance(b, str) and (re.match(a_pattern, b) is None) or isinstance(c, str) and (re.match(a_pattern, c) is None)): logger.debug(a_pattern) raise TypeError('incomparable string patterns detected') def inject_repo_data_into_db(self, models_list): """Inject data into the db. Args: models_list: list of dicts mapping keys from the db model for each repo to be injected Returns: dict of uid:sql_repo pairs """ injected_repos = {} for m in models_list: injected_repos[m['uid']] = self.db_inject_repo(m) return injected_repos def schedule_missing_tasks(self, models_list, injected_repos): """Find any newly created db entries that do not have been scheduled yet. Args: models_list ([Model]): List of dicts mapping keys in the db model for each repo injected_repos ([dict]): Dict of uid:sql_repo pairs that have just been created Returns: Nothing. Modifies injected_repos. """ tasks = {} def _task_key(m): return '%s-%s' % ( m['type'], json.dumps(m['arguments'], sort_keys=True) ) for m in models_list: ir = injected_repos[m['uid']] if not ir.task_id: + # Patching the model instance to add the policy/priority task + # scheduling + if 'policy' in self.config: + m['policy'] = self.config['policy'] + if 'priority' in self.config: + m['priority'] = self.config['priority'] task_dict = self.task_dict(**m) tasks[_task_key(task_dict)] = (ir, m, task_dict) new_tasks = self.scheduler.create_tasks( (task_dicts for (_, _, task_dicts) in tasks.values())) for task in new_tasks: ir, m, _ = tasks[_task_key(task)] ir.task_id = task['id'] def ingest_data(self, identifier, checks=False): """The core data fetch sequence. Request server endpoint. Simplify and filter response list of repositories. Inject repo information into local db. Queue loader tasks for linked repositories. Args: identifier: Resource identifier. checks (bool): Additional checks required """ # Request (partial?) list of repositories info response = self.safely_issue_request(identifier) if not response: return response, [] models_list = self.transport_response_simplified(response) models_list = self.filter_before_inject(models_list) if checks: models_list = self.do_additional_checks(models_list) if not models_list: return response, [] # inject into local db injected = self.inject_repo_data_into_db(models_list) # queue workers self.schedule_missing_tasks(models_list, injected) return response, injected def save_response(self, response): """Log the response from a server request to a cache dir. Args: response: full server response cache_dir: system path for cache dir Returns: nothing """ datepath = utcnow().isoformat() fname = os.path.join( self.config['cache_dir'], datepath + '.gz', ) with gzip.open(fname, 'w') as f: f.write(bytes( self.transport_response_to_string(response), 'UTF-8' )) diff --git a/swh/lister/core/tests/conftest.py b/swh/lister/core/tests/conftest.py index b8dd868..a1f9346 100644 --- a/swh/lister/core/tests/conftest.py +++ b/swh/lister/core/tests/conftest.py @@ -1,18 +1,19 @@ import pytest from swh.scheduler.tests.conftest import * # noqa @pytest.fixture(scope='session') def celery_includes(): return [ 'swh.lister.bitbucket.tasks', 'swh.lister.cgit.tasks', 'swh.lister.cran.tasks', 'swh.lister.debian.tasks', 'swh.lister.github.tasks', 'swh.lister.gitlab.tasks', 'swh.lister.gnu.tasks', 'swh.lister.npm.tasks', - 'swh.lister.pypi.tasks', + 'swh.lister.packagist.tasks', 'swh.lister.phabricator.tasks', + 'swh.lister.pypi.tasks', ] diff --git a/swh/lister/core/tests/test_lister.py b/swh/lister/core/tests/test_lister.py index 5b4d666..b7ae9e5 100644 --- a/swh/lister/core/tests/test_lister.py +++ b/swh/lister/core/tests/test_lister.py @@ -1,239 +1,340 @@ -# Copyright (C) 2017-2018 the Software Heritage developers +# Copyright (C) 2019 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import abc import time from unittest import TestCase from unittest.mock import Mock, patch import requests_mock from sqlalchemy import create_engine -from testing.postgresql import Postgresql from swh.lister.core.abstractattribute import AbstractAttribute +from swh.lister.tests.test_utils import init_db def noop(*args, **kwargs): pass -@requests_mock.Mocker() class HttpListerTesterBase(abc.ABC): - """Base testing class for subclasses of + """Testing base class for listers. + This contains methods for both :class:`HttpSimpleListerTester` and + :class:`HttpListerTester`. - swh.lister.core.indexing_lister.IndexingHttpLister. - swh.lister.core.page_by_page_lister.PageByPageHttpLister - - See swh.lister.github.tests.test_gh_lister for an example of how + See :class:`swh.lister.gitlab.tests.test_lister` for an example of how to customize for a specific listing service. """ Lister = AbstractAttribute('The lister class to test') - test_re = AbstractAttribute('Compiled regex matching the server url. Must' - ' capture the index value.') lister_subdir = AbstractAttribute('bitbucket, github, etc.') good_api_response_file = AbstractAttribute('Example good response body') - bad_api_response_file = AbstractAttribute('Example bad response body') - first_index = AbstractAttribute('First index in good_api_response') - entries_per_page = AbstractAttribute('Number of results in good response') LISTER_NAME = 'fake-lister' - convert_type = str - """static method used to convert the "request_index" to its right type (for - indexing listers for example, this is in accordance with the model's - "indexable" column). - - """ # May need to override this if the headers are used for something def response_headers(self, request): return {} # May need to override this if the server uses non-standard rate limiting # method. # Please keep the requested retry delay reasonably low. def mock_rate_quota(self, n, request, context): self.rate_limit += 1 context.status_code = 429 context.headers['Retry-After'] = '1' return '{"error":"dummy"}' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.rate_limit = 1 self.response = None self.fl = None self.helper = None if self.__class__ != HttpListerTesterBase: self.run = TestCase.run.__get__(self, self.__class__) else: self.run = noop - def request_index(self, request): - m = self.test_re.search(request.path_url) - if m and (len(m.groups()) > 0): - return self.convert_type(m.group(1)) - - def mock_response(self, request, context): - self.fl.reset_backoff() - self.rate_limit = 1 - context.status_code = 200 - custom_headers = self.response_headers(request) - context.headers.update(custom_headers) - req_index = self.request_index(request) - - if req_index == self.first_index: - response_file = self.good_api_response_file - else: - response_file = self.bad_api_response_file - - with open('swh/lister/%s/tests/%s' % (self.lister_subdir, - response_file), - 'r', encoding='utf-8') as r: - return r.read() - def mock_limit_n_response(self, n, request, context): self.fl.reset_backoff() if self.rate_limit <= n: return self.mock_rate_quota(n, request, context) else: return self.mock_response(request, context) - def mock_limit_once_response(self, request, context): - return self.mock_limit_n_response(1, request, context) - def mock_limit_twice_response(self, request, context): return self.mock_limit_n_response(2, request, context) + def get_api_response(self, identifier): + fl = self.get_fl() + if self.response is None: + self.response = fl.safely_issue_request(identifier) + return self.response + def get_fl(self, override_config=None): """Retrieve an instance of fake lister (fl). """ if override_config or self.fl is None: self.fl = self.Lister(api_baseurl='https://fakeurl', override_config=override_config) self.fl.INITIAL_BACKOFF = 1 self.fl.reset_backoff() return self.fl - def get_api_response(self): - fl = self.get_fl() - if self.response is None: - self.response = fl.safely_issue_request(self.first_index) - return self.response + def disable_scheduler(self, fl): + fl.schedule_missing_tasks = Mock(return_value=None) + def disable_db(self, fl): + fl.winnow_models = Mock(return_value=[]) + fl.db_inject_repo = Mock(return_value=fl.MODEL()) + fl.disable_deleted_repo_tasks = Mock(return_value=None) + + def init_db(self, db, model): + engine = create_engine(db.url()) + model.metadata.create_all(engine) + + @requests_mock.Mocker() def test_is_within_bounds(self, http_mocker): fl = self.get_fl() self.assertFalse(fl.is_within_bounds(1, 2, 3)) self.assertTrue(fl.is_within_bounds(2, 1, 3)) self.assertTrue(fl.is_within_bounds(1, 1, 1)) self.assertTrue(fl.is_within_bounds(1, None, None)) self.assertTrue(fl.is_within_bounds(1, None, 2)) self.assertTrue(fl.is_within_bounds(1, 0, None)) self.assertTrue(fl.is_within_bounds("b", "a", "c")) self.assertFalse(fl.is_within_bounds("a", "b", "c")) self.assertTrue(fl.is_within_bounds("a", None, "c")) self.assertTrue(fl.is_within_bounds("a", None, None)) self.assertTrue(fl.is_within_bounds("b", "a", None)) self.assertFalse(fl.is_within_bounds("a", "b", None)) self.assertTrue(fl.is_within_bounds("aa:02", "aa:01", "aa:03")) self.assertFalse(fl.is_within_bounds("aa:12", None, "aa:03")) with self.assertRaises(TypeError): fl.is_within_bounds(1.0, "b", None) with self.assertRaises(TypeError): fl.is_within_bounds("A:B", "A::B", None) - def test_api_request(self, http_mocker): - http_mocker.get(self.test_re, text=self.mock_limit_twice_response) - with patch.object(time, 'sleep', wraps=time.sleep) as sleepmock: - self.get_api_response() - self.assertEqual(sleepmock.call_count, 2) - def test_repos_list(self, http_mocker): - http_mocker.get(self.test_re, text=self.mock_response) - li = self.get_fl().transport_response_simplified( - self.get_api_response() - ) - self.assertIsInstance(li, list) - self.assertEqual(len(li), self.entries_per_page) +class HttpListerTester(HttpListerTesterBase, abc.ABC): + """Base testing class for subclass of - def test_model_map(self, http_mocker): + :class:`swh.lister.core.indexing_lister.IndexingHttpLister` + + See :class:`swh.lister.github.tests.test_gh_lister` for an example of how + to customize for a specific listing service. + + """ + last_index = AbstractAttribute('Last index in good_api_response') + first_index = AbstractAttribute('First index in good_api_response') + bad_api_response_file = AbstractAttribute('Example bad response body') + entries_per_page = AbstractAttribute('Number of results in good response') + test_re = AbstractAttribute('Compiled regex matching the server url. Must' + ' capture the index value.') + convert_type = str + """static method used to convert the "request_index" to its right type (for + indexing listers for example, this is in accordance with the model's + "indexable" column). + + """ + def mock_response(self, request, context): + self.fl.reset_backoff() + self.rate_limit = 1 + context.status_code = 200 + custom_headers = self.response_headers(request) + context.headers.update(custom_headers) + req_index = self.request_index(request) + + if req_index == self.first_index: + response_file = self.good_api_response_file + else: + response_file = self.bad_api_response_file + + with open('swh/lister/%s/tests/%s' % (self.lister_subdir, + response_file), + 'r', encoding='utf-8') as r: + return r.read() + + def request_index(self, request): + m = self.test_re.search(request.path_url) + if m and (len(m.groups()) > 0): + return self.convert_type(m.group(1)) + + @requests_mock.Mocker() + def test_fetch_multiple_pages_yesdb(self, http_mocker): http_mocker.get(self.test_re, text=self.mock_response) - fl = self.get_fl() - li = fl.transport_response_simplified(self.get_api_response()) - di = li[0] - self.assertIsInstance(di, dict) - pubs = [k for k in vars(fl.MODEL).keys() if not k.startswith('_')] - for k in pubs: - if k not in ['last_seen', 'task_id', 'id']: - self.assertIn(k, di) + db = init_db() - def disable_scheduler(self, fl): - fl.schedule_missing_tasks = Mock(return_value=None) + fl = self.get_fl(override_config={ + 'lister': { + 'cls': 'local', + 'args': {'db': db.url()} + } + }) + self.init_db(db, fl.MODEL) - def disable_db(self, fl): - fl.winnow_models = Mock(return_value=[]) - fl.db_inject_repo = Mock(return_value=fl.MODEL()) - fl.disable_deleted_repo_tasks = Mock(return_value=None) + self.disable_scheduler(fl) + + fl.run(min_bound=self.first_index) + + self.assertEqual(fl.db_last_index(), self.last_index) + partitions = fl.db_partition_indices(5) + self.assertGreater(len(partitions), 0) + for k in partitions: + self.assertLessEqual(len(k), 5) + self.assertGreater(len(k), 0) + @requests_mock.Mocker() def test_fetch_none_nodb(self, http_mocker): http_mocker.get(self.test_re, text=self.mock_response) fl = self.get_fl() self.disable_scheduler(fl) self.disable_db(fl) fl.run(min_bound=1, max_bound=1) # stores no results + # FIXME: Determine what this method tries to test and add checks to + # actually test + @requests_mock.Mocker() def test_fetch_one_nodb(self, http_mocker): http_mocker.get(self.test_re, text=self.mock_response) fl = self.get_fl() self.disable_scheduler(fl) self.disable_db(fl) fl.run(min_bound=self.first_index, max_bound=self.first_index) + # FIXME: Determine what this method tries to test and add checks to + # actually test + @requests_mock.Mocker() def test_fetch_multiple_pages_nodb(self, http_mocker): http_mocker.get(self.test_re, text=self.mock_response) fl = self.get_fl() self.disable_scheduler(fl) self.disable_db(fl) fl.run(min_bound=self.first_index) + # FIXME: Determine what this method tries to test and add checks to + # actually test - def init_db(self, db, model): - engine = create_engine(db.url()) - model.metadata.create_all(engine) - + @requests_mock.Mocker() + def test_repos_list(self, http_mocker): + """Test the number of repos listed by the lister -class HttpListerTester(HttpListerTesterBase, abc.ABC): - last_index = AbstractAttribute('Last index in good_api_response') + """ + http_mocker.get(self.test_re, text=self.mock_response) + li = self.get_fl().transport_response_simplified( + self.get_api_response(self.first_index) + ) + self.assertIsInstance(li, list) + self.assertEqual(len(li), self.entries_per_page) @requests_mock.Mocker() - def test_fetch_multiple_pages_yesdb(self, http_mocker): + def test_model_map(self, http_mocker): + """Check if all the keys of model are present in the model created by + the `transport_response_simplified` + + """ http_mocker.get(self.test_re, text=self.mock_response) - initdb_args = Postgresql.DEFAULT_SETTINGS['initdb_args'] - initdb_args = ' '.join([initdb_args, '-E UTF-8']) - db = Postgresql(initdb_args=initdb_args) + fl = self.get_fl() + li = fl.transport_response_simplified( + self.get_api_response(self.first_index)) + di = li[0] + self.assertIsInstance(di, dict) + pubs = [k for k in vars(fl.MODEL).keys() if not k.startswith('_')] + for k in pubs: + if k not in ['last_seen', 'task_id', 'id']: + self.assertIn(k, di) - fl = self.get_fl(override_config={ - 'lister': { - 'cls': 'local', - 'args': {'db': db.url()} - } - }) - self.init_db(db, fl.MODEL) + @requests_mock.Mocker() + def test_api_request(self, http_mocker): + """Test API request for rate limit handling - self.disable_scheduler(fl) + """ + http_mocker.get(self.test_re, text=self.mock_limit_twice_response) + with patch.object(time, 'sleep', wraps=time.sleep) as sleepmock: + self.get_api_response(self.first_index) + self.assertEqual(sleepmock.call_count, 2) - fl.run(min_bound=self.first_index) - self.assertEqual(fl.db_last_index(), self.last_index) - partitions = fl.db_partition_indices(5) - self.assertGreater(len(partitions), 0) - for k in partitions: - self.assertLessEqual(len(k), 5) - self.assertGreater(len(k), 0) +class HttpSimpleListerTester(HttpListerTesterBase, abc.ABC): + """Base testing class for subclass of + :class:`swh.lister.core.simple)_lister.SimpleLister` + + See :class:`swh.lister.pypi.tests.test_lister` for an example of how + to customize for a specific listing service. + + """ + entries = AbstractAttribute('Number of results in good response') + PAGE = AbstractAttribute("The server api's unique page to retrieve and " + "parse for information") + + def get_fl(self, override_config=None): + """Retrieve an instance of fake lister (fl). + + """ + if override_config or self.fl is None: + self.fl = self.Lister( + override_config=override_config) + self.fl.INITIAL_BACKOFF = 1 + + self.fl.reset_backoff() + return self.fl + + def mock_response(self, request, context): + self.fl.reset_backoff() + self.rate_limit = 1 + context.status_code = 200 + custom_headers = self.response_headers(request) + context.headers.update(custom_headers) + response_file = self.good_api_response_file + + with open('swh/lister/%s/tests/%s' % (self.lister_subdir, + response_file), + 'r', encoding='utf-8') as r: + return r.read() + + @requests_mock.Mocker() + def test_api_request(self, http_mocker): + """Test API request for rate limit handling + + """ + http_mocker.get(self.PAGE, text=self.mock_limit_twice_response) + with patch.object(time, 'sleep', wraps=time.sleep) as sleepmock: + self.get_api_response(0) + self.assertEqual(sleepmock.call_count, 2) + + @requests_mock.Mocker() + def test_model_map(self, http_mocker): + """Check if all the keys of model are present in the model created by + the `transport_response_simplified` + + """ + http_mocker.get(self.PAGE, text=self.mock_response) + fl = self.get_fl() + li = fl.list_packages(self.get_api_response(0)) + li = fl.transport_response_simplified(li) + di = li[0] + self.assertIsInstance(di, dict) + pubs = [k for k in vars(fl.MODEL).keys() if not k.startswith('_')] + for k in pubs: + if k not in ['last_seen', 'task_id', 'id']: + self.assertIn(k, di) + + @requests_mock.Mocker() + def test_repos_list(self, http_mocker): + """Test the number of packages listed by the lister + + """ + http_mocker.get(self.PAGE, text=self.mock_response) + li = self.get_fl().list_packages( + self.get_api_response(0) + ) + self.assertIsInstance(li, list) + self.assertEqual(len(li), self.entries) diff --git a/swh/lister/cran/lister.py b/swh/lister/cran/lister.py index 55428b5..cae5f1f 100644 --- a/swh/lister/cran/lister.py +++ b/swh/lister/cran/lister.py @@ -1,121 +1,122 @@ # Copyright (C) 2019 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import subprocess import json import logging import pkg_resources from collections import defaultdict from swh.lister.cran.models import CRANModel from swh.scheduler.utils import create_task_dict from swh.core import utils from swh.lister.core.simple_lister import SimpleLister class CRANLister(SimpleLister): MODEL = CRANModel LISTER_NAME = 'cran' instance = 'cran' descriptions = defaultdict(dict) def task_dict(self, origin_type, origin_url, **kwargs): """Return task format dict This is overridden from the lister_base as more information is needed for the ingestion task creation. """ return create_task_dict( - 'load-%s' % origin_type, 'recurring', + 'load-%s' % origin_type, + kwargs.get('policy', 'recurring'), kwargs.get('name'), origin_url, kwargs.get('version'), project_metadata=self.descriptions[kwargs.get('name')]) def r_script_request(self): """Runs R script which uses inbuilt API to return a json response containing data about all the R packages Returns: List of dictionaries example [ {'Package': 'A3', 'Version': '1.0.0', 'Title': 'Accurate, Adaptable, and Accessible Error Metrics for Predictive\nModels', 'Description': 'Supplies tools for tabulating and analyzing the results of predictive models. The methods employed are ... ' } {'Package': 'abbyyR', 'Version': '0.5.4', 'Title': 'Access to Abbyy Optical Character Recognition (OCR) API', 'Description': 'Get text from images of text using Abbyy Cloud Optical Character\n ...' } ... ] """ file_path = pkg_resources.resource_filename('swh.lister.cran', 'list_all_packages.R') response = subprocess.run(file_path, stdout=subprocess.PIPE, shell=False) return json.loads(response.stdout) def get_model_from_repo(self, repo): """Transform from repository representation to model """ self.descriptions[repo["Package"]] = repo['Description'] project_url = 'https://cran.r-project.org/src/contrib' \ '/%(Package)s_%(Version)s.tar.gz' % repo return { 'uid': repo["Package"], 'name': repo["Package"], 'full_name': repo["Title"], 'version': repo["Version"], 'html_url': project_url, 'origin_url': project_url, 'origin_type': 'cran', } def transport_response_simplified(self, response): """Transform response to list for model manipulation """ return [self.get_model_from_repo(repo) for repo in response] def ingest_data(self, identifier, checks=False): """Rework the base ingest_data. Request server endpoint which gives all in one go. Simplify and filter response list of repositories. Inject repo information into local db. Queue loader tasks for linked repositories. Args: identifier: Resource identifier (unused) checks (bool): Additional checks required (unused) """ response = self.r_script_request() if not response: return response, [] models_list = self.transport_response_simplified(response) models_list = self.filter_before_inject(models_list) all_injected = [] for models in utils.grouper(models_list, n=10000): models = list(models) logging.debug('models: %s' % len(models)) # inject into local db injected = self.inject_repo_data_into_db(models) # queue workers self.create_missing_origins_and_tasks(models, injected) all_injected.append(injected) # flush self.db_session.commit() self.db_session = self.mk_session() return response, all_injected diff --git a/swh/lister/gnu/lister.py b/swh/lister/gnu/lister.py index 1029de7..48a9e7b 100644 --- a/swh/lister/gnu/lister.py +++ b/swh/lister/gnu/lister.py @@ -1,222 +1,223 @@ # Copyright (C) 2019 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import random import gzip import json import requests from pathlib import Path from collections import defaultdict from .models import GNUModel from swh.scheduler import utils from swh.lister.core.simple_lister import SimpleLister class GNULister(SimpleLister): MODEL = GNUModel LISTER_NAME = 'gnu' TREE_URL = 'https://ftp.gnu.org/tree.json.gz' BASE_URL = 'https://ftp.gnu.org' instance = 'gnu' tarballs = defaultdict(dict) # Dict of key with project name value the # associated is list of tarballs of package to ingest from the gnu mirror def task_dict(self, origin_type, origin_url, **kwargs): """ Return task format dict This is overridden from the lister_base as more information is needed for the ingestion task creation. """ return utils.create_task_dict( - 'load-%s' % origin_type, 'recurring', kwargs.get('name'), + 'load-%s' % origin_type, kwargs.get('policy', 'recurring'), + kwargs.get('name'), origin_url, tarballs=self.tarballs[kwargs.get('name')]) def get_file(self): ''' Download and unzip tree.json.gz file and returns its content in JSON format Returns File content in dictionary format ''' response = requests.get(self.TREE_URL, allow_redirects=True) uncompressed_content = gzip.decompress(response.content) return json.loads(uncompressed_content.decode('utf-8')) def safely_issue_request(self, identifier): ''' Make network request to download the file which has file structure of the GNU website. Args: identifier: resource identifier Returns: Server response ''' return self.get_file() def list_packages(self, response): """ List the actual gnu origins with their names,url and the list of all the tarball for a package from the response. Args: response : File structure of the website in dictionary format Returns: A list of all the packages with their names, url of their root directory and the tarballs present for the particular package. [ {'name': '3dldf', 'url': 'https://ftp.gnu.org/gnu/3dldf/', 'tarballs': [ {'archive': 'https://ftp.gnu.org/gnu/3dldf/3DLDF-1.1.3.tar.gz', 'date': '1071002600'}, {'archive': 'https://ftp.gnu.org/gnu/3dldf/3DLDF-1.1.4.tar.gz', 'date': '1071078759'}} ] }, {'name': '8sync', 'url': 'https://ftp.gnu.org/gnu/8sync/', 'tarballs': [ {'archive': 'https://ftp.gnu.org/gnu/8sync/8sync-0.1.0.tar.gz', 'date': '1461357336'}, {'archive': 'https://ftp.gnu.org/gnu/8sync/8sync-0.2.0.tar.gz', 'date': '1480991830'} ] ] """ response = filter_directories(response) packages = [] for directory in response: content = directory['contents'] for repo in content: if repo['type'] == 'directory': package_url = '%s/%s/%s/' % (self.BASE_URL, directory['name'], repo['name']) package_tarballs = find_tarballs( repo['contents'], package_url) if package_tarballs != []: repo_details = { 'name': repo['name'], 'url': package_url, 'time_modified': repo['time'], } self.tarballs[repo['name']] = package_tarballs packages.append(repo_details) random.shuffle(packages) return packages def get_model_from_repo(self, repo): """Transform from repository representation to model """ return { 'uid': repo['name'], 'name': repo['name'], 'full_name': repo['name'], 'html_url': repo['url'], 'origin_url': repo['url'], 'time_last_updated': repo['time_modified'], 'origin_type': 'tar', } def transport_response_simplified(self, response): """Transform response to list for model manipulation """ return [self.get_model_from_repo(repo) for repo in response] def find_tarballs(package_file_structure, url): ''' Recursively lists all the tarball present in the folder and subfolders for a particular package url. Args package_file_structure : File structure of the package root directory url : URL of the corresponding package Returns List of all the tarball urls and the last their time of update example- For a package called 3dldf [ {'archive': 'https://ftp.gnu.org/gnu/3dldf/3DLDF-1.1.3.tar.gz', 'date': '1071002600'} {'archive': 'https://ftp.gnu.org/gnu/3dldf/3DLDF-1.1.4.tar.gz', 'date': '1071078759'} {'archive': 'https://ftp.gnu.org/gnu/3dldf/3DLDF-1.1.5.1.tar.gz', 'date': '1074278633'} ... ] ''' tarballs = [] for single_file in package_file_structure: file_type = single_file['type'] file_name = single_file['name'] if file_type == 'file': if file_extension_check(file_name): tarballs .append({ "archive": url + file_name, "date": single_file['time'] }) # It will recursively check for tarballs in all sub-folders elif file_type == 'directory': tarballs_in_dir = find_tarballs( single_file['contents'], url + file_name + '/') tarballs.extend(tarballs_in_dir) return tarballs def filter_directories(response): ''' Keep only gnu and old-gnu folders from JSON ''' final_response = [] file_system = response[0]['contents'] for directory in file_system: if directory['name'] in ('gnu', 'old-gnu'): final_response.append(directory) return final_response def file_extension_check(file_name): ''' Check for the extension of the file, if the file is of zip format of .tar.x format, where x could be anything, then returns true. Args: file_name : name of the file for which the extensions is needs to be checked. Returns: True or False example file_extension_check('abc.zip') will return True file_extension_check('abc.tar.gz') will return True file_extension_check('abc.tar.gz.sig') will return False ''' file_suffixes = Path(file_name).suffixes if len(file_suffixes) == 1 and file_suffixes[-1] == '.zip': return True elif len(file_suffixes) > 1: if file_suffixes[-1] == '.zip' or file_suffixes[-2] == '.tar': return True return False diff --git a/swh/lister/packagist/__init__.py b/swh/lister/packagist/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/lister/packagist/lister.py b/swh/lister/packagist/lister.py new file mode 100644 index 0000000..93a9fb8 --- /dev/null +++ b/swh/lister/packagist/lister.py @@ -0,0 +1,85 @@ +# Copyright (C) 2019 the Software Heritage developers +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import random +import json +from .models import PackagistModel + +from swh.scheduler import utils +from swh.lister.core.simple_lister import SimpleLister +from swh.lister.core.lister_transports import ListerOnePageApiTransport + + +class PackagistLister(ListerOnePageApiTransport, SimpleLister): + """List packages available in the Packagist package manger. + + The lister sends the request to the url present in the class + variable `PAGE`, to receive a list of all the package names + present in the Packagist package manger. Iterates over all the + packages and constructs the metadata url of the package from + the name of the package and creates a loading task. + + Task: + Type: load-packagist + Policy: recurring + Args: + + + + Example: + Type: load-packagist + Policy: recurring + Args: + 'hypejunction/hypegamemechanics' + 'https://repo.packagist.org/p/hypejunction/hypegamemechanics.json' + + """ + MODEL = PackagistModel + LISTER_NAME = 'packagist' + PAGE = 'https://packagist.org/packages/list.json' + instance = 'packagist' + + def __init__(self, override_config=None): + ListerOnePageApiTransport .__init__(self) + SimpleLister.__init__(self, override_config=override_config) + + def task_dict(self, origin_type, origin_url, **kwargs): + """Return task format dict + + This is overridden from the lister_base as more information is + needed for the ingestion task creation. + + """ + return utils.create_task_dict('load-%s' % origin_type, + kwargs.get('policy', 'recurring'), + kwargs.get('name'), origin_url) + + def list_packages(self, response): + """List the actual packagist origins from the response. + + """ + response = json.loads(response.text) + packages = [name for name in response['packageNames']] + random.shuffle(packages) + return packages + + def get_model_from_repo(self, repo_name): + """Transform from repository representation to model + + """ + url = 'https://repo.packagist.org/p/%s.json' % repo_name + return { + 'uid': repo_name, + 'name': repo_name, + 'full_name': repo_name, + 'html_url': url, + 'origin_url': url, + 'origin_type': 'packagist', + } + + def transport_response_simplified(self, response): + """Transform response to list for model manipulation + + """ + return [self.get_model_from_repo(repo_name) for repo_name in response] diff --git a/swh/lister/packagist/models.py b/swh/lister/packagist/models.py new file mode 100644 index 0000000..36a6333 --- /dev/null +++ b/swh/lister/packagist/models.py @@ -0,0 +1,16 @@ +# Copyright (C) 2019 the Software Heritage developers +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from sqlalchemy import Column, String + +from ..core.models import ModelBase + + +class PackagistModel(ModelBase): + """a Packagist repository representation + + """ + __tablename__ = 'packagist_repo' + + uid = Column(String, primary_key=True) diff --git a/swh/lister/packagist/tasks.py b/swh/lister/packagist/tasks.py new file mode 100644 index 0000000..e17e892 --- /dev/null +++ b/swh/lister/packagist/tasks.py @@ -0,0 +1,17 @@ +# Copyright (C) 2019 the Software Heritage developers +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from swh.scheduler.celery_backend.config import app + +from .lister import PackagistLister + + +@app.task(name=__name__ + '.PackagistListerTask') +def packagist_lister(**lister_args): + PackagistLister(**lister_args).run() + + +@app.task(name=__name__ + '.ping') +def ping(): + return 'OK' diff --git a/swh/lister/packagist/tests/__init__.py b/swh/lister/packagist/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/lister/packagist/tests/api_response.json b/swh/lister/packagist/tests/api_response.json new file mode 100644 index 0000000..2e4843c --- /dev/null +++ b/swh/lister/packagist/tests/api_response.json @@ -0,0 +1,9 @@ +{ + "packageNames": [ + "0.0.0/composer-include-files", + "0.0.0/laravel-env-shim", + "0.0.1/try-make-package", + "0099ff/dialogflowphp", + "00f100/array_dot" + ] +} \ No newline at end of file diff --git a/swh/lister/packagist/tests/conftest.py b/swh/lister/packagist/tests/conftest.py new file mode 100644 index 0000000..507fef9 --- /dev/null +++ b/swh/lister/packagist/tests/conftest.py @@ -0,0 +1 @@ +from swh.lister.core.tests.conftest import * # noqa diff --git a/swh/lister/packagist/tests/test_lister.py b/swh/lister/packagist/tests/test_lister.py new file mode 100644 index 0000000..fb58424 --- /dev/null +++ b/swh/lister/packagist/tests/test_lister.py @@ -0,0 +1,66 @@ +# Copyright (C) 2019 the Software Heritage developers +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import unittest +import requests_mock +from unittest.mock import patch +from swh.lister.packagist.lister import PackagistLister +from swh.lister.core.tests.test_lister import HttpSimpleListerTester + + +expected_packages = ['0.0.0/composer-include-files', '0.0.0/laravel-env-shim', + '0.0.1/try-make-package', '0099ff/dialogflowphp', + '00f100/array_dot'] + +expected_model = { + 'uid': '0099ff/dialogflowphp', + 'name': '0099ff/dialogflowphp', + 'full_name': '0099ff/dialogflowphp', + 'html_url': + 'https://repo.packagist.org/p/0099ff/dialogflowphp.json', + 'origin_url': + 'https://repo.packagist.org/p/0099ff/dialogflowphp.json', + 'origin_type': 'packagist', + } + + +class PackagistListerTester(HttpSimpleListerTester, unittest.TestCase): + Lister = PackagistLister + PAGE = 'https://packagist.org/packages/list.json' + lister_subdir = 'packagist' + good_api_response_file = 'api_response.json' + entries = 5 + + @requests_mock.Mocker() + def test_list_packages(self, http_mocker): + """List packages from simple api page should retrieve all packages within + + """ + http_mocker.get(self.PAGE, text=self.mock_response) + fl = self.get_fl() + packages = fl.list_packages(self.get_api_response(0)) + + for package in expected_packages: + assert package in packages + + def test_transport_response_simplified(self): + """Test model created by the lister + + """ + fl = self.get_fl() + model = fl.transport_response_simplified(['0099ff/dialogflowphp']) + assert len(model) == 1 + for key, values in model[0].items(): + assert values == expected_model[key] + + def test_task_dict(self): + """Test the task creation of lister + + """ + fl = self.get_fl() + with patch('swh.lister.packagist.lister.utils.create_task_dict') as mock_create_tasks: # noqa + fl.task_dict(origin_type='packagist', origin_url='https://abc', + name='test_pack') + mock_create_tasks.assert_called_once_with( + 'load-packagist', 'recurring', 'test_pack', 'https://abc') diff --git a/swh/lister/packagist/tests/test_tasks.py b/swh/lister/packagist/tests/test_tasks.py new file mode 100644 index 0000000..cbe807d --- /dev/null +++ b/swh/lister/packagist/tests/test_tasks.py @@ -0,0 +1,31 @@ +# Copyright (C) 2019 the Software Heritage developers +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from unittest.mock import patch + + +def test_ping(swh_app, celery_session_worker): + res = swh_app.send_task( + 'swh.lister.packagist.tasks.ping') + assert res + res.wait() + assert res.successful() + assert res.result == 'OK' + + +@patch('swh.lister.packagist.tasks.PackagistLister') +def test_lister(lister, swh_app, celery_session_worker): + # setup the mocked PackagistLister + lister.return_value = lister + lister.run.return_value = None + + res = swh_app.send_task( + 'swh.lister.packagist.tasks.PackagistListerTask') + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with() + lister.db_last_index.assert_not_called() + lister.run.assert_called_once_with() diff --git a/swh/lister/pypi/lister.py b/swh/lister/pypi/lister.py index f45a3a6..c8e0e0d 100644 --- a/swh/lister/pypi/lister.py +++ b/swh/lister/pypi/lister.py @@ -1,76 +1,76 @@ # Copyright (C) 2018-2019 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import random import xmltodict from .models import PyPIModel from swh.scheduler import utils from swh.lister.core.simple_lister import SimpleLister from swh.lister.core.lister_transports import ListerOnePageApiTransport class PyPILister(ListerOnePageApiTransport, SimpleLister): MODEL = PyPIModel LISTER_NAME = 'pypi' PAGE = 'https://pypi.org/simple/' instance = 'pypi' # As of today only the main pypi.org is used def __init__(self, override_config=None): ListerOnePageApiTransport .__init__(self) SimpleLister.__init__(self, override_config=override_config) def task_dict(self, origin_type, origin_url, **kwargs): """(Override) Return task format dict This is overridden from the lister_base as more information is needed for the ingestion task creation. """ _type = 'load-%s' % origin_type - _policy = 'recurring' + _policy = kwargs.get('policy', 'recurring') project_name = kwargs.get('name') project_metadata_url = kwargs.get('html_url') return utils.create_task_dict( _type, _policy, project_name, origin_url, project_metadata_url=project_metadata_url) def list_packages(self, response): """(Override) List the actual pypi origins from the response. """ result = xmltodict.parse(response.content) _packages = [p['#text'] for p in result['html']['body']['a']] random.shuffle(_packages) return _packages def _compute_urls(self, repo_name): """Returns a tuple (project_url, project_metadata_url) """ return ( 'https://pypi.org/project/%s/' % repo_name, 'https://pypi.org/pypi/%s/json' % repo_name ) def get_model_from_repo(self, repo_name): """(Override) Transform from repository representation to model """ project_url, project_url_meta = self._compute_urls(repo_name) return { 'uid': repo_name, 'name': repo_name, 'full_name': repo_name, 'html_url': project_url_meta, 'origin_url': project_url, 'origin_type': 'pypi', } def transport_response_simplified(self, response): """(Override) Transform response to list for model manipulation """ return [self.get_model_from_repo(repo_name) for repo_name in response] diff --git a/swh/lister/pypi/tests/api_response.html b/swh/lister/pypi/tests/api_response.html new file mode 100644 index 0000000..40ec945 --- /dev/null +++ b/swh/lister/pypi/tests/api_response.html @@ -0,0 +1,12 @@ + + + + Simple index + + + 0lever-so + 0lever-utils + 0-orchestrator + 0wned + + \ No newline at end of file diff --git a/swh/lister/pypi/tests/test_lister.py b/swh/lister/pypi/tests/test_lister.py new file mode 100644 index 0000000..bda21a2 --- /dev/null +++ b/swh/lister/pypi/tests/test_lister.py @@ -0,0 +1,64 @@ +# Copyright (C) 2019 the Software Heritage developers +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import requests_mock +import unittest +from unittest.mock import patch +from swh.lister.pypi.lister import PyPILister +from swh.lister.core.tests.test_lister import HttpSimpleListerTester + +lister = PyPILister() + +expected_packages = ['0lever-so', '0lever-utils', '0-orchestrator', '0wned'] + +expected_model = { + 'uid': 'arrow', + 'name': 'arrow', + 'full_name': 'arrow', + 'html_url': 'https://pypi.org/pypi/arrow/json', + 'origin_url': 'https://pypi.org/project/arrow/', + 'origin_type': 'pypi', + } + + +class PyPIListerTester(HttpSimpleListerTester, unittest.TestCase): + Lister = PyPILister + PAGE = 'https://pypi.org/simple/' + lister_subdir = 'pypi' + good_api_response_file = 'api_response.html' + entries = 4 + + @requests_mock.Mocker() + def test_list_packages(self, http_mocker): + """List packages from simple api page should retrieve all packages within + + """ + http_mocker.get(self.PAGE, text=self.mock_response) + fl = self.get_fl() + packages = fl.list_packages(self.get_api_response(0)) + + for package in expected_packages: + assert package in packages + + def test_transport_response_simplified(self): + """Test model created by the lister + + """ + fl = self.get_fl() + model = fl.transport_response_simplified(['arrow']) + assert len(model) == 1 + for key, values in model[0].items(): + assert values == expected_model[key] + + def test_task_dict(self): + """Test the task creation of lister + + """ + with patch('swh.lister.pypi.lister.utils.create_task_dict') as mock_create_tasks: # noqa + lister.task_dict(origin_type='pypi', origin_url='https://abc', + name='test_pack', html_url='https://def') + + mock_create_tasks.assert_called_once_with( + 'load-pypi', 'recurring', 'test_pack', 'https://abc', + project_metadata_url='https://def') diff --git a/swh/lister/tests/test_cli.py b/swh/lister/tests/test_cli.py new file mode 100644 index 0000000..57ea7a3 --- /dev/null +++ b/swh/lister/tests/test_cli.py @@ -0,0 +1,95 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import pytest + +from swh.lister.core.lister_base import ListerBase +from swh.lister.cli import get_lister, SUPPORTED_LISTERS, DEFAULT_BASEURLS + +from .test_utils import init_db + + +def test_get_lister_wrong_input(): + """Unsupported lister should raise""" + with pytest.raises(ValueError) as e: + get_lister('unknown', 'db-url') + + assert "Invalid lister" in str(e.value) + + +def test_get_lister(): + """Instantiating a supported lister should be ok + + """ + db_url = init_db().url() + supported_listers_with_init = {'npm', 'debian'} + supported_listers = set(SUPPORTED_LISTERS) - supported_listers_with_init + for lister_name in supported_listers: + lst, drop_fn, init_fn, insert_data_fn = get_lister(lister_name, db_url) + + assert isinstance(lst, ListerBase) + assert drop_fn is None + assert init_fn is not None + assert insert_data_fn is None + + for lister_name in supported_listers_with_init: + lst, drop_fn, init_fn, insert_data_fn = get_lister(lister_name, db_url) + + assert isinstance(lst, ListerBase) + assert drop_fn is None + assert init_fn is not None + assert insert_data_fn is not None + + for lister_name in supported_listers_with_init: + lst, drop_fn, init_fn, insert_data_fn = get_lister(lister_name, db_url, + drop_tables=True) + + assert isinstance(lst, ListerBase) + assert drop_fn is not None + assert init_fn is not None + assert insert_data_fn is not None + + +def test_get_lister_override(): + """Overriding the lister configuration should populate its config + + """ + db_url = init_db().url() + + listers = { + 'gitlab': ('api_baseurl', 'https://gitlab.uni/api/v4/'), + 'phabricator': ('forge_url', 'https://somewhere.org'), + 'cgit': ('url_prefix', 'https://some-cgit.eu/'), + } + + # check the override ends up defined in the lister + for lister_name, (url_key, url_value) in listers.items(): + lst, drop_fn, init_fn, insert_data_fn = get_lister( + lister_name, db_url, **{ + 'api_baseurl': url_value, + 'priority': 'high', + 'policy': 'oneshot', + }) + + assert getattr(lst, url_key) == url_value + assert lst.config['priority'] == 'high' + assert lst.config['policy'] == 'oneshot' + + # check the default urls are used and not the override (since it's not + # passed) + for lister_name, (url_key, url_value) in listers.items(): + lst, drop_fn, init_fn, insert_data_fn = get_lister(lister_name, db_url) + + # no override so this does not end up in lister's configuration + assert url_key not in lst.config + + # then the default base url is used + default_url = DEFAULT_BASEURLS[lister_name] + if isinstance(default_url, tuple): # cgit implementation detail... + default_url = default_url[1] + + assert getattr(lst, url_key) == default_url + assert 'priority' not in lst.config + assert 'oneshot' not in lst.config diff --git a/swh/lister/tests/test_utils.py b/swh/lister/tests/test_utils.py index 5d9f476..1fe7e7a 100644 --- a/swh/lister/tests/test_utils.py +++ b/swh/lister/tests/test_utils.py @@ -1,24 +1,38 @@ -# Copyright (C) 2018 the Software Heritage developers +# Copyright (C) 2018-2019 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import unittest +from testing.postgresql import Postgresql + from swh.lister import utils class UtilsTest(unittest.TestCase): def test_split_range(self): actual_ranges = list(utils.split_range(14, 5)) self.assertEqual(actual_ranges, [(0, 5), (5, 10), (10, 14)]) actual_ranges = list(utils.split_range(19, 10)) self.assertEqual(actual_ranges, [(0, 10), (10, 19)]) def test_split_range_errors(self): with self.assertRaises(TypeError): list(utils.split_range(None, 1)) with self.assertRaises(TypeError): list(utils.split_range(100, None)) + + +def init_db(): + """Factorize the db_url instantiation + + Returns: + db object to ease db manipulation + + """ + initdb_args = Postgresql.DEFAULT_SETTINGS['initdb_args'] + initdb_args = ' '.join([initdb_args, '-E UTF-8']) + return Postgresql(initdb_args=initdb_args) diff --git a/version.txt b/version.txt index 30ffa0d..f20dce7 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v0.0.32-0-g6bd5cca \ No newline at end of file +v0.0.33-0-g09f3605 \ No newline at end of file