From df6711c5bf6e589bab39ddfcece87db0761fde9d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 7 Jul 2022 12:37:41 -0500 Subject: [PATCH] initial commit --- .gitignore | 7 + .vscode/settings.json | 23 + LICENSE | 674 ++++++++++++++++ README.md | 28 + examples/main.py | 91 +++ flake.lock | 43 + flake.nix | 50 ++ jtftp/__init__.py | 15 + jtftp/datagram.py | 518 +++++++++++++ jtftp/errors.py | 112 +++ jtftp/filesystem/__init__.py | 53 ++ jtftp/filesystem/inmemory.py | 102 +++ jtftp/filesystem/ondisk.py | 122 +++ jtftp/filesystem/test.py | 51 ++ jtftp/log.py | 31 + jtftp/netascii.py | 186 +++++ jtftp/protocol/__init__.py | 169 ++++ jtftp/protocol/remote_origin_read.py | 191 +++++ jtftp/protocol/remote_origin_write.py | 202 +++++ jtftp/util.py | 85 ++ poetry.lock | 1035 +++++++++++++++++++++++++ pyproject.toml | 28 + pytest.ini | 2 + test/test_datagram.py | 214 +++++ test/test_netascii.py | 58 ++ test/test_util.py | 85 ++ 26 files changed, 4175 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/main.py create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 jtftp/__init__.py create mode 100644 jtftp/datagram.py create mode 100644 jtftp/errors.py create mode 100644 jtftp/filesystem/__init__.py create mode 100644 jtftp/filesystem/inmemory.py create mode 100644 jtftp/filesystem/ondisk.py create mode 100644 jtftp/filesystem/test.py create mode 100644 jtftp/log.py create mode 100644 jtftp/netascii.py create mode 100644 jtftp/protocol/__init__.py create mode 100644 jtftp/protocol/remote_origin_read.py create mode 100644 jtftp/protocol/remote_origin_write.py create mode 100644 jtftp/util.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 test/test_datagram.py create mode 100644 test/test_netascii.py create mode 100644 test/test_util.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4041030 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/.venv +/result +__pycache__ +/.pytest_cache +/.hypothesis +/htmlcov +/.coverage diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..41dc525 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "python.formatting.provider": "black", + "editor.formatOnSave": true, + "files.autoSave": "afterDelay", + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "python.linting.flake8Enabled": true, + "python.linting.flake8Args": [ + "--max-line-length=88", + "--extend-ignore=E501", + "--extend-select=B950" + ], + "python.formatting.blackArgs": [ + "--line-length=88" + ], + "editor.codeActionsOnSave": { + "source.organizeImports": true, + }, + "python.sortImports.args": [ + "--force-single-line-imports" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d657a7d --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Jeff's TFTP + +TFTP server for Python using asyncio. Borrows heavily from [python-tx-tftp](https://github.com/shylent/python-tx-tftp) and +[aiotftp](https://github.com/sangoma/aiotftp). ``python-tx-tftp`` was not suitable for my needs as it's Python 3 support is limited, it uses Twisted instead of Python's native async support (Twisted is a fine library but I needed an implementation that ) + +## Features + +* Use of newer Python features + * asyncio + * type hinting + * enums + +## TODO + +* More tests +* TFTP client + +## Reference List +* [IEN 133](https://www.rfc-editor.org/ien/ien133.txt) - TFTP protocol +* [RFC 783](https://datatracker.ietf.org/doc/html/rfc783) - TFTP protocol revision 2 +* [RFC 1350](https://datatracker.ietf.org/doc/html/rfc1350) - TFTP protocol revision 2 +* [RFC 1782](https://datatracker.ietf.org/doc/html/rfc1782) - TFTP Option Extension +* [RFC 1783](https://datatracker.ietf.org/doc/html/rfc1783) - TFTP Blocksize Option +* [RFC 1784](https://datatracker.ietf.org/doc/html/rfc1784) - TFTP Timeout Interval and Transfer Size Options +* [RFC 1785](https://datatracker.ietf.org/doc/html/rfc1785) - TFTP Option Negotiation Analysis +* [RFC 2347](https://datatracker.ietf.org/doc/html/rfc2347) - TFTP Option Extension +* [RFC 2348](https://datatracker.ietf.org/doc/html/rfc2348) - TFTP Blocksize Option +* [RFC 2349](https://datatracker.ietf.org/doc/html/rfc2349) - TFTP Timeout Interval and Transfer Size Options diff --git a/examples/main.py b/examples/main.py new file mode 100644 index 0000000..9e06781 --- /dev/null +++ b/examples/main.py @@ -0,0 +1,91 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import asyncio +import functools +import logging +import pathlib +import sys +import traceback + +import arrow +from jtftp.filesystem.inmemory import InMemoryFilesystem +from jtftp.protocol import tftp_server_protocol_factory + +logger = logging.getLogger(__name__) + + +class Formatter(logging.Formatter): + def __init__(self): + logging.Formatter.__init__(self, "") + + def format(self, record: logging.LogRecord): + message = record.msg + if len(record.args) > 0: + message = record.msg % record.args + created = arrow.get(record.created).to("America/Chicago") + + name = record.name + if len(name) > 25: + name = name[:24] + "\u2026" + + msg = f"{created:YYYY-MM-DD HH:mm:ss.SSSSSS Z} [{name:^25s}] {record.levelname:8s} {message:s}" + + if record.exc_info is not None: + msg += "\n" + lines = traceback.format_exception(*record.exc_info) + for line in lines: + msg += line + + return msg.rstrip() + + def formatTime(self, record: logging.LogRecord, datefmt=None): # noqa: N802 + """Format the timestamp.""" + created = arrow.get(record.created) + return created.format("YYYY-MM-DD HH:mm:ss.SSSSSS Z") + + +def setup_logging() -> None: + """Set up logging.""" + formatter = Formatter() + console = logging.StreamHandler(stream=sys.stderr) + console.setFormatter(formatter) + console.setLevel(logging.DEBUG) + root = logging.getLogger() + for handler in root.handlers: + root.removeHandler(handler) + handler.close() + root.addHandler(console) + root.setLevel(logging.DEBUG) + + logging.getLogger("opentelemetry").setLevel(logging.DEBUG) + logging.getLogger("urllib3.connectionpool").setLevel(logging.DEBUG) + logging.getLogger("cisco.connection").setLevel(logging.DEBUG) + + +def serve(): + setup_logging() + # filesystem = TestFilesystem() + filesystem = InMemoryFilesystem() + loop = asyncio.get_event_loop() + future = loop.create_datagram_endpoint( + functools.partial( + tftp_server_protocol_factory, filesystem=filesystem, loop=loop + ), + local_addr=("::", 69), + ) + loop.run_until_complete(future) + loop.run_forever() diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c5459c1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1656065134, + "narHash": "sha256-oc6E6ByIw3oJaIyc67maaFcnjYOz1mMcOtHxbEf9NwQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "bee6a7250dd1b01844a2de7e02e4df7d8a0a206c", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1656335358, + "narHash": "sha256-t0PZIFCKgjF2WQ0NC6ORigdGgLqQD3vtT+Jx/dhjNsY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "62de360d75419386b69ea4af4a8ce104949a8242", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-22.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..bd426aa --- /dev/null +++ b/flake.nix @@ -0,0 +1,50 @@ +{ + inputs = { + nixpkgs = { + url = "github:NixOS/nixpkgs/nixos-22.05"; + }; + flake-utils = { + url = "github:numtide/flake-utils"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, ... }@inputs: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = + let + python = pkgs.python310.withPackages (ps: with ps; [ + poetry + ]); + in + pkgs.mkShell { + buildInputs = [ + python + ]; + shellHook = '' + export POETRY_VIRTUALENVS_IN_PROJECT=true + export PS1='\n\[\033[1;34m\][jtftp:\w]\$\[\033[0m\] ' + ''; + }; + packages = { + jtftp = pkgs.poetry2nix.mkPoetryApplication { + python = pkgs.python310; + projectDir = ./.; + }; + default = self.packages.${system}.jtftp; + }; + apps = { + default = { + type = "app"; + program = "${self.packages.${system}.jtftp}/bin/serve"; + }; + }; + } + ); +} diff --git a/jtftp/__init__.py b/jtftp/__init__.py new file mode 100644 index 0000000..1f4c914 --- /dev/null +++ b/jtftp/__init__.py @@ -0,0 +1,15 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . diff --git a/jtftp/datagram.py b/jtftp/datagram.py new file mode 100644 index 0000000..fd149df --- /dev/null +++ b/jtftp/datagram.py @@ -0,0 +1,518 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import itertools +import logging +import struct +from abc import ABC +from abc import abstractmethod +from collections import OrderedDict +from enum import Enum +from enum import IntEnum +from typing import Any + +from jtftp.errors import InvalidErrorcodeError +from jtftp.errors import InvalidOpcodeError +from jtftp.errors import OptionsDecodeError +from jtftp.errors import PayloadDecodeError +from jtftp.errors import WireProtocolError + +logger = logging.getLogger(__name__) + + +class TFTPOption(bytes, Enum): + BLOCKSIZE = b"blksize" + TIMEOUT = b"timeout" + TRANSFER_SIZE = b"tsize" + + @classmethod + def _missing_(cls, value: bytes | str): + if isinstance(value, str): + value = value.encode("ascii", "replace") + value = value.lower() + for member in cls: + if member.value == value: + return member + + +class TFTPMode(bytes, Enum): + MAIL = b"mail" + NETASCII = b"netascii" + OCTET = b"octet" + + @classmethod + def _missing_(cls, value: bytes | str): + if isinstance(value, str): + value = value.encode("ascii", "replace") + value = value.lower() + for member in cls: + if member.value == value: + return member + + +class TFTPOpcode(IntEnum): + RRQ = 1 + WRQ = 2 + DATA = 3 + ACK = 4 + ERROR = 5 + OACK = 6 + + +class TFTPError(IntEnum): + NOT_DEFINED = 0 + FILE_NOT_FOUND = 1 + ACCESS_VIOLATION = 2 + DISK_FULL = 3 + ILLEGAL_OPERATION = 4 + TID_UNKNOWN = 5 + FILE_EXISTS = 6 + NO_SUCH_USER = 7 + TERM_OPTION = 8 + + def message(self) -> bytes: + match self.value: + case self.NOT_DEFINED: + return b"" + case self.FILE_NOT_FOUND: + return b"File not found" + case self.ACCESS_VIOLATION: + return b"Access violation" + case self.DISK_FULL: + return b"Disk full or allocation exceeded" + case self.ILLEGAL_OPERATION: + return b"Illegal TFTP operation" + case self.TID_UNKNOWN: + return b"Unknown transfer ID" + case self.FILE_EXISTS: + return b"File already exists" + case self.NO_SUCH_USER: + return b"No such user" + case self.TERM_OPTION: + return b"Terminate transfer due to option negotiation" + + +def split_opcode(datagram: bytes) -> tuple[TFTPOpcode, bytes]: + """Split the raw datagram into opcode and payload. + @param datagram: raw datagram + @type datagram: C{bytes} + @return: a 2-tuple, the first item is the opcode and the second item is the payload + @rtype: (C{OP}, C{bytes}) + @raise WireProtocolError: if the opcode cannot be extracted + """ + + try: + opcode = struct.unpack(b"!H", datagram[:2])[0] + try: + opcode = TFTPOpcode(opcode) + except ValueError: + raise InvalidOpcodeError(opcode) + return opcode, datagram[2:] + except struct.error: + raise WireProtocolError("failed to extract the opcode") + + +def assert_options(options: dict) -> None: + if __debug__: + for name, value in options.items(): + assert isinstance( + name, TFTPOption + ), f"{name} ({type(name)}) is not a TFTPOption" + + +def decode_options(parts: list[bytes]) -> dict[TFTPOption, Any]: + if parts and not parts[-1]: + parts.pop(-1) + + # To maintain consistency during testing. + # The actual order of options is not important as per RFC2347 + options = OrderedDict() + + if len(parts) % 2: + raise OptionsDecodeError(f"no value for option {parts[-1]}") + + iparts = iter(parts) + for name, value in [(name, next(iparts, None)) for name in iparts]: + try: + name = TFTPOption(name) + + except ValueError: + raise OptionsDecodeError( + f"{name.decode('ascii', 'replace')!r} is not a valid option" + ) + + try: + match name: + case TFTPOption.BLOCKSIZE: + value = int(value) + if value < 8 or value > 65464: + raise OptionsDecodeError( + f"{value} is not a valid value for option {name.decode('ascii', 'replace')!r}" + ) + + case TFTPOption.TIMEOUT: + value = int(value) + if value < 1: + raise OptionsDecodeError( + f"{value} is not a valid value for option {name.decode('ascii', 'replace')!r}" + ) + + case TFTPOption.TRANSFER_SIZE: + value = int(value) + if value < 0: + raise OptionsDecodeError( + f"{value} is not a valid value for option {name.decode('ascii', 'replace')!r}" + ) + + except ValueError: + raise OptionsDecodeError( + f"{value.decode('ascii','replace')!r} is not a valid value for option {name.decode('ascii', 'replace')!r}" + ) + + if name in options: + raise OptionsDecodeError( + f"duplicate option specified: {name.decode('ascii', 'replace')!r}" + ) + + options[name] = value + + return options + + +def encode_options(options: dict[TFTPOption, Any]) -> list[bytes]: + parts = [] + for name, value in options.items(): + match name: + case TFTPOption.BLOCKSIZE | TFTPOption.TIMEOUT | TFTPOption.TRANSFER_SIZE: + parts.append(bytes(name)) + parts.append(f"{value:d}".encode("ascii")) + + case _: + raise WireProtocolError( + f"unknown option {name.decode('ascii','replace')}" + ) + return parts + + +class Datagram(ABC): + """Base class for datagrams + @cvar opcode: The opcode, corresponding to this datagram + @type opcode: C{Opcode} + """ + + opcode: TFTPOpcode + + @classmethod + @abstractmethod + def from_wire(cls, payload: bytes): + """Parse the payload and return a datagram object + @param payload: Binary representation of the payload (without the opcode) + @type payload: C{bytes} + """ + raise NotImplementedError("Subclasses must override this") + + @abstractmethod + def to_wire(self) -> bytes: + """Return the wire representation of the datagram. + @rtype: C{bytes} + """ + raise NotImplementedError("Subclasses must override this") + + +class RQDatagram(Datagram): + """Base class for "RQ" (request) datagrams. + @ivar filename: File name, that corresponds to this request. + @type filename: C{bytes} + @ivar mode: Transfer mode. Valid values are C{netascii} and C{octet}. + Case-insensitive. + @type mode: C{bytes} + @ivar options: Any options, that were requested by the client (as per + U{RFC2374} + @type options: C{dict} + """ + + filename: bytes + mode: TFTPMode + options: dict[TFTPOption, Any] + + @classmethod + def from_wire(cls, payload: bytes): + """Parse the payload and return a RRQ/WRQ datagram object. + @return: datagram object + @rtype: L{RRQDatagram} or L{WRQDatagram} + @raise OptionsDecodeError: if we failed to decode the options, requested + by the client + @raise PayloadDecodeError: if there were not enough fields in the payload. + Fields are terminated by NUL. + """ + parts = payload.split(b"\x00") + try: + filename, mode = parts.pop(0), parts.pop(0) + try: + mode = TFTPMode(mode) + except ValueError: + raise PayloadDecodeError( + f"{mode.decode('ascii', 'replace')!r} is not a valid mode" + ) + except IndexError: + raise PayloadDecodeError("Not enough fields in the payload") + + options = decode_options(parts) + + return cls(filename, mode, options) + + def __init__( + self, filename: bytes, mode: TFTPMode, options: dict[TFTPOption, bytes] + ): + assert isinstance(filename, bytes) + assert isinstance(mode, TFTPMode) + assert_options(options) + self.filename = filename + self.mode = mode + self.options = options + + def __repr__(self): + if self.options: + return f"<{self.__class__.__name__}(filename={self.filename}, mode={self.mode}, options={self.options})>" + return "<{self.__class__.__name__}(filename={self.filename}, mode={self.mode})>" + + def to_wire(self): + opcode = struct.pack(b"!H", self.opcode) + if self.options: + options = b"\x00".join(encode_options(self.options)) + return b"".join( + (opcode, self.filename, b"\x00", self.mode, b"\x00", options, b"\x00") + ) + else: + return b"".join((opcode, self.filename, b"\x00", self.mode, b"\x00")) + + +class RRQDatagram(RQDatagram): + opcode = TFTPOpcode.RRQ + + +class WRQDatagram(RQDatagram): + opcode = TFTPOpcode.WRQ + + +class OACKDatagram(Datagram): + """An OACK datagram + @ivar options: Any options, that were requested by the client (as per + U{RFC2374} + @type options: C{dict} + """ + + opcode = TFTPOpcode.OACK + options = dict[TFTPOption, Any] + + @classmethod + def from_wire(cls, payload: bytes): + """Parse the payload and return an OACK datagram object. + @return: datagram object + @rtype: L{OACKDatagram} + @raise OptionsDecodeError: if we failed to decode the options + """ + parts = payload.split(b"\x00") + options = decode_options(parts) + return cls(options) + + def __init__(self, options: dict[TFTPOption, Any]): + assert_options(options) + self.options = options + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}(options={self.options})>" + + def to_wire(self) -> bytes: + opcode = struct.pack(b"!H", self.opcode) + if self.options: + options = b"\x00".join(encode_options(self.options)) + return b"".join((opcode, options, b"\x00")) + else: + return opcode + + +class DATADatagram(Datagram): + """A DATA datagram + @ivar blocknum: A block number, that this chunk of data is associated with + @type blocknum: C{int} + @ivar data: binary data + @type data: C{bytes} + """ + + opcode = TFTPOpcode.DATA + payload: bytes + + @classmethod + def from_wire(cls, payload: bytes): + """Parse the payload and return a L{DATADatagram} object. + @param payload: Binary representation of the payload (without the opcode) + @type payload: C{bytes} + @return: A L{DATADatagram} object + @rtype: L{DATADatagram} + @raise PayloadDecodeError: if the format of payload is incorrect + """ + try: + block_number, payload = struct.unpack(b"!H", payload[:2])[0], payload[2:] + except struct.error: + raise PayloadDecodeError() + return cls(block_number, payload) + + def __init__(self, block_number: int, payload: bytes): + assert isinstance(payload, bytes) + self.block_number = block_number + self.payload = payload + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}(blocknum={self.block_number}, {len(self.payload)} bytes of data)>" + + def to_wire(self) -> bytes: + return b"".join( + (struct.pack(b"!HH", self.opcode, self.block_number), self.payload) + ) + + +class ACKDatagram(Datagram): + """An ACK datagram. + @ivar blocknum: Block number of the data chunk, which this datagram is supposed to acknowledge + @type blocknum: C{int} + """ + + opcode = TFTPOpcode.ACK + block_number: int + + @classmethod + def from_wire(cls, payload: bytes): + """Parse the payload and return a L{ACKDatagram} object. + @param payload: Binary representation of the payload (without the opcode) + @type payload: C{bytes} + @return: An L{ACKDatagram} object + @rtype: L{ACKDatagram} + @raise PayloadDecodeError: if the format of payload is incorrect + """ + try: + block_number = struct.unpack(b"!H", payload)[0] + except struct.error: + raise PayloadDecodeError("Unable to extract the block number") + return cls(block_number) + + def __init__(self, blocknum: int): + self.block_number = blocknum + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}(block_number={self.block_number})>" + + def to_wire(self) -> bytes: + return struct.pack(b"!HH", self.opcode, self.block_number) + + +class ERRORDatagram(Datagram): + """An ERROR datagram. + @ivar errorcode: A valid TFTP error code + @type errorcode: C{int} + @ivar errmsg: An error message, describing the error condition in which this + datagram was produced + @type errmsg: C{bytes} + """ + + opcode = TFTPOpcode.ERROR + error_code: TFTPError + error_message: bytes + + @classmethod + def from_wire(cls, payload: bytes): + """Parse the payload and return a L{ERRORDatagram} object. + This method violates the standard a bit - if the error string was not + extracted, a default error string is generated, based on the error code. + @param payload: Binary representation of the payload (without the opcode) + @type payload: C{bytes} + @return: An L{ERRORDatagram} object + @rtype: L{ERRORDatagram} + @raise PayloadDecodeError: if the format of payload is incorrect + @raise InvalidErrorcodeError: a more specific exception, that is raised + if the error code was successfully, extracted, but it does not correspond + to any known/standartized error code values. + """ + try: + error_code = struct.unpack(b"!H", payload[:2])[0] + try: + error_code = TFTPError(error_code) + except ValueError as e: + raise InvalidErrorcodeError(error_code) + except struct.error: + raise PayloadDecodeError("Unable to extract the error code") + + error_message = payload[2:].split(b"\x00")[0] + if not error_message: + error_message = error_code.message() + return cls(error_code, error_message) + + @classmethod + def from_code(cls, error_code: TFTPError, error_message: bytes | str | None = None): + """Create an L{ERRORDatagram}, given an error code and, optionally, an + error message to go with it. If not provided, default error message for + the given error code is used. + @param error_code: An error code + @type error_code: L{TFTPError} + @param error_message: An error message (optional) + @type error_message: C{bytes} or C{str} or C{NoneType} + @raise InvalidErrorcodeError: if the error code is not known + @return: an L{ERRORDatagram} + @rtype: L{ERRORDatagram} + """ + assert isinstance(error_code, TFTPError) + if isinstance(error_message, str): + error_message = error_message.encode("ascii", "replace") + elif error_message is None: + error_message = error_code.message() + assert isinstance(error_message, bytes) + return cls(error_code, error_message) + + def __init__(self, error_code: TFTPError, error_message: bytes): + assert isinstance(error_message, bytes) + self.error_code = error_code + self.error_message = error_message + + def to_wire(self) -> bytes: + return b"".join( + ( + struct.pack(b"!HH", self.opcode, self.error_code), + self.error_message, + b"\x00", + ) + ) + + +class _DatagramFactory: + classes: dict[TFTPOpcode, Datagram] = { + TFTPOpcode.ACK: ACKDatagram, + TFTPOpcode.DATA: DATADatagram, + TFTPOpcode.ERROR: ERRORDatagram, + TFTPOpcode.OACK: OACKDatagram, + TFTPOpcode.RRQ: RRQDatagram, + TFTPOpcode.WRQ: WRQDatagram, + } + + def __call__(self, datagram: bytes) -> Datagram: + opcode, payload = split_opcode(datagram) + try: + cls = self.classes[opcode] + except KeyError: + raise InvalidOpcodeError(opcode.value) + return cls.from_wire(payload) + + +datagram_factory = _DatagramFactory() diff --git a/jtftp/errors.py b/jtftp/errors.py new file mode 100644 index 0000000..66b80dc --- /dev/null +++ b/jtftp/errors.py @@ -0,0 +1,112 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + + +class TFTPError(Exception): + """Base exception class for this package""" + + +class WireProtocolError(TFTPError): + """Base exception class for wire-protocol level errors""" + + +class InvalidOpcodeError(WireProtocolError): + """An invalid opcode was encountered""" + + opcode: int + + def __init__(self, opcode: int): + self.opcode = opcode + super().__init__(f"Invalid opcode: {opcode}") + + +class PayloadDecodeError(WireProtocolError): + """Failed to parse the payload""" + + +class OptionsDecodeError(PayloadDecodeError): + """Failed to parse options in the WRQ/RRQ datagram. It is distinct from + L{PayloadDecodeError} so that it can be caught and dealt with gracefully + (pretend we didn't see any options at all, perhaps). + + """ + + +class InvalidErrorcodeError(PayloadDecodeError): + """An ERROR datagram has an error code, that does not correspond to any known + error code values. + + @ivar errorcode: The error code, that we were unable to parse + @type errorcode: C{int} + + """ + + error_code: int + + def __init__(self, error_code: int): + self.error_code = error_code + super().__init__(f"unknown error code: {error_code}") + + +class BackendError(TFTPError): + """Base exception class for backend errors""" + + +class Unsupported(BackendError): + """Requested operation (read/write) is not supported""" + + +class AccessViolation(BackendError): + """Illegal filesystem operation. Corresponds to the "(2) Access violation" + TFTP error code. + + One of the prime examples of these is an attempt at directory traversal. + + """ + + +class FileNotFound(BackendError): + """File not found. + + Corresponds to the "(1) File not found" TFTP error code. + + @ivar file_path: Path to the file, that was requested + @type file_path: C{bytes} or L{twisted.python.filepath.FilePath} + + """ + + def __init__(self, file_path): + self.file_path = file_path + + def __str__(self): + return f"File not found: {self.file_path}" + + +class FileExists(BackendError): + """File exists. + + Corresponds to the "(6) File already exists" TFTP error code. + + @ivar file_path: Path to file + @type file_path: C{bytes} or L{twisted.python.filepath.FilePath} + + """ + + def __init__(self, file_path): + self.file_path = file_path + + def __str__(self): + return f"File already exists: {self.file_path}" diff --git a/jtftp/filesystem/__init__.py b/jtftp/filesystem/__init__.py new file mode 100644 index 0000000..29a82c1 --- /dev/null +++ b/jtftp/filesystem/__init__.py @@ -0,0 +1,53 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie + +# 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 . + + +import logging +from typing import Protocol + +logger = logging.getLogger(__name__) + +from enum import Enum + + +class FileMode(str, Enum): + BINARY_READ = "rb" + BINARY_WRITE = "wb" + + +class FileProtocol(Protocol): + closed: bool + + async def length(self) -> int | None: + """Return the length of the file, or None if the length cannot be determined.""" + raise NotImplementedError + + async def seek(self, offset: int, whence: int) -> int: + raise NotImplementedError + + async def read(self, length: int) -> bytes: + raise NotImplementedError + + async def write(self, data: bytes) -> int: + raise NotImplementedError + + async def close(self, complete: bool) -> None: + raise NotImplementedError + + +class FilesystemProtocol(Protocol): + async def open(self, filename: bytes, mode: FileMode) -> FileProtocol: + raise NotImplementedError diff --git a/jtftp/filesystem/inmemory.py b/jtftp/filesystem/inmemory.py new file mode 100644 index 0000000..e5879ba --- /dev/null +++ b/jtftp/filesystem/inmemory.py @@ -0,0 +1,102 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie + +# 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 . + + +import io +import logging +from typing import Awaitable +from typing import Callable + +from jtftp.filesystem import FileMode + +logger = logging.getLogger(__name__) + + +class InMemoryFile: + filename: bytes + mode: FileMode + data: io.BytesIO + closed: bool + complete_callback: Callable[[bytes, bytes], Awaitable[None]] | None + incomplete_callback: Callable[[bytes, bytes], Awaitable[None]] | None + + def __init__( + self, + filename: bytes, + mode: FileMode, + *, + initial_bytes: bytes | None = None, + complete_callback: Callable[[bytes, bytes], Awaitable[None]], + incomplete_callback: Callable[[bytes, bytes], Awaitable[None]], + ): + logger.debug(f"myfile {filename} {mode}") + self.filename = filename + self.mode = mode + self.data = io.BytesIO(initial_bytes) + self.complete_callback = complete_callback + self.incomplete_callback = incomplete_callback + self.closed = False + + async def length(self) -> int: + logger.debug("myfile length") + return len(self.data.getbuffer()) + + async def seek(self, offset: int, whence: int) -> int: + logger.debug(f"myfile seek {offset} {whence}") + return self.data.seek(offset, whence) + + async def read(self, length: int) -> bytes: + logger.debug(f"myfile read {length}") + return self.data.read(length) + + async def write(self, data: bytes) -> int: + logger.debug(f"myfile write {len(data)}") + return self.data.write(data) + + async def close(self, complete: bool) -> None: + logger.debug(f"myfile close {complete}") + if complete: + await self.complete_callback(self.filename, self.data.getvalue()) + else: + await self.incomplete_callback(self.filename, self.data.getvalue()) + self.closed = True + + +class InMemoryFilesystem: + files: dict[bytes, bytes] + + def __init__(self): + self.files = {} + + async def open(self, filename: bytes, mode: FileMode) -> InMemoryFile: + logger.debug(f"InMemoryFilesystem open {filename} {mode}") + match mode: + case FileMode.BINARY_READ: + if filename not in self.files: + raise FileNotFoundError + + return InMemoryFile(filename, mode, initial_bytes=self.files[filename]) + + case FileMode.BINARY_WRITE: + return InMemoryFile( + filename, mode, complete_callback=self._complete_callback + ) + + case _: + raise ValueError + + async def _complete_callback(self, filename: bytes, data: bytes): + self.files[filename] = data diff --git a/jtftp/filesystem/ondisk.py b/jtftp/filesystem/ondisk.py new file mode 100644 index 0000000..0c1ad78 --- /dev/null +++ b/jtftp/filesystem/ondisk.py @@ -0,0 +1,122 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie + +# 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 . + +# +# Warning!!! This does not actually work because aiofiles does not support +# usage outside of a context manager. See: https://github.com/Tinche/aiofiles/issues/139 +# + +import functools +import logging +import os +import pathlib +from typing import Callable +from typing import TypeVar + +import aiofiles +import aiofiles.os +from jtftp.errors import AccessViolation +from jtftp.filesystem import FileMode + +logger = logging.getLogger(__name__) + +RT = TypeVar("RT") + + +def ensure_open(method: Callable[..., RT]) -> Callable[..., RT]: + @functools.wraps(method) + async def _impl(self, *args, **kwargs) -> RT: + logger.debug("ensure open") + if self.data is None: + await self.open() + return await method(self, *args, **kwargs) + + return _impl + + +class OnDiskFile: + path: bytes + mode: FileMode + data: aiofiles.threadpool.binary.AsyncFileIO | None + closed: bool + + def __init__(self, path: pathlib.PosixPath, mode: FileMode): + logger.debug(f"myfile {path} {mode}") + self.path = path + self.mode = mode + self.data = None + self.closed = True + + async def open(self) -> None: + logger.debug("diskfile open") + self.data = aiofiles.open(self.path, self.mode.value) + self.closed = False + + @ensure_open + async def length(self) -> int: + logger.debug("diskfile length") + cur = await self.data.seek(0, os.SEEK_CUR) + logger.debug(f"diskfile cur {cur}") + length = await self.data.seek(0, os.SEEK_END) + logger.debug(f"diskfile len {length}") + await self.data.seek(cur, os.SEEK_SET) + return length + + @ensure_open + async def seek(self, offset: int, whence: int) -> int: + logger.debug(f"diskfile seek {offset} {whence}") + return await self.data.seek(offset, whence) + + @ensure_open + async def read(self, length: int) -> bytes: + logger.debug(f"diskfile read {length}") + return await self.data.read(length) + + @ensure_open + async def write(self, data: bytes) -> int: + logger.debug(f"diskfile write {len(data)}") + return await self.data.write(data) + + @ensure_open + async def close(self, complete: bool) -> None: + logger.debug(f"diskfile close {complete}") + self.closed = True + if self.data is not None: + await self.data.close() + self.data = None + + +class ReadOnlyOnDiskFilesystem: + def __init__(self, root: pathlib.PosixPath): + self.root = root.resolve() + + async def open(self, filename: bytes, mode: FileMode) -> OnDiskFile: + path = self.root.joinpath(filename.decode("ascii")).resolve() + try: + path.relative_to(self.root) + except ValueError: + raise AccessViolation("illegal directory traversal") + + logger.debug(f"ro ondisk open {filename} {mode}") + match mode: + case FileMode.BINARY_READ: + return OnDiskFile(path, mode) + + case FileMode.BINARY_WRITE: + raise AccessViolation("read-only") + + case _: + raise ValueError diff --git a/jtftp/filesystem/test.py b/jtftp/filesystem/test.py new file mode 100644 index 0000000..f6238f2 --- /dev/null +++ b/jtftp/filesystem/test.py @@ -0,0 +1,51 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie + +# 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 . + + +import io +import logging +import os +import random + +from jtftp.errors import AccessViolation +from jtftp.filesystem import FileMode +from jtftp.filesystem.inmemory import InMemoryFile + +logger = logging.getLogger(__name__) + + +class TestFilesystem: + async def open(self, filename: bytes, mode: FileMode) -> InMemoryFile: + logger.debug(f"TestFilesystem open {filename} {mode}") + match mode: + case FileMode.BINARY_READ: + if filename == b"random": + return InMemoryFile( + filename, + mode, + initial_bytes=os.urandom(random.randint(128, 16384)), + ) + + raise FileNotFoundError + + case FileMode.BINARY_WRITE: + if filename.startswith(b"config"): + return InMemoryFile(filename, mode) + + raise AccessViolation + + case _: + raise ValueError diff --git a/jtftp/log.py b/jtftp/log.py new file mode 100644 index 0000000..bdb85d0 --- /dev/null +++ b/jtftp/log.py @@ -0,0 +1,31 @@ +"""Configure logging.""" + +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +def handle_task_result(task: asyncio.Task) -> None: + try: + task.result() + except asyncio.CancelledError: + pass + except Exception as e: + logger.exception(f"exception! {e}") diff --git a/jtftp/netascii.py b/jtftp/netascii.py new file mode 100644 index 0000000..2add415 --- /dev/null +++ b/jtftp/netascii.py @@ -0,0 +1,186 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import os +import re +from enum import Enum + +from jtftp.filesystem import FileProtocol + + +class NetAsciiBase(bytes, Enum): + CR: bytes + LF: bytes + CRLF: bytes + NUL: bytes + CRNUL: bytes + NL: bytes + _re_from_netascii: re.Pattern[bytes] + _re_to_netascii: re.Pattern[bytes] + + @classmethod + def _convert_from_netascii(cls, match_obj: re.Match) -> bytes | None: + match match_obj.group(1): + case cls.CRNUL: + return cls.CR + case cls.CRLF: + return cls.NL + + @classmethod + def _convert_to_netascii(cls, match_obj: re.Match) -> bytes | None: + match match_obj.group(1): + case cls.NL: + return cls.CRLF + case cls.CR: + return cls.CRNUL + + @classmethod + def from_netascii(cls, data: bytes) -> bytes: + """Convert a netascii-encoded string into a string with platform-specific + newlines. + """ + if not hasattr(cls, "_re_from_netascii"): + cls._re_from_netascii = re.compile(rb"(\x0d\x0a|\x0d\x00)") + + return cls._re_from_netascii.sub(cls._convert_from_netascii, data) + + @classmethod + def to_netascii(cls, data: bytes) -> bytes: + """Convert a string with platform-specific newlines into netascii.""" + if not hasattr(cls, "_re_from_netascii"): + cls._re_to_netascii = re.compile(rb"(" + cls.NL + rb"|\x0d\x00)") + + return cls._re_to_netascii.sub(cls._convert_to_netascii, data) + + +class NetAsciiCR(NetAsciiBase): + CR = b"\x0d" + LF = b"\x0a" + CRLF = b"\x0d\x0a" # CR + LF + NUL = b"\x00" + CRNUL = b"\x0d\x00" # CR + NUL + NL = b"\x0d" + + +class NetAsciiLF(NetAsciiBase): + CR = b"\x0d" + LF = b"\x0a" + CRLF = b"\x0d\x0a" # CR + LF + NUL = b"\x00" + CRNUL = b"\x0d\x00" # CR + NUL + NL = b"\x0a" + + +class NetAsciiCRLF(NetAsciiBase): + CR = b"\x0d" + LF = b"\x0a" + CRLF = b"\x0d\x0a" # CR + LF + NUL = b"\x00" + CRNUL = b"\x0d\x00" # CR + NUL + NL = b"\x0d\x0a" + + +NetAscii: NetAsciiBase + +match os.linesep: + case "\x0d": + NetAscii = NetAsciiCR + case "\x0a": + NetAscii = NetAsciiLF + case "\x0d\x0a": + NetAscii = NetAsciiCRLF + case _: + raise RuntimeError(f"{os.linesep!r} is not a supported line separator") + + +class NetAsciiReceiverProxy: + writer: FileProtocol + netascii: NetAsciiBase + carry_cr: bool + + def __init__(self, writer: FileProtocol, netascii: NetAsciiBase = NetAscii): + self.writer = writer + self.netascii = netascii + self.carry_cr = False + + @property + def closed(self) -> bool: + return self.writer.closed + + async def length(self) -> int | None: + return None + + async def seek(self, offset: int, whence: int) -> int: + raise RuntimeError(f"{self.__class__.__name__} cannot seek") + + async def read(self, length: int) -> bytes: + raise RuntimeError(f"{self.__class__.__name__} cannot read") + + async def write(self, data: bytes) -> int: + if self.carry_cr: + data = self.netascii.CR + data + + data = self.netascii.from_netascii(data) + + if data.endswith(self.netascii.CR): + self.carry_cr = True + return await self.writer.write(data[:-1]) + + else: + self.carry_cr = False + return await self.writer.write(data) + + async def close(self, complete: bool): + if self.carry_cr: + await self.writer.write(self.netascii.CR) + await self.writer.close(complete) + + +class NetAsciiSenderProxy: + reader: FileProtocol + netascii: NetAsciiBase + buffer: bytes + + def __init__(self, reader: FileProtocol, netascii: NetAsciiBase = NetAscii): + self.reader = reader + self.netascii = netascii + self.buffer = b"" + + @property + def closed(self) -> bool: + return self.reader.closed + + async def length(self) -> int | None: + return None + + async def seek(self, offset: int, whence: int) -> int: + raise RuntimeError(f"{self.__class__.__name__} cannot seek") + + async def read(self, length: int) -> bytes: + need_bytes = length - len(self.buffer) + if need_bytes <= 0: + data, self.buffer = self.buffer[:length], self.buffer[length:] + return data + + data = await self.reader.read(need_bytes) + data = self.buffer + self.netascii.to_netascii(data) + data, self.buffer = data[:length], data[length:] + + async def write(self, data: bytes) -> int: + raise RuntimeError(f"{self.__class__.__name__} cannot write") + + async def close(self, complete: bool): + await self.reader.close(complete) diff --git a/jtftp/protocol/__init__.py b/jtftp/protocol/__init__.py new file mode 100644 index 0000000..9372a96 --- /dev/null +++ b/jtftp/protocol/__init__.py @@ -0,0 +1,169 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import asyncio +import functools +import logging + +from jtftp.datagram import Datagram +from jtftp.datagram import ERRORDatagram +from jtftp.datagram import RRQDatagram +from jtftp.datagram import TFTPError +from jtftp.datagram import TFTPMode +from jtftp.datagram import WRQDatagram +from jtftp.datagram import datagram_factory +from jtftp.errors import OptionsDecodeError +from jtftp.errors import PayloadDecodeError +from jtftp.filesystem import FileMode +from jtftp.filesystem import FilesystemProtocol +from jtftp.log import handle_task_result +from jtftp.netascii import NetAscii +from jtftp.netascii import NetAsciiReceiverProxy +from jtftp.netascii import NetAsciiSenderProxy +from jtftp.protocol.remote_origin_read import remote_origin_read_protocol +from jtftp.protocol.remote_origin_write import remote_origin_write_protocol + +logger = logging.getLogger(__name__) + + +class TftpServerProtocol(asyncio.DatagramProtocol): + filesystem: FilesystemProtocol + loop: asyncio.AbstractEventLoop + + def __init__( + self, filesystem: FilesystemProtocol, loop: asyncio.AbstractEventLoop = None + ): + super().__init__() + self.filesystem = filesystem + if loop is None: + loop = asyncio.get_event_loop() + self.loop = loop + + def connection_made(self, transport: asyncio.transports.DatagramTransport) -> None: + logger.debug(f"listening on made {transport.get_extra_info('sockname')[:2]}") + self.transport = transport + + async def send(self, datagram: Datagram, tid: tuple[str, int]) -> None: + self.transport.sendto(datagram.to_wire(), tid) + + def datagram_received( + self, data: bytes, addr: tuple[str, int] | tuple[str, int, int, int] + ) -> None: + logger.debug(f"datagram received: {data!r} {addr!r}") + + tid = addr[:2] + + task = self.loop.create_task(self._datagram_received(data, tid)) + task.add_done_callback(handle_task_result) + + async def _datagram_received(self, data: bytes, tid: tuple[str, int]) -> None: + try: + datagram = datagram_factory(data) + except OptionsDecodeError as e: + await self.send( + ERRORDatagram.from_code(TFTPError.ILLEGAL_OPERATION, str(e)), + tid, + ) + return + except PayloadDecodeError as e: + await self.send( + ERRORDatagram.from_code(TFTPError.ILLEGAL_OPERATION, str(e)), + tid, + ) + return + + if not isinstance(datagram, (RRQDatagram, WRQDatagram)): + logger.warning( + f"Datagram with unexpected opcode {datagram.opcode} was received without establishing the session. Ignoring." + ) + return + + if datagram.mode == TFTPMode.MAIL: + errmsg = f"Usupported transfer mode '{datagram.mode.decode('ascii')}'" + await self.send( + ERRORDatagram.from_code(TFTPError.ILLEGAL_OPERATION, errmsg), + tid, + ) + return + + match datagram: + case WRQDatagram(): + try: + file = await self.filesystem.open( + datagram.filename, FileMode.BINARY_WRITE + ) + + if datagram.mode != TFTPMode.OCTET: + file = NetAsciiReceiverProxy(file) + + protocol = functools.partial( + remote_origin_write_protocol, + file=file, + options=datagram.options, + loop=self.loop, + ) + except PermissionError: + await self.send( + ERRORDatagram.from_code(TFTPError.ACCESS_VIOLATION), + tid, + ) + return + except FileExistsError: + await self.send( + ERRORDatagram.from_code(TFTPError.FILE_NOT_FOUND), + tid, + ) + return + + case RRQDatagram(): + try: + file = await self.filesystem.open( + datagram.filename, FileMode.BINARY_READ + ) + + if datagram.mode != TFTPMode.OCTET: + file = NetAsciiSenderProxy(file) + + protocol = functools.partial( + remote_origin_read_protocol, + file=file, + options=datagram.options, + loop=self.loop, + ) + except PermissionError: + await self.send( + ERRORDatagram.from_code(TFTPError.ACCESS_VIOLATION), + tid, + ) + return + except FileNotFoundError: + await self.send( + ERRORDatagram.from_code(TFTPError.FILE_NOT_FOUND), + tid, + ) + return + + await self.loop.create_datagram_endpoint( + protocol, + local_addr=None, + remote_addr=tid, + ) + + +def tftp_server_protocol_factory( + *, filesystem: FilesystemProtocol, loop: asyncio.AbstractEventLoop = None +) -> TftpServerProtocol: + return TftpServerProtocol(filesystem=filesystem, loop=loop) diff --git a/jtftp/protocol/remote_origin_read.py b/jtftp/protocol/remote_origin_read.py new file mode 100644 index 0000000..c54e957 --- /dev/null +++ b/jtftp/protocol/remote_origin_read.py @@ -0,0 +1,191 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import asyncio +import functools +import itertools +import logging +from collections import OrderedDict + +from jtftp.datagram import ACKDatagram +from jtftp.datagram import DATADatagram +from jtftp.datagram import Datagram +from jtftp.datagram import ERRORDatagram +from jtftp.datagram import OACKDatagram +from jtftp.datagram import TFTPError +from jtftp.datagram import TFTPMode +from jtftp.datagram import TFTPOption +from jtftp.datagram import datagram_factory +from jtftp.filesystem import FileProtocol +from jtftp.log import handle_task_result +from jtftp.util import timed_caller + +logger = logging.getLogger(__name__) + + +class RemoteOriginReadProtocol(asyncio.DatagramProtocol): + block_size: int + timeout: tuple[int, int, int] + transfer_size: int | None + retransmit_task: asyncio.Task | None + last_block_number: int + last_block_sent: False + offered_options: dict[TFTPOption, bytes] + accepted_options: dict[TFTPOption, bytes] + local_tid = tuple[str, int] + remote_tid = tuple[str, int] + + def __init__( + self, + *, + file: FileProtocol, + options: dict[TFTPOption, bytes], + loop: asyncio.AbstractEventLoop, + ) -> None: + super().__init__() + + self.file = file + self.loop = loop + + self.block_size = 512 + self.timeout = (1, 3, 7) + self.transfer_size = None + self.last_block_number = 0 + self.last_block_sent = False + self.offered_options = options + self.accepted_options = OrderedDict() + self.remote_tid = tuple[str, int] + + def connection_made(self, transport: asyncio.transports.DatagramTransport) -> None: + self.transport = transport + self.local_tid = transport.get_extra_info("sockname")[:2] + self.remote_tid = transport.get_extra_info("peername")[:2] + task = self.loop.create_task(self._connection_made()) + task.add_done_callback(handle_task_result) + + async def _connection_made(self): + logger.debug(f"new session between {self.local_tid} → {self.remote_tid}") + for name, value in self.offered_options.items(): + logger.debug(f"{name!r} {value!r}") + match name: + case TFTPOption.BLOCKSIZE: + if value >= 8 and value <= 65464: + self.block_size = value + self.accepted_options[name] = value + + case TFTPOption.TIMEOUT: + self.timeout = (value,) * 3 + self.accepted_options[name] = value + + case TFTPOption.TRANSFER_SIZE: + self.transfer_size = await self.file.length() + if self.transfer_size is not None: + self.accepted_options[name] = self.transfer_size + + case _: + logger.warning(f"unknown option {name}: {value}") + + if len(self.offered_options) != 0: + data = OACKDatagram(self.accepted_options) + else: + data = await self._get_next_block() + + self.retransmit_task = self.loop.create_task( + timed_caller( + itertools.chain((0,), self.timeout), + functools.partial(self.send, data), + self._timed_out, + ) + ) + self.retransmit_task.add_done_callback(handle_task_result) + + def connection_lost(self, exc: Exception | None) -> None: + return super().connection_lost(exc) + + async def send(self, datagram: Datagram): + self.transport.sendto(datagram.to_wire()) + + def datagram_received( + self, datagram: bytes, addr: tuple[str, int] | tuple[str, int, int, int] + ) -> None: + tid = addr[:2] + + if self.remote_tid != tid: + logger.error(f"packet from unknown sender {tid}") + return + + task = self.loop.create_task(self._datagram_received(datagram)) + task.add_done_callback(handle_task_result) + + async def _datagram_received(self, datagram: bytes) -> None: + datagram = datagram_factory(datagram) + if isinstance(datagram, ACKDatagram): + if datagram.block_number == self.last_block_number: + if ( + self.retransmit_task is not None + and not self.retransmit_task.cancelled() + ): + self.retransmit_task.cancel() + self.retransmit_task = None + + if self.last_block_sent: + await self.file.close(complete=True) + self.transport.close() + return + + data = await self._get_next_block() + self.retransmit_task = asyncio.create_task( + timed_caller( + itertools.chain((0,), self.timeout), + functools.partial(self.send, data), + self._timed_out, + ) + ) + self.retransmit_task.add_done_callback(handle_task_result) + else: + logger.warning( + f"received ack for block number {datagram.block_number} - was expecting {self.last_block_number}" + ) + else: + await self.send(ERRORDatagram.from_code(TFTPError.ILLEGAL_OPERATION)) + + async def _get_next_block(self) -> Datagram: + payload = await self.file.read(self.block_size) + if len(payload) < self.block_size: + self.last_block_sent = True + + self.last_block_number = (self.last_block_number + 1) % 65536 + return DATADatagram(self.last_block_number, payload) + + async def _timed_out(self): + logger.debug("timed out") + if self.retransmit_task is not None and not self.retransmit_task.cancelled(): + self.retransmit_task.cancel() + self.retransmit_task = None + await self.file.close(complete=False) + self.transport.close() + + def __del__(self): + logger.debug("RemoteOriginReadProtocol __del__") + + +def remote_origin_read_protocol( + *, + file: FileProtocol, + options: dict[bytes, bytes], + loop: asyncio.AbstractEventLoop, +) -> RemoteOriginReadProtocol: + return RemoteOriginReadProtocol(file=file, options=options, loop=loop) diff --git a/jtftp/protocol/remote_origin_write.py b/jtftp/protocol/remote_origin_write.py new file mode 100644 index 0000000..9b56bd5 --- /dev/null +++ b/jtftp/protocol/remote_origin_write.py @@ -0,0 +1,202 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import asyncio +import functools +import itertools +import logging +from collections import OrderedDict + +from jtftp.datagram import ACKDatagram +from jtftp.datagram import DATADatagram +from jtftp.datagram import Datagram +from jtftp.datagram import ERRORDatagram +from jtftp.datagram import OACKDatagram +from jtftp.datagram import TFTPError +from jtftp.datagram import TFTPMode +from jtftp.datagram import TFTPOption +from jtftp.datagram import datagram_factory +from jtftp.filesystem import FileProtocol +from jtftp.log import handle_task_result +from jtftp.util import timed_caller + +logger = logging.getLogger(__name__) + + +class RemoteOriginWriteProtocol(asyncio.DatagramProtocol): + block_size: int + timeout: tuple[int, int, int] + transfer_size: int | None + retransmit_task: asyncio.Task | None + offered_options: dict[TFTPOption, bytes] + accepted_options: dict[TFTPOption, bytes] + local_tid: tuple[str, int] + remote_tid: tuple[str, int] + + def __init__( + self, + *, + file: FileProtocol, + options: dict[TFTPOption, bytes], + loop: asyncio.AbstractEventLoop, + ) -> None: + super().__init__() + + self.file = file + self.offered_options = options + self.loop = loop + + self.block_size = 512 + self.timeout = (1, 3, 7) + self.transfer_size = None + self.retransmit_task = None + self.accepted_options = OrderedDict() + self.local_tid = None + self.remote_tid = None + + def connection_made(self, transport: asyncio.transports.DatagramTransport) -> None: + self.transport = transport + self.local_tid = transport.get_extra_info("sockname")[:2] + self.remote_tid = self.transport.get_extra_info("peername")[:2] + task = self.loop.create_task(self._connection_made()) + task.add_done_callback(handle_task_result) + + async def _connection_made(self): + logger.debug(f"new session between {self.local_tid} → {self.remote_tid}") + for name, value in self.offered_options.items(): + logger.debug(f"{name!r} {value!r}") + match name: + case TFTPOption.BLOCKSIZE: + self.block_size = int(value) + self.accepted_options[name] = value + + case TFTPOption.TIMEOUT: + self.timeout = (int(value),) * 3 + self.accepted_options[name] = value + + case TFTPOption.TRANSFER_SIZE: + self.transfer_size = int(value) + self.accepted_options[name] = value + + case _: + logger.warning(f"unknown option {name}: {value}") + + if len(self.offered_options) == 0: + data = ACKDatagram(0).to_wire() + + else: + data = OACKDatagram(self.accepted_options).to_wire() + + self.retransmit_task = asyncio.create_task( + timed_caller( + itertools.chain((0,), self.timeout), + functools.partial(self.transport.sendto, data), + self._timed_out, + ) + ) + + def connection_lost(self, exc: Exception | None) -> None: + logger.debug(f"closed session between {self.local_tid} → {self.remote_tid}") + return super().connection_lost(exc) + + async def send(self, datagram: Datagram) -> None: + self.transport.sendto(datagram.to_wire()) + + def datagram_received( + self, datagram: bytes, addr: tuple[str, int] | tuple[str, int, int, int] + ) -> None: + logger.debug( + f"RemoteOriginWriteProtocol data_received {len(datagram)} {addr!r}" + ) + tid = addr[:2] + task = self.loop.create_task(self._datagram_received(datagram, tid)) + task.add_done_callback(handle_task_result) + + async def _datagram_received(self, datagram: bytes, tid: tuple[str, int]) -> None: + if self.remote_tid != tid: + logger.error(f"received packet from wrong address: {tid}") + if ( + self.retransmit_task is not None + and not self.retransmit_task.cancelled() + ): + self.retransmit_task.cancel() + self.retransmit_task = None + + await self.file.close(complete=False) + self.transport.close() + return + + datagram = datagram_factory(datagram) + if not isinstance(datagram, DATADatagram): + if ( + self.retransmit_task is not None + and not self.retransmit_task.cancelled() + ): + self.retransmit_task.cancel() + self.retransmit_task = None + + await self.file.close(complete=False) + await self.send(ERRORDatagram.from_code(TFTPError.ILLEGAL_OPERATION)) + self.transport.close() + return + + logger.debug(f"RemoteOriginWriteProtocol data_received {datagram!r} {tid!r}") + if self.retransmit_task is not None and not self.retransmit_task.cancelled(): + logger.debug("cancelling old timeout task") + self.retransmit_task.cancel() + self.retransmit_task = None + + # need to check block numbers + + if len(datagram.payload) == self.block_size: + self.file.write(datagram.payload) + self.retransmit_task = asyncio.create_task( + timed_caller( + itertools.chain((0,), self.timeout), + functools.partial( + self.send, + ACKDatagram(datagram.block_number), + ), + self._timed_out, + ) + ) + self.retransmit_task.add_done_callback(handle_task_result) + else: + logger.debug(f"last data packet received") + await self.file.write(datagram.payload) + await self.file.close(complete=True) + await self.send(ACKDatagram(datagram.block_number)) + self.transport.close() + + async def _timed_out(self): + logger.debug("timed out") + if self.retransmit_task is not None and not self.retransmit_task.cancelled(): + self.retransmit_task.cancel() + self.retransmit_task = None + await self.file.close(complete=False) + self.transport.close() + + def __del__(self): + logger.debug("RemoteOriginWriteProtocol __del__") + + +def remote_origin_write_protocol( + *, + file: FileProtocol, + options: dict[TFTPOption, bytes], + loop: asyncio.AbstractEventLoop, +) -> RemoteOriginWriteProtocol: + return RemoteOriginWriteProtocol(file=file, options=options, loop=loop) diff --git a/jtftp/util.py b/jtftp/util.py new file mode 100644 index 0000000..f5d0f8c --- /dev/null +++ b/jtftp/util.py @@ -0,0 +1,85 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import asyncio +import itertools +import logging +from typing import Annotated +from typing import Awaitable +from typing import Callable +from typing import Iterable +from typing import TypeVar + +from annotated_types import Ge + +logger = logging.getLogger(__name__) +T = TypeVar("T") + + +def iterlast(iterable: Iterable[T]) -> Iterable[tuple[bool, T]]: + """Generate C{(is_last, item)} tuples from C{iterable}. + On each iteration this peeks ahead to see if the most recent iteration + will be the last, and returns this information as the C{is_last} element + of each tuple. + """ + iterable, peekable = itertools.tee(iterable) + + try: + # advance the peekable iterator + next(peekable) + except StopIteration: + # the iterator is zero length + return + + for item in iterable: + try: + next(peekable) + except StopIteration: + yield True, item + else: + yield False, item + + +async def timed_caller( + timings: Iterable[Annotated[int, Ge(0)]], + call: Callable[[None], Awaitable[None]], + last: Callable[[None], Awaitable[None]], +) -> None: + """Call C{call} or C{last} according to C{timings}. + The given C{timings} is an iterable of numbers. Each is a delay in seconds + that will be taken before making the next call to C{call} or C{last}. + The call to C{last} will happen after the last delay. If C{timings} is an + infinite iterable then C{last} will never be called. + @raise ValueError: if no timings are specified; there must be at least + one, even if it specifies a zero seconds delay. + @raise ValueError: if a negative timing is specified. + """ + no_timings = True + for is_last, timing in iterlast(timings): + no_timings = False + + if timing < 0: + raise ValueError("negative timing") + + await asyncio.sleep(timing) + + if is_last: + await last() + else: + await call() + + if no_timings: + raise ValueError("no timings specified") diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..d8117e9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1035 @@ +[[package]] +name = "aiofiles" +version = "0.8.0" +description = "File support for asyncio." +category = "main" +optional = false +python-versions = ">=3.6,<4.0" + +[[package]] +name = "aiohttp" +version = "3.8.1" +description = "Async http client/server framework (asyncio)" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" +attrs = ">=17.3.0" +charset-normalizer = ">=2.0,<3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["aiodns", "brotli", "cchardet"] + +[[package]] +name = "aiosignal" +version = "1.2.0" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "annotated-types" +version = "0.2.0" +description = "Reusable constraint types to use with typing.Annotated" +category = "main" +optional = false +python-versions = ">=3.7.0,<4.0.0" + +[[package]] +name = "arrow" +version = "1.2.2" +description = "Better dates & times for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +python-dateutil = ">=2.7.0" + +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "black" +version = "22.6.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "charset-normalizer" +version = "2.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "6.4.1" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "exceptiongroup" +version = "1.0.0rc8" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "flake8" +version = "4.0.1" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.8.0,<2.9.0" +pyflakes = ">=2.4.0,<2.5.0" + +[[package]] +name = "flake8-bugbear" +version = "22.6.22" +description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +attrs = ">=19.2.0" +flake8 = ">=3.0.0" + +[package.extras] +dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"] + +[[package]] +name = "frozenlist" +version = "1.3.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "hypothesis" +version = "6.48.2" +description = "A library for property-based testing" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=19.10b0)", "click (>=7.0)", "django (>=2.2)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "libcst (>=0.3.16)", "numpy (>=1.9.0)", "pandas (>=0.25)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "importlib-metadata (>=3.6)", "backports.zoneinfo (>=0.2.1)", "tzdata (>=2022.1)"] +cli = ["click (>=7.0)", "black (>=19.10b0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=2.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=19.10b0)"] +lark = ["lark-parser (>=0.6.5)"] +numpy = ["numpy (>=1.9.0)"] +pandas = ["pandas (>=0.25)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2022.1)"] + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "multidict" +version = "6.0.2" +description = "multidict implementation" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "prometheus-async" +version = "22.2.0" +description = "Async helpers for prometheus_client." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +aiohttp = {version = ">=3", optional = true, markers = "extra == \"aiohttp\""} +prometheus_client = ">=0.8.0" +wrapt = "*" + +[package.extras] +aiohttp = ["aiohttp (>=3)"] +consul = ["aiohttp (>=3)"] +dev = ["pre-commit", "pytest-twisted", "tomli", "cogapp", "mypy", "coverage", "pytest", "pytest-asyncio", "aiohttp", "furo", "myst-parser", "sphinx-notfound-page", "sphinx", "sphinxcontrib-asyncio", "twisted"] +docs = ["aiohttp", "furo", "myst-parser", "sphinx-notfound-page", "sphinx", "sphinxcontrib-asyncio", "twisted"] +tests = ["coverage", "pytest", "pytest-asyncio"] +twisted = ["twisted"] + +[[package]] +name = "prometheus-client" +version = "0.14.1" +description = "Python client for the Prometheus monitoring system." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +twisted = ["twisted"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycodestyle" +version = "2.8.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyflakes" +version = "2.4.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.18.3" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=6.1.0" + +[package.extras] +testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)", "pytest-trio (>=0.7.0)"] + +[[package]] +name = "pytest-cov" +version = "3.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "wrapt" +version = "1.14.1" +description = "Module for decorators, wrappers and monkey patching." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "yarl" +version = "1.7.2" +description = "Yet another URL library" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "b30f4fc0f062d4579ac56503060d0dd3f38c0995c2c16bc74e78ed92f83febf1" + +[metadata.files] +aiofiles = [ + {file = "aiofiles-0.8.0-py3-none-any.whl", hash = "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937"}, + {file = "aiofiles-0.8.0.tar.gz", hash = "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"}, +] +aiohttp = [ + {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"}, + {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"}, + {file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"}, + {file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"}, + {file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"}, + {file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"}, + {file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"}, + {file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"}, + {file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"}, + {file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"}, + {file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"}, + {file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"}, + {file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"}, + {file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"}, + {file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"}, + {file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"}, +] +aiosignal = [ + {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, + {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, +] +annotated-types = [ + {file = "annotated-types-0.2.0.tar.gz", hash = "sha256:82afd5f40c1e77066b7cf54209c7160a5a30b91108d6dead943c64a511b10918"}, + {file = "annotated_types-0.2.0-py3-none-any.whl", hash = "sha256:5e7d2e6f477b7abe7b44c1903bb5fb86320fa2fa41837d8e9a81c7beb999a079"}, +] +arrow = [ + {file = "arrow-1.2.2-py3-none-any.whl", hash = "sha256:d622c46ca681b5b3e3574fcb60a04e5cc81b9625112d5fb2b44220c36c892177"}, + {file = "arrow-1.2.2.tar.gz", hash = "sha256:05caf1fd3d9a11a1135b2b6f09887421153b94558e5ef4d090b567b47173ac2b"}, +] +async-timeout = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +black = [ + {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, + {file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"}, + {file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"}, + {file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"}, + {file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"}, + {file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"}, + {file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"}, + {file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"}, + {file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"}, + {file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"}, + {file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"}, + {file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"}, + {file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"}, + {file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"}, + {file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"}, + {file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"}, + {file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"}, + {file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"}, + {file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"}, + {file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"}, +] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +colorama = [ + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, +] +coverage = [ + {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"}, + {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"}, + {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"}, + {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"}, + {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"}, + {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"}, + {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"}, + {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"}, + {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"}, + {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"}, + {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"}, + {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"}, + {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"}, + {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"}, + {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"}, + {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"}, + {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, +] +exceptiongroup = [ + {file = "exceptiongroup-1.0.0rc8-py3-none-any.whl", hash = "sha256:ab0a968e1ef769e55d9a596f4a89f7be9ffedbc9fdefdb77cc68cf5c33ce1035"}, + {file = "exceptiongroup-1.0.0rc8.tar.gz", hash = "sha256:6990c24f06b8d33c8065cfe43e5e8a4bfa384e0358be036af9cc60b6321bd11a"}, +] +flake8 = [ + {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, + {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, +] +flake8-bugbear = [ + {file = "flake8-bugbear-22.6.22.tar.gz", hash = "sha256:ac3317eba27d79dc19dcdeb7356ca1f656f0cde11d899c4551badf770f05cbef"}, + {file = "flake8_bugbear-22.6.22-py3-none-any.whl", hash = "sha256:ad2b33dbe33a6d4ca1f0037e1d156d0a89107ee63c0600e3b4f7b60e37998ac2"}, +] +frozenlist = [ + {file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2257aaba9660f78c7b1d8fea963b68f3feffb1a9d5d05a18401ca9eb3e8d0a3"}, + {file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a44ebbf601d7bac77976d429e9bdb5a4614f9f4027777f9e54fd765196e9d3b"}, + {file = "frozenlist-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:45334234ec30fc4ea677f43171b18a27505bfb2dba9aca4398a62692c0ea8868"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47be22dc27ed933d55ee55845d34a3e4e9f6fee93039e7f8ebadb0c2f60d403f"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03a7dd1bfce30216a3f51a84e6dd0e4a573d23ca50f0346634916ff105ba6e6b"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:691ddf6dc50480ce49f68441f1d16a4c3325887453837036e0fb94736eae1e58"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bde99812f237f79eaf3f04ebffd74f6718bbd216101b35ac7955c2d47c17da02"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a202458d1298ced3768f5a7d44301e7c86defac162ace0ab7434c2e961166e8"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9e3e9e365991f8cc5f5edc1fd65b58b41d0514a6a7ad95ef5c7f34eb49b3d3e"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:04cb491c4b1c051734d41ea2552fde292f5f3a9c911363f74f39c23659c4af78"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:436496321dad302b8b27ca955364a439ed1f0999311c393dccb243e451ff66aa"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:754728d65f1acc61e0f4df784456106e35afb7bf39cfe37227ab00436fb38676"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eb275c6385dd72594758cbe96c07cdb9bd6becf84235f4a594bdf21e3596c9d"}, + {file = "frozenlist-1.3.0-cp310-cp310-win32.whl", hash = "sha256:e30b2f9683812eb30cf3f0a8e9f79f8d590a7999f731cf39f9105a7c4a39489d"}, + {file = "frozenlist-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7353ba3367473d1d616ee727945f439e027f0bb16ac1a750219a8344d1d5d3c"}, + {file = "frozenlist-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88aafd445a233dbbf8a65a62bc3249a0acd0d81ab18f6feb461cc5a938610d24"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4406cfabef8f07b3b3af0f50f70938ec06d9f0fc26cbdeaab431cbc3ca3caeaa"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf829bd2e2956066dd4de43fd8ec881d87842a06708c035b37ef632930505a2"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:603b9091bd70fae7be28bdb8aa5c9990f4241aa33abb673390a7f7329296695f"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25af28b560e0c76fa41f550eacb389905633e7ac02d6eb3c09017fa1c8cdfde1"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c7a8a9fc9383b52c410a2ec952521906d355d18fccc927fca52ab575ee8b93"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:65bc6e2fece04e2145ab6e3c47428d1bbc05aede61ae365b2c1bddd94906e478"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3f7c935c7b58b0d78c0beea0c7358e165f95f1fd8a7e98baa40d22a05b4a8141"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd89acd1b8bb4f31b47072615d72e7f53a948d302b7c1d1455e42622de180eae"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6983a31698490825171be44ffbafeaa930ddf590d3f051e397143a5045513b01"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adac9700675cf99e3615eb6a0eb5e9f5a4143c7d42c05cea2e7f71c27a3d0846"}, + {file = "frozenlist-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:0c36e78b9509e97042ef869c0e1e6ef6429e55817c12d78245eb915e1cca7468"}, + {file = "frozenlist-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:57f4d3f03a18facacb2a6bcd21bccd011e3b75d463dc49f838fd699d074fabd1"}, + {file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8c905a5186d77111f02144fab5b849ab524f1e876a1e75205cd1386a9be4b00a"}, + {file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5009062d78a8c6890d50b4e53b0ddda31841b3935c1937e2ed8c1bda1c7fb9d"}, + {file = "frozenlist-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2fdc3cd845e5a1f71a0c3518528bfdbfe2efaf9886d6f49eacc5ee4fd9a10953"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e650bd09b5dda929523b9f8e7f99b24deac61240ecc1a32aeba487afcd970f"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dff8962b8eba91fd3848d857203f0bd704b5f1fa2b3fc9af64901a190bba08"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:768efd082074bb203c934e83a61654ed4931ef02412c2fbdecea0cff7ecd0274"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871d42623ae15eb0b0e9df65baeee6976b2e161d0ba93155411d58ff27483ad8"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aff388be97ef2677ae185e72dc500d19ecaf31b698986800d3fc4f399a5e30a5"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f892d6a94ec5c7b785e548e42722e6f3a52f5f32a8461e82ac3e67a3bd073f1"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e982878792c971cbd60ee510c4ee5bf089a8246226dea1f2138aa0bb67aff148"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c6c321dd013e8fc20735b92cb4892c115f5cdb82c817b1e5b07f6b95d952b2f0"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:30530930410855c451bea83f7b272fb1c495ed9d5cc72895ac29e91279401db3"}, + {file = "frozenlist-1.3.0-cp38-cp38-win32.whl", hash = "sha256:40ec383bc194accba825fbb7d0ef3dda5736ceab2375462f1d8672d9f6b68d07"}, + {file = "frozenlist-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:f20baa05eaa2bcd5404c445ec51aed1c268d62600362dc6cfe04fae34a424bd9"}, + {file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0437fe763fb5d4adad1756050cbf855bbb2bf0d9385c7bb13d7a10b0dd550486"}, + {file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b684c68077b84522b5c7eafc1dc735bfa5b341fb011d5552ebe0968e22ed641c"}, + {file = "frozenlist-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93641a51f89473837333b2f8100f3f89795295b858cd4c7d4a1f18e299dc0a4f"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6d32ff213aef0fd0bcf803bffe15cfa2d4fde237d1d4838e62aec242a8362fa"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31977f84828b5bb856ca1eb07bf7e3a34f33a5cddce981d880240ba06639b94d"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c62964192a1c0c30b49f403495911298810bada64e4f03249ca35a33ca0417a"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eda49bea3602812518765810af732229b4291d2695ed24a0a20e098c45a707b"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb267b09a509c1df5a4ca04140da96016f40d2ed183cdc356d237286c971b51"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e1e26ac0a253a2907d654a37e390904426d5ae5483150ce3adedb35c8c06614a"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e84cb61b0ac40a0c3e0e8b79c575161c5300d1d89e13c0e02f76193982f066ed"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d26b650b71fdc88065b7a21f8ace70175bcf3b5bdba5ea22df4bfd893e795a3b"}, + {file = "frozenlist-1.3.0-cp39-cp39-win32.whl", hash = "sha256:01a73627448b1f2145bddb6e6c2259988bb8aee0fb361776ff8604b99616cd08"}, + {file = "frozenlist-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:772965f773757a6026dea111a15e6e2678fbd6216180f82a48a40b27de1ee2ab"}, + {file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"}, +] +hypothesis = [ + {file = "hypothesis-6.48.2-py3-none-any.whl", hash = "sha256:6223e31cc35c786a179a3caa6032238286937a6943de7ad0adb9e0f1382edcc3"}, + {file = "hypothesis-6.48.2.tar.gz", hash = "sha256:ef548f10438095678af7fcfa369b882390355a6329db73174a65749b677677ec"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +multidict = [ + {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"}, + {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"}, + {file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"}, + {file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"}, + {file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"}, + {file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"}, + {file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"}, + {file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"}, + {file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"}, + {file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"}, + {file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"}, + {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"}, + {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +platformdirs = [ + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +prometheus-async = [ + {file = "prometheus-async-22.2.0.tar.gz", hash = "sha256:b0426370eb3b3bacd99afcf1fcc669c118cb67603cc951a6fe12434e9d4307f2"}, + {file = "prometheus_async-22.2.0-py3-none-any.whl", hash = "sha256:5cbfa535561342b834c087c4f3f3be0a3cb8785a0b8748111c916f3d68bbc370"}, +] +prometheus-client = [ + {file = "prometheus_client-0.14.1-py3-none-any.whl", hash = "sha256:522fded625282822a89e2773452f42df14b5a8e84a86433e3f8a189c1d54dc01"}, + {file = "prometheus_client-0.14.1.tar.gz", hash = "sha256:5459c427624961076277fdc6dc50540e2bacb98eebde99886e59ec55ed92093a"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pycodestyle = [ + {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, + {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, +] +pyflakes = [ + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytest = [ + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, +] +pytest-asyncio = [ + {file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"}, + {file = "pytest_asyncio-0.18.3-1-py3-none-any.whl", hash = "sha256:16cf40bdf2b4fb7fc8e4b82bd05ce3fbcd454cbf7b92afc445fe299dabb88213"}, + {file = "pytest_asyncio-0.18.3-py3-none-any.whl", hash = "sha256:8fafa6c52161addfd41ee7ab35f11836c5a16ec208f93ee388f752bea3493a84"}, +] +pytest-cov = [ + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +sortedcontainers = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +wrapt = [ + {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, + {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, + {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"}, + {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"}, + {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"}, + {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"}, + {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"}, + {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"}, + {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"}, + {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"}, + {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"}, + {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"}, + {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, + {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, + {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, + {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, +] +yarl = [ + {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"}, + {file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"}, + {file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"}, + {file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"}, + {file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"}, + {file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"}, + {file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"}, + {file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"}, + {file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"}, + {file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"}, + {file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"}, + {file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"}, + {file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"}, + {file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4755913 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[tool.poetry] +name = "jtftp" +version = "0.1.0" +description = "Jeff's TFTP" +authors = ["Jeffrey C. Ollie "] + +[tool.poetry.dependencies] +python = "^3.10" +arrow = "^1.2.2" +prometheus-async = {extras = ["aiohttp"], version = "^22.2.0"} +annotated-types = "^0.2.0" +aiofiles = "^0.8.0" + +[tool.poetry.dev-dependencies] +black = "^22.6.0" +flake8 = "^4.0.1" +flake8-bugbear = "^22.6.22" +hypothesis = "^6.48.2" +pytest = "^7.1.2" +pytest-asyncio = "^0.18.3" +pytest-cov = "^3.0.0" + +[tool.poetry.scripts] +serve = 'examples.main:serve' + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..641c4b5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = strict diff --git a/test/test_datagram.py b/test/test_datagram.py new file mode 100644 index 0000000..117ecf4 --- /dev/null +++ b/test/test_datagram.py @@ -0,0 +1,214 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +from typing import OrderedDict + +from hypothesis import given +from hypothesis.strategies import integers +from jtftp.datagram import RQDatagram +from jtftp.datagram import RRQDatagram +from jtftp.datagram import TFTPError +from jtftp.datagram import TFTPMode +from jtftp.datagram import TFTPOpcode +from jtftp.datagram import TFTPOption +from jtftp.datagram import datagram_factory +from jtftp.datagram import decode_options +from jtftp.datagram import split_opcode +from jtftp.errors import InvalidOpcodeError +from jtftp.errors import OptionsDecodeError +from jtftp.errors import PayloadDecodeError +from jtftp.errors import WireProtocolError +from pytest import raises + + +@given(integers(min_value=1, max_value=6)) +def test_tftp_opcode_normal(opcode: int): + assert TFTPOpcode(opcode).value == opcode + + +def test_tftp_opcode_abnormal(): + with raises(ValueError): + TFTPOpcode(7) + + +@given(integers(min_value=0, max_value=8)) +def test_tftperror_normal(error_code: int): + assert TFTPError(error_code).value == error_code + + +def test_option_wrong_case(): + assert TFTPOption(b"BLKSIZE") == TFTPOption.BLOCKSIZE + + +def test_option_wrong_type(): + assert TFTPOption("blksize") == TFTPOption.BLOCKSIZE + + +def test_mode_wrong_case(): + assert TFTPMode(b"OCTET") == TFTPMode.OCTET + + +def test_mode_wrong_type(): + assert TFTPMode("octet") == TFTPMode.OCTET + + +def test_datagram_zero_length(): + with raises(WireProtocolError): + split_opcode(b"") + + +def test_datagram_incomplete_opcode(): + with raises(WireProtocolError): + split_opcode(b"\x00") + + +def test_datagram_invalid_opcode(): + with raises(InvalidOpcodeError): + split_opcode(b"\x00\xff") + + +def test_datagram_empty_payload(): + assert split_opcode(b"\x00\x01") == (1, b"") + + +def test_datagram_non_empty_payload(): + assert split_opcode(b"\x00\x01foo") == (1, b"foo") + + +def test_datagram_unknown_opcode(): + with raises(WireProtocolError): + datagram_factory(b"\x00\x0ffoobar") + + +def test_rq_datagram(): + with raises(WireProtocolError): + RQDatagram.from_wire(b"foobar") + + +def test_rq_datagram_invalid_mode(): + with raises(PayloadDecodeError): + RQDatagram.from_wire(b"foo\x00bar\x00") + + +def test_rq_datagram_valid_mode(): + dgram = RQDatagram.from_wire(b"foo\x00octet") + dgram.opcode = TFTPOpcode.RRQ + assert dgram.to_wire() == b"\x00\x01foo\x00octet\x00" + + +def test_decode_options_missing_value(): + with raises(OptionsDecodeError): + decode_options([b"blksize"]) + + +def test_decode_options_duplicate_value(): + with raises(OptionsDecodeError): + decode_options([b"blksize", b"1024", b"blksize", b"2048"]) + + +def test_rq_datagam_invalid_option_name(): + with raises(PayloadDecodeError): + RQDatagram.from_wire(b"foo\x00octet\x00spam\x00baz\x00") + + +def test_rq_datagam_blksize_invalid_option_value_1(): + with raises(PayloadDecodeError): + RQDatagram.from_wire(b"foo\x00octet\x00blksize\x00baz\x00") + + +def test_rq_datagam_blksize_invalid_option_value_2(): + with raises(OptionsDecodeError): + RQDatagram.from_wire(b"foo\x00octet\x00blksize\x001\x00") + + +def test_rq_datagam_blksize_invalid_option_value_3(): + with raises(OptionsDecodeError): + RQDatagram.from_wire(b"foo\x00octet\x00blksize\x0072384\x00") + + +def test_rq_datagram_blksize_valid_option(): + dgram = RQDatagram.from_wire(b"foo\x00octet\x00blksize\x001024\x00") + assert dgram.filename == b"foo" + assert dgram.mode == TFTPMode.OCTET + assert dgram.options == OrderedDict({TFTPOption.BLOCKSIZE: 1024}) + + +def test_rq_datagam_timeout_invalid_option_value_1(): + with raises(PayloadDecodeError): + RQDatagram.from_wire(b"foo\x00octet\x00timeout\x000\x00") + + +def test_rq_datagam_timeout_invalid_option_value_2(): + with raises(OptionsDecodeError): + RQDatagram.from_wire(b"foo\x00octet\x00timeout\x00-1\x00") + + +def test_rq_datagram_timeout_valid_option(): + dgram = RQDatagram.from_wire(b"foo\x00octet\x00timeout\x005\x00") + assert dgram.filename == b"foo" + assert dgram.mode == TFTPMode.OCTET + assert dgram.options == OrderedDict({TFTPOption.TIMEOUT: 5}) + + +def test_rq_datagam_tsize_invalid_option_value_1(): + with raises(PayloadDecodeError): + RQDatagram.from_wire(b"foo\x00octet\x00tsize\x00-1\x00") + + +def test_rq_datagram_timeout_valid_option_1(): + dgram = RQDatagram.from_wire(b"foo\x00octet\x00tsize\x000\x00") + assert dgram.filename == b"foo" + assert dgram.mode == TFTPMode.OCTET + assert dgram.options == OrderedDict({TFTPOption.TRANSFER_SIZE: 0}) + + +def test_rq_datagram_timeout_valid_option_2(): + dgram = RQDatagram.from_wire(b"foo\x00octet\x00tsize\x00512\x00") + assert dgram.filename == b"foo" + assert dgram.mode == TFTPMode.OCTET + assert dgram.options == OrderedDict({TFTPOption.TRANSFER_SIZE: 512}) + + +def test_rrq_datagram_to_wire_1(): + dgram = RRQDatagram( + b"foo", TFTPMode.OCTET, OrderedDict({TFTPOption.TRANSFER_SIZE: 512}) + ) + assert dgram.opcode == TFTPOpcode.RRQ + assert dgram.filename == b"foo" + assert dgram.mode == TFTPMode.OCTET + assert dgram.options == OrderedDict({TFTPOption.TRANSFER_SIZE: 512}) + assert dgram.to_wire() == b"\x00\x01foo\x00octet\x00tsize\x00512\x00" + + +def test_rrq_datagram_to_wire_2(): + with raises(AssertionError): + dgram = RRQDatagram(b"foo", TFTPMode.OCTET, OrderedDict({b"foo": 512})) + # assert dgram.opcode == TFTPOpcode.RRQ + # assert dgram.filename == b"foo" + # assert dgram.mode == TFTPMode.OCTET + # assert dgram.options == OrderedDict({TFTPOption.TRANSFER_SIZE: 512}) + # assert dgram.to_wire() == b"\x00\x01foo\x00octet\x00tsize\x00512\x00" + + +def test_rrq_datagram_to_wire_3(): + dgram = RRQDatagram(b"foo", TFTPMode.OCTET, OrderedDict({})) + dgram.options = OrderedDict({b"baz": 512}) + assert dgram.opcode == TFTPOpcode.RRQ + assert dgram.filename == b"foo" + assert dgram.mode == TFTPMode.OCTET + assert dgram.options == OrderedDict({b"baz": 512}) + with raises(WireProtocolError): + dgram.to_wire() diff --git a/test/test_netascii.py b/test/test_netascii.py new file mode 100644 index 0000000..dd814e2 --- /dev/null +++ b/test/test_netascii.py @@ -0,0 +1,58 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +from jtftp.netascii import NetAsciiCR +from jtftp.netascii import NetAsciiLF + + +def test_from_netascii_cr_1(): + assert NetAsciiCR.from_netascii(b"\x0d\x00") == b"\x0d" + + +def test_from_netascii_cr_1(): + assert NetAsciiCR.from_netascii(b"\x0d\x0a") == b"\x0d" + + +def test_from_netascii_cr_2(): + assert NetAsciiCR.from_netascii(b"foo\x0d\x0a\x0abar") == b"foo\x0d\x0abar" + + +def test_from_netascii_cr_3(): + assert NetAsciiCR.from_netascii(b"foo\x0d\x00\x0abar") == b"foo\x0d\x0abar" + + +def test_from_netascii_cr_4(): + assert NetAsciiCR.from_netascii(b"foo\x0d\x0a\x0dbar") == b"foo\x0d\x0dbar" + + +def test_from_netascii_lf_1(): + assert NetAsciiLF.from_netascii(b"\x0d\x00") == b"\x0d" + + +def test_from_netascii_lf_1(): + assert NetAsciiLF.from_netascii(b"\x0d\x0a") == b"\x0a" + + +def test_from_netascii_lf_2(): + assert NetAsciiLF.from_netascii(b"foo\x0d\x0a\x0abar") == b"foo\x0a\x0abar" + + +def test_from_netascii_lf_3(): + assert NetAsciiLF.from_netascii(b"foo\x0d\x00\x0abar") == b"foo\x0d\x0abar" + + +def test_from_netascii_lf_4(): + assert NetAsciiLF.from_netascii(b"foo\x0d\x0a\x0dbar") == b"foo\x0a\x0dbar" diff --git a/test/test_util.py b/test/test_util.py new file mode 100644 index 0000000..1be176d --- /dev/null +++ b/test/test_util.py @@ -0,0 +1,85 @@ +# JTFTP - Python/AsyncIO TFTP Server +# Copyright (C) 2022 Jeffrey C. Ollie +# +# 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 . + +import time +from itertools import count +from itertools import islice + +import pytest +from jtftp.util import iterlast +from jtftp.util import timed_caller + + +def test_iterlast_3(): + assert list(iterlast([1, 2, 3])) == [(False, 1), (False, 2), (True, 3)] + + +def test_iterlast_0(): + assert list(iterlast([])) == [] + + +def test_iterlast_1(): + assert list(iterlast([1])) == [(True, 1)] + + +def test_iterlast_infinite(): + assert list(islice(iterlast(count(1)), 3)) == [(False, 1), (False, 2), (False, 3)] + + +@pytest.mark.asyncio +async def test_timed_caller(): + latency = 250000 + r = [] + t = [time.monotonic_ns()] + + async def c(): + end = time.monotonic_ns() + r.append(("c", (end - t[-1]) < latency)) + t.append(end) + + async def l(): + end = time.monotonic_ns() + r.append(("l", (end - t[-1]) < latency)) + t.append(end) + + await timed_caller((0, 0, 0), c, l) + assert r == [("c", True), ("c", True), ("l", True)] + + +@pytest.mark.asyncio +async def test_timed_caller_no_timings(): + with pytest.raises(ValueError): + + async def c(): + pass + + async def l(): + pass + + await timed_caller([], c, l) + + +@pytest.mark.asyncio +async def test_timed_caller_negative_timings(): + with pytest.raises(ValueError): + + async def c(): + pass + + async def l(): + pass + + await timed_caller([0, -1, 0], c, l)