├── .flake8 ├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── Makefile ├── README.md ├── doc ├── .gitignore ├── Makefile ├── conf.py ├── index.rst ├── modules.rst └── qav.rst ├── qav.spec ├── qav ├── __init__.py ├── filters.py ├── listpack.py ├── questions.py ├── utils.py └── validators.py ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── test_filters.py ├── test_listpack.py ├── test_questions.py ├── test_utils.py └── test_validators.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | *.egg-info/ 4 | *.pyc 5 | .*.swp 6 | .DS_Store 7 | env/ 8 | 9 | # pytest/tox 10 | .cache/ 11 | .pytest_cache/ 12 | .coverage 13 | .tox/ 14 | .mypy_cache/ 15 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - build 4 | - deploy 5 | 6 | run_unit_tests: 7 | stage: test 8 | image: python:3.6-slim 9 | script: 10 | - pip install -q tox 11 | - tox -e py36 12 | tags: 13 | - docker 14 | 15 | flake8: 16 | stage: test 17 | image: python:3.6-slim 18 | script: 19 | - pip install -q tox 20 | - tox -e flake8 21 | tags: 22 | - docker 23 | 24 | mypy: 25 | stage: test 26 | image: python:3.6-slim 27 | script: 28 | - pip install -q tox 29 | - tox -e mypy 30 | tags: 31 | - docker 32 | 33 | .build_rpm_template: &build_rpm_definition 34 | stage: build 35 | image: registry.umiacs.umd.edu/docker/build/umbuild:$DISTRO 36 | script: 37 | - make rpm 38 | artifacts: 39 | expire_in: 1hr 40 | paths: 41 | - dist/ 42 | only: 43 | - tags 44 | tags: 45 | - docker 46 | 47 | build_rpm_rhel7: 48 | <<: *build_rpm_definition 49 | variables: 50 | DISTRO: rhel7 51 | 52 | build_rpm_rhel8: 53 | <<: *build_rpm_definition 54 | variables: 55 | DISTRO: rhel8 56 | 57 | build_python_package: 58 | stage: build 59 | image: python:3.6-slim 60 | script: 61 | - python setup.py sdist bdist_wheel 62 | artifacts: 63 | expire_in: 1hr 64 | paths: 65 | - dist/ 66 | only: 67 | - tags 68 | tags: 69 | - docker 70 | 71 | .deploy_rpm_template: &deploy_rpm_definition 72 | stage: deploy 73 | image: registry.umiacs.umd.edu/docker/build/umbuild:$DISTRO 74 | script: 75 | - make copy_rpm 76 | - make createrepo 77 | when: manual 78 | only: 79 | - tags 80 | tags: 81 | - umrepos 82 | - docker 83 | 84 | deploy_rpm_rhel7: 85 | <<: *deploy_rpm_definition 86 | variables: 87 | DISTRO: rhel7 88 | dependencies: 89 | - build_rpm_rhel7 90 | 91 | deploy_rpm_rhel8: 92 | <<: *deploy_rpm_definition 93 | variables: 94 | DISTRO: rhel8 95 | dependencies: 96 | - build_rpm_rhel8 97 | 98 | upload_pypi: 99 | stage: deploy 100 | image: python:3.6-slim 101 | script: 102 | - pip install -q twine 103 | - twine upload -u $PYPI_USERNAME -p $PYPI_PASSWORD dist/$CI_PROJECT_NAME-$CI_COMMIT_TAG.tar.gz 104 | when: manual 105 | only: 106 | - tags 107 | dependencies: 108 | - build_python_package 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 2.1, February 1999 3 | 4 | Copyright (C) 1991, 1999 Free Software Foundation, Inc. 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | (This is the first released version of the Lesser GPL. It also counts 10 | as the successor of the GNU Library Public License, version 2, hence 11 | the version number 2.1.) 12 | 13 | Preamble 14 | 15 | The licenses for most software are designed to take away your 16 | freedom to share and change it. By contrast, the GNU General Public 17 | Licenses are intended to guarantee your freedom to share and change 18 | free software--to make sure the software is free for all its users. 19 | 20 | This license, the Lesser General Public License, applies to some 21 | specially designated software packages--typically libraries--of the 22 | Free Software Foundation and other authors who decide to use it. You 23 | can use it too, but we suggest you first think carefully about whether 24 | this license or the ordinary General Public License is the better 25 | strategy to use in any particular case, based on the explanations below. 26 | 27 | When we speak of free software, we are referring to freedom of use, 28 | not price. Our General Public Licenses are designed to make sure that 29 | you have the freedom to distribute copies of free software (and charge 30 | for this service if you wish); that you receive source code or can get 31 | it if you want it; that you can change the software and use pieces of 32 | it in new free programs; and that you are informed that you can do 33 | these things. 34 | 35 | To protect your rights, we need to make restrictions that forbid 36 | distributors to deny you these rights or to ask you to surrender these 37 | rights. These restrictions translate to certain responsibilities for 38 | you if you distribute copies of the library or if you modify it. 39 | 40 | For example, if you distribute copies of the library, whether gratis 41 | or for a fee, you must give the recipients all the rights that we gave 42 | you. You must make sure that they, too, receive or can get the source 43 | code. If you link other code with the library, you must provide 44 | complete object files to the recipients, so that they can relink them 45 | with the library after making changes to the library and recompiling 46 | it. And you must show them these terms so they know their rights. 47 | 48 | We protect your rights with a two-step method: (1) we copyright the 49 | library, and (2) we offer you this license, which gives you legal 50 | permission to copy, distribute and/or modify the library. 51 | 52 | To protect each distributor, we want to make it very clear that 53 | there is no warranty for the free library. Also, if the library is 54 | modified by someone else and passed on, the recipients should know 55 | that what they have is not the original version, so that the original 56 | author's reputation will not be affected by problems that might be 57 | introduced by others. 58 | 59 | Finally, software patents pose a constant threat to the existence of 60 | any free program. We wish to make sure that a company cannot 61 | effectively restrict the users of a free program by obtaining a 62 | restrictive license from a patent holder. Therefore, we insist that 63 | any patent license obtained for a version of the library must be 64 | consistent with the full freedom of use specified in this license. 65 | 66 | Most GNU software, including some libraries, is covered by the 67 | ordinary GNU General Public License. This license, the GNU Lesser 68 | General Public License, applies to certain designated libraries, and 69 | is quite different from the ordinary General Public License. We use 70 | this license for certain libraries in order to permit linking those 71 | libraries into non-free programs. 72 | 73 | When a program is linked with a library, whether statically or using 74 | a shared library, the combination of the two is legally speaking a 75 | combined work, a derivative of the original library. The ordinary 76 | General Public License therefore permits such linking only if the 77 | entire combination fits its criteria of freedom. The Lesser General 78 | Public License permits more lax criteria for linking other code with 79 | the library. 80 | 81 | We call this license the "Lesser" General Public License because it 82 | does Less to protect the user's freedom than the ordinary General 83 | Public License. It also provides other free software developers Less 84 | of an advantage over competing non-free programs. These disadvantages 85 | are the reason we use the ordinary General Public License for many 86 | libraries. However, the Lesser license provides advantages in certain 87 | special circumstances. 88 | 89 | For example, on rare occasions, there may be a special need to 90 | encourage the widest possible use of a certain library, so that it becomes 91 | a de-facto standard. To achieve this, non-free programs must be 92 | allowed to use the library. A more frequent case is that a free 93 | library does the same job as widely used non-free libraries. In this 94 | case, there is little to gain by limiting the free library to free 95 | software only, so we use the Lesser General Public License. 96 | 97 | In other cases, permission to use a particular library in non-free 98 | programs enables a greater number of people to use a large body of 99 | free software. For example, permission to use the GNU C Library in 100 | non-free programs enables many more people to use the whole GNU 101 | operating system, as well as its variant, the GNU/Linux operating 102 | system. 103 | 104 | Although the Lesser General Public License is Less protective of the 105 | users' freedom, it does ensure that the user of a program that is 106 | linked with the Library has the freedom and the wherewithal to run 107 | that program using a modified version of the Library. 108 | 109 | The precise terms and conditions for copying, distribution and 110 | modification follow. Pay close attention to the difference between a 111 | "work based on the library" and a "work that uses the library". The 112 | former contains code derived from the library, whereas the latter must 113 | be combined with the library in order to run. 114 | 115 | GNU LESSER GENERAL PUBLIC LICENSE 116 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 117 | 118 | 0. This License Agreement applies to any software library or other 119 | program which contains a notice placed by the copyright holder or 120 | other authorized party saying it may be distributed under the terms of 121 | this Lesser General Public License (also called "this License"). 122 | Each licensee is addressed as "you". 123 | 124 | A "library" means a collection of software functions and/or data 125 | prepared so as to be conveniently linked with application programs 126 | (which use some of those functions and data) to form executables. 127 | 128 | The "Library", below, refers to any such software library or work 129 | which has been distributed under these terms. A "work based on the 130 | Library" means either the Library or any derivative work under 131 | copyright law: that is to say, a work containing the Library or a 132 | portion of it, either verbatim or with modifications and/or translated 133 | straightforwardly into another language. (Hereinafter, translation is 134 | included without limitation in the term "modification".) 135 | 136 | "Source code" for a work means the preferred form of the work for 137 | making modifications to it. For a library, complete source code means 138 | all the source code for all modules it contains, plus any associated 139 | interface definition files, plus the scripts used to control compilation 140 | and installation of the library. 141 | 142 | Activities other than copying, distribution and modification are not 143 | covered by this License; they are outside its scope. The act of 144 | running a program using the Library is not restricted, and output from 145 | such a program is covered only if its contents constitute a work based 146 | on the Library (independent of the use of the Library in a tool for 147 | writing it). Whether that is true depends on what the Library does 148 | and what the program that uses the Library does. 149 | 150 | 1. You may copy and distribute verbatim copies of the Library's 151 | complete source code as you receive it, in any medium, provided that 152 | you conspicuously and appropriately publish on each copy an 153 | appropriate copyright notice and disclaimer of warranty; keep intact 154 | all the notices that refer to this License and to the absence of any 155 | warranty; and distribute a copy of this License along with the 156 | Library. 157 | 158 | You may charge a fee for the physical act of transferring a copy, 159 | and you may at your option offer warranty protection in exchange for a 160 | fee. 161 | 162 | 2. You may modify your copy or copies of the Library or any portion 163 | of it, thus forming a work based on the Library, and copy and 164 | distribute such modifications or work under the terms of Section 1 165 | above, provided that you also meet all of these conditions: 166 | 167 | a) The modified work must itself be a software library. 168 | 169 | b) You must cause the files modified to carry prominent notices 170 | stating that you changed the files and the date of any change. 171 | 172 | c) You must cause the whole of the work to be licensed at no 173 | charge to all third parties under the terms of this License. 174 | 175 | d) If a facility in the modified Library refers to a function or a 176 | table of data to be supplied by an application program that uses 177 | the facility, other than as an argument passed when the facility 178 | is invoked, then you must make a good faith effort to ensure that, 179 | in the event an application does not supply such function or 180 | table, the facility still operates, and performs whatever part of 181 | its purpose remains meaningful. 182 | 183 | (For example, a function in a library to compute square roots has 184 | a purpose that is entirely well-defined independent of the 185 | application. Therefore, Subsection 2d requires that any 186 | application-supplied function or table used by this function must 187 | be optional: if the application does not supply it, the square 188 | root function must still compute square roots.) 189 | 190 | These requirements apply to the modified work as a whole. If 191 | identifiable sections of that work are not derived from the Library, 192 | and can be reasonably considered independent and separate works in 193 | themselves, then this License, and its terms, do not apply to those 194 | sections when you distribute them as separate works. But when you 195 | distribute the same sections as part of a whole which is a work based 196 | on the Library, the distribution of the whole must be on the terms of 197 | this License, whose permissions for other licensees extend to the 198 | entire whole, and thus to each and every part regardless of who wrote 199 | it. 200 | 201 | Thus, it is not the intent of this section to claim rights or contest 202 | your rights to work written entirely by you; rather, the intent is to 203 | exercise the right to control the distribution of derivative or 204 | collective works based on the Library. 205 | 206 | In addition, mere aggregation of another work not based on the Library 207 | with the Library (or with a work based on the Library) on a volume of 208 | a storage or distribution medium does not bring the other work under 209 | the scope of this License. 210 | 211 | 3. You may opt to apply the terms of the ordinary GNU General Public 212 | License instead of this License to a given copy of the Library. To do 213 | this, you must alter all the notices that refer to this License, so 214 | that they refer to the ordinary GNU General Public License, version 2, 215 | instead of to this License. (If a newer version than version 2 of the 216 | ordinary GNU General Public License has appeared, then you can specify 217 | that version instead if you wish.) Do not make any other change in 218 | these notices. 219 | 220 | Once this change is made in a given copy, it is irreversible for 221 | that copy, so the ordinary GNU General Public License applies to all 222 | subsequent copies and derivative works made from that copy. 223 | 224 | This option is useful when you wish to copy part of the code of 225 | the Library into a program that is not a library. 226 | 227 | 4. You may copy and distribute the Library (or a portion or 228 | derivative of it, under Section 2) in object code or executable form 229 | under the terms of Sections 1 and 2 above provided that you accompany 230 | it with the complete corresponding machine-readable source code, which 231 | must be distributed under the terms of Sections 1 and 2 above on a 232 | medium customarily used for software interchange. 233 | 234 | If distribution of object code is made by offering access to copy 235 | from a designated place, then offering equivalent access to copy the 236 | source code from the same place satisfies the requirement to 237 | distribute the source code, even though third parties are not 238 | compelled to copy the source along with the object code. 239 | 240 | 5. A program that contains no derivative of any portion of the 241 | Library, but is designed to work with the Library by being compiled or 242 | linked with it, is called a "work that uses the Library". Such a 243 | work, in isolation, is not a derivative work of the Library, and 244 | therefore falls outside the scope of this License. 245 | 246 | However, linking a "work that uses the Library" with the Library 247 | creates an executable that is a derivative of the Library (because it 248 | contains portions of the Library), rather than a "work that uses the 249 | library". The executable is therefore covered by this License. 250 | Section 6 states terms for distribution of such executables. 251 | 252 | When a "work that uses the Library" uses material from a header file 253 | that is part of the Library, the object code for the work may be a 254 | derivative work of the Library even though the source code is not. 255 | Whether this is true is especially significant if the work can be 256 | linked without the Library, or if the work is itself a library. The 257 | threshold for this to be true is not precisely defined by law. 258 | 259 | If such an object file uses only numerical parameters, data 260 | structure layouts and accessors, and small macros and small inline 261 | functions (ten lines or less in length), then the use of the object 262 | file is unrestricted, regardless of whether it is legally a derivative 263 | work. (Executables containing this object code plus portions of the 264 | Library will still fall under Section 6.) 265 | 266 | Otherwise, if the work is a derivative of the Library, you may 267 | distribute the object code for the work under the terms of Section 6. 268 | Any executables containing that work also fall under Section 6, 269 | whether or not they are linked directly with the Library itself. 270 | 271 | 6. As an exception to the Sections above, you may also combine or 272 | link a "work that uses the Library" with the Library to produce a 273 | work containing portions of the Library, and distribute that work 274 | under terms of your choice, provided that the terms permit 275 | modification of the work for the customer's own use and reverse 276 | engineering for debugging such modifications. 277 | 278 | You must give prominent notice with each copy of the work that the 279 | Library is used in it and that the Library and its use are covered by 280 | this License. You must supply a copy of this License. If the work 281 | during execution displays copyright notices, you must include the 282 | copyright notice for the Library among them, as well as a reference 283 | directing the user to the copy of this License. Also, you must do one 284 | of these things: 285 | 286 | a) Accompany the work with the complete corresponding 287 | machine-readable source code for the Library including whatever 288 | changes were used in the work (which must be distributed under 289 | Sections 1 and 2 above); and, if the work is an executable linked 290 | with the Library, with the complete machine-readable "work that 291 | uses the Library", as object code and/or source code, so that the 292 | user can modify the Library and then relink to produce a modified 293 | executable containing the modified Library. (It is understood 294 | that the user who changes the contents of definitions files in the 295 | Library will not necessarily be able to recompile the application 296 | to use the modified definitions.) 297 | 298 | b) Use a suitable shared library mechanism for linking with the 299 | Library. A suitable mechanism is one that (1) uses at run time a 300 | copy of the library already present on the user's computer system, 301 | rather than copying library functions into the executable, and (2) 302 | will operate properly with a modified version of the library, if 303 | the user installs one, as long as the modified version is 304 | interface-compatible with the version that the work was made with. 305 | 306 | c) Accompany the work with a written offer, valid for at 307 | least three years, to give the same user the materials 308 | specified in Subsection 6a, above, for a charge no more 309 | than the cost of performing this distribution. 310 | 311 | d) If distribution of the work is made by offering access to copy 312 | from a designated place, offer equivalent access to copy the above 313 | specified materials from the same place. 314 | 315 | e) Verify that the user has already received a copy of these 316 | materials or that you have already sent this user a copy. 317 | 318 | For an executable, the required form of the "work that uses the 319 | Library" must include any data and utility programs needed for 320 | reproducing the executable from it. However, as a special exception, 321 | the materials to be distributed need not include anything that is 322 | normally distributed (in either source or binary form) with the major 323 | components (compiler, kernel, and so on) of the operating system on 324 | which the executable runs, unless that component itself accompanies 325 | the executable. 326 | 327 | It may happen that this requirement contradicts the license 328 | restrictions of other proprietary libraries that do not normally 329 | accompany the operating system. Such a contradiction means you cannot 330 | use both them and the Library together in an executable that you 331 | distribute. 332 | 333 | 7. You may place library facilities that are a work based on the 334 | Library side-by-side in a single library together with other library 335 | facilities not covered by this License, and distribute such a combined 336 | library, provided that the separate distribution of the work based on 337 | the Library and of the other library facilities is otherwise 338 | permitted, and provided that you do these two things: 339 | 340 | a) Accompany the combined library with a copy of the same work 341 | based on the Library, uncombined with any other library 342 | facilities. This must be distributed under the terms of the 343 | Sections above. 344 | 345 | b) Give prominent notice with the combined library of the fact 346 | that part of it is a work based on the Library, and explaining 347 | where to find the accompanying uncombined form of the same work. 348 | 349 | 8. You may not copy, modify, sublicense, link with, or distribute 350 | the Library except as expressly provided under this License. Any 351 | attempt otherwise to copy, modify, sublicense, link with, or 352 | distribute the Library is void, and will automatically terminate your 353 | rights under this License. However, parties who have received copies, 354 | or rights, from you under this License will not have their licenses 355 | terminated so long as such parties remain in full compliance. 356 | 357 | 9. You are not required to accept this License, since you have not 358 | signed it. However, nothing else grants you permission to modify or 359 | distribute the Library or its derivative works. These actions are 360 | prohibited by law if you do not accept this License. Therefore, by 361 | modifying or distributing the Library (or any work based on the 362 | Library), you indicate your acceptance of this License to do so, and 363 | all its terms and conditions for copying, distributing or modifying 364 | the Library or works based on it. 365 | 366 | 10. Each time you redistribute the Library (or any work based on the 367 | Library), the recipient automatically receives a license from the 368 | original licensor to copy, distribute, link with or modify the Library 369 | subject to these terms and conditions. You may not impose any further 370 | restrictions on the recipients' exercise of the rights granted herein. 371 | You are not responsible for enforcing compliance by third parties with 372 | this License. 373 | 374 | 11. If, as a consequence of a court judgment or allegation of patent 375 | infringement or for any other reason (not limited to patent issues), 376 | conditions are imposed on you (whether by court order, agreement or 377 | otherwise) that contradict the conditions of this License, they do not 378 | excuse you from the conditions of this License. If you cannot 379 | distribute so as to satisfy simultaneously your obligations under this 380 | License and any other pertinent obligations, then as a consequence you 381 | may not distribute the Library at all. For example, if a patent 382 | license would not permit royalty-free redistribution of the Library by 383 | all those who receive copies directly or indirectly through you, then 384 | the only way you could satisfy both it and this License would be to 385 | refrain entirely from distribution of the Library. 386 | 387 | If any portion of this section is held invalid or unenforceable under any 388 | particular circumstance, the balance of the section is intended to apply, 389 | and the section as a whole is intended to apply in other circumstances. 390 | 391 | It is not the purpose of this section to induce you to infringe any 392 | patents or other property right claims or to contest validity of any 393 | such claims; this section has the sole purpose of protecting the 394 | integrity of the free software distribution system which is 395 | implemented by public license practices. Many people have made 396 | generous contributions to the wide range of software distributed 397 | through that system in reliance on consistent application of that 398 | system; it is up to the author/donor to decide if he or she is willing 399 | to distribute software through any other system and a licensee cannot 400 | impose that choice. 401 | 402 | This section is intended to make thoroughly clear what is believed to 403 | be a consequence of the rest of this License. 404 | 405 | 12. If the distribution and/or use of the Library is restricted in 406 | certain countries either by patents or by copyrighted interfaces, the 407 | original copyright holder who places the Library under this License may add 408 | an explicit geographical distribution limitation excluding those countries, 409 | so that distribution is permitted only in or among countries not thus 410 | excluded. In such case, this License incorporates the limitation as if 411 | written in the body of this License. 412 | 413 | 13. The Free Software Foundation may publish revised and/or new 414 | versions of the Lesser General Public License from time to time. 415 | Such new versions will be similar in spirit to the present version, 416 | but may differ in detail to address new problems or concerns. 417 | 418 | Each version is given a distinguishing version number. If the Library 419 | specifies a version number of this License which applies to it and 420 | "any later version", you have the option of following the terms and 421 | conditions either of that version or of any later version published by 422 | the Free Software Foundation. If the Library does not specify a 423 | license version number, you may choose any version ever published by 424 | the Free Software Foundation. 425 | 426 | 14. If you wish to incorporate parts of the Library into other free 427 | programs whose distribution conditions are incompatible with these, 428 | write to the author to ask for permission. For software which is 429 | copyrighted by the Free Software Foundation, write to the Free 430 | Software Foundation; we sometimes make exceptions for this. Our 431 | decision will be guided by the two goals of preserving the free status 432 | of all derivatives of our free software and of promoting the sharing 433 | and reuse of software generally. 434 | 435 | NO WARRANTY 436 | 437 | 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO 438 | WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 439 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR 440 | OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY 441 | KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE 442 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 443 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 444 | LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME 445 | THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 446 | 447 | 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN 448 | WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY 449 | AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU 450 | FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR 451 | CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 452 | LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING 453 | RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 454 | FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF 455 | SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 456 | DAMAGES. 457 | 458 | END OF TERMS AND CONDITIONS 459 | 460 | How to Apply These Terms to Your New Libraries 461 | 462 | If you develop a new library, and you want it to be of the greatest 463 | possible use to the public, we recommend making it free software that 464 | everyone can redistribute and change. You can do so by permitting 465 | redistribution under these terms (or, alternatively, under the terms of the 466 | ordinary General Public License). 467 | 468 | To apply these terms, attach the following notices to the library. It is 469 | safest to attach them to the start of each source file to most effectively 470 | convey the exclusion of warranty; and each file should have at least the 471 | "copyright" line and a pointer to where the full notice is found. 472 | 473 | {description} 474 | Copyright (C) {year} {fullname} 475 | 476 | This library is free software; you can redistribute it and/or 477 | modify it under the terms of the GNU Lesser General Public 478 | License as published by the Free Software Foundation; either 479 | version 2.1 of the License, or (at your option) any later version. 480 | 481 | This library is distributed in the hope that it will be useful, 482 | but WITHOUT ANY WARRANTY; without even the implied warranty of 483 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 484 | Lesser General Public License for more details. 485 | 486 | You should have received a copy of the GNU Lesser General Public 487 | License along with this library; if not, write to the Free Software 488 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 489 | USA 490 | 491 | Also add information on how to contact you by electronic and paper mail. 492 | 493 | You should also get your employer (if you work as a programmer) or your 494 | school, if any, to sign a "copyright disclaimer" for the library, if 495 | necessary. Here is a sample; alter the names: 496 | 497 | Yoyodyne, Inc., hereby disclaims all copyright interest in the 498 | library `Frob' (a library for tweaking knobs) written by James Random 499 | Hacker. 500 | 501 | {signature of Ty Coon}, 1 April 1990 502 | Ty Coon, President of Vice 503 | 504 | That's all there is to it! 505 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAR = tar 2 | GIT = git 3 | PYTHON = python3 4 | 5 | # Note that there is no default PYTHON interpreter. It must be specified. 6 | 7 | PACKAGE = qav 8 | VERSION = $(shell git describe --abbrev=0 --tags) 9 | RELEASE = 1 10 | OS_MAJOR_VERSION = $(shell lsb_release -rs | cut -f1 -d.) 11 | OS := rhel$(OS_MAJOR_VERSION) 12 | DIST_DIR := dist/$(OS) 13 | BUILDROOT := /srv/build/$(OS) 14 | 15 | RPM_FILE := $(PYTHON)-$(PACKAGE)-$(VERSION)-$(RELEASE).noarch.rpm 16 | 17 | YUMREPO_LOCATION=/srv/UMyumrepos/$(OS)/stable 18 | 19 | .PHONY: rpm 20 | rpm: 21 | $(eval TEMPDIR := $(shell mktemp -d /tmp/tmp.XXXXX)) 22 | mkdir -p $(TEMPDIR)/$(PACKAGE)-$(VERSION) 23 | $(GIT) clone . $(TEMPDIR)/$(PACKAGE)-$(VERSION) 24 | $(GIT) \ 25 | --git-dir=$(TEMPDIR)/$(PACKAGE)-$(VERSION)/.git \ 26 | --work-tree=$(TEMPDIR)/$(PACKAGE)-$(VERSION) \ 27 | checkout tags/$(VERSION) 28 | $(TAR) -C $(TEMPDIR) --exclude .git -czf $(BUILDROOT)/SOURCES/$(PACKAGE)-$(VERSION).tar.gz $(PACKAGE)-$(VERSION) 29 | rpmbuild -bb $(PACKAGE).spec --define "python ${PYTHON}" --define "version ${VERSION}" 30 | rm -rf $(TEMPDIR) 31 | mkdir -p $(DIST_DIR) 32 | cp $(BUILDROOT)/RPMS/noarch/$(RPM_FILE) $(DIST_DIR) 33 | 34 | .PHONY: copy_rpm 35 | copy_rpm: 36 | sudo cp $(DIST_DIR)/$(RPM_FILE) $(YUMREPO_LOCATION)/Packages/noarch 37 | 38 | .PHONY: createrepo 39 | createrepo: 40 | sudo createrepo --workers=4 $(YUMREPO_LOCATION) 41 | 42 | .PHONY: tag 43 | tag: 44 | sed -i 's/__version__ = .*/__version__ = "$(VERSION)"/g' $(PACKAGE)/__init__.py 45 | git add $(PACKAGE)/__init__.py 46 | git commit -m "Tagging $(VERSION)" 47 | git tag -a $(VERSION) -m "Tagging $(VERSION)" 48 | 49 | .PHONY: clean 50 | clean: 51 | rm -rf dist/ 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Question Answer Validation (qav) 2 | 3 | [![pypi version](https://img.shields.io/pypi/v/qav.svg)](https://pypi.python.org/pypi/qav) 4 | [![license](https://img.shields.io/pypi/l/qav.svg)](https://pypi.python.org/pypi/qav) 5 | [![pyversions](https://img.shields.io/pypi/pyversions/qav.svg)](https://pypi.python.org/pypi/qav) 6 | [![pipeline status](https://gitlab.umiacs.umd.edu/staff/qav/badges/master/pipeline.svg)](https://gitlab.umiacs.umd.edu/staff/qav/commits/master) 7 | [![coverage report](https://gitlab.umiacs.umd.edu/staff/qav/badges/master/coverage.svg)](https://gitlab.umiacs.umd.edu/staff/qav/commits/master) 8 | 9 | qav is a Python library for console-based question and answering, with the 10 | ability to validate input. 11 | 12 | It provides question sets to group related questions. Questions can also 13 | have subordinate Questions underneath them. Answers to those questions can be 14 | validated based on a simple, static piece of information provided by you. 15 | Answers may also be validated dynamically based on the information provided in 16 | previous questions. 17 | 18 | ## Example Usage 19 | ``` 20 | >>> from qav.questions import Question 21 | >>> from qav.validators import ListValidator 22 | >>> q = Question('How old am I? ', 'age', ListValidator(['20', '35', '40'])) 23 | >>> q.ask() 24 | Please select from the following choices: 25 | [0] - 20 26 | [1] - 35 27 | [2] - 40 28 | How old am I? : 0 29 | >>> q.answer() 30 | # returns => {'age': '20'} 31 | ``` 32 | 33 | ## Requirements 34 | [`netaddr`](https://pypi.org/project/netaddr/) 35 | 36 | ## Installation 37 | ``` 38 | $ pip install qav 39 | ``` 40 | 41 | ## Compatibility 42 | This library has been tested to support: 43 | * Python 3.6 44 | 45 | It most likely will still run on Python 2.7, but official support has been dropped. 46 | 47 | ## License 48 | 49 | qav - question answer validation in Python 50 | Copyright (C) 2015 UMIACS 51 | 52 | This library is free software; you can redistribute it and/or 53 | modify it under the terms of the GNU Lesser General Public 54 | License as published by the Free Software Foundation; either 55 | version 2.1 of the License, or (at your option) any later version. 56 | 57 | This library is distributed in the hope that it will be useful, 58 | but WITHOUT ANY WARRANTY; without even the implied warranty of 59 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 60 | Lesser General Public License for more details. 61 | 62 | You should have received a copy of the GNU Lesser General Public 63 | License along with this library; if not, write to the Free Software 64 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 65 | 66 | Email: 67 | github@umiacs.umd.edu 68 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/qav.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/qav.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/qav" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/qav" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # qav documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Jul 8 22:35:49 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import shlex 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('.') + '/../qav') 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.todo', 35 | 'sphinx.ext.viewcode', 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = u'qav' 54 | copyright = u'2015, Derek Yarnell' 55 | author = u'Derek Yarnell' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | import qav 63 | version = qav.__version__ 64 | # The full version, including alpha/beta/rc tags. 65 | release = version 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # There are two options for replacing |today|: either, you set today to some 75 | # non-false value, then it is used: 76 | #today = '' 77 | # Else, today_fmt is used as the format for a strftime call. 78 | #today_fmt = '%B %d, %Y' 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | exclude_patterns = ['_build'] 83 | 84 | # The reST default role (used for this markup: `text`) to use for all 85 | # documents. 86 | #default_role = None 87 | 88 | # If true, '()' will be appended to :func: etc. cross-reference text. 89 | #add_function_parentheses = True 90 | 91 | # If true, the current module name will be prepended to all description 92 | # unit titles (such as .. function::). 93 | #add_module_names = True 94 | 95 | # If true, sectionauthor and moduleauthor directives will be shown in the 96 | # output. They are ignored by default. 97 | #show_authors = False 98 | 99 | # The name of the Pygments (syntax highlighting) style to use. 100 | pygments_style = 'sphinx' 101 | 102 | # A list of ignored prefixes for module index sorting. 103 | #modindex_common_prefix = [] 104 | 105 | # If true, keep warnings as "system message" paragraphs in the built documents. 106 | #keep_warnings = False 107 | 108 | # If true, `todo` and `todoList` produce output, else they produce nothing. 109 | todo_include_todos = True 110 | 111 | 112 | # -- Options for HTML output ---------------------------------------------- 113 | 114 | # The theme to use for HTML and HTML Help pages. See the documentation for 115 | # a list of builtin themes. 116 | html_theme = 'alabaster' 117 | 118 | # Theme options are theme-specific and customize the look and feel of a theme 119 | # further. For a list of options available for each theme, see the 120 | # documentation. 121 | #html_theme_options = {} 122 | 123 | # Add any paths that contain custom themes here, relative to this directory. 124 | #html_theme_path = [] 125 | 126 | # The name for this set of Sphinx documents. If None, it defaults to 127 | # " v documentation". 128 | #html_title = None 129 | 130 | # A shorter title for the navigation bar. Default is the same as html_title. 131 | #html_short_title = None 132 | 133 | # The name of an image file (relative to this directory) to place at the top 134 | # of the sidebar. 135 | #html_logo = None 136 | 137 | # The name of an image file (within the static path) to use as favicon of the 138 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 139 | # pixels large. 140 | #html_favicon = None 141 | 142 | # Add any paths that contain custom static files (such as style sheets) here, 143 | # relative to this directory. They are copied after the builtin static files, 144 | # so a file named "default.css" will overwrite the builtin "default.css". 145 | html_static_path = ['_static'] 146 | 147 | # Add any extra paths that contain custom files (such as robots.txt or 148 | # .htaccess) here, relative to this directory. These files are copied 149 | # directly to the root of the documentation. 150 | #html_extra_path = [] 151 | 152 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 153 | # using the given strftime format. 154 | #html_last_updated_fmt = '%b %d, %Y' 155 | 156 | # If true, SmartyPants will be used to convert quotes and dashes to 157 | # typographically correct entities. 158 | #html_use_smartypants = True 159 | 160 | # Custom sidebar templates, maps document names to template names. 161 | #html_sidebars = {} 162 | 163 | # Additional templates that should be rendered to pages, maps page names to 164 | # template names. 165 | #html_additional_pages = {} 166 | 167 | # If false, no module index is generated. 168 | #html_domain_indices = True 169 | 170 | # If false, no index is generated. 171 | #html_use_index = True 172 | 173 | # If true, the index is split into individual pages for each letter. 174 | #html_split_index = False 175 | 176 | # If true, links to the reST sources are added to the pages. 177 | #html_show_sourcelink = True 178 | 179 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 180 | #html_show_sphinx = True 181 | 182 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 183 | #html_show_copyright = True 184 | 185 | # If true, an OpenSearch description file will be output, and all pages will 186 | # contain a tag referring to it. The value of this option must be the 187 | # base URL from which the finished HTML is served. 188 | #html_use_opensearch = '' 189 | 190 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 191 | #html_file_suffix = None 192 | 193 | # Language to be used for generating the HTML full-text search index. 194 | # Sphinx supports the following languages: 195 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 196 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 197 | #html_search_language = 'en' 198 | 199 | # A dictionary with options for the search language support, empty by default. 200 | # Now only 'ja' uses this config value 201 | #html_search_options = {'type': 'default'} 202 | 203 | # The name of a javascript file (relative to the configuration directory) that 204 | # implements a search results scorer. If empty, the default will be used. 205 | #html_search_scorer = 'scorer.js' 206 | 207 | # Output file base name for HTML help builder. 208 | htmlhelp_basename = 'qavdoc' 209 | 210 | # -- Options for LaTeX output --------------------------------------------- 211 | 212 | latex_elements = { 213 | # The paper size ('letterpaper' or 'a4paper'). 214 | #'papersize': 'letterpaper', 215 | 216 | # The font size ('10pt', '11pt' or '12pt'). 217 | #'pointsize': '10pt', 218 | 219 | # Additional stuff for the LaTeX preamble. 220 | #'preamble': '', 221 | 222 | # Latex figure (float) alignment 223 | #'figure_align': 'htbp', 224 | } 225 | 226 | # Grouping the document tree into LaTeX files. List of tuples 227 | # (source start file, target name, title, 228 | # author, documentclass [howto, manual, or own class]). 229 | latex_documents = [ 230 | (master_doc, 'qav.tex', u'qav Documentation', 231 | u'Derek Yarnell', 'manual'), 232 | ] 233 | 234 | # The name of an image file (relative to this directory) to place at the top of 235 | # the title page. 236 | #latex_logo = None 237 | 238 | # For "manual" documents, if this is true, then toplevel headings are parts, 239 | # not chapters. 240 | #latex_use_parts = False 241 | 242 | # If true, show page references after internal links. 243 | #latex_show_pagerefs = False 244 | 245 | # If true, show URL addresses after external links. 246 | #latex_show_urls = False 247 | 248 | # Documents to append as an appendix to all manuals. 249 | #latex_appendices = [] 250 | 251 | # If false, no module index is generated. 252 | #latex_domain_indices = True 253 | 254 | 255 | # -- Options for manual page output --------------------------------------- 256 | 257 | # One entry per manual page. List of tuples 258 | # (source start file, name, description, authors, manual section). 259 | man_pages = [ 260 | (master_doc, 'qav', u'qav Documentation', 261 | [author], 1) 262 | ] 263 | 264 | # If true, show URL addresses after external links. 265 | #man_show_urls = False 266 | 267 | 268 | # -- Options for Texinfo output ------------------------------------------- 269 | 270 | # Grouping the document tree into Texinfo files. List of tuples 271 | # (source start file, target name, title, author, 272 | # dir menu entry, description, category) 273 | texinfo_documents = [ 274 | (master_doc, 'qav', u'qav Documentation', 275 | author, 'qav', 'One line description of project.', 276 | 'Miscellaneous'), 277 | ] 278 | 279 | # Documents to append as an appendix to all manuals. 280 | #texinfo_appendices = [] 281 | 282 | # If false, no module index is generated. 283 | #texinfo_domain_indices = True 284 | 285 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 286 | #texinfo_show_urls = 'footnote' 287 | 288 | # If true, do not generate a @detailmenu in the "Top" node's menu. 289 | #texinfo_no_detailmenu = False 290 | 291 | 292 | # Example configuration for intersphinx: refer to the Python standard library. 293 | intersphinx_mapping = {'https://docs.python.org/': None} 294 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. qav documentation master file, created by 2 | sphinx-quickstart on Wed Jul 8 22:35:49 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to qav's documentation! 7 | =============================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | 23 | -------------------------------------------------------------------------------- /doc/modules.rst: -------------------------------------------------------------------------------- 1 | qav 2 | === 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | qav 8 | -------------------------------------------------------------------------------- /doc/qav.rst: -------------------------------------------------------------------------------- 1 | qav package 2 | ================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | qav.filters module 8 | ------------------- 9 | 10 | .. automodule:: qav.filters 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | qav.listpack module 16 | ------------------- 17 | 18 | .. automodule:: qav.listpack 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | qav.questions module 24 | ------------------- 25 | 26 | .. automodule:: qav.questions 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | qav.utils module 32 | ------------------- 33 | 34 | .. automodule:: qav.utils 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | qav.validators module 40 | ------------------- 41 | 42 | .. automodule:: qav.validators 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | -------------------------------------------------------------------------------- /qav.spec: -------------------------------------------------------------------------------- 1 | %define name qav 2 | %define unmangled_name qav 3 | %define release 1 4 | 5 | Summary: Question Answer Validation 6 | Name: %{python}-%{name} 7 | Version: %{version} 8 | Release: %{release} 9 | Source0: %{unmangled_name}-%{version}.tar.gz 10 | License: LGPL v2.1 11 | Group: Development/Libraries 12 | BuildRoot: %{_tmppath}/%{unmangled_name}-%{version}-%{release}-buildroot 13 | Requires: %{python} 14 | Requires: %{python}-netaddr 15 | Prefix: %{_prefix} 16 | BuildArch: noarch 17 | Vendor: UMIACS Staff 18 | Url: https://github.com/UMIACS/qav 19 | 20 | %description 21 | qav is a Python library for console-based question and answering, with the 22 | ability to validate input. 23 | 24 | %prep 25 | %setup -n %{unmangled_name}-%{version} 26 | 27 | %build 28 | %{python} setup.py build 29 | 30 | %install 31 | %{python} setup.py install --single-version-externally-managed -O1 --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES 32 | 33 | %clean 34 | rm -rf $RPM_BUILD_ROOT 35 | 36 | %files -f INSTALLED_FILES 37 | %defattr(-,root,root) 38 | -------------------------------------------------------------------------------- /qav/__init__.py: -------------------------------------------------------------------------------- 1 | # qav (Question Answer Validation) 2 | # Copyright (C) 2015 UMIACS 3 | 4 | 5 | __version__ = "1.1.1" 6 | -------------------------------------------------------------------------------- /qav/filters.py: -------------------------------------------------------------------------------- 1 | # qav (Question Answer Validation) 2 | # Copyright (C) 2015 UMIACS 3 | 4 | from typing import Callable 5 | 6 | 7 | class Filter(object): 8 | 9 | def __init__(self, string: str) -> None: 10 | self.string = string 11 | 12 | 13 | class DynamicFilter(Filter): 14 | 15 | ''' 16 | A Filter that can dynamically prune choices based off of whether 17 | filterable_func(choice[, table]) returns True or False. 18 | ''' 19 | 20 | def __init__(self, filterable_func: Callable) -> None: 21 | self.filterable_func = filterable_func 22 | 23 | def filter(self, value: str, table=None) -> bool: 24 | ''' 25 | Return True if the value should be pruned; False otherwise. 26 | 27 | If a `table` argument was provided, pass it to filterable_func. 28 | ''' 29 | if table is not None: 30 | filterable = self.filterable_func(value, table) 31 | else: 32 | filterable = self.filterable_func(value) 33 | return filterable 34 | 35 | 36 | class SubFilter(Filter): 37 | 38 | ''' 39 | SubFilter keeps those choices containing a given substring. 40 | ''' 41 | 42 | def filter(self, value: str, table=None) -> bool: 43 | if table is not None and self.string in table: 44 | s = table[self.string] 45 | else: 46 | s = self.string 47 | if value.count(s) > 0: 48 | return False 49 | else: 50 | return True 51 | 52 | 53 | class PreFilter(Filter): 54 | 55 | ''' 56 | PreFilter keeps those choices starting with a given substring. 57 | ''' 58 | 59 | def filter(self, value: str, table=None) -> bool: 60 | if table is not None and self.string in table: 61 | s = table[self.string] 62 | else: 63 | s = self.string 64 | if value.startswith(s): 65 | return False 66 | else: 67 | return True 68 | 69 | 70 | class PostFilter(Filter): 71 | 72 | ''' 73 | PostFilter keeps those choices ending with a given substring. 74 | ''' 75 | 76 | def filter(self, value: str, table=None) -> bool: 77 | if table is not None and self.string in table: 78 | s = table[self.string] 79 | else: 80 | s = self.string 81 | if value.endswith(s): 82 | return False 83 | else: 84 | return True 85 | -------------------------------------------------------------------------------- /qav/listpack.py: -------------------------------------------------------------------------------- 1 | # qav (Question Answer Validation) 2 | # Copyright (C) 2015 UMIACS 3 | 4 | from typing import List, Tuple 5 | 6 | 7 | class ListPack(object): 8 | BOLD = '\033[1m' 9 | OFF = '\033[0m' 10 | 11 | def __init__(self, lp=None, sep: str = ": ", padding: str = " ", 12 | indentation: int = 0, width: int = 79) -> None: 13 | self.sep = sep 14 | self.padding = padding 15 | self.indentation = indentation 16 | self.width = width 17 | if lp: 18 | self._lp = lp 19 | else: 20 | self._lp = [] 21 | 22 | self.new_line = '' + (' ' * self.indentation) 23 | 24 | def calc(self, t: List) -> int: 25 | s1, s2 = t 26 | return len(str(s1)) + len(self.sep) + len(str(s2)) + len(self.padding) 27 | 28 | def bold(self, t: List) -> str: 29 | s1, s2 = t 30 | return '%s%s%s%s%s%s' % (self.BOLD, str(s1), self.OFF, self.sep, 31 | str(s2), self.padding) 32 | 33 | def append_item(self, item: Tuple['str', 'str']) -> None: 34 | self._lp.append(item) 35 | 36 | def prepend_item(self, item: Tuple['str', 'str']) -> None: 37 | self._lp.insert(0, item) 38 | 39 | def __str__(self) -> str: 40 | _str = '' 41 | line = self.new_line 42 | line_length = len(line) 43 | for i in self._lp: 44 | if line_length + self.calc(i) > self.width: 45 | if _str != '': 46 | _str = _str + '\n' + line 47 | else: 48 | _str = line 49 | line = self.new_line + self.bold(i) 50 | line_length = len(self.new_line) + self.calc(i) 51 | else: 52 | line += self.bold(i) 53 | line_length += self.calc(i) 54 | _str = _str + '\n' + line 55 | return _str 56 | -------------------------------------------------------------------------------- /qav/questions.py: -------------------------------------------------------------------------------- 1 | # qav (Question Answer Validation) 2 | # Copyright (C) 2015 UMIACS 3 | 4 | import logging 5 | 6 | from typing import Dict, List, Union 7 | 8 | from qav.validators import Validator, CompactListValidator 9 | from qav.listpack import ListPack 10 | from qav.utils import bold 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class QuestionSet(object): 16 | answers: Dict 17 | questions: List 18 | 19 | def __init__(self) -> None: 20 | self.answers = {} 21 | self.questions = [] 22 | 23 | def add(self, question: str) -> 'QuestionSet': 24 | self.questions.append(question) 25 | return self 26 | 27 | def remove(self, question: str) -> 'QuestionSet': 28 | self.questions.remove(question) 29 | return self 30 | 31 | def ask(self) -> Dict: 32 | for question in self.questions: 33 | self.answers = dict(self.answers, **question.ask(self.answers)) 34 | return self.answers 35 | 36 | def ask_and_confirm(self, additional_readonly_items: List = None, 37 | prepend_listpacking_items: bool = True) -> Union[Dict, None]: 38 | confirm_question = Question('Are these answers correct? ' + 39 | '[yes/abort/retry]', value='confirm', 40 | validator=CompactListValidator( 41 | choices=['yes', 'abort', 'retry'])) 42 | 43 | while True: 44 | answers = self.ask() 45 | lp = ListPack( 46 | [(q.printable_name, answers[q.value]) for q in self.questions]) 47 | 48 | # add in items that were not asked as questions but should be 49 | # displayed alongside that information 50 | if additional_readonly_items: 51 | if prepend_listpacking_items: 52 | for item in additional_readonly_items: 53 | lp.prepend_item(item) 54 | else: 55 | for item in additional_readonly_items: 56 | lp.append_item(item) 57 | 58 | print(lp) 59 | confirm_answer = confirm_question.ask() 60 | if confirm_answer['confirm'] == 'yes': 61 | return answers 62 | if confirm_answer['confirm'] != 'retry': 63 | return None 64 | 65 | 66 | class Question(object): 67 | _answers: Dict 68 | _questions: List 69 | 70 | def __init__(self, question: str, value: str, validator: 'Validator' = None, 71 | multiple=False, printable_name=None) -> None: 72 | """ Basic Question class. 73 | 74 | Supports simple question and answer or question and multiple 75 | answers (note: list/hash validators have caveats). Also 76 | support for one or more Validator classes to ensure the answer 77 | given meets the question criteria. 78 | """ 79 | self.question = question 80 | self.value = value 81 | self.multiple = multiple 82 | if printable_name: 83 | self.printable_name = printable_name 84 | else: 85 | self.printable_name = value 86 | if validator is None: 87 | self.validator = Validator() 88 | else: 89 | self.validator = validator 90 | self._questions = [] 91 | 92 | def __eq__(self, other: 'Question') -> bool: # type: ignore 93 | if self.question == other.question and self.value == other.value: 94 | return True 95 | else: 96 | return False 97 | 98 | def __repr__(self) -> str: 99 | return self.value 100 | 101 | def _get_input(self, text) -> str: 102 | return input(text) 103 | 104 | def _ask(self, answers: Dict) -> Union[Dict, None]: 105 | """ Really ask the question. 106 | 107 | We may need to populate multiple validators with answers here. 108 | Then ask the question and insert the default value if 109 | appropriate. Finally call the validate function to check all 110 | validators for this question and returning the answer. 111 | """ 112 | if isinstance(self.validator, list): 113 | for v in self.validator: 114 | v.answers = answers 115 | else: 116 | self.validator.answers = answers 117 | while(True): 118 | q = self.question % answers 119 | 120 | if not self.choices(): 121 | logger.warning('No choices were supplied for "%s"' % q) 122 | return None 123 | if self.value in answers: 124 | default = Validator.stringify(answers[self.value]) 125 | answer = self._get_input("%s [%s]: " % (q, default)) 126 | if answer == '': 127 | answer = answers[self.value] 128 | else: 129 | answer = self._get_input("%s: " % q) 130 | # if we are in multiple mode and the answer is just the empty 131 | # string (enter/return pressed) then we will just answer None 132 | # to indicate we are done 133 | if answer == '.' and self.multiple: 134 | return None 135 | if self.validate(answer): 136 | return self.answer() 137 | else: 138 | if isinstance(self.validator, list): 139 | for v in self.validator: 140 | if v.error() != '': 141 | print(v.error()) 142 | else: 143 | print(self.validator.error()) 144 | 145 | def ask(self, answers: Dict = None) -> Dict: 146 | """ Ask the question, then ask any sub-questions. 147 | 148 | This returns a dict with the {value: answer} pairs for the current 149 | question plus all descendant questions. 150 | """ 151 | if answers is None: 152 | answers = {} 153 | _answers: Dict = {} 154 | if self.multiple: 155 | print((bold('Multiple answers are supported for this question. ' + 156 | 'Please enter a "." character to finish.'))) 157 | _answers[self.value] = [] 158 | answer = self._ask(answers) 159 | while answer is not None: 160 | _answers[self.value].append(answer) 161 | answer = self._ask(answers) 162 | else: 163 | _answers[self.value] = self._ask(answers) 164 | if isinstance(self.validator, list): 165 | for v in self.validator: 166 | _answers = dict(_answers, **v.hints()) 167 | else: 168 | _answers = dict(_answers, **self.validator.hints()) 169 | for q in self._questions: 170 | answers = dict(answers, **_answers) 171 | _answers = dict(_answers, **q.ask(answers)) 172 | return _answers 173 | 174 | def validate(self, answer: str) -> bool: 175 | """ Validate the answer with our Validator(s) 176 | 177 | This will support one or more validator classes being applied to 178 | this question. If there are multiple, all validators must return 179 | True for the answer to be valid. 180 | """ 181 | if answer is None: 182 | return False 183 | else: 184 | if isinstance(self.validator, list): 185 | for v in self.validator: 186 | if not v.validate(answer): 187 | return False 188 | return True 189 | else: 190 | return self.validator.validate(answer) 191 | 192 | def answer(self) -> Dict: 193 | """ Return the answer for the question from the validator. 194 | 195 | This will ultimately only be called on the first validator if 196 | multiple validators have been added. 197 | """ 198 | if isinstance(self.validator, list): 199 | return self.validator[0].choice() 200 | return self.validator.choice() 201 | 202 | def choices(self) -> bool: 203 | """ Print the choices for this question. 204 | 205 | This may be a empty string and in the case of a list of validators 206 | we will only show the first validator's choices. 207 | """ 208 | if isinstance(self.validator, list): 209 | return self.validator[0].print_choices() 210 | return self.validator.print_choices() 211 | 212 | def add(self, question: 'Question') -> None: 213 | if isinstance(question, Question): 214 | self._questions.append(question) 215 | else: 216 | # TODO this should raise a less generic exception 217 | raise Exception 218 | 219 | def remove(self, question: 'Question') -> None: 220 | if isinstance(question, Question): 221 | self._questions.remove(question) 222 | else: 223 | raise Exception 224 | -------------------------------------------------------------------------------- /qav/utils.py: -------------------------------------------------------------------------------- 1 | # qav (Question Answer Validation) 2 | # Copyright (C) 2015 UMIACS 3 | 4 | 5 | BOLD = '\033[1m' 6 | OFF = '\033[0m' 7 | 8 | 9 | def bold(s: str) -> str: 10 | return '%s%s%s' % (BOLD, str(s), OFF) 11 | 12 | 13 | def nonesorter(elem) -> str: 14 | """Allow NoneType to be sortable. Used as a key function.""" 15 | if not elem: 16 | return "" 17 | return elem 18 | -------------------------------------------------------------------------------- /qav/validators.py: -------------------------------------------------------------------------------- 1 | # qav (Question Answer Validation) 2 | # Copyright (C) 2015 UMIACS 3 | 4 | from __future__ import absolute_import 5 | from __future__ import print_function 6 | 7 | from typing import Dict, List, Optional, Any 8 | 9 | import re 10 | import socket 11 | import datetime 12 | import time 13 | from copy import copy 14 | 15 | from collections import OrderedDict 16 | 17 | # There aren't type annotations for netaddr yet 18 | from netaddr import IPAddress # type: ignore 19 | from netaddr.core import AddrFormatError # type: ignore 20 | 21 | from .utils import nonesorter 22 | 23 | 24 | class Validator(object): 25 | 26 | ''' 27 | Validator asserts if an answer given is acceptable. It does this through 28 | the return value of validate(). 29 | 30 | Validators can also transform an acceptable value by setting `_choice`. 31 | Think of the example of a date being passed in as a string, being 32 | validated, and then transformed into a datetime object... 33 | 34 | If validation failed, an error message can be set. 35 | ''' 36 | 37 | def __init__(self, blank: bool = False, negate: bool = False) -> None: 38 | self.blank = blank 39 | self.negate = negate # TODO this doesn't get used internally.......... 40 | self._choice: Any = None 41 | self._hints: Dict = {} 42 | self.answers: Dict = {} 43 | self.error_message: Optional[str] = None 44 | 45 | def validate(self, value: Any) -> bool: 46 | '''The most basic validation''' 47 | if not self.blank and value == '': 48 | self.error_message = 'Can not be empty. Please provide a value.' 49 | return False 50 | self._choice = value 51 | return True 52 | 53 | def choice(self) -> Any: 54 | return self._choice 55 | 56 | def print_choices(self) -> bool: 57 | return True 58 | 59 | def hints(self) -> Dict: 60 | return self._hints 61 | 62 | def error(self) -> str: 63 | if self.error_message is not None: 64 | return 'ERROR: %s' % self.error_message 65 | else: 66 | return '' 67 | 68 | @staticmethod 69 | def stringify(value: Any) -> str: 70 | return str(value) 71 | 72 | 73 | class YesNoValidator(Validator): 74 | 75 | def validate(self, value: str) -> bool: 76 | if value.lower() in ['yes', 'no']: 77 | self._choice = value.lower() 78 | return True 79 | else: 80 | self.error_message = 'Please choose yes or no.' 81 | return False 82 | 83 | 84 | class CompactListValidator(Validator): 85 | 86 | ''' 87 | Accepts a list of choices like ListValidator but doesn't print 88 | validator choices. 89 | ''' 90 | 91 | def __init__(self, choices) -> None: 92 | self._choices = choices 93 | super(CompactListValidator, self).__init__() 94 | 95 | def validate(self, value: str) -> bool: 96 | if value.lower() in self._choices: 97 | # TODO should this really call lower()? 98 | self._choice = value.lower() 99 | return True 100 | else: 101 | self.error_message = 'Please choose %s.' % '/'.join(self._choices) 102 | return False 103 | 104 | 105 | class DateValidator(Validator): 106 | 107 | '''Accepts dates in the format YYYYMMDD''' 108 | 109 | date_regex = re.compile(r'\d{8}') 110 | 111 | def validate(self, value: str) -> bool: 112 | if self.blank and value == '': 113 | return True 114 | if DateValidator.date_regex.match(value): 115 | # TODO this should account for the GMT offset 116 | try: 117 | date = time.strptime(value, "%Y%m%d") 118 | except ValueError: 119 | return False 120 | self._choice = datetime.datetime(*date[:6]) 121 | return True 122 | else: 123 | return False 124 | 125 | 126 | class DomainNameValidator(Validator): 127 | 128 | def validate(self, value: str) -> bool: 129 | """Attempts a forward lookup via the socket library and if 130 | successful will try to do a reverse lookup to verify DNS 131 | is returning both lookups. 132 | """ 133 | if '.' not in value: 134 | self.error_message = '%s is not a fully qualified domain name.' % \ 135 | value 136 | return False 137 | try: 138 | ipaddress = socket.gethostbyname(value) 139 | except socket.gaierror: 140 | self.error_message = '%s does not resolve.' % value 141 | return False 142 | try: 143 | socket.gethostbyaddr(ipaddress) 144 | except socket.herror: 145 | self.error_message = \ 146 | '%s reverse address (%s) does not resolve.' % \ 147 | (value, ipaddress) 148 | return False 149 | self._choice = value 150 | return True 151 | 152 | 153 | class MacAddressValidator(Validator): 154 | 155 | macaddr_regex = re.compile(r'^([0-9a-f]{2}[:]){5}([0-9a-f]{2})$') 156 | 157 | def validate(self, value: str) -> bool: 158 | if MacAddressValidator.macaddr_regex.match(value.lower()): 159 | self._choice = value 160 | return True 161 | else: 162 | self.error_message = '%s is not a valid MAC address.' % value 163 | return False 164 | 165 | 166 | class IPAddressValidator(Validator): 167 | 168 | def validate(self, value: str) -> bool: 169 | """Return a boolean if the value is valid""" 170 | try: 171 | self._choice = IPAddress(value) 172 | return True 173 | except (ValueError, AddrFormatError): 174 | self.error_message = '%s is not a valid IP address.' % value 175 | return False 176 | 177 | 178 | class IPNetmaskValidator(Validator): 179 | 180 | def validate(self, value: str) -> bool: 181 | """Return a boolean if the value is a valid netmask.""" 182 | try: 183 | self._choice = IPAddress(value) 184 | except (ValueError, AddrFormatError): 185 | self.error_message = '%s is not a valid IP address.' % value 186 | return False 187 | if self._choice.is_netmask(): 188 | return True 189 | else: 190 | self.error_message = '%s is not a valid IP netmask.' % value 191 | return False 192 | 193 | 194 | class URIValidator(Validator): 195 | 196 | # taken from Django URL validator 197 | uri_regex = re.compile( 198 | r'^\w+:(?://)?' # uri scheme 199 | # domain... 200 | r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # NOQA 201 | r'localhost|' # localhost... 202 | r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip 203 | r'(?::\d+)?' # optional port 204 | r'(?:/?|[/?]\S+)$', re.IGNORECASE) 205 | 206 | def validate(self, value: str) -> bool: 207 | '''Return a boolean indicating if the value is a valid URI''' 208 | if self.blank and value == '': 209 | return True 210 | if URIValidator.uri_regex.match(value): 211 | self._choice = value 212 | return True 213 | else: 214 | self.error_message = '%s is not a valid URI' % value 215 | return False 216 | 217 | 218 | class EmailValidator(Validator): 219 | 220 | email_regex = re.compile(r'[^@]+@[^@]+\.[^@]+') 221 | 222 | def validate(self, value: str) -> bool: 223 | if self.blank and value == '': 224 | return True 225 | if EmailValidator.email_regex.match(value) and len(value) > 3: 226 | self._choice = value 227 | return True 228 | else: 229 | self.error_message = '%s is not a valid email address.' % value 230 | return False 231 | 232 | 233 | class ListValidator(Validator): 234 | 235 | def __init__(self, choices: List, filters: List = None): 236 | self._choices = choices 237 | if filters is None: 238 | self.filters = [] 239 | else: 240 | self.filters = filters 241 | super(ListValidator, self).__init__() 242 | 243 | @property 244 | def choices(self) -> List: 245 | _choices = copy(self._choices) 246 | for c in self._choices: 247 | for f in self.filters: 248 | if f.filter(c, self.answers): 249 | _choices.remove(c) 250 | break 251 | _choices.sort(key=nonesorter) 252 | return _choices 253 | 254 | def print_choices(self) -> bool: 255 | if len(self.choices) > 0: 256 | print("Please select from the following choices:") 257 | for x, y in enumerate(self.choices): 258 | print(" [%d] - %s" % (x, str(y))) 259 | return True 260 | else: 261 | return False 262 | 263 | def validate(self, value: str) -> bool: 264 | """Return a boolean if the choice is a number in the enumeration""" 265 | if value in self.choices: 266 | self._choice = value 267 | return True 268 | try: 269 | self._choice = self.choices[int(value)] 270 | return True 271 | except (ValueError, IndexError): 272 | self.error_message = '%s is not a valid choice.' % value 273 | return False 274 | 275 | 276 | class TupleValidator(Validator): 277 | _choices: List 278 | filters: List 279 | 280 | def __init__(self, choices: Dict, filters: List = None): 281 | assert isinstance(choices, list) 282 | self._choices = choices 283 | if filters is None: 284 | self.filters = [] 285 | else: 286 | self.filters = filters 287 | super(TupleValidator, self).__init__() 288 | 289 | @property 290 | def choices(self) -> List: 291 | _choices = copy(self._choices) 292 | for c in self._choices: 293 | for f in self.filters: 294 | if f.filter(c, self.answers): 295 | _choices.remove(c) 296 | break 297 | _choices.sort() 298 | return _choices 299 | 300 | def print_choices(self) -> bool: 301 | if len(self.choices) > 0: 302 | print("Please select from the following choices:") 303 | for x, y in enumerate(self.choices): 304 | a, b = y 305 | print(" [%d] - %s (%s)" % (x, a, b)) 306 | return True 307 | else: 308 | return False 309 | 310 | def validate(self, value: str) -> bool: 311 | """Return a boolean if the choice a number in the enumeration""" 312 | for x, y in self.choices: 313 | if x == value: 314 | self._choice = value 315 | return True 316 | try: 317 | self._choice = self.choices[int(value)][0] 318 | return True 319 | except (ValueError, IndexError): 320 | self.error_message = '%s is not a valid choice.' % value 321 | return False 322 | 323 | 324 | class HashValidator(Validator): 325 | 326 | def __init__(self, choices: Dict, filters: List = None, 327 | verbose: bool = True) -> None: 328 | self._choices = OrderedDict() 329 | self.verbose = verbose 330 | for x in choices: 331 | self._choices[x] = choices[x] 332 | if filters is None: 333 | self.filters = [] 334 | else: 335 | self.filters = filters 336 | super(HashValidator, self).__init__() 337 | 338 | @property 339 | def choices(self) -> Dict: 340 | _choices = copy(self._choices) 341 | for c in self._choices: 342 | for f in self.filters: 343 | if f.filter(_choices[c], self.answers): 344 | del _choices[c] 345 | break 346 | return _choices 347 | 348 | def print_choices(self) -> bool: 349 | if len(self.choices) > 0: 350 | print("Please select from the following choices:") 351 | for x, y in enumerate(self.choices): 352 | if self.verbose: 353 | print(" [%d] - %s (%s)" % (x, y, self.choices[y])) 354 | else: 355 | print(" [%d] - %s" % (x, y)) 356 | return True 357 | else: 358 | return False 359 | 360 | def validate(self, value: str) -> bool: 361 | """Return a boolean if the choice is a number in the enumeration""" 362 | if value in list(self.choices.keys()): 363 | self._choice = value 364 | return True 365 | try: 366 | self._choice = list(self.choices.keys())[int(value)] 367 | return True 368 | except (ValueError, IndexError): 369 | self.error_message = '%s is not a valid choice.' % value 370 | return False 371 | 372 | 373 | class IntegerValidator(Validator): 374 | 375 | def validate(self, value: int) -> bool: 376 | """ 377 | Return True if the choice is an integer; False otherwise. 378 | 379 | If the value was cast successfully to an int, set the choice that will 380 | make its way into the answers dict to the cast int value, not the 381 | string representation. 382 | """ 383 | try: 384 | int_value = int(value) 385 | self._choice = int_value 386 | return True 387 | except ValueError: 388 | self.error_message = '%s is not a valid integer.' % value 389 | return False 390 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [metadata] 5 | description-file = README.md 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | try: 4 | from setuptools import setup 5 | extra = dict(include_package_data=True) 6 | except ImportError: 7 | from distutils.core import setup 8 | extra = {} 9 | 10 | from qav import __version__ 11 | 12 | long_description = ''' 13 | qav is a Python library for console-based question and answering, with the 14 | ability to validate input. 15 | ''' 16 | 17 | setup( 18 | name='qav', 19 | version=__version__, 20 | author='Derek Yarnell', 21 | author_email='derek@umiacs.umd.edu', 22 | packages=['qav'], 23 | install_requires=[ 24 | 'netaddr', 25 | ], 26 | url='https://github.com/UMIACS/qav', 27 | license='LGPL v2.1', 28 | description='Question Answer Validation', 29 | long_description=long_description, 30 | classifiers=[ 31 | "Development Status :: 5 - Production/Stable", 32 | "Environment :: Console", 33 | "Intended Audience :: Developers", 34 | "License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)", 35 | "Natural Language :: English", 36 | "Operating System :: OS Independent", 37 | "Topic :: Software Development :: Libraries", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3.6", 40 | ], 41 | **extra 42 | ) 43 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | 2 | import pytest 3 | 4 | 5 | @pytest.fixture 6 | def give_input(monkeypatch): 7 | '''Emit one value each time get_input is called.''' 8 | def give_input_wrapper(question, values): 9 | def mock_input(self): 10 | return values.pop(0) 11 | 12 | monkeypatch.setattr(question, '_get_input', mock_input) 13 | 14 | return give_input_wrapper 15 | -------------------------------------------------------------------------------- /tests/test_filters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from qav.filters import ( 4 | DynamicFilter, 5 | SubFilter, 6 | PreFilter, 7 | PostFilter, 8 | ) 9 | from qav.validators import ListValidator 10 | 11 | 12 | class TestFilters(object): 13 | 14 | def test_dynamic_filter(self): 15 | def filter_things_with_foo(value, choices): 16 | return 'foo' in value 17 | 18 | def filter_things_with_bar(value, choices): 19 | return 'bar' in value 20 | 21 | choices = ['foo', 'bar', 'baz'] 22 | 23 | assert set(ListValidator(choices).choices) == set(['foo', 'bar', 'baz']) 24 | 25 | prune_foo_validator = ListValidator( 26 | choices, 27 | filters=[DynamicFilter(filter_things_with_foo)]) 28 | assert prune_foo_validator.choices == ['bar', 'baz'] 29 | 30 | prune_foo_and_bar_validator = ListValidator( 31 | choices, 32 | filters=[ 33 | DynamicFilter(filter_things_with_foo), 34 | DynamicFilter(filter_things_with_bar), 35 | ]) 36 | assert prune_foo_and_bar_validator.choices == ['baz'] 37 | 38 | def test_sub_filter(self): 39 | # subfilter should keep choices containing a given string 40 | choices = ['foo', 'bar', 'baz'] 41 | validator = ListValidator( 42 | choices, 43 | filters=[SubFilter('a')]) 44 | assert validator.choices == ['bar', 'baz'] 45 | 46 | def test_pre_filter(self): 47 | choices = ['foo', 'bar', 'baz'] 48 | validator = ListValidator( 49 | choices, 50 | filters=[PreFilter('f')]) 51 | assert validator.choices == ['foo'] 52 | 53 | def test_post_filter(self): 54 | choices = ['foo', 'bar', 'baz'] 55 | validator = ListValidator( 56 | choices, 57 | filters=[PostFilter('r')]) 58 | assert validator.choices == ['bar'] 59 | -------------------------------------------------------------------------------- /tests/test_listpack.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from qav.listpack import ListPack 4 | 5 | 6 | class TestListPack(object): 7 | 8 | def test_defaults(self): 9 | deets = [('name', 'Cicero'), ('occupation', 'orator')] 10 | lp = ListPack(deets) 11 | assert str(lp) == '\n\x1b[1mname\x1b[0m: Cicero \x1b[1moccupation\x1b[0m: orator ' 12 | 13 | def test_non_defaults(self): 14 | deets = [('name', 'Cicero'), ('occupation', 'orator')] 15 | lp = ListPack(deets, sep='# ', padding=' ', indentation=2) 16 | assert str(lp) == '\n \x1b[1mname\x1b[0m# Cicero \x1b[1moccupation\x1b[0m# orator ' 17 | 18 | def test_calc(self): 19 | assert ListPack().calc(('name', 'Cicero')) == 14 20 | 21 | def test_append_item(self): 22 | lp = ListPack([('a', 'b')]) 23 | lp.append_item(('c', 'd')) 24 | assert str(lp) == '\n\x1b[1ma\x1b[0m: b \x1b[1mc\x1b[0m: d ' 25 | 26 | def test_prepend_item(self): 27 | lp = ListPack([('a', 'b')]) 28 | lp.prepend_item(('c', 'd')) 29 | assert str(lp) == '\n\x1b[1mc\x1b[0m: d \x1b[1ma\x1b[0m: b ' 30 | -------------------------------------------------------------------------------- /tests/test_questions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | 5 | from qav.questions import Question, QuestionSet 6 | from qav.validators import ( 7 | Validator, 8 | YesNoValidator, 9 | ) 10 | 11 | 12 | class TestQuestionSet(object): 13 | 14 | def test_init(self): 15 | qs = QuestionSet() 16 | assert qs.questions == [] 17 | assert qs.ask() == {} 18 | 19 | def test_add_remove(self): 20 | qs = QuestionSet() 21 | q1 = Question('foo', 'foo') 22 | q2 = Question('bar', 'bar') 23 | qs.add(q1).add(q2) 24 | assert len(qs.questions) == 2 25 | qs.remove(q1) 26 | assert qs.questions == [q2] 27 | qs.remove(q2) 28 | assert qs.questions == [] 29 | 30 | def test_ask(self, give_input): 31 | qs = QuestionSet() 32 | q1 = Question('foo', 'foo') 33 | q2 = Question('bar', 'bar') 34 | qs.add(q1).add(q2) 35 | give_input(q1, ['98']) 36 | give_input(q2, ['99']) 37 | assert qs.ask() == {'foo': '98', 'bar': '99'} 38 | 39 | 40 | class TestQuestion(object): 41 | 42 | def test_init(self): 43 | # want to ensure that the two positional args are correct 44 | question = Question('Your age?', 'age') 45 | assert question.question == 'Your age?' 46 | assert question.value == 'age' 47 | assert isinstance(question.validator, Validator) 48 | 49 | def test_equals(self): 50 | q1 = Question('Your age?', 'age') 51 | q2 = Question('Your age?', 'age') 52 | q3 = Question('Your height?', 'height') 53 | assert q1 == q2 54 | assert q1 != q3 55 | assert q2 != q3 56 | 57 | def test_ask(self, give_input): 58 | q = Question('Your age?', 'age') 59 | give_input(q, ['99']) 60 | assert q.ask() == {'age': '99'} 61 | 62 | def test_multiple_validators(self, give_input): 63 | class FooValidator(Validator): 64 | 65 | def validate(self, value): 66 | if 'foo' in value: 67 | self._choice = value 68 | return True 69 | else: 70 | return False 71 | 72 | class BarValidator(Validator): 73 | 74 | def validate(self, value): 75 | if 'bar' in value: 76 | self._choice = value 77 | return True 78 | else: 79 | return False 80 | 81 | q = Question( 82 | 'Your favorite variable name?', 'varname', 83 | validator=[FooValidator(), BarValidator()] 84 | ) 85 | give_input(q, ['foobar']) 86 | 87 | assert q.ask() == {'varname': 'foobar'} 88 | 89 | def test_multiple_answers(self, give_input): 90 | question = Question( 91 | 'Your favorite ice cream flavors?', 'flavors', 92 | multiple=True) 93 | give_input(question, ['chocolate', 'vanilla', '.']) 94 | assert question.ask() == {'flavors': ['chocolate', 'vanilla']} 95 | 96 | def test_answer(self, give_input): 97 | q = Question( 98 | 'Are you a machine?', 'is_machine', 99 | validator=YesNoValidator()) 100 | # try first with bad input that'll fail validator 101 | assert not q.validate('NO WAY!') # not of the proper format 102 | assert q.answer() is None 103 | 104 | # try again with good input 105 | assert q.validate('no') 106 | assert q.answer() == 'no' 107 | 108 | def test_ask_with_subquestion(self, give_input): 109 | question = Question( 110 | 'favorite food?', 'food') 111 | subquestion = Question('favorite time to eat this food?', 'time') 112 | question.add(subquestion) 113 | give_input(question, ['pesto']) 114 | give_input(subquestion, ['9 PM']) 115 | 116 | assert question.ask() == {'food': 'pesto', 'time': '9 PM'} 117 | 118 | def test_add_non_question_should_fail(self): 119 | with pytest.raises(Exception): 120 | Question().add(5) 121 | with pytest.raises(Exception): 122 | Question().remove(5) 123 | 124 | def test_remove_question(self): 125 | q = Question('favorite food?', 'food') 126 | subq = Question('Why is %(food)s your favorite food?', 'why') 127 | q.add(subq) 128 | assert len(q._questions) == 1 129 | q.remove(subq) 130 | assert len(q._questions) == 0 131 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from qav.utils import bold 4 | 5 | 6 | def test_bold(): 7 | assert bold('foo') == '\x1b[1mfoo\x1b[0m' 8 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | from collections import OrderedDict 5 | 6 | import pytest 7 | from netaddr import IPAddress 8 | 9 | from qav.filters import PreFilter 10 | from qav.validators import ( 11 | Validator, 12 | CompactListValidator, 13 | DateValidator, 14 | DomainNameValidator, 15 | EmailValidator, 16 | HashValidator, 17 | IntegerValidator, 18 | IPAddressValidator, 19 | IPNetmaskValidator, 20 | ListValidator, 21 | MacAddressValidator, 22 | TupleValidator, 23 | URIValidator, 24 | YesNoValidator, 25 | ) 26 | 27 | 28 | class TestValidator(object): 29 | 30 | def test_validate(self): 31 | v = Validator() 32 | assert v.validate('') is False 33 | assert v.error_message is not None 34 | 35 | def test_blank(self): 36 | v = Validator(blank=True) 37 | assert v.validate('') is True 38 | assert v.choice() == '' 39 | 40 | def test_error(self): 41 | v = Validator() 42 | v.validate('') 43 | assert v.error() == 'ERROR: Can not be empty. Please provide a value.' 44 | 45 | def test_stringify(self): 46 | assert Validator.stringify(5) == '5' 47 | assert Validator.stringify('5') == '5' 48 | 49 | 50 | class TestYesNoValidator(object): 51 | 52 | @pytest.mark.parametrize('value', ('yes', 'YES', 'no', 'NO')) 53 | def test_validate_success(self, value): 54 | v = YesNoValidator() 55 | assert v.validate(value) is True 56 | assert v.choice() == value.lower() 57 | 58 | def test_validate_failure(self): 59 | v = YesNoValidator() 60 | assert v.validate('something else') is False 61 | assert v.choice() is None 62 | assert v.error() == 'ERROR: Please choose yes or no.' 63 | 64 | 65 | class TestCompactListValidator(object): 66 | 67 | @pytest.mark.parametrize('value', ('foo', 'bar', 'baz')) 68 | def test_validate_success(self, value): 69 | v = CompactListValidator(choices=['foo', 'bar', 'baz']) 70 | assert v.validate(value) is True 71 | assert v.choice() == value 72 | 73 | def test_validate_failure(self): 74 | v = CompactListValidator(choices=['foo', 'bar', 'baz']) 75 | assert v.validate('junk') is False 76 | assert v.error() == 'ERROR: Please choose foo/bar/baz.' 77 | 78 | 79 | class TestDateValidator(object): 80 | 81 | @pytest.mark.parametrize('value,expected_choice', [ 82 | ('20180518', datetime.datetime(2018, 5, 18)), 83 | ('20120521', datetime.datetime(2012, 5, 21)), 84 | ]) 85 | def test_validate_success(self, value, expected_choice): 86 | v = DateValidator() 87 | assert v.validate(value) is True 88 | assert v.choice() == expected_choice 89 | 90 | @pytest.mark.parametrize('value', ('20170517 00:00:00', '5/18/1992', 'foo')) 91 | def test_validate_failure(self, value): 92 | assert DateValidator().validate(value) is False 93 | 94 | 95 | class TestDomainNameValidator(object): 96 | 97 | def test_is_fqdn(self): 98 | v = DomainNameValidator() 99 | assert v.validate('localhost') is False 100 | assert v.error() == 'ERROR: localhost is not a fully qualified domain name.' 101 | 102 | def test_does_resolve(self): 103 | v = DomainNameValidator() 104 | assert v.validate('google.com') is True 105 | assert v.choice() == 'google.com' 106 | 107 | def test_does_not_resolve(self): 108 | v = DomainNameValidator() 109 | assert v.validate('lsdkajflsdjsldsfjk.com') is False 110 | assert v.error() == 'ERROR: lsdkajflsdjsldsfjk.com does not resolve.' 111 | 112 | 113 | class TestMacAddressValidator(object): 114 | 115 | @pytest.mark.parametrize('value', ( 116 | 'AA:01:54:21:BB:0F', 117 | 'aa:01:54:21:bb:0f' 118 | )) 119 | def test_validate_success(self, value): 120 | v = MacAddressValidator() 121 | assert v.validate(value) is True 122 | assert v.choice() == value 123 | 124 | @pytest.mark.parametrize('value', ( 125 | 'AA:01:54:21:BB:0F:55', # too long 126 | 'AA:01:54:21:BB', # too short 127 | 'AA 01 54 21 BB 0F', # bad formats 128 | 'AA015421BB0F', 129 | 'foobar', 130 | )) 131 | def test_validate_failure(self, value): 132 | v = MacAddressValidator() 133 | assert v.validate(value) is False 134 | assert v.error() == 'ERROR: %s is not a valid MAC address.' % value 135 | 136 | 137 | class TestIPAddressValidator(object): 138 | 139 | @pytest.mark.parametrize('value', ( 140 | '192.168.78.10', 141 | '10.88.88.1', 142 | '8.8.8.8', 143 | )) 144 | def test_validate_success(self, value): 145 | v = IPAddressValidator() 146 | assert v.validate(value) is True 147 | assert v.choice() == IPAddress(value) 148 | 149 | @pytest.mark.parametrize('value', ( 150 | '10.500.10.10', 151 | '10.20.20.100/24', 152 | 'foobar', 153 | )) 154 | def test_validate_failure(self, value): 155 | v = IPAddressValidator() 156 | assert v.validate(value) is False 157 | assert v.error() == 'ERROR: %s is not a valid IP address.' % value 158 | 159 | 160 | class TestIPNetmaskValidator(object): 161 | 162 | @pytest.mark.parametrize('value', ( 163 | '255.255.255.0', 164 | '255.255.254.0', 165 | '0.0.0.0', 166 | )) 167 | def test_validate_success(self, value): 168 | v = IPNetmaskValidator() 169 | assert v.validate(value) is True 170 | assert v.choice() == IPAddress(value) 171 | 172 | @pytest.mark.parametrize('value', ( 173 | '62.125.24.5', 174 | '10.20.20.100/24', 175 | 'foobar', 176 | '', 177 | )) 178 | def test_validate_failure(self, value): 179 | v = IPNetmaskValidator() 180 | assert v.validate(value) is False 181 | assert v.error_message is not None 182 | 183 | 184 | class TestURIValidator(object): 185 | 186 | @pytest.mark.parametrize('value', ( 187 | 'http://google.com', 188 | 'https://google.com', 189 | 'http://localhost', 190 | 'HTTP://GOOGLE.COM', 191 | 'smb://foo.com', 192 | )) 193 | def test_validate_success(self, value): 194 | v = URIValidator() 195 | assert v.validate(value) is True 196 | assert v.choice() == value 197 | 198 | @pytest.mark.parametrize('value', ( 199 | 'http://example', 200 | 'google.com', 201 | 'http://_*.com', 202 | 'foobar', 203 | '5' 204 | )) 205 | def test_validate_failure(self, value): 206 | v = URIValidator() 207 | assert v.validate(value) is False 208 | assert v.error() == 'ERROR: %s is not a valid URI' % value 209 | 210 | 211 | class TestEmailValidator(object): 212 | 213 | def test_validate_success(self): 214 | v = EmailValidator() 215 | assert v.validate('user@example.com') is True 216 | assert v.choice() == 'user@example.com' 217 | 218 | @pytest.mark.parametrize('value', ( 219 | 'user', 220 | 'user@', 221 | '@user', 222 | 'user@foo', 223 | )) 224 | def test_validate_failure(self, value): 225 | v = EmailValidator() 226 | assert v.validate(value) is False 227 | assert v.error() == 'ERROR: %s is not a valid email address.' % value 228 | 229 | 230 | class TestListValidator(object): 231 | 232 | def test_non_choices(self): 233 | choices = [None, 'foo', 'bar'] 234 | v = ListValidator(choices) 235 | assert v.choices == [None, 'bar', 'foo'] 236 | 237 | def test_filters(self): 238 | choices = ['one dog', 'two dogs'] 239 | v = ListValidator(choices, filters=[PreFilter('one')]) 240 | assert v.choices == ['one dog'] 241 | 242 | def test_choices_get_sorted(self): 243 | v = ListValidator(['c', 'b', 'f', 'a']) 244 | assert v.choices == ['a', 'b', 'c', 'f'] 245 | 246 | def test_print_choices(self, capsys): 247 | v = ListValidator(['a', 'b', 'c']) 248 | assert v.print_choices() is True 249 | out, err = capsys.readouterr() 250 | assert out == '''Please select from the following choices: 251 | [0] - a 252 | [1] - b 253 | [2] - c 254 | ''' 255 | 256 | def test_no_choices(self): 257 | v = ListValidator([]) 258 | assert v.print_choices() is False 259 | assert v.validate('0') is False 260 | 261 | @pytest.mark.parametrize('value,idx', [ 262 | ('a', '0'), 263 | ('b', '1'), 264 | ('c', '2'), 265 | ]) 266 | def test_validate_success(self, value, idx): 267 | v = ListValidator(['a', 'b', 'c']) 268 | 269 | # try passing in the value itself 270 | assert v.validate(value) is True 271 | assert v.choice() == value 272 | 273 | # passing in the index number of out choice should work, too 274 | assert v.validate(idx) is True 275 | assert v.choice() == value 276 | 277 | def test_validate_failure(self): 278 | v = ListValidator(['a', 'b', 'c']) 279 | assert v.validate('d') is False 280 | assert v.error() == 'ERROR: d is not a valid choice.' 281 | assert v.validate('5') is False 282 | assert v.error() == 'ERROR: 5 is not a valid choice.' 283 | 284 | 285 | class TestTupleValidator(object): 286 | 287 | def test_list_of_tuples_passed_as_choice(self): 288 | with pytest.raises(AssertionError): 289 | TupleValidator(('a', 'b')) 290 | with pytest.raises(AssertionError): 291 | TupleValidator(( 292 | ('a', 'A'), 293 | ('b', 'B'))) 294 | 295 | def test_print_choices(self, capsys): 296 | v = TupleValidator([('a', 'A'), ('b', 'B'), ('c', 'C')]) 297 | assert v.print_choices() is True 298 | out, err = capsys.readouterr() 299 | assert out == '''Please select from the following choices: 300 | [0] - a (A) 301 | [1] - b (B) 302 | [2] - c (C) 303 | ''' 304 | 305 | def test_no_choices(self, capsys): 306 | v = TupleValidator([]) 307 | assert v.print_choices() is False 308 | assert v.validate('0') is False 309 | 310 | def test_validate_success(self): 311 | v = TupleValidator([ 312 | ('ABRT', 'Abort'), 313 | ('CONT', 'Continue')]) 314 | assert v.choices == [('ABRT', 'Abort'), 315 | ('CONT', 'Continue')] 316 | 317 | @pytest.mark.parametrize('value,idx', [ 318 | ('a', '0'), 319 | ('b', '1'), 320 | ('c', '2'), 321 | ]) 322 | def test_validate_success2(self, value, idx): 323 | v = TupleValidator([('a', 'A'), ('b', 'B'), ('c', 'C')]) 324 | 325 | # try passing in the value itself 326 | assert v.validate(value) is True 327 | assert v.choice() == value 328 | 329 | # passing in the index number of out choice should work, too 330 | assert v.validate(idx) is True 331 | assert v.choice() == value 332 | 333 | def test_validate_failure(self): 334 | v = TupleValidator([('a', 'A'), ('b', 'B'), ('c', 'C')]) 335 | assert v.validate('d') is False 336 | assert v.error() == 'ERROR: d is not a valid choice.' 337 | assert v.validate('5') is False 338 | assert v.error() == 'ERROR: 5 is not a valid choice.' 339 | 340 | 341 | class TestHashValidator(object): 342 | 343 | def test_choices(self): 344 | choices = {'ten': '10', 'twenty': '20'} 345 | v = HashValidator(choices, filters=[PreFilter('1')]) 346 | assert v.choices == OrderedDict([('ten', '10')]) 347 | 348 | def print_choices(self, capsys): 349 | v = HashValidator({'ten': '10', 'twenty': '20'}) 350 | assert v.print_choices() is True 351 | out, err = capsys.readouterr() 352 | assert out == '''Please select from the following choices: 353 | [0] - ten (10) 354 | [1] - twenty (20) 355 | ''' 356 | 357 | @pytest.mark.parametrize('key,value', [ 358 | ('ten', '10'), 359 | ('twenty', '20'), 360 | ]) 361 | def test_validate_success(self, key, value): 362 | v = HashValidator({'ten': '10', 'twenty': '20'}) 363 | 364 | # try passing in the value itself 365 | assert v.validate(key) is True 366 | assert v.choice() == key 367 | 368 | def test_validate_failure(self): 369 | v = HashValidator({'ten': '10', 'twenty': '20'}) 370 | assert v.validate('d') is False 371 | assert v.error() == 'ERROR: d is not a valid choice.' 372 | assert v.validate('5') is False 373 | assert v.error() == 'ERROR: 5 is not a valid choice.' 374 | 375 | 376 | class TestIntegerValidator(object): 377 | 378 | @pytest.mark.parametrize('value', ('2', '4', '8')) 379 | def test_validate_success(self, value): 380 | v = IntegerValidator() 381 | assert v.validate(value) is True 382 | assert v.choice() == int(value) 383 | 384 | @pytest.mark.parametrize('value', ('a2', '4a', 'foo', '7.2')) 385 | def test_validate_failure(self, value): 386 | assert IntegerValidator().validate(value) is False 387 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36 3 | 4 | [testenv] 5 | deps= 6 | pytest 7 | pytest-cov 8 | commands=pytest --cov {envsitepackagesdir}/qav {posargs} 9 | 10 | [testenv:flake8] 11 | basepython = python3.6 12 | deps = flake8 13 | commands = flake8 qav/ 14 | 15 | [testenv:mypy] 16 | basepython = python3.6 17 | deps = mypy 18 | commands = mypy qav/ 19 | --------------------------------------------------------------------------------