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
+
+[](https://lifecycle.r-lib.org/articles/stages.html#stable) [](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)