diff --git a/MANIFEST.in b/MANIFEST.in index 81c908b..7ccb237 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include Makefile include README include requirements.txt include requirements-swh.txt include requirements-test.txt include version.txt +recursive-include swh/lister/*/tests/ *.json diff --git a/PKG-INFO b/PKG-INFO index 08d1b5d..1c786e3 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,10 +1,10 @@ Metadata-Version: 1.0 Name: swh.lister -Version: 0.0.10 +Version: 0.0.11 Summary: Software Heritage GitHub lister Home-page: https://forge.softwareheritage.org/diffusion/DLSGH/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN diff --git a/README b/README deleted file mode 100644 index ae7b7ed..0000000 --- a/README +++ /dev/null @@ -1,47 +0,0 @@ - -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. - - -Dependencies -============ - -- python3 -- python3-psycopg2 -- python3-requests -- python3-sqlalchemy - - -Deployment -========== - -1. git clone under $GHLISTER_ROOT (of your choosing) -2. mkdir ~/.config/swh/ ~/.cache/swh/lister-github/ -3. edit $GHLISTER_ROOT/etc/crontab and customize GHLISTER_ROOT -4. crontab $GHLISTER_ROOT/etc/crontab -5. create configuration file ~/.config/swh/lister-github.ini - -Sample configuration file -------------------------- - -cat ~/.config/swh/lister-github.ini - - [main] - db_url = postgres:///github - # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls - cache_dir = /home/zack/.cache/swh/lister-github - log_dir = /home/zack/.cache/swh/lister-github - username = foobar # github username - password = quux # github password diff --git a/README.md b/README.md new file mode 100644 index 0000000..88a59a6 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +SWH-lister +============ + +The Software Heritage Lister is both a library module to permit to +centralize lister behaviors, and to provide lister implementations. + +Actual lister implementations are: + +- swh-lister-debian +- swh-lister-github +- swh-lister-bitbucket + +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. + + +Dependencies +------------ + +- python3 +- python3-requests +- python3-sqlalchemy + +More details in requirements*.txt + + +Local deployment +----------- + +## lister-github + +### Preparation steps + +1. git clone under $SWH_ENVIRONMENT_HOME/swh-lister (of your choosing) +2. mkdir ~/.config/swh/ ~/.cache/swh/lister/github.com/ +3. create configuration file ~/.config/swh/lister-github.com.yml +4. Bootstrap the db instance schema + + $ createdb lister-github + $ python3 -m swh.lister.cli --db-url postgres:///lister-github \ + --lister github \ + --create-tables + +### Configuration file sample + +Minimalistic configuration: + + $ cat ~/.config/swh/lister-github.com.yml + # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls + lister_db_url: postgres:///lister-github + credentials: [] + cache_responses: True + cache_dir: /home/zack/.cache/swh/lister/github.com + +Note: This expects storage (5002) and scheduler (5008) services to run locally + +### Run + + $ python3 + >>> import logging + >>> logging.basicConfig(level=logging.DEBUG) + >>> from swh.lister.github.tasks import RangeGitHubLister; RangeGitHubLister().run(364, 365) + INFO:root:listing repos starting at 364 + DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): api.github.com + DEBUG:urllib3.connectionpool:https://api.github.com:443 "GET /repositories?since=364 HTTP/1.1" 200 None + DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): localhost + DEBUG:urllib3.connectionpool:http://localhost:5002 "POST /origin/add HTTP/1.1" 200 1 + + +## lister-gitlab + +### preparation steps + +1. git clone under $SWH_ENVIRONMENT_HOME/swh-lister (of your choosing) +2. mkdir ~/.config/swh/ ~/.cache/swh/lister/gitlab/ +3. create configuration file ~/.config/swh/lister-gitlab.yml +4. Bootstrap the db instance schema + + $ createdb lister-gitlab + $ python3 -m swh.lister.cli --db-url postgres:///lister-gitlab \ + --lister gitlab \ + --create-tables + +### Configuration file sample + + $ cat ~/.config/swh/lister-gitlab.yml + # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls + lister_db_url: postgres:///lister-gitlab + credentials: [] + cache_responses: True + cache_dir: /home/zack/.cache/swh/lister/gitlab + +Note: This expects storage (5002) and scheduler (5008) services to run locally + +### Run + + $ python3 + Python 3.6.6 (default, Jun 27 2018, 14:44:17) + [GCC 8.1.0] on linux + Type "help", "copyright", "credits" or "license" for more information. + >>> from swh.lister.gitlab.tasks import RangeGitLabLister; RangeGitLabLister().run_task(1, 2, + {'instance': 'debian', 'api_baseurl': 'https://salsa.debian.org/api/v4', 'sort': 'asc'}) + >>> from swh.lister.gitlab.tasks import FullGitLabRelister; FullGitLabRelister().run_task( + {'instance':'0xacab', 'api_baseurl':'https://0xacab.org/api/v4', 'sort': 'asc'}) + >>> from swh.lister.gitlab.tasks import IncrementalGitLabLister; IncrementalGitLabLister().run_task( + {'instance': 'freedesktop.org', 'api_baseurl': 'https://gitlab.freedesktop.org/api/v4', + 'sort': 'asc'}) diff --git a/TODO b/TODO deleted file mode 100644 index 46a84f3..0000000 --- a/TODO +++ /dev/null @@ -1,57 +0,0 @@ -# -*- mode: org -*- - -* TODO SQL: rework repo_history/repo_creations to use last_seen -* TODO cache dir: split json data from other HTTP info - for easier further processing of additional API data - -* TODO cache dir: split in subdirs - to avoid hitting too hard on the filesystem due to the large amount of files - (200k+) - -* TODO network-level traceback - Traceback (most recent call last): - File "/usr/lib/python3/dist-packages/urllib3/response.py", line 186, in read - data = self._fp.read(amt) - File "/usr/lib/python3.4/http/client.py", line 500, in read - return super(HTTPResponse, self).read(amt) - File "/usr/lib/python3.4/http/client.py", line 529, in readinto - return self._readinto_chunked(b) - File "/usr/lib/python3.4/http/client.py", line 621, in _readinto_chunked - n = self._safe_readinto(mvb) - File "/usr/lib/python3.4/http/client.py", line 680, in _safe_readinto - raise IncompleteRead(bytes(mvb[0:total_bytes]), len(b)) - http.client.IncompleteRead: IncompleteRead(3201 bytes read, 10240 more expected) - - During handling of the above exception, another exception occurred: - - Traceback (most recent call last): - File "/usr/lib/python3/dist-packages/requests/models.py", line 653, in generate - for chunk in self.raw.stream(chunk_size, decode_content=True): - File "/usr/lib/python3/dist-packages/urllib3/response.py", line 256, in stream - data = self.read(amt=amt, decode_content=decode_content) - File "/usr/lib/python3/dist-packages/urllib3/response.py", line 214, in read - raise ProtocolError('Connection broken: %r' % e, e) - urllib3.exceptions.ProtocolError: ('Connection broken: IncompleteRead(3201 bytes read, 10240 more expected)', IncompleteRead(3201 bytes read, 10240 more expected)) - - During handling of the above exception, another exception occurred: - - Traceback (most recent call last): - File "bin/ghlister", line 110, in - max_id=args.interval[1]) - File "/home/zack/dati/projects/github-list-repo/ghlister/lister.py", line 129, in fetch - repos_res = gh_api_request('/repositories?since=%d' % since, **cred) - File "/home/zack/dati/projects/github-list-repo/ghlister/lister.py", line 55, in gh_api_request - r = requests.get(GH_API_URL + path, **params) - File "/usr/lib/python3/dist-packages/requests/api.py", line 60, in get - return request('get', url, **kwargs) - File "/usr/lib/python3/dist-packages/requests/api.py", line 49, in request - return session.request(method=method, url=url, **kwargs) - File "/usr/lib/python3/dist-packages/requests/sessions.py", line 457, in request - resp = self.send(prep, **send_kwargs) - File "/usr/lib/python3/dist-packages/requests/sessions.py", line 606, in send - r.content - File "/usr/lib/python3/dist-packages/requests/models.py", line 724, in content - self._content = bytes().join(self.iter_content(CONTENT_CHUNK_SIZE)) or bytes() - File "/usr/lib/python3/dist-packages/requests/models.py", line 656, in generate - raise ChunkedEncodingError(e) - requests.exceptions.ChunkedEncodingError: ('Connection broken: IncompleteRead(3201 bytes read, 10240 more expected)', IncompleteRead(3201 bytes read, 10240 more expected)) diff --git a/bin/ghlister b/bin/ghlister index 594f138..d8e6cf5 100755 --- a/bin/ghlister +++ b/bin/ghlister @@ -1,101 +1,103 @@ #!/usr/bin/python3 # Copyright (C) 2015 Stefano Zacchiroli # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import argparse import logging import sys from swh.lister.github import models from swh.lister.github.lister import GitHubLister DEFAULT_CONF = { 'cache_dir': './cache', 'log_dir': './log', 'cache_json': 'False', } def int_interval(s): """parse an "N-M" string as an interval. Return an (N,M) int (or None) pair """ def not_an_interval(): raise argparse.ArgumentTypeError('not an interval: ' + s) def parse_int(s): if s: return int(s) else: return None if '-' not in s: not_an_interval() parts = s.split('-') if len(parts) > 2: not_an_interval() return tuple([parse_int(p) for p in parts]) def parse_args(): cli = argparse.ArgumentParser( description='list GitHub repositories and load them into a DB') cli.add_argument('--db-url', '-d', metavar='SQLALCHEMY_URL', help='SQLAlchemy DB URL (override conffile); see ' '') # NOQA cli.add_argument('--verbose', '-v', action='store_true', help='be verbose') subcli = cli.add_subparsers(dest='action') subcli.add_parser('createdb', help='initialize DB') subcli.add_parser('dropdb', help='destroy DB') list_cli = subcli.add_parser('list', help='list repositories') list_cli.add_argument('interval', type=int_interval, help='interval of repository IDs to list, ' 'in N-M format; either N or M can be omitted.') list_cli = subcli.add_parser('catchup', help='catchup with new repos since last time') args = cli.parse_args() if not args.action: cli.error('no action given') return args if __name__ == '__main__': logging.basicConfig(level=logging.INFO) # XXX args = parse_args() override_conf = {} if args.db_url: override_conf['lister_db_url'] = args.db_url - lister = GitHubLister(override_conf) + lister = GitHubLister(lister_name='github.com', + api_baseurl='https://api.github.com', + override_config=override_conf) if args.action == 'createdb': - models.SQLBase.metadata.create_all(lister.db_engine) + models.ModelBase.metadata.create_all(lister.db_engine) elif args.action == 'dropdb': - models.SQLBase.metadata.drop_all(lister.db_engine) + models.ModelBase.metadata.drop_all(lister.db_engine) elif args.action == 'list': lister.fetch(min_id=args.interval[0], max_id=args.interval[1]) elif args.action == 'catchup': last_known_id = lister.last_repo_id() if last_known_id is not None: logging.info('catching up from last known repo id: %d' % last_known_id) lister.fetch(min_id=last_known_id + 1, max_id=None) else: logging.error('Cannot catchup: no last known id found. Abort.') sys.exit(2) diff --git a/debian/control b/debian/control index ab4b458..6b27958 100644 --- a/debian/control +++ b/debian/control @@ -1,31 +1,31 @@ Source: swh-lister Maintainer: Software Heritage developers Section: python Priority: optional Build-Depends: debhelper (>= 9), dh-python (>= 2), python3-all, python3-dateutil, python3-debian, python3-nose, python3-requests-mock, python3-setuptools, python3-sqlalchemy (>= 1.0), python3-swh.core, - python3-swh.scheduler (>= 0.0.14~), + python3-swh.scheduler (>= 0.0.30~), python3-swh.storage, python3-swh.storage.schemata, python3-testing.postgresql, python3-vcversioner, python3-xmltodict Standards-Version: 3.9.6 Homepage: https://forge.softwareheritage.org/source/swh-lister/ Package: python3-swh.lister Architecture: all -Depends: python3-swh.scheduler (>= 0.0.14~), +Depends: python3-swh.scheduler (>= 0.0.30~), ${misc:Depends}, ${python3:Depends} Breaks: python3-swh.lister.github Replaces: python3-swh.lister.github Description: Software Heritage lister diff --git a/docs/images/new_base.png b/docs/images/new_base.png new file mode 100644 index 0000000..2a2e3fc Binary files /dev/null and b/docs/images/new_base.png differ diff --git a/docs/images/new_bitbucket_lister.png b/docs/images/new_bitbucket_lister.png new file mode 100644 index 0000000..7c491bb Binary files /dev/null and b/docs/images/new_bitbucket_lister.png differ diff --git a/docs/images/new_github_lister.png b/docs/images/new_github_lister.png new file mode 100644 index 0000000..e5a7fba Binary files /dev/null and b/docs/images/new_github_lister.png differ diff --git a/docs/images/old_github_lister.png b/docs/images/old_github_lister.png new file mode 100644 index 0000000..65398a0 Binary files /dev/null and b/docs/images/old_github_lister.png differ diff --git a/docs/index.rst b/docs/index.rst index 8b64117..653b85e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,15 +1,22 @@ -Software Heritage - Development Documentation -============================================= +.. _swh-lister: + +Software Heritage listers +========================= .. toctree:: :maxdepth: 2 :caption: Contents: +Overview +-------- + +* :ref:`lister-tutorial` + Indices and tables -================== +------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` diff --git a/docs/tutorial.rst b/docs/tutorial.rst new file mode 100644 index 0000000..88d430b --- /dev/null +++ b/docs/tutorial.rst @@ -0,0 +1,358 @@ +.. _lister-tutorial: + +Tutorial: list the content of your favorite forge in just a few steps +===================================================================== + +(the `original version +`_ +of this article appeared on the Software Heritage blog) + +Back in November 2016, Nicolas Dandrimont wrote about structural code changes +`leading to a massive (+15 million!) upswing in the number of repositories +archived by Software Heritage +`_ +through a combination of automatic linkage between the listing and loading +scheduler, new understanding of how to deal with extremely large repository +hosts like `GitHub `_, and activating a new set of +repositories that had previously been skipped over. + +In the post, Nicolas outlined the three major phases of work in Software +Heritage's preservation process (listing, scheduling updates, loading) and +highlighted that the ability to preserve the world's free software heritage +depends on our ability to find and list the repositories. + +At the time, Software Heritage was only able to list projects on +GitHub. Focusing early on GitHub, one of the largest and most active forge in +the world, allowed for a big value-to-effort ratio and a rapid launch for the +archive. As the old Italian proverb goes, "Il meglio è nemico del bene," or in +modern English parlance, "Perfect is the enemy of good," right? Right. So the +plan from the beginning was to implement a lister for GitHub, then maybe +implement another one, and then take a few giant steps backward and squint our +eyes. + +Why? Because source code hosting services don't behave according to a unified +standard. Each new service requires dedicated development time to implement a +new scraping client for the non-transferable requirements and intricacies of +that service's API. At the time, doing it in an extensible and adaptable way +required a level of exposure to the myriad differences between these services +that we just didn't think we had yet. + +Nicolas' post closed by saying "We haven't carved out a stable API yet that +allows you to just fill in the blanks, as we only have the GitHub lister +currently, and a proven API will emerge organically only once we have some +diversity." + +That has since changed. As of March 6, 2017, the Software Heritage **lister +code has been aggressively restructured, abstracted, and commented** to make +creating new listers significantly easier. There may yet be a few kinks to iron +out, but **now making a new lister is practically like filling in the blanks**. + +Fundamentally, a basic lister must follow these steps: + +1. Issue a network request for a service endpoint. +2. Convert the response into a canonical format. +3. Populate a work queue for fetching and ingesting source repositories. + +Steps 1 and 3 are generic problems, so they can get generic solutions hidden +away in base code, most of which never needs to change. That leaves us to +implement step 2, which can be trivially done now for services with clean web +APIs. + +In the new code we've tried to hide away as much generic functionality as +possible, turning it into set-and-forget plumbing between a few simple +customized elements. Different hosting services might use different network +protocols, rate-limit messages, or pagination schemes, but, as long as there is +some way to get a list of the hosted repositories, we think that the new base +code will make getting those repositories much easier. + +First let me give you the 30,000 foot view… + +The old GitHub-specific lister code looked like this (265 lines of Python): + +.. figure:: images/old_github_lister.png + +By contrast, the new GitHub-specific code looks like this (34 lines of Python): + +.. figure:: images/new_github_lister.png + +And the new BitBucket-specific code is even shorter and looks like this (24 lines of Python): + +.. figure:: images/new_bitbucket_lister.png + +And now this is common shared code in a few abstract base classes, with some new features and loads of docstring comments (in red): + +.. figure:: images/new_base.png + +So how does the lister code work now, and **how might a contributing developer +go about making a new one** + +The first thing to know is that we now have a generic lister base class and ORM +model. A subclass of the lister base should already be able to do almost +everything needed to complete a listing task for a single service +request/response cycle with the following implementation requirements: + +1. A member variable must be declared called ``MODEL``, which is equal to a + subclass (Note: type, not instance) of the base ORM model. The reasons for + using a subclass is mostly just because different services use different + incompatible primary identifiers for their repositories. The model + subclasses are typically only one or two additional variable declarations. + +2. A method called ``transport_request`` must be implemented, which takes the + complete target identifier (e.g., a URL) and tries to request it one time + using whatever transport protocol is required for interacting with the + service. It should not attempt to retry on timeouts or do anything else with + the response (that is already done for you). It should just either return + the response or raise a ``FetchError`` exception. + +3. A method called ``transport_response_to_string`` must be implemented, which + takes the entire response of the request in (1) and converts it to a string + for logging purposes. + +4. A method called ``transport_quota_check`` must be implemented, which takes + the entire response of the request in (1) and checks to see if the process + has run afoul of any query quotas or rate limits. If the service says to + wait before making more requests, the method should return ``True`` and also + the number of seconds to wait, otherwise it returns ``False``. + +5. A method called ``transport_response_simplified`` must be implemented, which + also takes the entire response of the request in (1) and converts it to a + Python list of dicts (one dict for each repository) with keys given + according to the aforementioned ``MODEL`` class members. + +Because 1, 2, 3, and 4 are basically dependent only on the chosen network +protocol, we also have an HTTP mix-in module, which supplements the lister base +and provides default implementations for those methods along with optional +request header injection using the Python Requests library. The +``transport_quota_check`` method as provided follows the IETF standard for +communicating rate limits with `HTTP code 429 +`_ which some hosting services +have chosen not to follow, so it's possible that a specific lister will need to +override it. + +On top of all of that, we also provide another layer over the base lister class +which adds support for sequentially looping over indices. What are indices? +Well, some services (`BitBucket `_ and GitHub for +example) don't send you the entire list of all of their repositories at once, +because that server response would be unwieldy. Instead they paginate their +results, and they also allow you to query their APIs like this: +``https://server_address.tld/query_type?start_listing_from_id=foo``. Changing +the value of 'foo' lets you fetch a set of repositories starting from there. We +call 'foo' an index, and we call a service that works this way an indexing +service. GitHub uses the repository unique identifier and BitBucket uses the +repository creation time, but a service can really use anything as long as the +values monotonically increase with new repositories. A good indexing service +also includes the URL of the next page with a later 'foo' in its responses. For +these indexing services we provide another intermediate lister called the +indexing lister. Instead of inheriting from :class:`SWHListerBase +`, the lister class would inherit +from :class:`SWHIndexingLister +`. Along with the +requirements of the lister base, the indexing lister base adds one extra +requirement: + +1. A method called ``get_next_target_from_response`` must be defined, which + takes a complete request response and returns the index ('foo' above) of the + next page. + +So those are all the basic requirements. There are, of course, a few other +little bits and pieces (covered for now in the code's docstring comments), but +for the most part that's it. It sounds like a lot of information to absorb and +implement, but remember that most of the implementation requirements mentioned +above are already provided for 99% of services by the HTTP mix-in module. It +looks much simpler when we look at the actual implementations of the two +new-style indexing listers we currently have… + +This is the entire source code for the BitBucket repository lister:: + + # Copyright (C) 2017 the Software Heritage developers + # License: GNU General Public License version 3 or later + # See top-level LICENSE file for more information + + from urllib import parse + from swh.lister.bitbucket.models import BitBucketModel + from swh.lister.core.indexing_lister import SWHIndexingHttpLister + + class BitBucketLister(SWHIndexingHttpLister): + PATH_TEMPLATE = '/repositories?after=%s' + MODEL = BitBucketModel + + def get_model_from_repo(self, repo): + return {'uid': repo['uuid'], + 'indexable': 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'], + 'description': repo['description']} + + def get_next_target_from_response(self, response): + body = response.json() + if 'next' in body: + return parse.unquote(body['next'].split('after=')[1]) + else: + return None + + def transport_response_simplified(self, response): + repos = response.json()['values'] + return [self.get_model_from_repo(repo) for repo in repos] + +And this is the entire source code for the GitHub repository lister:: + + # Copyright (C) 2017 the Software Heritage developers + # License: GNU General Public License version 3 or later + # See top-level LICENSE file for more information + + import time + from swh.lister.core.indexing_lister import SWHIndexingHttpLister + from swh.lister.github.models import GitHubModel + + class GitHubLister(SWHIndexingHttpLister): + PATH_TEMPLATE = '/repositories?since=%d' + MODEL = GitHubModel + + def get_model_from_repo(self, repo): + return {'uid': repo['id'], + 'indexable': repo['id'], + 'name': repo['name'], + 'full_name': repo['full_name'], + 'html_url': repo['html_url'], + 'origin_url': repo['html_url'], + 'origin_type': 'git', + 'description': repo['description']} + + def get_next_target_from_response(self, response): + if 'next' in response.links: + next_url = response.links['next']['url'] + return int(next_url.split('since=')[1]) + else: + return None + + def transport_response_simplified(self, response): + repos = response.json() + return [self.get_model_from_repo(repo) for repo in repos] + + def request_headers(self): + return {'Accept': 'application/vnd.github.v3+json'} + + def transport_quota_check(self, response): + remain = int(response.headers['X-RateLimit-Remaining']) + if response.status_code == 403 and remain == 0: + reset_at = int(response.headers['X-RateLimit-Reset']) + delay = min(reset_at - time.time(), 3600) + return True, delay + else: + return False, 0 + +We can see that there are some common elements: + +* Both use the HTTP transport mixin (:class:`SWHIndexingHttpLister + `) just combines + :class:`SWHListerHttpTransport + ` and + :class:`SWHIndexingLister + `) to get most of the + network request functionality for free. + +* Both also define ``MODEL`` and ``PATH_TEMPLATE`` variables. It should be + clear to developers that ``PATH_TEMPLATE``, when combined with the base + service URL (e.g., ``https://some_service.com``) and passed a value (the + 'foo' index described earlier) results in a complete identifier for making + API requests to these services. It is required by our HTTP module. + +* Both services respond using JSON, so both implementations of + ``transport_response_simplified`` are similar and quite short. + +We can also see that there are a few differences: + +* GitHub sends the next URL as part of the response header, while BitBucket + sends it in the response body. + +* GitHub differentiates API versions with a request header (our HTTP + transport mix-in will automatically use any headers provided by an + optional request_headers method that we implement here), while + BitBucket has it as part of their base service URL. BitBucket uses + the IETF standard HTTP 429 response code for their rate limit + notifications (the HTTP transport mix-in automatically handles + that), while GitHub uses their own custom response headers that need + special treatment. + +* But look at them! 58 lines of Python code, combined, to absorb all + repositories from two of the largest and most influential source code hosting + services. + +Ok, so what is going on behind the scenes? + +To trace the operation of the code, let's start with a sample instantiation and +progress from there to see which methods get called when. What follows will be +a series of extremely reductionist pseudocode methods. This is not what the +code actually looks like (it's not even real code), but it does have the same +basic flow. Bear with me while I try to lay out lister operation in a +quasi-linear way…:: + + # main task + + ghl = GitHubLister(lister_name='github.com', + api_baseurl='https://github.com') + ghl.run() + +⇓ (SWHIndexingLister.run):: + + # SWHIndexingLister.run + + identifier = None + do + response, repos = SWHListerBase.ingest_data(identifier) + identifier = GitHubLister.get_next_target_from_response(response) + while(identifier) + +⇓ (SWHListerBase.ingest_data):: + + # SWHListerBase.ingest_data + + response = SWHListerBase.safely_issue_request(identifier) + repos = GitHubLister.transport_response_simplified(response) + injected = SWHListerBase.inject_repo_data_into_db(repos) + return response, injected + +⇓ (SWHListerBase.safely_issue_request):: + + # SWHListerBase.safely_issue_request + + repeat: + resp = SWHListerHttpTransport.transport_request(identifier) + retry, delay = SWHListerHttpTransport.transport_quota_check(resp) + if retry: + sleep(delay) + until((not retry) or too_many_retries) + return resp + +⇓ (SWHListerHttpTransport.transport_request):: + + # SWHListerHttpTransport.transport_request + + path = SWHListerBase.api_baseurl + + SWHListerHttpTransport.PATH_TEMPLATE % identifier + headers = SWHListerHttpTransport.request_headers() + return http.get(path, headers) + +(Oh look, there's our ``PATH_TEMPLATE``) + +⇓ (SWHListerHttpTransport.request_headers):: + + # SWHListerHttpTransport.request_headers + + override → GitHubLister.request_headers + +↑↑ (SWHListerBase.safely_issue_request) + +⇓ (SWHListerHttpTransport.transport_quota_check):: + + # SWHListerHttpTransport.transport_quota_check + + override → GitHubLister.transport_quota_check + +And then we're done. From start to finish, I hope this helps you understand how +the few customized pieces fit into the new shared plumbing. + +Now you can go and write up a lister for a code hosting site we don't have yet! diff --git a/etc/crontab b/etc/crontab deleted file mode 100644 index 4ebb2d1..0000000 --- a/etc/crontab +++ /dev/null @@ -1,5 +0,0 @@ -SHELL=/bin/bash -GHLISTER_ROOT=/home/zack/src/swh-lister-github - -# m h dom mon dow command - 0 8 * * * PYTHONPATH=$GHLISTER_ROOT $GHLISTER_ROOT/bin/ghlister catchup >> ~/.cache/swh/lister-github/$(date +\%Y\%m\%d).log 2>&1 diff --git a/requirements-swh.txt b/requirements-swh.txt index c08589d..e4f6ba1 100644 --- a/requirements-swh.txt +++ b/requirements-swh.txt @@ -1,3 +1,3 @@ swh.core swh.storage[schemata] >= 0.0.76 -swh.scheduler >= 0.0.14 +swh.scheduler >= 0.0.30 diff --git a/swh.lister.egg-info/PKG-INFO b/swh.lister.egg-info/PKG-INFO index 08d1b5d..1c786e3 100644 --- a/swh.lister.egg-info/PKG-INFO +++ b/swh.lister.egg-info/PKG-INFO @@ -1,10 +1,10 @@ Metadata-Version: 1.0 Name: swh.lister -Version: 0.0.10 +Version: 0.0.11 Summary: Software Heritage GitHub lister Home-page: https://forge.softwareheritage.org/diffusion/DLSGH/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN diff --git a/swh.lister.egg-info/SOURCES.txt b/swh.lister.egg-info/SOURCES.txt index f8ca453..72f9b9e 100644 --- a/swh.lister.egg-info/SOURCES.txt +++ b/swh.lister.egg-info/SOURCES.txt @@ -1,72 +1,88 @@ .gitignore ACKNOWLEDGEMENTS LICENSE MANIFEST.in Makefile -README -TODO +README.md requirements-swh.txt requirements-test.txt requirements.txt setup.py version.txt bin/batch bin/ghlister bin/reset.sh bin/status debian/changelog debian/clean debian/compat debian/control debian/copyright debian/rules debian/source/format docs/.gitignore docs/Makefile docs/conf.py docs/index.rst +docs/tutorial.rst docs/_static/.placeholder docs/_templates/.placeholder -etc/crontab +docs/images/new_base.png +docs/images/new_bitbucket_lister.png +docs/images/new_github_lister.png +docs/images/old_github_lister.png sql/crawler.sql sql/pimp_db.sql 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/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/test_bb_lister.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/tasks.py swh/lister/core/tests/__init__.py swh/lister/core/tests/test_abstractattribute.py swh/lister/core/tests/test_lister.py swh/lister/core/tests/test_model.py swh/lister/debian/__init__.py swh/lister/debian/lister.py swh/lister/debian/tasks.py swh/lister/debian/utils.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/test_gh_lister.py \ No newline at end of file +swh/lister/github/tests/test_gh_lister.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/test_gitlab_lister.py +swh/lister/tests/__init__.py +swh/lister/tests/test_utils.py \ No newline at end of file diff --git a/swh.lister.egg-info/requires.txt b/swh.lister.egg-info/requires.txt index dfd633a..23a3f54 100644 --- a/swh.lister.egg-info/requires.txt +++ b/swh.lister.egg-info/requires.txt @@ -1,9 +1,9 @@ SQLAlchemy arrow python_debian requests setuptools swh.core -swh.scheduler>=0.0.14 +swh.scheduler>=0.0.30 swh.storage[schemata]>=0.0.76 xmltodict diff --git a/swh/lister/_version.py b/swh/lister/_version.py index 9af018d..2592692 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.10' -__sha__ = 'g9b58ecb' -__revision__ = 'g9b58ecb' +__version__ = '0.0.11' +__sha__ = 'gd6694a8' +__revision__ = 'gd6694a8' diff --git a/swh/lister/bitbucket/lister.py b/swh/lister/bitbucket/lister.py index 53f359a..6885c8e 100644 --- a/swh/lister/bitbucket/lister.py +++ b/swh/lister/bitbucket/lister.py @@ -1,36 +1,37 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-2018 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from urllib import parse from swh.lister.bitbucket.models import BitBucketModel from swh.lister.core.indexing_lister import SWHIndexingHttpLister class BitBucketLister(SWHIndexingHttpLister): PATH_TEMPLATE = '/repositories?after=%s' MODEL = BitBucketModel + LISTER_NAME = 'bitbucket.com' def get_model_from_repo(self, repo): return { - 'uid': repo['uuid'], - 'indexable': 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'], - 'description': repo['description'] - } + 'uid': repo['uuid'], + 'indexable': 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'], + 'description': repo['description'] + } def get_next_target_from_response(self, response): body = response.json() if 'next' in body: return parse.unquote(body['next'].split('after=')[1]) else: return None def transport_response_simplified(self, response): repos = response.json()['values'] return [self.get_model_from_repo(repo) for repo in repos] diff --git a/swh/lister/bitbucket/models.py b/swh/lister/bitbucket/models.py index 07b78f2..65fba9c 100644 --- a/swh/lister/bitbucket/models.py +++ b/swh/lister/bitbucket/models.py @@ -1,15 +1,15 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-2018 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 swh.lister.core.models import ModelBase +from swh.lister.core.models import IndexingModelBase -class BitBucketModel(ModelBase): +class BitBucketModel(IndexingModelBase): """a BitBucket repository""" __tablename__ = 'bitbucket_repos' uid = Column(String, primary_key=True) indexable = Column(String, index=True) diff --git a/swh/lister/bitbucket/tasks.py b/swh/lister/bitbucket/tasks.py index d27af18..c54063b 100644 --- a/swh/lister/bitbucket/tasks.py +++ b/swh/lister/bitbucket/tasks.py @@ -1,28 +1,27 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-2018 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.lister.core.tasks import (IndexingDiscoveryListerTask, - IndexingRangeListerTask, + RangeListerTask, IndexingRefreshListerTask, ListerTaskBase) from .lister import BitBucketLister class BitBucketListerTask(ListerTaskBase): - def new_lister(self): - return BitBucketLister(lister_name='bitbucket.com', - api_baseurl='https://api.bitbucket.org/2.0') + def new_lister(self, *, api_baseurl='https://api.bitbucket.org/2.0'): + return BitBucketLister(api_baseurl=api_baseurl) class IncrementalBitBucketLister(BitBucketListerTask, IndexingDiscoveryListerTask): task_queue = 'swh_lister_bitbucket_discover' -class RangeBitBucketLister(BitBucketListerTask, IndexingRangeListerTask): +class RangeBitBucketLister(BitBucketListerTask, RangeListerTask): task_queue = 'swh_lister_bitbucket_refresh' class FullBitBucketRelister(BitBucketListerTask, IndexingRefreshListerTask): task_queue = 'swh_lister_bitbucket_refresh' diff --git a/swh/lister/bitbucket/tests/test_bb_lister.py b/swh/lister/bitbucket/tests/test_bb_lister.py index 37efbc0..7769e00 100644 --- a/swh/lister/bitbucket/tests/test_bb_lister.py +++ b/swh/lister/bitbucket/tests/test_bb_lister.py @@ -1,20 +1,20 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-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 re import unittest from swh.lister.bitbucket.lister import BitBucketLister -from swh.lister.core.tests.test_lister import IndexingHttpListerTesterBase +from swh.lister.core.tests.test_lister import HttpListerTester -class BitBucketListerTester(IndexingHttpListerTesterBase, unittest.TestCase): +class BitBucketListerTester(HttpListerTester, unittest.TestCase): Lister = BitBucketLister test_re = re.compile(r'/repositories\?after=([^?&]+)') lister_subdir = 'bitbucket' good_api_response_file = 'api_response.json' bad_api_response_file = 'api_empty_response.json' first_index = '2008-07-12T07:44:01.476818+00:00' last_index = '2008-07-19T06:16:43.044743+00:00' entries_per_page = 10 diff --git a/swh/lister/cli.py b/swh/lister/cli.py new file mode 100644 index 0000000..17dbbe6 --- /dev/null +++ b/swh/lister/cli.py @@ -0,0 +1,56 @@ +# Copyright (C) 2018 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 click + + +@click.command() +@click.option( + '--db-url', '-d', default='postgres:///lister-gitlab.com', + help='SQLAlchemy DB URL; see ' + '') # noqa +@click.option('--lister', required=1, + type=click.Choice(['github', 'gitlab', 'bitbucket']), + help='Lister to act upon') +@click.option('--create-tables', is_flag=True, default=False, + help='create tables') +@click.option('--drop-tables', is_flag=True, default=False, + help='Drop tables') +def cli(db_url, lister, create_tables, drop_tables): + """Initialize db model according to lister. + + """ + supported_listers = ['github', 'gitlab', 'bitbucket'] + override_conf = {'lister_db_url': db_url} + + 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) + else: + raise ValueError('Only supported listers are %s' % supported_listers) + + if drop_tables: + ModelBase.metadata.drop_all(_lister.db_engine) + + if create_tables: + ModelBase.metadata.create_all(_lister.db_engine) + + +if __name__ == '__main__': + cli() diff --git a/swh/lister/core/indexing_lister.py b/swh/lister/core/indexing_lister.py index 8166900..f76c4fc 100644 --- a/swh/lister/core/indexing_lister.py +++ b/swh/lister/core/indexing_lister.py @@ -1,212 +1,210 @@ # Copyright (C) 2015-2017 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 logging from sqlalchemy import func from .lister_transports import SWHListerHttpTransport from .lister_base import SWHListerBase class SWHIndexingLister(SWHListerBase): """Lister* intermediate class for any service that follows the pattern: - The service must report at least one stable unique identifier, known herein as the UID value, for every listed repository. - If the service splits the list of repositories into sublists, it must report at least one stable and sorted index identifier for every listed repository, known herein as the indexable value, which can be used as part of the service endpoint query to request a sublist beginning from that index. This might be the UID if the UID is monotonic. - Client sends a request to list repositories starting from a given index. - Client receives structured (json/xml/etc) response with information about a sequential series of repositories starting from that index and, if necessary/available, some indication of the URL or index for fetching the next series of repository data. See :class:`swh.lister.core.lister_base.SWHListerBase` for more details. This class cannot be instantiated. To create a new Lister for a source code listing service that follows the model described above, you must subclass this class and provide the required overrides in addition to any unmet implementation/override requirements of this class's base. (see parent class and member docstrings for details) Required Overrides:: def get_next_target_from_response """ @abc.abstractmethod def get_next_target_from_response(self, response): """Find the next server endpoint identifier given the entire response. 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 (transport response): response page from the server Returns: index of next page, possibly extracted from a next href url """ pass # You probably don't need to override anything below this line. def filter_before_inject(self, models_list): """Overrides SWHListerBase.filter_before_inject Bounds query results by this Lister's set max_index. """ models_list = [ m for m in models_list if self.is_within_bounds(m['indexable'], None, self.max_index) ] return models_list def db_query_range(self, start, end): """Look in the db for a range of repositories with indexable values in the range [start, end] Args: start (model indexable type): start of desired indexable range end (model indexable type): end of desired indexable range Returns: a list of sqlalchemy.ext.declarative.declarative_base objects with indexable values within the given range """ retlist = self.db_session.query(self.MODEL) if start is not None: retlist = retlist.filter(self.MODEL.indexable >= start) if end is not None: retlist = retlist.filter(self.MODEL.indexable <= end) return retlist def db_partition_indices(self, partition_size): """Describe an index-space compartmentalization of the db table in equal sized chunks. This is used to describe min&max bounds for parallelizing fetch tasks. Args: partition_size (int): desired size to make each partition Returns: a list of tuples (begin, end) of indexable value that declare approximately equal-sized ranges of existing repos """ n = self.db_num_entries() partitions = [] partition_size = min(partition_size, n) prev_index = None for i in range(0, n-1, partition_size): # indexable column from the ith row index = self.db_session.query(self.MODEL.indexable) \ .order_by(self.MODEL.indexable).offset(i).first() if index is not None and prev_index is not None: partitions.append((prev_index, index)) prev_index = index partitions.append((prev_index, self.db_last_index())) return partitions def db_last_index(self): """Look in the db for the largest indexable value Returns: the largest indexable value of all repos in the db """ t = self.db_session.query(func.max(self.MODEL.indexable)).first() if t: return t[0] else: return None def disable_deleted_repo_tasks(self, start, end, keep_these): """Disable tasks for repos that no longer exist between start and end. Args: start: beginning of range to disable end: end of range to disable keep_these (uid list): do not disable repos with uids in this list """ if end is None: end = self.db_last_index() if not self.is_within_bounds(end, None, self.max_index): end = self.max_index deleted_repos = self.winnow_models( self.db_query_range(start, end), self.MODEL.uid, keep_these ) tasks_to_disable = [repo.task_id for repo in deleted_repos if repo.task_id is not None] if tasks_to_disable: self.scheduler.disable_tasks(tasks_to_disable) for repo in deleted_repos: repo.task_id = None - def run(self, min_index=None, max_index=None): + def run(self, min_bound=None, max_bound=None): """Main entry function. Sequentially fetches repository data from the service according to the basic outline in the class docstring, continually fetching sublists until either there is no next index reference given or the given next index is greater - than the desired max_index. + than the desired max_bound. Args: - min_index (indexable type): optional index to start from - max_index (indexable type): optional index to stop at + min_bound (indexable type): optional index to start from + max_bound (indexable type): optional index to stop at Returns: nothing """ - index = min_index or '' + index = min_bound or '' loop_count = 0 - self.min_index = min_index - self.max_index = max_index + self.min_index = min_bound + self.max_index = max_bound while self.is_within_bounds(index, self.min_index, self.max_index): logging.info('listing repos starting at %s' % index) response, injected_repos = self.ingest_data(index) next_index = self.get_next_target_from_response(response) # Determine if any repos were deleted, and disable their tasks. keep_these = [k for k in injected_repos.keys()] self.disable_deleted_repo_tasks(index, next_index, keep_these) # termination condition if (next_index is None) or (next_index == index): logging.info('stopping after index %s, no next link found' % index) break else: index = next_index loop_count += 1 if loop_count == 20: logging.info('flushing updates') loop_count = 0 self.db_session.commit() self.db_session = self.mk_session() self.db_session.commit() self.db_session = self.mk_session() class SWHIndexingHttpLister(SWHListerHttpTransport, SWHIndexingLister): """Convenience class for ensuring right lookup and init order when combining SWHIndexingLister and SWHListerHttpTransport.""" - def __init__(self, lister_name=None, api_baseurl=None, - override_config=None): + def __init__(self, api_baseurl=None, override_config=None): SWHListerHttpTransport.__init__(self, api_baseurl=api_baseurl) - SWHIndexingLister.__init__(self, lister_name=lister_name, - override_config=override_config) + SWHIndexingLister.__init__(self, override_config=override_config) diff --git a/swh/lister/core/lister_base.py b/swh/lister/core/lister_base.py index 39d2c70..5b37159 100644 --- a/swh/lister/core/lister_base.py +++ b/swh/lister/core/lister_base.py @@ -1,496 +1,522 @@ # Copyright (C) 2015-2017 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 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.backend import SchedulerBackend +from swh.scheduler import get_scheduler from swh.storage import get_storage from .abstractattribute import AbstractAttribute 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 SWHListerBase(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.SWHIndexingLister for example usage. This class cannot be instantiated. Any instantiable Lister descending from SWHListerBase 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") @abc.abstractmethod 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 propogate unchanged. + non-connection exceptions should propagate unchanged. """ pass @abc.abstractmethod 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 @abc.abstractmethod 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 @abc.abstractmethod 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): - """Function run after transport_response_simplified but before injection - into the local db and creation of workers. Can be used to eliminate - some of the results if necessary. + """Function run after transport_response_simplified but before + injection into the local db and creation of workers. Can be + used to eliminate some of the results if necessary. MAY BE OVERRIDDEN if an intermediate Lister class needs to filter results before injection without requiring every child class to do so. 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. For example, to + check for existing repositories in the db. + + MAY BE OVERRIDDEN if an intermediate Lister class needs to + check some more the results before injection. + + Checks are fine by default, returns the models_list as is by default. + + 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.SWHIndexingLister ) 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: logging.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/' }, }), - 'scheduling_db': ('str', 'dbname=softwareheritage-scheduler-dev'), + 'scheduler': ('dict', { + 'cls': 'remote', + 'args': { + 'url': 'http://localhost:5008/' + }, + }) } @property def CONFIG_BASE_FILENAME(self): # noqa: N802 - return 'lister-%s' % self.lister_name + return 'lister-%s' % self.LISTER_NAME @property def ADDITIONAL_CONFIG(self): # noqa: N802 return { 'lister_db_url': - ('str', 'postgresql:///lister-%s' % self.lister_name), + ('str', 'postgresql:///lister-%s' % self.LISTER_NAME), 'credentials': ('list[dict]', []), 'cache_responses': ('bool', False), 'cache_dir': - ('str', '~/.cache/swh/lister/%s' % self.lister_name), + ('str', '~/.cache/swh/lister/%s' % self.LISTER_NAME), } INITIAL_BACKOFF = 10 MAX_RETRIES = 7 CONN_SLEEP = 10 - def __init__(self, lister_name=None, override_config=None): + def __init__(self, override_config=None): self.backoff = self.INITIAL_BACKOFF - if lister_name is None: - raise NameError("Every lister must be assigned a lister_name.") - self.lister_name = lister_name # 'github?', 'bitbucket?', 'foo.com?' 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) self.storage = get_storage(**self.config['storage']) - self.scheduler = SchedulerBackend( - scheduling_db=self.config['scheduling_db'], - ) + self.scheduler = get_scheduler(**self.config['scheduler']) self.db_engine = create_engine(self.config['lister_db_url']) 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'] while retries_left > 0: try: r = self.transport_request(identifier) except FetchError: # network-level connection error, try again logging.warn('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: logging.warn('rate limited on %s: sleep for %f seconds' % (identifier, delay)) time.sleep(delay) else: # request ok break retries_left -= 1 if not retries_left: logging.warn('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 origin_dict(self, origin_type, origin_url): """Return special dict format for the origins list Args: origin_type (string) origin_url (string) Returns: the same information in a different form """ return { 'type': origin_type, 'url': origin_url, } def task_dict(self, origin_type, origin_url): """Return special dict format for the tasks list Args: origin_type (string) origin_url (string) Returns: the same information in a different form """ return { 'type': 'origin-update-%s' % origin_type, 'arguments': { 'args': [ origin_url, ], 'kwargs': {}, }, 'next_run': utcnow(), } 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)): logging.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 create_missing_origins_and_tasks(self, models_list, injected_repos): """Find any newly created db entries that don't yet have tasks or origin objects assigned. Args: models_list: a list of dicts mapping keys in the db model for each repo injected_repos: dict of uid:sql_repo pairs that have just been created Returns: Nothing. Modifies injected_repos. """ for m in models_list: ir = injected_repos[m['uid']] if not ir.origin_id: ir.origin_id = self.storage.origin_add_one( self.origin_dict(m['origin_type'], m['origin_url']) ) if not ir.task_id: ir.task_id = self.scheduler.create_tasks( [self.task_dict(m['origin_type'], m['origin_url'])] )[0]['id'] - def ingest_data(self, identifier): + 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) 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.create_missing_origins_and_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/lister_transports.py b/swh/lister/core/lister_transports.py index 58a4605..d681743 100644 --- a/swh/lister/core/lister_transports.py +++ b/swh/lister/core/lister_transports.py @@ -1,132 +1,153 @@ # Copyright (C) 2017 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 random from datetime import datetime from email.utils import parsedate from pprint import pformat import requests import xmltodict try: from swh.lister._version import __version__ except ImportError: __version__ = 'devel' from .abstractattribute import AbstractAttribute from .lister_base import FetchError class SWHListerHttpTransport(abc.ABC): """Use the Requests library for making Lister endpoint requests. To be used in conjunction with SWHListerBase or a subclass of it. """ PATH_TEMPLATE = AbstractAttribute('string containing a python string' ' format pattern that produces the API' ' endpoint path for listing stored' ' repositories when given an index.' ' eg. "/repositories?after=%s".' 'To be implemented in the API-specific' ' class inheriting this.') EXPECTED_STATUS_CODES = (200, 429, 403, 404) def request_headers(self): """Returns dictionary of any request headers needed by the server. MAY BE OVERRIDDEN if request headers are needed. """ return { 'User-Agent': 'Software Heritage lister (%s)' % self.lister_version } def request_uri(self, identifier): """Get the full request URI given the transport_request identifier. MAY BE OVERRIDDEN if something more complex than the PATH_TEMPLATE is required. """ path = self.PATH_TEMPLATE % identifier return self.api_baseurl + path def request_params(self, identifier): - """Get the full parameters passed to requests given the transport_request - identifier. + """Get the full parameters passed to requests given the + transport_request identifier. MAY BE OVERRIDDEN if something more complex than the request headers - ois needed. + is needed. + """ params = {} params['headers'] = self.request_headers() or {} creds = self.config['credentials'] auth = random.choice(creds) if creds else None if auth: params['auth'] = (auth['username'], auth['password']) return params def transport_quota_check(self, response): - """Implements SWHListerBase.transport_quota_check with standard 429 code - check for HTTP with Requests library. + """Implements SWHListerBase.transport_quota_check with standard 429 + code check for HTTP with Requests library. MAY BE OVERRIDDEN if the server notifies about rate limits in a non-standard way that doesn't use HTTP 429 and the Retry-After response header. ( https://tools.ietf.org/html/rfc6585#section-4 ) + """ if response.status_code == 429: # HTTP too many requests retry_after = response.headers.get('Retry-After', self.back_off()) try: # might be seconds return True, float(retry_after) except: # might be http-date at_date = datetime(*parsedate(retry_after)[:6]) from_now = (at_date - datetime.today()).total_seconds() + 5 return True, max(0, from_now) else: # response ok self.reset_backoff() return False, 0 def __init__(self, api_baseurl=None): if not api_baseurl: raise NameError('HTTP Lister Transport requires api_baseurl.') self.api_baseurl = api_baseurl # eg. 'https://api.github.com' self.session = requests.Session() self.lister_version = __version__ - def transport_request(self, identifier): - """Implements SWHListerBase.transport_request for HTTP using Requests. + def _transport_action(self, identifier, method='get'): + """Permit to ask information to the api prior to actually executing + query. + """ path = self.request_uri(identifier) params = self.request_params(identifier) try: - response = self.session.get(path, **params) + if method == 'head': + response = self.session.head(path, **params) + else: + response = self.session.get(path, **params) except requests.exceptions.ConnectionError as e: raise FetchError(e) else: if response.status_code not in self.EXPECTED_STATUS_CODES: raise FetchError(response) return response + def transport_head(self, identifier): + """Retrieve head information on api. + + """ + return self._transport_action(identifier, method='head') + + def transport_request(self, identifier): + """Implements SWHListerBase.transport_request for HTTP using Requests. + + Retrieve get information on api. + + """ + return self._transport_action(identifier) + def transport_response_to_string(self, response): """Implements SWHListerBase.transport_response_to_string for HTTP given Requests responses. """ s = pformat(response.request.path_url) s += '\n#\n' + pformat(response.request.headers) s += '\n#\n' + pformat(response.status_code) s += '\n#\n' + pformat(response.headers) s += '\n#\n' try: # json? s += pformat(response.json()) except: # not json try: # xml? s += pformat(xmltodict.parse(response.text)) except: # not xml s += pformat(response.text) return s diff --git a/swh/lister/core/models.py b/swh/lister/core/models.py index 46dd684..589918d 100644 --- a/swh/lister/core/models.py +++ b/swh/lister/core/models.py @@ -1,67 +1,82 @@ # Copyright (C) 2015-2017 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 from datetime import datetime from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta from .abstractattribute import AbstractAttribute SQLBase = declarative_base() class ABCSQLMeta(abc.ABCMeta, DeclarativeMeta): pass class ModelBase(SQLBase, metaclass=ABCSQLMeta): """a common repository""" __abstract__ = True __tablename__ = AbstractAttribute uid = AbstractAttribute('Column(, primary_key=True)') - # The value used for sorting, segmenting, or api query paging, - # because uids aren't always sequential. - indexable = AbstractAttribute('Column(, index=True)') - name = Column(String, index=True) full_name = Column(String, index=True) html_url = Column(String) origin_url = Column(String) origin_type = Column(String) description = Column(String) last_seen = Column(DateTime, nullable=False) task_id = Column(Integer) origin_id = Column(Integer) - def __init__(self, uid=None, indexable=None, name=None, full_name=None, + def __init__(self, uid=None, name=None, full_name=None, html_url=None, origin_url=None, origin_type=None, description=None, task_id=None, origin_id=None): self.uid = uid self.last_seen = datetime.now() - if indexable is not None: - self.indexable = indexable if name is not None: self.name = name if full_name is not None: self.full_name = full_name if html_url is not None: self.html_url = html_url if origin_url is not None: self.origin_url = origin_url if origin_type is not None: self.origin_type = origin_type if description is not None: self.description = description if task_id is not None: self.task_id = task_id if origin_id is not None: self.origin_id = origin_id + + +class IndexingModelBase(ModelBase, metaclass=ABCSQLMeta): + __abstract__ = True + __tablename__ = AbstractAttribute + + # The value used for sorting, segmenting, or api query paging, + # because uids aren't always sequential. + indexable = AbstractAttribute('Column(, index=True)') + + def __init__(self, uid=None, name=None, full_name=None, + html_url=None, origin_url=None, origin_type=None, + description=None, task_id=None, origin_id=None, + indexable=None): + super().__init__( + uid=uid, name=name, full_name=full_name, html_url=html_url, + origin_url=origin_url, origin_type=origin_type, + description=description, task_id=task_id, origin_id=origin_id) + + if indexable is not None: + self.indexable = indexable diff --git a/swh/lister/core/page_by_page_lister.py b/swh/lister/core/page_by_page_lister.py new file mode 100644 index 0000000..8286b4a --- /dev/null +++ b/swh/lister/core/page_by_page_lister.py @@ -0,0 +1,157 @@ +# 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 logging + +from .lister_transports import SWHListerHttpTransport +from .lister_base import SWHListerBase + + +class PageByPageLister(SWHListerBase): + """Lister* intermediate class for any service that follows the simple + pagination page pattern. + + - Client sends a request to list repositories starting from a + given page identifier. + + - Client receives structured (json/xml/etc) response with + information about a sequential series of repositories (per page) + starting from a given index. And, if available, some indication + of the next page index for fetching the remaining repository + data. + + See :class:`swh.lister.core.lister_base.SWHListerBase` for more + details. + + This class cannot be instantiated. To create a new Lister for a + source code listing service that follows the model described + above, you must subclass this class. Then provide the required + overrides in addition to any unmet implementation/override + requirements of this class's base (see parent class and member + docstrings for details). + + Required Overrides:: + + def get_next_target_from_response + + """ + @abc.abstractmethod + def get_next_target_from_response(self, response): + """Find the next server endpoint page given the entire response. + + Implementation of this method depends on the server API spec + and the shape of the network response object returned by the + transport_request method. + + For example, some api can use the headers links to provide the + next page. + + Args: + response (transport response): response page from the server + + Returns: + index of next page, possibly extracted from a next href url + + """ + pass + + @abc.abstractmethod + def get_pages_information(self): + """Find the total number of pages. + + Implementation of this method depends on the server API spec + and the shape of the network response object returned by the + transport_request method. + + For example, some api can use dedicated headers: + - x-total-pages to provide the total number of pages + - x-total to provide the total number of repositories + - x-per-page to provide the number of elements per page + + Returns: + tuple (total number of repositories, total number of + pages, per_page) + + """ + pass + + # You probably don't need to override anything below this line. + + def do_additional_checks(self, models_list): + """Potentially check for existence of repositories in models_list. + + This will be called only if check_existence is flipped on in + the run method below. + + """ + for m in models_list: + sql_repo = self.db_query_equal('uid', m['uid']) + if sql_repo: + return False + return models_list + + def run(self, min_bound=None, max_bound=None, check_existence=False): + """Main entry function. Sequentially fetches repository data from the + service according to the basic outline in the class + docstring. Continually fetching sublists until either there + is no next page reference given or the given next page is + greater than the desired max_page. + + Args: + min_bound: optional page to start from + max_bound: optional page to stop at + check_existence (bool): optional existence check (for + incremental lister whose sort + order is inverted) + + Returns: + nothing + + """ + page = min_bound or 0 + loop_count = 0 + + self.min_page = min_bound + self.max_page = max_bound + + while self.is_within_bounds(page, self.min_page, self.max_page): + logging.info('listing repos starting at %s' % page) + + response, injected_repos = self.ingest_data(page, + checks=check_existence) + if not injected_repos: + logging.info('Repositories already seen, stopping') + break + + next_page = self.get_next_target_from_response(response) + + # termination condition + + if (next_page is None) or (next_page == page): + logging.info('stopping after page %s, no next link found' % + page) + break + else: + page = next_page + + loop_count += 1 + if loop_count == 20: + logging.info('flushing updates') + loop_count = 0 + self.db_session.commit() + self.db_session = self.mk_session() + + self.db_session.commit() + self.db_session = self.mk_session() + + +class PageByPageHttpLister(SWHListerHttpTransport, PageByPageLister): + """Convenience class for ensuring right lookup and init order when + combining PageByPageLister and SWHListerHttpTransport. + + """ + def __init__(self, api_baseurl=None, override_config=None): + SWHListerHttpTransport.__init__(self, api_baseurl=api_baseurl) + PageByPageLister.__init__(self, override_config=override_config) diff --git a/swh/lister/core/tasks.py b/swh/lister/core/tasks.py index 5d4e0f6..c3deb8b 100644 --- a/swh/lister/core/tasks.py +++ b/swh/lister/core/tasks.py @@ -1,73 +1,95 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-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 random from celery import group -from celery.app.task import TaskType -from swh.scheduler.task import Task +from swh.scheduler.task import Task, TaskType from .abstractattribute import AbstractAttribute class AbstractTaskMeta(abc.ABCMeta, TaskType): pass class ListerTaskBase(Task, metaclass=AbstractTaskMeta): """Lister Tasks define the process of periodically requesting batches of repository information from source code hosting services. They instantiate Listers to do batches of work at periodic intervals. There are two main kinds of lister tasks: 1. Discovering new repositories. 2. Refreshing the list of already discovered repositories. If the hosting service is indexable (according to the requirements of :class:`SWHIndexingLister`), then we can optionally partition the set of known repositories into sub-sets to distribute the work. This means that there is a third possible Task type for Indexing Listers: 3. Discover or refresh a specific range of indices. """ task_queue = AbstractAttribute('Celery Task queue name') @abc.abstractmethod - def new_lister(self): + def new_lister(self, **lister_args): """Return a new lister of the appropriate type. """ pass @abc.abstractmethod - def run_task(self): + def run_task(self, *, lister_args=None): pass -class IndexingDiscoveryListerTask(ListerTaskBase): - def run_task(self): - lister = self.new_lister() - return lister.run(min_index=lister.db_last_index(), max_index=None) +# Paging/Indexing lister tasks derivatives +# (cf. {github/bitbucket/gitlab}/tasks) + + +class RangeListerTask(ListerTaskBase): + """Range lister task. + + """ + def run_task(self, start, end, lister_args=None): + if lister_args is None: + lister_args = {} + lister = self.new_lister(**lister_args) + return lister.run(min_bound=start, max_bound=end) + +# Indexing Lister tasks derivatives (cf. {github/bitbucket}/tasks) -class IndexingRangeListerTask(ListerTaskBase): - def run_task(self, start, end): - lister = self.new_lister() - return lister.run(min_index=start, max_index=end) + +class IndexingDiscoveryListerTask(ListerTaskBase): + """Incremental indexing lister task. + + """ + def run_task(self, *, lister_args=None): + if lister_args is None: + lister_args = {} + lister = self.new_lister(**lister_args) + return lister.run(min_bound=lister.db_last_index(), max_bound=None) class IndexingRefreshListerTask(ListerTaskBase): + """Full indexing lister task. + + """ GROUP_SPLIT = 10000 - def run_task(self): - lister = self.new_lister() + def run_task(self, *, lister_args=None): + if lister_args is None: + lister_args = {} + lister = self.new_lister(**lister_args) ranges = lister.db_partition_indices(self.GROUP_SPLIT) random.shuffle(ranges) - range_task = IndexingRangeListerTask() - group(range_task.s(minv, maxv) for minv, maxv in ranges)() + range_task = RangeListerTask() + group(range_task.s(minv, maxv, lister_args) + for minv, maxv in ranges)() diff --git a/swh/lister/core/tests/test_lister.py b/swh/lister/core/tests/test_lister.py index 6bc0259..b2e28cd 100644 --- a/swh/lister/core/tests/test_lister.py +++ b/swh/lister/core/tests/test_lister.py @@ -1,231 +1,238 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-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 time from unittest import TestCase from unittest.mock import Mock, patch import requests_mock from testing.postgresql import Postgresql from nose.tools import istest from sqlalchemy import create_engine from swh.lister.core.abstractattribute import AbstractAttribute def noop(*args, **kwargs): pass @requests_mock.Mocker() -class IndexingHttpListerTesterBase(abc.ABC): +class HttpListerTesterBase(abc.ABC): """Base testing class for subclasses of - swh.lister.core.indexing_lister.SWHIndexingHttpLister. - See swh.lister.github.tests.test_gh_lister for an example of how to - customize for a specific listing service. + swh.lister.core.indexing_lister.SWHIndexingHttpLister. + swh.lister.core.page_by_page_lister.PageByPageHttpLister + + See swh.lister.github.tests.test_gh_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') - last_index = AbstractAttribute('Last index in good_api_response') entries_per_page = AbstractAttribute('Number of results in good response') + LISTER_NAME = 'fake-lister' # 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__ != IndexingHttpListerTesterBase: + 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 m.group(1) else: return None 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) if self.request_index(request) == str(self.first_index): with open('swh/lister/%s/tests/%s' % (self.lister_subdir, self.good_api_response_file), 'r', encoding='utf-8') as r: return r.read() else: with open('swh/lister/%s/tests/%s' % (self.lister_subdir, self.bad_api_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_fl(self, override_config=None): + """Retrieve an instance of fake lister (fl). + + """ if override_config or self.fl is None: - with patch( - 'swh.scheduler.backend.SchedulerBackend.reconnect', noop - ): - self.fl = self.Lister(lister_name='fakelister', - api_baseurl='https://fakeurl', - override_config=override_config) - self.fl.INITIAL_BACKOFF = 1 + 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 @istest 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) @istest 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) @istest 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) @istest def test_model_map(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', 'origin_id']: + if k not in ['last_seen', 'task_id', 'origin_id', 'id']: self.assertIn(k, di) def disable_storage_and_scheduler(self, fl): fl.create_missing_origins_and_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) @istest def test_fetch_none_nodb(self, http_mocker): http_mocker.get(self.test_re, text=self.mock_response) fl = self.get_fl() self.disable_storage_and_scheduler(fl) self.disable_db(fl) - fl.run(min_index=1, max_index=1) # stores no results + fl.run(min_bound=1, max_bound=1) # stores no results @istest def test_fetch_one_nodb(self, http_mocker): http_mocker.get(self.test_re, text=self.mock_response) fl = self.get_fl() self.disable_storage_and_scheduler(fl) self.disable_db(fl) - fl.run(min_index=self.first_index, max_index=self.first_index) + fl.run(min_bound=self.first_index, max_bound=self.first_index) @istest 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_storage_and_scheduler(fl) self.disable_db(fl) - fl.run(min_index=self.first_index) + fl.run(min_bound=self.first_index) def init_db(self, db, model): engine = create_engine(db.url()) model.metadata.create_all(engine) + +class HttpListerTester(HttpListerTesterBase, abc.ABC): + last_index = AbstractAttribute('Last index in good_api_response') + + @requests_mock.Mocker() @istest def test_fetch_multiple_pages_yesdb(self, http_mocker): 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(override_config={'lister_db_url': db.url()}) self.init_db(db, fl.MODEL) self.disable_storage_and_scheduler(fl) - fl.run(min_index=self.first_index) + 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) diff --git a/swh/lister/core/tests/test_model.py b/swh/lister/core/tests/test_model.py index 6eb9a4b..b2c25e1 100644 --- a/swh/lister/core/tests/test_model.py +++ b/swh/lister/core/tests/test_model.py @@ -1,53 +1,94 @@ # Copyright (C) 2017 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 nose.tools import istest from sqlalchemy import Column, Integer -from swh.lister.core.models import ModelBase +from swh.lister.core.models import ModelBase, IndexingModelBase class BadSubclass1(ModelBase): __abstract__ = True pass class BadSubclass2(ModelBase): __abstract__ = True __tablename__ = 'foo' class BadSubclass3(BadSubclass2): __abstract__ = True pass class GoodSubclass(BadSubclass2): uid = Column(Integer, primary_key=True) indexable = Column(Integer, index=True) +class IndexingBadSubclass(IndexingModelBase): + __abstract__ = True + pass + + +class IndexingBadSubclass2(IndexingModelBase): + __abstract__ = True + __tablename__ = 'foo' + + +class IndexingBadSubclass3(IndexingBadSubclass2): + __abstract__ = True + pass + + +class IndexingGoodSubclass(IndexingModelBase): + uid = Column(Integer, primary_key=True) + indexable = Column(Integer, index=True) + __tablename__ = 'bar' + + class TestModel(unittest.TestCase): @istest def test_model_instancing(self): with self.assertRaises(TypeError): ModelBase() with self.assertRaises(TypeError): BadSubclass1() with self.assertRaises(TypeError): BadSubclass2() with self.assertRaises(TypeError): BadSubclass3() self.assertIsInstance(GoodSubclass(), GoodSubclass) - gsc = GoodSubclass(uid='uid', indexable='indexable') + gsc = GoodSubclass(uid='uid') self.assertEqual(gsc.__tablename__, 'foo') self.assertEqual(gsc.uid, 'uid') + + @istest + def test_indexing_model_instancing(self): + with self.assertRaises(TypeError): + IndexingModelBase() + + with self.assertRaises(TypeError): + IndexingBadSubclass() + + with self.assertRaises(TypeError): + IndexingBadSubclass2() + + with self.assertRaises(TypeError): + IndexingBadSubclass3() + + self.assertIsInstance(IndexingGoodSubclass(), IndexingGoodSubclass) + gsc = IndexingGoodSubclass(uid='uid', indexable='indexable') + + self.assertEqual(gsc.__tablename__, 'bar') + self.assertEqual(gsc.uid, 'uid') self.assertEqual(gsc.indexable, 'indexable') diff --git a/swh/lister/debian/lister.py b/swh/lister/debian/lister.py index 913971b..d23e693 100644 --- a/swh/lister/debian/lister.py +++ b/swh/lister/debian/lister.py @@ -1,236 +1,236 @@ # Copyright (C) 2017 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import bz2 from collections import defaultdict import datetime import gzip import lzma import logging from debian.deb822 import Sources from sqlalchemy.orm import joinedload, load_only from sqlalchemy.schema import CreateTable, DropTable from swh.storage.schemata.distribution import ( AreaSnapshot, Distribution, DistributionSnapshot, Package, TempPackage, ) from swh.lister.core.lister_base import SWHListerBase, FetchError from swh.lister.core.lister_transports import SWHListerHttpTransport decompressors = { 'gz': lambda f: gzip.GzipFile(fileobj=f), 'bz2': bz2.BZ2File, 'xz': lzma.LZMAFile, } class DebianLister(SWHListerHttpTransport, SWHListerBase): MODEL = Package PATH_TEMPLATE = None + LISTER_NAME = 'debian' def __init__(self, override_config=None): SWHListerHttpTransport.__init__(self, api_baseurl="bogus") - SWHListerBase.__init__(self, lister_name='debian', - override_config=override_config) + SWHListerBase.__init__(self, override_config=override_config) def transport_request(self, identifier): """Subvert SWHListerHttpTransport.transport_request, to try several index URIs in turn. The Debian repository format supports several compression algorithms across the ages, so we try several URIs. Once we have found a working URI, we break and set `self.decompressor` to the one that matched. Returns: a requests Response object. Raises: FetchError: when all the URIs failed to be retrieved. """ response = None compression = None for uri, compression in self.area.index_uris(): response = super().transport_request(uri) if response.status_code == 200: break else: raise FetchError( "Could not retrieve index for %s" % self.area ) self.decompressor = decompressors.get(compression) return response def request_uri(self, identifier): # In the overridden transport_request, we pass # SWHListerBase.transport_request() the full URI as identifier, so we # need to return it here. return identifier def request_params(self, identifier): # Enable streaming to allow wrapping the response in the decompressor # in transport_response_simplified. params = super().request_params(identifier) params['stream'] = True return params def transport_response_simplified(self, response): """Decompress and parse the package index fetched in `transport_request`. For each package, we "pivot" the file list entries (Files, Checksums-Sha1, Checksums-Sha256), to return a files dict mapping filenames to their checksums. """ if self.decompressor: data = self.decompressor(response.raw) else: data = response.raw for src_pkg in Sources.iter_paragraphs(data.readlines()): files = defaultdict(dict) for field in src_pkg._multivalued_fields: if field.startswith('checksums-'): sum_name = field[len('checksums-'):] else: sum_name = 'md5sum' if field in src_pkg: for entry in src_pkg[field]: name = entry['name'] files[name]['name'] = entry['name'] files[name]['size'] = int(entry['size'], 10) files[name][sum_name] = entry[sum_name] yield { 'name': src_pkg['Package'], 'version': src_pkg['Version'], 'directory': src_pkg['Directory'], 'files': files, } def inject_repo_data_into_db(self, models_list): """Generate the Package entries that didn't previously exist. Contrary to SWHListerBase, we don't actually insert the data in database. `create_missing_origins_and_tasks` does it once we have the origin and task identifiers. """ by_name_version = {} temp_packages = [] area_id = self.area.id for model in models_list: name = model['name'] version = model['version'] temp_packages.append({ 'area_id': area_id, 'name': name, 'version': version, }) by_name_version[name, version] = model # Add all the listed packages to a temporary table self.db_session.execute(CreateTable(TempPackage.__table__)) self.db_session.bulk_insert_mappings(TempPackage, temp_packages) def exists_tmp_pkg(db_session, model): return ( db_session.query(model) .filter(Package.area_id == TempPackage.area_id) .filter(Package.name == TempPackage.name) .filter(Package.version == TempPackage.version) .exists() ) # Filter out the packages that already exist in the main Package table new_packages = self.db_session\ .query(TempPackage)\ .options(load_only('name', 'version'))\ .filter(~exists_tmp_pkg(self.db_session, Package))\ .all() self.old_area_packages = self.db_session.query(Package).filter( exists_tmp_pkg(self.db_session, TempPackage) ).all() self.db_session.execute(DropTable(TempPackage.__table__)) added_packages = [] for package in new_packages: model = by_name_version[package.name, package.version] added_packages.append(Package(area=self.area, **model)) self.db_session.add_all(added_packages) return added_packages def create_missing_origins_and_tasks(self, models_list, added_packages): """We create tasks at the end of the full snapshot processing""" return def create_tasks_for_snapshot(self, snapshot): tasks = [ snapshot.task_for_package(name, versions) for name, versions in snapshot.get_packages().items() ] return self.scheduler.create_tasks(tasks) def run(self, distribution, date=None): """Run the lister for a given (distribution, area) tuple. Args: distribution (str): name of the distribution (e.g. "Debian") date (datetime.datetime): date the snapshot is taken (defaults to now) """ distribution = self.db_session\ .query(Distribution)\ .options(joinedload(Distribution.areas))\ .filter(Distribution.name == distribution)\ .one_or_none() if not distribution: raise ValueError("Distribution %s is not registered" % distribution) if not distribution.type == 'deb': raise ValueError("Distribution %s is not a Debian derivative" % distribution) date = date or datetime.datetime.now(tz=datetime.timezone.utc) logging.debug('Creating snapshot for distribution %s on date %s' % (distribution, date)) snapshot = DistributionSnapshot(date=date, distribution=distribution) self.db_session.add(snapshot) for area in distribution.areas: if not area.active: continue self.area = area logging.debug('Processing area %s' % area) _, new_area_packages = self.ingest_data(None) area_snapshot = AreaSnapshot(snapshot=snapshot, area=area) self.db_session.add(area_snapshot) area_snapshot.packages.extend(new_area_packages) area_snapshot.packages.extend(self.old_area_packages) self.create_tasks_for_snapshot(snapshot) self.db_session.commit() return True diff --git a/swh/lister/debian/tasks.py b/swh/lister/debian/tasks.py index cdac167..0ddb653 100644 --- a/swh/lister/debian/tasks.py +++ b/swh/lister/debian/tasks.py @@ -1,18 +1,18 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-2018 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.lister.core.tasks import ListerTaskBase from .lister import DebianLister class DebianListerTask(ListerTaskBase): task_queue = 'swh_lister_debian' def new_lister(self): return DebianLister() def run_task(self, distribution): lister = self.new_lister() return lister.run(distribution) diff --git a/swh/lister/github/lister.py b/swh/lister/github/lister.py index af27899..f841f60 100644 --- a/swh/lister/github/lister.py +++ b/swh/lister/github/lister.py @@ -1,51 +1,52 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-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 re import time from swh.lister.core.indexing_lister import SWHIndexingHttpLister from swh.lister.github.models import GitHubModel class GitHubLister(SWHIndexingHttpLister): PATH_TEMPLATE = '/repositories?since=%d' MODEL = GitHubModel API_URL_INDEX_RE = re.compile(r'^.*/repositories\?since=(\d+)') + LISTER_NAME = 'github.com' def get_model_from_repo(self, repo): return { - 'uid': repo['id'], - 'indexable': repo['id'], - 'name': repo['name'], - 'full_name': repo['full_name'], - 'html_url': repo['html_url'], - 'origin_url': repo['html_url'], - 'origin_type': 'git', - 'description': repo['description'], - 'fork': repo['fork'], - } + 'uid': repo['id'], + 'indexable': repo['id'], + 'name': repo['name'], + 'full_name': repo['full_name'], + 'html_url': repo['html_url'], + 'origin_url': repo['html_url'], + 'origin_type': 'git', + 'description': repo['description'], + 'fork': repo['fork'], + } def transport_quota_check(self, response): reqs_remaining = int(response.headers['X-RateLimit-Remaining']) if response.status_code == 403 and reqs_remaining == 0: reset_at = int(response.headers['X-RateLimit-Reset']) delay = min(reset_at - time.time(), 3600) return True, delay else: return False, 0 def get_next_target_from_response(self, response): if 'next' in response.links: next_url = response.links['next']['url'] return int(self.API_URL_INDEX_RE.match(next_url).group(1)) else: return None def transport_response_simplified(self, response): repos = response.json() return [self.get_model_from_repo(repo) for repo in repos] def request_headers(self): return {'Accept': 'application/vnd.github.v3+json'} diff --git a/swh/lister/github/models.py b/swh/lister/github/models.py index 32055a7..2cb429f 100644 --- a/swh/lister/github/models.py +++ b/swh/lister/github/models.py @@ -1,20 +1,20 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-2018 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, Boolean, Integer -from swh.lister.core.models import ModelBase +from swh.lister.core.models import IndexingModelBase -class GitHubModel(ModelBase): +class GitHubModel(IndexingModelBase): """a GitHub repository""" __tablename__ = 'github_repos' uid = Column(Integer, primary_key=True) indexable = Column(Integer, index=True) fork = Column(Boolean) def __init__(self, *args, **kwargs): self.fork = kwargs.pop('fork', False) super().__init__(*args, **kwargs) diff --git a/swh/lister/github/tasks.py b/swh/lister/github/tasks.py index ef88c5d..c2e841e 100644 --- a/swh/lister/github/tasks.py +++ b/swh/lister/github/tasks.py @@ -1,27 +1,26 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-2018 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.lister.core.tasks import (IndexingDiscoveryListerTask, - IndexingRangeListerTask, + RangeListerTask, IndexingRefreshListerTask, ListerTaskBase) from .lister import GitHubLister class GitHubListerTask(ListerTaskBase): - def new_lister(self): - return GitHubLister(lister_name='github.com', - api_baseurl='https://api.github.com') + def new_lister(self, *, api_baseurl='https://api.github.com'): + return GitHubLister(api_baseurl=api_baseurl) class IncrementalGitHubLister(GitHubListerTask, IndexingDiscoveryListerTask): task_queue = 'swh_lister_github_discover' -class RangeGitHubLister(GitHubListerTask, IndexingRangeListerTask): +class RangeGitHubLister(GitHubListerTask, RangeListerTask): task_queue = 'swh_lister_github_refresh' class FullGitHubRelister(GitHubListerTask, IndexingRefreshListerTask): task_queue = 'swh_lister_github_refresh' diff --git a/swh/lister/github/tests/test_gh_lister.py b/swh/lister/github/tests/test_gh_lister.py index 3ce4453..768a247 100644 --- a/swh/lister/github/tests/test_gh_lister.py +++ b/swh/lister/github/tests/test_gh_lister.py @@ -1,46 +1,46 @@ -# Copyright (C) 2017 the Software Heritage developers +# Copyright (C) 2017-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 re import unittest from datetime import datetime, timedelta -from swh.lister.core.tests.test_lister import IndexingHttpListerTesterBase +from swh.lister.core.tests.test_lister import HttpListerTester from swh.lister.github.lister import GitHubLister -class GitHubListerTester(IndexingHttpListerTesterBase, unittest.TestCase): +class GitHubListerTester(HttpListerTester, unittest.TestCase): Lister = GitHubLister test_re = re.compile(r'/repositories\?since=([^?&]+)') lister_subdir = 'github' good_api_response_file = 'api_response.json' bad_api_response_file = 'api_empty_response.json' first_index = 26 last_index = 368 entries_per_page = 100 def response_headers(self, request): headers = {'X-RateLimit-Remaining': '1'} if self.request_index(request) == str(self.first_index): headers.update({ 'Link': ';' ' rel="next",' ';' ' rel="first"' }) else: headers.update({ 'Link': ';' ' rel="first"' }) return headers def mock_rate_quota(self, n, request, context): self.rate_limit += 1 context.status_code = 403 context.headers['X-RateLimit-Remaining'] = '0' one_second = int((datetime.now() + timedelta(seconds=1.5)).timestamp()) context.headers['X-RateLimit-Reset'] = str(one_second) return '{"error":"dummy"}' diff --git a/swh/lister/gitlab/__init__.py b/swh/lister/gitlab/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/lister/gitlab/lister.py b/swh/lister/gitlab/lister.py new file mode 100644 index 0000000..654cfc3 --- /dev/null +++ b/swh/lister/gitlab/lister.py @@ -0,0 +1,119 @@ +# Copyright (C) 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 random +import time + +from ..core.page_by_page_lister import PageByPageHttpLister +from .models import GitLabModel + + +class GitLabLister(PageByPageHttpLister): + # Template path expecting an integer that represents the page id + PATH_TEMPLATE = '/projects?page=%d&order_by=id' + MODEL = GitLabModel + LISTER_NAME = 'gitlab' + + def __init__(self, api_baseurl=None, instance=None, + override_config=None, sort='asc'): + super().__init__(api_baseurl=api_baseurl, + override_config=override_config) + self.instance = instance + self.PATH_TEMPLATE = '%s&sort=%s' % (self.PATH_TEMPLATE, sort) + + @property + def ADDITIONAL_CONFIG(self): + """Override additional config as the 'credentials' structure change + between the ancestor classes and this class. + + cf. request_params method below + + """ + default_config = super().ADDITIONAL_CONFIG + # 'credentials' is a dict of (instance, {username, password}) dict + default_config['credentials'] = ('dict', {}) + return default_config + + def request_params(self, identifier): + """Get the full parameters passed to requests given the + transport_request identifier. + + For the gitlab lister, the 'credentials' entries is configured + per instance. For example: + + - credentials: + - gitlab.com: + - username: user0 + password: + - username: user1 + password: + - ... + - other-gitlab-instance: + ... + + """ + params = { + 'headers': self.request_headers() or {} + } + # Retrieve the credentials per instance + creds = self.config['credentials'] + if creds: + creds_lister = creds[self.instance] + auth = random.choice(creds_lister) if creds else None + if auth: + params['auth'] = (auth['username'], auth['password']) + return params + + def uid(self, repo): + return '%s/%s' % (self.instance, repo['path_with_namespace']) + + def get_model_from_repo(self, repo): + return { + 'instance': self.instance, + 'uid': self.uid(repo), + 'name': repo['name'], + 'full_name': repo['path_with_namespace'], + 'html_url': repo['web_url'], + 'origin_url': repo['http_url_to_repo'], + 'origin_type': 'git', + 'description': repo['description'], + } + + def transport_quota_check(self, response): + """Deal with rate limit if any. + + """ + # not all gitlab instance have rate limit + if 'RateLimit-Remaining' in response.headers: + reqs_remaining = int(response.headers['RateLimit-Remaining']) + if response.status_code == 403 and reqs_remaining == 0: + reset_at = int(response.headers['RateLimit-Reset']) + delay = min(reset_at - time.time(), 3600) + return True, delay + return False, 0 + + def _get_int(self, headers, key): + _val = headers.get(key) + if _val: + return int(_val) + + def get_next_target_from_response(self, response): + """Determine the next page identifier. + + """ + return self._get_int(response.headers, 'x-next-page') + + def get_pages_information(self): + """Determine pages information. + + """ + response = self.transport_head(identifier=1) + h = response.headers + return (self._get_int(h, 'x-total'), + self._get_int(h, 'x-total-pages'), + self._get_int(h, 'x-per-page')) + + def transport_response_simplified(self, response): + repos = response.json() + return [self.get_model_from_repo(repo) for repo in repos] diff --git a/swh/lister/gitlab/models.py b/swh/lister/gitlab/models.py new file mode 100644 index 0000000..68841bf --- /dev/null +++ b/swh/lister/gitlab/models.py @@ -0,0 +1,28 @@ +# Copyright (C) 2018 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, Integer, String + +from ..core.models import ModelBase + + +class GitLabModel(ModelBase): + """a Gitlab repository from a gitlab instance + + """ + __tablename__ = 'gitlab_repo' + + uid = Column(String, primary_key=True) + instance = Column(String, index=True) + + def __init__(self, uid=None, indexable=None, name=None, + full_name=None, html_url=None, origin_url=None, + origin_type=None, description=None, task_id=None, + origin_id=None, instance=None): + super().__init__(uid=uid, name=name, + full_name=full_name, html_url=html_url, + origin_url=origin_url, origin_type=origin_type, + description=description, task_id=task_id, + origin_id=origin_id) + self.instance = instance diff --git a/swh/lister/gitlab/tasks.py b/swh/lister/gitlab/tasks.py new file mode 100644 index 0000000..7d46078 --- /dev/null +++ b/swh/lister/gitlab/tasks.py @@ -0,0 +1,63 @@ +# Copyright (C) 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 random + +from celery import group + +from .. import utils +from ..core.tasks import ListerTaskBase, RangeListerTask +from .lister import GitLabLister + + +class GitLabListerTask(ListerTaskBase): + def new_lister(self, *, api_baseurl='https://gitlab.com/api/v4', + instance='gitlab.com', sort='asc'): + return GitLabLister( + api_baseurl=api_baseurl, instance=instance, sort=sort) + + +class RangeGitLabLister(GitLabListerTask, RangeListerTask): + """Range GitLab lister (list available origins on specified range) + + """ + task_queue = 'swh_lister_gitlab_refresh' + + +class FullGitLabRelister(GitLabListerTask): + """Full GitLab lister (list all available origins from the api). + + """ + task_queue = 'swh_lister_gitlab_refresh' + + # nb pages + nb_pages = 10 + + def run_task(self, lister_args=None): + if lister_args is None: + lister_args = {} + lister = self.new_lister(**lister_args) + _, total_pages, _ = lister.get_pages_information() + ranges = list(utils.split_range(total_pages, self.nb_pages)) + random.shuffle(ranges) + range_task = RangeGitLabLister() + group(range_task.s(minv, maxv, lister_args=lister_args) + for minv, maxv in ranges)() + + +class IncrementalGitLabLister(GitLabListerTask): + """Incremental GitLab lister (list only new available origins). + + """ + task_queue = 'swh_lister_gitlab_discover' + + def run_task(self, lister_args=None): + if lister_args is None: + lister_args = {} + lister_args['sort'] = 'desc' + lister = self.new_lister(**lister_args) + _, total_pages, _ = lister.get_pages_information() + # stopping as soon as existing origins for that instance are detected + return lister.run(min_bound=1, max_bound=total_pages, + check_existence=True) diff --git a/swh/lister/gitlab/tests/__init__.py b/swh/lister/gitlab/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/lister/gitlab/tests/api_empty_response.json b/swh/lister/gitlab/tests/api_empty_response.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/swh/lister/gitlab/tests/api_empty_response.json @@ -0,0 +1 @@ +[] diff --git a/swh/lister/gitlab/tests/api_response.json b/swh/lister/gitlab/tests/api_response.json new file mode 100644 index 0000000..ada382a --- /dev/null +++ b/swh/lister/gitlab/tests/api_response.json @@ -0,0 +1,170 @@ +[{"avatar_url": null, + "created_at": "2012-10-15T17:26:53.000Z", + "default_branch": "master", + "description": null, + "forks_count": 3, + "http_url_to_repo": "https://gitlab.com/leberwurscht/teardownwalls.git", + "id": 143, + "last_activity_at": "2013-10-03T08:08:46.000Z", + "name": "TearDownWalls", + "name_with_namespace": "Leberwurscht / TearDownWalls", + "path": "teardownwalls", + "path_with_namespace": "leberwurscht/teardownwalls", + "readme_url": "https://gitlab.com/leberwurscht/teardownwalls/blob/master/README.md", + "ssh_url_to_repo": "git@gitlab.com:leberwurscht/teardownwalls.git", + "star_count": 1, + "tag_list": [], + "web_url": "https://gitlab.com/leberwurscht/teardownwalls"}, + {"avatar_url": null, + "created_at": "2012-12-12T21:30:14.000Z", + "default_branch": "master", + "description": "", + "forks_count": 0, + "http_url_to_repo": "https://gitlab.com/technomancy/leiningen.git", + "id": 450, + "last_activity_at": "2018-06-24T00:07:06.666Z", + "name": "Leiningen", + "name_with_namespace": "Phil Hagelberg / Leiningen", + "path": "leiningen", + "path_with_namespace": "technomancy/leiningen", + "readme_url": "https://gitlab.com/technomancy/leiningen/blob/master/README.md", + "ssh_url_to_repo": "git@gitlab.com:technomancy/leiningen.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/technomancy/leiningen"}, + {"avatar_url": null, + "created_at": "2012-12-18T17:25:39.000Z", + "default_branch": "master", + "description": null, + "forks_count": 4, + "http_url_to_repo": "https://gitlab.com/jonan/heroes-of-wesnoth.git", + "id": 526, + "last_activity_at": "2015-04-09T14:43:49.363Z", + "name": "Heroes of Wesnoth", + "name_with_namespace": "Jonan / Heroes of Wesnoth", + "path": "heroes-of-wesnoth", + "path_with_namespace": "jonan/heroes-of-wesnoth", + "readme_url": null, + "ssh_url_to_repo": "git@gitlab.com:jonan/heroes-of-wesnoth.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/jonan/heroes-of-wesnoth"}, + {"avatar_url": null, + "created_at": "2012-12-18T17:33:03.000Z", + "default_branch": "master", + "description": null, + "forks_count": 0, + "http_url_to_repo": "https://gitlab.com/jonan/k.git", + "id": 527, + "last_activity_at": "2014-10-11T22:29:04.138Z", + "name": "K", + "name_with_namespace": "Jonan / K", + "path": "k", + "path_with_namespace": "jonan/k", + "readme_url": "https://gitlab.com/jonan/k/blob/master/README", + "ssh_url_to_repo": "git@gitlab.com:jonan/k.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/jonan/k"}, + {"avatar_url": null, + "created_at": "2013-01-06T20:35:42.000Z", + "default_branch": "master", + "description": "", + "forks_count": 0, + "http_url_to_repo": "https://gitlab.com/hcs/hcs_utils.git", + "id": 1025, + "last_activity_at": "2015-09-14T12:01:11.151Z", + "name": "hcs_utils", + "name_with_namespace": "Christer Sjöholm / hcs_utils", + "path": "hcs_utils", + "path_with_namespace": "hcs/hcs_utils", + "readme_url": "https://gitlab.com/hcs/hcs_utils/blob/master/README.txt", + "ssh_url_to_repo": "git@gitlab.com:hcs/hcs_utils.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/hcs/hcs_utils"}, + {"avatar_url": null, + "created_at": "2013-01-24T08:41:56.000Z", + "default_branch": null, + "description": null, + "forks_count": 0, + "http_url_to_repo": "https://gitlab.com/soeren/sspssptest.git", + "id": 1702, + "last_activity_at": "2013-10-03T08:31:54.000Z", + "name": "sspssptest", + "name_with_namespace": "kruemel / sspssptest", + "path": "sspssptest", + "path_with_namespace": "soeren/sspssptest", + "readme_url": null, + "ssh_url_to_repo": "git@gitlab.com:soeren/sspssptest.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/soeren/sspssptest"}, + {"avatar_url": null, + "created_at": "2013-01-28T22:59:31.000Z", + "default_branch": "master", + "description": null, + "forks_count": 0, + "http_url_to_repo": "https://gitlab.com/dpp/slothbeast.git", + "id": 1865, + "last_activity_at": "2013-05-05T09:44:57.000Z", + "name": "slothbeast", + "name_with_namespace": "David Pollak / slothbeast", + "path": "slothbeast", + "path_with_namespace": "dpp/slothbeast", + "readme_url": "https://gitlab.com/dpp/slothbeast/blob/master/README.md", + "ssh_url_to_repo": "git@gitlab.com:dpp/slothbeast.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/dpp/slothbeast"}, + {"avatar_url": null, + "created_at": "2013-02-07T20:50:20.000Z", + "default_branch": "master", + "description": null, + "forks_count": 0, + "http_url_to_repo": "https://gitlab.com/rocksoniko/easy.git", + "id": 2227, + "last_activity_at": "2013-05-05T09:45:00.000Z", + "name": "easy", + "name_with_namespace": "Hugo / easy", + "path": "easy", + "path_with_namespace": "rocksoniko/easy", + "readme_url": "https://gitlab.com/rocksoniko/easy/blob/master/README", + "ssh_url_to_repo": "git@gitlab.com:rocksoniko/easy.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/rocksoniko/easy"}, + {"avatar_url": null, + "created_at": "2013-02-10T17:21:24.000Z", + "default_branch": null, + "description": null, + "forks_count": 0, + "http_url_to_repo": "https://gitlab.com/grup/grup.git", + "id": 2294, + "last_activity_at": "2013-05-05T09:45:01.000Z", + "name": "grup", + "name_with_namespace": "grup / grup", + "path": "grup", + "path_with_namespace": "grup/grup", + "readme_url": null, + "ssh_url_to_repo": "git@gitlab.com:grup/grup.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/grup/grup"}, + {"avatar_url": null, + "created_at": "2013-02-14T09:31:50.000Z", + "default_branch": "master", + "description": "", + "forks_count": 0, + "http_url_to_repo": "https://gitlab.com/varac/test.git", + "id": 2390, + "last_activity_at": "2016-02-11T13:51:47.463Z", + "name": "test", + "name_with_namespace": "varac / test", + "path": "test", + "path_with_namespace": "varac/test", + "readme_url": null, + "ssh_url_to_repo": "git@gitlab.com:varac/test.git", + "star_count": 0, + "tag_list": [], + "web_url": "https://gitlab.com/varac/test"}] diff --git a/swh/lister/gitlab/tests/test_gitlab_lister.py b/swh/lister/gitlab/tests/test_gitlab_lister.py new file mode 100644 index 0000000..9d17330 --- /dev/null +++ b/swh/lister/gitlab/tests/test_gitlab_lister.py @@ -0,0 +1,38 @@ +# Copyright (C) 2017-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 re +import unittest + +from datetime import datetime, timedelta + +from swh.lister.gitlab.lister import GitLabLister +from swh.lister.core.tests.test_lister import HttpListerTesterBase + + +class GitLabListerTester(HttpListerTesterBase, unittest.TestCase): + Lister = GitLabLister + test_re = re.compile(r'^.*/projects.*page=(\d+).*') + lister_subdir = 'gitlab' + good_api_response_file = 'api_response.json' + bad_api_response_file = 'api_empty_response.json' + first_index = 1 + entries_per_page = 10 + + def response_headers(self, request): + headers = {'RateLimit-Remaining': '1'} + if self.request_index(request) == str(self.first_index): + headers.update({ + 'x-next-page': '3', + }) + + return headers + + def mock_rate_quota(self, n, request, context): + self.rate_limit += 1 + context.status_code = 403 + context.headers['RateLimit-Remaining'] = '0' + one_second = int((datetime.now() + timedelta(seconds=1.5)).timestamp()) + context.headers['RateLimit-Reset'] = str(one_second) + return '{"error":"dummy"}' diff --git a/swh/lister/tests/__init__.py b/swh/lister/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/lister/tests/test_utils.py b/swh/lister/tests/test_utils.py new file mode 100644 index 0000000..978127a --- /dev/null +++ b/swh/lister/tests/test_utils.py @@ -0,0 +1,28 @@ +# Copyright (C) 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 unittest + +from nose.tools import istest + +from swh.lister import utils + + +class UtilsTest(unittest.TestCase): + + @istest + def 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)]) + + @istest + def split_range_errors(self): + with self.assertRaises(TypeError): + list(utils.split_range(None, 1)) + + with self.assertRaises(TypeError): + list(utils.split_range(100, None)) diff --git a/swh/lister/utils.py b/swh/lister/utils.py new file mode 100644 index 0000000..68e8b82 --- /dev/null +++ b/swh/lister/utils.py @@ -0,0 +1,14 @@ +# Copyright (C) 2018 the Software Heritage developers +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + + +def split_range(total_pages, nb_pages): + prev_index = None + for index in range(0, total_pages, nb_pages): + if index is not None and prev_index is not None: + yield prev_index, index + prev_index = index + + if index != total_pages: + yield index, total_pages diff --git a/version.txt b/version.txt index 88856ca..6587e4b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v0.0.10-0-g9b58ecb \ No newline at end of file +v0.0.11-0-gd6694a8 \ No newline at end of file