diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..a7cba45 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + tests/* diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..204f6df --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,37 @@ +name: Run tests and upload coverage report to Codecov + +on: + push: + branches: [dev, main] + +jobs: + coverage: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Python dependencies + run: | + pip install -r requirements.txt + pip install codecov + pip install -e . + + - name: Run tests with coverage + env: + SKIP_ONLINE_TESTS: "true" + run: | + coverage run -m pytest + coverage xml + + - name: Upload score to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml new file mode 100644 index 0000000..b40ad50 --- /dev/null +++ b/.github/workflows/mkdocs.yml @@ -0,0 +1,39 @@ +name: Build and deploy mkdocs site to GitHub Pages + +on: + push: + branches: [main] + +jobs: + mkdocs: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install docs dependencies + run: | + pip install mkdocs + + - name: Generate index.md from README.md + run: | + cp README.md docs/index.md + sed -i 's|docs/guide.md|guide/|g' docs/index.md + mkdir docs/media + cp media/logo.png docs/media/logo.png + + - name: Build mkdocs site + run: mkdocs build + + - name: Deploy site to gh-pages branch + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31910b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea/ +*.egg-info/ +.env +__pycache__/ +.coverage +dist/ +coverage.xml diff --git a/LICENSE b/LICENSE index f288702..c29ce2f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,287 @@ - 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 -. + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..87ab033 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# eppoPynder + +[![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable) [![codecov](https://codecov.io/gh/openefsa/eppoPynder/branch/main/graph/badge.svg?token=6U3TL9T27T)](https://codecov.io/gh/openefsa/eppoPynder) + +## Overview + +**eppoPynder** provides a Python interface to the public APIs of the **European +and Mediterranean Plant Protection Organization (EPPO)** database. +The package facilitates access to a wide range of pest-related information +collected and maintained by EPPO, allowing users to query, retrieve, and +process this data directly from Python. + +The package is intended for researchers, analysts, and practitioners in plant +protection who require convenient programmatic access to EPPO data. + +## Installation + +### From PyPi + +``` +pip install eppopynder +``` + +### Development version + +To install the latest development version: + +``` +pip install git+https://github.com/openefsa/eppoPynder.git +``` + +## Requirements + +An active internet connection is required, as the package communicates with +EPPO's online services to fetch and process data. + +## Usage + +Once installed, load the package as usual: + +```python +from eppopynder import * +``` + +Basic usage examples and full documentation are available in the package +[guide](docs/guide.md). + +## Authors and maintainers + +- **Lorenzo Copelli** (author, [ORCID](https://orcid.org/0009-0002-4305-065X)). +- **Dayana Stephanie Buzle** (author, [ORCID](https://orcid.org/0009-0003-2990-7431)). +- **Rafael Vieira** (author, [ORCID](https://orcid.org/0009-0009-0289-5438)). +- **Agata Kaczmarek** (author, [ORCID](https://orcid.org/0000-0002-7463-5821)). +- **Luca Belmonte** (author, maintainer, [ORCID](https://orcid.org/0000-0002-7977-9170)). + +## Links + +- **Homepage**: [GitHub](https://github.com/openefsa/eppoPynder). +- **Bug Tracker**: [Issues on GitHub](https://github.com/openefsa/eppoPynder/issues). +- **EPPO Database**: [https://gd.eppo.int](https://gd.eppo.int). diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..d01fa18 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,342 @@ +# Introduction to eppoPynder + +## Overview + +**eppoPynder** provides a Python interface to the public APIs of the **European +and Mediterranean Plant Protection Organization (EPPO)** database. +The package facilitates access to a wide range of pest-related information +collected and maintained by EPPO, allowing users to query, retrieve, and +process this data directly from Python. + +The package is intended for researchers, analysts, and practitioners in plant +protection who require convenient programmatic access to EPPO data. + +## Installation + +### From PyPi + +```bash +$ pip install eppoPynder +``` + +### Development version + +To install the latest development version: + +```bash +$ pip install git+https://github.com/openefsa/eppoPynder.git +``` + +## Requirements + +An active internet connection is required, as the package communicates with +EPPO's online services to fetch and process data. + +## Working with API keys + +The *eppoPynder* package requires your unique API token to function properly. +You can provide this token in one of two ways: + +1. By setting it in a `.env` file. +2. By including it manually during the client initialization. + +### Setting the API key via `.env` + +A `.env` file is used to define environment variables that Python can load at +runtime. This approach is particularly convenient for sensitive information +like API keys, as it allows you to use them in any Python script or function +without hardcoding them. + +Place the `.env` file in the root directory of you project (for example, +`C:/Users/username/Documents/myProject/.env` on Windows or +`~/Documents/myProject/.env` on Unix-like systems). You can create or edit this +file with any plain text editor. + +Add your EPPO API key in the following format: + +`EPPO_API_KEY=` + +Once the file is saved, the variable will be correctly set for the library to +use during execution. + +### Setting the API key manually during client initialization + +Alternatively, you can provide the API key directly in the `api_key` argument +of client instantiation. This is useful if you prefer not to store the token +globally. For example: + +```python +from eppopynder import Client + +client = Client(api_key="") +``` + +Note that if an API key is explicitly provided, the API key set through the +`.env` file will be ignored, if any. + +## Basic usage + +The main purpose of *eppoPynder* is to query the EPPO database for specific +EPPO codes and retrieve relevant information across various endpoints. For each +endpoint, you can either: + +1. Query all available services to obtain more comprehensive data. +2. Query one or more services to obtain specific data. + +For plant and pest species, the basic information returned includes scientific +names, synonyms, common names in different languages, and taxonomic +classification. For pests of regulatory interest, you can also retrieve more +detailed information, such as host plants and quarantine categorization. + +Below are examples demonstrating how to use the functions in this package. +First, load the *eppoPynder* package: + +```python +from eppopynder import * +``` + +Then, initialize the client by specifying the API key you want to use: + +```python +client = Client() # Use the API key defined in .env file. +client = Client(api_key="") # Manually define the API key. +``` + +To explore the arguments and usage of a specific function, you can run: + +```python +help(function_name) +``` + +This will show the full documentation for the function, including its +arguments, return values, and usage examples. + +For example, if you are working with the `uniform_taxonoy()` function, +you can check its documentation with: + +```python +help(uniform_taxonomy) +``` + +Again, if you want to check the documentation of a function that represents a +category, you can run: + +```python +help(Client.taxon) +``` + +Note that the functions representing the categories are all defined inside the +`Client` class. + +## Querying a specific category + +The *eppoPynder* package allows you to query all categories available in +version 2.0 of the EPPO APIs: General, Taxons, Taxon, Country, Tools, Reporting +Services, and References. + +Each category has a corresponding function in the package with the same name +in *snake_case* format: general(), taxons(), taxon(), country(), tools(), +reporting_service(), and reportings(). By default, these functions return all +data available under the selected category, but you can customize the query by +specifying the desired services. + +For example, to query all services of the Taxon category for the EPPO code +"BEMITA", you can use the following code: + +```python +data = client.taxon(eppo_codes=["BEMITA"]) +``` + +Some services require certain mandatory parameters, which must be provided when +making the request. For example: + +```python +data = client.tools( + services=[ToolsService.NAME2CODES], + params={ + ToolsService.NAME2CODES: { + "name": "Bemisia tabaci" + } + } +) +``` + +Each category comes with a set of service names that can be queried. These are +stored in an enumeration whose name follows the convention Category + +"Service". For example, for the Taxon category, the collection of queryable +services is named `TaxonService`, and its values are `OVERVIEW`, `INFOS`, +`NAMES` and so on, exactly matching the names of the available services. +Keep in mind that services can be provided to the corresponding functions only +through their dedicated enum. + +You can import the enumeration associated with a specific category as shown +below: + +```python +from eppopynder import TaxonService, ToolsService # And so on... +``` + +Alternatively, you can import all of them at once by importing everything from +the library: + +```python +from eppopynder import * +``` + +## Querying a specific service + +To query a specific service within an endpoint, *eppoPynder* allows you to +specify it directly in the function call. For example, to access only the +`/taxons/taxon/categorization` service for the EPPO code "BEMITA", you can use +the `Client.taxon()` function as follows: + +```python +data = client.taxon( + eppo_codes=["BEMITA"], + services=[TaxonService.CATEGORIZATION] +) +``` + +### Expected output + +```python +print(data) +``` +```python +{: queried_eppo_code continent_id continent_name country_iso country_name ... year_add year_delete year_transient queried_url queried_on +0 BEMITA 4 America CL Chile ... 2019 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +1 BEMITA 2 Asia BH Bahrain ... 2003 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +2 BEMITA 2 Asia KZ Kazakhstan ... 2017 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +3 BEMITA 1 Europe AZ Azerbaijan ... 2024 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +4 BEMITA 1 Europe BY Belarus ... 1994 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +5 BEMITA 1 Europe GE Georgia ... 2018 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +6 BEMITA 1 Europe MD Moldova, Republic of ... 2017 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +7 BEMITA 1 Europe NO Norway ... 2012 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +8 BEMITA 1 Europe RU Russian Federation (the) ... 2014 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +9 BEMITA 1 Europe RS Serbia ... 2015 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +10 BEMITA 1 Europe CH Switzerland ... 2019 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +11 BEMITA 1 Europe TR Türkiye ... 2016 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +12 BEMITA 1 Europe UA Ukraine ... 2019 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +13 BEMITA 1 Europe GB United Kingdom ... 2020 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +14 BEMITA 5 Oceania NZ New Zealand ... 2000 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +15 BEMITA 6 RPPO/EU 9M EAEU ... 2016 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +16 BEMITA 6 RPPO/EU 9A EPPO ... 1989 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +17 BEMITA 6 RPPO/EU 9L EU ... 2019 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +18 BEMITA 6 RPPO/EU 9L EU ... 2019 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 +19 BEMITA 6 RPPO/EU 9H OIRSA ... 1992 None None https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 14:58:20.605414 + +[20 rows x 12 columns]} +``` + +All function calls return a dictionary of data frames, each containing the +information from the requested services. + +## Querying all available services + +To fetch data from all available services within a specific endpoint, simply +use the corresponding function without modifying the `services` parameter. Each +function will return a dictionary of data frames, with each data frame +containing information from one service. + +```python +data = client.taxon(eppo_codes=["BEMITA"]) +``` + +### Expected output + +```python +# Print the names of the services in the result. +print(data.keys()) +``` +```python +dict_keys([, , , , , , , , , , , , , , , , ]) +``` + +```python +# Print the data from a specific service. +print(data[TaxonService.OVERVIEW]) +``` +```python + queried_eppo_code eppocode datecreate lastupdate infos replacedby prefname is_active datatype queried_url queried_on +0 BEMITA BEMITA 2002-10-28 00:00:00 2002-10-28 00:00:00 None None Bemisia tabaci True GAI https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:08:26.388685 +``` + +Using `data.keys()` will display the names of the queried services. You can +then access a specific service to view the data associated with it. + +## Data wrangling + +The *eppoPynder* package provides a convenient function, `uniform_taxonomy()`, +to create a complete and standardized taxonomy data frame. This function takes +the taxonomy data returned by the taxonomy service and ensures a uniform +structure, including all expected taxonomic ranks, even if some ranks are +missing in the original result. + +For example, assume you have retrieved taxonomy data for the EPPO code "BEMITA" +using the `Client.taxon()` function: + +```python +data = client.taxon(eppo_codes=["BEMITA"], services=[TaxonService.TAXONOMY]) +``` + +You can then generate a uniform taxonomy table with all ranks using: + +```python +taxonomy = uniform_taxonomy(data[TaxonService.TAXONOMY]) +``` + +### Expected output + +```python +print(taxonomy) +``` +```python + type queried_eppo_code eppocode prefname queried_url queried_on +0 Kingdom BEMITA 1ANIMK Animalia https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +1 Phylum BEMITA 1ARTHP Arthropoda https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +2 Subphylum BEMITA 1HEXAQ Hexapoda https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +3 Class BEMITA 1INSEC Insecta https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +4 Subclass NaN NaN NaN NaN NaT +5 Order BEMITA 1HEMIO Hemiptera https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +6 Suborder BEMITA 1STERR Sternorrhyncha https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +7 Family BEMITA 1ALEYF Aleyrodidae https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +8 Subfamily NaN NaN NaN NaN NaT +9 Genus BEMITA 1BEMIG Bemisia https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +10 Species BEMITA BEMITA Bemisia tabaci https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:26:07.594529 +``` + +## Data retrieval for multiple EPPO or ISO codes + +The `Client.taxon()` and `Client.country()` functions allow you to retrieve +also a complete data dump for multiple EPPO or ISO codes by querying all +available EPPO services for each of them. + +Here is an example showing how to use the function to fetch all available +information for the EPPO codes "BEMITA" and "GOSHI": + +```python +data = client.taxon(eppo_codes=["BEMITA", "GOSHI"]) +``` + +### Expected output + +```python +# Print the names of the services in the result. +print(data.keys()) +``` +```python +dict_keys([, , , , , , , , , , , , , , , , ]) +``` + +```python +# Print the data from a specific service. +print(data[TaxonService.OVERVIEW]) +``` +```python + queried_eppo_code eppocode datecreate lastupdate ... is_active datatype queried_url queried_on +0 BEMITA BEMITA 2002-10-28 00:00:00 2002-10-28 00:00:00 ... True GAI https://api.eppo.int/gd/v2/taxons/taxon/BEMITA... 2025-12-10 15:31:54.300453 +1 GOSHI GOSHI 2004-04-21 00:00:00 2004-04-21 00:00:00 ... True PFL https://api.eppo.int/gd/v2/taxons/taxon/GOSHI/... 2025-12-10 15:31:54.864112 +``` + +Using `data.keys()` will display the names of the queried services. You can +then access a specific service to view the data associated with it. diff --git a/media/logo.png b/media/logo.png new file mode 100644 index 0000000..4d48006 Binary files /dev/null and b/media/logo.png differ diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..25eaa24 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,13 @@ +site_name: eppoPynder +site_description: Python interface to the EPPO Database and Public APIs +site_url: https://openefsa.github.io/eppoPynder +repo_url: https://github.com/openefsa/eppoPynder +docs_dir: docs +site_dir: site + +theme: + name: mkdocs + +nav: + - Home: 'index.md' + - 'Get started': 'guide.md' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1d6c405 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "eppoPynder" +version = "2.0.0" +description = "Python interface to the EPPO Database and Public APIs" +readme = "README.md" +authors = [ + { name = "Lorenzo Copelli" }, + { name = "Dayana Stephanie Buzle" }, + { name = "Rafael Vieira" }, + { name = "Agata Kaczmarek" }, + { name = "Luca Belmonte" } +] +maintainers = [ + { name = "Luca Belmonte", email = "luca.belmonte@efsa.europa.eu" } +] +license = "EUPL-1.2" +license-files = ["LICENSE"] +requires-python = ">=3.11" +dependencies = [ + "numpy>=2.0", + "pandas>=2.2", + "requests>=2.32", + "python-dotenv>=1.0" +] + +[project.optional-dependencies] +dev = [ + "coverage>=7.6", + "pytest>=8.0", +] + +[project.urls] +"Homepage" = "https://github.com/openefsa/eppoPynder" +"Repository" = "https://github.com/openefsa/eppoPynder" +"Bug Tracker" = "https://github.com/openefsa/eppoPynder/issues" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a8a6749 Binary files /dev/null and b/requirements.txt differ diff --git a/src/eppopynder/__init__.py b/src/eppopynder/__init__.py new file mode 100644 index 0000000..9191fd5 --- /dev/null +++ b/src/eppopynder/__init__.py @@ -0,0 +1,23 @@ +from .client import Client +from .data_wrangling import uniform_taxonomy +from ._core._general import GeneralService +from ._core._taxons import TaxonsService +from ._core._taxon import TaxonService +from ._core._country import CountryService +from ._core._rppo import RPPOService +from ._core._tools import ToolsService +from ._core._reporting_service import ReportingServiceService +from ._core._references import ReferencesService + +__all__ = [ + "Client", + "GeneralService", + "TaxonsService", + "TaxonService", + "CountryService", + "RPPOService", + "ToolsService", + "ReportingServiceService", + "ReferencesService", + "uniform_taxonomy" +] diff --git a/src/eppopynder/_core/__init__.py b/src/eppopynder/_core/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/src/eppopynder/_core/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/src/eppopynder/_core/_country.py b/src/eppopynder/_core/_country.py new file mode 100644 index 0000000..5f96b38 --- /dev/null +++ b/src/eppopynder/_core/_country.py @@ -0,0 +1,69 @@ +"""This module contains core functions for working with the Country endpoint of +the EPPO API. +""" + +from enum import StrEnum + +from eppopynder._utils import _checks, _requests, _data + + +class CountryService(StrEnum): + """The list of services supported by the Country endpoint.""" + OVERVIEW = "overview" + CATEGORIZATION = "categorization" + PRESENCE = "presence" + + +def _country(api_key, iso_codes, services=None): + """Query the EPPO API Country endpoint. + + This internal function queries the Country endpoints of the EPPO Global + Database via REST API for one or more ISO code(s) and one or more + service(s). For each ISO code in `iso_codes`, the function sequentially + queries all specified `services` and returns the extracted data through a + list of dataframes. + + Args: + api_key (str): The API key used for authentication. + iso_codes (list[str]): One or more ISO codes to query. + services (list[CountryService], optional): One or more Country services + to query. A validation step ensures that all provided services are + of type `CountryService` and match the supported service names. If + not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content for all the specified ISO codes. + """ + + _checks._require_type(value=api_key, expected_type=str) + _checks._require_type(value=iso_codes, expected_type=list) + _checks._require_list_of(items=iso_codes, expected_type=str) + if services is None: + services = list(CountryService) + _checks._require_type(value=services, expected_type=list) + _checks._check_services( + services=services, + choices=list(CountryService) + ) + + country_data_ = { + service_: { + iso_code_: _requests._fetch_service( + base_path='/country', + api_key=api_key, + code=iso_code_, + service=service_ + ) + for iso_code_ in iso_codes + } + for service_ in services + } + + country_data_ = _data._merge_batch( + datasets=country_data_, + parent_column_name="queried_iso_code" + ) + + return country_data_ diff --git a/src/eppopynder/_core/_general.py b/src/eppopynder/_core/_general.py new file mode 100644 index 0000000..75f522b --- /dev/null +++ b/src/eppopynder/_core/_general.py @@ -0,0 +1,53 @@ +"""This module contains core functions for working with the General endpoint of +the EPPO API. +""" + +from enum import StrEnum + +from eppopynder._utils import _checks, _requests + + +class GeneralService(StrEnum): + """The list of services supported by the General endpoint.""" + STATUS = "status" + + +def _general(api_key, services=None): + """Query the EPPO API General endpoint. + + This internal function queries the General endpoints of the EPPO Global + Database via REST API. The function sequentially queries all specified + `services` and returns the extracted data. + + Args: + api_key (str): The API key used for authentication. + services (list[GeneralService], optional): One or more General services + to query. A validation step ensures that all provided services are + of type `GeneralService` and match the supported service names. If + not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content. + """ + + _checks._require_type(value=api_key, expected_type=str) + if services is None: + services = list(GeneralService) + _checks._require_type(value=services, expected_type=list) + _checks._check_services( + services=services, + choices=list(GeneralService) + ) + + general_data_ = { + service_: _requests._fetch_service( + base_path='/', + api_key=api_key, + service=service_ + ) + for service_ in services + } + + return general_data_ diff --git a/src/eppopynder/_core/_references.py b/src/eppopynder/_core/_references.py new file mode 100644 index 0000000..d364807 --- /dev/null +++ b/src/eppopynder/_core/_references.py @@ -0,0 +1,63 @@ +"""This module contains core functions for working with the References endpoint +of the EPPO API. +""" + +from enum import StrEnum + +from eppopynder._utils import _checks, _requests, _data + + +class ReferencesService(StrEnum): + """The list of services supported by the References endpoint.""" + RPPOS = "rppos" + Q_LIST = "qList" + DISTRIBUTION_STATUS = "distributionStatus" + PEST_HOST_CLASSIFICATION = "pestHostClassification" + VECTOR_CLASSIFICATION = "vectorClassification" + COUNTRIES = "countries" + COUNTRIES_STATES = "countriesStates" + + +def _references(api_key, services=None): + """Query the EPPO API References endpoint. + + This internal function queries the References endpoints of the EPPO Global + Database via REST API. The function sequentially queries all specified + `services` and returns the extracted data through a dictionary of data + frames. + + Args: + api_key (str): The API key used for authentication. + services (list[ReferencesService], optional): One or more References + services to query. A validation step ensures that all provided + services are of type `ReferencesService` and match the supported + service names. If not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content. + """ + + _checks._require_type(value=api_key, expected_type=str) + if services is None: + services = list(ReferencesService) + _checks._require_type(value=services, expected_type=list) + _checks._check_services( + services=services, + choices=list(ReferencesService) + ) + + references_data_ = { + service_: _requests._fetch_service( + base_path='/references', + api_key=api_key, + service=service_ + ) + for service_ in services + } + + references_data_ = _data._transform_references( + references_data=references_data_) + + return references_data_ diff --git a/src/eppopynder/_core/_reporting_service.py b/src/eppopynder/_core/_reporting_service.py new file mode 100644 index 0000000..216a742 --- /dev/null +++ b/src/eppopynder/_core/_reporting_service.py @@ -0,0 +1,65 @@ +"""This module contains core functions for working with the Reporting Service +endpoint of the EPPO API. +""" + +from enum import StrEnum + +from eppopynder._utils import _checks, _requests + + +class ReportingServiceService(StrEnum): + """The list of services supported by the Reporting Service endpoint.""" + LIST = "list" + REPORTING = "reporting" + ARTICLE = "article" + + +def _reporting_service(api_key, services=None, params=None): + """Query the EPPO API Reporting Service endpoint. + + This internal function queries the Reporting Service endpoints of the EPPO + Global Database via REST API. The function sequentially queries all + specified `services` and returns the extracted data through a dictionary of + data frames. + + Args: + api_key (str): The API key used for authentication. + services (list[ReportingServiceService], optional): One or more + Reporting Service services to query. A validation step ensures that + all provided services are of type `ReportingServiceService` and + match the supported service names. If not provided, all services + are considered. + params (dict, optional): A named dictionary of query parameters to + include in the request. The list of available parameters can be + accessed via the EPPO API Documentation platform + (https://data2025.eppo.int/ui/#/docs/GDAPI). + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content. + """ + + _checks._require_type(value=api_key, expected_type=str) + if services is None: + services = list(ReportingServiceService) + _checks._require_type(value=services, expected_type=list) + _checks._check_services(services=services, + choices=list(ReportingServiceService)) + if params is not None: + _checks._require_type(value=params, expected_type=dict) + + reporting_service_data_ = { + service_: _requests._fetch_service( + base_path="/reportings", + api_key=api_key, + service=_requests._build_reporting_service_path( + service=service_, + params=params.get(service_, None) + if params is not None else None + ) + ) + for service_ in services + } + + return reporting_service_data_ diff --git a/src/eppopynder/_core/_rppo.py b/src/eppopynder/_core/_rppo.py new file mode 100644 index 0000000..493abef --- /dev/null +++ b/src/eppopynder/_core/_rppo.py @@ -0,0 +1,68 @@ +"""This module contains core functions for working with the RPPO endpoint of +the EPPO API. +""" + +from enum import StrEnum + +from eppopynder._utils import _checks, _requests, _data + + +class RPPOService(StrEnum): + """The list of services supported by the RPPO endpoint.""" + OVERVIEW = "overview" + CATEGORIZATION = "categorization" + + +def _rppo(api_key, rppo_codes, services=None): + """Query the EPPO API RPPO endpoint. + + This internal function queries the RPPO endpoints of the EPPO Global + Database via REST API for one or more RPPO code(s) and one or more + service(s). For each RPPO code in `rppo_codes`, the function sequentially + queries all specified `services` and returns the extracted data through a + list of dataframes. + + Args: + api_key (str): The API key used for authentication. + rppo_codes (list[str]): One or more RPPO codes to query. + services (list[CountryService], optional): One or more RPPO services + to query. A validation step ensures that all provided services are + of type `RPPOService` and match the supported service names. If + not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content for all the specified RPPO codes. + """ + + _checks._require_type(value=api_key, expected_type=str) + _checks._require_type(value=rppo_codes, expected_type=list) + _checks._require_list_of(items=rppo_codes, expected_type=str) + if services is None: + services = list(RPPOService) + _checks._require_type(value=services, expected_type=list) + _checks._check_services( + services=services, + choices=list(RPPOService) + ) + + rppo_data_ = { + service_: { + rppo_code_: _requests._fetch_service( + base_path='/rppo', + api_key=api_key, + code=rppo_code_, + service=service_ + ) + for rppo_code_ in rppo_codes + } + for service_ in services + } + + rppo_data_ = _data._merge_batch( + datasets=rppo_data_, + parent_column_name="queried_rppo_code" + ) + + return rppo_data_ diff --git a/src/eppopynder/_core/_taxon.py b/src/eppopynder/_core/_taxon.py new file mode 100644 index 0000000..61b82e4 --- /dev/null +++ b/src/eppopynder/_core/_taxon.py @@ -0,0 +1,82 @@ +"""This module contains core functions for working with the Taxon endpoint of +the EPPO API. +""" + +from enum import StrEnum + +from eppopynder._utils import _checks, _requests, _data + + +class TaxonService(StrEnum): + """The list of services supported by the Taxon endpoint.""" + OVERVIEW = "overview" + INFOS = "infos" + NAMES = "names" + TAXONOMY = "taxonomy" + CATEGORIZATION = "categorization" + KINGDOM = "kingdom" + HOSTS = "hosts" + PESTS = "pests" + VECTORS = "vectors" + VECTOR_OF = "vectorof" + BCA = "bca" + BCA_OF = "bcaof" + PHOTOS = "photos" + REPORTING_ARTICLES = "reporting_articles" + DOCUMENTS = "documents" + STANDARDS = "standards" + DISTRIBUTION = "distribution" + + +def _taxon(api_key, eppo_codes, services=None): + """Query the EPPO API Taxon endpoint. + + This internal function queries the Taxon endpoints of the EPPO Global + Database via REST API for one or more EPPO code(s) and one or more + service(s). For each EPPO code in `eppo_codes`, the function sequentially + queries all specified `services` and returns the extracted data. + + Args: + api_key (str): The API key used for authentication. + eppo_codes (list[str]): One or more EPPO codes to query. + services (list[TaxonService], optional): One or more Taxon services + to query. A validation step ensures that all provided services are + of type `TaxonService` and match the supported service names. If + not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content for all the specified EPPO codes. + """ + + _checks._require_type(value=api_key, expected_type=str) + _checks._require_type(value=eppo_codes, expected_type=list) + _checks._require_list_of(items=eppo_codes, expected_type=str) + if services is None: + services = list(TaxonService) + _checks._require_type(value=services, expected_type=list) + _checks._check_services( + services=services, + choices=list(TaxonService) + ) + + taxon_data_ = { + service_: { + eppo_code_: _requests._fetch_service( + base_path='/taxons/taxon', + api_key=api_key, + code=eppo_code_, + service=service_ + ) + for eppo_code_ in eppo_codes + } + for service_ in services + } + + taxon_data_ = _data._merge_batch( + datasets=taxon_data_, + parent_column_name="queried_eppo_code" + ) + + return taxon_data_ diff --git a/src/eppopynder/_core/_taxons.py b/src/eppopynder/_core/_taxons.py new file mode 100644 index 0000000..6c8727b --- /dev/null +++ b/src/eppopynder/_core/_taxons.py @@ -0,0 +1,60 @@ +"""This module contains core functions for working with the Taxons endpoint of +the EPPO API. +""" + +from enum import StrEnum + +from eppopynder._utils import _checks, _requests, _data + + +class TaxonsService(StrEnum): + """The list of services supported by the Taxons endpoint.""" + LIST = "list" + + +def _taxons(api_key, services=None, params=None): + """Query the EPPO API Taxons endpoint. + + This internal function queries the Taxons endpoints of the EPPO Global + Database via REST API. The function sequentially queries all specified + `services` and returns the extracted data. + + Args: + api_key (str): The API key used for authentication. + services (list[TaxonsService], optional): One or more Taxons services + to query. A validation step ensures that all provided services are + of type `TaxonsService` and match the supported service names. If + not provided, all services are considered. + params (dict, optional): A named dictionary of query parameters to + include in the request. The list of available parameters can be + accessed via the EPPO API Documentation platform + (https://data2025.eppo.int/ui/#/docs/GDAPI). + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content. + """ + + _checks._require_type(value=api_key, expected_type=str) + if services is None: + services = list(TaxonsService) + _checks._require_type(value=services, expected_type=list) + _checks._check_services(services=services, choices=list(TaxonsService)) + if params is not None: + _checks._require_type(value=params, expected_type=dict) + + taxons_data_ = { + service_: _requests._fetch_service( + base_path="/taxons", + api_key=api_key, + service=service_, + params=params.get(service_, None) + if params is not None else None + ) + for service_ in services + } + + taxons_data_ = _data._transform_taxons(taxons_data=taxons_data_) + + return taxons_data_ diff --git a/src/eppopynder/_core/_tools.py b/src/eppopynder/_core/_tools.py new file mode 100644 index 0000000..73eabe7 --- /dev/null +++ b/src/eppopynder/_core/_tools.py @@ -0,0 +1,59 @@ +"""This module contains core functions for working with the Tools endpoint of +the EPPO API. +""" + +from enum import StrEnum + +from eppopynder._utils import _checks, _requests + + +class ToolsService(StrEnum): + """The list of services supported by the Tools endpoint.""" + NAME2CODES = "name2codes" + + +def _tools(api_key, services=None, params=None): + """Query the EPPO API Tools endpoint. + + This internal function queries the Tools endpoints of the EPPO Global + Database via REST API. The function sequentially queries all specified + `services` and returns the extracted data through a dictionary of data + frames. + + Args: + api_key (str): The API key used for authentication. + services (list[ToolsService], optional): One or more Tools services to + query. A validation step ensures that all provided services are of + type `ToolsService` and match the supported service names. If not + provided, all services are considered. + params (dict, optional): A named dictionary of query parameters to + include in the request. The list of available parameters can be + accessed via the EPPO API Documentation platform + (https://data2025.eppo.int/ui/#/docs/GDAPI). + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content. + """ + + _checks._require_type(value=api_key, expected_type=str) + if services is None: + services = list(ToolsService) + _checks._require_type(value=services, expected_type=list) + _checks._check_services(services=services, choices=list(ToolsService)) + if params is not None: + _checks._require_type(value=params, expected_type=dict) + + tools_data_ = { + service_: _requests._fetch_service( + base_path="/tools", + api_key=api_key, + service=service_, + params=params.get(service_, None) + if params is not None else None + ) + for service_ in services + } + + return tools_data_ diff --git a/src/eppopynder/_utils/__init__.py b/src/eppopynder/_utils/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/src/eppopynder/_utils/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/src/eppopynder/_utils/_checks.py b/src/eppopynder/_utils/_checks.py new file mode 100644 index 0000000..93d6c82 --- /dev/null +++ b/src/eppopynder/_utils/_checks.py @@ -0,0 +1,147 @@ +"""This module contains internal functions for performing type and data checks. +""" + +import pandas as pd + + +def _require_type(value, expected_type): + """Check that a value is of the expected type. + + Args: + value: The value to check. + expected_type: The expected type. + + Raises: + TypeError: If the value is not of the expected type. + + Returns: + None: The function returns nothing if the check passes. + """ + + if not isinstance(value, expected_type): + raise TypeError(f"Expected type {expected_type}, got {type(value)}") + + +def _require_trailing_slash(string): + """Check that a string starts with a trailing slash. + + Args: + string (str): The string value to check. + + Raises: + ValueError: If the string is not starting with a trailing slash. + + Returns: + None: The function returns nothing if the check passes. + """ + + _require_type(value=string, expected_type=str) + + if not string.startswith('/'): + raise ValueError(f"Expected trailing slash, got {string}") + + +def _require_list_of(items, expected_type): + """Check that a list contains only elements of the expected type. + + Args: + items (list): The list to check. + expected_type (type): The expected type of the elements. + + Raises: + TypeError: If at least one element of the list is not of the expected + type. + + Returns: + None: The function returns nothing if the check passes. + """ + + _require_type(value=items, expected_type=list) + _require_type(value=expected_type, expected_type=type) + + for item_ in items: + if not isinstance(item_, expected_type): + raise TypeError(f"Expected list of {expected_type}") + + +def _check_services(services, choices): + """Validate service names against a set of allowed choices. + + This function checks whether all elements in `services` are included in the + allowed `choices`. If any unsupported services are found, the function + raises an informative exception. + + Args: + services (list): A list of service names to validate. + choices (list): A list containing the allowed service names. + + Raises: + ValueError: If any unsupported services are found. + + Returns: + None: The function returns nothing if the check passes. + """ + + _require_type(value=services, expected_type=list) + _require_list_of(items=services, expected_type=type(choices[0])) + _require_type(value=choices, expected_type=list) + + invalid_services_ = set(services) - set(choices) + + if len(invalid_services_) > 0: + raise ValueError("Unsupported services requested: " + + f"{', '.join(invalid_services_)}") + + +def _require_column_names(dataframe, column_names): + """Check if a dataframe contains the specified column names. + + This function checks whether a dataframe contains the specified column + names. If the condition is not verified, the function raises an informative + exception. + + Args: + dataframe (pd.DataFrame): The dataframe to validate. + column_names (list): The list of required column names. + + Raises: + ValueError: If the required column names are not present in the + dataframe. + + Returns: + None: The function returns nothing if the check passes. + """ + + _require_type(value=dataframe, expected_type=pd.DataFrame) + _require_type(value=column_names, expected_type=list) + + if not all(column_ in dataframe.columns for column_ in column_names): + raise ValueError("Missing required columns in dataframe: " + + f"{', '.join(column_names)}") + + +def _require_not_all_nan(dataframe, column_name): + """Check if a dataframe contains the specified column names. + + This function checks whether a dataframe contains the specified column + names. If the condition is not verified, the function raises an informative + exception. + + Args: + dataframe (pd.DataFrame): The dataframe to check. + column_name (str): The name of the column to check. + + Raises: + ValueError: If the specified column contains only NaN values. + + Returns: + None: The function returns nothing if the check passes. + """ + + _require_type(value=dataframe, expected_type=pd.DataFrame) + _require_type(value=column_name, expected_type=str) + + if (not column_name in dataframe.columns + or not dataframe[column_name].notna().any()): + raise ValueError(f"Column {column_name} must contain at least a " + + "non-NaN value.") diff --git a/src/eppopynder/_utils/_data.py b/src/eppopynder/_utils/_data.py new file mode 100644 index 0000000..a9ab186 --- /dev/null +++ b/src/eppopynder/_utils/_data.py @@ -0,0 +1,275 @@ +"""This module contains internal functions for processing API response data.""" + +import pandas as pd + +from eppopynder._utils import _checks +from eppopynder._core import _taxons, _references + + +def _flatten(nested_data, separator='_'): + """Flattens nested JSON structures into a pandas DataFrame. + + This helper function automatically expands lists into multiple rows and + flattens nested dictionaries. + + Args: + nested_data: The dictionary or list of dictionaries to flatten. + separator (str): The separator to use for nested columns names. + Defaults to '_'. + + Raises: + TypeError: If data is not a dictionary or a list of dictionaries. Data + can be empty. + + Returns: + pandas.DataFrame: A flattened DataFrame, where: + - Lists are expanded into multiple rows. + - Nested dictionaries become columns with separator-notation. + - Parent-level fields are repeated for each child row. + """ + + def _flatten_dictionary(dictionary, sep, parent_key=''): + """Flatten a nested dictionary without expanding lists. + + This helper function recursively traverses a dictionary and creates + flat key-value pairs for nested keys, using the specified separator. + Lists are kept as-is and not expanded by this function. + + Args: + dictionary (dict): The dictionary to flatten. + sep (str): The separator to use between parent and child keys. + parent_key (str, optional): The parent key for the flattened + dictionary. It is the prefix to prepend to keys (used in + recursion). Defaults to ''. + + Returns: + dict: A flattened dictionary with sep-notated keys for nested + structures. + """ + + dictionary_items_ = [] + + for key_, value_ in dictionary.items(): + new_key_ = f"{parent_key}{sep}{key_}" if parent_key else key_ + if isinstance(value_, dict): + dictionary_items_.extend( + _flatten_dictionary(dictionary=value_, sep=sep, + parent_key=new_key_).items()) + else: + dictionary_items_.append((new_key_, value_)) + + return dict(dictionary_items_) + + def _is_dictionary_of_lists(dictionary): + """Check if a dictionary contains only lists as values. + + This helper function identifies the special case where the top-level + dictionary keys represent categories amd all values are lists. + + Args: + dictionary (dict): The dictionary to check. + + Returns: + bool: True if all values in the dictionary are lists, False + otherwise. + """ + + return all(isinstance(value_, list) for value_ in dictionary.values()) + + def _expand_dictionary_of_lists(dictionary, sep): + """Expand a dictionary where all values are lists. + + This helper function handles the special case where each top-level key + maps to a list of items. It creates a "key" column to preserve the + original dictionary key and expands each list item into a separate row. + + Args: + dictionary (dict): The dictionary where all values are lists. + sep (str): The separator for flattening nested dictionaries within + list items. + + Returns: + pandas.DataFrame: DataFrame with a "key" column containing the + original dictionary key and additional columns from the list + items. + """ + + expanded_rows_ = [] + + for key_, value_ in dictionary.items(): + for item_ in value_: + if isinstance(item_, dict): + row_ = {"parent_key": key_} + flat_item_ = _flatten_dictionary(dictionary=item_, sep=sep) + row_.update(flat_item_) + expanded_rows_.append(row_) + else: + expanded_rows_.append({"parent_key": key_, + "atomic_value": item_}) + + return pd.DataFrame(expanded_rows_) + + def _find_list_columns(dictionary): + """Finds dictionary keys that contain lists of dictionaries. + + This helper function identifies columns that need to be expanded into + multiple rows. It only considers lists that contain at least one + dictionary. + + Args: + dictionary (dict): The dictionary to check. + + Returns: + list: The list of keys whose values are lists of dictionaries. + """ + + list_keys_ = [] + + for key_, value_ in dictionary.items(): + if (isinstance(value_, list) and value_ + and any(isinstance(item_, dict) for item_ in value_)): + list_keys_.append(key_) + + return list_keys_ + + def _expand_lists(dictionary, sep): + """Recursively expand lists in a dictionary into DataFrame rows. + + This helper is the main recursive function that handles the expansion + logic. It detects different types of structures and applies the + appropriate expansion strategy. It processes one list level at a time + and recurses for additional nested lists. + + Args: + dictionary (dict): The dictionary to process. + sep (str): The separator for nested column names. + + Returns: + pandas.DataFrame: A DataFrame with all lists expanded into rows and + nested dictionaries flattened. + """ + + if _is_dictionary_of_lists(dictionary=dictionary): + return _expand_dictionary_of_lists(dictionary=dictionary, sep=sep) + + list_keys_ = _find_list_columns(dictionary=dictionary) + + if not list_keys_: + flat_ = _flatten_dictionary(dictionary=dictionary, sep=sep) + return pd.DataFrame([flat_]) + + list_key_ = list_keys_[0] + list_data_ = dictionary[list_key_] + + base_data_ = {key_: value_ for key_, value_ in dictionary.items() + if key_ != list_key_} + + expanded_rows_ = [] + + for item_ in list_data_: + row_data_ = base_data_.copy() + + if isinstance(item_, dict): + for key_, value_ in item_.items(): + row_data_[f"{list_key_}{sep}{key_}"] = value_ + else: + row_data_[list_key_] = item_ + + expanded_rows_.append(_expand_lists(dictionary=row_data_, sep=sep)) + + return pd.concat(expanded_rows_, ignore_index=True) + + if not nested_data: + return pd.DataFrame() + + elif (isinstance(nested_data, list) + and all(isinstance(item_, dict) for item_ in nested_data)): + dataframes_ = [_expand_lists(dictionary=item_, sep=separator) + for item_ in nested_data] + return pd.concat(dataframes_, ignore_index=True) + + elif isinstance(nested_data, dict): + return _expand_lists(dictionary=nested_data, sep=separator) + + raise TypeError("Data must empty, a dictionary or a list of dictionaries") + + +def _transform_taxons(taxons_data): + """Transform EPPO Taxons response data. + + This helper function restructures the raw output returned by the EPPO + Taxons endpoint for each queried service. Depending on the service name, + the function applies the appropriate post-processing steps. + + Args: + taxons_data (dict): The response from the EPPO Taxons endpoint. + + Returns: + dict: The transformed dictionary. + """ + + _checks._require_type(value=taxons_data, expected_type=dict) + + for service_, dataframe_ in taxons_data.items(): + if service_ == _taxons.TaxonsService.LIST: + dataframe_.drop(columns=[ + column_ for column_ in dataframe_.columns + if column_.startswith("pagination") + or column_.startswith("meta") + ], inplace=True, errors="ignore") + + return taxons_data + + +def _transform_references(references_data): + """Transform EPPO References response data. + + This helper function restructures the raw output returned by the EPPO + References endpoint for each queried service. Depending on the service + name, the function applies the appropriate post-processing steps. + + Args: + references_data (dict): The response from the EPPO References endpoint. + + Returns: + dict: The transformed dictionary. + """ + + _checks._require_type(value=references_data, expected_type=dict) + + for service_, dataframe_ in references_data.items(): + if service_ == _references.ReferencesService.COUNTRIES_STATES: + dataframe_.rename(columns={"parent_key": "country_iso"}, + inplace=True) + + return references_data + + +def _merge_batch(datasets, parent_column_name): + """Merge results containing more EPPO or ISO codes. + + This helper function merges results + + Args: + datasets (dict): The response from the EPPO Taxon or Country endpoints. + parent_column_name (str): The name of the new column that will contain + the EPPO or ISO code. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a data + frame with the queried content for all the specified EPPO or ISO + codes, along with a new column named `parent_column_name`. + """ + + _checks._require_type(value=datasets, expected_type=dict) + _checks._require_type(value=parent_column_name, expected_type=str) + + for service_, value_dictionary_ in datasets.items(): + dataframes_ = [] + for code_, dataframe_ in value_dictionary_.items(): + dataframe_.insert(0, parent_column_name, code_) + dataframes_.append(dataframe_) + datasets[service_] = pd.concat(dataframes_, ignore_index=True) + + return datasets diff --git a/src/eppopynder/_utils/_requests.py b/src/eppopynder/_utils/_requests.py new file mode 100644 index 0000000..c691495 --- /dev/null +++ b/src/eppopynder/_utils/_requests.py @@ -0,0 +1,309 @@ +"""This module contains internal functions for working with EPPO API requests. +""" + +import pandas as pd +import requests +from datetime import datetime +from json import JSONDecodeError + +from eppopynder._utils import _checks +from eppopynder._utils import _data + + +def _build_endpoint(base_path, code=None, service=None): + """Build an EPPO API endpoint path. + + This helper function constructs an endpoint path for retrieving data from + the EPPO API. The result must be appended to the EPPO API base URL. It + allows for the optional inclusion of a specific code and/or service name, + depending on the desired API resource. The function is based on the fact + that EPPO API endpoints follow the pattern: + {base path}/{resource identifier}/{service}. + + Args: + base_path (str): The base path, starting with '/' (e.g. + "/taxons/taxon"). + code (str, optional): The resource identifier (e.g. an EPPO code or + an ISO code). If provided, it will be appended to the base path. + service (str, optional): The desired API service. If provided, it will + be appended to the path after the resource identifier (if any, + otherwise after the base path). + + Returns: + str: A string representing the complete endpoint path to be used in an + API request. + """ + + _checks._require_type(value=base_path, expected_type=str) + _checks._require_trailing_slash(string=base_path) + if code is not None: + _checks._require_type(value=code, expected_type=str) + if service is not None: + _checks._require_type(value=service, expected_type=str) + + endpoint_parts_ = [base_path] + [part_ for part_ in (code, service) + if part_ is not None] + endpoint_path_ = '/'.join(endpoint_parts_) + endpoint_path_ = endpoint_path_.replace("//", '/') + + return endpoint_path_ + + +def _build_reporting_service_path(service, params = None): + """Build the EPPO reporting-related service path. + + This helper function constructs the endpoint path for EPPO API reporting + services based on the service type and parameters provided in the `params` + dictionary. It also handles missing or invalid parameters by raising + informative errors. + + Args: + service (str): The type of service. Supported values are `reporting` + and `article`. + params (dict, optional): A dictionary containing identifiers needed for + the endpoint path. Must include `reporting_id` if service is + "reporting" or `article_id` if service is "article". + + Raises: + ValueError: If a required parameter is missing or invalid for the + specified service. + + Returns: + str: The constructed service path to use with API calls. + """ + + _checks._require_type(value=service, expected_type=str) + if params is not None: + _checks._require_type(value=params, expected_type=dict) + + required_ids_ = { + "reporting": "reporting_id", + "article": "article_id", + } + + required_id_name_ = required_ids_.get(service) + + if required_id_name_ is not None: + if params is None or not params.get(required_id_name_): + raise ValueError("Missing required parameter " + + f"\"{required_id_name_}\"") + + service = f"{service}/{params[required_id_name_]}" + + return service + + +def _perform_request(url, api_key, params=None): + """Build and execute an HTTP GET request to the EPPO API. + + This helper function prepares and sends a GET request to the EPPO API, + setting the necessary headers, authentication key and query parameters. It + then performs the request and returns the corresponding response data. + + Args: + url (str): The full API endpoint URL to query. + api_key (str): The API key used for authentication. + params (dict, optional): Optional query parameters to include in the + request. + + Returns: + class (requests.Response): The HTTP response object returned by + the request. + """ + + _checks._require_type(value=url, expected_type=str) + _checks._require_type(value=api_key, expected_type=str) + if params is not None: + _checks._require_type(value=params, expected_type=dict) + + request_headers_ = { + "X-Api-Key": api_key, + "Accept": "application/json" + } + + response_ = requests.get(url, headers=request_headers_, params=params) + + return response_ + + +def _handle_http_errors(response): + """Handle non-successful HTTP responses from the EPPO API. + + This helper function checks whether an HTTP response from the EPPO API + indicates success (status code 200). If the response contains any other + status code, it attempts to parse the JSON body for an error message and + raises a formatted error. + + Args: + response (requests.Response): The HTTP response object. + + Raises: + requests.exceptions.HTTPError: If the request was not successful. + + Returns: + None: The function returns nothing if the request was successful. + """ + + _checks._require_type(value=response, expected_type=requests.Response) + + handled_error_status_codes_ = [400, 401, 403, 404, 429, 500] + + if response.status_code == 200: + return + + if response.status_code in handled_error_status_codes_: + try: + error_message_ = response.json().get("error", "Unknown error") + except JSONDecodeError as e_: + error_message_ = e_.msg + raise requests.HTTPError(f"API request failed: {error_message_}") + + raise requests.HTTPError("API request failed with status code " + + f"{response.status_code}") + + +def _parse_response(response): + """Parse a JSON API response. + + This helper function parses the JSON body of an API response. + + Args: + response (requests.Response): The HTTP response object. + + Raises: + JSONDecodeError: If the response body can not be parsed as valid JSON. + + Returns: + DataFrame: A Pandas DataFrame representing the parsed JSON response. + """ + + _checks._require_type(value=response, expected_type=requests.Response) + + try: + response_json_ = response.json() + response_data_ = _data._flatten(response_json_) + except (JSONDecodeError, KeyError) as e_: + raise JSONDecodeError(f"Failed to parse API response: {e_.msg}", + e_.doc, e_.pos) + + return response_data_ + + +def _enrich_response(response_data, url): + """Enrich API response data. + + This helper function takes a structure containing API response data and + enriches it with metadata, including the timestamp and the queried URL. + + Args: + response_data (DataFrame): A structure containing the API response + data. + url (str): The URL that was queried. + + Returns: + DataFrame: A Pandas DataFrame containing the original API response + data, along with two additional fields: `queried_on`, the timestamp + when the data was queried, and `queried_url`, the URL used for + the API request. + """ + + _checks._require_type(value=response_data, expected_type=pd.DataFrame) + _checks._require_type(value=url, expected_type=str) + + response_data["queried_on"] = datetime.now() + response_data["queried_url"] = url + + return response_data + + +def _query(endpoint, api_key, params=None): + """Query the EPPO REST API and return the result. + + This function performs a GET request to the EPPO REST API, automatically + builds the full request URL, adds the authentication header, handles HTTP + errors, parses the response and adds metadata about the query. + + Specific HTTP status codes (400, 401, 403, 404, 429 and 500) are handled + explicitly to extract the error message returned by the API; other status + codes trigger a connection error. + + Args: + endpoint (str): The relative path of the endpoint to query, starting + with a forward slash (`/`). + api_key (str): The API key used for authentication. + params (dict, optional): Optional query parameters to include in the + request. + + Returns: + DataFrame: A Pandas DataFrame containing the parsed JSON response from + the API, along with metadata fields. + """ + + _checks._require_type(value=endpoint, expected_type=str) + _checks._require_trailing_slash(string=endpoint) + _checks._require_type(value=api_key, expected_type=str) + if params is not None: + _checks._require_type(value=params, expected_type=dict) + + base_url_ = "https://api.eppo.int/gd/v2" + full_url_ = base_url_ + endpoint + + response_ = _perform_request( + url=full_url_, + api_key=api_key, + params=params + ) + + _handle_http_errors(response=response_) + + response_data_ = _parse_response(response=response_) + + enriched_data_ = _enrich_response(response_data=response_data_, + url=full_url_) + + return enriched_data_ + + +def _fetch_service(base_path, api_key, code=None, service=None, + params=None): + """Fetch data from a specific EPPO API service. + + This function retrieves data from a specific service of the EPPO API. It + builds the appropriate endpoint according to the EPPO API structure, then + queries the API. + + Args: + base_path (str): The base path, starting with '/' (e.g. + "/taxons/taxon"). + api_key (str): The API key used for authentication. + code (str, optional): The resource identifier (e.g. an EPPO code or an + ISO code). + service (str, optional): The desired API service. + params (dict, optional): Optional query parameters to include in the + request. + + Returns: + DataFrame: A Pandas DataFrame containing the data returned by the + specified EPPO API service, along with metadata fields. + """ + + _checks._require_type(value=base_path, expected_type=str) + _checks._require_trailing_slash(string=base_path) + _checks._require_type(value=api_key, expected_type=str) + if code is not None: + _checks._require_type(value=code, expected_type=str) + if service is not None: + _checks._require_type(value=service, expected_type=str) + if params is not None: + _checks._require_type(value=params, expected_type=dict) + + service_endpoint_ = _build_endpoint( + base_path=base_path, + code=code, + service=service + ) + + service_data_ = _query(endpoint=service_endpoint_, api_key=api_key, + params=params) + + return service_data_ diff --git a/src/eppopynder/client.py b/src/eppopynder/client.py new file mode 100644 index 0000000..7a5e733 --- /dev/null +++ b/src/eppopynder/client.py @@ -0,0 +1,448 @@ +import os +from dotenv import load_dotenv + +from eppopynder._utils import _checks +from eppopynder._core import (_general, _taxons, _taxon, _country, _rppo, + _tools, _reporting_service, _references) + + +class Client: + """Client class for working with the EPPO API. + + Attributes: + _api_key (str): The API key used for authentication. + + Methods: + general(services): Query the EPPO API General endpoint. + taxons(services, params): Query the EPPO API Taxons endpoint. + taxon(eppo_codes, services): Query the EPPO API Taxon endpoint. + country(iso_codes, services): Query the EPPO API Country endpoint. + rppo(rppo_codes, services): Query the EPPO API RPPO endpoint. + tools(services, params): Query the EPPO API Tools endpoint. + reporting_service(services, params): Query the EPPO API Reporting + Service endpoint. + references(services): Query the EPPO API References endpoint. + """ + + def __init__(self, api_key=None): + """Initialize the client. + + Args: + api_key (str, optional): The API key used for authentication. + + Examples: + >>> from eppopynder import Client + + >>> # Create a client using the API key defined in the .env file. + >>> client_with_default = Client() + + >>> # Create a client using a manually specified API key. + >>> client_with_api_key = Client(api_key="") + """ + + if api_key is not None: + self._api_key = api_key + else: + load_dotenv() + self._api_key = os.getenv("EPPO_API_KEY") + + if self._api_key is None: + raise ValueError( + "The EPPO_API_KEY environment variable is not set") + + _checks._require_type(value=self._api_key, expected_type=str) + + if not self._api_key.strip(): + raise ValueError("The API key can not be empty") + + def general(self, services=None): + """Query the EPPO API General endpoint. + + This function queries the General endpoints of the EPPO Global Database + via REST API. The function sequentially queries all specified + `services` and returns the extracted data. + + Args: + services (list[GeneralService], optional): One or more General + services to query. A validation step ensures that all provided + services are of type `GeneralService` and match the supported + service names. If not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a + data frame with the queried content. + + Examples: + >>> from eppopynder import Client, GeneralService + + >>> client = Client() + + >>> # Get information about system health status. + >>> data = client.general(services=[GeneralService.STATUS]) + """ + + return _general._general(api_key=self._api_key, services=services) + + def taxons(self, services=None, params=None): + """Query the EPPO API Taxons endpoint. + + This function queries the Taxons endpoints of the EPPO Global Database + via REST API. The function sequentially queries all specified + `services` and returns the extracted data. + + Args: + services (list[TaxonsService], optional): One or more Taxons + services to query. A validation step ensures that all provided + services are of type `TaxonsService` and match the supported + service names. If not provided, all services are considered. + params (dict, optional): A named dictionary of query parameters to + include in the request. The list of available parameters can be + accessed via the EPPO API Documentation platform + (https://data2025.eppo.int/ui/#/docs/GDAPI). + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a + data frame with the queried content. + + Examples: + >>> from eppopynder import Client, TaxonsService + + >>> client = Client() + + >>> # Get the list of taxons with default parameters. + >>> data = client.taxons(services=[TaxonsService.LIST]) + + >>> # Get the list of taxons with custom parameters. + ... data = client.taxons( + ... services=[TaxonsService.LIST], + ... params={ + ... "list": { + ... "createdFromDate": "2000-01-01", + ... "limit": 5, + ... "offset": 100, + ... "orderAsc": False, + ... "orderBy": "eppocode" + ... } + ... } + ... ) + """ + + return _taxons._taxons(api_key=self._api_key, services=services, + params=params) + + def taxon(self, eppo_codes, services=None): + """Query the EPPO API Taxon endpoint. + + This function queries the Taxon endpoints of the EPPO Global Database + via REST API for one or more EPPO code(s) and one or more service(s). + For each EPPO code in `eppo_codes`, the function sequentially queries + all specified `services` and returns the extracted data. + + Args: + eppo_codes (list[str]): One or more EPPO codes to query. + services (list[TaxonService], optional): One or more Taxon services + to query. A validation step ensures that all provided services + are of type `TaxonService` and match the supported service + names. If not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a + data frame with the queried content for all the specified EPPO + codes. + + Examples: + >>> from eppopynder import Client, TaxonService + + >>> client = Client() + + >>> # Get all information about Bemisia tabaci. + >>> data = client.taxon(eppo_codes=["BEMITA"]) + + >>> # Get names data about Bemisia tabaci. + >>> data = client.taxon( + ... eppo_codes=["BEMITA"], + ... services=[TaxonService.NAMES] + ... ) + + >>> # Get taxonomy and categorization data about Bemisia tabaci and + >>> # Gossypium hirsutum. + >>> data = client.taxon( + ... eppo_codes=["BEMITA", "GOSHI"], + ... services=[ + ... TaxonService.TAXONOMY, + ... TaxonService.CATEGORIZATION + ... ] + ... ) + """ + + return _taxon._taxon(api_key=self._api_key, eppo_codes=eppo_codes, + services=services) + + def country(self, iso_codes, services=None): + """Query the EPPO API Country endpoint. + + This function queries the Country endpoints of the EPPO Global Database + via REST API for one or more ISO code(s) and one or more service(s). + For each ISO code in `iso_codes`, the function sequentially queries all + specified `services` and returns the extracted data through a list of + dataframes. + + Args: + iso_codes (list[str]): One or more ISO codes to query. + services (list[CountryService], optional): One or more Country + services to query. A validation step ensures that all provided + services are of type `CountryService` and match the supported + service names. If not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a + data frame with the queried content for all the specified ISO + codes. + + Examples: + >>> from eppopynder import Client, CountryService + + >>> client = Client() + + >>> # Get all information about France. + >>> data = client.country(iso_codes=["FR"]) + + >>> # Get overview data about France. + >>> data = client.country( + ... iso_codes=["FR"], + ... services=[CountryService.OVERVIEW] + ... ) + + >>> # Get overview and categorization data about France and Italy. + >>> data = client.country( + ... iso_codes=["FR", "IT"], + ... services=[ + ... CountryService.OVERVIEW, + ... CountryService.CATEGORIZATION + ... ] + ... ) + """ + + return _country._country(api_key=self._api_key, iso_codes=iso_codes, + services=services) + + def rppo(self, rppo_codes, services=None): + """Query the EPPO API RPPO endpoint. + + This function queries the RPPO endpoints of the EPPO Global Database + via REST API for one or more RPPO code(s) and one or more service(s). + For each RPPO code in `rppo_codes`, the function sequentially queries + all specified `services` and returns the extracted data through a list + of dataframes. + + Args: + rppo_codes (list[str]): One or more RPPO codes to query. + services (list[CountryService], optional): One or more RPPO + services to query. A validation step ensures that all provided + services are of type `RPPOService` and match the supported + service names. If not provided, all services are considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a + data frame with the queried content for all the specified RPPO + codes. + + Examples: + >>> from eppopynder import Client, RPPOService + + >>> client = Client() + + >>> # Get all information about the European and Mediterranean + >>> # Plant Protection Organisation. + >>> data = client.rppo(rppo_codes=["9A"]) + + >>> # Get overview data about the European and Mediterranean + >>> # Plant Protection Organisation. + >>> data = client.rppo( + ... rppo_codes=["9A"], + ... services=[RPPOService.OVERVIEW] + ... ) + + >>> # Get overview and categorization data about the European and + >>> # Mediterranean Plant Protection Organisation and the European + >>> # Union. + >>> data = client.rppo( + ... rppo_codes=["9A", "9L"], + ... services=[ + ... RPPOService.OVERVIEW, + ... RPPOService.CATEGORIZATION + ... ] + ... ) + """ + + return _rppo._rppo(api_key=self._api_key, rppo_codes=rppo_codes, + services=services) + + def tools(self, services=None, params=None): + """Query the EPPO API Tools endpoint. + + This function queries the Tools endpoints of the EPPO Global Database + via REST API. The function sequentially queries all specified + `services` and returns the extracted data through a dictionary of data + frames. + + Args: + services (list[ToolsService], optional): One or more Tools services + to query. A validation step ensures that all provided services + are of type `ToolsService` and match the supported service + names. If not provided, all services are considered. + params (dict, optional): A named dictionary of query parameters to + include in the request. The list of available parameters can be + accessed via the EPPO API Documentation platform + (https://data2025.eppo.int/ui/#/docs/GDAPI). + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a + data frame with the queried content. + + Examples: + >>> from eppopynder import Client, ToolsService + + >>> client = Client() + + >>> # Get the EPPO codes associated to the name Bemisia tabaci. + >>> data = client.tools( + ... services=[ToolsService.NAME2CODES], + ... params={ + ... ToolsService.NAME2CODES: { + ... "name": "Bemisia tabaci", + ... "onlyPreferred": False + ... } + ... } + ... ) + """ + + return _tools._tools(api_key=self._api_key, services=services, + params=params) + + def reporting_service(self, services=None, params=None): + """Query the EPPO API Reporting Service endpoint. + + This function queries the Reporting Service endpoints of the EPPO + Global Database via REST API. The function sequentially queries all + specified `services` and returns the extracted data through a + dictionary of data frames. + + Args: + services (list[ReportingServiceService], optional): One or more + Reporting Service services to query. A validation step ensures + that all provided services are of type + `ReportingServiceService` and match the supported service + names. If not provided, all services are considered. + params (dict, optional): A named dictionary of query parameters to + include in the request. The list of available parameters can be + accessed via the EPPO API Documentation platform + (https://data2025.eppo.int/ui/#/docs/GDAPI). + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a + data frame with the queried content. + + Examples: + >>> from eppopynder import Client, ReportingServiceService + + >>> client = Client() + + >>> # Get the list of reporting service issues. + >>> data = client.reporting_service( + ... services=[ReportingServiceService.LIST] + ... ) + + >>> # Get a specific reporting service issue. + >>> data = client.reporting_service( + ... services=[ReportingServiceService.REPORTING], + ... params={ + ... ReportingServiceService.REPORTING: { + ... "reporting_id": 10 + ... } + ... } + ... ) + + >>> # Get a specific article. + >>> data = client.reporting_service( + ... services=[ReportingServiceService.ARTICLE], + ... params={ + ... ReportingServiceService.ARTICLE: { + ... "article_id": 234 + ... } + ... } + ... ) + + >>> # Get the list of reporting service issues, a specific + >>> # reporting service issue and a specific article. + >>> data = client.reporting_service( + ... params={ + ... ReportingServiceService.REPORTING: { + ... "reporting_id": 10 + ... }, + ... ReportingServiceService.ARTICLE: { + ... "article_id": 234 + ... } + ... } + ... ) + """ + + return _reporting_service._reporting_service( + api_key=self._api_key, + services=services, + params=params + ) + + def references(self, services=None): + """Query the EPPO API References endpoint. + + This function queries the References endpoints of the EPPO Global + Database via REST API. The function sequentially queries all specified + `services` and returns the extracted data through a dictionary of data + frames. + + Args: + services (list[ReferencesService], optional): One or more + References services to query. A validation step ensures that + all provided services are of type `ReferencesService` and match + the supported service names. If not provided, all services are + considered. + + Returns: + dict: A dictionary, in which each entry corresponds to the data + retrieved for each specified service. Each element contains a + data frame with the queried content. + + Examples: + >>> from eppopynder import Client, ReferencesService + + >>> client = Client() + + >>> # Get all references information. + >>> data = client.references() + + >>> # Get information about distribution status codes. + >>> data = client.references( + ... services=[ReferencesService.DISTRIBUTION_STATUS] + ... ) + + # Get information about EPPO list codes and labels and countries. + >>> data = client.references( + ... services=[ + ... ReferencesService.Q_LIST, + ... ReferencesService.COUNTRIES + ... ] + ... ) + """ + + return _references._references( + api_key=self._api_key, + services=services + ) diff --git a/src/eppopynder/data_wrangling.py b/src/eppopynder/data_wrangling.py new file mode 100644 index 0000000..f767bf8 --- /dev/null +++ b/src/eppopynder/data_wrangling.py @@ -0,0 +1,75 @@ +"""This module contains functions for data wrangling.""" + +import pandas as pd + +from eppopynder._utils import _checks + +def uniform_taxonomy(taxonomy_data): + """Create a complete and uniform taxonomy dataframe. + + This function normalizes the taxonomy returned by the EPPO service, + producing a uniform structure that includes all possible taxonomic + categories, even when some of them are not present in the original result. + + Args: + taxonomy_data (pandas.DataFrame): A dataframe containing taxonomy data + provided by the EPPO service for a given EPPO code. + + Returns: + pandas.DataFrame: A dataframe where each row represents one of the + expected taxonomic ranks. Fields corresponding to ranks not present in + the original taxonomy are filled with `NaN`/`NaT`. The `level` column + is excluded from the output. + + Examples: + >>> from eppopynder import Client, TaxonService uniform_taxonomy + + >>> client = Client() + + >>> # Retrieve taxonomy data from the EPPO service. + >>> taxon_data = client.taxon( + ... eppo_codes=["BEMITA"], + ... services=[TaxonService.TAXONOMY] + ... ) + + >>> # Create a uniform taxonomy with all ranks. + >>> taxonomy = uniform_taxonomy( + ... taxonomy_data=taxon_data[TaxonService.TAXONOMY]) + """ + + _checks._require_type(value=taxonomy_data, expected_type=pd.DataFrame) + _checks._require_column_names(dataframe=taxonomy_data, + column_names=["queried_eppo_code", "type"]) + _checks._require_not_all_nan(dataframe=taxonomy_data, + column_name="queried_eppo_code") + + taxonomy_types_ = pd.DataFrame({ + "type": [ + "Kingdom", + "Phylum", + "Subphylum", + "Class", + "Subclass", + "Order", + "Suborder", + "Family", + "Subfamily", + "Genus", + "Species" + ] + }) + + uniformed_taxonomy_data_ = ( + taxonomy_types_ + .merge(taxonomy_data, on="type", how="left") + .drop(columns="level", errors="ignore") + ) + + queried_eppo_code_ = \ + uniformed_taxonomy_data_["queried_eppo_code"].dropna().iloc[0] + + uniformed_taxonomy_data_["queried_eppo_code"] = \ + uniformed_taxonomy_data_["queried_eppo_code"] \ + .fillna(queried_eppo_code_) + + return uniformed_taxonomy_data_ diff --git a/tests/test__checks.py b/tests/test__checks.py new file mode 100644 index 0000000..96af93b --- /dev/null +++ b/tests/test__checks.py @@ -0,0 +1,132 @@ +import unittest +import pandas as pd + +from eppopynder._utils._checks import (_require_type, _require_trailing_slash, + _require_list_of, _check_services, + _require_column_names, + _require_not_all_nan) + + +class TestChecks(unittest.TestCase): + + ################### + # _require_type() # + ################### + + def test__require_type_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _require_type, value=123, + expected_type=str) + + def test__require_type_output(self): + """Test the behaviour for valid data.""" + self.assertIsNone(_require_type(value=123, expected_type=int)) + + ############################# + # _require_trailing_slash() # + ############################# + + def test__require_trailing_slash_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _require_trailing_slash, string=123) + + def test__require_trailing_slash_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(ValueError, _require_trailing_slash, string="country") + + def test__require_trailing_slash_output(self): + """Test the behaviour for valid data.""" + self.assertIsNone(_require_trailing_slash(string="/country")) + + ###################### + # _require_list_of() # + ###################### + + def test__require_list_of_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _require_list_of, items=123, + expected_type=int) + self.assertRaises(TypeError, _require_list_of, items=list(), + expected_type=123) + + def test__require_list_of_invalid(self): + """Test the behaviour for invalid elements.""" + self.assertRaises(TypeError, _require_list_of, items=[1, 2, 'x'], + expected_type=int) + + def test__require_list_of_output(self): + """Test the output for valid elements.""" + self.assertIsNone(_require_list_of(items=[1, 2, 3], expected_type=int)) + + ##################### + # _check_services() # + ##################### + + def test__check_services_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _check_services, services=123, + choices=list()) + self.assertRaises(TypeError, _check_services, services=list(), + choices=123) + self.assertRaises(TypeError, _check_services, services=[1, 2], + choices=['x', 'y']) + + def test__check_services_invalid(self): + """Test the behaviour for invalid element types.""" + self.assertRaises(ValueError, _check_services, services=['x', 'a'], + choices=['x', 'y']) + + def test__check_services_output(self): + """Test the output for valid elements.""" + self.assertIsNone(_check_services( + services=['a', 'b'], + choices=['a', 'b', 'c'] + )) + + ########################### + # _require_column_names() # + ########################### + + def test__require_column_names_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _require_column_names, dataframe=123, + column_names=list()) + self.assertRaises(TypeError, _require_column_names, + dataframe=pd.DataFrame(), column_names=123) + + def test__require_column_names_invalid(self): + """Test the behaviour for invalid element types.""" + self.assertRaises(ValueError, _require_column_names, + dataframe=pd.DataFrame(), column_names=["column_a"]) + + def test__require_column_names_output(self): + """Test the output for valid elements.""" + self.assertIsNone(_require_column_names( + dataframe=pd.DataFrame({"column_a": [1, 2, 3]}), + column_names=["column_a"] + )) + + ########################## + # _require_not_all_nan() # + ########################## + + def test__require_not_all_nan_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _require_not_all_nan, dataframe=123, + column_name="") + self.assertRaises(TypeError, _require_not_all_nan, + dataframe=pd.DataFrame(), column_names=123) + + def test__require_not_all_nan_invalid(self): + """Test the behaviour for invalid element types.""" + self.assertRaises(ValueError, _require_not_all_nan, + dataframe=pd.DataFrame({ + "column_a": [None, None, None]}), + column_name="column_a") + + def test__require_not_all_nan_output(self): + """Test the output for valid elements.""" + self.assertIsNone(_require_not_all_nan( + dataframe=pd.DataFrame({"column_a": [1, None, 1]}), + column_name="column_a" + )) diff --git a/tests/test__country.py b/tests/test__country.py new file mode 100644 index 0000000..256cf01 --- /dev/null +++ b/tests/test__country.py @@ -0,0 +1,103 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv +from requests import HTTPError + +from eppopynder._core._country import _country, CountryService + +load_dotenv() + + +class TestCountry(unittest.TestCase): + + ############## + # _country() # + ############## + + def test__country_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _country, api_key=123, iso_codes=[''], + services=list()) + self.assertRaises(TypeError, _country, api_key='', iso_codes=123, + services=123) + self.assertRaises(TypeError, _country, api_key='', iso_codes=[1, 2], + services=list()) + self.assertRaises(TypeError, _country, api_key='', iso_codes=[''], + services=123) + self.assertRaises(TypeError, _country, api_key='', iso_codes=[''], + services=[1, 2]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__country_output(self, mock_fetch_service): + """Test the output dict structure.""" + mock_fetch_service.return_value = pd.DataFrame() + services_ = [CountryService.OVERVIEW] + data_ = _country( + api_key="EPPO_API_KEY", + iso_codes=["FR"], + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[CountryService.OVERVIEW], pd.DataFrame) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__country_output_online(self): + """Test the output dict structure.""" + services_ = [CountryService.OVERVIEW] + data_ = _country( + api_key=os.getenv("EPPO_API_KEY"), + iso_codes=["FR"], + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[CountryService.OVERVIEW], pd.DataFrame) + + @patch("eppopynder._utils._requests._fetch_service") + def test__country_invalid_iso(self, mock_fetch_service): + """Test the behaviour for invalid ISO codes.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _country, + api_key="EPPO_API_KEY", + iso_codes=["BAD_ISO_CODE"]) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__country_invalid_iso_online(self): + """Test the behaviour for invalid ISO codes.""" + self.assertRaises(HTTPError, _country, + api_key=os.getenv("EPPO_API_KEY"), + iso_codes=["BAD_ISO_CODE"]) + + def test__country_invalid_service(self): + """Test the behaviour for invalid services.""" + self.assertRaises(TypeError, _country, + api_key="EPPO_API_KEY", + iso_codes=["FR"], services=["badService"]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__country_invalid_key(self, mock_fetch_service): + """Test the behaviour for invalid API keys.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _country, api_key="BAD_API_KEY", + iso_codes=["FR"]) + mock_fetch_service.side_effect = TypeError + self.assertRaises(TypeError, _country, api_key=None, iso_codes=["FR"]) + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__country_invalid_key_online(self): + """Test the behaviour for invalid API keys.""" + self.assertRaises(HTTPError, _country, api_key="BAD_API_KEY", + iso_codes=["FR"]) + self.assertRaises(TypeError, _country, + api_key=os.getenv("BAD_API_KEY"), iso_codes=["FR"]) diff --git a/tests/test__data.py b/tests/test__data.py new file mode 100644 index 0000000..d397f55 --- /dev/null +++ b/tests/test__data.py @@ -0,0 +1,209 @@ +import unittest +import pandas as pd + +from eppopynder._utils._data import (_flatten, _transform_taxons, + _transform_references, _merge_batch) +from eppopynder._core._taxons import TaxonsService +from eppopynder._core._references import ReferencesService + + +class TestData(unittest.TestCase): + + ############## + # _flatten() # + ############## + + def test__flatten_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _flatten, nested_data=123) + + def test__flatten_empty(self): + """Test the behaviour for empty data.""" + self.assertTrue(_flatten(nested_data=list()).empty) + self.assertTrue(_flatten(nested_data=dict()).empty) + + def test__flatten_sep(self): + flattened_ = _flatten(nested_data={'a': {'b': 1}}) + self.assertEqual(flattened_.keys()[0], "a_b") + flattened_ = _flatten(nested_data={'a': {'b': 1}}, separator='.') + self.assertEqual(flattened_.keys()[0], "a.b") + + def test__flatten_dict1(self): + """Test the behaviour for flattening a dictionary.""" + flattened_ = _flatten(nested_data={ + 'a': 1, + 'b': 2, + 'c': 3, + 'd': { + '1': 4, + '2': 5 + } + }) + self.assertEqual( + list(flattened_.keys()), + ['a', 'b', 'c', "d_1", "d_2"] + ) + + def test__flatten_dict2(self): + """Test the behaviour for flattening a dictionary.""" + flattened_ = _flatten(nested_data={ + 'a': 1, + 'b': 2, + 'c': 3, + 'd': { + '1': 4, + '2': { + '3': 5 + } + } + }) + self.assertEqual( + list(flattened_.keys()), + ['a', 'b', 'c', "d_1", "d_2_3"] + ) + + def test__flatten_dict3(self): + """Test the behaviour for flattening a dictionary.""" + flattened_ = _flatten(nested_data={ + 'a': 1, + 'b': [ + {'x': 1, 'y': 2, 'z': 3}, + {'x': 4, 'y': 5, 'z': 6} + ] + }) + self.assertEqual( + list(flattened_.keys()), + ['a', "b_x", "b_y", "b_z"] + ) + + def test__flatten_dict4(self): + """Test the behaviour for flattening a dictionary.""" + flattened_ = _flatten(nested_data={ + 'a': 1, + 'b': [ + {'x': 1, 'y': 2, 'z': 3}, + {'x': 4, 'y': 5, 'z': 6}, + "some text" + ] + }) + self.assertEqual( + list(flattened_.keys()), + ['a', "b_x", "b_y", "b_z", 'b'] + ) + + def test__flatten_list_of_dicts(self): + """Test the behaviour for flattening a list of dictionaries.""" + flattened_ = _flatten(nested_data=[ + {'a': 1, 'b': 2, 'c': 3}, + {'a': 4, 'b': 5, 'c': 6} + ]) + self.assertEqual(list(flattened_.keys()), ['a', 'b', 'c']) + + def test__flatten_dict_of_lists1(self): + """Test the behaviour for flattening a dictionary of lists.""" + flattened_ = _flatten(nested_data={ + 'a': [ + {'x': 1, 'y': 2, 'z': 3}, + {'x': 4, 'y': 5, 'z': 6} + ], + 'b': [ + {'x': 7, 'y': 8, 'z': 9}, + {'x': 10, 'y': 11, 'z': 12} + ] + }) + self.assertEqual( + list(flattened_.keys()), + ["parent_key", 'x', 'y', 'z'] + ) + + def test__flatten_dict_of_lists2(self): + """Test the behaviour for flattening a dictionary of lists.""" + flattened_ = _flatten(nested_data={ + 'a': [ + {'x': 1, 'y': 2, 'z': 3}, + {'x': 4, 'y': 5, 'z': 6} + ], + 'b': [ + {'x': 7, 'y': 8, 'z': 9}, + "some text" + ] + }) + self.assertEqual( + list(flattened_.keys()), + ["parent_key", 'x', 'y', 'z', "atomic_value"] + ) + + ####################### + # _transform_taxons() # + ####################### + + def test__transform_taxons_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _transform_taxons, taxons_data=123) + + def test__transform_taxons_valid(self): + """Test the behaviour for valid data.""" + data_ = _transform_taxons(taxons_data={ + TaxonsService.LIST: pd.DataFrame({ + "pagination_a": [1, 2, 3], + "meta_b": [4, 5, 6], + "other": [7, 8, 9] + }) + }) + self.assertEqual( + list(data_[TaxonsService.LIST].keys()), + ["other"] + ) + + ########################### + # _transform_references() # + ########################### + + def test__transform_references_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _transform_references, + references_data=123) + + def test__transform_references_valid(self): + """Test the behaviour for valid data.""" + data_ = _transform_references(references_data={ + ReferencesService.COUNTRIES_STATES: pd.DataFrame({ + "parent_key": [1, 2, 3], + "other_1": [4, 5, 6], + "other_2": [7, 8, 9] + }) + }) + self.assertEqual( + list(data_[ReferencesService.COUNTRIES_STATES].keys()), + ["country_iso", "other_1", "other_2"] + ) + + ################## + # _merge_batch() # + ################## + + def test__merge_batch_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _merge_batch, datasets=123, + parent_column_name="") + self.assertRaises(TypeError, _merge_batch, datasets=dict(), + parent_column_name=123) + + def test__merge_batch_valid(self): + """Test the behaviour for valid data.""" + data_ = _merge_batch( + datasets={ + "service1": { + 'A': pd.DataFrame({}), + 'B': pd.DataFrame({}) + } + }, + parent_column_name="parent_name" + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), ["service1"]) + self.assertIsInstance(data_["service1"], pd.DataFrame) + self.assertEqual( + list(data_["service1"].keys()), + ["parent_name"] + ) diff --git a/tests/test__general.py b/tests/test__general.py new file mode 100644 index 0000000..800e936 --- /dev/null +++ b/tests/test__general.py @@ -0,0 +1,73 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv +from requests import HTTPError + +from eppopynder._core._general import _general, GeneralService + +load_dotenv() + + +class TestGeneral(unittest.TestCase): + + ############## + # _general() # + ############## + + def test__general_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _general, api_key=123, services=list()) + self.assertRaises(TypeError, _general, api_key='', services=123) + + @patch("eppopynder._utils._requests._fetch_service") + def test__general_output(self, mock_fetch_service): + """Test the output dict structure.""" + mock_fetch_service.return_value = pd.DataFrame() + services_ = [GeneralService.STATUS] + data_ = _general( + api_key="EPPO_API_KEY", + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[GeneralService.STATUS], pd.DataFrame) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__general_output_online(self): + """Test the output dict structure.""" + services_ = [GeneralService.STATUS] + data_ = _general( + api_key=os.getenv("EPPO_API_KEY"), + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[GeneralService.STATUS], pd.DataFrame) + + def test__general_invalid_service(self): + """Test the behaviour for invalid services.""" + self.assertRaises(TypeError, _general, + api_key="EPPO_API_KEY", + services=["badService"]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__general_invalid_key(self, mock_fetch_service): + """Test the behaviour for invalid API keys.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _general, api_key="BAD_API_KEY") + mock_fetch_service.side_effect = TypeError + self.assertRaises(TypeError, _general, api_key=None) + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__general_invalid_key_online(self): + """Test the behaviour for invalid API keys.""" + self.assertRaises(HTTPError, _general, api_key="BAD_API_KEY") + self.assertRaises(TypeError, _general, + api_key=os.getenv("BAD_API_KEY")) diff --git a/tests/test__references.py b/tests/test__references.py new file mode 100644 index 0000000..3ff5c38 --- /dev/null +++ b/tests/test__references.py @@ -0,0 +1,73 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv +from requests import HTTPError + +from eppopynder._core._references import _references, ReferencesService + +load_dotenv() + + +class TestGeneral(unittest.TestCase): + + ################# + # _references() # + ################# + + def test__references_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _references, api_key=123, services=list()) + self.assertRaises(TypeError, _references, api_key='', services=123) + + @patch("eppopynder._utils._requests._fetch_service") + def test__references_output(self, mock_fetch_service): + """Test the output dict structure.""" + mock_fetch_service.return_value = pd.DataFrame() + services_ = [ReferencesService.Q_LIST] + data_ = _references( + api_key="EPPO_API_KEY", + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[ReferencesService.Q_LIST], pd.DataFrame) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__references_output_online(self): + """Test the output dict structure.""" + services_ = [ReferencesService.Q_LIST] + data_ = _references( + api_key=os.getenv("EPPO_API_KEY"), + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[ReferencesService.Q_LIST], pd.DataFrame) + + def test__references_invalid_service(self): + """Test the behaviour for invalid services.""" + self.assertRaises(TypeError, _references, + api_key="EPPO_API_KEY", + services=["badService"]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__references_invalid_key(self, mock_fetch_service): + """Test the behaviour for invalid API keys.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _references, api_key="BAD_API_KEY") + mock_fetch_service.side_effect = TypeError + self.assertRaises(TypeError, _references, api_key=None) + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__references_invalid_key_online(self): + """Test the behaviour for invalid API keys.""" + self.assertRaises(HTTPError, _references, api_key="BAD_API_KEY") + self.assertRaises(TypeError, _references, + api_key=os.getenv("BAD_API_KEY")) diff --git a/tests/test__reporting_service.py b/tests/test__reporting_service.py new file mode 100644 index 0000000..eb88dc5 --- /dev/null +++ b/tests/test__reporting_service.py @@ -0,0 +1,110 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv +from requests import HTTPError + +from eppopynder._core._reporting_service import (_reporting_service, + ReportingServiceService) + +load_dotenv() + + +class TestReportingService(unittest.TestCase): + + ######################## + # _reporting_service() # + ######################## + + def test__reporting_service_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _reporting_service, api_key=123, + services=list(), params=dict()) + self.assertRaises(TypeError, _reporting_service, api_key='', + services=123, params=dict()) + self.assertRaises(TypeError, _reporting_service, api_key='', + services=list(), params=123) + + @patch("eppopynder._utils._requests._fetch_service") + def test__reporting_service_output(self, mock_fetch_service): + """Test the output dict structure.""" + mock_fetch_service.return_value = pd.DataFrame() + services_ = [ReportingServiceService.LIST] + data_ = _reporting_service( + api_key="EPPO_API_KEY", + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance( + data_[ReportingServiceService.LIST], + pd.DataFrame + ) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__reporting_service_output_online(self): + """Test the output dict structure.""" + services_ = [ReportingServiceService.LIST] + data_ = _reporting_service( + api_key=os.getenv("EPPO_API_KEY"), + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance( + data_[ReportingServiceService.LIST], + pd.DataFrame + ) + + @patch("eppopynder._utils._requests._fetch_service") + def test__reporting_service_invalid_param(self, mock_fetch_service): + """Test the behaviour for invalid request parameters.""" + mock_fetch_service.side_effect = ValueError + self.assertRaises(ValueError, _reporting_service, + api_key="EPPO_API_KEY", + params={ReportingServiceService.REPORTING: { + "bad_param": 234 + }}) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__reporting_service_invalid_param_online(self): + """Test the behaviour for invalid request parameters.""" + self.assertRaises(ValueError, _reporting_service, + api_key=os.getenv("EPPO_API_KEY"), + params={ReportingServiceService.REPORTING: { + "bad_param": 234 + }}) + + def test__reporting_service_invalid_service(self): + """Test the behaviour for invalid services.""" + self.assertRaises(TypeError, _reporting_service, + api_key="EPPO_API_KEY", + services=["badService"]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__reporting_service_invalid_key(self, mock_fetch_service): + """Test the behaviour for invalid API keys.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _reporting_service, api_key="BAD_API_KEY", + services=[ReportingServiceService.LIST]) + mock_fetch_service.side_effect = TypeError + self.assertRaises(TypeError, _reporting_service, api_key=None, + services=[ReportingServiceService.LIST]) + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__reporting_service_invalid_key_online(self): + """Test the behaviour for invalid API keys.""" + self.assertRaises(HTTPError, _reporting_service, api_key="BAD_API_KEY", + services=[ReportingServiceService.LIST]) + self.assertRaises(TypeError, _reporting_service, + api_key=os.getenv("BAD_API_KEY"), + services=[ReportingServiceService.LIST]) diff --git a/tests/test__requests.py b/tests/test__requests.py new file mode 100644 index 0000000..6b2fcc6 --- /dev/null +++ b/tests/test__requests.py @@ -0,0 +1,403 @@ +import os +import json +import unittest +from unittest.mock import patch +import pandas as pd +import requests.exceptions +from dotenv import load_dotenv +from json import JSONDecodeError +from requests import Response, HTTPError + +from eppopynder._utils._requests import (_build_endpoint, _perform_request, + _handle_http_errors, _parse_response, + _enrich_response, _query, + _fetch_service, + _build_reporting_service_path) + +load_dotenv() + + +class TestRequests(unittest.TestCase): + + ##################### + # _build_endpoint() # + ##################### + + def test__build_endpoint_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _build_endpoint, base_path=123, code='', + service='') + self.assertRaises(ValueError, _build_endpoint, base_path="general", + code='', service='') + self.assertRaises(TypeError, _build_endpoint, base_path="/general", + code=123, service='') + self.assertRaises(TypeError, _build_endpoint, base_path="/general", + code='', service=123) + + def test__build_endpoint_output1(self): + """Test the behaviour if only the base path is given.""" + self.assertEqual( + _build_endpoint(base_path="/taxons/taxon"), + "/taxons/taxon" + ) + + def test__build_endpoint_output2(self): + """Test the behaviour if a resource identifier is given.""" + self.assertEqual( + _build_endpoint(base_path="/taxon/taxon", code="BEMITA"), + "/taxon/taxon/BEMITA" + ) + + def test__build_endpoint_output3(self): + """Test the behaviour if a service is given.""" + self.assertEqual( + _build_endpoint( + base_path="/country", + code="FR", + service="overview" + ), + "/country/FR/overview" + ) + + def test__build_endpoint_output4(self): + """Test the behaviour if no resource identifier is given.""" + self.assertEqual( + _build_endpoint(base_path="/reportings", service="list"), + "/reportings/list" + ) + + ################################### + # _build_reporting_service_path() # + ################################### + + def test__build_reporting_service_path_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _build_reporting_service_path, + service=123, params=dict()) + self.assertRaises(TypeError, _build_reporting_service_path, service='', + params=123) + + def test__build_reporting_service_path_output1(self): + """Test the behaviour for valid parameters.""" + self.assertEqual( + _build_reporting_service_path( + service="reporting", + params={"reporting_id": 234}), + "reporting/234" + ) + self.assertEqual( + _build_reporting_service_path( + service="article", + params={"article_id": 234}), + "article/234" + ) + + def test__build_reporting_service_path_output3(self): + """Test the behaviour for services without parameters.""" + self.assertEqual( + _build_reporting_service_path( + service="list", + params=dict()), + "list" + ) + + def test__build_reporting_service_path_invalid(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(ValueError, _build_reporting_service_path, + service="reporting", params={'a': 10}) + self.assertRaises(ValueError, _build_reporting_service_path, + service="article", params={'a': 234}) + + ###################### + # _perform_request() # + ###################### + + def test__perform_request_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _perform_request, url=123, api_key='', + params=None) + self.assertRaises(TypeError, _perform_request, + url="https://example.org", api_key=123, + params=None) + self.assertRaises(TypeError, _perform_request, + url="https://example.org", api_key='', params=123) + + @patch("eppopynder._utils._requests.requests.get") + def test__perform_request_malformed(self, mock_get): + """Test the behaviour for malformed requests.""" + mock_get.side_effect = requests.exceptions.ConnectionError + self.assertRaises(Exception, _perform_request, + url="https://invalid-domain", api_key="EPPO_API_KEY") + + # This test performs real requests. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__perform_request_malformed_online(self): + """Test the behaviour for malformed requests.""" + self.assertRaises(Exception, _perform_request, + url="https://invalid-domain", api_key="EPPO_API_KEY") + + @patch("eppopynder._utils._requests.requests.get") + def test__perform_request_output(self, mock_get): + """Test the output type of the request.""" + mock_get.return_value = Response() + response_ = _perform_request( + url="https://api.eppo.int/gd/v2/taxons/taxon/BEMITA/overview", + api_key="EPPO_API_KEY" + ) + self.assertIsInstance(response_, Response) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__perform_request_output_online(self): + """Test the output type of the request.""" + response_ = _perform_request( + url="https://api.eppo.int/gd/v2/taxons/taxon/BEMITA/overview", + api_key=os.getenv("EPPO_API_KEY") + ) + self.assertIsInstance(response_, Response) + + ######################### + # _handle_http_errors() # + ######################### + + def test__handle_http_errors_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _handle_http_errors, response=123) + + def test__handle_http_errors_valid(self): + """Test the behaviour for status code 200.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://api.eppo.int/gd/v2/status" + response_.method = "GET" + response_.headers["Content-Type"] = "application/json" + response_._content = (json.dumps({"data": "Custom data"}) + .encode("utf-8")) + self.assertIsNone(_handle_http_errors(response=response_)) + + def test__handle_http_errors_handled(self): + """Test the behaviour for handled status codes.""" + response_ = Response() + response_.status_code = 403 + response_.url = "https://api.eppo.int/gd/v2/status" + response_.method = "GET" + response_.headers["Content-Type"] = "application/json" + response_._content = (json.dumps({"error": "Custom message"}) + .encode("utf-8")) + self.assertRaises(HTTPError, _handle_http_errors, response=response_) + + def test__handle_http_errors_handled_invalid(self): + """Test the behaviour for handled status codes.""" + response_ = Response() + response_.status_code = 403 + response_.url = "https://api.eppo.int/gd/v2/status" + response_.method = "GET" + response_.headers["Content-Type"] = "text/html" + response_._content = "content".encode("utf-8") + self.assertRaises( + HTTPError, + _handle_http_errors, + response=response_ + ) + + def test__handle_http_errors_invalid(self): + """Test the behaviour for bad status codes.""" + response_ = Response() + response_.status_code = 502 + response_.url = "https://api.eppo.int/gd/v2/status" + response_.method = "GET" + self.assertRaises(HTTPError, _handle_http_errors, response=response_) + + ##################### + # _parse_response() # + ##################### + + def test__parse_response_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _parse_response, response=123) + + def test__parse_response_valid(self): + """Test the behaviour for valid parameters and data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://api.eppo.int/gd/v2/status" + response_.method = "GET" + response_.headers["Content-Type"] = "application/json" + response_._content = (json.dumps({"data": "Custom data"}) + .encode("utf-8")) + self.assertIsInstance( + _parse_response(response=response_), + pd.DataFrame + ) + + def test__parse_response_invalid(self): + """Test the behaviour for invalid body data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://api.eppo.int/gd/v2/status" + response_.method = "GET" + response_.headers["Content-Type"] = "text/html" + response_._content = "content".encode("utf-8") + self.assertRaises( + JSONDecodeError, + _parse_response, + response=response_ + ) + + ###################### + # _enrich_response() # + ###################### + + def test__enrich_response_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _enrich_response, response_data=123, + url='') + self.assertRaises(TypeError, _enrich_response, + response_data=pd.DataFrame(), url=123) + + def test__enrich_response_valid(self): + """Test the behaviour for valid parameters.""" + data_ = _enrich_response( + response_data=pd.DataFrame({"data": ["Custom data"]}), + url="https://example.org" + ) + self.assertTrue("queried_on" in data_) + self.assertTrue("queried_url" in data_) + self.assertEqual(data_["queried_url"].iloc[0], + "https://example.org") + + ############ + # _query() # + ############ + + def test__query_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _query, endpoint=123, api_key='', + params=dict()) + self.assertRaises(ValueError, _query, endpoint="general", api_key='', + params=dict()) + self.assertRaises(TypeError, _query, endpoint="/general", api_key=123, + params=dict()) + self.assertRaises(TypeError, _query, endpoint="/general", api_key='', + params=123) + + @patch("eppopynder._utils._requests._perform_request") + def test__query_wrong_endpoint(self, mock_perform_request): + """Test the behaviour if a bad endpoint is given.""" + mock_perform_request.side_effect = HTTPError + self.assertRaises(HTTPError, _query, + endpoint="/taxons/taxon/BEMITA/badService", + api_key="EPPO_API_KEY") + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__query_wrong_endpoint_online(self): + """Test the behaviour if a bad endpoint is given.""" + self.assertRaises(HTTPError, _query, + endpoint="/taxons/taxon/BEMITA/badService", + api_key=os.getenv("EPPO_API_KEY")) + + @patch("eppopynder._utils._requests._perform_request") + def test__query_wrong_api_key(self, mock_perform_request): + """Test the behaviour if a bad API key is given.""" + mock_perform_request.side_effect = HTTPError + self.assertRaises(HTTPError, _query, + endpoint="/taxons/taxon/BEMITA/overview", + api_key="BAD_API_KEY") + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__query_wrong_api_key_online(self): + """Test the behaviour if a bad API key is given.""" + self.assertRaises(HTTPError, _query, + endpoint="/taxons/taxon/BEMITA/overview", + api_key="BAD_API_KEY") + + @patch("eppopynder._utils._requests._perform_request") + def test__query_valid(self, mock_perform_request): + """Test the behaviour for valid parameters.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://api.eppo.int/gd/v2/status" + response_.method = "GET" + response_.headers["Content-Type"] = "application/json" + response_._content = (json.dumps({"data": "Custom data"}) + .encode("utf-8")) + mock_perform_request.return_value = response_ + self.assertIsInstance( + _query( + endpoint="/status", + api_key="EPPO_API_KEY" + ), + pd.DataFrame + ) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__query_valid_online(self): + """Test the behaviour for valid parameters.""" + self.assertIsInstance( + _query( + endpoint="/taxons/taxon/BEMITA/overview", + api_key=os.getenv("EPPO_API_KEY") + ), + pd.DataFrame + ) + + #################### + # _fetch_service() # + #################### + + def test__fetch_service_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _fetch_service, base_path=123, api_key='', + code='', service='', params=dict()) + self.assertRaises(ValueError, _fetch_service, base_path="taxons/taxon", + api_key='', code='', service='', params=dict()) + self.assertRaises(TypeError, _fetch_service, base_path="/taxons/taxon", + api_key=123, code='', service='', params=dict()) + self.assertRaises(TypeError, _fetch_service, base_path="/taxons/taxon", + api_key='', code=123, service='', params=dict()) + self.assertRaises(TypeError, _fetch_service, base_path="/taxons/taxon", + api_key='', code='', service=123, params=dict()) + self.assertRaises(TypeError, _fetch_service, base_path="/taxons/taxon", + api_key='', code='', service='', params=123 + ) + + @patch("eppopynder._utils._requests._query") + def test__fetch_service_output(self, mock_query): + """Test the output type for valid parameters.""" + mock_query.return_value = pd.DataFrame({'a': [1]}) + self.assertIsInstance( + _fetch_service( + base_path="/taxons/taxon", + api_key="EPPO_API_KEY", + code="BEMITA", + service="overview" + ), + pd.DataFrame + ) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__fetch_service_output_online(self): + """Test the output type for valid parameters.""" + self.assertIsInstance( + _fetch_service( + base_path="/taxons/taxon", + api_key=os.getenv("EPPO_API_KEY"), + code="BEMITA", + service="overview" + ), + pd.DataFrame + ) diff --git a/tests/test__rppo.py b/tests/test__rppo.py new file mode 100644 index 0000000..654e13e --- /dev/null +++ b/tests/test__rppo.py @@ -0,0 +1,101 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv +from requests import HTTPError + +from eppopynder._core._rppo import _rppo, RPPOService + +load_dotenv() + + +class TestRPPO(unittest.TestCase): + + ########### + # _rppo() # + ########### + + def test__rppo_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _rppo, api_key=123, rppo_codes=[''], + services=list()) + self.assertRaises(TypeError, _rppo, api_key='', rppo_codes=123, + services=123) + self.assertRaises(TypeError, _rppo, api_key='', rppo_codes=[1, 2], + services=list()) + self.assertRaises(TypeError, _rppo, api_key='', rppo_codes=[''], + services=123) + self.assertRaises(TypeError, _rppo, api_key='', rppo_codes=[''], + services=[1, 2]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__rppo_output(self, mock_fetch_service): + """Test the output dict structure.""" + mock_fetch_service.return_value = pd.DataFrame() + services_ = [RPPOService.OVERVIEW] + data_ = _rppo( + api_key="EPPO_API_KEY", + rppo_codes=["9A"], + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[RPPOService.OVERVIEW], pd.DataFrame) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__rppo_output_online(self): + """Test the output dict structure.""" + services_ = [RPPOService.OVERVIEW] + data_ = _rppo( + api_key=os.getenv("EPPO_API_KEY"), + rppo_codes=["9A"], + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[RPPOService.OVERVIEW], pd.DataFrame) + + @patch("eppopynder._utils._requests._fetch_service") + def test__rppo_invalid_rppo(self, mock_fetch_service): + """Test the behaviour for invalid RPPO codes.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _rppo, api_key="EPPO_API_KEY", + rppo_codes=["BAD_RPPO_CODE"]) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__rppo_invalid_rppo_online(self): + """Test the behaviour for invalid RPPO codes.""" + self.assertRaises(HTTPError, _rppo, + api_key=os.getenv("EPPO_API_KEY"), + rppo_codes=["BAD_RPPO_CODE"]) + + def test__rppo_invalid_service(self): + """Test the behaviour for invalid services.""" + self.assertRaises(TypeError, _rppo, api_key="EPPO_API_KEY", + rppo_codes=["9A"], services=["badService"]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__rppo_invalid_key(self, mock_fetch_service): + """Test the behaviour for invalid API keys.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _rppo, api_key="BAD_API_KEY", + rppo_codes=["9A"]) + mock_fetch_service.side_effect = TypeError + self.assertRaises(TypeError, _rppo, api_key=None, rppo_codes=["9A"]) + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__rppo_invalid_key_online(self): + """Test the behaviour for invalid API keys.""" + self.assertRaises(HTTPError, _rppo, api_key="BAD_API_KEY", + rppo_codes=["9A"]) + self.assertRaises(TypeError, _rppo, + api_key=os.getenv("BAD_API_KEY"), rppo_codes=["9A"]) diff --git a/tests/test__taxon.py b/tests/test__taxon.py new file mode 100644 index 0000000..6478f53 --- /dev/null +++ b/tests/test__taxon.py @@ -0,0 +1,101 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv +from requests import HTTPError + +from eppopynder._core._taxon import _taxon, TaxonService + +load_dotenv() + + +class TestTaxon(unittest.TestCase): + + ############# + # _taxon() # + ############# + + def test__taxon_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _taxon, api_key=123, eppo_codes=[''], + services=list()) + self.assertRaises(TypeError, _taxon, api_key='', eppo_codes=123, + services=123) + self.assertRaises(TypeError, _taxon, api_key='', eppo_codes=[1, 2], + services=list()) + self.assertRaises(TypeError, _taxon, api_key='', eppo_codes=[''], + services=123) + self.assertRaises(TypeError, _taxon, api_key='', eppo_codes=[''], + services=[1, 2]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__taxon_output(self, mock_fetch_service): + """Test the output dict structure.""" + mock_fetch_service.return_value = pd.DataFrame() + services_ = [TaxonService.OVERVIEW] + data_ = _taxon( + api_key="EPPO_API_KEY", + eppo_codes=["BEMITA"], + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[TaxonService.OVERVIEW], pd.DataFrame) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__taxon_output_online(self): + """Test the output dict structure.""" + services_ = [TaxonService.OVERVIEW] + data_ = _taxon( + api_key=os.getenv("EPPO_API_KEY"), + eppo_codes=["BEMITA"], + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[TaxonService.OVERVIEW], pd.DataFrame) + + @patch("eppopynder._utils._requests._fetch_service") + def test__taxon_invalid_eppo(self, mock_fetch_service): + """Test the behaviour for invalid EPPO codes.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _taxon, api_key="EPPO_API_KEY", + eppo_codes=["BAD_EPPO_CODE"]) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__taxon_invalid_eppo_online(self): + """Test the behaviour for invalid EPPO codes.""" + self.assertRaises(HTTPError, _taxon, api_key=os.getenv("EPPO_API_KEY"), + eppo_codes=["BAD_EPPO_CODE"]) + + def test__taxon_invalid_service(self): + """Test the behaviour for invalid services.""" + self.assertRaises(TypeError, _taxon, api_key="EPPO_API_KEY", + eppo_codes=["BEMITA"], services=["badService"]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__taxons_invalid_key(self, mock_fetch_service): + """Test the behaviour for invalid API keys.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _taxon, api_key="BAD_API_KEY", + eppo_codes=["BEMITA"]) + mock_fetch_service.side_effect = TypeError + self.assertRaises(TypeError, _taxon, api_key=None, + eppo_codes=["BEMITA"]) + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__taxons_invalid_key_online(self): + """Test the behaviour for invalid API keys.""" + self.assertRaises(HTTPError, _taxon, api_key="BAD_API_KEY", + eppo_codes=["BEMITA"]) + self.assertRaises(TypeError, _taxon, api_key=os.getenv("BAD_API_KEY"), + eppo_codes=["BEMITA"]) diff --git a/tests/test__taxons.py b/tests/test__taxons.py new file mode 100644 index 0000000..9505cf0 --- /dev/null +++ b/tests/test__taxons.py @@ -0,0 +1,76 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv +from requests import HTTPError + +from eppopynder._core._taxons import _taxons, TaxonsService + +load_dotenv() + + +class TestTaxons(unittest.TestCase): + + ############# + # _taxons() # + ############# + + def test__taxons_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _taxons, api_key=123, services=list(), + params=dict()) + self.assertRaises(TypeError, _taxons, api_key='', services=123, + params=dict()) + self.assertRaises(TypeError, _taxons, api_key='', services=list(), + params=123) + + @patch("eppopynder._utils._requests._fetch_service") + def test__taxons_output(self, mock_fetch_service): + """Test the output dict structure.""" + mock_fetch_service.return_value = pd.DataFrame() + services_ = [TaxonsService.LIST] + data_ = _taxons( + api_key="EPPO_API_KEY", + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[TaxonsService.LIST], pd.DataFrame) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__taxons_output_online(self): + """Test the output dict structure.""" + services_ = [TaxonsService.LIST] + data_ = _taxons( + api_key=os.getenv("EPPO_API_KEY"), + services=services_ + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[TaxonsService.LIST], pd.DataFrame) + + def test__taxons_invalid_service(self): + """Test the behaviour for invalid services.""" + self.assertRaises(TypeError, _taxons, + api_key="EPPO_API_KEY", + services=["badService"]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__taxons_invalid_key(self, mock_fetch_service): + """Test the behaviour for invalid API keys.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _taxons, api_key="BAD_API_KEY") + mock_fetch_service.side_effect = TypeError + self.assertRaises(TypeError, _taxons, api_key=None) + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__taxons_invalid_key_online(self): + """Test the behaviour for invalid API keys.""" + self.assertRaises(HTTPError, _taxons, api_key="BAD_API_KEY") + self.assertRaises(TypeError, _taxons, api_key=os.getenv("BAD_API_KEY")) diff --git a/tests/test__tools.py b/tests/test__tools.py new file mode 100644 index 0000000..40a775a --- /dev/null +++ b/tests/test__tools.py @@ -0,0 +1,98 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv +from requests import HTTPError + +from eppopynder._core._tools import _tools, ToolsService + +load_dotenv() + + +class TestTools(unittest.TestCase): + + ############ + # _tools() # + ############ + + def test__tools_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _tools, api_key=123, services=list(), + params=dict()) + self.assertRaises(TypeError, _tools, api_key='', services=123, + params=dict()) + self.assertRaises(TypeError, _tools, api_key='', services=list(), + params=123) + + @patch("eppopynder._utils._requests._fetch_service") + def test__tools_output(self, mock_fetch_service): + """Test the output dict structure.""" + mock_fetch_service.return_value = pd.DataFrame() + services_ = [ToolsService.NAME2CODES] + data_ = _tools( + api_key="EPPO_API_KEY", + services=services_, + params={ToolsService.NAME2CODES: {"name": "Bemisia tabaci"}} + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[ToolsService.NAME2CODES], pd.DataFrame) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__tools_output_online(self): + """Test the output dict structure.""" + services_ = [ToolsService.NAME2CODES] + data_ = _tools( + api_key=os.getenv("EPPO_API_KEY"), + services=services_, + params={ToolsService.NAME2CODES: {"name": "Bemisia tabaci"}} + ) + self.assertIsInstance(data_, dict) + self.assertEqual(list(data_.keys()), services_) + self.assertIsInstance(data_[ToolsService.NAME2CODES], pd.DataFrame) + + @patch("eppopynder._utils._requests._fetch_service") + def test__tools_invalid_param(self, mock_fetch_service): + """Test the behaviour for invalid parameters.""" + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _tools, api_key="EPPO_API_KEY", + params={"name2codes": {"onlyPreferred": False}}) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__tools_invalid_param_online(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(HTTPError, _tools, api_key=os.getenv("EPPO_API_KEY"), + params={"name2codes": {"onlyPreferred": False}}) + + def test__tools_invalid_service(self): + """Test the behaviour for invalid services.""" + self.assertRaises(TypeError, _tools, api_key="EPPO_API_KEY", + services=["badService"]) + + @patch("eppopynder._utils._requests._fetch_service") + def test__tools_invalid_key(self, mock_fetch_service): + """Test the behaviour for invalid API keys.""" + params_ = {"name2codes": {"name": "Bemisia tabaci"}} + mock_fetch_service.side_effect = HTTPError + self.assertRaises(HTTPError, _tools, api_key="BAD_API_KEY", + params=params_) + mock_fetch_service.side_effect = TypeError + self.assertRaises(TypeError, _tools, api_key=None, params=params_) + + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__tools_invalid_key_online(self): + """Test the behaviour for invalid API keys.""" + params_ = {"name2codes": {"name": "Bemisia tabaci"}} + self.assertRaises(HTTPError, _tools, api_key="BAD_API_KEY", + params=params_) + self.assertRaises(TypeError, _tools, api_key=os.getenv("BAD_API_KEY"), + params=params_) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..6696c68 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,251 @@ +import os +import unittest +from unittest.mock import patch +from dotenv import load_dotenv + +from eppopynder.client import Client +from eppopynder._core._general import GeneralService +from eppopynder._core._taxons import TaxonsService +from eppopynder._core._taxon import TaxonService +from eppopynder._core._country import CountryService +from eppopynder._core._rppo import RPPOService +from eppopynder._core._tools import ToolsService +from eppopynder._core._reporting_service import ReportingServiceService +from eppopynder._core._references import ReferencesService + +load_dotenv() + + +class TestClient(unittest.TestCase): + + ############## + # __init__() # + ############## + + def test___init___types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, Client, api_key=123) + self.assertRaises(ValueError, Client, api_key='') + + def test___init___invalid_key(self): + """Test that ValueError is raised when EPPO_API_KEY is not set.""" + with patch.dict(os.environ, {}, clear=True): + with patch("eppopynder.client.load_dotenv"): + self.assertRaises(ValueError, Client) + + def test___init__1(self): + """Test the correct creation of the object.""" + with patch.dict(os.environ, { + "EPPO_API_KEY": "EPPO_API_KEY", + }, clear=True): + with patch("eppopynder.client.load_dotenv"): + self.assertIsInstance(Client(api_key="EPPO_API_KEY"), Client) + + # This test requires the EPPO_API_KEY environment variable to be set. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test___init__1_online(self): + """Test the correct creation of the object.""" + self.assertIsInstance(Client(), Client) + + def test___init__2(self): + """Test the correct creation of the object.""" + self.assertIsInstance(Client(api_key="EPPO_API_KEY"), Client) + + ############# + # general() # + ############# + + @patch("eppopynder._core._general._general") + def test_general(self, mock_general): + """Test the General endpoint.""" + mock_general.return_value = {} + client_ = Client(api_key="EPPO_API_KEY") + services_ = [GeneralService.STATUS] + data_ = client_.general(services=services_) + self.assertIsInstance(data_, dict) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_general_online(self): + """Test the General endpoint.""" + client_ = Client() + services_ = [GeneralService.STATUS] + data_ = client_.general(services=services_) + self.assertIsInstance(data_, dict) + + ############ + # taxons() # + ############ + + @patch("eppopynder._core._taxons._taxons") + def test_taxons(self, mock_taxons): + """Test the Taxons endpoint.""" + mock_taxons.return_value = {} + client_ = Client(api_key="EPPO_API_KEY") + services_ = [TaxonsService.LIST] + data_ = client_.taxons(services=services_) + self.assertIsInstance(data_, dict) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_taxons_online(self): + """Test the Taxons endpoint.""" + client_ = Client() + services_ = [TaxonsService.LIST] + data_ = client_.taxons(services=services_) + self.assertIsInstance(data_, dict) + + ########### + # taxon() # + ########### + + @patch("eppopynder._core._taxon._taxon") + def test_taxon(self, mock_taxon): + """Test the Taxon endpoint.""" + mock_taxon.return_value = {} + client_ = Client(api_key="EPPO_API_KEY") + services_ = [TaxonService.OVERVIEW] + data_ = client_.taxon(eppo_codes=["BEMITA"], services=services_) + self.assertIsInstance(data_, dict) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_taxon_online(self): + """Test the Taxon endpoint.""" + client_ = Client() + services_ = [TaxonService.OVERVIEW] + data_ = client_.taxon(eppo_codes=["BEMITA"], services=services_) + self.assertIsInstance(data_, dict) + + ############# + # country() # + ############# + + @patch("eppopynder._core._country._country") + def test_country(self, mock_country): + """Test the Country endpoint.""" + mock_country.return_value = {} + client_ = Client(api_key="EPPO_API_KEY") + services_ = [CountryService.OVERVIEW] + data_ = client_.country(iso_codes=["FR"], services=services_) + self.assertIsInstance(data_, dict) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_country_online(self): + """Test the Country endpoint.""" + client_ = Client() + services_ = [CountryService.OVERVIEW] + data_ = client_.country(iso_codes=["FR"], services=services_) + self.assertIsInstance(data_, dict) + + ########## + # rppo() # + ########## + + @patch("eppopynder._core._rppo._rppo") + def test_rppo(self, mock_rppo): + """Test the RPPO endpoint.""" + mock_rppo.return_value = {} + client_ = Client(api_key="EPPO_API_KEY") + services_ = [RPPOService.OVERVIEW] + data_ = client_.rppo(rppo_codes=["9A"], services=services_) + self.assertIsInstance(data_, dict) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_rppo_online(self): + """Test the RPPO endpoint.""" + client_ = Client() + services_ = [RPPOService.OVERVIEW] + data_ = client_.rppo(rppo_codes=["9A"], services=services_) + self.assertIsInstance(data_, dict) + + ########### + # tools() # + ########### + + @patch("eppopynder._core._tools._tools") + def test_tools(self, mock_tools): + """Test the Tools endpoint.""" + mock_tools.return_value = {} + client_ = Client(api_key="EPPO_API_KEY") + services_ = [ToolsService.NAME2CODES] + data_ = client_.tools( + services=services_, + params={ToolsService.NAME2CODES: {"name": "Bemisia tabaci"}} + ) + self.assertIsInstance(data_, dict) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_tools_online(self): + """Test the Tools endpoint.""" + client_ = Client() + services_ = [ToolsService.NAME2CODES] + data_ = client_.tools( + services=services_, + params={ToolsService.NAME2CODES: {"name": "Bemisia tabaci"}} + ) + self.assertIsInstance(data_, dict) + + ####################### + # reporting_service() # + ####################### + + @patch("eppopynder._core._reporting_service._reporting_service") + def test_reporting_service(self, mock_reporting_service): + """Test the Reporting Service endpoint.""" + mock_reporting_service.return_value = {} + client_ = Client(api_key="EPPO_API_KEY") + services_ = [ReportingServiceService.LIST] + data_ = client_.reporting_service(services=services_) + self.assertIsInstance(data_, dict) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_reporting_service_online(self): + """Test the Reporting Service endpoint.""" + client_ = Client() + services_ = [ReportingServiceService.LIST] + data_ = client_.reporting_service(services=services_) + self.assertIsInstance(data_, dict) + + ################ + # references() # + ################ + + @patch("eppopynder._core._references._references") + def test_references(self, mock_references): + """Test the References endpoint.""" + mock_references.return_value = {} + client_ = Client(api_key="EPPO_API_KEY") + services_ = [ReferencesService.Q_LIST] + data_ = client_.references(services=services_) + self.assertIsInstance(data_, dict) + + # This test requires the EPPO_API_KEY environment variable to be set. + # This test performs real requests to the EPPO API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_references_online(self): + """Test the References endpoint.""" + client_ = Client() + services_ = [ReferencesService.Q_LIST] + data_ = client_.references(services=services_) + self.assertIsInstance(data_, dict) diff --git a/tests/test_data_wrangling.py b/tests/test_data_wrangling.py new file mode 100644 index 0000000..8d4e483 --- /dev/null +++ b/tests/test_data_wrangling.py @@ -0,0 +1,36 @@ +import unittest +import pandas as pd + +from eppopynder.data_wrangling import uniform_taxonomy + + +class TestDataWrangling(unittest.TestCase): + + ###################### + # uniform_taxonomy() # + ###################### + + def test_uniform_taxonomy_types(self): + """Test if the parameters are of the correct types.""" + self.assertRaises(TypeError, uniform_taxonomy, taxonomy_data=123) + self.assertRaises(ValueError, uniform_taxonomy, + taxonomy_data=pd.DataFrame()) + self.assertRaises(ValueError, uniform_taxonomy, + taxonomy_data=pd.DataFrame({ + "queried_eppo_code": [None], + "type": ["type_a"] + })) + + def test_output(self): + """Test the output for correct parameters.""" + taxonomy_ = pd.DataFrame({ + "queried_eppo_code": ['A', 'A', 'A'], + "eppocode": ['D', 'E', 'F'], + "prefname": ['G', 'H', 'I'], + "level": ['1', '2', '3'], + "type": ['Kingdom', 'Class', 'Order'], + "queried_url": ['M', 'N', 'O'], + "queried_on": ['P', 'Q', 'R'], + }) + self.assertIsInstance(uniform_taxonomy(taxonomy_data=taxonomy_), + pd.DataFrame)