diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 47817bb..0000000 --- a/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -*.pyc -*.sw? -*~ -.coverage -.eggs/ -__pycache__ -build/ -dist/ -*.egg-info -version.txt -swh/lister/_version.py diff --git a/ACKNOWLEDGEMENTS b/ACKNOWLEDGEMENTS deleted file mode 100644 index 1ff4ffc..0000000 --- a/ACKNOWLEDGEMENTS +++ /dev/null @@ -1,5 +0,0 @@ -Many thanks to: - -- Aaron Williamson for - https://github.com/copiesofcopies/github-license-analysis and his feedback - about using it to compute stats about software licensing on GitHub diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 94a9ed0..0000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - 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. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/PKG-INFO b/PKG-INFO index 27f7d11..2d0c65e 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,222 +1,222 @@ Metadata-Version: 2.1 Name: swh.lister -Version: 0.0.18 +Version: 0.0.19 Summary: Software Heritage lister Home-page: https://forge.softwareheritage.org/diffusion/DLSGH/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Project-URL: Bug Reports, https://forge.softwareheritage.org/maniphest -Project-URL: Funding, https://www.softwareheritage.org/donate Project-URL: Source, https://forge.softwareheritage.org/source/swh-lister +Project-URL: Funding, https://www.softwareheritage.org/donate Description: 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-bitbucket - swh-lister-debian - swh-lister-github - swh-lister-gitlab - swh-lister-pypi 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/user/.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/user/.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', 'per_page': 20}) >>> from swh.lister.gitlab.tasks import FullGitLabRelister; FullGitLabRelister().run_task( {'instance':'0xacab', 'api_baseurl':'https://0xacab.org/api/v4', 'sort': 'asc', 'per_page': 20}) >>> from swh.lister.gitlab.tasks import IncrementalGitLabLister; IncrementalGitLabLister().run_task( {'instance': 'freedesktop.org', 'api_baseurl': 'https://gitlab.freedesktop.org/api/v4', 'sort': 'asc', 'per_page': 20}) ## lister-debian ### preparation steps 1. git clone under $SWH_ENVIRONMENT_HOME/swh-lister (of your choosing) 2. mkdir ~/.config/swh/ ~/.cache/swh/lister/debian/ 3. create configuration file ~/.config/swh/lister-debian.yml 4. Bootstrap the db instance schema $ createdb lister-debian $ python3 -m swh.lister.cli --db-url postgres:///lister-debian \ --lister debian \ --create-tables \ --with-data Note: This bootstraps a minimum data set needed for the debian lister to run (for development) ### Configuration file sample $ cat ~/.config/swh/lister-debian.yml # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls lister_db_url: postgres:///lister-debian credentials: [] cache_responses: True cache_dir: /home/user/.cache/swh/lister/debian 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. >>> import logging; logging.basicConfig(level=logging.DEBUG); from swh.lister.debian.tasks import DebianListerTask; DebianListerTask().run_task('Debian') DEBUG:root:Creating snapshot for distribution Distribution(Debian (deb) on http://deb.debian.org/debian/) on date 2018-07-27 09:22:50.461165+00:00 DEBUG:root:Processing area Area(stretch/main of Debian) DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): deb.debian.org DEBUG:urllib3.connectionpool:http://deb.debian.org:80 "GET /debian//dists/stretch/main/source/Sources.xz HTTP/1.1" 302 325 ... ## lister-pypi ### preparation steps 1. git clone under $SWH_ENVIRONMENT_HOME/swh-lister (of your choosing) 2. mkdir ~/.config/swh/ ~/.cache/swh/lister/pypi/ 3. create configuration file ~/.config/swh/lister-pypi.yml 4. Bootstrap the db instance schema $ createdb lister-pypi $ python3 -m swh.lister.cli --db-url postgres:///lister-pypi \ --lister pypi \ --create-tables \ --with-data Note: This bootstraps a minimum data set needed for the pypi lister to run (for development) ### Configuration file sample $ cat ~/.config/swh/lister-pypi.yml # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls lister_db_url: postgres:///lister-pypi credentials: [] cache_responses: True cache_dir: /home/user/.cache/swh/lister/pypi 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.pypi.tasks import PyPIListerTask; PyPIListerTask().run_task() >>> Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Development Status :: 5 - Production/Stable Description-Content-Type: text/markdown Provides-Extra: testing diff --git a/bin/batch b/bin/batch deleted file mode 100755 index 9796387..0000000 --- a/bin/batch +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash - -# Copyright (C) 2015 Stefano Zacchiroli -# License: GNU General Public License version 3, or any later version -# See top-level LICENSE file for more information - -export https_proxy="127.0.0.1:8118" # use Tor -export PYTHONPATH=`pwd` - -DBNAME=github -DBCONN="-p 5433" - -psql="psql $DBCONN --no-psqlrc --pset t --pset format=unaligned ${DBNAME}" - -BATCH_NO="$1" -shift -if [ -z "$BATCH_NO" ] ; then - echo "Usage: batch MILLION_NO [ MIN_ID | continue ]" - exit 2 -fi - -MIN_ID="$1" -shift - -min_id=$[ ($BATCH_NO - 1) * 1000000 + 1 ] -max_id=$[ $BATCH_NO * 1000000 ] - -# allow min_id override on the command line -if [ "$MIN_ID" = "continue" ] ; then - last_id=$(echo "select max(id) from repos where ${min_id} <= id and id <= ${max_id}" | $psql) - if [ "$last_id" -eq "$last_id" ] 2> /dev/null ; then # is an integer? - echo "Continuing from last known id ${last_id}" - min_id=$last_id - fi -elif [ -n "$MIN_ID" ] ; then - min_id=$[ $MIN_ID > $min_id ? $MIN_ID : $min_id ] -fi - -cmd="bin/ghlister list ${min_id}-${max_id}" -echo Running $cmd ... -$cmd diff --git a/bin/reset.sh b/bin/reset.sh deleted file mode 100644 index f5bf69b..0000000 --- a/bin/reset.sh +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (C) 2015 Stefano Zacchiroli -# License: GNU General Public License version 3, or any later version -# See top-level LICENSE file for more information - -export PYTHONPATH=`pwd` -dropdb github -createdb github -bin/ghlister createdb -rm cache/* diff --git a/bin/status b/bin/status deleted file mode 100755 index 8a3105f..0000000 --- a/bin/status +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -# Copyright (C) 2015 Stefano Zacchiroli -# License: GNU General Public License version 3, or any later version -# See top-level LICENSE file for more information - -# intended usage: watch -n 60 bin/status - -DBNAME="github" -DBCONN="-p 5433" - -psql="psql $DBCONN --no-psqlrc" - -ps auxw | grep bin/batch -echo "SELECT COUNT(*), MAX(id) FROM repos" | $psql "$DBNAME" -echo "\\l+ ${DBNAME}" | $psql "$DBNAME" -du -sh cache/ -zgrep -i --color=auto "'X-RateLimit-Remaining'" cache/$(ls -t cache/ | head -n 4 | tail -n 1) diff --git a/debian/changelog b/debian/changelog deleted file mode 100644 index 6ef1b64..0000000 --- a/debian/changelog +++ /dev/null @@ -1,5 +0,0 @@ -swh-lister (0.0.0-1) unstable; urgency=low - - * Initial release - - -- Nicolas Dandrimont Thu, 17 Mar 2016 17:52:33 +0100 diff --git a/debian/clean b/debian/clean deleted file mode 100644 index c9e1970..0000000 --- a/debian/clean +++ /dev/null @@ -1 +0,0 @@ -swh/lister/_version.py diff --git a/debian/compat b/debian/compat deleted file mode 100644 index ec63514..0000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -9 diff --git a/debian/control b/debian/control deleted file mode 100644 index 22c40a0..0000000 --- a/debian/control +++ /dev/null @@ -1,32 +0,0 @@ -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.31~), - python3-swh.storage (>= 0.0.103~), - 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.31~), - python3-swh.storage (>= 0.0.103~), - ${misc:Depends}, - ${python3:Depends} -Breaks: python3-swh.lister.github -Replaces: python3-swh.lister.github -Description: Software Heritage lister diff --git a/debian/copyright b/debian/copyright deleted file mode 100644 index bec3a30..0000000 --- a/debian/copyright +++ /dev/null @@ -1,22 +0,0 @@ -Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ - -Files: * -Copyright: 2016 The Software Heritage developers -License: GPL-3+ - -License: GPL-3+ - 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. - . - You should have received a copy of the GNU General Public License - along with this program. If not, see . - . - On Debian systems, the complete text of the GNU General Public - License version 3 can be found in `/usr/share/common-licenses/GPL-3'. diff --git a/debian/rules b/debian/rules deleted file mode 100755 index 45428fd..0000000 --- a/debian/rules +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/make -f - -export PYBUILD_NAME=swh.lister -export PYBUILD_TEST_ARGS=--with-doctest -sv -a !db,!fs - -%: - dh $@ --with python3 --buildsystem=pybuild - -override_dh_install: - dh_install - rm -v $(CURDIR)/debian/python3-*/usr/lib/python*/dist-packages/swh/__init__.py diff --git a/debian/source/format b/debian/source/format deleted file mode 100644 index 163aaf8..0000000 --- a/debian/source/format +++ /dev/null @@ -1 +0,0 @@ -3.0 (quilt) diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 58a761e..0000000 --- a/docs/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -_build/ -apidoc/ -*-stamp diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index c30c50a..0000000 --- a/docs/Makefile +++ /dev/null @@ -1 +0,0 @@ -include ../../swh-docs/Makefile.sphinx diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 190deb7..0000000 --- a/docs/conf.py +++ /dev/null @@ -1 +0,0 @@ -from swh.docs.sphinx.conf import * # NoQA diff --git a/docs/images/new_base.png b/docs/images/new_base.png deleted file mode 100644 index 2a2e3fc..0000000 Binary files a/docs/images/new_base.png and /dev/null differ diff --git a/docs/images/new_bitbucket_lister.png b/docs/images/new_bitbucket_lister.png deleted file mode 100644 index 7c491bb..0000000 Binary files a/docs/images/new_bitbucket_lister.png and /dev/null differ diff --git a/docs/images/new_github_lister.png b/docs/images/new_github_lister.png deleted file mode 100644 index e5a7fba..0000000 Binary files a/docs/images/new_github_lister.png and /dev/null differ diff --git a/docs/images/old_github_lister.png b/docs/images/old_github_lister.png deleted file mode 100644 index 65398a0..0000000 Binary files a/docs/images/old_github_lister.png and /dev/null differ diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 3b76f15..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. _swh-lister: - -Software Heritage - Listers -=========================== - -Collection of listers for source code distribution places like development -forges, FOSS distributions, package managers, etc. Each lister is in charge to -enumerate the software origins (e.g., VCS, packages, etc.) available at a -source code distribution place. - - -.. 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 deleted file mode 100644 index 88d430b..0000000 --- a/docs/tutorial.rst +++ /dev/null @@ -1,358 +0,0 @@ -.. _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/requirements-swh.txt b/requirements-swh.txt index f39d241..ac9a199 100644 --- a/requirements-swh.txt +++ b/requirements-swh.txt @@ -1,4 +1,4 @@ swh.core -swh.storage >= 0.0.103 -swh.storage[schemata] >= 0.0.76 -swh.scheduler >= 0.0.31 +swh.storage >= 0.0.122 +swh.storage[schemata] +swh.scheduler >= 0.0.39 diff --git a/requirements-test.txt b/requirements-test.txt index 3eb878a..4a7da94 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,4 @@ -nose +pytest<4 +pytest-postgresql requests_mock testing.postgresql diff --git a/requirements.txt b/requirements.txt index 4f3f588..3ad87c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ SQLAlchemy arrow python_debian requests setuptools xmltodict +iso8601 diff --git a/setup.py b/setup.py index ab5e565..2d07fcc 100755 --- a/setup.py +++ b/setup.py @@ -1,67 +1,70 @@ #!/usr/bin/env python3 # Copyright (C) 2015-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 from setuptools import setup, find_packages from os import path from io import open here = path.abspath(path.dirname(__file__)) # Get the long description from the README file with open(path.join(here, 'README.md'), encoding='utf-8') as f: long_description = f.read() def parse_requirements(name=None): if name: reqf = 'requirements-%s.txt' % name else: reqf = 'requirements.txt' requirements = [] if not path.exists(reqf): return requirements with open(reqf) as f: for line in f.readlines(): line = line.strip() if not line or line.startswith('#'): continue requirements.append(line) return requirements setup( name='swh.lister', description='Software Heritage lister', long_description=long_description, long_description_content_type='text/markdown', author='Software Heritage developers', author_email='swh-devel@inria.fr', url='https://forge.softwareheritage.org/diffusion/DLSGH/', packages=find_packages(), scripts=['bin/ghlister'], install_requires=parse_requirements() + parse_requirements('swh'), - test_requires=parse_requirements('test'), - test_suite='nose.collector', + tests_require=parse_requirements('test'), setup_requires=['vcversioner'], extras_require={'testing': parse_requirements('test')}, vcversioner={'version_module_paths': ['swh/lister/_version.py']}, include_package_data=True, + entry_points=''' + [console_scripts] + swh-lister=swh.lister.cli:cli + ''', classifiers=[ "Programming Language :: Python :: 3", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Development Status :: 5 - Production/Stable", ], project_urls={ 'Bug Reports': 'https://forge.softwareheritage.org/maniphest', 'Funding': 'https://www.softwareheritage.org/donate', 'Source': 'https://forge.softwareheritage.org/source/swh-lister', }, ) diff --git a/sql/crawler.sql b/sql/crawler.sql deleted file mode 100644 index 0a30b54..0000000 --- a/sql/crawler.sql +++ /dev/null @@ -1,106 +0,0 @@ - --- -- return a random sample of repos, containing %percent repositories --- create or replace function repos_random_sample_array(percent real) --- returns setof repos as $$ --- declare --- samples integer; --- repo repos%rowtype; --- ids integer[]; --- begin --- select floor(count(*) / 100 * percent) into samples from repos; --- ids := array(select id from repos order by id); --- for i in 1 .. samples loop --- select * into repo --- from repos --- where id = ids[round(random() * samples)]; --- return next repo; --- end loop; --- return; --- end --- $$ --- language plpgsql; - --- return a random sample of repositories -create or replace function repos_random_sample(percent real) -returns setof repos as $$ -declare - sample_size integer; -begin - select floor(count(*) / 100 * percent) into sample_size from repos; - return query - select * from repos - order by random() - limit sample_size; - return; -end -$$ -language plpgsql; - --- -- return a random sample of repositories --- create or replace function random_sample_sequence(percent real) --- returns setof repos as $$ --- declare --- sample_size integer; --- seq_size integer; --- min_id integer; --- max_id integer; --- begin --- select floor(count(*) / 100 * percent) into sample_size from repos; --- select min(id) into min_id from repos; --- select max(id) into max_id from repos; --- seq_size := sample_size * 3; -- IDs are sparse, generate a larger sequence --- -- to have enough of them --- return query --- select * from repos --- where id in --- (select floor(random() * (max_id - min_id + 1))::integer --- + min_id --- from generate_series(1, seq_size)) --- order by random() limit sample_size; --- return; --- end --- $$ --- language plpgsql; - -create or replace function repos_well_known() -returns setof repos as $$ -begin - return query - select * from repos - where full_name like 'apache/%' - or full_name like 'eclipse/%' - or full_name like 'mozilla/%' - or full_name = 'torvalds/linux' - or full_name = 'gcc-mirror/gcc'; - return; -end -$$ -language plpgsql; - -create table crawl_history ( - id bigserial primary key, - repo integer references repos(id), - task_id uuid, -- celery task id - date timestamptz not null, - duration interval, - status boolean, - result json, - stdout text, - stderr text -); - -create index on crawl_history (repo); - -create view missing_orig_repos AS - select * - from orig_repos as repos - where not exists - (select 1 from crawl_history as history - where history.repo = repos.id); - -create view missing_fork_repos AS - select * - from fork_repos as repos - where not exists - (select 1 from crawl_history as history - where history.repo = repos.id); diff --git a/sql/pimp_db.sql b/sql/pimp_db.sql deleted file mode 100644 index 2cc9cef..0000000 --- a/sql/pimp_db.sql +++ /dev/null @@ -1,36 +0,0 @@ - -create view orig_repos as - select id, name, full_name, html_url, description, last_seen - from repos - where not fork; - -create view fork_repos as - select id, name, full_name, html_url, description, last_seen - from repos - where fork - -create extension pg_trgm; - -create index ix_trgm_repos_description on - repos using gin (description gin_trgm_ops); - -create index ix_trgm_repos_full_name on - repos using gin (full_name gin_trgm_ops); - -create table repos_history ( - ts timestamp default current_timestamp, - repos integer not null, - fork_repos integer, - orig_repos integer -); - -create view repo_creations as - select today.ts :: date as date, - today.repos - yesterday.repos as repos, - today.fork_repos - yesterday.fork_repos as fork_repos, - today.orig_repos - yesterday.orig_repos as orig_repos - from repos_history today - join repos_history yesterday on - (yesterday.ts = (select max(ts) - from repos_history - where ts < today.ts)); diff --git a/swh.lister.egg-info/PKG-INFO b/swh.lister.egg-info/PKG-INFO index 27f7d11..2d0c65e 100644 --- a/swh.lister.egg-info/PKG-INFO +++ b/swh.lister.egg-info/PKG-INFO @@ -1,222 +1,222 @@ Metadata-Version: 2.1 Name: swh.lister -Version: 0.0.18 +Version: 0.0.19 Summary: Software Heritage lister Home-page: https://forge.softwareheritage.org/diffusion/DLSGH/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Project-URL: Bug Reports, https://forge.softwareheritage.org/maniphest -Project-URL: Funding, https://www.softwareheritage.org/donate Project-URL: Source, https://forge.softwareheritage.org/source/swh-lister +Project-URL: Funding, https://www.softwareheritage.org/donate Description: 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-bitbucket - swh-lister-debian - swh-lister-github - swh-lister-gitlab - swh-lister-pypi 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/user/.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/user/.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', 'per_page': 20}) >>> from swh.lister.gitlab.tasks import FullGitLabRelister; FullGitLabRelister().run_task( {'instance':'0xacab', 'api_baseurl':'https://0xacab.org/api/v4', 'sort': 'asc', 'per_page': 20}) >>> from swh.lister.gitlab.tasks import IncrementalGitLabLister; IncrementalGitLabLister().run_task( {'instance': 'freedesktop.org', 'api_baseurl': 'https://gitlab.freedesktop.org/api/v4', 'sort': 'asc', 'per_page': 20}) ## lister-debian ### preparation steps 1. git clone under $SWH_ENVIRONMENT_HOME/swh-lister (of your choosing) 2. mkdir ~/.config/swh/ ~/.cache/swh/lister/debian/ 3. create configuration file ~/.config/swh/lister-debian.yml 4. Bootstrap the db instance schema $ createdb lister-debian $ python3 -m swh.lister.cli --db-url postgres:///lister-debian \ --lister debian \ --create-tables \ --with-data Note: This bootstraps a minimum data set needed for the debian lister to run (for development) ### Configuration file sample $ cat ~/.config/swh/lister-debian.yml # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls lister_db_url: postgres:///lister-debian credentials: [] cache_responses: True cache_dir: /home/user/.cache/swh/lister/debian 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. >>> import logging; logging.basicConfig(level=logging.DEBUG); from swh.lister.debian.tasks import DebianListerTask; DebianListerTask().run_task('Debian') DEBUG:root:Creating snapshot for distribution Distribution(Debian (deb) on http://deb.debian.org/debian/) on date 2018-07-27 09:22:50.461165+00:00 DEBUG:root:Processing area Area(stretch/main of Debian) DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): deb.debian.org DEBUG:urllib3.connectionpool:http://deb.debian.org:80 "GET /debian//dists/stretch/main/source/Sources.xz HTTP/1.1" 302 325 ... ## lister-pypi ### preparation steps 1. git clone under $SWH_ENVIRONMENT_HOME/swh-lister (of your choosing) 2. mkdir ~/.config/swh/ ~/.cache/swh/lister/pypi/ 3. create configuration file ~/.config/swh/lister-pypi.yml 4. Bootstrap the db instance schema $ createdb lister-pypi $ python3 -m swh.lister.cli --db-url postgres:///lister-pypi \ --lister pypi \ --create-tables \ --with-data Note: This bootstraps a minimum data set needed for the pypi lister to run (for development) ### Configuration file sample $ cat ~/.config/swh/lister-pypi.yml # see http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls lister_db_url: postgres:///lister-pypi credentials: [] cache_responses: True cache_dir: /home/user/.cache/swh/lister/pypi 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.pypi.tasks import PyPIListerTask; PyPIListerTask().run_task() >>> Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Development Status :: 5 - Production/Stable Description-Content-Type: text/markdown Provides-Extra: testing diff --git a/swh.lister.egg-info/SOURCES.txt b/swh.lister.egg-info/SOURCES.txt index 7d02d7e..15d1b85 100644 --- a/swh.lister.egg-info/SOURCES.txt +++ b/swh.lister.egg-info/SOURCES.txt @@ -1,93 +1,88 @@ -.gitignore -ACKNOWLEDGEMENTS -LICENSE MANIFEST.in Makefile 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 -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/entry_points.txt swh.lister.egg-info/requires.txt swh.lister.egg-info/top_level.txt swh/lister/__init__.py swh/lister/_version.py swh/lister/cli.py swh/lister/utils.py swh/lister/bitbucket/__init__.py swh/lister/bitbucket/lister.py swh/lister/bitbucket/models.py swh/lister/bitbucket/tasks.py swh/lister/bitbucket/tests/__init__.py swh/lister/bitbucket/tests/api_empty_response.json swh/lister/bitbucket/tests/api_response.json +swh/lister/bitbucket/tests/conftest.py swh/lister/bitbucket/tests/test_bb_lister.py +swh/lister/bitbucket/tests/test_tasks.py swh/lister/core/__init__.py swh/lister/core/abstractattribute.py swh/lister/core/db_utils.py swh/lister/core/indexing_lister.py swh/lister/core/lister_base.py swh/lister/core/lister_transports.py swh/lister/core/models.py swh/lister/core/page_by_page_lister.py swh/lister/core/simple_lister.py -swh/lister/core/tasks.py swh/lister/core/tests/__init__.py +swh/lister/core/tests/conftest.py swh/lister/core/tests/test_abstractattribute.py swh/lister/core/tests/test_lister.py swh/lister/core/tests/test_model.py swh/lister/debian/__init__.py swh/lister/debian/lister.py swh/lister/debian/tasks.py swh/lister/debian/utils.py +swh/lister/debian/tests/__init__.py +swh/lister/debian/tests/conftest.py +swh/lister/debian/tests/test_tasks.py swh/lister/github/__init__.py swh/lister/github/lister.py swh/lister/github/models.py swh/lister/github/tasks.py swh/lister/github/tests/__init__.py swh/lister/github/tests/api_empty_response.json swh/lister/github/tests/api_response.json +swh/lister/github/tests/conftest.py swh/lister/github/tests/test_gh_lister.py +swh/lister/github/tests/test_tasks.py swh/lister/gitlab/__init__.py swh/lister/gitlab/lister.py swh/lister/gitlab/models.py swh/lister/gitlab/tasks.py swh/lister/gitlab/tests/__init__.py swh/lister/gitlab/tests/api_empty_response.json swh/lister/gitlab/tests/api_response.json +swh/lister/gitlab/tests/conftest.py swh/lister/gitlab/tests/test_gitlab_lister.py +swh/lister/gitlab/tests/test_tasks.py +swh/lister/npm/__init__.py +swh/lister/npm/lister.py +swh/lister/npm/models.py +swh/lister/npm/tasks.py +swh/lister/npm/tests/api_empty_response.json +swh/lister/npm/tests/api_inc_empty_response.json +swh/lister/npm/tests/api_inc_response.json +swh/lister/npm/tests/api_response.json swh/lister/pypi/__init__.py swh/lister/pypi/lister.py swh/lister/pypi/models.py swh/lister/pypi/tasks.py +swh/lister/pypi/tests/__init__.py +swh/lister/pypi/tests/conftest.py +swh/lister/pypi/tests/test_tasks.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/entry_points.txt b/swh.lister.egg-info/entry_points.txt new file mode 100644 index 0000000..b08f0aa --- /dev/null +++ b/swh.lister.egg-info/entry_points.txt @@ -0,0 +1,4 @@ + + [console_scripts] + swh-lister=swh.lister.cli:cli + \ No newline at end of file diff --git a/swh.lister.egg-info/requires.txt b/swh.lister.egg-info/requires.txt index 421835d..8ffcc3c 100644 --- a/swh.lister.egg-info/requires.txt +++ b/swh.lister.egg-info/requires.txt @@ -1,15 +1,17 @@ SQLAlchemy arrow python_debian requests setuptools -swh.core -swh.scheduler>=0.0.31 -swh.storage>=0.0.103 -swh.storage[schemata]>=0.0.76 xmltodict +iso8601 +swh.core +swh.storage>=0.0.122 +swh.storage[schemata] +swh.scheduler>=0.0.39 [testing] -nose +pytest<4 +pytest-postgresql requests_mock testing.postgresql diff --git a/swh/lister/_version.py b/swh/lister/_version.py index c6eccd0..8eb674b 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.18' -__sha__ = 'g8f5b10b' -__revision__ = 'g8f5b10b' +__version__ = '0.0.19' +__sha__ = 'g1756e2e' +__revision__ = 'g1756e2e' diff --git a/swh/lister/bitbucket/lister.py b/swh/lister/bitbucket/lister.py index 6885c8e..ae5866c 100644 --- a/swh/lister/bitbucket/lister.py +++ b/swh/lister/bitbucket/lister.py @@ -1,37 +1,71 @@ # 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 +import logging +import iso8601 from swh.lister.bitbucket.models import BitBucketModel from swh.lister.core.indexing_lister import SWHIndexingHttpLister +logger = logging.getLogger(__name__) + + class BitBucketLister(SWHIndexingHttpLister): PATH_TEMPLATE = '/repositories?after=%s' MODEL = BitBucketModel - LISTER_NAME = 'bitbucket.com' + LISTER_NAME = 'bitbucket' 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] + + def request_uri(self, identifier): + return super().request_uri(identifier or '1970-01-01') + + def is_within_bounds(self, inner, lower=None, upper=None): + # values are expected to be str dates + try: + inner = iso8601.parse_date(inner) + if lower: + lower = iso8601.parse_date(lower) + if upper: + upper = iso8601.parse_date(upper) + 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 + except Exception as e: + logger.error(str(e) + ': %s, %s, %s' % + (('inner=%s%s' % (type(inner), inner)), + ('lower=%s%s' % (type(lower), lower)), + ('upper=%s%s' % (type(upper), upper))) + ) + raise + + return ret diff --git a/swh/lister/bitbucket/tasks.py b/swh/lister/bitbucket/tasks.py index c54063b..98be1ac 100644 --- a/swh/lister/bitbucket/tasks.py +++ b/swh/lister/bitbucket/tasks.py @@ -1,27 +1,48 @@ # 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, - RangeListerTask, - IndexingRefreshListerTask, ListerTaskBase) +import random +from celery import group + +from swh.scheduler.celery_backend.config import app from .lister import BitBucketLister +GROUP_SPLIT = 10000 + + +def new_lister(api_baseurl='https://api.bitbucket.org/2.0'): + return BitBucketLister(api_baseurl=api_baseurl) + -class BitBucketListerTask(ListerTaskBase): - def new_lister(self, *, api_baseurl='https://api.bitbucket.org/2.0'): - return BitBucketLister(api_baseurl=api_baseurl) +@app.task(name=__name__ + '.IncrementalBitBucketLister') +def incremental_bitbucket_lister(**lister_args): + lister = new_lister(**lister_args) + lister.run(min_bound=lister.db_last_index(), max_bound=None) -class IncrementalBitBucketLister(BitBucketListerTask, - IndexingDiscoveryListerTask): - task_queue = 'swh_lister_bitbucket_discover' +@app.task(name=__name__ + '.RangeBitBucketLister') +def range_bitbucket_lister(start, end, **lister_args): + lister = new_lister(**lister_args) + lister.run(min_bound=start, max_bound=end) -class RangeBitBucketLister(BitBucketListerTask, RangeListerTask): - task_queue = 'swh_lister_bitbucket_refresh' +@app.task(name=__name__ + '.FullBitBucketRelister', bind=True) +def full_bitbucket_relister(self, split=None, **lister_args): + lister = new_lister(**lister_args) + ranges = lister.db_partition_indices(split or GROUP_SPLIT) + random.shuffle(ranges) + promise = group(range_bitbucket_lister.s(minv, maxv, **lister_args) + for minv, maxv in ranges)() + self.log.debug('%s OK (spawned %s subtasks)' % (self.name, len(ranges))) + try: + promise.save() # so that we can restore the GroupResult in tests + except NotImplementedError: + self.log.info('Unable to call save_group with current result backend.') + return promise.id -class FullBitBucketRelister(BitBucketListerTask, IndexingRefreshListerTask): - task_queue = 'swh_lister_bitbucket_refresh' +@app.task(name=__name__ + '.ping') +def ping(): + return 'OK' diff --git a/swh/lister/bitbucket/tests/conftest.py b/swh/lister/bitbucket/tests/conftest.py new file mode 100644 index 0000000..507fef9 --- /dev/null +++ b/swh/lister/bitbucket/tests/conftest.py @@ -0,0 +1 @@ +from swh.lister.core.tests.conftest import * # noqa diff --git a/swh/lister/bitbucket/tests/test_bb_lister.py b/swh/lister/bitbucket/tests/test_bb_lister.py index 7769e00..3db69dc 100644 --- a/swh/lister/bitbucket/tests/test_bb_lister.py +++ b/swh/lister/bitbucket/tests/test_bb_lister.py @@ -1,20 +1,29 @@ # 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 HttpListerTester 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 + + def test_is_within_bounds(self): + fl = self.get_fl() + self.assertTrue(fl.is_within_bounds( + '2008-07-15', self.first_index, self.last_index)) + self.assertFalse(fl.is_within_bounds( + '2008-07-20', self.first_index, self.last_index)) + self.assertFalse(fl.is_within_bounds( + '2008-07-11', self.first_index, self.last_index)) diff --git a/swh/lister/bitbucket/tests/test_tasks.py b/swh/lister/bitbucket/tests/test_tasks.py new file mode 100644 index 0000000..7674627 --- /dev/null +++ b/swh/lister/bitbucket/tests/test_tasks.py @@ -0,0 +1,89 @@ +from time import sleep +from celery.result import GroupResult + +from unittest.mock import patch + + +def test_ping(swh_app, celery_session_worker): + res = swh_app.send_task( + 'swh.lister.bitbucket.tasks.ping') + assert res + res.wait() + assert res.successful() + assert res.result == 'OK' + + +@patch('swh.lister.bitbucket.tasks.BitBucketLister') +def test_incremental(lister, swh_app, celery_session_worker): + # setup the mocked BitbucketLister + lister.return_value = lister + lister.db_last_index.return_value = 42 + lister.run.return_value = None + + res = swh_app.send_task( + 'swh.lister.bitbucket.tasks.IncrementalBitBucketLister') + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with(api_baseurl='https://api.bitbucket.org/2.0') + lister.db_last_index.assert_called_once_with() + lister.run.assert_called_once_with(min_bound=42, max_bound=None) + + +@patch('swh.lister.bitbucket.tasks.BitBucketLister') +def test_range(lister, swh_app, celery_session_worker): + # setup the mocked BitbucketLister + lister.return_value = lister + lister.run.return_value = None + + res = swh_app.send_task( + 'swh.lister.bitbucket.tasks.RangeBitBucketLister', + kwargs=dict(start=12, end=42)) + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with(api_baseurl='https://api.bitbucket.org/2.0') + lister.db_last_index.assert_not_called() + lister.run.assert_called_once_with(min_bound=12, max_bound=42) + + +@patch('swh.lister.bitbucket.tasks.BitBucketLister') +def test_relister(lister, swh_app, celery_session_worker): + # setup the mocked BitbucketLister + lister.return_value = lister + lister.run.return_value = None + lister.db_partition_indices.return_value = [ + (i, i+9) for i in range(0, 50, 10)] + + res = swh_app.send_task( + 'swh.lister.bitbucket.tasks.FullBitBucketRelister') + assert res + + res.wait() + assert res.successful() + + # retrieve the GroupResult for this task and wait for all the subtasks + # to complete + promise_id = res.result + assert promise_id + promise = GroupResult.restore(promise_id, app=swh_app) + for i in range(5): + if promise.ready(): + break + sleep(1) + + lister.assert_called_with(api_baseurl='https://api.bitbucket.org/2.0') + + # one by the FullBitbucketRelister task + # + 5 for the RangeBitbucketLister subtasks + assert lister.call_count == 6 + + lister.db_last_index.assert_not_called() + lister.db_partition_indices.assert_called_once_with(10000) + + # lister.run should have been called once per partition interval + for i in range(5): + assert (dict(min_bound=10*i, max_bound=10*i + 9),) \ + in lister.run.call_args_list diff --git a/swh/lister/cli.py b/swh/lister/cli.py index c5503ec..9a9a030 100644 --- a/swh/lister/cli.py +++ b/swh/lister/cli.py @@ -1,93 +1,122 @@ # 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 logging import click -SUPPORTED_LISTERS = ['github', 'gitlab', 'bitbucket', 'debian', 'pypi'] +logger = logging.getLogger(__name__) + +SUPPORTED_LISTERS = ['github', 'gitlab', 'bitbucket', 'debian', 'pypi', 'npm'] @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(SUPPORTED_LISTERS), - 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') -@click.option('--with-data', is_flag=True, default=False, - help='Insert minimum required data') -def cli(db_url, lister, create_tables, drop_tables, with_data): +@click.argument('listers', required=1, nargs=-1, + type=click.Choice(SUPPORTED_LISTERS + ['all'])) +@click.option('--drop-tables', '-D', is_flag=True, default=False, + help='Drop tables before creating the database schema') +def cli(db_url, listers, drop_tables): """Initialize db model according to lister. """ - override_conf = {'lister_db_url': db_url} + override_conf = { + 'lister_db_url': db_url, + 'lister': { + 'cls': 'local', + 'args': {'db': db_url} + } + } insert_minimum_data = None - - if lister == 'github': - from .github.models import IndexingModelBase as ModelBase - from .github.lister import GitHubLister - - _lister = GitHubLister(api_baseurl='https://api.github.com', - override_config=override_conf) - elif lister == 'bitbucket': - from .bitbucket.models import IndexingModelBase as ModelBase - from .bitbucket.lister import BitBucketLister - _lister = BitBucketLister(api_baseurl='https://api.bitbucket.org/2.0', - override_config=override_conf) - - elif lister == 'gitlab': - from .gitlab.models import ModelBase - from .gitlab.lister import GitLabLister - _lister = GitLabLister(api_baseurl='https://gitlab.com/api/v4/', - override_config=override_conf) - elif lister == 'debian': - from .debian.lister import DebianLister - ModelBase = DebianLister.MODEL - _lister = DebianLister() - - def insert_minimum_data(lister): - from swh.storage.schemata.distribution import Distribution, Area - d = Distribution( - name='Debian', - type='deb', - mirror_uri='http://deb.debian.org/debian/') - lister.db_session.add(d) - - areas = [] - for distribution_name in ['stretch']: - for area_name in ['main', 'contrib', 'non-free']: - areas.append(Area( - name='%s/%s' % (distribution_name, area_name), - distribution=d, - )) - lister.db_session.add_all(areas) - lister.db_session.commit() - - elif lister == 'pypi': - from .pypi.models import ModelBase - from .pypi.lister import PyPILister - _lister = PyPILister(override_config=override_conf) - - else: - raise ValueError('Only supported listers are %s' % SUPPORTED_LISTERS) - - if drop_tables: - ModelBase.metadata.drop_all(_lister.db_engine) - - if create_tables: + if 'all' in listers: + listers = SUPPORTED_LISTERS + + for lister in listers: + logger.info('Initializing lister %s', lister) + if lister == 'github': + from .github.models import IndexingModelBase as ModelBase + from .github.lister import GitHubLister + + _lister = GitHubLister( + api_baseurl='https://api.github.com', + override_config=override_conf) + elif lister == 'bitbucket': + from .bitbucket.models import IndexingModelBase as ModelBase + from .bitbucket.lister import BitBucketLister + _lister = BitBucketLister( + api_baseurl='https://api.bitbucket.org/2.0', + override_config=override_conf) + + elif lister == 'gitlab': + from .gitlab.models import ModelBase + from .gitlab.lister import GitLabLister + _lister = GitLabLister( + api_baseurl='https://gitlab.com/api/v4/', + override_config=override_conf) + elif lister == 'debian': + from .debian.lister import DebianLister + ModelBase = DebianLister.MODEL # noqa + _lister = DebianLister(override_config=override_conf) + + def insert_minimum_data(lister): + from swh.storage.schemata.distribution import ( + Distribution, Area) + d = Distribution( + name='Debian', + type='deb', + mirror_uri='http://deb.debian.org/debian/') + lister.db_session.add(d) + + areas = [] + for distribution_name in ['stretch']: + for area_name in ['main', 'contrib', 'non-free']: + areas.append(Area( + name='%s/%s' % (distribution_name, area_name), + distribution=d, + )) + lister.db_session.add_all(areas) + lister.db_session.commit() + + elif lister == 'pypi': + from .pypi.models import ModelBase + from .pypi.lister import PyPILister + _lister = PyPILister(override_config=override_conf) + + elif lister == 'npm': + from .npm.models import IndexingModelBase as ModelBase + from .npm.models import NpmVisitModel + from .npm.lister import NpmLister + _lister = NpmLister(override_config=override_conf) + if drop_tables: + NpmVisitModel.metadata.drop_all(_lister.db_engine) + NpmVisitModel.metadata.create_all(_lister.db_engine) + + else: + raise ValueError( + 'Invalid lister %s: only supported listers are %s' % + (lister, SUPPORTED_LISTERS)) + + if drop_tables: + logger.info('Dropping tables for %s', lister) + ModelBase.metadata.drop_all(_lister.db_engine) + + logger.info('Creating tables for %s', lister) ModelBase.metadata.create_all(_lister.db_engine) - if with_data and insert_minimum_data: - insert_minimum_data(_lister) + if insert_minimum_data: + logger.info('Inserting minimal data for %s', lister) + try: + insert_minimum_data(_lister) + except Exception: + logger.warning( + 'Failed to insert minumum data in %s', lister) if __name__ == '__main__': cli() diff --git a/swh/lister/core/db_utils.py b/swh/lister/core/db_utils.py index 0563036..bfdabb7 100644 --- a/swh/lister/core/db_utils.py +++ b/swh/lister/core/db_utils.py @@ -1,18 +1,18 @@ # Copyright (C) 2015 Stefano Zacchiroli # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from contextlib import contextmanager @contextmanager def session_scope(mk_session): session = mk_session() try: yield session session.commit() - except: + except: # noqa session.rollback() raise finally: session.close() diff --git a/swh/lister/core/indexing_lister.py b/swh/lister/core/indexing_lister.py index 3388077..3d17158 100644 --- a/swh/lister/core/indexing_lister.py +++ b/swh/lister/core/indexing_lister.py @@ -1,213 +1,212 @@ # 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 itertools import count from sqlalchemy import func from .lister_transports import SWHListerHttpTransport from .lister_base import SWHListerBase +logger = logging.getLogger(__name__) + 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() + n = max(self.db_num_entries(), 10) 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: + index = index[0] 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_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_bound. Args: min_bound (indexable type): optional index to start from max_bound (indexable type): optional index to stop at Returns: nothing """ - index = min_bound or '' - loop_count = 0 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) - if not response and not injected_repos: - logging.info('No response from api server, stopping') - break - - 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: + def ingest_indexes(): + index = min_bound or '' + for i in count(1): + response, injected_repos = self.ingest_data(index) + if not response and not injected_repos: + logger.info('No response from api server, stopping') + return + + next_index = self.get_next_target_from_response(response) + # Determine if any repos were deleted, and disable their tasks. + keep_these = list(injected_repos.keys()) + self.disable_deleted_repo_tasks(index, next_index, keep_these) + + # termination condition + if next_index is None or next_index == index: + logger.info('stopping after index %s, no next link found' % + index) + return index = next_index + yield i - loop_count += 1 - if loop_count == 20: - logging.info('flushing updates') - loop_count = 0 + for i in ingest_indexes(): + if (i % 20) == 0: + logger.info('flushing updates') 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, api_baseurl=None, override_config=None): SWHListerHttpTransport.__init__(self, api_baseurl=api_baseurl) SWHIndexingLister.__init__(self, override_config=override_config) diff --git a/swh/lister/core/lister_base.py b/swh/lister/core/lister_base.py index 8bda609..b09fe13 100644 --- a/swh/lister/core/lister_base.py +++ b/swh/lister/core/lister_base.py @@ -1,539 +1,551 @@ # Copyright (C) 2015-2018 the Software Heritage developers # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import abc import datetime import gzip import json import logging import os import re import time from sqlalchemy import create_engine, func from sqlalchemy.orm import sessionmaker from swh.core import config from swh.scheduler import get_scheduler, utils from swh.storage import get_storage from .abstractattribute import AbstractAttribute +logger = logging.getLogger(__name__) + + def utcnow(): return datetime.datetime.now(tz=datetime.timezone.utc) class FetchError(RuntimeError): def __init__(self, response): self.response = response def __str__(self): return repr(self.response) class 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 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. 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)), + logger.error(str(e) + ': %s, %s, %s' % + (('inner=%s%s' % (type(inner), inner)), ('lower=%s%s' % (type(lower), lower)), ('upper=%s%s' % (type(upper), upper))) - ) + ) raise return ret # You probably don't need to override anything below this line. DEFAULT_CONFIG = { 'storage': ('dict', { 'cls': 'remote', 'args': { 'url': 'http://localhost:5002/' }, }), 'scheduler': ('dict', { 'cls': 'remote', 'args': { 'url': 'http://localhost:5008/' }, - }) + }), + 'lister': ('dict', { + 'cls': 'local', + 'args': { + 'db': 'postgresql:///lister', + }, + }), } @property def CONFIG_BASE_FILENAME(self): # noqa: N802 return 'lister-%s' % self.LISTER_NAME @property def ADDITIONAL_CONFIG(self): # noqa: N802 return { - 'lister_db_url': - ('str', 'postgresql:///lister-%s' % self.LISTER_NAME), 'credentials': ('list[dict]', []), 'cache_responses': ('bool', False), 'cache_dir': ('str', '~/.cache/swh/lister/%s' % self.LISTER_NAME), } INITIAL_BACKOFF = 10 MAX_RETRIES = 7 CONN_SLEEP = 10 def __init__(self, override_config=None): self.backoff = self.INITIAL_BACKOFF + logger.debug('Loading config from %s' % self.CONFIG_BASE_FILENAME) self.config = self.parse_config_file( base_filename=self.CONFIG_BASE_FILENAME, additional_configs=[self.ADDITIONAL_CONFIG] ) self.config['cache_dir'] = os.path.expanduser(self.config['cache_dir']) if self.config['cache_responses']: config.prepare_folders(self.config, 'cache_dir') if override_config: self.config.update(override_config) + logger.debug('%s CONFIG=%s' % (self, self.config)) self.storage = get_storage(**self.config['storage']) self.scheduler = get_scheduler(**self.config['scheduler']) - self.db_engine = create_engine(self.config['lister_db_url']) + self.db_engine = create_engine(self.config['lister']['args']['db']) self.mk_session = sessionmaker(bind=self.db_engine) self.db_session = self.mk_session() def reset_backoff(self): """Reset exponential backoff timeout to initial level.""" self.backoff = self.INITIAL_BACKOFF def back_off(self): """Get next exponential backoff timeout.""" ret = self.backoff self.backoff *= 10 return ret def safely_issue_request(self, identifier): """Make network request with retries, rate quotas, and response logs. Protocol is handled by the implementation of the transport_request method. Args: identifier: resource identifier Returns: server response """ retries_left = self.MAX_RETRIES do_cache = self.config['cache_responses'] r = None while retries_left > 0: try: r = self.transport_request(identifier) except FetchError: # network-level connection error, try again - logging.warn('connection error on %s: sleep for %d seconds' % - (identifier, self.CONN_SLEEP)) + logger.warning( + 'connection error on %s: sleep for %d seconds' % + (identifier, self.CONN_SLEEP)) time.sleep(self.CONN_SLEEP) retries_left -= 1 continue if do_cache: self.save_response(r) # detect throttling must_retry, delay = self.transport_quota_check(r) if must_retry: - logging.warn('rate limited on %s: sleep for %f seconds' % - (identifier, delay)) + logger.warning( + 'rate limited on %s: sleep for %f seconds' % + (identifier, delay)) time.sleep(delay) else: # request ok break retries_left -= 1 if not retries_left: - logging.warn('giving up on %s: max retries exceeded' % identifier) + logger.warning( + 'giving up on %s: max retries exceeded' % identifier) return r def db_query_equal(self, key, value): """Look in the db for a row with key == value Args: key: column key to look at value: value to look for in that column Returns: sqlalchemy.ext.declarative.declarative_base object with the given key == value """ if isinstance(key, str): key = self.MODEL.__dict__[key] return self.db_session.query(self.MODEL) \ .filter(key == value).first() def winnow_models(self, mlist, key, to_remove): """Given a list of models, remove any with matching some member of a list of values. Args: mlist (list of model rows): the initial list of models key (column): the column to filter on to_remove (list): if anything in mlist has column equal to one of the values in to_remove, it will be removed from the result Returns: A list of model rows starting from mlist minus any matching rows """ if isinstance(key, str): key = self.MODEL.__dict__[key] if to_remove: return mlist.filter(~key.in_(to_remove)).all() else: return mlist.all() def db_num_entries(self): """Return the known number of entries in the lister db""" return self.db_session.query(func.count('*')).select_from(self.MODEL) \ .scalar() def db_inject_repo(self, model_dict): """Add/update a new repo to the db and mark it last_seen now. Args: model_dict: dictionary mapping model keys to values Returns: new or updated sqlalchemy.ext.declarative.declarative_base object associated with the injection """ sql_repo = self.db_query_equal('uid', model_dict['uid']) if not sql_repo: sql_repo = self.MODEL(**model_dict) self.db_session.add(sql_repo) else: for k in model_dict: setattr(sql_repo, k, model_dict[k]) sql_repo.last_seen = utcnow() return sql_repo def origin_dict(self, origin_type, origin_url, **kwargs): """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, **kwargs): """Return special dict format for the tasks list Args: origin_type (string) origin_url (string) Returns: the same information in a different form """ _type = 'origin-update-%s' % origin_type _policy = 'recurring' return utils.create_task_dict(_type, _policy, origin_url) 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) + logger.debug(a_pattern) raise TypeError('incomparable string patterns detected') def inject_repo_data_into_db(self, models_list): """Inject data into the db. Args: models_list: list of dicts mapping keys from the db model for each repo to be injected Returns: dict of uid:sql_repo pairs """ injected_repos = {} for m in models_list: injected_repos[m['uid']] = self.db_inject_repo(m) return injected_repos def 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. """ origins = {} tasks = {} def _origin_key(m): _type = m.get('origin_type', m.get('type')) _url = m.get('origin_url', m.get('url')) return '%s-%s' % (_type, _url) def _task_key(m): return '%s-%s' % (m['type'], json.dumps(m['arguments'])) for m in models_list: ir = injected_repos[m['uid']] if not ir.origin_id: origin_dict = self.origin_dict(**m) origins[_origin_key(m)] = (ir, m, origin_dict) if not ir.task_id: task_dict = self.task_dict(**m) tasks[_task_key(task_dict)] = (ir, m, task_dict) new_origins = self.storage.origin_add( (origin_dicts for (_, _, origin_dicts) in origins.values())) for origin in new_origins: ir, m, _ = origins[_origin_key(origin)] ir.origin_id = origin['id'] new_tasks = self.scheduler.create_tasks( (task_dicts for (_, _, task_dicts) in tasks.values())) for task in new_tasks: ir, m, _ = tasks[_task_key(task)] ir.task_id = task['id'] def ingest_data(self, identifier, checks=False): """The core data fetch sequence. Request server endpoint. Simplify and filter response list of repositories. Inject repo information into local db. Queue loader tasks for linked repositories. Args: identifier: Resource identifier. checks (bool): Additional checks required """ # Request (partial?) list of repositories info response = self.safely_issue_request(identifier) if not response: return response, [] models_list = self.transport_response_simplified(response) models_list = self.filter_before_inject(models_list) if checks: models_list = self.do_additional_checks(models_list) if not models_list: return response, [] # inject into local db injected = self.inject_repo_data_into_db(models_list) # queue workers self.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 ef59b6f..20f841f 100644 --- a/swh/lister/core/lister_transports.py +++ b/swh/lister/core/lister_transports.py @@ -1,232 +1,180 @@ # 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 datetime import datetime from email.utils import parsedate from pprint import pformat -from xmlrpc import client +import logging 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 ListerXMLRPCTransport(abc.ABC): - """Use the xmlrpc library for making Lister endpoint requests. - - To be used in conjunction with SWHListerBase or a subclass of it. - """ - SERVER = AbstractAttribute('string containing the server to contact for ' - 'information') - - def __init__(self): - self.lister_version = __version__ - - def get_client(self, path): - """Initialize client to query for result - - """ - return client.ServerProxy(path) - - def request_uri(self, _): - """Same uri called once - - """ - return self.SERVER - - def request_params(self, identifier): - """Cannot pass any parameters to query to the xmlrpc client so cannot - even pass our user-agent specifics. - - """ - return {} - - def transport_quota_check(self, response): - """No rate limit dealing explained. - - """ - return False, 0 - - def transport_request(self, identifier): - """Implements SWHListerBase.transport_request - - """ - path = self.request_uri(identifier) - try: - return self.get_client(path) - except Exception as e: - raise FetchError(e) - - def transport_response_to_string(self, response): - """Implements SWHListerBase.transport_response_to_string for XMLRPC - given responses. - - """ - s = pformat(self.SERVER) - s += '\n#\n' + pformat(response) # Note: will potentially be big - return s +logger = logging.getLogger(__name__) 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. MAY BE OVERRIDDEN if something more complex than the request headers 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. 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 Exception: # 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_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: if method == 'head': response = self.session.head(path, **params) else: response = self.session.get(path, **params) except requests.exceptions.ConnectionError as e: + logger.warning('Failed to fetch %s: %s', path, 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 Exception: # not json try: # xml? s += pformat(xmltodict.parse(response.text)) except Exception: # not xml s += pformat(response.text) return s class ListerOnePageApiTransport(SWHListerHttpTransport): """Leverage requests library to retrieve basic html page and parse result. To be used in conjunction with SWHListerBase or a subclass of it. """ PAGE = AbstractAttribute("The server api's unique page to retrieve and " "parse for information") PATH_TEMPLATE = None # we do not use it def __init__(self, api_baseurl=None): self.session = requests.Session() self.lister_version = __version__ def request_uri(self, _): """Get the full request URI given the transport_request identifier. """ return self.PAGE diff --git a/swh/lister/core/models.py b/swh/lister/core/models.py index 589918d..15a1441 100644 --- a/swh/lister/core/models.py +++ b/swh/lister/core/models.py @@ -1,82 +1,50 @@ # 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)') 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, 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 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 + def __init__(self, **kw): + kw['last_seen'] = datetime.now() + super().__init__(**kw) 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/tasks.py b/swh/lister/core/tasks.py deleted file mode 100644 index c3deb8b..0000000 --- a/swh/lister/core/tasks.py +++ /dev/null @@ -1,95 +0,0 @@ -# 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 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, **lister_args): - """Return a new lister of the appropriate type. - """ - pass - - @abc.abstractmethod - def run_task(self, *, lister_args=None): - pass - - -# 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 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_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 = RangeListerTask() - group(range_task.s(minv, maxv, lister_args) - for minv, maxv in ranges)() diff --git a/swh/lister/core/tests/conftest.py b/swh/lister/core/tests/conftest.py new file mode 100644 index 0000000..bdbfa02 --- /dev/null +++ b/swh/lister/core/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest +from swh.scheduler.tests.conftest import * # noqa + + +@pytest.fixture(scope='session') +def celery_includes(): + return [ + 'swh.lister.bitbucket.tasks', + 'swh.lister.debian.tasks', + 'swh.lister.github.tasks', + 'swh.lister.gitlab.tasks', + 'swh.lister.npm.tasks', + 'swh.lister.pypi.tasks', + ] diff --git a/swh/lister/core/tests/test_abstractattribute.py b/swh/lister/core/tests/test_abstractattribute.py index 0b81d33..bfadca6 100644 --- a/swh/lister/core/tests/test_abstractattribute.py +++ b/swh/lister/core/tests/test_abstractattribute.py @@ -1,68 +1,64 @@ # 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 unittest -from nose.tools import istest - from swh.lister.core.abstractattribute import AbstractAttribute class BaseClass(abc.ABC): v1 = AbstractAttribute v2 = AbstractAttribute() v3 = AbstractAttribute('changed docstring') v4 = 'qux' class BadSubclass1(BaseClass): pass class BadSubclass2(BaseClass): v1 = 'foo' v2 = 'bar' class BadSubclass3(BaseClass): v2 = 'bar' v3 = 'baz' class GoodSubclass(BaseClass): v1 = 'foo' v2 = 'bar' v3 = 'baz' class TestAbstractAttributes(unittest.TestCase): - @istest def test_aa(self): with self.assertRaises(TypeError): BaseClass() with self.assertRaises(TypeError): BadSubclass1() with self.assertRaises(TypeError): BadSubclass2() with self.assertRaises(TypeError): BadSubclass3() self.assertIsInstance(GoodSubclass(), GoodSubclass) gsc = GoodSubclass() self.assertEqual(gsc.v1, 'foo') self.assertEqual(gsc.v2, 'bar') self.assertEqual(gsc.v3, 'baz') self.assertEqual(gsc.v4, 'qux') - @istest def test_aa_docstrings(self): self.assertEqual(BaseClass.v1.__doc__, AbstractAttribute.__doc__) self.assertEqual(BaseClass.v2.__doc__, AbstractAttribute.__doc__) self.assertEqual(BaseClass.v3.__doc__, 'AbstractAttribute: changed docstring') diff --git a/swh/lister/core/tests/test_lister.py b/swh/lister/core/tests/test_lister.py index b2e28cd..29dcd2a 100644 --- a/swh/lister/core/tests/test_lister.py +++ b/swh/lister/core/tests/test_lister.py @@ -1,238 +1,234 @@ # 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 testing.postgresql import Postgresql from swh.lister.core.abstractattribute import AbstractAttribute def noop(*args, **kwargs): pass @requests_mock.Mocker() class HttpListerTesterBase(abc.ABC): """Base testing class for subclasses of 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') 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__ != 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: 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', '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_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_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_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()}) + fl = self.get_fl(override_config={ + 'lister': { + 'cls': 'local', + 'args': {'db': db.url()} + } + }) self.init_db(db, fl.MODEL) self.disable_storage_and_scheduler(fl) fl.run(min_bound=self.first_index) self.assertEqual(fl.db_last_index(), self.last_index) partitions = fl.db_partition_indices(5) self.assertGreater(len(partitions), 0) for k in partitions: self.assertLessEqual(len(k), 5) self.assertGreater(len(k), 0) diff --git a/swh/lister/core/tests/test_model.py b/swh/lister/core/tests/test_model.py index b2c25e1..9f07223 100644 --- a/swh/lister/core/tests/test_model.py +++ b/swh/lister/core/tests/test_model.py @@ -1,94 +1,91 @@ # 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, IndexingModelBase +from swh.lister.core.models import IndexingModelBase, ModelBase 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') 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/tasks.py b/swh/lister/debian/tasks.py index 0ddb653..9f5af90 100644 --- a/swh/lister/debian/tasks.py +++ b/swh/lister/debian/tasks.py @@ -1,18 +1,17 @@ # 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 swh.scheduler.celery_backend.config import app from .lister import DebianLister -class DebianListerTask(ListerTaskBase): - task_queue = 'swh_lister_debian' +@app.task(name=__name__ + '.DebianListerTask') +def debian_lister(distribution, **lister_args): + DebianLister(**lister_args).run(distribution) - def new_lister(self): - return DebianLister() - def run_task(self, distribution): - lister = self.new_lister() - return lister.run(distribution) +@app.task(name=__name__ + '.ping') +def ping(): + return 'OK' diff --git a/docs/_static/.placeholder b/swh/lister/debian/tests/__init__.py similarity index 100% copy from docs/_static/.placeholder copy to swh/lister/debian/tests/__init__.py diff --git a/swh/lister/debian/tests/conftest.py b/swh/lister/debian/tests/conftest.py new file mode 100644 index 0000000..507fef9 --- /dev/null +++ b/swh/lister/debian/tests/conftest.py @@ -0,0 +1 @@ +from swh.lister.core.tests.conftest import * # noqa diff --git a/swh/lister/debian/tests/test_tasks.py b/swh/lister/debian/tests/test_tasks.py new file mode 100644 index 0000000..a5a5d0e --- /dev/null +++ b/swh/lister/debian/tests/test_tasks.py @@ -0,0 +1,26 @@ +from unittest.mock import patch + + +def test_ping(swh_app, celery_session_worker): + res = swh_app.send_task( + 'swh.lister.debian.tasks.ping') + assert res + res.wait() + assert res.successful() + assert res.result == 'OK' + + +@patch('swh.lister.debian.tasks.DebianLister') +def test_lister(lister, swh_app, celery_session_worker): + # setup the mocked DebianLister + lister.return_value = lister + lister.run.return_value = None + + res = swh_app.send_task( + 'swh.lister.debian.tasks.DebianListerTask', ('stretch',)) + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with() + lister.run.assert_called_once_with('stretch') diff --git a/swh/lister/github/lister.py b/swh/lister/github/lister.py index f841f60..c0e49c4 100644 --- a/swh/lister/github/lister.py +++ b/swh/lister/github/lister.py @@ -1,52 +1,52 @@ # 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' + LISTER_NAME = 'github' 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'], } 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 2cb429f..6584b34 100644 --- a/swh/lister/github/models.py +++ b/swh/lister/github/models.py @@ -1,20 +1,16 @@ # 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 IndexingModelBase 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) + fork = Column(Boolean, default=False) diff --git a/swh/lister/github/tasks.py b/swh/lister/github/tasks.py index c2e841e..97dfad5 100644 --- a/swh/lister/github/tasks.py +++ b/swh/lister/github/tasks.py @@ -1,26 +1,49 @@ # 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, - RangeListerTask, - IndexingRefreshListerTask, ListerTaskBase) +import random -from .lister import GitHubLister +from celery import group +from swh.scheduler.celery_backend.config import app +from swh.lister.github.lister import GitHubLister -class GitHubListerTask(ListerTaskBase): - def new_lister(self, *, api_baseurl='https://api.github.com'): - return GitHubLister(api_baseurl=api_baseurl) +GROUP_SPLIT = 10000 -class IncrementalGitHubLister(GitHubListerTask, IndexingDiscoveryListerTask): - task_queue = 'swh_lister_github_discover' +def new_lister(api_baseurl='https://api.github.com', **kw): + return GitHubLister(api_baseurl=api_baseurl, **kw) -class RangeGitHubLister(GitHubListerTask, RangeListerTask): - task_queue = 'swh_lister_github_refresh' +@app.task(name=__name__ + '.IncrementalGitHubLister') +def incremental_github_lister(**lister_args): + lister = new_lister(**lister_args) + lister.run(min_bound=lister.db_last_index(), max_bound=None) -class FullGitHubRelister(GitHubListerTask, IndexingRefreshListerTask): - task_queue = 'swh_lister_github_refresh' +@app.task(name=__name__ + '.RangeGitHubLister') +def range_github_lister(start, end, **lister_args): + lister = new_lister(**lister_args) + lister.run(min_bound=start, max_bound=end) + + +@app.task(name=__name__ + '.FullGitHubRelister', bind=True) +def full_github_relister(self, split=None, **lister_args): + lister = new_lister(**lister_args) + ranges = lister.db_partition_indices(split or GROUP_SPLIT) + random.shuffle(ranges) + promise = group(range_github_lister.s(minv, maxv, **lister_args) + for minv, maxv in ranges)() + self.log.debug('%s OK (spawned %s subtasks)' % (self.name, len(ranges))) + try: + promise.save() # so that we can restore the GroupResult in tests + except NotImplementedError: + self.log.info('Unable to call save_group with current result backend.') + raise + return promise.id + + +@app.task(name=__name__ + '.ping') +def ping(): + return 'OK' diff --git a/swh/lister/github/tests/conftest.py b/swh/lister/github/tests/conftest.py new file mode 100644 index 0000000..507fef9 --- /dev/null +++ b/swh/lister/github/tests/conftest.py @@ -0,0 +1 @@ +from swh.lister.core.tests.conftest import * # noqa diff --git a/swh/lister/github/tests/test_tasks.py b/swh/lister/github/tests/test_tasks.py new file mode 100644 index 0000000..9bd30c1 --- /dev/null +++ b/swh/lister/github/tests/test_tasks.py @@ -0,0 +1,90 @@ +from time import sleep +from celery.result import GroupResult + +from unittest.mock import patch + + +def test_ping(swh_app, celery_session_worker): + res = swh_app.send_task( + 'swh.lister.github.tasks.ping') + assert res + res.wait() + assert res.successful() + assert res.result == 'OK' + + +@patch('swh.lister.github.tasks.GitHubLister') +def test_incremental(lister, swh_app, celery_session_worker): + # setup the mocked GitHubLister + lister.return_value = lister + lister.db_last_index.return_value = 42 + lister.run.return_value = None + + res = swh_app.send_task( + 'swh.lister.github.tasks.IncrementalGitHubLister') + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with(api_baseurl='https://api.github.com') + lister.db_last_index.assert_called_once_with() + lister.run.assert_called_once_with(min_bound=42, max_bound=None) + + +@patch('swh.lister.github.tasks.GitHubLister') +def test_range(lister, swh_app, celery_session_worker): + # setup the mocked GitHubLister + lister.return_value = lister + lister.run.return_value = None + + res = swh_app.send_task( + 'swh.lister.github.tasks.RangeGitHubLister', + kwargs=dict(start=12, end=42)) + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with(api_baseurl='https://api.github.com') + lister.db_last_index.assert_not_called() + lister.run.assert_called_once_with(min_bound=12, max_bound=42) + + +@patch('swh.lister.github.tasks.GitHubLister') +def test_relister(lister, swh_app, celery_session_worker): + # setup the mocked GitHubLister + lister.return_value = lister + lister.run.return_value = None + lister.db_partition_indices.return_value = [ + (i, i+9) for i in range(0, 50, 10)] + + res = swh_app.send_task( + 'swh.lister.github.tasks.FullGitHubRelister') + assert res + + res.wait() + assert res.successful() + + # retrieve the GroupResult for this task and wait for all the subtasks + # to complete + promise_id = res.result + assert promise_id + promise = GroupResult.restore(promise_id, app=swh_app) + for i in range(5): + if promise.ready(): + break + sleep(1) + + lister.assert_called_with(api_baseurl='https://api.github.com') + + # one by the FullGitHubRelister task + # + 5 for the RangeGitHubLister subtasks + assert lister.call_count == 6 + + lister.db_last_index.assert_not_called() + lister.db_partition_indices.assert_called_once_with(10000) + + # lister.run should have been called once per partition interval + for i in range(5): + # XXX inconsistent behavior: max_bound is INCLUDED here + assert (dict(min_bound=10*i, max_bound=10*i + 9),) \ + in lister.run.call_args_list diff --git a/swh/lister/gitlab/lister.py b/swh/lister/gitlab/lister.py index 97b8024..8e09e23 100644 --- a/swh/lister/gitlab/lister.py +++ b/swh/lister/gitlab/lister.py @@ -1,123 +1,126 @@ # 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 urllib3.util import parse_url 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, + def __init__(self, api_baseurl, instance=None, override_config=None, sort='asc', per_page=20): super().__init__(api_baseurl=api_baseurl, override_config=override_config) + if instance is None: + instance = parse_url(api_baseurl).host self.instance = instance self.PATH_TEMPLATE = '%s&sort=%s' % (self.PATH_TEMPLATE, sort) if per_page != 20: self.PATH_TEMPLATE = '%s&per_page=%s' % ( self.PATH_TEMPLATE, per_page) @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: - ... + per instance. For example:: + + - credentials: + - gitlab.com: + - username: user0 + password: + - username: user1 + password: + - ... + - other-gitlab-instance: + ... """ params = { 'headers': self.request_headers() or {} } creds_lister = self.config['credentials'].get(self.instance) if creds_lister: auth = random.choice(creds_lister) 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) if not response.ok: raise ValueError( 'Problem during information fetch: %s' % response.status_code) 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 index 2b045a3..1302e67 100644 --- a/swh/lister/gitlab/models.py +++ b/swh/lister/gitlab/models.py @@ -1,28 +1,17 @@ # 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, 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 index 9adcf12..9e57081 100644 --- a/swh/lister/gitlab/tasks.py +++ b/swh/lister/gitlab/tasks.py @@ -1,63 +1,57 @@ # 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 swh.scheduler.celery_backend.config import app 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', sort='asc', per_page=20): - return GitLabLister( - api_baseurl=api_baseurl, instance=instance, sort=sort) +NBPAGES = 10 -class RangeGitLabLister(GitLabListerTask, RangeListerTask): - """Range GitLab lister (list available origins on specified range) +def new_lister(api_baseurl='https://gitlab.com/api/v4', + instance=None, sort='asc', per_page=20): + return GitLabLister( + api_baseurl=api_baseurl, instance=instance, sort=sort, + per_page=per_page) - """ - task_queue = 'swh_lister_gitlab_refresh' +@app.task(name=__name__ + '.IncrementalGitLabLister') +def incremental_gitlab_lister(**lister_args): + lister_args['sort'] = 'desc' + lister = new_lister(**lister_args) + total_pages = lister.get_pages_information()[1] + # stopping as soon as existing origins for that instance are detected + lister.run(min_bound=1, max_bound=total_pages, check_existence=True) -class FullGitLabRelister(GitLabListerTask): - """Full GitLab lister (list all available origins from the api). - """ - task_queue = 'swh_lister_gitlab_refresh' +@app.task(name=__name__ + '.RangeGitLabLister') +def range_gitlab_lister(start, end, **lister_args): + lister = new_lister(**lister_args) + lister.run(min_bound=start, max_bound=end) - # 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)() +@app.task(name=__name__ + '.FullGitLabRelister', bind=True) +def full_gitlab_relister(self, **lister_args): + lister = new_lister(**lister_args) + _, total_pages, _ = lister.get_pages_information() + ranges = list(utils.split_range(total_pages, NBPAGES)) + random.shuffle(ranges) + promise = group(range_gitlab_lister.s(minv, maxv, **lister_args) + for minv, maxv in ranges)() + self.log.debug('%s OK (spawned %s subtasks)' % (self.name, len(ranges))) + try: + promise.save() + except NotImplementedError: + self.log.info('Unable to call save_group with current result backend.') + return promise.id -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) +@app.task(name=__name__ + '.ping') +def ping(): + return 'OK' diff --git a/swh/lister/gitlab/tests/conftest.py b/swh/lister/gitlab/tests/conftest.py new file mode 100644 index 0000000..507fef9 --- /dev/null +++ b/swh/lister/gitlab/tests/conftest.py @@ -0,0 +1 @@ +from swh.lister.core.tests.conftest import * # noqa diff --git a/swh/lister/gitlab/tests/test_gitlab_lister.py b/swh/lister/gitlab/tests/test_gitlab_lister.py index 9d17330..cbe0f4d 100644 --- a/swh/lister/gitlab/tests/test_gitlab_lister.py +++ b/swh/lister/gitlab/tests/test_gitlab_lister.py @@ -1,38 +1,37 @@ # 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 +from swh.lister.gitlab.lister import GitLabLister 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/gitlab/tests/test_tasks.py b/swh/lister/gitlab/tests/test_tasks.py new file mode 100644 index 0000000..f8d0a81 --- /dev/null +++ b/swh/lister/gitlab/tests/test_tasks.py @@ -0,0 +1,150 @@ +from time import sleep +from celery.result import GroupResult + +from unittest.mock import patch + + +def test_ping(swh_app, celery_session_worker): + res = swh_app.send_task( + 'swh.lister.gitlab.tasks.ping') + assert res + res.wait() + assert res.successful() + assert res.result == 'OK' + + +@patch('swh.lister.gitlab.tasks.GitLabLister') +def test_incremental(lister, swh_app, celery_session_worker): + # setup the mocked GitlabLister + lister.return_value = lister + lister.run.return_value = None + lister.get_pages_information.return_value = (None, 10, None) + + res = swh_app.send_task( + 'swh.lister.gitlab.tasks.IncrementalGitLabLister') + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with( + api_baseurl='https://gitlab.com/api/v4', + instance=None, sort='desc', per_page=20) + lister.db_last_index.assert_not_called() + lister.get_pages_information.assert_called_once_with() + lister.run.assert_called_once_with( + min_bound=1, max_bound=10, check_existence=True) + + +@patch('swh.lister.gitlab.tasks.GitLabLister') +def test_range(lister, swh_app, celery_session_worker): + # setup the mocked GitlabLister + lister.return_value = lister + lister.run.return_value = None + + res = swh_app.send_task( + 'swh.lister.gitlab.tasks.RangeGitLabLister', + kwargs=dict(start=12, end=42)) + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with( + api_baseurl='https://gitlab.com/api/v4', + instance=None, sort='asc', per_page=20) + lister.db_last_index.assert_not_called() + lister.run.assert_called_once_with(min_bound=12, max_bound=42) + + +@patch('swh.lister.gitlab.tasks.GitLabLister') +def test_relister(lister, swh_app, celery_session_worker): + # setup the mocked GitlabLister + lister.return_value = lister + lister.run.return_value = None + lister.get_pages_information.return_value = (None, 85, None) + lister.db_partition_indices.return_value = [ + (i, i+9) for i in range(0, 80, 10)] + [(80, 85)] + + res = swh_app.send_task( + 'swh.lister.gitlab.tasks.FullGitLabRelister') + assert res + + res.wait() + assert res.successful() + + # retrieve the GroupResult for this task and wait for all the subtasks + # to complete + promise_id = res.result + assert promise_id + promise = GroupResult.restore(promise_id, app=swh_app) + for i in range(5): + if promise.ready(): + break + sleep(1) + + lister.assert_called_with( + api_baseurl='https://gitlab.com/api/v4', + instance=None, sort='asc', per_page=20) + + # one by the FullGitlabRelister task + # + 9 for the RangeGitlabLister subtasks + assert lister.call_count == 10 + + lister.db_last_index.assert_not_called() + lister.db_partition_indices.assert_not_called() + lister.get_pages_information.assert_called_once_with() + + # lister.run should have been called once per partition interval + for i in range(8): + # XXX inconsistent behavior: max_bound is EXCLUDED here + assert (dict(min_bound=10*i, max_bound=10*i + 10),) \ + in lister.run.call_args_list + assert (dict(min_bound=80, max_bound=85),) \ + in lister.run.call_args_list + + +@patch('swh.lister.gitlab.tasks.GitLabLister') +def test_relister_instance(lister, swh_app, celery_session_worker): + # setup the mocked GitlabLister + lister.return_value = lister + lister.run.return_value = None + lister.get_pages_information.return_value = (None, 85, None) + lister.db_partition_indices.return_value = [ + (i, i+9) for i in range(0, 80, 10)] + [(80, 85)] + + res = swh_app.send_task( + 'swh.lister.gitlab.tasks.FullGitLabRelister', + kwargs=dict(api_baseurl='https://0xacab.org/api/v4')) + assert res + + res.wait() + assert res.successful() + + # retrieve the GroupResult for this task and wait for all the subtasks + # to complete + promise_id = res.result + assert promise_id + promise = GroupResult.restore(promise_id, app=swh_app) + for i in range(5): + if promise.ready(): + break + sleep(1) + + lister.assert_called_with( + api_baseurl='https://0xacab.org/api/v4', + instance=None, sort='asc', per_page=20) + + # one by the FullGitlabRelister task + # + 9 for the RangeGitlabLister subtasks + assert lister.call_count == 10 + + lister.db_last_index.assert_not_called() + lister.db_partition_indices.assert_not_called() + lister.get_pages_information.assert_called_once_with() + + # lister.run should have been called once per partition interval + for i in range(8): + # XXX inconsistent behavior: max_bound is EXCLUDED here + assert (dict(min_bound=10*i, max_bound=10*i + 10),) \ + in lister.run.call_args_list + assert (dict(min_bound=80, max_bound=85),) \ + in lister.run.call_args_list diff --git a/docs/_templates/.placeholder b/swh/lister/npm/__init__.py similarity index 100% rename from docs/_templates/.placeholder rename to swh/lister/npm/__init__.py diff --git a/swh/lister/npm/lister.py b/swh/lister/npm/lister.py new file mode 100644 index 0000000..a058411 --- /dev/null +++ b/swh/lister/npm/lister.py @@ -0,0 +1,157 @@ +# 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 urllib.parse import quote + +from swh.lister.core.indexing_lister import SWHIndexingHttpLister +from swh.lister.npm.models import NpmModel +from swh.scheduler.utils import create_task_dict + + +class NpmListerBase(SWHIndexingHttpLister): + """List packages available in the npm registry in a paginated way + """ + MODEL = NpmModel + LISTER_NAME = 'npm' + + def __init__(self, api_baseurl='https://replicate.npmjs.com', + per_page=1000, override_config=None): + super().__init__(api_baseurl=api_baseurl, + override_config=override_config) + self.per_page = per_page + 1 + self.PATH_TEMPLATE += '&limit=%s' % self.per_page + + @property + def ADDITIONAL_CONFIG(self): + """(Override) Add extra configuration + + """ + default_config = super().ADDITIONAL_CONFIG + default_config['loading_task_policy'] = ('str', 'recurring') + return default_config + + def get_model_from_repo(self, repo_name): + """(Override) Transform from npm package name to model + + """ + package_url, package_metadata_url = self._compute_urls(repo_name) + return { + 'uid': repo_name, + 'indexable': repo_name, + 'name': repo_name, + 'full_name': repo_name, + 'html_url': package_metadata_url, + 'origin_url': package_url, + 'origin_type': 'npm', + 'description': None + } + + def task_dict(self, origin_type, origin_url, **kwargs): + """(Override) Return task dict for loading a npm package into the archive + + This is overridden from the lister_base as more information is + needed for the ingestion task creation. + + """ + task_type = 'origin-update-%s' % origin_type + task_policy = self.config['loading_task_policy'] + package_name = kwargs.get('name') + package_metadata_url = kwargs.get('html_url') + return create_task_dict(task_type, task_policy, + package_name, origin_url, + package_metadata_url=package_metadata_url) + + def request_headers(self): + """(Override) Set requests headers to send when querying the npm registry + + """ + return {'User-Agent': 'Software Heritage npm lister', + 'Accept': 'application/json'} + + def _compute_urls(self, repo_name): + """Return a tuple (package_url, package_metadata_url) + """ + return ( + 'https://www.npmjs.com/package/%s' % repo_name, + # package metadata url needs to be escaped otherwise some requests + # may fail (for instance when a package name contains '/') + '%s/%s' % (self.api_baseurl, quote(repo_name, safe='')) + ) + + def string_pattern_check(self, inner, lower, upper=None): + """ (Override) Inhibit the effect of that method as packages indices + correspond to package names and thus do not respect any kind + of fixed length string pattern + """ + pass + + +class NpmLister(NpmListerBase): + """List all packages available in the npm registry in a paginated way + """ + PATH_TEMPLATE = '/_all_docs?startkey="%s"' + + def get_next_target_from_response(self, response): + """(Override) Get next npm package name to continue the listing + + """ + repos = response.json()['rows'] + return repos[-1]['id'] if len(repos) == self.per_page else None + + def transport_response_simplified(self, response): + """(Override) Transform npm registry response to list for model manipulation + + """ + repos = response.json()['rows'] + if len(repos) == self.per_page: + repos = repos[:-1] + return [self.get_model_from_repo(repo['id']) for repo in repos] + + +class NpmIncrementalLister(NpmListerBase): + """List packages in the npm registry, updated since a specific + update_seq value of the underlying CouchDB database, in a paginated way + """ + PATH_TEMPLATE = '/_changes?since=%s' + + @property + def CONFIG_BASE_FILENAME(self): # noqa: N802 + return 'lister-npm-incremental' + + def get_next_target_from_response(self, response): + """(Override) Get next npm package name to continue the listing + + """ + repos = response.json()['results'] + return repos[-1]['seq'] if len(repos) == self.per_page else None + + def transport_response_simplified(self, response): + """(Override) Transform npm registry response to list for model manipulation + + """ + repos = response.json()['results'] + if len(repos) == self.per_page: + repos = repos[:-1] + return [self.get_model_from_repo(repo['id']) for repo in repos] + + def filter_before_inject(self, models_list): + """(Override) Filter out documents in the CouchDB database + not related to a npm package + """ + models_filtered = [] + for model in models_list: + package_name = model['name'] + # document related to CouchDB internals + if package_name.startswith('_design/'): + continue + models_filtered.append(model) + return models_filtered + + def disable_deleted_repo_tasks(self, start, end, keep_these): + """(Override) Disable the processing performed by that method + as it is not relevant in this incremental lister context + and it raises and exception due to a different index type + (int instead of str) + """ + pass diff --git a/swh/lister/npm/models.py b/swh/lister/npm/models.py new file mode 100644 index 0000000..206ae36 --- /dev/null +++ b/swh/lister/npm/models.py @@ -0,0 +1,38 @@ +# 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, String, DateTime, Integer, BigInteger, Sequence +from sqlalchemy.ext.declarative import declarative_base + +from swh.lister.core.models import IndexingModelBase, ABCSQLMeta + +SQLBase = declarative_base() + + +class NpmVisitModel(SQLBase, metaclass=ABCSQLMeta): + """Table to store the npm registry state at the time of a + content listing by Software Heritage + """ + __tablename__ = 'npm_visit' + + uid = Column(Integer, Sequence('npm_visit_id_seq'), primary_key=True) + visit_date = Column(DateTime, nullable=False) + doc_count = Column(BigInteger) + doc_del_count = Column(BigInteger) + update_seq = Column(BigInteger) + purge_seq = Column(BigInteger) + disk_size = Column(BigInteger) + data_size = Column(BigInteger) + committed_update_seq = Column(BigInteger) + compacted_seq = Column(BigInteger) + + +class NpmModel(IndexingModelBase): + """A npm package representation + + """ + __tablename__ = 'npm_repo' + + uid = Column(String, primary_key=True) + indexable = Column(String, index=True) diff --git a/swh/lister/npm/tasks.py b/swh/lister/npm/tasks.py new file mode 100644 index 0000000..26a243b --- /dev/null +++ b/swh/lister/npm/tasks.py @@ -0,0 +1,60 @@ +# 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 datetime import datetime +from contextlib import contextmanager + +from swh.scheduler.celery_backend.config import app + +from swh.lister.npm.lister import NpmLister, NpmIncrementalLister +from swh.lister.npm.models import NpmVisitModel + + +@contextmanager +def save_registry_state(lister): + params = {'headers': lister.request_headers()} + registry_state = lister.session.get(lister.api_baseurl, **params) + registry_state = registry_state.json() + keys = ('doc_count', 'doc_del_count', 'update_seq', 'purge_seq', + 'disk_size', 'data_size', 'committed_update_seq', + 'compacted_seq') + + state = {key: registry_state[key] for key in keys} + state['visit_date'] = datetime.now() + yield + npm_visit = NpmVisitModel(**state) + lister.db_session.add(npm_visit) + lister.db_session.commit() + + +def get_last_update_seq(lister): + """Get latest ``update_seq`` value for listing only updated packages. + """ + query = lister.db_session.query(NpmVisitModel.update_seq) + row = query.order_by(NpmVisitModel.uid.desc()).first() + if not row: + raise ValueError('No npm registry listing previously performed ! ' + 'This is required prior to the execution of an ' + 'incremental listing.') + return row[0] + + +@app.task(name=__name__ + '.NpmListerTask') +def npm_lister(**lister_args): + lister = NpmLister(**lister_args) + with save_registry_state(lister): + lister.run() + + +@app.task(name=__name__ + '.NpmIncrementalListerTask') +def npm_incremental_lister(**lister_args): + lister = NpmIncrementalLister(**lister_args) + update_seq_start = get_last_update_seq(lister) + with save_registry_state(lister): + lister.run(min_bound=update_seq_start) + + +@app.task(name=__name__ + '.ping') +def ping(): + return 'OK' diff --git a/swh/lister/npm/tests/api_empty_response.json b/swh/lister/npm/tests/api_empty_response.json new file mode 100644 index 0000000..23eb9e7 --- /dev/null +++ b/swh/lister/npm/tests/api_empty_response.json @@ -0,0 +1,5 @@ +{ + "total_rows": 839080, + "offset": 839080, + "rows": [] +} \ No newline at end of file diff --git a/swh/lister/npm/tests/api_inc_empty_response.json b/swh/lister/npm/tests/api_inc_empty_response.json new file mode 100644 index 0000000..f820af2 --- /dev/null +++ b/swh/lister/npm/tests/api_inc_empty_response.json @@ -0,0 +1,4 @@ +{ + "results": [], + "last_seq": 6927821 +} \ No newline at end of file diff --git a/swh/lister/npm/tests/api_inc_response.json b/swh/lister/npm/tests/api_inc_response.json new file mode 100644 index 0000000..595298f --- /dev/null +++ b/swh/lister/npm/tests/api_inc_response.json @@ -0,0 +1,906 @@ +{ + "results": [ + { + "seq": 6920644, + "id": "electron-scripts", + "changes": [ + { + "rev": "3-a19944df5a3636bb225af9e0c8f9eedc" + } + ] + }, + { + "seq": 6920649, + "id": "@crexi-dev/schematics", + "changes": [ + { + "rev": "3-00188360eeca1f9123b2d7cd4b468c50" + } + ] + }, + { + "seq": 6920651, + "id": "botfactory-conversation", + "changes": [ + { + "rev": "50-f3667cde87637505528c46adc87f44e3" + } + ] + }, + { + "seq": 6920667, + "id": "castle", + "changes": [ + { + "rev": "3-d9adf9c9fd687cdaa2bf460c5bb523f0" + } + ] + }, + { + "seq": 6920671, + "id": "rbc-wm-framework-vuejs", + "changes": [ + { + "rev": "111-32bed479afacdd88aed9ac16dd135843" + } + ] + }, + { + "seq": 6920678, + "id": "bitcoinfiles", + "changes": [ + { + "rev": "22-ab3cd6b46f84d9aac1a24560cabdc9f0" + } + ] + }, + { + "seq": 6920679, + "id": "jovo-core", + "changes": [ + { + "rev": "2-d7440f1d17823e1a0760d9b3d4537c6e" + } + ] + }, + { + "seq": 6920687, + "id": "jovo-framework", + "changes": [ + { + "rev": "103-e4f46a3530514c2ee81a97d25fc8c8c9" + } + ] + }, + { + "seq": 6920690, + "id": "smart-form-lib", + "changes": [ + { + "rev": "18-3b6b6b2b0ea2e114a3f1335a8e798ade" + } + ] + }, + { + "seq": 6920694, + "id": "bokehjs", + "changes": [ + { + "rev": "18-115ce2d4bf4f281eb50c25f3203b3dd2" + } + ] + }, + { + "seq": 6920701, + "id": "guijarro", + "changes": [ + { + "rev": "14-82ece581d6a35d4e1d78e5292ca245c0" + } + ] + }, + { + "seq": 6920702, + "id": "@kava-labs/crypto-rate-utils", + "changes": [ + { + "rev": "3-cecc6a6c226a0590b1a685e3041028c6" + } + ] + }, + { + "seq": 6920703, + "id": "@riouxjean/test", + "changes": [ + { + "rev": "10-01e97dc7d0241dc49ea93b3468ec7b29" + } + ] + }, + { + "seq": 6920704, + "id": "react-scrabblefy", + "changes": [ + { + "rev": "7-970c8206f3b8744204f7dcb106f8462b" + } + ] + }, + { + "seq": 6920706, + "id": "molart", + "changes": [ + { + "rev": "14-416cd3cec62dd46f9b59a3bbe35308f6" + } + ] + }, + { + "seq": 6920707, + "id": "@universal-material/angular", + "changes": [ + { + "rev": "32-266ed3f67e1ddd0b4a37ca29f1cf5bf3" + } + ] + }, + { + "seq": 6920708, + "id": "cozy-doctypes", + "changes": [ + { + "rev": "68-8e90cc26e25da6c9430d373e43ac3c25" + } + ] + }, + { + "seq": 6920710, + "id": "2o3t-ui", + "changes": [ + { + "rev": "96-1e65d5320ea7c78525aba5daf328bd4b" + } + ] + }, + { + "seq": 6920712, + "id": "ark-ts", + "changes": [ + { + "rev": "24-033183c2f7f9cbb6e44d553213e525b6" + } + ] + }, + { + "seq": 6920715, + "id": "mysqlconnector", + "changes": [ + { + "rev": "19-f09bc0b82281ca486db5ebe83843679e" + } + ] + }, + { + "seq": 6920716, + "id": "@innovexa/ng-form-creator-lib", + "changes": [ + { + "rev": "147-480665ee17fa889dfec1aee75b907ff2" + } + ] + }, + { + "seq": 6920717, + "id": "k-routes-example-basic", + "changes": [ + { + "rev": "1-35142059e1c63cc724da71a9eebf229c" + } + ] + }, + { + "seq": 6920718, + "id": "wloggertojs", + "changes": [ + { + "rev": "29-5b5aa74bd30ff0fc86b39fba799befe2" + } + ] + }, + { + "seq": 6920720, + "id": "wloggertofile", + "changes": [ + { + "rev": "65-aa8d2005c1ecb90b8bd67b62daecfbb5" + } + ] + }, + { + "seq": 6920721, + "id": "@brightcove/flashls", + "changes": [ + { + "rev": "62-fbadb49476a58e98f0f136c86b614734" + } + ] + }, + { + "seq": 6920722, + "id": "@brightcove/hls-fetcher", + "changes": [ + { + "rev": "76-3341ed8ade38f3251a97c94c3a7af5ac" + } + ] + }, + { + "seq": 6920723, + "id": "@brightcove/kacl", + "changes": [ + { + "rev": "33-d0bc6b639cccb301086114d548ecfdbf" + } + ] + }, + { + "seq": 6920724, + "id": "just-in-types", + "changes": [ + { + "rev": "2-fc329aa885dc795aee340f36ec60f333" + } + ] + }, + { + "seq": 6920725, + "id": "@brightcove/player-loader", + "changes": [ + { + "rev": "56-9ff5aebc9743a44d46c182746313877d" + } + ] + }, + { + "seq": 6920726, + "id": "@brightcove/player-loader-webpack-plugin", + "changes": [ + { + "rev": "33-db8b4d6765f19e475e1c1d16843824cb" + } + ] + }, + { + "seq": 6920727, + "id": "@brightcove/player-url", + "changes": [ + { + "rev": "28-2e5c7fecca46bf0f341395a57dc6b3bc" + } + ] + }, + { + "seq": 6920728, + "id": "@brightcove/react-player-loader", + "changes": [ + { + "rev": "39-b7bf609de666ec7e71f517db53ab9c0a" + } + ] + }, + { + "seq": 6920729, + "id": "vscode-theme-generator", + "changes": [ + { + "rev": "21-bcb92281d6f7e37548bb18113681df88" + } + ] + }, + { + "seq": 6920733, + "id": "@brightcove/typed-immutable-extensions", + "changes": [ + { + "rev": "29-4f44b68fd5b8fdc0e499a8a93d8fbabe" + } + ] + }, + { + "seq": 6920734, + "id": "@brightcove/typed-immutable-proptypes", + "changes": [ + { + "rev": "27-e4802afc947c55d34f778864476c17e4" + } + ] + }, + { + "seq": 6920737, + "id": "@brightcove/videojs-flashls-source-handler", + "changes": [ + { + "rev": "59-faf69c49be866b2ab7faa7be9972e7a5" + } + ] + }, + { + "seq": 6920738, + "id": "@brightcove/videojs-flashls-swf", + "changes": [ + { + "rev": "60-04908466eaac2194bc3061e91f463dab" + } + ] + }, + { + "seq": 6920739, + "id": "@noqcks/generated", + "changes": [ + { + "rev": "2-e07d07614182d4beccc507ca199e612d" + } + ] + }, + { + "seq": 6920740, + "id": "pkcs7", + "changes": [ + { + "rev": "60-65ba116f3b6b705f472971b5c6a8f8d2" + } + ] + }, + { + "seq": 6920741, + "id": "videojs-errors", + "changes": [ + { + "rev": "57-c999abd162ca4b93412e363443aa688a" + } + ] + }, + { + "seq": 6920742, + "id": "videojs-flashls-source-handler", + "changes": [ + { + "rev": "59-46d62e18971a8c800710a8fbf985c1c5" + } + ] + }, + { + "seq": 6920743, + "id": "videojs-playlist", + "changes": [ + { + "rev": "97-d4b3492a94c1084c272162dd51901188" + } + ] + }, + { + "seq": 6920745, + "id": "videojs-playlist-ui", + "changes": [ + { + "rev": "95-ba97c44c354b2262e639f8c515bed9bc" + } + ] + }, + { + "seq": 6920746, + "id": "fusion-apollo-universal-client", + "changes": [ + { + "rev": "25-7123042a477cec67c7d5fc702254c7a3" + } + ] + }, + { + "seq": 6920749, + "id": "msg-fabric-core", + "changes": [ + { + "rev": "20-17c33e06faca357526c7395aca1113d2" + } + ] + }, + { + "seq": 6920750, + "id": "@expo/schemer", + "changes": [ + { + "rev": "62-3b1fc389ba4a6ecfc7a40f9c1b83016d" + } + ] + }, + { + "seq": 6920752, + "id": "mathjs", + "changes": [ + { + "rev": "115-bff8ab85ac0812cad09d37ddcbd8ac18" + } + ] + }, + { + "seq": 6920758, + "id": "statesauce-ui", + "changes": [ + { + "rev": "6-db9a39366c1a082c56a2212e368e3ae2" + } + ] + }, + { + "seq": 6920782, + "id": "@catchandrelease/arbor", + "changes": [ + { + "rev": "19-925648432b398ecadc98993e6fba2353" + } + ] + }, + { + "seq": 6920784, + "id": "discover-shared-ebsco-ui-core", + "changes": [ + { + "rev": "4-277063cbc6b71f969e5f0db8c371db65" + } + ] + }, + { + "seq": 6920807, + "id": "react-apexcharts", + "changes": [ + { + "rev": "13-18505be8026a50390c1ff1ba522cb9bd" + } + ] + }, + { + "seq": 6920819, + "id": "zigbee-shepherd-converters", + "changes": [ + { + "rev": "90-5819692a5a9679ff8669fb410e190515" + } + ] + }, + { + "seq": 6920835, + "id": "honeycomb-grid", + "changes": [ + { + "rev": "36-edd6733c80b04a72600558dc55348c73" + } + ] + }, + { + "seq": 6920838, + "id": "pixl-config", + "changes": [ + { + "rev": "7-5dd2b68d04fefb4039b3965b3497eda2" + } + ] + }, + { + "seq": 6920842, + "id": "discover-shared-ebsco-ui-theming", + "changes": [ + { + "rev": "4-e9d083825b1eae46f28c4def2d0db79f" + } + ] + }, + { + "seq": 6920843, + "id": "common-oxgalaxy-lengua-app", + "changes": [ + { + "rev": "66-8b64fa98b4c16b81fb906f0a1bb8539f" + } + ] + }, + { + "seq": 6920845, + "id": "discover-shared-ebsco-ui-grid", + "changes": [ + { + "rev": "2-6f71cf625a5232075071952b2adaa8f2" + } + ] + }, + { + "seq": 6920847, + "id": "@auth0/cosmos-tokens", + "changes": [ + { + "rev": "44-85cd3760dc5e7cfc2fa6330f12f04efb" + } + ] + }, + { + "seq": 6920848, + "id": "@auth0/babel-preset-cosmos", + "changes": [ + { + "rev": "43-d05d3779db08f08726ba048da298e046" + } + ] + }, + { + "seq": 6920849, + "id": "jsrender", + "changes": [ + { + "rev": "11-c949091592b3329d73ae564e45a3472d" + } + ] + }, + { + "seq": 6920850, + "id": "discover-shared-ebsco-ui-container", + "changes": [ + { + "rev": "2-c32089f76b7f253bc0d765da8b9f670d" + } + ] + }, + { + "seq": 6920852, + "id": "@auth0/cosmos", + "changes": [ + { + "rev": "42-5fdaf3d9063c20dac13dcf455c42773c" + } + ] + }, + { + "seq": 6920853, + "id": "discover-shared-ebsco-ui-checkbox", + "changes": [ + { + "rev": "2-06d9521b86f0dbf4a398726faead1212" + } + ] + }, + { + "seq": 6920854, + "id": "@adunigan/toggles", + "changes": [ + { + "rev": "1-c2a830cf814a9fe2d72084339c9c5d28" + } + ] + }, + { + "seq": 6920855, + "id": "@spriteful/spriteful-lazy-carousel", + "changes": [ + { + "rev": "8-28a4bbfe2d1ff24cddcc5aeba6c77837" + } + ] + }, + { + "seq": 6920856, + "id": "react-modal-hook", + "changes": [ + { + "rev": "2-364b39d6559364c41d5b852ccad4ce31" + } + ], + "deleted": true + }, + { + "seq": 6920859, + "id": "@bellese/angular-design-system", + "changes": [ + { + "rev": "39-3e297f85ce2d6a6b6d15fc26420fc471" + } + ] + }, + { + "seq": 6920861, + "id": "@uifabric/styling", + "changes": [ + { + "rev": "229-addf6cc0e74a335125c04d60047353f5" + } + ] + }, + { + "seq": 6920862, + "id": "@uifabric/file-type-icons", + "changes": [ + { + "rev": "37-8a7e43399d1bb9f17334b10995f78df4" + } + ] + }, + { + "seq": 6920864, + "id": "throttlewrap", + "changes": [ + { + "rev": "3-7ab31c0a6a02ed02b96734c747c8c6fa" + } + ] + }, + { + "seq": 6920865, + "id": "airtable", + "changes": [ + { + "rev": "16-d8aee935f6fa4c88057d75a0542bc58c" + } + ] + }, + { + "seq": 6920866, + "id": "@csmart/ngc-smart-address", + "changes": [ + { + "rev": "19-66a6ea868aae1912952f232d2c699f3a" + } + ] + }, + { + "seq": 6920868, + "id": "office-ui-fabric-react", + "changes": [ + { + "rev": "744-8542f4e04c0e9230e2ba19c9e0d7b461" + } + ] + }, + { + "seq": 6920869, + "id": "@fuelrats/eslint-config", + "changes": [ + { + "rev": "12-1b4c71b78fd078e3c1cba535e8541bed" + } + ] + }, + { + "seq": 6920870, + "id": "@uifabric/date-time", + "changes": [ + { + "rev": "2-f955fd46e3b7d3b70d1c82eeadd3f2ed" + } + ] + }, + { + "seq": 6920872, + "id": "dark-client", + "changes": [ + { + "rev": "11-a954c2a89a130ae73f064233d9b3bce2" + } + ] + }, + { + "seq": 6920873, + "id": "@uifabric/variants", + "changes": [ + { + "rev": "59-391c720194c663b9a5c59fe2c10a1535" + } + ] + }, + { + "seq": 6920875, + "id": "discover-shared-ebsco-ui-header", + "changes": [ + { + "rev": "2-efd8f0426a83422a6c8b7bff11054c72" + } + ] + }, + { + "seq": 6920876, + "id": "react-responsive-picture", + "changes": [ + { + "rev": "14-32a6d0850c8af33412cfdb23afd2ecfa" + } + ] + }, + { + "seq": 6920877, + "id": "@uifabric/fluent-theme", + "changes": [ + { + "rev": "16-39c29e00b81a0b654213a5a50d7e7f42" + } + ] + }, + { + "seq": 6920878, + "id": "@uifabric/dashboard", + "changes": [ + { + "rev": "82-04d6dc25b33e811c1d8c24566127b09c" + } + ] + }, + { + "seq": 6920879, + "id": "ids-enterprise", + "changes": [ + { + "rev": "201-dd709a3912f9832440320d448850b61a" + } + ] + }, + { + "seq": 6920880, + "id": "@uifabric/experiments", + "changes": [ + { + "rev": "224-efd1ef07f7640952c286488eae282367" + } + ] + }, + { + "seq": 6920881, + "id": "@fuelrats/eslint-config-react", + "changes": [ + { + "rev": "10-d872deb1eebced4d1d8c3ea6cb5d98bc" + } + ] + }, + { + "seq": 6920883, + "id": "jsviews", + "changes": [ + { + "rev": "11-44d8bedffc98cf6ac4aa669ba8844746" + } + ] + }, + { + "seq": 6920885, + "id": "pixl-server", + "changes": [ + { + "rev": "15-823f4598c3354500d8d2a266dd062502" + } + ] + }, + { + "seq": 6920887, + "id": "@rrpm/netlify-cms-core", + "changes": [ + { + "rev": "17-0dc4eafba1098806dd4cc0cb631eb5fa" + } + ] + }, + { + "seq": 6920889, + "id": "lodash-a", + "changes": [ + { + "rev": "2-6ee66153dbe611a080b40775387d2d45" + } + ] + }, + { + "seq": 6920891, + "id": "meshcentral", + "changes": [ + { + "rev": "499-6677ca74525ed2aa77644c68001382fe" + } + ] + }, + { + "seq": 6920892, + "id": "vue-transition-collection", + "changes": [ + { + "rev": "2-0510ee52c014c0d3b1e65f24376d76f0" + } + ] + }, + { + "seq": 6920894, + "id": "fury-adapter-swagger", + "changes": [ + { + "rev": "47-09f0c55d8574d654c67f9244c21d7ef7" + } + ] + }, + { + "seq": 6920895, + "id": "@isobar-us/redux-form-gen", + "changes": [ + { + "rev": "30-70d7d9210264a321092c832063934648" + } + ] + }, + { + "seq": 6920896, + "id": "atomizer", + "changes": [ + { + "rev": "19-129774900cb2a67a46871cc2c40c34d3" + } + ] + }, + { + "seq": 6920904, + "id": "boom-js-client", + "changes": [ + { + "rev": "15-fe8d703ddfdc0bd220c3c2f7ea46d2c9" + } + ] + }, + { + "seq": 6920905, + "id": "@ts-common/json-parser", + "changes": [ + { + "rev": "17-fe8cc9bc4a5021fde8629a8f880f64b3" + } + ] + }, + { + "seq": 6920906, + "id": "rutt", + "changes": [ + { + "rev": "13-78aab849cb00a6ef7ebc8165770b7d33" + } + ] + }, + { + "seq": 6920907, + "id": "linear-react-components-ui", + "changes": [ + { + "rev": "171-0307f1d69843b270e687371c67cbd1b0" + } + ] + }, + { + "seq": 6920908, + "id": "@earnest/eslint-config", + "changes": [ + { + "rev": "180-b5250dd803102cf7dbac8da9c1a403fd" + } + ] + }, + { + "seq": 6920909, + "id": "@earnest/eslint-config-es7", + "changes": [ + { + "rev": "181-da26885e0baacaea95814857f459572d" + } + ] + }, + { + "seq": 6920910, + "id": "fuse-design", + "changes": [ + { + "rev": "10-e2b78592872f680c05e55eb5b81a0cab" + } + ] + } + ], + "last_seq": 6920912 +} \ No newline at end of file diff --git a/swh/lister/npm/tests/api_response.json b/swh/lister/npm/tests/api_response.json new file mode 100644 index 0000000..b1c78ef --- /dev/null +++ b/swh/lister/npm/tests/api_response.json @@ -0,0 +1,807 @@ +{ + "total_rows": 839080, + "offset": 422482, + "rows": [ + + { + "id": "jquery", + "key": "jquery", + "value": { + "rev": "212-2eac7c93af4c8bccdf7317739f0319b6" + } + }, + + { + "id": "jquery-1.8", + "key": "jquery-1.8", + "value": { + "rev": "1-711ded49a7453adce85ce7a51c2157de" + } + }, + + { + "id": "jquery-1x", + "key": "jquery-1x", + "value": { + "rev": "1-c53fa04d9c8fb231336704508732c287" + } + }, + + { + "id": "jquery-2-typescript-async-await-adapter", + "key": "jquery-2-typescript-async-await-adapter", + "value": { + "rev": "8-5cfb484e9afaa6e326a97240fccd8f93" + } + }, + + { + "id": "jquery-accessible-accordion-aria", + "key": "jquery-accessible-accordion-aria", + "value": { + "rev": "15-9fc0df7cb2f1cd1001e2da302443b56e" + } + }, + + { + "id": "jquery-accessible-autocomplete-list-aria", + "key": "jquery-accessible-autocomplete-list-aria", + "value": { + "rev": "8-961b382442c1a5bafe58f0e05424701d" + } + }, + + { + "id": "jquery-accessible-carrousel-aria", + "key": "jquery-accessible-carrousel-aria", + "value": { + "rev": "9-f33f59d7f601bafe023bd711b551282b" + } + }, + + { + "id": "jquery-accessible-dialog-tooltip-aria", + "key": "jquery-accessible-dialog-tooltip-aria", + "value": { + "rev": "12-0a7b5ba6f7717c2c6603cabdb29de9ba" + } + }, + + { + "id": "jquery-accessible-hide-show-aria", + "key": "jquery-accessible-hide-show-aria", + "value": { + "rev": "10-5a03c47a8995b08246e4bc103782dafa" + } + }, + + { + "id": "jquery-accessible-modal-window-aria", + "key": "jquery-accessible-modal-window-aria", + "value": { + "rev": "18-50266e260f6b807019cfcfcd3a3685ab" + } + }, + + { + "id": "jquery-accessible-simple-tooltip-aria", + "key": "jquery-accessible-simple-tooltip-aria", + "value": { + "rev": "6-ea71aa37760790dc603b56117f054e1b" + } + }, + + { + "id": "jquery-accessible-subnav-dropdown", + "key": "jquery-accessible-subnav-dropdown", + "value": { + "rev": "2-496f017a9ac243655225e43b5697b09b" + } + }, + + { + "id": "jquery-accessible-tabpanel-aria", + "key": "jquery-accessible-tabpanel-aria", + "value": { + "rev": "11-659971471e6ac0fbb3b2f78ad208722a" + } + }, + + { + "id": "jquery-accessible-tabs-umd", + "key": "jquery-accessible-tabs-umd", + "value": { + "rev": "1-f92015de5bb36e411d8c0940cca2883f" + } + }, + + { + "id": "jquery-active-descendant", + "key": "jquery-active-descendant", + "value": { + "rev": "8-79aed7a6cbca4e1f3c3ac0570d0290de" + } + }, + + { + "id": "jquery-ada-validation", + "key": "jquery-ada-validation", + "value": { + "rev": "1-9aab9629027c29fbece90485dd9d3112" + } + }, + + { + "id": "jquery-adaptText", + "key": "jquery-adaptText", + "value": { + "rev": "3-2e15fc801ea8235b9180a3defc782ed0" + } + }, + + { + "id": "jquery-adapttr", + "key": "jquery-adapttr", + "value": { + "rev": "6-74585f2d4be60b3f493585a6d28b90bc" + } + }, + + { + "id": "jquery-add-prefixed-class", + "key": "jquery-add-prefixed-class", + "value": { + "rev": "1-9e43aee9758504b3f5271e9804a95f20" + } + }, + + { + "id": "jquery-address", + "key": "jquery-address", + "value": { + "rev": "1-64173ede32157b26f4de910ad0f49590" + } + }, + + { + "id": "jquery-address-suggestion", + "key": "jquery-address-suggestion", + "value": { + "rev": "6-18d9df51d472c365bcd84a61c9105774" + } + }, + + { + "id": "jquery-advscrollevent", + "key": "jquery-advscrollevent", + "value": { + "rev": "1-f6033de9ba0f8e364c42826441d93119" + } + }, + + { + "id": "jquery-affix", + "key": "jquery-affix", + "value": { + "rev": "6-777371f67df59abf18ec1fe326df3b82" + } + }, + + { + "id": "jquery-airload", + "key": "jquery-airload", + "value": { + "rev": "7-136d513d2604a25238eb88709d6d9003" + } + }, + + { + "id": "jquery-ajax", + "key": "jquery-ajax", + "value": { + "rev": "1-ee358f630d4c928b52c968c7667d0d31" + } + }, + + { + "id": "jquery-ajax-cache", + "key": "jquery-ajax-cache", + "value": { + "rev": "2-ca31e0d43ae28e9cea968f1f538f06d3" + } + }, + + { + "id": "jquery-ajax-chain", + "key": "jquery-ajax-chain", + "value": { + "rev": "1-dc0e5aee651c0128b7f411aac96132a2" + } + }, + + { + "id": "jquery-ajax-file-upload", + "key": "jquery-ajax-file-upload", + "value": { + "rev": "1-96147d8bf69245c622e76583bb615d49" + } + }, + + { + "id": "jquery-ajax-json", + "key": "jquery-ajax-json", + "value": { + "rev": "1-b47eec12168e4cb39b45f1523d7cd397" + } + }, + + { + "id": "jquery-ajax-markup", + "key": "jquery-ajax-markup", + "value": { + "rev": "1-8e65dc822cb63be76c62a1323666265e" + } + }, + + { + "id": "jquery-ajax-native", + "key": "jquery-ajax-native", + "value": { + "rev": "2-9d67b8d43713e3546ad50f817c040139" + } + }, + + { + "id": "jquery-ajax-request", + "key": "jquery-ajax-request", + "value": { + "rev": "1-fdc0960ec73667bc2b46adf493c05db4" + } + }, + + { + "id": "jquery-ajax-retry", + "key": "jquery-ajax-retry", + "value": { + "rev": "1-27ca186953e346aa9c0ca2310c732751" + } + }, + + { + "id": "jquery-ajax-tracking", + "key": "jquery-ajax-tracking", + "value": { + "rev": "3-d48876f3c115ee4743a6a94bb65bb01d" + } + }, + + { + "id": "jquery-ajax-transport-xdomainrequest", + "key": "jquery-ajax-transport-xdomainrequest", + "value": { + "rev": "1-ece69aa5b9f0c950a1fa2806cf74392d" + } + }, + + { + "id": "jquery-ajax-unobtrusive", + "key": "jquery-ajax-unobtrusive", + "value": { + "rev": "3-fb0daab8480b9a2cc9c6876e1c4874f4" + } + }, + + { + "id": "jquery-ajax-unobtrusive-multi", + "key": "jquery-ajax-unobtrusive-multi", + "value": { + "rev": "1-0a2ffdabaf5708d4ae3d9e29a3a9ef11" + } + }, + + { + "id": "jquery-ajaxreadystate", + "key": "jquery-ajaxreadystate", + "value": { + "rev": "1-5e618474fe2e77ad5869c206164f82bf" + } + }, + + { + "id": "jquery-albe-timeline", + "key": "jquery-albe-timeline", + "value": { + "rev": "2-3db2b43778b5c50db873e724d9940eb6" + } + }, + + { + "id": "jquery-all-attributes", + "key": "jquery-all-attributes", + "value": { + "rev": "1-89bb7e01ee312ad5d36d78a3aa2327e4" + } + }, + + { + "id": "jquery-alphaindex", + "key": "jquery-alphaindex", + "value": { + "rev": "4-7f61cde9cfb70617a6fbe992dfcbc10a" + } + }, + + { + "id": "jquery-always", + "key": "jquery-always", + "value": { + "rev": "1-0ad944881bbc39c67df0a694d80bebef" + } + }, + + { + "id": "jquery-amd", + "key": "jquery-amd", + "value": { + "rev": "1-931646c751bef740c361dd0f6e68653c" + } + }, + + { + "id": "jquery-anaglyph-image-effect", + "key": "jquery-anaglyph-image-effect", + "value": { + "rev": "1-9bf7afce2e1bc73747ef22abc859b22b" + } + }, + + { + "id": "jquery-analytics", + "key": "jquery-analytics", + "value": { + "rev": "1-d84b0c8ce886b9f01d2c5c1cf0a7317f" + } + }, + + { + "id": "jquery-ancestors", + "key": "jquery-ancestors", + "value": { + "rev": "1-49b30817a03558f1f585c8c0cd4b8afb" + } + }, + + { + "id": "jquery-angry-loader", + "key": "jquery-angry-loader", + "value": { + "rev": "1-31c9fd950d32b9d3a73829cde1dae577" + } + }, + + { + "id": "jquery-angular-shim", + "key": "jquery-angular-shim", + "value": { + "rev": "1-723e72b2981f02dd3abcfe6d2395d636" + } + }, + + { + "id": "jquery-animate-gradient", + "key": "jquery-animate-gradient", + "value": { + "rev": "5-a3e0fc89699237e7e7241cd608a0dcf7" + } + }, + + { + "id": "jquery-animate-scroll", + "key": "jquery-animate-scroll", + "value": { + "rev": "1-37d49d89fe99aa599540e6ff83b15888" + } + }, + + { + "id": "jquery-animated-headlines", + "key": "jquery-animated-headlines", + "value": { + "rev": "1-adf1d149bc83fa8445e141e3c900759e" + } + }, + + { + "id": "jquery-animation", + "key": "jquery-animation", + "value": { + "rev": "4-f51d0559010bbe9d74d70e58de9bd733" + } + }, + + { + "id": "jquery-animation-support", + "key": "jquery-animation-support", + "value": { + "rev": "1-9013bc4bdeb2bd70bedcc988a811fcc0" + } + }, + + { + "id": "jquery-aniview", + "key": "jquery-aniview", + "value": { + "rev": "3-5754524da237693458bcff19b626b875" + } + }, + + { + "id": "jquery-anything-clickable", + "key": "jquery-anything-clickable", + "value": { + "rev": "2-e1aaaf1a369f7796c438a3efbf05bcce" + } + }, + + { + "id": "jquery-app", + "key": "jquery-app", + "value": { + "rev": "6-4e0bf5abd71c72ced3c4cf3035116f70" + } + }, + + { + "id": "jquery-app-banner", + "key": "jquery-app-banner", + "value": { + "rev": "2-8a5b530eaab94315eb00c77acd13f2dd" + } + }, + + { + "id": "jquery-appear-poetic", + "key": "jquery-appear-poetic", + "value": { + "rev": "1-368094b72ed36d42cf2fca438fa4b344" + } + }, + + { + "id": "jquery-applyonscreen", + "key": "jquery-applyonscreen", + "value": { + "rev": "4-d76c18a6e66fffba01a9a774b40663f8" + } + }, + + { + "id": "jquery-apta", + "key": "jquery-apta", + "value": { + "rev": "1-c486380fedefd887e6293a00c3b6a222" + } + }, + + { + "id": "jquery-arrow-navigate", + "key": "jquery-arrow-navigate", + "value": { + "rev": "3-0efe881e01ef0eac24a92baf1eb6d8d1" + } + }, + + { + "id": "jquery-asAccordion", + "key": "jquery-asAccordion", + "value": { + "rev": "2-2d18d3fe9089dcf67de5f29d1763b4ce" + } + }, + + { + "id": "jquery-asBgPicker", + "key": "jquery-asBgPicker", + "value": { + "rev": "2-d1403cd306d5764ee0f5aa852c2bed8e" + } + }, + + { + "id": "jquery-asBreadcrumbs", + "key": "jquery-asBreadcrumbs", + "value": { + "rev": "2-77e566a07680005ce1cb322f2a733fe4" + } + }, + + { + "id": "jquery-asCheck", + "key": "jquery-asCheck", + "value": { + "rev": "2-d0b2741b70616c7d563419cc125d193d" + } + }, + + { + "id": "jquery-asChoice", + "key": "jquery-asChoice", + "value": { + "rev": "2-0eda5269cbd59976ee904b74da209389" + } + }, + + { + "id": "jquery-asColor", + "key": "jquery-asColor", + "value": { + "rev": "3-aa730d81322561c7a3174d5c7bb6b3b8" + } + }, + + { + "id": "jquery-asColorPicker", + "key": "jquery-asColorPicker", + "value": { + "rev": "2-6bbaecaf94a324331a3d1f5d3aad3b3d" + } + }, + + { + "id": "jquery-asDropdown", + "key": "jquery-asDropdown", + "value": { + "rev": "2-b29b187cdd0bdce502d11855415e6887" + } + }, + + { + "id": "jquery-asFontEditor", + "key": "jquery-asFontEditor", + "value": { + "rev": "2-132882375101062896413afdc93b4c8c" + } + }, + + { + "id": "jquery-asGalleryPicker", + "key": "jquery-asGalleryPicker", + "value": { + "rev": "1-864a80930d72c6150aa74969a28617e4" + } + }, + + { + "id": "jquery-asGmap", + "key": "jquery-asGmap", + "value": { + "rev": "2-b0c4330774137b2f1b91bd4686880f2a" + } + }, + + { + "id": "jquery-asGradient", + "key": "jquery-asGradient", + "value": { + "rev": "2-5184670a313d5e161cb62659de3db55c" + } + }, + + { + "id": "jquery-asHoverScroll", + "key": "jquery-asHoverScroll", + "value": { + "rev": "7-3f6efebf248bd27520d03eaac33d8ca2" + } + }, + + { + "id": "jquery-asIconPicker", + "key": "jquery-asIconPicker", + "value": { + "rev": "2-9070adda148ea75247c7cee810ae91e2" + } + }, + + { + "id": "jquery-asImagePicker", + "key": "jquery-asImagePicker", + "value": { + "rev": "2-fb3115c2296b0b07ed9e379176626e01" + } + }, + + { + "id": "jquery-asItemList", + "key": "jquery-asItemList", + "value": { + "rev": "2-88a7d2900f47c785c2a6cb764ac467d6" + } + }, + + { + "id": "jquery-asModal", + "key": "jquery-asModal", + "value": { + "rev": "2-1719b8e6a489e03cc3e22bd329148366" + } + }, + + { + "id": "jquery-asOffset", + "key": "jquery-asOffset", + "value": { + "rev": "2-e45a0077e5bc0bbf91b32dc76387c945" + } + }, + + { + "id": "jquery-asPaginator", + "key": "jquery-asPaginator", + "value": { + "rev": "2-0d279d2748fc5e875f5fb2a8d3d48377" + } + }, + + { + "id": "jquery-asPieProgress", + "key": "jquery-asPieProgress", + "value": { + "rev": "2-14dc464a19e9d3feaa532f62e45bbd26" + } + }, + + { + "id": "jquery-asProgress", + "key": "jquery-asProgress", + "value": { + "rev": "2-a58d7100f1a78f7753efcf0e34dfaf0e" + } + }, + + { + "id": "jquery-asRange", + "key": "jquery-asRange", + "value": { + "rev": "3-aa3d2f348a933161868ba6b6fd9eb881" + } + }, + + { + "id": "jquery-asScroll", + "key": "jquery-asScroll", + "value": { + "rev": "1-f4880ea057adbfebb912ba0157575ca1" + } + }, + + { + "id": "jquery-asScrollable", + "key": "jquery-asScrollable", + "value": { + "rev": "7-5c18eb2180d8aa85f0b5e940667c8344" + } + }, + + { + "id": "jquery-asScrollbar", + "key": "jquery-asScrollbar", + "value": { + "rev": "4-89420658c355a5584825b45ee4ef0beb" + } + }, + + { + "id": "jquery-asSelect", + "key": "jquery-asSelect", + "value": { + "rev": "2-caf3dc516665009b654236b876fe02bb" + } + }, + + { + "id": "jquery-asSpinner", + "key": "jquery-asSpinner", + "value": { + "rev": "2-bf26b5d9c77eb4b63acbf16019407834" + } + }, + + { + "id": "jquery-asSwitch", + "key": "jquery-asSwitch", + "value": { + "rev": "2-f738586946b432caa73297568b5f38ad" + } + }, + + { + "id": "jquery-asTooltip", + "key": "jquery-asTooltip", + "value": { + "rev": "2-80d3fe5cdae70d9310969723e7045384" + } + }, + + { + "id": "jquery-asTree", + "key": "jquery-asTree", + "value": { + "rev": "2-353063a563c0322cbc317af385f71b27" + } + }, + + { + "id": "jquery-ascolorpicker-flat", + "key": "jquery-ascolorpicker-flat", + "value": { + "rev": "11-1681d53cd475e7b6b9564baa51a79611" + } + }, + + { + "id": "jquery-aslider", + "key": "jquery-aslider", + "value": { + "rev": "1-2b3dd953493eeaa4dc329cbf0d81116a" + } + }, + + { + "id": "jquery-aspect-ratio-keeper", + "key": "jquery-aspect-ratio-keeper", + "value": { + "rev": "1-1ad8e5588218e1d38fff351858655eda" + } + }, + + { + "id": "jquery-assinadordigitaldiscus", + "key": "jquery-assinadordigitaldiscus", + "value": { + "rev": "1-897cd68ef3699551630bd3454dceb6f0" + } + }, + + { + "id": "jquery-async-gravatar", + "key": "jquery-async-gravatar", + "value": { + "rev": "3-a3192e741d14d57635f4ebfb41a904db" + } + }, + + { + "id": "jquery-asynclink", + "key": "jquery-asynclink", + "value": { + "rev": "1-2159a3c49e3c8fe9280c592770e83522" + } + }, + + { + "id": "jquery-atlas", + "key": "jquery-atlas", + "value": { + "rev": "1-6142c5a0af67a0470daf36151d3f9d8c" + } + }, + + { + "id": "jquery-atomic-nav", + "key": "jquery-atomic-nav", + "value": { + "rev": "1-18e4ef14be83a907cbee0cd0adee25d4" + } + }, + + { + "id": "jquery-attach", + "key": "jquery-attach", + "value": { + "rev": "8-da4f17596c25a02b0cce266e59706d5f" + } + } + + ] +} diff --git a/swh/lister/pypi/tasks.py b/swh/lister/pypi/tasks.py index df2d275..bf210ab 100644 --- a/swh/lister/pypi/tasks.py +++ b/swh/lister/pypi/tasks.py @@ -1,20 +1,17 @@ # 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 ..core.tasks import ListerTaskBase -from .lister import PyPILister +from swh.scheduler.celery_backend.config import app +from .lister import PyPILister -class PyPIListerTask(ListerTaskBase): - """Full PyPI lister (list all available origins from the api). - """ - task_queue = 'swh_lister_pypi_refresh' +@app.task(name=__name__ + '.PyPIListerTask') +def pypi_lister(**lister_args): + PyPILister(**lister_args).run() - def new_lister(self): - return PyPILister() - def run_task(self): - lister = self.new_lister() - lister.run() +@app.task(name=__name__ + '.ping') +def ping(): + return 'OK' diff --git a/docs/_static/.placeholder b/swh/lister/pypi/tests/__init__.py similarity index 100% rename from docs/_static/.placeholder rename to swh/lister/pypi/tests/__init__.py diff --git a/swh/lister/pypi/tests/conftest.py b/swh/lister/pypi/tests/conftest.py new file mode 100644 index 0000000..507fef9 --- /dev/null +++ b/swh/lister/pypi/tests/conftest.py @@ -0,0 +1 @@ +from swh.lister.core.tests.conftest import * # noqa diff --git a/swh/lister/pypi/tests/test_tasks.py b/swh/lister/pypi/tests/test_tasks.py new file mode 100644 index 0000000..ab7032b --- /dev/null +++ b/swh/lister/pypi/tests/test_tasks.py @@ -0,0 +1,27 @@ +from unittest.mock import patch + + +def test_ping(swh_app, celery_session_worker): + res = swh_app.send_task( + 'swh.lister.pypi.tasks.ping') + assert res + res.wait() + assert res.successful() + assert res.result == 'OK' + + +@patch('swh.lister.pypi.tasks.PyPILister') +def test_lister(lister, swh_app, celery_session_worker): + # setup the mocked PypiLister + lister.return_value = lister + lister.run.return_value = None + + res = swh_app.send_task( + 'swh.lister.pypi.tasks.PyPIListerTask') + assert res + res.wait() + assert res.successful() + + lister.assert_called_once_with() + lister.db_last_index.assert_not_called() + lister.run.assert_called_once_with() diff --git a/swh/lister/tests/test_utils.py b/swh/lister/tests/test_utils.py index 978127a..5d9f476 100644 --- a/swh/lister/tests/test_utils.py +++ b/swh/lister/tests/test_utils.py @@ -1,28 +1,24 @@ # 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): + def test_split_range(self): actual_ranges = list(utils.split_range(14, 5)) self.assertEqual(actual_ranges, [(0, 5), (5, 10), (10, 14)]) actual_ranges = list(utils.split_range(19, 10)) self.assertEqual(actual_ranges, [(0, 10), (10, 19)]) - @istest - def split_range_errors(self): + def test_split_range_errors(self): with self.assertRaises(TypeError): list(utils.split_range(None, 1)) with self.assertRaises(TypeError): list(utils.split_range(100, None)) diff --git a/version.txt b/version.txt index 12470b5..00a8745 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v0.0.18-0-g8f5b10b \ No newline at end of file +v0.0.19-0-g1756e2e \ No newline at end of file