commit 211cb1acc4f1cabb7938b48b26f30255f0957f47 Author: George Rawlinson Date: Sun Nov 1 05:57:30 2020 +1300 initial commit diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..132e0f6 --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +select = B,B9,C,D,DAR,E,F,N,RST,S,W +ignore = E203,E501,RST201,RST203,RST301,W503 +max-line-length = 80 +max-complexity = 10 +docstring-convention = google +per-file-ignores = + tests/*:S101 + */__init__.py:F401 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a81c8ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,138 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..24d9af9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: local + hooks: + - id: black + name: black + entry: black + language: system + types: [python] + require_serial: true + - id: flake8 + name: flake8 + entry: flake8 + language: system + types: [python] + require_serial: true + - id: reorder-python-imports + name: Reorder python imports + entry: reorder-python-imports + language: system + types: [python] + args: [--application-directories=src] diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2deecc8 --- /dev/null +++ b/.python-version @@ -0,0 +1,4 @@ +3.9.0 +3.8.6 +3.7.8 +3.6.11 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/Makefile b/Makefile new file mode 100644 index 0000000..be499e1 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +.DEFAULT: help +.PHONY: help clean clean-pyc clean-build dist lint test tests docs + +help: + @echo "clean : delete all artifacts" + @echo "clean-pyc : delete python cache artifacts" + @echo "clean-build : delete distribution artifacts" + @echo "dist : generate distribution artifacts" + @echo "lint : lint with black, flake8 & reorder-python-imports" + @echo "test : run tests with latest Python version" + @echo "coverage : run coverage tests with latest Python version" + @echo "tests : run tests with supported Python versions" + +clean: clean-pyc clean-build + +clean-pyc: + @find . -name '*.pyc' -delete + @find . -name '*.pyo' -delete + @find . -name __pycache__ -delete + +clean-build: + @rm --force --recursive build dist src/*.egg-info docs/_build + +dist: clean + poetry build + +lint: + nox -rs precommit + +test: + nox + +coverage: + nox -rs coverage + +tests: + nox -rs tests + +docs: + nox -rs docs \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d2b98e7 --- /dev/null +++ b/README.rst @@ -0,0 +1,54 @@ +SMBMC +===== + +An unofficial Python interface for obtaining metrics from Supermicro BMCs. + +The following metrics are accessible: + +- Sensor: Temperature, Fan, Voltage, etc. +- PMBus: Power Consumption, Fan, Temperature, etc. + +**Note:** This library depends on the BMC web-interface being available. + +Usage +----- + +:: + + # smbmc_example.py + from smbmc import Client + + # initialise client with connection details + c = Client(IPMI_SERVER, IPMI_USER, IPMI_PASS) + + # retrieve session token + c.login() + + # obtain sensor metrics + sensors = c.get_sensor_metrics() + + # and pmbus metrics + power_supplies = c.get_pmbus_metrics() + + # or, retrieve all known metrics + metrics = c.get_metrics() + +Contributing +------------ + +This library has been tested on a system with the following components: + +- Chassis: SC846 (unknown revision; possibly 846BA-R920B) +- Motherboard: CSE-PTJBOD-CB3 +- Power Supply: PWS-920P-SQ +- Backplane: BPN-SAS2-846EL1 +- Power Distribution Board: PDB-PT846-2824 + +If there are any errors or additional functionality for other components, please file an issue with as *much* detail as you can! + +Legal +----- + +This library is not associated with Super Micro Computer, Inc. + +Supermicro have released some `BMC/IPMI `_ code under the GPL, which has been used as a reference. Therefore, this library is licensed as GPLv3. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..bd34d6d --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,68 @@ +"""Sphinx configuration.""" +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +# -- Path setup -------------------------------------------------------------- +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +try: + import importlib.metadata as metadata +except ImportError: + import importlib_metadata as metadata +from datetime import datetime + +sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath(".")) + + +# -- Project information ----------------------------------------------------- + +project = "smbmc" +author = "George Rawlinson" +copyright = f"{datetime.now().year}, {author}" + +version = metadata.version(project) +release = version + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# Autoclass configuration +autoclass_content = "both" + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "alabaster" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..e3172ab --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +Welcome to smbmc +================ + +.. autoclass:: smbmc.Client + :members: + :inherited-members: + +.. toctree:: + :hidden: + :maxdepth: 2 + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..b2b434e --- /dev/null +++ b/noxfile.py @@ -0,0 +1,162 @@ +"""Nox sessions.""" +import functools +import os +from pathlib import Path +from textwrap import dedent + +import nox +import nox_poetry +from nox.sessions import Session + +# package name +package = "smbmc" + +# python versions +supported_versions = ["3.6", "3.7", "3.8", "3.9"] +latest_version = supported_versions[-1] + +# nox settings +nox.options.sessions = [f"tests-{latest_version}"] +nox.options.reuse_existing_virtualenvs = True + +locations = ["src", "tests", "noxfile.py", "docs/conf.py"] + + +@nox.session(python=supported_versions) +def tests(session: Session) -> None: + """Run the test suite. + + Args: + session: The Session object. + """ + nox_poetry.install(session, nox_poetry.WHEEL) + nox_poetry.install(session, "pytest", "betamax") + session.run("pytest") + + +@nox.session(python=latest_version) +def coverage(session: Session) -> None: + """Generate coverage report. + + Args: + session: The Session object. + """ + nox_poetry.install(session, nox_poetry.WHEEL) + nox_poetry.install(session, "pytest", "betamax", "pytest-cov", "coverage[toml]") + session.run("pytest", f"--cov={package}", "tests/") + + +def activate_virtualenv_in_precommit_hooks(session: Session) -> None: + """Activate virtualenv in hooks installed by pre-commit. + + This function patches git hooks installed by pre-commit to activate the + session's virtual environment. This allows pre-commit to locate hooks in + that environment when invoked from git. + + Args: + session: The Session object. + """ + if session.bin is None: + return + + virtualenv = session.env.get("VIRTUAL_ENV") + if virtualenv is None: + return + + hookdir = Path(".git") / "hooks" + if not hookdir.is_dir(): + return + + for hook in hookdir.iterdir(): + if hook.name.endswith(".sample") or not hook.is_file(): + continue + + text = hook.read_text() + bindir = repr(session.bin)[1:-1] # strip quotes + if not ( + Path("A") == Path("a") and bindir.lower() in text.lower() or bindir in text + ): + continue + + lines = text.splitlines() + if not (lines[0].startswith("#!") and "python" in lines[0].lower()): + continue + + header = dedent( + f"""\ + import os + os.environ["VIRTUAL_ENV"] = {virtualenv!r} + os.environ["PATH"] = os.pathsep.join(( + {session.bin!r}, + os.environ.get("PATH", ""), + )) + """ + ) + + lines.insert(1, header) + hook.write_text("\n".join(lines)) + + +@nox.session(python=latest_version) +def precommit(session: Session) -> None: + """Lint using pre-commit. + + Args: + session: The Session object. + """ + args = session.posargs or [ + "run", + "--all-files", + ] # "--show-diff-on-failure"] + nox_poetry.install( + session, + "black", + "darglint", + "flake8", + "flake8-bandit", + "flake8-bugbear", + "flake8-docstrings", + "flake8-rst-docstrings", + "pep8-naming", + "pre-commit", + "pre-commit-hooks", + "reorder-python-imports", + ) + session.run("pre-commit", *args) + if args and args[0] == "install": + activate_virtualenv_in_precommit_hooks(session) + + +@nox.session(python=latest_version) +def docs(session: Session) -> None: + """Build the documentation. + + Args: + session: The Session object. + """ + output_dir = os.path.join(session.create_tmp(), "output") + doctrees, html = map( + functools.partial(os.path.join, output_dir), ["doctrees", "html"] + ) + session.run("rm", "-rf", output_dir, external=True) + + nox_poetry.install(session, nox_poetry.WHEEL) + nox_poetry.install(session, "sphinx", "sphinx-autobuild") + session.cd("docs") + sphinx_args = [ + "-b", + "html", + "-W", + "-d", + doctrees, + ".", + html, + ] + + if not session.interactive: + sphinx_cmd = "sphinx-build" + else: + sphinx_cmd = "sphinx-autobuild" + sphinx_args.insert(0, "--open-browser") + + session.run(sphinx_cmd, *sphinx_args) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..29c951e --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1450 @@ +[[package]] +name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "aspy.refactor-imports" +version = "2.1.1" +description = "Utilities for refactoring imports in python-like syntax." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +cached-property = "*" + +[[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.*" +marker = "sys_platform == \"win32\"" + +[[package]] +name = "attrs" +version = "20.2.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] + +[[package]] +name = "babel" +version = "2.8.0" +description = "Internationalization utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "bandit" +version = "1.6.2" +description = "Security oriented static analyser for python code." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +colorama = ">=0.3.9" +GitPython = ">=1.0.1" +PyYAML = ">=3.13" +six = ">=1.10.0" +stevedore = ">=1.20.0" + +[[package]] +name = "betamax" +version = "0.8.1" +description = "A VCR imitation for python-requests" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=2.0" + +[[package]] +name = "black" +version = "20.8b1" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + +[package.dependencies] +appdirs = "*" +click = ">=7.1.2" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.6,<1" +regex = ">=2020.1.8" +toml = ">=0.10.1" +typed-ast = ">=1.4.0" +typing-extensions = ">=3.7.4" + +[package.dependencies.dataclasses] +version = ">=0.6" +python = "<3.7" + +[[package]] +name = "cached-property" +version = "1.5.2" +description = "A decorator for caching properties in classes." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "certifi" +version = "2020.6.20" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "cfgv" +version = "3.2.0" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[[package]] +name = "chardet" +version = "3.0.4" +description = "Universal encoding detector for Python 2 and 3" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" + +[[package]] +name = "coverage" +version = "5.3" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +toml = ["toml"] + +[package.dependencies] +[package.dependencies.toml] +version = "*" +optional = true + +[[package]] +name = "darglint" +version = "1.5.5" +description = "A utility for ensuring Google-style docstrings stay up to date with the source code." +category = "dev" +optional = false +python-versions = ">=3.5,<4.0" + +[[package]] +name = "dataclasses" +version = "0.6" +description = "A backport of the dataclasses module for Python 3.6" +category = "dev" +optional = false +python-versions = "*" +marker = "python_version < \"3.7\"" + +[[package]] +name = "defusedxml" +version = "0.6.0" +description = "XML bomb protection for Python stdlib modules" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "distlib" +version = "0.3.1" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "docutils" +version = "0.16" +description = "Docutils -- Python Documentation Utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "filelock" +version = "3.0.12" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "flake8" +version = "3.8.4" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[package.dependencies.importlib-metadata] +version = "*" +python = "<3.8" + +[[package]] +name = "flake8-bandit" +version = "2.1.2" +description = "Automated security testing with bandit and flake8." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +bandit = "*" +flake8 = "*" +flake8-polyfill = "*" +pycodestyle = "*" + +[[package]] +name = "flake8-bugbear" +version = "20.1.4" +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]] +name = "flake8-docstrings" +version = "1.5.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + +[[package]] +name = "flake8-polyfill" +version = "1.0.2" +description = "Polyfill package for Flake8 plugins" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = "*" + +[[package]] +name = "flake8-rst-docstrings" +version = "0.0.14" +description = "Python docstring reStructuredText (RST) validator" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3.0.0" +restructuredtext_lint = "*" + +[[package]] +name = "gitdb" +version = "4.0.5" +description = "Git Object Database" +category = "dev" +optional = false +python-versions = ">=3.4" + +[package.dependencies] +smmap = ">=3.0.1,<4" + +[[package]] +name = "gitpython" +version = "3.1.11" +description = "Python Git Library" +category = "dev" +optional = false +python-versions = ">=3.4" + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[[package]] +name = "identify" +version = "1.5.6" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.extras] +license = ["editdistance"] + +[[package]] +name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "imagesize" +version = "1.2.0" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "importlib-metadata" +version = "2.0.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +marker = "python_version < \"3.8\"" + +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] + +[package.dependencies] +zipp = ">=0.5" + +[[package]] +name = "importlib-resources" +version = "3.3.0" +description = "Read resources from Python packages" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +marker = "python_version < \"3.7\"" + +[package.extras] +docs = ["sphinx", "rst.linker", "jaraco.packaging"] + +[package.dependencies] +[package.dependencies.zipp] +version = ">=0.4" +python = "<3.8" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "jinja2" +version = "2.11.2" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +i18n = ["Babel (>=0.8)"] + +[package.dependencies] +MarkupSafe = ">=0.23" + +[[package]] +name = "livereload" +version = "2.6.3" +description = "Python LiveReload is an awesome tool for web developers" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +six = "*" + +[package.dependencies.tornado] +version = "*" +python = ">=2.8" + +[[package]] +name = "markupsafe" +version = "1.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[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 = "nodeenv" +version = "1.5.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "20.4" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +name = "pathspec" +version = "0.8.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pbr" +version = "5.5.1" +description = "Python Build Reasonableness" +category = "dev" +optional = false +python-versions = ">=2.6" + +[[package]] +name = "pep8-naming" +version = "0.11.1" +description = "Check PEP-8 naming conventions, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8-polyfill = ">=1.0.2,<2" + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + +[package.dependencies] +[package.dependencies.importlib-metadata] +version = ">=0.12" +python = "<3.8" + +[[package]] +name = "pre-commit" +version = "2.8.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +toml = "*" +virtualenv = ">=20.0.8" + +[package.dependencies.importlib-metadata] +version = "*" +python = "<3.8" + +[package.dependencies.importlib-resources] +version = "*" +python = "<3.7" + +[[package]] +name = "pre-commit-hooks" +version = "3.3.0" +description = "Some out-of-the-box hooks for pre-commit." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +"ruamel.yaml" = ">=0.15" +toml = "*" + +[[package]] +name = "py" +version = "1.9.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.*" + +[[package]] +name = "pycodestyle" +version = "2.6.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pydocstyle" +version = "5.1.1" +description = "Python docstring style checker" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +snowballstemmer = "*" + +[[package]] +name = "pyflakes" +version = "2.2.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 = "pygments" +version = "2.7.2" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pytest" +version = "6.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +checkqa_mypy = ["mypy (0.780)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[package.dependencies] +atomicwrites = ">=1.0" +attrs = ">=17.4.0" +colorama = "*" +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.8.2" +toml = "*" + +[package.dependencies.importlib-metadata] +version = ">=0.12" +python = "<3.8" + +[[package]] +name = "pytest-cov" +version = "2.10.1" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=4.6" + +[[package]] +name = "pytz" +version = "2020.1" +description = "World timezone definitions, modern and historical" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pyyaml" +version = "5.3.1" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "regex" +version = "2020.10.28" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "reorder-python-imports" +version = "2.3.5" +description = "Tool for reordering python imports" +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +"aspy.refactor-imports" = ">=2.1.0" + +[[package]] +name = "requests" +version = "2.24.0" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<4" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" + +[[package]] +name = "restructuredtext-lint" +version = "1.3.1" +description = "reStructuredText linter" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +docutils = ">=0.11,<1.0" + +[[package]] +name = "ruamel.yaml" +version = "0.16.12" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +docs = ["ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[package.dependencies] +[package.dependencies."ruamel.yaml.clib"] +version = ">=0.1.2" +python = "<3.9" + +[[package]] +name = "ruamel.yaml.clib" +version = "0.2.2" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "dev" +optional = false +python-versions = "*" +marker = "platform_python_implementation == \"CPython\" and python_version < \"3.9\"" + +[[package]] +name = "six" +version = "1.15.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "smmap" +version = "3.0.4" +description = "A pure Python implementation of a sliding window memory map manager" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "snowballstemmer" +version = "2.0.0" +description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "sphinx" +version = "3.2.1" +description = "Python documentation generator" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.780)", "docutils-stubs"] +test = ["pytest", "pytest-cov", "html5lib", "typed-ast", "cython"] + +[package.dependencies] +alabaster = ">=0.7,<0.8" +babel = ">=1.3" +colorama = ">=0.3.5" +docutils = ">=0.12" +imagesize = "*" +Jinja2 = ">=2.3" +packaging = "*" +Pygments = ">=2.0" +requests = ">=2.5.0" +setuptools = "*" +snowballstemmer = ">=1.1" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = "*" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = "*" + +[[package]] +name = "sphinx-autobuild" +version = "2020.9.1" +description = "Rebuild Sphinx documentation on changes, with live-reload in the browser." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +test = ["pytest", "pytest-cov"] + +[package.dependencies] +livereload = "*" +sphinx = "*" + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "1.0.3" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest", "html5lib"] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +test = ["pytest", "flake8", "mypy"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.4" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "stevedore" +version = "3.2.2" +description = "Manage dynamic plugins for Python applications" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[package.dependencies.importlib-metadata] +version = ">=1.7.0" +python = "<3.8" + +[[package]] +name = "toml" +version = "0.10.1" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "tornado" +version = "6.0.4" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +category = "dev" +optional = false +python-versions = ">= 3.5" +marker = "python_version > \"2.7\"" + +[[package]] +name = "typed-ast" +version = "1.4.1" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "urllib3" +version = "1.25.11" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] + +[[package]] +name = "virtualenv" +version = "20.1.0" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.extras] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] + +[package.dependencies] +appdirs = ">=1.4.3,<2" +distlib = ">=0.3.1,<1" +filelock = ">=3.0.0,<4" +six = ">=1.9.0,<2" + +[package.dependencies.importlib-metadata] +version = ">=0.12,<3" +python = "<3.8" + +[package.dependencies.importlib-resources] +version = ">=1.0" +python = "<3.7" + +[[package]] +name = "zipp" +version = "3.4.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.6" +marker = "python_version < \"3.8\"" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] + +[metadata] +lock-version = "1.0" +python-versions = "^3.6.1" +content-hash = "d42a9e4480b364cd2fcc929659360b991f7e69c575d6a5a1b73be71729b9c714" + +[metadata.files] +alabaster = [ + {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, + {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, +] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +"aspy.refactor-imports" = [ + {file = "aspy.refactor_imports-2.1.1-py2.py3-none-any.whl", hash = "sha256:9df76bf19ef81620068b785a386740ab3c8939fcbdcebf20c4a4e0057230d782"}, + {file = "aspy.refactor_imports-2.1.1.tar.gz", hash = "sha256:eec8d1a73bedf64ffb8b589ad919a030c1fb14acf7d1ce0ab192f6eedae895c5"}, +] +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-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, + {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, +] +babel = [ + {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, + {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, +] +bandit = [ + {file = "bandit-1.6.2-py2.py3-none-any.whl", hash = "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952"}, + {file = "bandit-1.6.2.tar.gz", hash = "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065"}, +] +betamax = [ + {file = "betamax-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa5ad34cc8d018b35814fb0557d15c78ced9ac56fdc43ccacdb882aa7a5217c1"}, + {file = "betamax-0.8.1.tar.gz", hash = "sha256:5bf004ceffccae881213fb722f34517166b84a34919b92ffc14d1dbd050b71c2"}, +] +black = [ + {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, +] +cached-property = [ + {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, + {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, +] +certifi = [ + {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, + {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, +] +cfgv = [ + {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, + {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, +] +chardet = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, +] +coverage = [ + {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, + {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, + {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, + {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, + {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, + {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, + {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, + {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, + {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, + {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, + {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, + {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, + {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, + {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, + {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, + {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, + {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, + {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, + {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, + {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, +] +darglint = [ + {file = "darglint-1.5.5-py3-none-any.whl", hash = "sha256:cd882c812f28ee3b5577259bfd8d6d25962386dd87fc1f3756eac24370aaa060"}, + {file = "darglint-1.5.5.tar.gz", hash = "sha256:2f12ce2ef3d8189279a8f2eb4c53fd215dbacae50e37765542a91310400a9cd6"}, +] +dataclasses = [ + {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"}, + {file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"}, +] +defusedxml = [ + {file = "defusedxml-0.6.0-py2.py3-none-any.whl", hash = "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93"}, + {file = "defusedxml-0.6.0.tar.gz", hash = "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5"}, +] +distlib = [ + {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, + {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, +] +docutils = [ + {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, + {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, +] +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +] +flake8 = [ + {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, + {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, +] +flake8-bandit = [ + {file = "flake8_bandit-2.1.2.tar.gz", hash = "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"}, +] +flake8-bugbear = [ + {file = "flake8-bugbear-20.1.4.tar.gz", hash = "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"}, + {file = "flake8_bugbear-20.1.4-py36.py37.py38-none-any.whl", hash = "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63"}, +] +flake8-docstrings = [ + {file = "flake8-docstrings-1.5.0.tar.gz", hash = "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717"}, + {file = "flake8_docstrings-1.5.0-py2.py3-none-any.whl", hash = "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc"}, +] +flake8-polyfill = [ + {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, + {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, +] +flake8-rst-docstrings = [ + {file = "flake8-rst-docstrings-0.0.14.tar.gz", hash = "sha256:8f8bcb18f1408b506dd8ba2c99af3eac6128f6911d4bf6ff874b94caa70182a2"}, +] +gitdb = [ + {file = "gitdb-4.0.5-py3-none-any.whl", hash = "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac"}, + {file = "gitdb-4.0.5.tar.gz", hash = "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"}, +] +gitpython = [ + {file = "GitPython-3.1.11-py3-none-any.whl", hash = "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b"}, + {file = "GitPython-3.1.11.tar.gz", hash = "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8"}, +] +identify = [ + {file = "identify-1.5.6-py2.py3-none-any.whl", hash = "sha256:3139bf72d81dfd785b0a464e2776bd59bdc725b4cc10e6cf46b56a0db931c82e"}, + {file = "identify-1.5.6.tar.gz", hash = "sha256:969d844b7a85d32a5f9ac4e163df6e846d73c87c8b75847494ee8f4bd2186421"}, +] +idna = [ + {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, + {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, +] +imagesize = [ + {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, + {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, +] +importlib-metadata = [ + {file = "importlib_metadata-2.0.0-py2.py3-none-any.whl", hash = "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"}, + {file = "importlib_metadata-2.0.0.tar.gz", hash = "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da"}, +] +importlib-resources = [ + {file = "importlib_resources-3.3.0-py2.py3-none-any.whl", hash = "sha256:a3d34a8464ce1d5d7c92b0ea4e921e696d86f2aa212e684451cb1482c8d84ed5"}, + {file = "importlib_resources-3.3.0.tar.gz", hash = "sha256:7b51f0106c8ec564b1bef3d9c588bc694ce2b92125bbb6278f4f2f5b54ec3592"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +jinja2 = [ + {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, + {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, +] +livereload = [ + {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, +] +markupsafe = [ + {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +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"}, +] +nodeenv = [ + {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, + {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, +] +packaging = [ + {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, + {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, +] +pathspec = [ + {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, + {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, +] +pbr = [ + {file = "pbr-5.5.1-py2.py3-none-any.whl", hash = "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"}, + {file = "pbr-5.5.1.tar.gz", hash = "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9"}, +] +pep8-naming = [ + {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, + {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +pre-commit = [ + {file = "pre_commit-2.8.0-py2.py3-none-any.whl", hash = "sha256:92c62687ff39b92dc700d68369ec9081de85c2e0373d8bf5fc07a36116ddec4e"}, + {file = "pre_commit-2.8.0.tar.gz", hash = "sha256:973b8f53e426266cfb136886a1fcfdbea2ca2641dde77f4ad9b4f9a7e174f742"}, +] +pre-commit-hooks = [ + {file = "pre_commit_hooks-3.3.0-py2.py3-none-any.whl", hash = "sha256:2190d72ac867bd9b8880de32d9304ec54182c89720cce56f22742890ed8ba90f"}, + {file = "pre_commit_hooks-3.3.0.tar.gz", hash = "sha256:1e18c0451279fb88653c7b9f8fd73ccc35925e95b636c5b64095538f68a23b06"}, +] +py = [ + {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, + {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, +] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] +pydocstyle = [ + {file = "pydocstyle-5.1.1-py3-none-any.whl", hash = "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"}, + {file = "pydocstyle-5.1.1.tar.gz", hash = "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] +pygments = [ + {file = "Pygments-2.7.2-py3-none-any.whl", hash = "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773"}, + {file = "Pygments-2.7.2.tar.gz", hash = "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pytest = [ + {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, + {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, +] +pytest-cov = [ + {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, + {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, +] +pytz = [ + {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, + {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, +] +pyyaml = [ + {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, + {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, + {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, + {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, + {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, +] +regex = [ + {file = "regex-2020.10.28-cp27-cp27m-win32.whl", hash = "sha256:4b5a9bcb56cc146c3932c648603b24514447eafa6ce9295234767bf92f69b504"}, + {file = "regex-2020.10.28-cp27-cp27m-win_amd64.whl", hash = "sha256:c13d311a4c4a8d671f5860317eb5f09591fbe8259676b86a85769423b544451e"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c8a2b7ccff330ae4c460aff36626f911f918555660cc28163417cb84ffb25789"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4afa350f162551cf402bfa3cd8302165c8e03e689c897d185f16a167328cc6dd"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b88fa3b8a3469f22b4f13d045d9bd3eda797aa4e406fde0a2644bc92bbdd4bdd"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f43109822df2d3faac7aad79613f5f02e4eab0fc8ad7932d2e70e2a83bd49c26"}, + {file = "regex-2020.10.28-cp36-cp36m-win32.whl", hash = "sha256:8092a5a06ad9a7a247f2a76ace121183dc4e1a84c259cf9c2ce3bbb69fac3582"}, + {file = "regex-2020.10.28-cp36-cp36m-win_amd64.whl", hash = "sha256:49461446b783945597c4076aea3f49aee4b4ce922bd241e4fcf62a3e7c61794c"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8ca9dca965bd86ea3631b975d63b0693566d3cc347e55786d5514988b6f5b84c"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ea37320877d56a7f0a1e6a625d892cf963aa7f570013499f5b8d5ab8402b5625"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:3a5f08039eee9ea195a89e180c5762bfb55258bfb9abb61a20d3abee3b37fd12"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:cb905f3d2e290a8b8f1579d3984f2cfa7c3a29cc7cba608540ceeed18513f520"}, + {file = "regex-2020.10.28-cp37-cp37m-win32.whl", hash = "sha256:a62162be05edf64f819925ea88d09d18b09bebf20971b363ce0c24e8b4aa14c0"}, + {file = "regex-2020.10.28-cp37-cp37m-win_amd64.whl", hash = "sha256:03855ee22980c3e4863dc84c42d6d2901133362db5daf4c36b710dd895d78f0a"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux1_i686.whl", hash = "sha256:625116aca6c4b57c56ea3d70369cacc4d62fead4930f8329d242e4fe7a58ce4b"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2dc522e25e57e88b4980d2bdd334825dbf6fa55f28a922fc3bfa60cc09e5ef53"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:119e0355dbdd4cf593b17f2fc5dbd4aec2b8899d0057e4957ba92f941f704bf5"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:cfcf28ed4ce9ced47b9b9670a4f0d3d3c0e4d4779ad4dadb1ad468b097f808aa"}, + {file = "regex-2020.10.28-cp38-cp38-win32.whl", hash = "sha256:06b52815d4ad38d6524666e0d50fe9173533c9cc145a5779b89733284e6f688f"}, + {file = "regex-2020.10.28-cp38-cp38-win_amd64.whl", hash = "sha256:c3466a84fce42c2016113101018a9981804097bacbab029c2d5b4fcb224b89de"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c2c6c56ee97485a127555c9595c069201b5161de9d05495fbe2132b5ac104786"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1ec66700a10e3c75f1f92cbde36cca0d3aaee4c73dfa26699495a3a30b09093c"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:11116d424734fe356d8777f89d625f0df783251ada95d6261b4c36ad27a394bb"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f1fce1e4929157b2afeb4bb7069204d4370bab9f4fc03ca1fbec8bd601f8c87d"}, + {file = "regex-2020.10.28-cp39-cp39-win32.whl", hash = "sha256:832339223b9ce56b7b15168e691ae654d345ac1635eeb367ade9ecfe0e66bee0"}, + {file = "regex-2020.10.28-cp39-cp39-win_amd64.whl", hash = "sha256:654c1635f2313d0843028487db2191530bca45af61ca85d0b16555c399625b0e"}, + {file = "regex-2020.10.28.tar.gz", hash = "sha256:dd3e6547ecf842a29cf25123fbf8d2461c53c8d37aa20d87ecee130c89b7079b"}, +] +reorder-python-imports = [ + {file = "reorder_python_imports-2.3.5-py2.py3-none-any.whl", hash = "sha256:6e8d3baba68c409ec87242757cf579a7ad2b133d1efed498be987b97ee385ac3"}, + {file = "reorder_python_imports-2.3.5.tar.gz", hash = "sha256:7c46593d39899e3fb249248b448bde93ee7417889904f015c0c5a738c23fd0e0"}, +] +requests = [ + {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, + {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, +] +restructuredtext-lint = [ + {file = "restructuredtext_lint-1.3.1.tar.gz", hash = "sha256:470e53b64817211a42805c3a104d2216f6f5834b22fe7adb637d1de4d6501fb8"}, +] +"ruamel.yaml" = [ + {file = "ruamel.yaml-0.16.12-py2.py3-none-any.whl", hash = "sha256:012b9470a0ea06e4e44e99e7920277edf6b46eee0232a04487ea73a7386340a5"}, + {file = "ruamel.yaml-0.16.12.tar.gz", hash = "sha256:076cc0bc34f1966d920a49f18b52b6ad559fbe656a0748e3535cf7b3f29ebf9e"}, +] +"ruamel.yaml.clib" = [ + {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc"}, + {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1"}, + {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-win32.whl", hash = "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7"}, + {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-win_amd64.whl", hash = "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f"}, + {file = "ruamel.yaml.clib-0.2.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2"}, + {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026"}, + {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b"}, + {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-win32.whl", hash = "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f"}, + {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62"}, + {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c"}, + {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988"}, + {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-win32.whl", hash = "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2"}, + {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91"}, + {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6"}, + {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e"}, + {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-win32.whl", hash = "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6"}, + {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5"}, + {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0"}, + {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99"}, + {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-win32.whl", hash = "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1"}, + {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b"}, + {file = "ruamel.yaml.clib-0.2.2.tar.gz", hash = "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7"}, +] +six = [ + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, +] +smmap = [ + {file = "smmap-3.0.4-py2.py3-none-any.whl", hash = "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4"}, + {file = "smmap-3.0.4.tar.gz", hash = "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, + {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, +] +sphinx = [ + {file = "Sphinx-3.2.1-py3-none-any.whl", hash = "sha256:ce6fd7ff5b215af39e2fcd44d4a321f6694b4530b6f2b2109b64d120773faea0"}, + {file = "Sphinx-3.2.1.tar.gz", hash = "sha256:321d6d9b16fa381a5306e5a0b76cd48ffbc588e6340059a729c6fdd66087e0e8"}, +] +sphinx-autobuild = [ + {file = "sphinx-autobuild-2020.9.1.tar.gz", hash = "sha256:4b184a7db893f2100bbd831991ae54ca89167a2b9ce68faea71eaa9e37716aed"}, + {file = "sphinx_autobuild-2020.9.1-py3-none-any.whl", hash = "sha256:df5c72cb8b8fc9b31279c4619780c4e95029be6de569ff60a8bb2e99d20f63dd"}, +] +sphinxcontrib-applehelp = [ + {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, + {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, +] +sphinxcontrib-devhelp = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] +sphinxcontrib-htmlhelp = [ + {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, + {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, +] +sphinxcontrib-jsmath = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] +sphinxcontrib-qthelp = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] +sphinxcontrib-serializinghtml = [ + {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, + {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, +] +stevedore = [ + {file = "stevedore-3.2.2-py3-none-any.whl", hash = "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62"}, + {file = "stevedore-3.2.2.tar.gz", hash = "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0"}, +] +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, +] +tornado = [ + {file = "tornado-6.0.4-cp35-cp35m-win32.whl", hash = "sha256:5217e601700f24e966ddab689f90b7ea4bd91ff3357c3600fa1045e26d68e55d"}, + {file = "tornado-6.0.4-cp35-cp35m-win_amd64.whl", hash = "sha256:c98232a3ac391f5faea6821b53db8db461157baa788f5d6222a193e9456e1740"}, + {file = "tornado-6.0.4-cp36-cp36m-win32.whl", hash = "sha256:5f6a07e62e799be5d2330e68d808c8ac41d4a259b9cea61da4101b83cb5dc673"}, + {file = "tornado-6.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:c952975c8ba74f546ae6de2e226ab3cc3cc11ae47baf607459a6728585bb542a"}, + {file = "tornado-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:2c027eb2a393d964b22b5c154d1a23a5f8727db6fda837118a776b29e2b8ebc6"}, + {file = "tornado-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:5618f72e947533832cbc3dec54e1dffc1747a5cb17d1fd91577ed14fa0dc081b"}, + {file = "tornado-6.0.4-cp38-cp38-win32.whl", hash = "sha256:22aed82c2ea340c3771e3babc5ef220272f6fd06b5108a53b4976d0d722bcd52"}, + {file = "tornado-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:c58d56003daf1b616336781b26d184023ea4af13ae143d9dda65e31e534940b9"}, + {file = "tornado-6.0.4.tar.gz", hash = "sha256:0fe2d45ba43b00a41cd73f8be321a44936dc1aba233dee979f17a042b83eb6dc"}, +] +typed-ast = [ + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, + {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, + {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, + {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, + {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] +urllib3 = [ + {file = "urllib3-1.25.11-py2.py3-none-any.whl", hash = "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"}, + {file = "urllib3-1.25.11.tar.gz", hash = "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2"}, +] +virtualenv = [ + {file = "virtualenv-20.1.0-py2.py3-none-any.whl", hash = "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2"}, + {file = "virtualenv-20.1.0.tar.gz", hash = "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380"}, +] +zipp = [ + {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, + {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..68eedae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[tool.poetry] +name = "smbmc" +version = "0.1.0" +description = "Supermicro BMC interface" +authors = ["George Rawlinson "] +repository = "https://github.com/grawlinson/smbmc" +readme = "README.rst" + +[tool.poetry.dependencies] +python = "^3.6.1" +requests = "^2.24.0" +defusedxml = "^0.6.0" + +[tool.poetry.dev-dependencies] +pytest = "^6.1" +betamax = "^0.8.1" +pytest-cov = "^2.10.1" +coverage = {extras = ["toml"], version = "^5.3"} +pre-commit = "^2.7.1" +pre-commit-hooks = "^3.2.0" +flake8 = "^3.8.4" +black = "^20.8b1" +flake8-bandit = "^2.1.2" +flake8-bugbear = "^20.1.4" +flake8-docstrings = "^1.5.0" +flake8-rst-docstrings = "^0.0.14" +pep8-naming = "^0.11.1" +reorder-python-imports = "^2.3.5" +darglint = "^1.5.5" +sphinx = "^3.2.1" +sphinx-autobuild = "^2020.9.1" + +[tool.coverage.paths] +source = ["src", "*/site-packages"] + +[tool.coverage.run] +branch = true +source = ["smbmc"] + +[tool.coverage.report] +fail_under = 100 +show_missing = true + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/src/smbmc/__init__.py b/src/smbmc/__init__.py new file mode 100644 index 0000000..b86b2b6 --- /dev/null +++ b/src/smbmc/__init__.py @@ -0,0 +1,20 @@ +"""The smbmc package.""" +try: + from importlib.metadata import version, PackageNotFoundError +except ImportError: # pragma: no cover + from importlib_metadata import version, PackageNotFoundError + +try: + __version__ = version(__name__) +except PackageNotFoundError: # pragma: no cover + __version__ = "unknown" + +from .models import ( + PowerSupply, + PowerSupplyFlag, + Sensor, + SensorStateEnum, + SensorTypeEnum, + SensorUnitEnum, +) +from .client import Client diff --git a/src/smbmc/client.py b/src/smbmc/client.py new file mode 100644 index 0000000..2c4bbdf --- /dev/null +++ b/src/smbmc/client.py @@ -0,0 +1,125 @@ +"""Provides the Client class.""" +from time import time as now + +from requests import Session + +from .ipmi_pmbus import process_pmbus_response +from .ipmi_sensor import process_sensor_response +from .util import contains_duplicates +from .util import contains_valid_items +from .util import extract_xml_attr + +KNOWN_SENSORS = ["pmbus", "sensor"] + + +class Client: + """Client used to access Supermicro BMCs.""" + + def __init__(self, server, username, password): + """Initialises an instance of smbmc.Client. + + Args: + server: Address of server in form: 'http://192.168.1.1'. + username: Username. + password: Password. + """ + self.server = server + self.username = username + self.password = password + self._session = Session() + self.last_call = None + + def login(self): + """Login to Supermicro web interface. + + Fetches a session ID (SID) cookie, which allows access to the rest + of the web interface. SID length is approximately 30 minutes, + according to the default timeout configuration. + + Raises: + Exception: Authentication Error. + """ + self._session.post( + f"{self.server}/cgi/login.cgi", + data={ + "name": self.username, + "pwd": self.password, + }, + ) + + if "SID" in self._session.cookies.get_dict().keys(): + self.last_call = now() + else: + raise Exception("Authentication Error") + + def get_pmbus_metrics(self): + """Acquire metrics for all power supplies. + + Returns: + str: XML response. + """ + self.last_call = now() + r = self._session.post( + f"{self.server}/cgi/ipmi.cgi", + data={ + "Get_PSInfoReadings.XML": "(0,0)", + }, + ) + + psu_list = extract_xml_attr(r.text, ".//PSItem") + power_supplies = process_pmbus_response(psu_list) + + return power_supplies + + def get_sensor_metrics(self): + """Acquire metrics for all sensors. + + Returns: + str: XML response. + """ + self.last_call = now() + + r = self._session.post( + f"{self.server}/cgi/ipmi.cgi", + data={ + "SENSOR_INFO.XML": "(1,ff)", + }, + ) + + sensor_list = extract_xml_attr(r.text, ".//SENSOR") + sensors = process_sensor_response(sensor_list) + + return sensors + + def get_metrics(self, metrics=["pmbus", "sensor"]): # noqa: C901 + """Fetch metrics with minimum network calls. + + Args: + metrics: List of metric(s) to query. + + Raises: + Exception: Argument contains duplicate metrics. + Exception: Argument contains invalid metrics. + + Returns: + dict: A dict containing all metrics. + """ + if contains_duplicates(metrics): + raise Exception("metrics array contains duplicates") + + if not contains_valid_items(KNOWN_SENSORS, metrics): + raise Exception("metrics array contains invalid metrics") + + self.login() + result = {} + + for metric in metrics: + values = None + if metric == "pmbus": + values = self.get_pmbus_metrics() + elif metric == "sensor": # pragma: no cover + values = self.get_sensor_metrics() + + result.update({metric: values}) + + return result diff --git a/src/smbmc/ipmi_pmbus.py b/src/smbmc/ipmi_pmbus.py new file mode 100644 index 0000000..c541e94 --- /dev/null +++ b/src/smbmc/ipmi_pmbus.py @@ -0,0 +1,65 @@ +"""Provides IPMI PMBus related functions.""" +from .models import PowerSupply + + +def process_pmbus_response(psu_list: list) -> list: + """Obtain all power supplies. + + Args: + psu_list: List of power supplies obtained from an XML response. + + Returns: + list: Fully populated power supplies, complete with ID. + """ + power_supplies = [] + psu_id = 0 + for item in psu_list: + psu = process_pmbus_psu(item) + psu.id = psu_id + psu_id += 1 + power_supplies.append(psu) + + return power_supplies + + +def process_pmbus_psu(item: dict) -> PowerSupply: + """Process a single power supply. + + Args: + item: A single power supply obtained from an XML response. + + Returns: + PowerSupply: Fully populated power supply, minus the ID. + """ + psu = PowerSupply() + psu.name = item["name"] + psu.status = item["a_b_PS_Status_I2C"] + psu.type = item["psType"] + psu.input_voltage = int(item["acInVoltage"], 16) + psu.input_current = int(item["acInCurrent"], 16) / 1000 + psu.input_power = int(item["acInPower"], 16) + psu.output_voltage = int(item["dc12OutVoltage"], 16) / 10 + psu.output_current = int(item["dc12OutCurrent"], 16) / 1000 + psu.output_power = int(item["dcOutPower"], 16) + psu.temp_1 = int(item["temp1"], 16) + psu.temp_2 = int(item["temp2"], 16) + psu.fan_1 = int(item["fan1"], 16) + psu.fan_2 = int(item["fan2"], 16) + + # for key, value in item.items(): + # if "temp" in key: + # sensor = Sensor() + # sensor.name = key + # sensor.reading = int(value, 16) + # sensor.type = SensorTypeEnum.TEMPERATURE + # sensor.unit = SensorUnitEnum.DEGREES_CELSIUS + # psu.sensors.append(sensor) + # elif "fan" in key: + # sensor = Sensor() + # sensor.name = key + # sensor.reading = int(value, 16) + # sensor.type = SensorTypeEnum.FAN + # sensor.unit = SensorUnitEnum.RPM + # psu.sensors.append(sensor) + + return psu diff --git a/src/smbmc/ipmi_sensor.py b/src/smbmc/ipmi_sensor.py new file mode 100644 index 0000000..54ace8f --- /dev/null +++ b/src/smbmc/ipmi_sensor.py @@ -0,0 +1,267 @@ +"""Provides IPMI sensor related functions.""" +from enum import auto +from enum import IntEnum + +from .models import PowerSupplyFlag +from .models import Sensor +from .models import SensorStateEnum +from .models import SensorTypeEnum +from .models import SensorUnitEnum +from .util import hex_signed_int +from .util import signed_int +from .util import ten_bit_str + +SENSOR_READING_SCALE = 1000 + + +class LinearisationEnum(IntEnum): + """Enumeration of linearisation formulas.""" + + LINEAR = 0 + LN = auto() + LOG_10 = auto() + LOG_2 = auto() + EULER = auto() + EXP_10 = auto() + EXP_2 = auto() + ONE_DIV_X = auto() + SQR = auto() + CUBE = auto() + ONE_DIV_CUBE = auto() + + +def reading_conversion(data: str, m: str, b: str, rb: str) -> float: + """Performs Sensor Reading Conversion Formula. + + TODO: This is the pre-linearisation formula, so look + into the spec some more. + + Extracted from IPMI 2.0 specification, section 36.3. + + Args: + data: Data reading. + m: Multiplier value. + b: Offset value. + rb: RB Exponent value. + + Returns: + float: Pre-linearisation reading. + """ + from math import pow + + # Extracted from 43.1 - SDR Type 01h, bytes 25, 27, 30. + m_raw = ten_bit_str(m) + b_raw = ten_bit_str(b) + km_raw = int(rb, 16) >> 4 + kb_raw = int(rb, 16) & 0x0F + + m_data = signed_int(m_raw, 10) + b_data = signed_int(b_raw, 10) + km_data = signed_int(km_raw, 4) + kb_data = signed_int(kb_raw, 4) + + sensor_data = (m_data * int(data, 16) + b_data * pow(10, kb_data)) * pow( + 10, km_data + ) + + return float(sensor_data) + + +def is_threshold_sensor(er_type: str) -> bool: + """Detects whether sensor is threshold or discrete. + + Extracted from the raw Event Reading Type field. + + Byte 14 - Event/Reading Type Code. + + Referenced from chapter 41. + + Args: + er_type: Event Reading Type in raw form. + + Returns: + bool: True if is a threshold sensor. + """ + return int(er_type, 16) == 1 + + +def is_analog_data_format(unit_type_1: str) -> bool: + """Detects whether sensor reading is in analog data format. + + Args: + unit_type_1: No clue. + + Returns: + bool: True if analog data format. + """ + return (int(unit_type_1, 16) >> 6) == 2 + + +def get_sensor_state(option: str) -> SensorStateEnum: + """Extract sensor state from OPTION byte. + + ... hopefully! Actually, probably a bad guess. + + Args: + option: Raw OPTION byte from IPMI response. + + Returns: + SensorStateEnum: State of the sensor. + """ + if int(option, 16) & 0x40: + return SensorStateEnum.PRESENT + else: + return SensorStateEnum.NOT_PRESENT + + +def perform_linearisation(method: str, reading: str) -> int: + """Perform linearisation based on a method and given reading. + + Args: + method: Linearisation method specified by IPMI response. + reading: Reading obtained from IPMI response. + + Returns: + int: Linearised sensor reading. + + Raises: + NotImplementedError: Raised when linearisation methods have + not been implemented. + """ + i_method = int(method, 16) + + if i_method == LinearisationEnum.LINEAR: + return int((reading * SENSOR_READING_SCALE)) / SENSOR_READING_SCALE + else: + raise NotImplementedError + + +def process_threshold_sensor(item: dict) -> Sensor: + """Process a threshold sensor. + + Args: + item: Dict representing a sensor, obtained from the IPMI response. + + Returns: + Sensor: Fully populated sensor. + """ + # values + values = {} + values["reading"] = item["READING"][:2] + values["unr"] = item["UNR"] + values["uc"] = item["UC"] + values["unc"] = item["UNC"] + values["lnc"] = item["LNC"] + values["lc"] = item["LC"] + values["lnr"] = item["LNR"] + + # linearisation variables + multiplier = item["M"] + offset = item["B"] + rb_exponent = item["RB"] + l_method = item["L"] + + # analog data conversion + if is_analog_data_format(item["UNIT1"]): + for key, value in values.items(): + values[key] = hex_signed_int(value) + + # perform linearisation of readings + for key, value in values.items(): + values[key] = perform_linearisation( + l_method, reading_conversion(value, multiplier, offset, rb_exponent) + ) + + # add a sensor and we've got a stew goin'! + sensor = Sensor() + sensor.name = item["NAME"] + sensor.type = SensorTypeEnum(int(item["STYPE"], 16)) + sensor.unit = SensorUnitEnum(int(item["UNIT"], 16)) + sensor.state = get_sensor_state(item["OPTION"]) + sensor.reading = values["reading"] + sensor.unc = values["unc"] + sensor.uc = values["uc"] + sensor.unr = values["unr"] + sensor.lc = values["lc"] + sensor.lnc = values["lnc"] + sensor.lnr = values["lnr"] + + return sensor + + +def process_discrete_sensor(item: dict) -> Sensor: + """Process a discrete sensor. + + TODO: finish this function. + + Args: + item: Dict representing a sensor, obtained from the IPMI response. + + Returns: + Sensor: Fully populated sensor. + + Raises: + NotImplementedError: Raised when sensor type has not been implemented. + """ + type = int(item["STYPE"], 16) + reading = item["READING"] + # raw_reading = reading[:2] + # option = int(item["OPTION"], 16) + sensor_d = int(reading[2:4], 16) + # sensor_dmsb = int(reading[4:6], 16) + # print(f"R:{reading} RR:{raw_reading} OPT:{option}") + # print(f"SD:{sensor_d} S_DMSB:{sensor_dmsb}") + sensor_state = get_sensor_state(item["OPTION"]) + + # TODO: add edge-cases from utils.js + if sensor_state is not SensorStateEnum.NOT_PRESENT: + if type == SensorTypeEnum.POWER_SUPPLY: + psu = Sensor() + psu.name = item["NAME"] + psu.type = SensorTypeEnum(type) + psu.flags = PowerSupplyFlag(sensor_d) + psu.state = sensor_state + return psu + else: + raise NotImplementedError + + # utils.js: ShowDiscStateAPI( Sensor_Type, sensor_d ) + # servh_sensor: ProcDiscreteSensor(node,Idx) + + +def process_sensor_response(sensor_list: list) -> list: + """Obtain all sensors. + + Args: + sensor_list: List of sensors obtained from an XML response. + + Returns: + list: Fully populated sensors. + """ + sensors = [] + sensor_id = 0 + for item in sensor_list: + sensor = process_sensor(item) + sensor.id = sensor_id + sensors.append(sensor) + + sensor_id += 1 + + return sensors + + +def process_sensor(item: dict) -> Sensor: + """Process a single sensor. + + Args: + item: A single sensor obtained from an XML response. + + Returns: + Sensor: Fully populated sensor. + """ + if is_threshold_sensor(item["ERTYPE"]): + sensor = process_threshold_sensor(item) + else: + sensor = process_discrete_sensor(item) + + return sensor diff --git a/src/smbmc/models.py b/src/smbmc/models.py new file mode 100644 index 0000000..25f2360 --- /dev/null +++ b/src/smbmc/models.py @@ -0,0 +1,131 @@ +"""Provides models.""" +from enum import IntEnum +from enum import IntFlag + + +class SensorStateEnum(IntEnum): + """Enumeration of sensor states.""" + + UNSPECIFIED = 0 + PRESENT = 1 + NOT_PRESENT = 2 + + +class SensorUnitEnum(IntEnum): + """Enumeration of sensor units.""" + + UNSPECIFIED = 0 + DEGREES_CELSIUS = 1 + VOLTS = 4 + AMPS = 5 + WATTS = 6 + RPM = 18 + + +class SensorTypeEnum(IntEnum): + """Enumeration of sensor types.""" + + UNSPECIFIED = 0 + TEMPERATURE = 1 + VOLTAGE = 2 + FAN = 4 + POWER_SUPPLY = 8 + + +class PowerSupplyFlag(IntFlag): + """Flags for power supply specific events.""" + + UNSPECIFIED = 0 + PRESENCE_DETECTED = 1 + FAILURE = 2 + PREDICTIVE_FAILURE = 4 + SOURCE_INPUT_LOST = 8 + SOURCE_INPUT_OUT_OF_RANGE = 16 + SOURCE_INPUT_DETECTED_OUT_OF_RANGE = 32 + CONFIGURATION_ERROR = 64 + STANDBY = 128 + + +class Sensor: + """Sensor provides an interface to sensors. + + Attributes: + id: Psuedo-unique id. + name: Sensor name. + type: Sensor type. + unit: Reading unit. e.g. Temperature - degrees Celsius. + state: Sensor state. + flags: Discrete sensors only; type specific flags. + reading: Sensor reading. + lnr: Lower non-recoverable indicator. + lc: Lower critical indicator. + lnc: Lower non-critical indicator. + unc: Upper non-critical indicator. + uc: Upper critical indicator. + unr: Upper non-recoverable indicator. + """ + + def __init__(self): + """Creates an instance of the Sensor class.""" + self.id = 0 + self.name = "" + self.type = SensorTypeEnum.UNSPECIFIED + self.unit = SensorUnitEnum.UNSPECIFIED + self.state = SensorStateEnum.UNSPECIFIED + self.flags = None + # values + self.reading = 0 + self.lnr = 0 + self.lc = 0 + self.lnc = 0 + self.unc = 0 + self.uc = 0 + self.unr = 0 + + +class PowerSupply: + """PowerSupply provides an interface to power supplies. + + Attributes: + id: Psuedo-unique id. + name: Serial number. + status: Not 100% sure, but possibly status relayed via I2C. + type: Not 100% sure on this either. + input_voltage: Input voltage (V_AC). + input_current: Input current (A). + input_power: Input power (W). + output_voltage: Output voltage (V_DC). + output_current: Output current (A). + output_power: Output power (W). + temp_1: Temperature 1. Possibly intake (degrees Celsius). + temp_2: Temperature 2. Possibly outlet (degrees Celsius). + fan_1: Fan 1. Possibly intake (r.p.m.). + fan_2: Fan 2. Possibly outlet (r.p.m.). + """ + + def __init__(self): + """Creates an instance of the PowerSupply class.""" + # id : slot no. in server chassis + # name : power supply serial number + # status : most likely i2c status + # [1=i2c_enabled,0=i2c_disabled] + # type : most likely power status + # [1=on,0=disconnected/off/standby] + self.id = 0 + self.name = "" + self.status = 0 + self.type = 0 + # input characteristics + self.input_voltage = 0 + self.input_current = 0 + self.input_power = 0 + # output characteristics + self.output_voltage = 0 + self.output_current = 0 + self.output_power = 0 + # sensors + # self.sensors = [] + self.temp_1 = 0 + self.temp_2 = 0 + self.fan_1 = 0 + self.fan_2 = 0 diff --git a/src/smbmc/util.py b/src/smbmc/util.py new file mode 100644 index 0000000..3b44fcb --- /dev/null +++ b/src/smbmc/util.py @@ -0,0 +1,108 @@ +"""Provides utility functions.""" +# from __future__ import annotations # only works with python 3.7+ +# list[str] + + +def contains_duplicates(item_list: list) -> bool: + """Check if given list contains any duplicates. + + Args: + item_list: List of items to check for duplicates. + + Returns: + True if list contains duplicates. + """ + element_set = set() + for item in item_list: + if item in element_set: + return True + else: + element_set.add(item) + return False + + +def contains_valid_items(known_items: list, item_list: list) -> bool: + """Check if given list contains known items. + + Args: + known_items: List of known strings to check against. + item_list: List of strings to check for unknown items. + + Returns: + True if list contains valid items. + """ + for item in item_list: + if item not in known_items: + return False + return True + + +def signed_int(value: int, signed_bit: int) -> int: + """Convert from unsigned to a signed integer. + + Args: + value: Unsigned integer. + signed_bit: Location of the signed bit. + + Returns: + int: Signed integer. + """ + if signed_bit > 0: + if (value % (0x01 << signed_bit) / (0x01 << (signed_bit - 1))) < 1: + return value % (0x01 << signed_bit - 1) + else: + temporary_value = (value % (0x01 << signed_bit - 1)) ^ ( + (0x01 << signed_bit - 1) - 1 + ) + return -1 - temporary_value + + return value + + +def hex_signed_int(value: str, signed_bit=8) -> str: + """Convert from unsigned to signed integer as a hexadecimal string. + + Args: + value: Unsigned integer. + signed_bit: Location of the signed bit. Default: 8. + + Returns: + str: Hexadecimal representation of a signed integer. + """ + return hex(signed_int(int(value, 16), signed_bit)) + + +def ten_bit_str(value: str) -> int: + """Convert two bytes to a 10-bit int. + + TODO: check this, I'm tired. + + Args: + value: String consisting of two bytes. + + Returns: + int: 10-bit int. + """ + return ((int(value, 16) & 0xC0) << 2) + (int(value, 16) >> 8) + + +def extract_xml_attr(xml: str, match: str) -> list: + """Extract all incidences of a given XML element. + + Args: + xml: String representation of an XML document. + match: Subelements to match via tag name or path. + + Returns: + list: List of subelements matching query. + """ + from defusedxml import ElementTree + + tree = ElementTree.fromstring(xml.strip()) + elements = tree.findall(match) + + result = [] + for element in elements: + result.append(element.attrib) + + return result diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0b9fbf6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for smbmc package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7c552c7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Test Configuration.""" +import os + +import betamax + +SMBMC_SERVER = os.environ.get("SMBMC_SERVER", "http://192.168.1.1") +SMBMC_USER = os.environ.get("SMBMC_USER", "ipmi_user") +SMBMC_PASS = os.environ.get("SMBMC_PASS", "ipmi_pass") + +with betamax.Betamax.configure() as config: + config.cassette_library_dir = "tests/integration/cassettes" + config.default_cassette_options["match_requests_on"].extend(["body", "path"]) + config.define_cassette_placeholder("", SMBMC_SERVER) + config.define_cassette_placeholder("", SMBMC_USER) + config.define_cassette_placeholder("", SMBMC_PASS) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..647c09e --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests for smbmc package.""" diff --git a/tests/integration/cassettes/Client_bad_auth.json b/tests/integration/cassettes/Client_bad_auth.json new file mode 100644 index 0000000..f5a5f1c --- /dev/null +++ b/tests/integration/cassettes/Client_bad_auth.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": "name=nonexistent_user&pwd=nonexistent_password"}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Content-Length": ["46"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/login.cgi"}, "response": {"body": {"encoding": "ISO-8859-1", "string": "\n\n \n \n \n \n\n\n\n"}, "headers": {"Content-Length": ["381"], "Content-Type": ["text/html"], "Date": ["Mon, 12 Oct 2020 07:26:14 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/login.cgi"}, "recorded_at": "2020-10-12T07:26:15"}], "recorded_with": "betamax/0.8.1"} \ No newline at end of file diff --git a/tests/integration/cassettes/Client_get_metrics.json b/tests/integration/cassettes/Client_get_metrics.json new file mode 100644 index 0000000..18d7519 --- /dev/null +++ b/tests/integration/cassettes/Client_get_metrics.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": "name=&pwd="}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Content-Length": ["29"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/login.cgi"}, "response": {"body": {"encoding": "ISO-8859-1", "string": "\n\n\n \n \n \n \n \n\n\n\n"}, "headers": {"Set-Cookie": ["SID=; expires=Thursday,01-Jan-1970 00:00:00 GMT; HttpOnly", "SID=sjbhfksjnquapwkl; path=/ ; HttpOnly"], "Content-Length": ["580"], "Content-Type": ["text/html"], "Date": ["Thu, 08 Oct 2020 19:01:37 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/login.cgi"}, "recorded_at": "2020-10-08T19:01:37"}, {"request": {"body": {"encoding": "utf-8", "string": "Get_PSInfoReadings.XML=%280%2C0%29"}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Cookie": ["SID=sjbhfksjnquapwkl"], "Content-Length": ["34"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/ipmi.cgi"}, "response": {"body": {"encoding": null, "string": "\n\n \n \n \n \n \n \n\n"}, "headers": {"Content-Type": ["application/xml"], "Content-Length": ["932"], "Date": ["Thu, 08 Oct 2020 19:01:37 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/ipmi.cgi"}, "recorded_at": "2020-10-08T19:01:37"}, {"request": {"body": {"encoding": "utf-8", "string": "SENSOR_INFO.XML=%281%2Cff%29"}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Cookie": ["SID=sjbhfksjnquapwkl"], "Content-Length": ["28"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/ipmi.cgi"}, "response": {"body": {"encoding": null, "string": " "}, "headers": {"Content-Type": ["application/xml"], "Content-Length": ["6104"], "Date": ["Thu, 08 Oct 2020 19:01:37 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/ipmi.cgi"}, "recorded_at": "2020-10-08T19:01:37"}], "recorded_with": "betamax/0.8.1"} diff --git a/tests/integration/cassettes/Client_get_pmbus_metrics.json b/tests/integration/cassettes/Client_get_pmbus_metrics.json new file mode 100644 index 0000000..fdc94c5 --- /dev/null +++ b/tests/integration/cassettes/Client_get_pmbus_metrics.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": "name=&pwd="}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Content-Length": ["29"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/login.cgi"}, "response": {"body": {"encoding": "ISO-8859-1", "string": "\n\n\n \n \n \n \n \n\n\n\n"}, "headers": {"Set-Cookie": ["SID=; expires=Thursday,01-Jan-1970 00:00:00 GMT; HttpOnly", "SID=vqpcndpiuloyjnlw; path=/ ; HttpOnly"], "Content-Length": ["580"], "Content-Type": ["text/html"], "Date": ["Wed, 07 Oct 2020 07:28:48 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/login.cgi"}, "recorded_at": "2020-10-07T07:28:48"}, {"request": {"body": {"encoding": "utf-8", "string": "Get_PSInfoReadings.XML=%280%2C0%29"}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Cookie": ["SID=vqpcndpiuloyjnlw"], "Content-Length": ["34"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/ipmi.cgi"}, "response": {"body": {"encoding": null, "string": "\n\n \n \n \n \n \n \n\n"}, "headers": {"Content-Type": ["application/xml"], "Content-Length": ["932"], "Date": ["Wed, 07 Oct 2020 07:28:48 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/ipmi.cgi"}, "recorded_at": "2020-10-07T07:28:48"}], "recorded_with": "betamax/0.8.1"} diff --git a/tests/integration/cassettes/Client_get_sensor_metrics.json b/tests/integration/cassettes/Client_get_sensor_metrics.json new file mode 100644 index 0000000..fdf4a94 --- /dev/null +++ b/tests/integration/cassettes/Client_get_sensor_metrics.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": "name=&pwd="}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Content-Length": ["29"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/login.cgi"}, "response": {"body": {"encoding": "ISO-8859-1", "string": "\n\n\n \n \n \n \n \n\n\n\n"}, "headers": {"Set-Cookie": ["SID=; expires=Thursday,01-Jan-1970 00:00:00 GMT; HttpOnly", "SID=vqpcndpiuloyjnlw; path=/ ; HttpOnly"], "Content-Length": ["580"], "Content-Type": ["text/html"], "Date": ["Wed, 07 Oct 2020 07:28:48 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/login.cgi"}, "recorded_at": "2020-10-07T07:28:48"}, {"request": {"body": {"encoding": "utf-8", "string": "SENSOR_INFO.XML=%281%2Cff%29"}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Cookie": ["SID=vqpcndpiuloyjnlw"], "Content-Length": ["28"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/ipmi.cgi"}, "response": {"body": {"encoding": null, "string": " "}, "headers": {"Content-Type": ["application/xml"], "Content-Length": ["6104"], "Date": ["Wed, 07 Oct 2020 07:28:48 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/ipmi.cgi"}, "recorded_at": "2020-10-07T07:28:48"}], "recorded_with": "betamax/0.8.1"} diff --git a/tests/integration/cassettes/Client_login.json b/tests/integration/cassettes/Client_login.json new file mode 100644 index 0000000..957342b --- /dev/null +++ b/tests/integration/cassettes/Client_login.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": "name=&pwd="}, "headers": {"User-Agent": ["python-requests/2.24.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"], "Content-Length": ["29"], "Content-Type": ["application/x-www-form-urlencoded"]}, "method": "POST", "uri": "/cgi/login.cgi"}, "response": {"body": {"encoding": "ISO-8859-1", "string": "\n\n\n \n \n \n \n \n\n\n\n"}, "headers": {"Set-Cookie": ["SID=; expires=Thursday,01-Jan-1970 00:00:00 GMT; HttpOnly", "SID=vqpcndpiuloyjnlw; path=/ ; HttpOnly"], "Content-Length": ["580"], "Content-Type": ["text/html"], "Date": ["Wed, 07 Oct 2020 07:28:48 GMT"]}, "status": {"code": 200, "message": "OK"}, "url": "/cgi/login.cgi"}, "recorded_at": "2020-10-07T07:28:48"}], "recorded_with": "betamax/0.8.1"} diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py new file mode 100644 index 0000000..f0ba350 --- /dev/null +++ b/tests/integration/test_client.py @@ -0,0 +1,93 @@ +"""Integration tests for smbmc.Client class.""" +import os + +import betamax +import pytest + +from smbmc import Client +from smbmc import PowerSupply +from smbmc import Sensor + +SMBMC_SERVER = os.environ.get("SMBMC_SERVER", "http://192.168.1.1") +SMBMC_USER = os.environ.get("SMBMC_USER", "ipmi_user") +SMBMC_PASS = os.environ.get("SMBMC_PASS", "ipmi_pass") + + +class TestClient: + """Testing class for smbmc.Client.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Set-up for testing.""" + self.client = Client(SMBMC_SERVER, SMBMC_USER, SMBMC_PASS) + self.recorder = betamax.Betamax(self.client._session) + + @staticmethod + def generate_cassette_name(method_name): + """Generate cassette name. + + Args: + method_name: The method name being tested. + + Returns: + cassette_name: Name used for betamax Cassette. + """ + return f"Client_{method_name}" + + def test_login(self): + """Test smbmc.Client.login().""" + cassette_name = self.generate_cassette_name("login") + with self.recorder.use_cassette(cassette_name): + self.client.login() + + assert self.client.last_call is not None + assert "SID" in self.client._session.cookies.get_dict().keys() + assert self.client._session.cookies["SID"] is not None + + def test_get_sensor_metrics(self): + """Test smbmc.Client.get_sensor_metrics().""" + cassette_name = self.generate_cassette_name("get_sensor_metrics") + with self.recorder.use_cassette(cassette_name): + self.client.login() + r = self.client.get_sensor_metrics() + + assert r is not None + assert len(r) == 28 + for sensor in r: + assert isinstance(sensor, Sensor) + + def test_get_pmbus_metrics(self): + """Test smbmc.Client.get_pmbus_metrics().""" + cassette_name = self.generate_cassette_name("get_pmbus_metrics") + with self.recorder.use_cassette(cassette_name): + self.client.login() + r = self.client.get_pmbus_metrics() + + assert r is not None + assert len(r) == 4 + for psu in r: + assert isinstance(psu, PowerSupply) + + def test_get_metrics(self): + """Test smbmc.Client.get_metrics().""" + cassette_name = self.generate_cassette_name("get_metrics") + with self.recorder.use_cassette(cassette_name): + r = self.client.get_metrics() + + assert r is not None + assert "pmbus" in r + assert r["pmbus"] is not None + assert len(r["pmbus"]) == 4 + assert "sensor" in r + assert r["sensor"] is not None + assert len(r["sensor"]) == 28 + + def test_bad_auth(self): + """Test invalid authentication.""" + self.client = Client(SMBMC_SERVER, "nonexistent_user", "nonexistent_password") + self.recorder = betamax.Betamax(self.client._session) + + cassette_name = self.generate_cassette_name("bad_auth") + with self.recorder.use_cassette(cassette_name): + with pytest.raises(Exception, match="Authentication Error"): + assert self.client.login() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..44b828e --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for smbmc package.""" diff --git a/tests/unit/ipmi_response_pmbus.xml b/tests/unit/ipmi_response_pmbus.xml new file mode 100644 index 0000000..c38e07e --- /dev/null +++ b/tests/unit/ipmi_response_pmbus.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/unit/ipmi_response_sel.xml b/tests/unit/ipmi_response_sel.xml new file mode 100644 index 0000000..edb0c91 --- /dev/null +++ b/tests/unit/ipmi_response_sel.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/unit/ipmi_response_sensors.xml b/tests/unit/ipmi_response_sensors.xml new file mode 100644 index 0000000..70c3580 --- /dev/null +++ b/tests/unit/ipmi_response_sensors.xml @@ -0,0 +1 @@ + diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..0838046 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,19 @@ +"""Unit tests for smbmc.Client class.""" +import pytest + +from smbmc import Client + +# TODO stub out request call +client = Client("", "", "") + + +def test_duplicate_metrics(): + """Check for duplicate metrics.""" + with pytest.raises(Exception, match="duplicates"): + assert client.get_metrics(["dupe", "dupe"]) + + +def test_invalid_metrics(): + """Check for invalid metrics.""" + with pytest.raises(Exception, match="invalid metric"): + assert client.get_metrics([None, 1, "magic_school_bus"]) diff --git a/tests/unit/test_ipmi_pmbus.py b/tests/unit/test_ipmi_pmbus.py new file mode 100644 index 0000000..ee2922b --- /dev/null +++ b/tests/unit/test_ipmi_pmbus.py @@ -0,0 +1,44 @@ +"""Unit tests for IPMI PMBus functions.""" +from smbmc.ipmi_pmbus import process_pmbus_response +from smbmc.util import extract_xml_attr + + +def test_process_power_supplies(): + """Test the processing of an IPMI response containing PSUs.""" + xml_file = "ipmi_response_pmbus" + selector = ".//PSItem" + xml_string = open(f"tests/unit/{xml_file}.xml").read() + extracted_list = extract_xml_attr(xml_string, selector) + power_supplies = process_pmbus_response(extracted_list) + + assert len(power_supplies) == 4 + + for psu in power_supplies: + if psu.id == 1: + assert psu.name == "PSU0SERIAL0NO00" + assert psu.status == "1" + assert psu.type == "1" + assert psu.input_voltage == 239 + assert psu.input_current == 0.359 + assert psu.input_power == 84 + assert psu.output_voltage == 12.1 + assert psu.output_current == 5.75 + assert psu.output_power == 69 + assert psu.temp_1 == 40 + assert psu.temp_2 == 55 + assert psu.fan_1 == 2894 + assert psu.fan_2 == 3847 + else: + assert psu.name == "" + assert psu.status == "ff" + assert psu.type == "0" + assert psu.input_voltage == 0 + assert psu.input_current == 0.0 + assert psu.input_power == 0 + assert psu.output_voltage == 0.0 + assert psu.output_current == 0.0 + assert psu.output_power == 0 + assert psu.temp_1 == 0 + assert psu.temp_2 == 0 + assert psu.fan_1 == 0 + assert psu.fan_2 == 0 diff --git a/tests/unit/test_ipmi_sensor.py b/tests/unit/test_ipmi_sensor.py new file mode 100644 index 0000000..7ae3710 --- /dev/null +++ b/tests/unit/test_ipmi_sensor.py @@ -0,0 +1,160 @@ +"""Unit tests for IPMI sensor functions.""" +import pytest + +from smbmc.ipmi_sensor import get_sensor_state +from smbmc.ipmi_sensor import is_analog_data_format +from smbmc.ipmi_sensor import is_threshold_sensor +from smbmc.ipmi_sensor import perform_linearisation +from smbmc.ipmi_sensor import process_discrete_sensor +from smbmc.ipmi_sensor import process_sensor_response +from smbmc.ipmi_sensor import reading_conversion +from smbmc.models import Sensor +from smbmc.models import SensorStateEnum +from smbmc.util import extract_xml_attr + + +@pytest.mark.parametrize( + "data,m,b,rb,expected_result", + [ + ("84", "6400", "0000", "d0", 13.200000000000001), + ("83", "6400", "0000", "d0", 13.1), + ("7f", "6400", "0000", "d0", 12.700000000000001), + ("6a", "6400", "0000", "d0", 10.6), + ("65", "6400", "0000", "d0", 10.1), + ("64", "6400", "0000", "d0", 10.0), + ], +) +def test_reading_conversion(data, m, b, rb, expected_result): + """Ensure sensor readings are converted properly. + + Args: + data: Sensor reading. + m: Multiplier value. + b: Offset value. + rb: RB Exponent value. + expected_result: Floating point result. + """ + assert reading_conversion(data, m, b, rb) == expected_result + + +@pytest.mark.parametrize( + "er_type,expected_result", + [ + ("0x01", True), + ("1", True), + ("FF", False), + ("2", False), + ("0x02", False), + ], +) +def test_is_threshold_sensor(er_type, expected_result): + """Ensure sensor type is correctly guessed. + + Args: + er_type: Unmodified ERTYPE value. + expected_result: Expected guess. + """ + assert is_threshold_sensor(er_type) is expected_result + + +@pytest.mark.parametrize( + "unit_type_1,expected_result", + [ + ("0x80", True), + ("91", True), + ("0xC0", False), + ("C0", False), + ("0x00", False), + ("00", False), + ], +) +def test_is_analog_data_format(unit_type_1, expected_result): + """Ensure analog data format is correctly guessed. + + Args: + unit_type_1: Unmodified UNIT1 value. + expected_result: Expected guess. + """ + assert is_analog_data_format(unit_type_1) is expected_result + + +@pytest.mark.parametrize( + "option,expected_result", + [ + ("0x40", SensorStateEnum.PRESENT), + ("0x80", SensorStateEnum.NOT_PRESENT), + ], +) +def test_get_sensor_state(option, expected_result): + """Ensure correct sensor state is returned. + + Args: + option: Unmodified OPTION value. + expected_result: Expected sensor state. + """ + assert get_sensor_state(option) is expected_result + + +@pytest.mark.parametrize( + "method,reading,expected_result", + [ + ("0", 5.0000001, 5.0), + ("0", 5.0, 5.0), + ("0", 5.7, 5.7), + ("0", 5.6000000000000005, 5.6), + ("0", 13.200000000000001, 13.2), + ("0", 13.1, 13.1), + ("0", 12.700000000000001, 12.7), + ("0", 0, 0), + ], +) +def test_perform_linearisation(method, reading, expected_result): + """Ensure linearisation is performed correctly. + + Args: + method: Currently, only linear formula is implemented. + reading: Pre-linearisation reading. + expected_result: Expected result. + """ + assert perform_linearisation(method, reading) == expected_result + + +def test_perform_linearisation_error(): + """Ensure unimplemented functionality raises an error.""" + with pytest.raises(NotImplementedError): + assert perform_linearisation("02", "02") + + +def test_process_sensor_response(): + """Ensure all items returned are Sensor instances.""" + xml_file = "ipmi_response_sensors" + selector = ".//SENSOR" + xml_string = open(f"tests/unit/{xml_file}.xml").read() + sensor_list = extract_xml_attr(xml_string, selector) + sensors = process_sensor_response(sensor_list) + + assert len(sensors) == 28 + + for sensor in sensors: + assert isinstance(sensor, Sensor) + + +def test_process_threshold_sensor_error(): + """Ensure unimplemented sensor raises an error.""" + item = {} + item["STYPE"] = "01" + item["READING"] = "010100" + item["OPTION"] = "c0" + + with pytest.raises(NotImplementedError): + assert process_discrete_sensor(item) + + +def test_process_threshold_sensor_not_present(): + """Test result if discrete sensor is not present.""" + item = {} + item["STYPE"] = "01" + item["READING"] = "010100" + item["OPTION"] = "00" + + assert process_discrete_sensor(item) is None diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py new file mode 100644 index 0000000..11f34cc --- /dev/null +++ b/tests/unit/test_util.py @@ -0,0 +1,161 @@ +"""Unit tests for utility functions.""" +import pytest + +from smbmc.util import contains_duplicates +from smbmc.util import contains_valid_items +from smbmc.util import extract_xml_attr +from smbmc.util import hex_signed_int +from smbmc.util import signed_int +from smbmc.util import ten_bit_str + + +@pytest.mark.parametrize( + "items,expected_result", + [ + ( + ["first", "second", "third"], + False, + ), + ( + ["duplicate", "duplicate", "duplicate"], + True, + ), + ], +) +def test_contains_duplicates(items, expected_result): + """Check if duplicates are correctly detected. + + Args: + items: Array of items to check. + expected_result: Boolean value representing duplicate status. + """ + assert contains_duplicates(items) is expected_result + + +@pytest.mark.parametrize( + "allowed_items,items,expected_result", + [ + ( + ["bang-a-rang!", "rufiooooo!"], + ["definitely not rufio!"], + False, + ), + ( + ["1", "2", "3"], + ["2", "3"], + True, + ), + ], +) +def test_contains_valid_items(allowed_items, items, expected_result): + """Check if valid/invalid items are correctly detected. + + Args: + allowed_items: Array of items allowed to be present. + items: Array of items to check. + expected_result: Boolean value representing validity of items array. + """ + assert contains_valid_items(allowed_items, items) is expected_result + + +@pytest.mark.parametrize( + "unsigned_value,signed_bit,signed_value", + [ + (129, 8, -127), + (874, 10, -150), + (200, 0, 200), + (7, 4, 7), + ], +) +def test_signed_int(unsigned_value, signed_bit, signed_value): + """Check conversion of unsigned -> signed integers. + + Args: + unsigned_value: Unsigned integer. + signed_bit: Bit representing integer length, e.g. 8 = 8-bit integer. + signed_value: Signed integer representing expected result. + """ + assert signed_int(unsigned_value, signed_bit) == signed_value + + +@pytest.mark.parametrize( + "unsigned_string,signed_bit,hex_string", + [ + ("10", 0, "0x10"), + ("120", 8, "0x20"), + ], +) +def test_hex_signed_int(unsigned_string, signed_bit, hex_string): + """Check conversion of unsigned strings to signed integers stored as hex strings. + + Args: + unsigned_string: Unsigned integer stored as a string. + signed_bit: Bit representing integer length. + hex_string: Signed integer stored as a hex string. + """ + assert hex_signed_int(unsigned_string, signed_bit) == hex_string + + +@pytest.mark.parametrize( + "two_byte_string,ten_bit_int", + [ + ("FF", 768), + ("AB", 512), + ("200", 2), + ("0x200", 2), + ], +) +def test_ten_bit_str(two_byte_string, ten_bit_int): + """Check conversion of 16-bit hexadecimal strings to 10-bit integers. + + Args: + two_byte_string: Hexadecimal string representing an integer value to convert. + ten_bit_int: Integer representing 10-bit result. + + """ + assert ten_bit_str(two_byte_string) == ten_bit_int + + +@pytest.mark.parametrize( + "xml_file,selector,expected_length", + [ + ( + "ipmi_response_sensors", + ".//SENSOR", + 28, + ), + ( + "ipmi_response_sensors", + ".//NOT_A_SENSOR", + 0, + ), + ( + "ipmi_response_sel", + ".//SEL", + 6, + ), + ( + "ipmi_response_pmbus", + ".//PSItem", + 4, + ), + ( + "ipmi_response_pmbus", + ".//PSInfo", + 1, + ), + ], +) +def test_extract_xml(xml_file, selector, expected_length): + """Ensure that specific selectors are extracted. + + Args: + xml_file: XML file containing an IPMI response. + selector: XML Selector used to match specific sub-element(s). + expected_length: Quantity of expected sub-element(s). + """ + xml_string = open(f"tests/unit/{xml_file}.xml").read() + extracted_list = extract_xml_attr(xml_string, selector) + + assert extracted_list is not None + assert len(extracted_list) == expected_length