diff --git a/.coveragerc b/.coveragerc index b4028c6..9c55f15 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,4 +6,5 @@ branch = True [report] exclude_lines = pragma: nocover + pragma: windows if __name__ == .__main__.: diff --git a/.gitignore b/.gitignore index 3993d43..90944dd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tmp/ .tox/ .coverage __pycache__/ +tags diff --git a/.travis.yml b/.travis.yml index df0597f..760dd38 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,21 @@ language: python dist: xenial python: - - 3.6 - - 3.7 + - 3.6 + - 3.7 +matrix: + include: + - name: flake8 + install: pip install flake8 + script: flake8 src *.py + after_script: install: - - pip install pytest coverage coveralls flake8 - - pip install -e . + - pip install pytest coverage coveralls + - pip install -e . script: - - coverage run -m pytest tests.py - - coverage report -m --fail-under=100 - - flake8 *.py + - coverage run -m pytest tests + - coverage report -m after_script: - - coveralls + - coveralls notifications: - email: false + email: false diff --git a/CHANGES.rst b/CHANGES.rst index b00ddf0..8020531 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,8 @@ Changelog - Ignore unreleased Python versions (3.8 at the moment). +- Allow half-open ranges like ``--expect 3.5-``. + 0.11.0 (2019-02-13) ------------------- diff --git a/CLASSIFIERS b/CLASSIFIERS new file mode 100644 index 0000000..70f7118 --- /dev/null +++ b/CLASSIFIERS @@ -0,0 +1,680 @@ +Development Status :: 1 - Planning +Development Status :: 2 - Pre-Alpha +Development Status :: 3 - Alpha +Development Status :: 4 - Beta +Development Status :: 5 - Production/Stable +Development Status :: 6 - Mature +Development Status :: 7 - Inactive +Environment :: Console +Environment :: Console :: Curses +Environment :: Console :: Framebuffer +Environment :: Console :: Newt +Environment :: Console :: svgalib +Environment :: Handhelds/PDA's +Environment :: MacOS X +Environment :: MacOS X :: Aqua +Environment :: MacOS X :: Carbon +Environment :: MacOS X :: Cocoa +Environment :: No Input/Output (Daemon) +Environment :: OpenStack +Environment :: Other Environment +Environment :: Plugins +Environment :: Web Environment +Environment :: Web Environment :: Buffet +Environment :: Web Environment :: Mozilla +Environment :: Web Environment :: ToscaWidgets +Environment :: Win32 (MS Windows) +Environment :: X11 Applications +Environment :: X11 Applications :: Gnome +Environment :: X11 Applications :: GTK +Environment :: X11 Applications :: KDE +Environment :: X11 Applications :: Qt +Framework :: AiiDA +Framework :: AsyncIO +Framework :: BEAT +Framework :: BFG +Framework :: Bob +Framework :: Bottle +Framework :: Buildout +Framework :: Buildout :: Extension +Framework :: Buildout :: Recipe +Framework :: CastleCMS +Framework :: CastleCMS :: Theme +Framework :: Chandler +Framework :: CherryPy +Framework :: CubicWeb +Framework :: Django +Framework :: Django :: 1.10 +Framework :: Django :: 1.11 +Framework :: Django :: 1.4 +Framework :: Django :: 1.5 +Framework :: Django :: 1.6 +Framework :: Django :: 1.7 +Framework :: Django :: 1.8 +Framework :: Django :: 1.9 +Framework :: Django :: 2.0 +Framework :: Django :: 2.1 +Framework :: Django :: 2.2 +Framework :: Flake8 +Framework :: Flask +Framework :: Hypothesis +Framework :: IDLE +Framework :: IPython +Framework :: Jupyter +Framework :: Lektor +Framework :: Masonite +Framework :: Nengo +Framework :: Odoo +Framework :: Opps +Framework :: Paste +Framework :: Pelican +Framework :: Pelican :: Plugins +Framework :: Pelican :: Themes +Framework :: Plone +Framework :: Plone :: 3.2 +Framework :: Plone :: 3.3 +Framework :: Plone :: 4.0 +Framework :: Plone :: 4.1 +Framework :: Plone :: 4.2 +Framework :: Plone :: 4.3 +Framework :: Plone :: 5.0 +Framework :: Plone :: 5.1 +Framework :: Plone :: 5.2 +Framework :: Plone :: 5.3 +Framework :: Plone :: Addon +Framework :: Plone :: Core +Framework :: Plone :: Theme +Framework :: Pylons +Framework :: Pyramid +Framework :: Pytest +Framework :: Review Board +Framework :: Robot Framework +Framework :: Robot Framework :: Library +Framework :: Robot Framework :: Tool +Framework :: Scrapy +Framework :: Setuptools Plugin +Framework :: Sphinx +Framework :: Sphinx :: Extension +Framework :: Sphinx :: Theme +Framework :: tox +Framework :: Trac +Framework :: Trio +Framework :: Tryton +Framework :: TurboGears +Framework :: TurboGears :: Applications +Framework :: TurboGears :: Widgets +Framework :: Twisted +Framework :: Wagtail +Framework :: Wagtail :: 1 +Framework :: Wagtail :: 2 +Framework :: ZODB +Framework :: Zope +Framework :: Zope2 +Framework :: Zope :: 2 +Framework :: Zope3 +Framework :: Zope :: 3 +Framework :: Zope :: 4 +Intended Audience :: Customer Service +Intended Audience :: Developers +Intended Audience :: Education +Intended Audience :: End Users/Desktop +Intended Audience :: Financial and Insurance Industry +Intended Audience :: Healthcare Industry +Intended Audience :: Information Technology +Intended Audience :: Legal Industry +Intended Audience :: Manufacturing +Intended Audience :: Other Audience +Intended Audience :: Religion +Intended Audience :: Science/Research +Intended Audience :: System Administrators +Intended Audience :: Telecommunications Industry +License :: Aladdin Free Public License (AFPL) +License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication +License :: CeCILL-B Free Software License Agreement (CECILL-B) +License :: CeCILL-C Free Software License Agreement (CECILL-C) +License :: DFSG approved +License :: Eiffel Forum License (EFL) +License :: Free For Educational Use +License :: Free For Home Use +License :: Free for non-commercial use +License :: Freely Distributable +License :: Free To Use But Restricted +License :: Freeware +License :: GUST Font License 1.0 +License :: GUST Font License 2006-09-30 +License :: Netscape Public License (NPL) +License :: Nokia Open Source License (NOKOS) +License :: OSI Approved +License :: OSI Approved :: Academic Free License (AFL) +License :: OSI Approved :: Apache Software License +License :: OSI Approved :: Apple Public Source License +License :: OSI Approved :: Artistic License +License :: OSI Approved :: Attribution Assurance License +License :: OSI Approved :: Boost Software License 1.0 (BSL-1.0) +License :: OSI Approved :: BSD License +License :: OSI Approved :: CEA CNRS Inria Logiciel Libre License, version 2.1 (CeCILL-2.1) +License :: OSI Approved :: Common Development and Distribution License 1.0 (CDDL-1.0) +License :: OSI Approved :: Common Public License +License :: OSI Approved :: Eclipse Public License 1.0 (EPL-1.0) +License :: OSI Approved :: Eclipse Public License 2.0 (EPL-2.0) +License :: OSI Approved :: Eiffel Forum License +License :: OSI Approved :: European Union Public Licence 1.0 (EUPL 1.0) +License :: OSI Approved :: European Union Public Licence 1.1 (EUPL 1.1) +License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2) +License :: OSI Approved :: GNU Affero General Public License v3 +License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+) +License :: OSI Approved :: GNU Free Documentation License (FDL) +License :: OSI Approved :: GNU General Public License (GPL) +License :: OSI Approved :: GNU General Public License v2 (GPLv2) +License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+) +License :: OSI Approved :: GNU General Public License v3 (GPLv3) +License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) +License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2) +License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+) +License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) +License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+) +License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL) +License :: OSI Approved :: Historical Permission Notice and Disclaimer (HPND) +License :: OSI Approved :: IBM Public License +License :: OSI Approved :: Intel Open Source License +License :: OSI Approved :: ISC License (ISCL) +License :: OSI Approved :: Jabber Open Source License +License :: OSI Approved :: MirOS License (MirOS) +License :: OSI Approved :: MIT License +License :: OSI Approved :: MITRE Collaborative Virtual Workspace License (CVW) +License :: OSI Approved :: Motosoto License +License :: OSI Approved :: Mozilla Public License 1.0 (MPL) +License :: OSI Approved :: Mozilla Public License 1.1 (MPL 1.1) +License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0) +License :: OSI Approved :: Nethack General Public License +License :: OSI Approved :: Nokia Open Source License +License :: OSI Approved :: Open Group Test Suite License +License :: OSI Approved :: PostgreSQL License +License :: OSI Approved :: Python License (CNRI Python License) +License :: OSI Approved :: Python Software Foundation License +License :: OSI Approved :: Qt Public License (QPL) +License :: OSI Approved :: Ricoh Source Code Public License +License :: OSI Approved :: SIL Open Font License 1.1 (OFL-1.1) +License :: OSI Approved :: Sleepycat License +License :: OSI Approved :: Sun Industry Standards Source License (SISSL) +License :: OSI Approved :: Sun Public License +License :: OSI Approved :: Universal Permissive License (UPL) +License :: OSI Approved :: University of Illinois/NCSA Open Source License +License :: OSI Approved :: Vovida Software License 1.0 +License :: OSI Approved :: W3C License +License :: OSI Approved :: X.Net License +License :: OSI Approved :: zlib/libpng License +License :: OSI Approved :: Zope Public License +License :: Other/Proprietary License +License :: Public Domain +License :: Repoze Public License +Natural Language :: Afrikaans +Natural Language :: Arabic +Natural Language :: Bengali +Natural Language :: Bosnian +Natural Language :: Bulgarian +Natural Language :: Cantonese +Natural Language :: Catalan +Natural Language :: Chinese (Simplified) +Natural Language :: Chinese (Traditional) +Natural Language :: Croatian +Natural Language :: Czech +Natural Language :: Danish +Natural Language :: Dutch +Natural Language :: English +Natural Language :: Esperanto +Natural Language :: Finnish +Natural Language :: French +Natural Language :: Galician +Natural Language :: German +Natural Language :: Greek +Natural Language :: Hebrew +Natural Language :: Hindi +Natural Language :: Hungarian +Natural Language :: Icelandic +Natural Language :: Indonesian +Natural Language :: Italian +Natural Language :: Japanese +Natural Language :: Javanese +Natural Language :: Korean +Natural Language :: Latin +Natural Language :: Latvian +Natural Language :: Macedonian +Natural Language :: Malay +Natural Language :: Marathi +Natural Language :: Norwegian +Natural Language :: Panjabi +Natural Language :: Persian +Natural Language :: Polish +Natural Language :: Portuguese +Natural Language :: Portuguese (Brazilian) +Natural Language :: Romanian +Natural Language :: Russian +Natural Language :: Serbian +Natural Language :: Slovak +Natural Language :: Slovenian +Natural Language :: Spanish +Natural Language :: Swedish +Natural Language :: Tamil +Natural Language :: Telugu +Natural Language :: Thai +Natural Language :: Tibetan +Natural Language :: Turkish +Natural Language :: Ukrainian +Natural Language :: Urdu +Natural Language :: Vietnamese +Operating System :: Android +Operating System :: BeOS +Operating System :: iOS +Operating System :: MacOS +Operating System :: MacOS :: MacOS 9 +Operating System :: MacOS :: MacOS X +Operating System :: Microsoft +Operating System :: Microsoft :: MS-DOS +Operating System :: Microsoft :: Windows +Operating System :: Microsoft :: Windows :: Windows 10 +Operating System :: Microsoft :: Windows :: Windows 3.1 or Earlier +Operating System :: Microsoft :: Windows :: Windows 7 +Operating System :: Microsoft :: Windows :: Windows 8 +Operating System :: Microsoft :: Windows :: Windows 8.1 +Operating System :: Microsoft :: Windows :: Windows 95/98/2000 +Operating System :: Microsoft :: Windows :: Windows CE +Operating System :: Microsoft :: Windows :: Windows NT/2000 +Operating System :: Microsoft :: Windows :: Windows Server 2003 +Operating System :: Microsoft :: Windows :: Windows Server 2008 +Operating System :: Microsoft :: Windows :: Windows Vista +Operating System :: Microsoft :: Windows :: Windows XP +Operating System :: OS/2 +Operating System :: OS Independent +Operating System :: Other OS +Operating System :: PalmOS +Operating System :: PDA Systems +Operating System :: POSIX +Operating System :: POSIX :: AIX +Operating System :: POSIX :: BSD +Operating System :: POSIX :: BSD :: BSD/OS +Operating System :: POSIX :: BSD :: FreeBSD +Operating System :: POSIX :: BSD :: NetBSD +Operating System :: POSIX :: BSD :: OpenBSD +Operating System :: POSIX :: GNU Hurd +Operating System :: POSIX :: HP-UX +Operating System :: POSIX :: IRIX +Operating System :: POSIX :: Linux +Operating System :: POSIX :: Other +Operating System :: POSIX :: SCO +Operating System :: POSIX :: SunOS/Solaris +Operating System :: Unix +Programming Language :: Ada +Programming Language :: APL +Programming Language :: ASP +Programming Language :: Assembly +Programming Language :: Awk +Programming Language :: Basic +Programming Language :: C +Programming Language :: C# +Programming Language :: C++ +Programming Language :: Cold Fusion +Programming Language :: Cython +Programming Language :: Delphi/Kylix +Programming Language :: Dylan +Programming Language :: Eiffel +Programming Language :: Emacs-Lisp +Programming Language :: Erlang +Programming Language :: Euler +Programming Language :: Euphoria +Programming Language :: Forth +Programming Language :: Fortran +Programming Language :: Haskell +Programming Language :: Java +Programming Language :: JavaScript +Programming Language :: Lisp +Programming Language :: Logo +Programming Language :: ML +Programming Language :: Modula +Programming Language :: Objective C +Programming Language :: Object Pascal +Programming Language :: OCaml +Programming Language :: Other +Programming Language :: Other Scripting Engines +Programming Language :: Pascal +Programming Language :: Perl +Programming Language :: PHP +Programming Language :: Pike +Programming Language :: Pliant +Programming Language :: PL/SQL +Programming Language :: PROGRESS +Programming Language :: Prolog +Programming Language :: Python +Programming Language :: Python :: 2 +Programming Language :: Python :: 2.3 +Programming Language :: Python :: 2.4 +Programming Language :: Python :: 2.5 +Programming Language :: Python :: 2.6 +Programming Language :: Python :: 2.7 +Programming Language :: Python :: 2 :: Only +Programming Language :: Python :: 3 +Programming Language :: Python :: 3.0 +Programming Language :: Python :: 3.1 +Programming Language :: Python :: 3.2 +Programming Language :: Python :: 3.3 +Programming Language :: Python :: 3.4 +Programming Language :: Python :: 3.5 +Programming Language :: Python :: 3.6 +Programming Language :: Python :: 3.7 +Programming Language :: Python :: 3.8 +Programming Language :: Python :: 3 :: Only +Programming Language :: Python :: Implementation +Programming Language :: Python :: Implementation :: CPython +Programming Language :: Python :: Implementation :: IronPython +Programming Language :: Python :: Implementation :: Jython +Programming Language :: Python :: Implementation :: MicroPython +Programming Language :: Python :: Implementation :: PyPy +Programming Language :: Python :: Implementation :: Stackless +Programming Language :: R +Programming Language :: REBOL +Programming Language :: Rexx +Programming Language :: Ruby +Programming Language :: Rust +Programming Language :: Scheme +Programming Language :: Simula +Programming Language :: Smalltalk +Programming Language :: SQL +Programming Language :: Tcl +Programming Language :: Unix Shell +Programming Language :: Visual Basic +Programming Language :: XBasic +Programming Language :: YACC +Programming Language :: Zope +Topic :: Adaptive Technologies +Topic :: Artistic Software +Topic :: Communications +Topic :: Communications :: BBS +Topic :: Communications :: Chat +Topic :: Communications :: Chat :: ICQ +Topic :: Communications :: Chat :: Internet Relay Chat +Topic :: Communications :: Chat :: Unix Talk +Topic :: Communications :: Conferencing +Topic :: Communications :: Email +Topic :: Communications :: Email :: Address Book +Topic :: Communications :: Email :: Email Clients (MUA) +Topic :: Communications :: Email :: Filters +Topic :: Communications :: Email :: Mailing List Servers +Topic :: Communications :: Email :: Mail Transport Agents +Topic :: Communications :: Email :: Post-Office +Topic :: Communications :: Email :: Post-Office :: IMAP +Topic :: Communications :: Email :: Post-Office :: POP3 +Topic :: Communications :: Fax +Topic :: Communications :: FIDO +Topic :: Communications :: File Sharing +Topic :: Communications :: File Sharing :: Gnutella +Topic :: Communications :: File Sharing :: Napster +Topic :: Communications :: Ham Radio +Topic :: Communications :: Internet Phone +Topic :: Communications :: Telephony +Topic :: Communications :: Usenet News +Topic :: Database +Topic :: Database :: Database Engines/Servers +Topic :: Database :: Front-Ends +Topic :: Desktop Environment +Topic :: Desktop Environment :: File Managers +Topic :: Desktop Environment :: Gnome +Topic :: Desktop Environment :: GNUstep +Topic :: Desktop Environment :: K Desktop Environment (KDE) +Topic :: Desktop Environment :: K Desktop Environment (KDE) :: Themes +Topic :: Desktop Environment :: PicoGUI +Topic :: Desktop Environment :: PicoGUI :: Applications +Topic :: Desktop Environment :: PicoGUI :: Themes +Topic :: Desktop Environment :: Screen Savers +Topic :: Desktop Environment :: Window Managers +Topic :: Desktop Environment :: Window Managers :: Afterstep +Topic :: Desktop Environment :: Window Managers :: Afterstep :: Themes +Topic :: Desktop Environment :: Window Managers :: Applets +Topic :: Desktop Environment :: Window Managers :: Blackbox +Topic :: Desktop Environment :: Window Managers :: Blackbox :: Themes +Topic :: Desktop Environment :: Window Managers :: CTWM +Topic :: Desktop Environment :: Window Managers :: CTWM :: Themes +Topic :: Desktop Environment :: Window Managers :: Enlightenment +Topic :: Desktop Environment :: Window Managers :: Enlightenment :: Epplets +Topic :: Desktop Environment :: Window Managers :: Enlightenment :: Themes DR15 +Topic :: Desktop Environment :: Window Managers :: Enlightenment :: Themes DR16 +Topic :: Desktop Environment :: Window Managers :: Enlightenment :: Themes DR17 +Topic :: Desktop Environment :: Window Managers :: Fluxbox +Topic :: Desktop Environment :: Window Managers :: Fluxbox :: Themes +Topic :: Desktop Environment :: Window Managers :: FVWM +Topic :: Desktop Environment :: Window Managers :: FVWM :: Themes +Topic :: Desktop Environment :: Window Managers :: IceWM +Topic :: Desktop Environment :: Window Managers :: IceWM :: Themes +Topic :: Desktop Environment :: Window Managers :: MetaCity +Topic :: Desktop Environment :: Window Managers :: MetaCity :: Themes +Topic :: Desktop Environment :: Window Managers :: Oroborus +Topic :: Desktop Environment :: Window Managers :: Oroborus :: Themes +Topic :: Desktop Environment :: Window Managers :: Sawfish +Topic :: Desktop Environment :: Window Managers :: Sawfish :: Themes 0.30 +Topic :: Desktop Environment :: Window Managers :: Sawfish :: Themes pre-0.30 +Topic :: Desktop Environment :: Window Managers :: Waimea +Topic :: Desktop Environment :: Window Managers :: Waimea :: Themes +Topic :: Desktop Environment :: Window Managers :: Window Maker +Topic :: Desktop Environment :: Window Managers :: Window Maker :: Applets +Topic :: Desktop Environment :: Window Managers :: Window Maker :: Themes +Topic :: Desktop Environment :: Window Managers :: XFCE +Topic :: Desktop Environment :: Window Managers :: XFCE :: Themes +Topic :: Documentation +Topic :: Documentation :: Sphinx +Topic :: Education +Topic :: Education :: Computer Aided Instruction (CAI) +Topic :: Education :: Testing +Topic :: Games/Entertainment +Topic :: Games/Entertainment :: Arcade +Topic :: Games/Entertainment :: Board Games +Topic :: Games/Entertainment :: First Person Shooters +Topic :: Games/Entertainment :: Fortune Cookies +Topic :: Games/Entertainment :: Multi-User Dungeons (MUD) +Topic :: Games/Entertainment :: Puzzle Games +Topic :: Games/Entertainment :: Real Time Strategy +Topic :: Games/Entertainment :: Role-Playing +Topic :: Games/Entertainment :: Side-Scrolling/Arcade Games +Topic :: Games/Entertainment :: Simulation +Topic :: Games/Entertainment :: Turn Based Strategy +Topic :: Home Automation +Topic :: Internet +Topic :: Internet :: File Transfer Protocol (FTP) +Topic :: Internet :: Finger +Topic :: Internet :: Log Analysis +Topic :: Internet :: Name Service (DNS) +Topic :: Internet :: Proxy Servers +Topic :: Internet :: WAP +Topic :: Internet :: WWW/HTTP +Topic :: Internet :: WWW/HTTP :: Browsers +Topic :: Internet :: WWW/HTTP :: Dynamic Content +Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries +Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System +Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards +Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary +Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Page Counters +Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Wiki +Topic :: Internet :: WWW/HTTP :: HTTP Servers +Topic :: Internet :: WWW/HTTP :: Indexing/Search +Topic :: Internet :: WWW/HTTP :: Session +Topic :: Internet :: WWW/HTTP :: Site Management +Topic :: Internet :: WWW/HTTP :: Site Management :: Link Checking +Topic :: Internet :: WWW/HTTP :: WSGI +Topic :: Internet :: WWW/HTTP :: WSGI :: Application +Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware +Topic :: Internet :: WWW/HTTP :: WSGI :: Server +Topic :: Internet :: XMPP +Topic :: Internet :: Z39.50 +Topic :: Multimedia +Topic :: Multimedia :: Graphics +Topic :: Multimedia :: Graphics :: 3D Modeling +Topic :: Multimedia :: Graphics :: 3D Rendering +Topic :: Multimedia :: Graphics :: Capture +Topic :: Multimedia :: Graphics :: Capture :: Digital Camera +Topic :: Multimedia :: Graphics :: Capture :: Scanners +Topic :: Multimedia :: Graphics :: Capture :: Screen Capture +Topic :: Multimedia :: Graphics :: Editors +Topic :: Multimedia :: Graphics :: Editors :: Raster-Based +Topic :: Multimedia :: Graphics :: Editors :: Vector-Based +Topic :: Multimedia :: Graphics :: Graphics Conversion +Topic :: Multimedia :: Graphics :: Presentation +Topic :: Multimedia :: Graphics :: Viewers +Topic :: Multimedia :: Sound/Audio +Topic :: Multimedia :: Sound/Audio :: Analysis +Topic :: Multimedia :: Sound/Audio :: Capture/Recording +Topic :: Multimedia :: Sound/Audio :: CD Audio +Topic :: Multimedia :: Sound/Audio :: CD Audio :: CD Playing +Topic :: Multimedia :: Sound/Audio :: CD Audio :: CD Ripping +Topic :: Multimedia :: Sound/Audio :: CD Audio :: CD Writing +Topic :: Multimedia :: Sound/Audio :: Conversion +Topic :: Multimedia :: Sound/Audio :: Editors +Topic :: Multimedia :: Sound/Audio :: MIDI +Topic :: Multimedia :: Sound/Audio :: Mixers +Topic :: Multimedia :: Sound/Audio :: Players +Topic :: Multimedia :: Sound/Audio :: Players :: MP3 +Topic :: Multimedia :: Sound/Audio :: Sound Synthesis +Topic :: Multimedia :: Sound/Audio :: Speech +Topic :: Multimedia :: Video +Topic :: Multimedia :: Video :: Capture +Topic :: Multimedia :: Video :: Conversion +Topic :: Multimedia :: Video :: Display +Topic :: Multimedia :: Video :: Non-Linear Editor +Topic :: Office/Business +Topic :: Office/Business :: Financial +Topic :: Office/Business :: Financial :: Accounting +Topic :: Office/Business :: Financial :: Investment +Topic :: Office/Business :: Financial :: Point-Of-Sale +Topic :: Office/Business :: Financial :: Spreadsheet +Topic :: Office/Business :: Groupware +Topic :: Office/Business :: News/Diary +Topic :: Office/Business :: Office Suites +Topic :: Office/Business :: Scheduling +Topic :: Other/Nonlisted Topic +Topic :: Printing +Topic :: Religion +Topic :: Scientific/Engineering +Topic :: Scientific/Engineering :: Artificial Intelligence +Topic :: Scientific/Engineering :: Artificial Life +Topic :: Scientific/Engineering :: Astronomy +Topic :: Scientific/Engineering :: Atmospheric Science +Topic :: Scientific/Engineering :: Bio-Informatics +Topic :: Scientific/Engineering :: Chemistry +Topic :: Scientific/Engineering :: Electronic Design Automation (EDA) +Topic :: Scientific/Engineering :: GIS +Topic :: Scientific/Engineering :: Human Machine Interfaces +Topic :: Scientific/Engineering :: Image Recognition +Topic :: Scientific/Engineering :: Information Analysis +Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator +Topic :: Scientific/Engineering :: Mathematics +Topic :: Scientific/Engineering :: Medical Science Apps. +Topic :: Scientific/Engineering :: Physics +Topic :: Scientific/Engineering :: Visualization +Topic :: Security +Topic :: Security :: Cryptography +Topic :: Sociology +Topic :: Sociology :: Genealogy +Topic :: Sociology :: History +Topic :: Software Development +Topic :: Software Development :: Assemblers +Topic :: Software Development :: Bug Tracking +Topic :: Software Development :: Build Tools +Topic :: Software Development :: Code Generators +Topic :: Software Development :: Compilers +Topic :: Software Development :: Debuggers +Topic :: Software Development :: Disassemblers +Topic :: Software Development :: Documentation +Topic :: Software Development :: Embedded Systems +Topic :: Software Development :: Internationalization +Topic :: Software Development :: Interpreters +Topic :: Software Development :: Libraries +Topic :: Software Development :: Libraries :: Application Frameworks +Topic :: Software Development :: Libraries :: Java Libraries +Topic :: Software Development :: Libraries :: Perl Modules +Topic :: Software Development :: Libraries :: PHP Classes +Topic :: Software Development :: Libraries :: Pike Modules +Topic :: Software Development :: Libraries :: pygame +Topic :: Software Development :: Libraries :: Python Modules +Topic :: Software Development :: Libraries :: Ruby Modules +Topic :: Software Development :: Libraries :: Tcl Extensions +Topic :: Software Development :: Localization +Topic :: Software Development :: Object Brokering +Topic :: Software Development :: Object Brokering :: CORBA +Topic :: Software Development :: Pre-processors +Topic :: Software Development :: Quality Assurance +Topic :: Software Development :: Testing +Topic :: Software Development :: Testing :: Acceptance +Topic :: Software Development :: Testing :: BDD +Topic :: Software Development :: Testing :: Mocking +Topic :: Software Development :: Testing :: Traffic Generation +Topic :: Software Development :: Testing :: Unit +Topic :: Software Development :: User Interfaces +Topic :: Software Development :: Version Control +Topic :: Software Development :: Version Control :: Bazaar +Topic :: Software Development :: Version Control :: CVS +Topic :: Software Development :: Version Control :: Git +Topic :: Software Development :: Version Control :: Mercurial +Topic :: Software Development :: Version Control :: RCS +Topic :: Software Development :: Version Control :: SCCS +Topic :: Software Development :: Widget Sets +Topic :: System +Topic :: System :: Archiving +Topic :: System :: Archiving :: Backup +Topic :: System :: Archiving :: Compression +Topic :: System :: Archiving :: Mirroring +Topic :: System :: Archiving :: Packaging +Topic :: System :: Benchmark +Topic :: System :: Boot +Topic :: System :: Boot :: Init +Topic :: System :: Clustering +Topic :: System :: Console Fonts +Topic :: System :: Distributed Computing +Topic :: System :: Emulators +Topic :: System :: Filesystems +Topic :: System :: Hardware +Topic :: System :: Hardware :: Hardware Drivers +Topic :: System :: Hardware :: Mainframes +Topic :: System :: Hardware :: Symmetric Multi-processing +Topic :: System :: Installation/Setup +Topic :: System :: Logging +Topic :: System :: Monitoring +Topic :: System :: Networking +Topic :: System :: Networking :: Firewalls +Topic :: System :: Networking :: Monitoring +Topic :: System :: Networking :: Monitoring :: Hardware Watchdog +Topic :: System :: Networking :: Time Synchronization +Topic :: System :: Operating System +Topic :: System :: Operating System Kernels +Topic :: System :: Operating System Kernels :: BSD +Topic :: System :: Operating System Kernels :: GNU Hurd +Topic :: System :: Operating System Kernels :: Linux +Topic :: System :: Power (UPS) +Topic :: System :: Recovery Tools +Topic :: System :: Shells +Topic :: System :: Software Distribution +Topic :: System :: Systems Administration +Topic :: System :: Systems Administration :: Authentication/Directory +Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP +Topic :: System :: Systems Administration :: Authentication/Directory :: NIS +Topic :: System :: System Shells +Topic :: Terminals +Topic :: Terminals :: Serial +Topic :: Terminals :: Telnet +Topic :: Terminals :: Terminal Emulators/X Terminals +Topic :: Text Editors +Topic :: Text Editors :: Documentation +Topic :: Text Editors :: Emacs +Topic :: Text Editors :: Integrated Development Environments (IDE) +Topic :: Text Editors :: Text Processing +Topic :: Text Editors :: Word Processors +Topic :: Text Processing +Topic :: Text Processing :: Filters +Topic :: Text Processing :: Fonts +Topic :: Text Processing :: General +Topic :: Text Processing :: Indexing +Topic :: Text Processing :: Linguistic +Topic :: Text Processing :: Markup +Topic :: Text Processing :: Markup :: HTML +Topic :: Text Processing :: Markup :: LaTeX +Topic :: Text Processing :: Markup :: SGML +Topic :: Text Processing :: Markup :: VRML +Topic :: Text Processing :: Markup :: XML +Topic :: Utilities +Typing :: Typed \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index b196f4e..29d311f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,3 +10,11 @@ include tox.ini # added by check_manifest.py include *.yml + +# added by check_manifest.py +include CLASSIFIERS +include check-python-versions + +# added by check_manifest.py +include pytest.ini +recursive-include tests *.py diff --git a/Makefile b/Makefile index 9c670ed..06398c8 100644 --- a/Makefile +++ b/Makefile @@ -14,4 +14,8 @@ coverage: tox -e coverage +.PHONY: flake8 +flake8: + flake8 src setup.py + include release.mk diff --git a/README.rst b/README.rst index ace959f..0a7e5ca 100644 --- a/README.rst +++ b/README.rst @@ -87,7 +87,9 @@ Usage $ check-python-versions --help usage: check-python-versions [-h] [--version] [--expect VERSIONS] - [--skip-non-packages] + [--skip-non-packages] [--only ONLY] + [--add VERSIONS] [--drop VERSIONS] + [--update VERSIONS] [--diff] [--dry-run] [where [where ...]] verify that supported Python versions are the same in setup.py, tox.ini, @@ -101,9 +103,19 @@ Usage -h, --help show this help message and exit --version show program's version number and exit --expect VERSIONS expect these versions to be supported, e.g. --expect - 2.7,3.4-3.7 + 2.7,3.5-3.7 --skip-non-packages skip arguments that are not Python packages without warning about them + --only ONLY check only the specified files (comma-separated list) + + updating supported version lists (EXPERIMENTAL): + --add VERSIONS add these versions to supported ones, e.g --add 3.8 + --drop VERSIONS drop these versions from supported ones, e.g --drop + 2.6,3.4 + --update VERSIONS update the set of supported versions, e.g. --update + 2.7,3.5-3.7 + --diff show a diff of proposed changes + --dry-run verify proposed changes without writing them to disk If run without any arguments, check-python-versions will look for a setup.py in the current working directory. @@ -121,6 +133,20 @@ helpful when, e.g. you want to run :: to check all 380+ packages, and then want re-run the checks only on the failed ones, for a faster turnabout. +There's also experimental support for updating supported Python versions +so you can do things like :: + + check-python-versions ~/projects/* --add 3.8 --dry-run --expect 2.7,3.5-3.8 + check-python-versions ~/projects/* --drop 3.4 --diff + check-python-versions ~/projects/* --update 2.7,3.4- --dry-run --diff + check-python-versions ~/projects/* --add 3.8 --drop=-2.6,-3.4 + +(the last one will show a diff for each file and ask for interactive +confirmation before making any changes.) + +Programmatically updating human-writable files is difficult, so expect +bugs (and please file issues). + Files ----- @@ -141,6 +167,16 @@ they'll be ignored (and this will not considered a failure). extract classifiers, but if that fails, it'll execute ``python setup.py --classifiers`` and parse the output. + There's rudimentary support for dynamically-computed classifiers if at + least one part is a list literal, e.g. this can work and can even be + updated :: + + classifiers=[ + ... + "Programming Language :: Python :: x.y", + ... + ] + ... expression that computes extra classifiers ..., + - **setup.py**: the ``python_requires`` argument passed to ``setup()``, if present:: @@ -150,8 +186,6 @@ they'll be ignored (and this will not considered a failure). extract the ``python_requires`` value. It expects to find a string literal or a simple expression of the form ``"literal".join(["...", "..."])``. - Only ``>=`` and ``!=`` constraints are currently supported. - - **tox.ini**: if present, it's expected to have :: [tox] @@ -186,6 +220,8 @@ they'll be ignored (and this will not considered a failure). env: - TOXENV=... + (but not all of these forms are supported for updates) + - **appveyor.yml**: if present, it's expected to have :: environment: @@ -202,6 +238,8 @@ they'll be ignored (and this will not considered a failure). Alternatively, you can use ``TOXENV`` with the usual values (pyXY). + (``TOXENV`` is currently not supported for updates.) + - **.manylinux-install.sh**: if present, it's expected to contain a loop like :: @@ -241,13 +279,40 @@ in some of the files: - **tox.ini** may have pypy[-suffix] and pypy3[-suffix] environments -- **.travis.yml** may have pypy and pypy3 jobs +- **.travis.yml** may have pypy and pypy3 jobs with optional version suffixes + (e.g. pypy2.7-6.0.0, pypy3.5-6.0.0) - **appveyor.yml** and **.manylinux-install.sh** do not usually have pypy tests, so check-python-versions cannot recognize them. These extra Pythons are shown, but not compared for consistency. +Upcoming Python releases (such as 3.8 in setup.py or 3.8-dev in a .travis.yml) +are also shown but do not cause mismatch errors. + In addition, ``python_requires`` in setup.py usually has a lower limit, but no upper limit. check-python-versions will assume this means support up to the current Python 3.x release (3.7 at the moment). + +When you're specifying Python version ranges for --expect, --add, --drop or +--update, you can use + +- ``X.Y`` (e.g. ``--add 3.8``) +- ``X.Y-U.V`` for an inclusive range (e.g. ``--add 3.5-3.8``) +- ``X.Y-``, which means from X.Y until the latest known release from the X series + (e.g. ``--add 3.5-`` is equivalent to ``--add 3.5-3.7``) +- ``-X.Y``, which is the same as ``X.0-X.Y`` + (e.g. ``--drop -3.4`` is equivalent to ``--drop 3.0-3.4``) + +or a comma-separated list of the above (e.g. ``--expect 2.7,3.5-``, +``--drop -2.6,-3.4``). + +You may have to take extra care when using ranges with no explicit lower limit, +as they look like command-line flags, so instead of :: + + --drop -2.6 + +you may need to write :: + + --drop=-2.6 + diff --git a/check-python-versions b/check-python-versions new file mode 100755 index 0000000..17c3b2f --- /dev/null +++ b/check-python-versions @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +""" +Wrapper script for running ./check-python-versions directly from a source +checkout without installing. (You'll need pyyaml in your system Python.) +""" + +import sys +import os + +here = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(here, 'src')) + +from check_python_versions.cli import main # noqa: E402 +main() diff --git a/check_python_versions.py b/check_python_versions.py deleted file mode 100755 index 5c76cd2..0000000 --- a/check_python_versions.py +++ /dev/null @@ -1,538 +0,0 @@ -#!/usr/bin/python3 -""" -Check supported Python versions in a Python package. - -Makes sure the set of supported Python versions is consistent between - -- setup.py PyPI classifiers -- tox.ini default env list -- .travis-ci.yml -- appveyor.yml -- (optionally) .manylinux-install.sh as used by various ZopeFoundation projects - -""" - -import argparse -import ast -import configparser -import logging -import os -import re -import subprocess -import sys -from functools import partial - - -try: - import yaml -except ImportError: # pragma: nocover - # Shouldn't happen, we install_requires=['PyYAML'], but maybe someone is - # running ./check_python_versions.py directly from a git checkout. - yaml = None - print("PyYAML is needed for Travis CI/Appveyor support" - " (apt install python3-yaml)") - - -__author__ = 'Marius Gedminas ' -__version__ = '0.12.0.dev0' - - -log = logging.getLogger('check-python-versions') - - -TOX_INI = 'tox.ini' -TRAVIS_YML = '.travis.yml' -APPVEYOR_YML = 'appveyor.yml' -MANYLINUX_INSTALL_SH = '.manylinux-install.sh' - - -MAX_PYTHON_1_VERSION = 6 # i.e. 1.6 -MAX_PYTHON_2_VERSION = 7 # i.e. 2.7 -CURRENT_PYTHON_3_VERSION = 7 # i.e. 3.7 - - -def warn(msg): - print(msg, file=sys.stderr) - - -def pipe(*cmd, **kwargs): - if 'cwd' in kwargs: - log.debug('EXEC cd %s && %s', kwargs['cwd'], ' '.join(cmd)) - else: - log.debug('EXEC %s', ' '.join(cmd)) - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, **kwargs) - return p.communicate()[0].decode('UTF-8', 'replace') - - -def get_supported_python_versions(repo_path='.'): - setup_py = os.path.join(repo_path, 'setup.py') - classifiers = get_setup_py_keyword(setup_py, 'classifiers') - if classifiers is None: - # AST parsing is complicated - classifiers = pipe("python", "setup.py", "-q", "--classifiers", - cwd=repo_path).splitlines() - return get_versions_from_classifiers(classifiers) - - -def get_versions_from_classifiers(classifiers): - # Based on - # https://github.com/mgedmin/project-summary/blob/master/summary.py#L221-L234 - prefix = 'Programming Language :: Python :: ' - impl_prefix = 'Programming Language :: Python :: Implementation :: ' - cpython = impl_prefix + 'CPython' - versions = { - s[len(prefix):].replace(' :: Only', '').rstrip() - for s in classifiers - if s.startswith(prefix) and s[len(prefix):len(prefix) + 1].isdigit() - } | { - s[len(impl_prefix):].rstrip() - for s in classifiers - if s.startswith(impl_prefix) and s != cpython - } - for major in '2', '3': - if major in versions and any( - v.startswith(f'{major}.') for v in versions): - versions.remove(major) - return sorted(versions) - - -def get_python_requires(setup_py='setup.py'): - python_requires = get_setup_py_keyword(setup_py, 'python_requires') - if python_requires is None: - return None - return parse_python_requires(python_requires) - - -def get_setup_py_keyword(setup_py, keyword): - with open(setup_py) as f: - try: - tree = ast.parse(f.read(), setup_py) - except SyntaxError as error: - warn(f'Could not parse {setup_py}: {error}') - return None - node = find_call_kwarg_in_ast(tree, 'setup', keyword) - return node and eval_ast_node(node, keyword) - - -def find_call_kwarg_in_ast(tree, funcname, keyword, filename='setup.py'): - for node in ast.walk(tree): - if (isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == funcname): - for kwarg in node.keywords: - if kwarg.arg == keyword: - return kwarg.value - else: - return None - else: - warn(f'Could not find {funcname}() call in {filename}') - return None - - -def eval_ast_node(node, keyword): - if isinstance(node, ast.Str): - return node.s - if isinstance(node, (ast.List, ast.Tuple)): - try: - return ast.literal_eval(node) - except ValueError: - pass - if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Str) - and node.func.attr == 'join'): - try: - return node.func.value.s.join(ast.literal_eval(node.args[0])) - except ValueError: - pass - warn(f'Non-literal {keyword}= passed to setup()') - return None - - -def parse_python_requires(s): - # https://www.python.org/dev/peps/pep-0440/#version-specifiers - rx = re.compile(r'^(~=|==|!=|<=|>=|<|>|===)\s*(\d+(?:\.\d+)*(?:\.\*)?)$') - - class BadConstraint(Exception): - pass - - handlers = {} - handler = partial(partial, handlers.__setitem__) - - # - # We are not doing a strict PEP-440 implementation here because if - # python_reqiures allows, say, Python 2.7.16, then we want to report that - # as Python 2.7. In each handler ``canditate`` is a two-tuple (X, Y) - # that represents any Python version between X.Y.0 and X.Y.. - # - - @handler('~=') - def compatible_version(constraint): - if len(constraint) < 2: - raise BadConstraint('~= requires a version with at least one dot') - if constraint[-1] == '*': - raise BadConstraint('~= does not allow a .*') - return lambda candidate: candidate == constraint[:2] - - @handler('==') - def matching_version(constraint): - # we know len(candidate) == 2 - if len(constraint) == 2 and constraint[-1] == '*': - return lambda candidate: candidate[0] == constraint[0] - elif len(constraint) == 1: - # == X should imply Python X.0 - return lambda candidate: candidate == constraint + (0,) - else: - # == X.Y.* and == X.Y.Z both imply Python X.Y - return lambda candidate: candidate == constraint[:2] - - @handler('!=') - def excluded_version(constraint): - # we know len(candidate) == 2 - if constraint[-1] != '*': - # != X or != X.Y or != X.Y.Z all are meaningless for us, because - # there exists some W != Z where we allow X.Y.W and thus allow - # Python X.Y. - return lambda candidate: True - elif len(constraint) == 2: - # != X.* excludes the entirety of a major version - return lambda candidate: candidate[0] != constraint[0] - else: - # != X.Y.* excludes one particular minor version X.Y, - # != X.Y.Z.* does not exclude anything, but it's fine, - # len(candidate) != len(constraint[:-1] so it'll be equivalent to - # True anyway. - return lambda candidate: candidate != constraint[:-1] - - @handler('>=') - def greater_or_equal_version(constraint): - if constraint[-1] == '*': - raise BadConstraint('>= does not allow a .*') - # >= X, >= X.Y, >= X.Y.Z all work out nicely because in Python - # (3, 0) >= (3,) - return lambda candidate: candidate >= constraint[:2] - - @handler('<=') - def lesser_or_equal_version(constraint): - if constraint[-1] == '*': - raise BadConstraint('<= does not allow a .*') - if len(constraint) == 1: - # <= X allows up to X.0 - return lambda candidate: candidate <= constraint + (0,) - else: - # <= X.Y[.Z] allows up to X.Y - return lambda candidate: candidate <= constraint - - @handler('>') - def greater_version(constraint): - if constraint[-1] == '*': - raise BadConstraint('> does not allow a .*') - if len(constraint) == 1: - # > X allows X+1.0 etc - return lambda candidate: candidate[0] > constraint[0] - elif len(constraint) == 2: - # > X.Y allows X.Y+1 etc - return lambda candidate: candidate > constraint - else: - # > X.Y.Z allows X.Y - return lambda candidate: candidate >= constraint[:2] - - @handler('<') - def lesser_version(constraint): - if constraint[-1] == '*': - raise BadConstraint('< does not allow a .*') - # < X, < X.Y, < X.Y.Z all work out nicely because in Python - # (3, 0) > (3,), (3, 0) == (3, 0) and (3, 0) < (3, 0, 1) - return lambda candidate: candidate < constraint - - @handler('===') - def arbitrary_version(constraint): - if constraint[-1] == '*': - raise BadConstraint('=== does not allow a .*') - # === X does not allow anything - # === X.Y throws me into confusion; will pip compare Python's X.Y.Z === - # X.Y and reject all possible values of Z? - # === X.Y.Z allows X.Y - return lambda candidate: candidate == constraint[:2] - - constraints = [] - for specifier in map(str.strip, s.split(',')): - m = rx.match(specifier) - if not m: - warn(f'Bad python_requires specifier: {specifier}') - continue - op, ver = m.groups() - ver = tuple( - int(segment) if segment != '*' else segment - for segment in ver.split('.') - ) - try: - constraints.append(handlers[op](ver)) - except BadConstraint as error: - warn(f'Bad python_requires specifier: {specifier} ({error})') - - if not constraints: - return None - - versions = [] - for major, max_minor in [ - (1, MAX_PYTHON_1_VERSION), - (2, MAX_PYTHON_2_VERSION), - (3, CURRENT_PYTHON_3_VERSION)]: - for minor in range(0, max_minor + 1): - if all(constraint((major, minor)) for constraint in constraints): - versions.append(f'{major}.{minor}') - return versions - - -def get_tox_ini_python_versions(filename=TOX_INI): - conf = configparser.ConfigParser() - try: - conf.read(filename) - envlist = conf.get('tox', 'envlist') - except configparser.Error: - return [] - envlist = parse_envlist(envlist) - return sorted(set( - tox_env_to_py_version(e) for e in envlist if e.startswith('py'))) - - -def parse_envlist(envlist): - envs = [] - for part in re.split(r'((?:[{][^}]*[}]|[^,{\s])+)|,|\s+', envlist): - # NB: part can be None - part = (part or '').strip() - if not part: - continue - envs += brace_expand(part) - return envs - - -def brace_expand(s): - m = re.match('^([^{]*)[{]([^}]*)[}](.*)$', s) - if not m: - return [s] - left = m.group(1) - right = m.group(3) - res = [] - for alt in m.group(2).split(','): - res += brace_expand(left + alt + right) - return res - - -def tox_env_to_py_version(env): - if '-' in env: - # e.g. py34-coverage, pypy-subunit - env = env.partition('-')[0] - if env.startswith('pypy'): - return 'PyPy' + env[4:] - elif env.startswith('py') and len(env) >= 4: - return f'{env[2]}.{env[3:]}' - else: - return env - - -def get_travis_yml_python_versions(filename=TRAVIS_YML): - with open(filename) as fp: - conf = yaml.safe_load(fp) - versions = [] - if 'python' in conf: - versions += map(travis_normalize_py_version, conf['python']) - if 'matrix' in conf and 'include' in conf['matrix']: - for job in conf['matrix']['include']: - if 'python' in job: - versions.append(travis_normalize_py_version(job['python'])) - if 'jobs' in conf and 'include' in conf['jobs']: - for job in conf['jobs']['include']: - if 'python' in job: - versions.append(travis_normalize_py_version(job['python'])) - if 'env' in conf: - toxenvs = [] - for env in conf['env']: - if env.startswith('TOXENV='): - toxenvs.extend(parse_envlist(env.partition('=')[-1])) - versions.extend( - tox_env_to_py_version(e) for e in toxenvs if e.startswith('py')) - return sorted(set(versions)) - - -def travis_normalize_py_version(v): - v = str(v) - if v.startswith('pypy3'): - # could be pypy3, pypy3.5, pypy3.5-5.10.0 - return 'PyPy3' - elif v.startswith('pypy'): - # could be pypy, pypy2, pypy2.7, pypy2.7-5.10.0 - return 'PyPy' - else: - return v - - -def get_appveyor_yml_python_versions(filename=APPVEYOR_YML): - with open(filename) as fp: - conf = yaml.safe_load(fp) - # There's more than one way of doing this, I'm setting %PYTHON% to - # the directory that has a Python interpreter (C:\PythonXY) - versions = [] - for env in conf['environment']['matrix']: - for var, value in env.items(): - if var.lower() == 'python': - versions.append(appveyor_normalize_py_version(value)) - elif var == 'TOXENV': - toxenvs = parse_envlist(value) - versions.extend( - tox_env_to_py_version(e) - for e in toxenvs if e.startswith('py')) - return sorted(set(versions)) - - -def appveyor_normalize_py_version(ver): - ver = str(ver).lower() - if ver.startswith('c:\\python'): - ver = ver[len('c:\\python'):] - if ver.endswith('\\'): - ver = ver[:-1] - if ver.endswith('-x64'): - ver = ver[:-len('-x64')] - assert len(ver) >= 2 and ver[:2].isdigit() - return f'{ver[0]}.{ver[1:]}' - - -def get_manylinux_python_versions(filename=MANYLINUX_INSTALL_SH): - magic = re.compile(r'.*\[\[ "\$\{PYBIN\}" == \*"cp(\d)(\d)"\* \]\]') - versions = [] - with open(filename) as fp: - for line in fp: - m = magic.match(line) - if m: - versions.append('{}.{}'.format(*m.groups())) - return sorted(set(versions)) - - -def important(versions): - upcoming_release = f'3.{CURRENT_PYTHON_3_VERSION + 1}' - return { - v for v in versions - if not v.startswith(('PyPy', 'Jython')) and v != 'nightly' - and not v.endswith('-dev') and v != upcoming_release - } - - -def parse_expect(v): - versions = set() - - for part in v.split(','): - if '-' in part: - lo, hi = part.split('-', 1) - else: - lo = hi = part - - lo_major, lo_minor = map(int, lo.split('.', 1)) - hi_major, hi_minor = map(int, hi.split('.', 1)) - - if lo_major != hi_major: - raise ValueError(f'bad range: {part} ({lo_major} != {hi_major})') - - for v in range(lo_minor, hi_minor + 1): - versions.add(f'{lo_major}.{v}') - - return sorted(versions) - - -def is_package(where='.'): - setup_py = os.path.join(where, 'setup.py') - return os.path.exists(setup_py) - - -def check(where='.', *, print=print, expect=None): - - if not os.path.isdir(where): - print("not a directory") - return None - - setup_py = os.path.join(where, 'setup.py') - if not os.path.exists(setup_py): - print("no setup.py -- not a Python package?") - return None - - sources = [ - ('setup.py', get_supported_python_versions, None), - ('- python_requires', get_python_requires, 'setup.py'), - (TOX_INI, get_tox_ini_python_versions, TOX_INI), - (TRAVIS_YML, get_travis_yml_python_versions, TRAVIS_YML), - (APPVEYOR_YML, get_appveyor_yml_python_versions, APPVEYOR_YML), - (MANYLINUX_INSTALL_SH, get_manylinux_python_versions, - MANYLINUX_INSTALL_SH), - ] - - width = max(len(title) for title, *etc in sources) + len(" says:") - - version_sets = [] - - for (title, extractor, filename) in sources: - arg = os.path.join(where, filename) if filename else where - if not os.path.exists(arg): - continue - versions = extractor(arg) - if versions is None: - continue - print(f"{title} says:".ljust(width), ", ".join(versions) or "(empty)") - version_sets.append(important(versions)) - - if not expect: - expect = version_sets[0] - else: - print("expected:".ljust(width), ', '.join(expect)) - - expect = important(expect) - return all( - expect == v for v in version_sets - ) - - -def main(): - parser = argparse.ArgumentParser( - description="verify that supported Python versions are the same" - " in setup.py, tox.ini, .travis.yml and appveyor.yml") - parser.add_argument('--version', action='version', - version="%(prog)s version " + __version__) - parser.add_argument('--expect', metavar='VERSIONS', - help='expect these versions to be supported, e.g.' - ' --expect 2.7,3.4-3.7') - parser.add_argument('--skip-non-packages', action='store_true', - help='skip arguments that are not Python packages' - ' without warning about them') - parser.add_argument('where', nargs='*', - help='directory where a Python package with a setup.py' - ' and other files is located') - args = parser.parse_args() - - try: - expect = args.expect and parse_expect(args.expect) - except ValueError: - parser.error(f"bad value for --expect: {args.expect}") - - where = args.where or ['.'] - if args.skip_non_packages: - where = [path for path in where if is_package(path)] - - multiple = len(where) > 1 - mismatches = [] - for n, path in enumerate(where): - if multiple: - if n: - print("\n") - print(f"{path}:\n") - if not check(path, expect=expect): - mismatches.append(path) - - if mismatches: - if multiple: - sys.exit(f"\n\nmismatch in {' '.join(mismatches)}!") - else: - sys.exit("\nmismatch!") - elif multiple: - print("\n\nall ok!") - - -if __name__ == '__main__': - main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3591f3c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -ra diff --git a/setup.py b/setup.py index 9ee799c..ebe2f95 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import os import re -from setuptools import setup +from setuptools import setup, find_packages here = os.path.dirname(__file__) @@ -11,8 +11,10 @@ with open(os.path.join(here, 'README.rst')) as f: long_description = f.read() +source_dir = os.path.join(here, 'src', 'check_python_versions') + metadata = {} -with open(os.path.join(here, 'check_python_versions.py')) as f: +with open(os.path.join(source_dir, '__init__.py')) as f: rx = re.compile('(__version__|__author__|__url__|__licence__) = (.*)') for line in f: m = rx.match(line) @@ -45,10 +47,11 @@ ], license='GPL', python_requires=">=3.6", - py_modules=['check_python_versions'], + packages=find_packages('src'), + package_dir={'': 'src'}, entry_points={ 'console_scripts': [ - 'check-python-versions = check_python_versions:main', + 'check-python-versions = check_python_versions.cli:main', ], }, install_requires=['pyyaml'], diff --git a/src/check_python_versions/__init__.py b/src/check_python_versions/__init__.py new file mode 100644 index 0000000..c58c0d6 --- /dev/null +++ b/src/check_python_versions/__init__.py @@ -0,0 +1,14 @@ +""" +Check supported Python versions in a Python package. + +Makes sure the set of supported Python versions is consistent between + +- setup.py PyPI classifiers +- tox.ini default env list +- .travis-ci.yml +- appveyor.yml +- (optionally) .manylinux-install.sh as used by various ZopeFoundation projects + +""" +__author__ = 'Marius Gedminas ' +__version__ = '0.12.0.dev0' diff --git a/src/check_python_versions/__main__.py b/src/check_python_versions/__main__.py new file mode 100644 index 0000000..71b440f --- /dev/null +++ b/src/check_python_versions/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == '__main__': + main() diff --git a/src/check_python_versions/cli.py b/src/check_python_versions/cli.py new file mode 100644 index 0000000..9a39e37 --- /dev/null +++ b/src/check_python_versions/cli.py @@ -0,0 +1,306 @@ +import argparse +import os +import sys +from io import StringIO + +from . import __version__ +from .utils import confirm_and_update_file, show_diff +from .versions import ( + MAX_MINOR_FOR_MAJOR, + important, + update_version_list, +) +from .parsers.python import ( + get_supported_python_versions, + get_python_requires, + update_python_requires, + update_supported_python_versions, +) +from .parsers.tox import ( + TOX_INI, + get_tox_ini_python_versions, + update_tox_ini_python_versions, +) +from .parsers.travis import ( + TRAVIS_YML, + get_travis_yml_python_versions, + update_travis_yml_python_versions, +) +from .parsers.appveyor import ( + APPVEYOR_YML, + get_appveyor_yml_python_versions, + update_appveyor_yml_python_versions, +) +from .parsers.manylinux import ( + MANYLINUX_INSTALL_SH, + get_manylinux_python_versions, + update_manylinux_python_versions, +) + +try: + import yaml +except ImportError: # pragma: nocover + # Shouldn't happen, we install_requires=['PyYAML'], but maybe someone is + # running ./check-python-versions directly from a git checkout. + yaml = None + print("PyYAML is needed for Travis CI/Appveyor support" + " (apt install python3-yaml)") + + +def parse_version(v): + try: + major, minor = map(int, v.split('.', 1)) + except ValueError: + raise argparse.ArgumentTypeError(f'bad version: {v}') + return (major, minor) + + +def parse_version_list(v): + versions = set() + + for part in v.split(','): + if '-' in part: + lo, hi = part.split('-', 1) + else: + lo = hi = part + + if lo and hi: + lo_major, lo_minor = parse_version(lo) + hi_major, hi_minor = parse_version(hi) + elif hi and not lo: + hi_major, hi_minor = parse_version(hi) + lo_major, lo_minor = hi_major, 0 + elif lo and not hi: + lo_major, lo_minor = parse_version(lo) + try: + hi_major, hi_minor = lo_major, MAX_MINOR_FOR_MAJOR[lo_major] + except KeyError: + raise argparse.ArgumentTypeError( + f'bad range: {part}') + else: + raise argparse.ArgumentTypeError( + f'bad range: {part}') + + if lo_major != hi_major: + raise argparse.ArgumentTypeError( + f'bad range: {part} ({lo_major} != {hi_major})') + + for v in range(lo_minor, hi_minor + 1): + versions.add(f'{lo_major}.{v}') + + return sorted(versions) + + +def is_package(where='.'): + setup_py = os.path.join(where, 'setup.py') + return os.path.exists(setup_py) + + +def check_package(where='.', *, print=print): + + if not os.path.isdir(where): + print("not a directory") + return False + + setup_py = os.path.join(where, 'setup.py') + if not os.path.exists(setup_py): + print("no setup.py -- not a Python package?") + return False + + return True + + +def filename_or_replacement(pathname, replacements): + if replacements and pathname in replacements: + new_lines = replacements[pathname] + buf = StringIO("".join(new_lines)) + buf.name = pathname + return buf + else: + return pathname + + +def check_versions(where='.', *, print=print, expect=None, replacements=None, + only=None): + + sources = [ + ('setup.py', get_supported_python_versions, 'setup.py'), + ('- python_requires', get_python_requires, 'setup.py'), + (TOX_INI, get_tox_ini_python_versions, TOX_INI), + (TRAVIS_YML, get_travis_yml_python_versions, TRAVIS_YML), + (APPVEYOR_YML, get_appveyor_yml_python_versions, APPVEYOR_YML), + (MANYLINUX_INSTALL_SH, get_manylinux_python_versions, + MANYLINUX_INSTALL_SH), + ] + + width = max(len(title) for title, *etc in sources) + len(" says:") + + version_sets = [] + + for (title, extractor, filename) in sources: + if only and filename not in only: + continue + pathname = os.path.join(where, filename) + if not os.path.exists(pathname): + continue + versions = extractor(filename_or_replacement(pathname, replacements)) + if versions is None: + continue + print(f"{title} says:".ljust(width), ", ".join(versions) or "(empty)") + version_sets.append(important(versions)) + + if not expect: + expect = version_sets[0] + else: + print("expected:".ljust(width), ', '.join(expect)) + + expect = important(expect) + return all( + expect == v for v in version_sets + ) + + +def update_versions(where='.', *, add=None, drop=None, update=None, + diff=False, dry_run=False, only=None): + + sources = [ + ('setup.py', get_supported_python_versions, + update_supported_python_versions), + ('setup.py', get_python_requires, + update_python_requires), + (TOX_INI, get_tox_ini_python_versions, + update_tox_ini_python_versions), + (TRAVIS_YML, get_travis_yml_python_versions, + update_travis_yml_python_versions), + (APPVEYOR_YML, get_appveyor_yml_python_versions, + update_appveyor_yml_python_versions), + (MANYLINUX_INSTALL_SH, get_manylinux_python_versions, + update_manylinux_python_versions), + # TODO: CHANGES.rst + ] + replacements = {} + + for (filename, extractor, updater) in sources: + if only and filename not in only: + continue + pathname = os.path.join(where, filename) + if not os.path.exists(pathname): + continue + versions = extractor(filename_or_replacement(pathname, replacements)) + if versions is None: + continue + + versions = sorted(important(versions)) + new_versions = update_version_list( + versions, add=add, drop=drop, update=update) + if versions != new_versions: + fp = filename_or_replacement(pathname, replacements) + new_lines = updater(fp, new_versions) + if new_lines is not None: + if diff: + fp = filename_or_replacement(pathname, replacements) + show_diff(fp, new_lines) + if dry_run: + replacements[pathname] = new_lines + if not diff and not dry_run: + confirm_and_update_file(pathname, new_lines) + + return replacements + + +def _main(): + parser = argparse.ArgumentParser( + description="verify that supported Python versions are the same" + " in setup.py, tox.ini, .travis.yml and appveyor.yml") + parser.add_argument('--version', action='version', + version="%(prog)s version " + __version__) + parser.add_argument('--expect', metavar='VERSIONS', + type=parse_version_list, + help='expect these versions to be supported, e.g.' + ' --expect 2.7,3.5-3.7') + parser.add_argument('--skip-non-packages', action='store_true', + help='skip arguments that are not Python packages' + ' without warning about them') + parser.add_argument('--only', + help='check only the specified files' + ' (comma-separated list)') + parser.add_argument('where', nargs='*', + help='directory where a Python package with a setup.py' + ' and other files is located') + group = parser.add_argument_group( + "updating supported version lists (EXPERIMENTAL)") + group.add_argument('--add', metavar='VERSIONS', type=parse_version_list, + help='add these versions to supported ones, e.g' + ' --add 3.8') + group.add_argument('--drop', metavar='VERSIONS', type=parse_version_list, + help='drop these versions from supported ones, e.g' + ' --drop 2.6,3.4') + group.add_argument('--update', metavar='VERSIONS', type=parse_version_list, + help='update the set of supported versions, e.g.' + ' --update 2.7,3.5-3.7') + group.add_argument('--diff', action='store_true', + help='show a diff of proposed changes') + group.add_argument('--dry-run', action='store_true', + help='verify proposed changes without' + ' writing them to disk') + args = parser.parse_args() + + if args.update and args.add: + parser.error("argument --add: not allowed with argument --update") + if args.update and args.drop: + parser.error("argument --drop: not allowed with argument --update") + if args.diff and not (args.update or args.add or args.drop): + parser.error( + "argument --diff: not allowed without --update/--add/--drop") + if args.dry_run and not (args.update or args.add or args.drop): + parser.error( + "argument --dry-run: not allowed without --update/--add/--drop") + if args.expect and args.diff and not args.dry_run: + parser.error( + "argument --expect: not allowed with --diff," + " unless you also add --dry-run") + + where = args.where or ['.'] + if args.skip_non_packages: + where = [path for path in where if is_package(path)] + + only = [a.strip() for a in args.only.split(',')] if args.only else None + + multiple = len(where) > 1 + mismatches = [] + for n, path in enumerate(where): + if multiple and (not args.diff or args.dry_run): + if n: + print("\n") + print(f"{path}:\n") + if not check_package(path): + mismatches.append(path) + continue + replacements = {} + if args.add or args.drop or args.update: + replacements = update_versions( + path, add=args.add, drop=args.drop, + update=args.update, diff=args.diff, + dry_run=args.dry_run, only=only) + if not args.diff or args.dry_run: + if not check_versions(path, expect=args.expect, + replacements=replacements, + only=only): + mismatches.append(path) + continue + + if not args.diff or args.dry_run: + if mismatches: + if multiple: + sys.exit(f"\n\nmismatch in {' '.join(mismatches)}!") + else: + sys.exit("\nmismatch!") + elif multiple: + print("\n\nall ok!") + + +def main(): + try: + _main() + except KeyboardInterrupt: + sys.exit(2) diff --git a/src/check_python_versions/parsers/__init__.py b/src/check_python_versions/parsers/__init__.py new file mode 100644 index 0000000..4014849 --- /dev/null +++ b/src/check_python_versions/parsers/__init__.py @@ -0,0 +1 @@ +# make a package diff --git a/src/check_python_versions/parsers/appveyor.py b/src/check_python_versions/parsers/appveyor.py new file mode 100644 index 0000000..8b652f8 --- /dev/null +++ b/src/check_python_versions/parsers/appveyor.py @@ -0,0 +1,122 @@ +from io import StringIO + +try: + import yaml +except ImportError: # pragma: nocover + yaml = None + +from .tox import parse_envlist, tox_env_to_py_version +from .travis import update_yaml_list +from ..utils import open_file, warn + + +APPVEYOR_YML = 'appveyor.yml' + + +def get_appveyor_yml_python_versions(filename=APPVEYOR_YML): + with open_file(filename) as fp: + conf = yaml.safe_load(fp) + # There's more than one way of doing this, I'm setting %PYTHON% to + # the directory that has a Python interpreter (C:\PythonXY) + versions = [] + for env in conf['environment']['matrix']: + for var, value in env.items(): + if var.lower() == 'python': + versions.append(appveyor_normalize_py_version(value)) + elif var == 'TOXENV': + toxenvs = parse_envlist(value) + versions.extend( + tox_env_to_py_version(e) + for e in toxenvs if e.startswith('py')) + return sorted(set(versions)) + + +def appveyor_normalize_py_version(ver): + ver = str(ver).lower() + if ver.startswith('c:\\python'): + ver = ver[len('c:\\python'):] + if ver.endswith('\\'): + ver = ver[:-1] + if ver.endswith('-x64'): + ver = ver[:-len('-x64')] + assert len(ver) >= 2 and ver[:2].isdigit() + return f'{ver[0]}.{ver[1:]}' + + +def appveyor_detect_py_version_pattern(ver): + ver = str(ver) + pattern = '{}' + if ver.lower().startswith('c:\\python'): + pos = len('c:\\python') + prefix, ver = ver[:pos], ver[pos:] + pattern = pattern.format(f'{prefix}{{}}') + if ver.endswith('\\'): + ver = ver[:-1] + pattern = pattern.format(f'{{}}\\') + if ver.lower().endswith('-x64'): + pos = -len('-x64') + ver, suffix = ver[:pos], ver[pos:] + pattern = pattern.format(f'{{}}{suffix}') + assert len(ver) >= 2 and ver[:2].isdigit() + return pattern.format('{}{}') + + +def escape(s): + return s.replace("\\", "\\\\").replace('"', '\\"') + + +def update_appveyor_yml_python_versions(filename, new_versions): + with open_file(filename) as fp: + orig_lines = fp.readlines() + fp.seek(0) + conf = yaml.safe_load(fp) + + varname = 'PYTHON' + patterns = set() + for env in conf['environment']['matrix']: + for var, value in env.items(): + if var.lower() == 'python': + varname = var + patterns.add(appveyor_detect_py_version_pattern(value)) + break + + if not patterns: + warn(f"Did not recognize any PYTHON environments in {fp.name}") + return orig_lines + + quote = any(f'{varname}: "' in line for line in orig_lines) + + patterns = sorted(patterns) + + new_pythons = [ + pattern.format(*ver.split(".", 1)) + for ver in new_versions + for pattern in patterns + ] + + if quote: + new_environments = [ + f'{varname}: "{escape(python)}"' + for python in new_pythons + ] + else: + new_environments = [ + f'{varname}: {python}' + for python in new_pythons + ] + + def keep_complicated(value): + if value.startswith('{') and value.endswith('}'): + env = yaml.safe_load(StringIO(value)) + for var, value in env.items(): + if var.lower() == 'python': + ver = appveyor_normalize_py_version(value) + if ver in new_versions: + return True + return False + + new_lines = update_yaml_list( + orig_lines, ('environment', 'matrix'), new_environments, + keep=keep_complicated, + ) + return new_lines diff --git a/src/check_python_versions/parsers/manylinux.py b/src/check_python_versions/parsers/manylinux.py new file mode 100644 index 0000000..1d737e8 --- /dev/null +++ b/src/check_python_versions/parsers/manylinux.py @@ -0,0 +1,50 @@ +import re + +from ..utils import open_file, warn + +MANYLINUX_INSTALL_SH = '.manylinux-install.sh' + + +def get_manylinux_python_versions(filename=MANYLINUX_INSTALL_SH): + magic = re.compile(r'.*\[\[ "\$\{PYBIN\}" == \*"cp(\d)(\d)"\* \]\]') + versions = [] + with open_file(filename) as fp: + for line in fp: + m = magic.match(line) + if m: + versions.append('{}.{}'.format(*m.groups())) + return sorted(set(versions)) + + +def update_manylinux_python_versions(filename, new_versions): + magic = re.compile(r'.*\[\[ "\$\{PYBIN\}" == \*"cp(\d)(\d)"\* \]\]') + with open_file(filename) as f: + orig_lines = f.readlines() + lines = iter(enumerate(orig_lines)) + for n, line in lines: + m = magic.match(line) + if m: + start = n + break + else: + warn(f'Failed to understand {f.name}') + return orig_lines + for n, line in lines: + m = magic.match(line) + if not m: + end = n + break + else: + warn(f'Failed to understand {f.name}') + return orig_lines + + indent = ' ' * 4 + conditions = f' || \\\n{indent} '.join( + f'[[ "${{PYBIN}}" == *"cp{ver.replace(".", "")}"* ]]' + for ver in new_versions + ) + new_lines = orig_lines[:start] + ( + f'{indent}if {conditions}; then\n' + ).splitlines(True) + orig_lines[end:] + + return new_lines diff --git a/src/check_python_versions/parsers/python.py b/src/check_python_versions/parsers/python.py new file mode 100644 index 0000000..503e1f6 --- /dev/null +++ b/src/check_python_versions/parsers/python.py @@ -0,0 +1,418 @@ +import ast +import os +import re +import string +from functools import partial + +from ..utils import warn, pipe, open_file, is_file_object, get_indent +from ..versions import MAX_MINOR_FOR_MAJOR + + +def get_supported_python_versions(filename='setup.py'): + classifiers = get_setup_py_keyword(filename, 'classifiers') + if classifiers is None and not is_file_object(filename): + # AST parsing is complicated + setup_py = os.path.basename(filename) + classifiers = pipe("python", setup_py, "-q", "--classifiers", + cwd=os.path.dirname(filename)).splitlines() + if classifiers is None: + return [] + return get_versions_from_classifiers(classifiers) + + +def get_python_requires(setup_py='setup.py'): + python_requires = get_setup_py_keyword(setup_py, 'python_requires') + if python_requires is None: + return None + return parse_python_requires(python_requires) + + +def is_version_classifier(s): + prefix = 'Programming Language :: Python :: ' + return s.startswith(prefix) and s[len(prefix):len(prefix) + 1].isdigit() + + +def is_major_version_classifier(s): + prefix = 'Programming Language :: Python :: ' + return ( + s.startswith(prefix) + and s[len(prefix):].replace(' :: Only', '').isdigit() + ) + + +def get_versions_from_classifiers(classifiers): + # Based on + # https://github.com/mgedmin/project-summary/blob/master/summary.py#L221-L234 + prefix = 'Programming Language :: Python :: ' + impl_prefix = 'Programming Language :: Python :: Implementation :: ' + cpython = impl_prefix + 'CPython' + versions = { + s[len(prefix):].replace(' :: Only', '').rstrip() + for s in classifiers + if is_version_classifier(s) + } | { + s[len(impl_prefix):].rstrip() + for s in classifiers + if s.startswith(impl_prefix) and s != cpython + } + for major in '2', '3': + if major in versions and any( + v.startswith(f'{major}.') for v in versions): + versions.remove(major) + return sorted(versions) + + +def update_classifiers(classifiers, new_versions): + prefix = 'Programming Language :: Python :: ' + + for pos, s in enumerate(classifiers): + if is_version_classifier(s): + break + else: + pos = len(classifiers) + + if any(map(is_major_version_classifier, classifiers)): + new_versions = sorted( + set(new_versions).union( + v.partition('.')[0] for v in new_versions + ) + ) + + classifiers = [ + s for s in classifiers if not is_version_classifier(s) + ] + new_classifiers = [ + f'{prefix}{version}' + for version in new_versions + ] + classifiers[pos:pos] = new_classifiers + return classifiers + + +def update_supported_python_versions(filename, new_versions): + classifiers = get_setup_py_keyword(filename, 'classifiers') + if classifiers is None: + return None + new_classifiers = update_classifiers(classifiers, new_versions) + return update_setup_py_keyword(filename, 'classifiers', new_classifiers) + + +def update_python_requires(filename, new_versions): + python_requires = get_setup_py_keyword(filename, 'python_requires') + if python_requires is None: + return None + new_python_requires = compute_python_requires(new_versions) + if is_file_object(filename): + filename.seek(0) + return update_setup_py_keyword(filename, 'python_requires', + new_python_requires) + + +def get_setup_py_keyword(setup_py, keyword): + with open_file(setup_py) as f: + try: + tree = ast.parse(f.read(), f.name) + except SyntaxError as error: + warn(f'Could not parse {f.name}: {error}') + return None + node = find_call_kwarg_in_ast(tree, 'setup', keyword) + return node and eval_ast_node(node, keyword) + + +def update_setup_py_keyword(setup_py, keyword, new_value): + with open_file(setup_py) as f: + lines = f.readlines() + new_lines = update_call_arg_in_source(lines, 'setup', keyword, new_value) + return new_lines + + +def to_literal(value, quote_style='"'): + # Because I don't want to deal with quoting, I'll require all values + # to contain only safe characters (i.e. no ' or " or \). Except some + # PyPI classifiers do include ' so I need to handle that at least. + # And python_requires uses all sorts of comparisons like ~= 3.7.* + safe_chars = string.ascii_letters + string.digits + " .:,-=>\\s*){re.escape(keyword)}(?P\\s*=\\s*)(?P.*)' + ) + for n, line in lines: + m = rx.match(line) + if m: + first_match = m + eq = m.group('eq') + first_indent = m.group('indent') + break + else: + warn(f'Did not find {keyword}= argument in {function}() call') + return source_lines + + quote_style = '"' + + if isinstance(new_value, list): + start = n + indent = first_indent + ' ' * 4 + if first_match.group('rest').startswith('[]'): + fix_closing_bracket = True + end = n + 1 + else: + must_fix_indents = first_match.group('rest').rstrip() != '[' + fix_closing_bracket = False + for n, line in lines: + stripped = line.lstrip() + if stripped.startswith(']'): + end = n + break + elif stripped: + if not must_fix_indents: + indent = get_indent(line) + if stripped[0] in ('"', "'"): + quote_style = stripped[0] + if line.rstrip().endswith('],'): + end = n + 1 + fix_closing_bracket = True + break + else: + warn( + f'Did not understand {keyword}= formatting' + f' in {function}() call' + ) + return source_lines + else: + start = n + end = n + 1 + + if isinstance(new_value, list): + return source_lines[:start] + [ + f"{first_indent}{keyword}{eq}[\n" + ] + [ + f"{indent}{to_literal(value, quote_style)},\n" + for value in new_value + ] + ([ + f"{first_indent}],\n" + ] if fix_closing_bracket else [ + ]) + source_lines[end:] + else: + if first_match.group('rest').startswith("'"): + quote_style = "'" + new_value_quoted = to_literal(new_value, quote_style) + return source_lines[:start] + [ + f"{first_indent}{keyword}{eq}{new_value_quoted},\n" + ] + source_lines[end:] + + +def find_call_kwarg_in_ast(tree, funcname, keyword, filename='setup.py'): + for node in ast.walk(tree): + if (isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == funcname): + for kwarg in node.keywords: + if kwarg.arg == keyword: + return kwarg.value + else: + return None + else: + warn(f'Could not find {funcname}() call in {filename}') + return None + + +def eval_ast_node(node, keyword): + if isinstance(node, ast.Str): + return node.s + if isinstance(node, (ast.List, ast.Tuple)): + try: + return ast.literal_eval(node) + except ValueError: + if any(isinstance(element, ast.Str) for element in node.elts): + # Let's try our best!!! + warn(f'Non-literal {keyword}= passed to setup(),' + ' skipping some values') + return [ + eval_ast_node(element, keyword) + for element in node.elts + if isinstance(element, ast.Str) + ] + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Str) + and node.func.attr == 'join'): + try: + return node.func.value.s.join(ast.literal_eval(node.args[0])) + except ValueError: + pass + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = eval_ast_node(node.left, keyword) + right = eval_ast_node(node.right, keyword) + if left is not None and right is not None: + return left + right + if left is None and right is not None: + return right + if left is not None and right is None: + return left + warn(f'Non-literal {keyword}= passed to setup()') + return None + + +def parse_python_requires(s): + # https://www.python.org/dev/peps/pep-0440/#version-specifiers + rx = re.compile(r'^(~=|==|!=|<=|>=|<|>|===)\s*(\d+(?:\.\d+)*(?:\.\*)?)$') + + class BadConstraint(Exception): + pass + + handlers = {} + handler = partial(partial, handlers.__setitem__) + + # + # We are not doing a strict PEP-440 implementation here because if + # python_reqiures allows, say, Python 2.7.16, then we want to report that + # as Python 2.7. In each handler ``canditate`` is a two-tuple (X, Y) + # that represents any Python version between X.Y.0 and X.Y.. + # + + @handler('~=') + def compatible_version(constraint): + if len(constraint) < 2: + raise BadConstraint('~= requires a version with at least one dot') + if constraint[-1] == '*': + raise BadConstraint('~= does not allow a .*') + return lambda candidate: candidate == constraint[:2] + + @handler('==') + def matching_version(constraint): + # we know len(candidate) == 2 + if len(constraint) == 2 and constraint[-1] == '*': + return lambda candidate: candidate[0] == constraint[0] + elif len(constraint) == 1: + # == X should imply Python X.0 + return lambda candidate: candidate == constraint + (0,) + else: + # == X.Y.* and == X.Y.Z both imply Python X.Y + return lambda candidate: candidate == constraint[:2] + + @handler('!=') + def excluded_version(constraint): + # we know len(candidate) == 2 + if constraint[-1] != '*': + # != X or != X.Y or != X.Y.Z all are meaningless for us, because + # there exists some W != Z where we allow X.Y.W and thus allow + # Python X.Y. + return lambda candidate: True + elif len(constraint) == 2: + # != X.* excludes the entirety of a major version + return lambda candidate: candidate[0] != constraint[0] + else: + # != X.Y.* excludes one particular minor version X.Y, + # != X.Y.Z.* does not exclude anything, but it's fine, + # len(candidate) != len(constraint[:-1] so it'll be equivalent to + # True anyway. + return lambda candidate: candidate != constraint[:-1] + + @handler('>=') + def greater_or_equal_version(constraint): + if constraint[-1] == '*': + raise BadConstraint('>= does not allow a .*') + # >= X, >= X.Y, >= X.Y.Z all work out nicely because in Python + # (3, 0) >= (3,) + return lambda candidate: candidate >= constraint[:2] + + @handler('<=') + def lesser_or_equal_version(constraint): + if constraint[-1] == '*': + raise BadConstraint('<= does not allow a .*') + if len(constraint) == 1: + # <= X allows up to X.0 + return lambda candidate: candidate <= constraint + (0,) + else: + # <= X.Y[.Z] allows up to X.Y + return lambda candidate: candidate <= constraint + + @handler('>') + def greater_version(constraint): + if constraint[-1] == '*': + raise BadConstraint('> does not allow a .*') + if len(constraint) == 1: + # > X allows X+1.0 etc + return lambda candidate: candidate[0] > constraint[0] + elif len(constraint) == 2: + # > X.Y allows X.Y+1 etc + return lambda candidate: candidate > constraint + else: + # > X.Y.Z allows X.Y + return lambda candidate: candidate >= constraint[:2] + + @handler('<') + def lesser_version(constraint): + if constraint[-1] == '*': + raise BadConstraint('< does not allow a .*') + # < X, < X.Y, < X.Y.Z all work out nicely because in Python + # (3, 0) > (3,), (3, 0) == (3, 0) and (3, 0) < (3, 0, 1) + return lambda candidate: candidate < constraint + + @handler('===') + def arbitrary_version(constraint): + if constraint[-1] == '*': + raise BadConstraint('=== does not allow a .*') + # === X does not allow anything + # === X.Y throws me into confusion; will pip compare Python's X.Y.Z === + # X.Y and reject all possible values of Z? + # === X.Y.Z allows X.Y + return lambda candidate: candidate == constraint[:2] + + constraints = [] + for specifier in map(str.strip, s.split(',')): + m = rx.match(specifier) + if not m: + warn(f'Bad python_requires specifier: {specifier}') + continue + op, ver = m.groups() + ver = tuple( + int(segment) if segment != '*' else segment + for segment in ver.split('.') + ) + try: + constraints.append(handlers[op](ver)) + except BadConstraint as error: + warn(f'Bad python_requires specifier: {specifier} ({error})') + + if not constraints: + return None + + versions = [] + for major in sorted(MAX_MINOR_FOR_MAJOR): + for minor in range(0, MAX_MINOR_FOR_MAJOR[major] + 1): + if all(constraint((major, minor)) for constraint in constraints): + versions.append(f'{major}.{minor}') + return versions + + +def compute_python_requires(new_versions): + new_versions = set(new_versions) + if len(new_versions) == 1: + return f'=={new_versions.pop()}.*' + # XXX assumes all versions are X.Y and 3.10 will never be released + min_version = min(new_versions) + specifiers = [f'>={min_version}'] + for major in sorted(MAX_MINOR_FOR_MAJOR): + for minor in range(0, MAX_MINOR_FOR_MAJOR[major] + 1): + ver = f'{major}.{minor}' + if ver >= min_version and ver not in new_versions: + specifiers.append(f'!={ver}.*') + return ', '.join(specifiers) diff --git a/src/check_python_versions/parsers/tox.py b/src/check_python_versions/parsers/tox.py new file mode 100644 index 0000000..6cb959b --- /dev/null +++ b/src/check_python_versions/parsers/tox.py @@ -0,0 +1,146 @@ +import configparser +import re + +from ..utils import warn, open_file, get_indent +from ..versions import is_important + + +TOX_INI = 'tox.ini' + + +def get_tox_ini_python_versions(filename=TOX_INI): + conf = configparser.ConfigParser() + try: + with open_file(filename) as fp: + conf.read_file(fp) + envlist = conf.get('tox', 'envlist') + except configparser.Error: + return [] + envlist = parse_envlist(envlist) + return sorted(set( + tox_env_to_py_version(e) for e in envlist if e.startswith('py'))) + + +def parse_envlist(envlist): + envs = [] + for part in re.split(r'((?:[{][^}]*[}]|[^,{\s])+)|,|\s+', envlist): + # NB: part can be None + part = (part or '').strip() + if not part: + continue + envs += brace_expand(part) + return envs + + +def brace_expand(s): + m = re.match('^([^{]*)[{]([^}]*)[}](.*)$', s) + if not m: + return [s] + left = m.group(1) + right = m.group(3) + res = [] + for alt in m.group(2).split(','): + res += brace_expand(left + alt + right) + return res + + +def tox_env_to_py_version(env): + if '-' in env: + # e.g. py34-coverage, pypy-subunit + env = env.partition('-')[0] + if env.startswith('pypy'): + return 'PyPy' + env[4:] + elif env.startswith('py') and len(env) >= 4: + return f'{env[2]}.{env[3:]}' + else: + return env + + +def update_tox_ini_python_versions(filename, new_versions): + with open_file(filename) as fp: + orig_lines = fp.readlines() + fp.seek(0) + conf = configparser.ConfigParser() + try: + conf.read_file(fp) + envlist = conf.get('tox', 'envlist') + except configparser.Error as error: + warn(f"Could not parse {fp.name}: {error}") + return orig_lines + + new_envlist = update_tox_envlist(envlist, new_versions) + + new_lines = update_ini_setting( + orig_lines, 'tox', 'envlist', new_envlist, + ) + return new_lines + + +def update_tox_envlist(envlist, new_versions): + sep = ',' + if ', ' in envlist: + sep = ', ' + + envlist = parse_envlist(envlist) + keep = [] + for env in envlist: + if not env.startswith('py'): + keep.append(env) + continue + if not is_important(tox_env_to_py_version(env)): + keep.append(env) + continue + if '-' in env: + baseversion = tox_env_to_py_version(env) + if baseversion in new_versions: + keep.append(env) + + new_envlist = sep.join([ + f"py{ver.replace('.', '')}" + for ver in new_versions + ] + keep) + + return new_envlist + + +def update_ini_setting(orig_lines, section, key, new_value, filename=TOX_INI): + lines = iter(enumerate(orig_lines)) + for n, line in lines: + if line.startswith(f'[{section}]'): + break + else: + warn(f'Did not find [{section}] section in {filename}') + return orig_lines + + # TODO: use a regex to allow an arbitrary number of spaces around = + for n, line in lines: + if line.startswith(f'{key} ='): + start = n + break + else: + warn(f'Did not find {key}= in [{section}] in {filename}') + return orig_lines + + end = start + 1 + comments = [] + indent = ' ' + for n, line in lines: + if line.startswith(' '): + indent = get_indent(line) + end = n + 1 + elif line.lstrip().startswith('#'): + comments.append(line) + else: + break + + prefix = ' ' + firstline = orig_lines[start].strip().expandtabs().replace(' ', '') + if firstline == f'{key}=': + if end > start + 1: + prefix = f'\n{"".join(comments)}{indent}' + + new_lines = orig_lines[:start] + ( + f"{key} ={prefix}{new_value}\n" + ).splitlines(True) + orig_lines[end:] + + return new_lines diff --git a/src/check_python_versions/parsers/travis.py b/src/check_python_versions/parsers/travis.py new file mode 100644 index 0000000..677e756 --- /dev/null +++ b/src/check_python_versions/parsers/travis.py @@ -0,0 +1,254 @@ +try: + import yaml +except ImportError: # pragma: nocover + yaml = None + +from .tox import parse_envlist, tox_env_to_py_version +from ..utils import warn, open_file +from ..versions import is_important + + +TRAVIS_YML = '.travis.yml' + +XENIAL_SUPPORTED_PYPY_VERSIONS = { + 'pypy': 'pypy2.7-6.0.0', + 'pypy3': 'pypy3.5-6.0.0', +} + + +def get_travis_yml_python_versions(filename=TRAVIS_YML): + with open_file(filename) as fp: + conf = yaml.safe_load(fp) + versions = [] + if conf.get('python'): + if isinstance(conf['python'], list): + versions += map(travis_normalize_py_version, conf['python']) + else: + versions.append(travis_normalize_py_version(conf['python'])) + if 'matrix' in conf and 'include' in conf['matrix']: + for job in conf['matrix']['include']: + if 'python' in job: + versions.append(travis_normalize_py_version(job['python'])) + if 'jobs' in conf and 'include' in conf['jobs']: + for job in conf['jobs']['include']: + if 'python' in job: + versions.append(travis_normalize_py_version(job['python'])) + if 'env' in conf: + toxenvs = [] + for env in conf['env']: + if env.startswith('TOXENV='): + toxenvs.extend(parse_envlist(env.partition('=')[-1])) + versions.extend( + tox_env_to_py_version(e) for e in toxenvs if e.startswith('py')) + return sorted(set(versions)) + + +def travis_normalize_py_version(v): + v = str(v) + if v.startswith('pypy3'): + # could be pypy3, pypy3.5, pypy3.5-5.10.0 + return 'PyPy3' + elif v.startswith('pypy'): + # could be pypy, pypy2, pypy2.7, pypy2.7-5.10.0 + return 'PyPy' + else: + return v + + +def needs_xenial(v): + major, minor = map(int, v.split('.')) + return major == 3 and minor >= 7 + + +def update_travis_yml_python_versions(filename, new_versions): + with open_file(filename) as fp: + orig_lines = fp.readlines() + fp.seek(0) + conf = yaml.safe_load(fp) + new_lines = orig_lines + + # Make sure we're using dist: xenial if we want to use Python 3.7 or newer. + replacements = {} + if any(map(needs_xenial, new_versions)): + replacements.update(XENIAL_SUPPORTED_PYPY_VERSIONS) + if conf.get('dist') != 'xenial': + new_lines = drop_yaml_node(new_lines, 'dist', filename=fp.name) + new_lines = add_yaml_node(new_lines, 'dist', 'xenial', + before=('python', 'matrix', 'jobs')) + if conf.get('sudo') is False: + # sudo is ignored nowadays, but in earlier times + # you needed both dist: xenial and sudo: required + # to get Python 3.7 + new_lines = drop_yaml_node(new_lines, "sudo", filename=fp.name) + + def keep_old(ver): + return not is_important(travis_normalize_py_version(ver)) + + def keep_old_job(job): + if job.startswith('python:'): + ver = job[len('python:'):].strip() + return not is_important(travis_normalize_py_version(ver)) + else: + return True + + if conf.get('python'): + new_lines = update_yaml_list( + new_lines, "python", new_versions, filename=fp.name, keep=keep_old, + replacements=replacements, + ) + else: + for toplevel in 'matrix', 'jobs': + if 'include' not in conf.get(toplevel, {}): + continue + new_jobs = [ + f'python: {ver}' + for ver in new_versions + ] + new_lines = update_yaml_list( + new_lines, (toplevel, "include"), new_jobs, filename=fp.name, + keep=keep_old_job + ) + + # If python 3.7 was enabled via matrix.include, we've just added a + # second 3.7 entry directly to top-level python by the above code. + # So let's drop the matrix. + + if ( + conf.get('python') + and 'include' in conf.get('matrix', {}) + and all( + job.get('dist') == 'xenial' + and set(job) <= {'python', 'dist', 'sudo'} + for job in conf['matrix']['include'] + ) + ): + # XXX: this may drop too much or too little! + new_lines = drop_yaml_node(new_lines, "matrix", filename=fp.name) + + return new_lines + + +def update_yaml_list( + orig_lines, key, new_value, filename=TRAVIS_YML, keep=None, + replacements=None, +): + if not isinstance(key, tuple): + key = (key,) + + lines = iter(enumerate(orig_lines)) + current = 0 + indents = [0] + for n, line in lines: + stripped = line.lstrip() + if not stripped or stripped.startswith('#'): + continue + indent = len(line) - len(stripped) + if current >= len(indents): + indents.append(indent) + elif indent > indents[current]: + continue + else: + while current > 0 and indent < indents[current]: + del indents[current] + current -= 1 + if stripped.startswith(f'{key[current]}:'): + current += 1 + if current == len(key): + break + else: + warn(f'Did not find {".".join(key)}: setting in {filename}') + return orig_lines + + start = n + end = n + 1 + indent = 2 + list_indent = None + keep_before = [] + keep_after = [] + lines_to_keep = keep_before + kept_last = False + for n, line in lines: + stripped = line.lstrip() + line_indent = len(line) - len(stripped) + if list_indent is None and stripped.startswith('- '): + list_indent = line_indent + if stripped.startswith('- ') and line_indent == list_indent: + lines_to_keep = keep_after + indent = line_indent + end = n + 1 + value = stripped[2:].strip() + kept_last = keep and keep(value) + if kept_last: + if replacements and value in replacements: + lines_to_keep.append( + f"{' '* indent}- {replacements[value]}\n" + ) + else: + lines_to_keep.append(line) + elif stripped.startswith('#'): + lines_to_keep.append(line) + end = n + 1 + elif line_indent > indent: + if kept_last: + lines_to_keep.append(line) + end = n + 1 + elif line == '\n': + continue + elif line[0] != ' ': + break + elif list_indent is not None and line_indent < list_indent: + break + + new_lines = orig_lines[:start] + [ + f"{' ' * indents[-1]}{key[-1]}:\n" + ] + keep_before + [ + f"{' ' * indent}- {value}\n" + for value in new_value + ] + keep_after + orig_lines[end:] + return new_lines + + +def drop_yaml_node(orig_lines, key, filename=TRAVIS_YML): + lines = iter(enumerate(orig_lines)) + where = None + for n, line in lines: + if line.startswith(f'{key}:'): + if where is not None: + warn( + f"Duplicate {key}: setting in {filename}" + f" (lines {where + 1} and {n + 1})" + ) + where = n + if where is None: + return orig_lines + + lines = iter(enumerate(orig_lines[where + 1:], where + 1)) + + start = where + end = start + 1 + for n, line in lines: + if line and line[0] != ' ': + break + else: + end = n + 1 + new_lines = orig_lines[:start] + orig_lines[end:] + + return new_lines + + +def add_yaml_node(orig_lines, key, value, before=None): + lines = iter(enumerate(orig_lines)) + where = len(orig_lines) + if before: + if not isinstance(before, (list, tuple, set)): + before = (before, ) + lines = iter(enumerate(orig_lines)) + for n, line in lines: + if any(line == f'{key}:\n' for key in before): + where = n + break + + new_lines = orig_lines[:where] + [ + f'{key}: {value}\n' + ] + orig_lines[where:] + return new_lines diff --git a/src/check_python_versions/utils.py b/src/check_python_versions/utils.py new file mode 100644 index 0000000..6649d68 --- /dev/null +++ b/src/check_python_versions/utils.py @@ -0,0 +1,89 @@ +import difflib +import logging +import os +import stat +import subprocess +import sys +from contextlib import contextmanager + + +log = logging.getLogger('check-python-versions') + + +def get_indent(line): + return line[:-len(line.lstrip())] + + +def warn(msg): + print(msg, file=sys.stderr) + + +def is_file_object(filename_or_file_object): + return hasattr(filename_or_file_object, 'read') + + +@contextmanager +def open_file(filename_or_file_object): + if is_file_object(filename_or_file_object): + yield filename_or_file_object + else: + with open(filename_or_file_object) as fp: + yield fp + + +def pipe(*cmd, **kwargs): + if 'cwd' in kwargs: + log.debug('EXEC cd %s && %s', kwargs['cwd'], ' '.join(cmd)) + else: + log.debug('EXEC %s', ' '.join(cmd)) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, **kwargs) + return p.communicate()[0].decode('UTF-8', 'replace') + + +def confirm_and_update_file(filename, new_lines): + if (show_diff(filename, new_lines) + and confirm(f"Write changes to {filename}?")): + mode = stat.S_IMODE(os.stat(filename).st_mode) + tempfile = filename + '.tmp' + with open(tempfile, 'w') as f: + if hasattr(os, 'fchmod'): + os.fchmod(f.fileno(), mode) + else: # pragma: windows + # Windows, what else? + os.chmod(tempfile, mode) + f.writelines(new_lines) + try: + os.rename(tempfile, filename) + except FileExistsError: # pragma: windows + # No atomic replace on Windows + os.unlink(filename) + os.rename(tempfile, filename) + + +def show_diff(filename_or_file_object, new_lines): + with open_file(filename_or_file_object) as f: + old_lines = f.readlines() + print_diff(old_lines, new_lines, f.name) + return old_lines != new_lines + + +def print_diff(a, b, filename): + print(''.join(difflib.unified_diff( + a, b, + filename, filename, + "(original)", "(updated)", + ))) + + +def confirm(prompt): + while True: + try: + answer = input(f'{prompt} [y/N] ').strip().lower() + except EOFError: + answer = "" + if answer == 'y': + print() + return True + if answer == 'n' or not answer: + print() + return False diff --git a/src/check_python_versions/versions.py b/src/check_python_versions/versions.py new file mode 100644 index 0000000..9c100cb --- /dev/null +++ b/src/check_python_versions/versions.py @@ -0,0 +1,31 @@ +MAX_PYTHON_1_VERSION = 6 # i.e. 1.6 +MAX_PYTHON_2_VERSION = 7 # i.e. 2.7 +CURRENT_PYTHON_3_VERSION = 7 # i.e. 3.7 + +MAX_MINOR_FOR_MAJOR = { + 1: MAX_PYTHON_1_VERSION, + 2: MAX_PYTHON_2_VERSION, + 3: CURRENT_PYTHON_3_VERSION, +} + + +def is_important(v): + upcoming_release = f'3.{CURRENT_PYTHON_3_VERSION + 1}' + return ( + not v.startswith(('PyPy', 'Jython')) and v != 'nightly' + and not v.endswith('-dev') and v != upcoming_release + ) + + +def important(versions): + return { + v for v in versions + if is_important(v) + } + + +def update_version_list(versions, add=None, drop=None, update=None): + if update: + return sorted(update) + else: + return sorted(set(versions).union(add or ()).difference(drop or ())) diff --git a/tests.py b/tests.py deleted file mode 100644 index a43baed..0000000 --- a/tests.py +++ /dev/null @@ -1,682 +0,0 @@ -import ast -import os -import sys -import textwrap - -import pytest - -import check_python_versions as cpv - - -needs_pyyaml = pytest.mark.skipIf(cpv.yaml is None, "PyYAML not installed") - - -def test_pipe(): - assert cpv.pipe('echo', 'hi') == 'hi\n' - - -def test_get_supported_python_versions(tmp_path): - (tmp_path / "setup.py").write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - classifiers=[ - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.6', - ], - ) - """)) - assert cpv.get_supported_python_versions(tmp_path) == ['2.7', '3.6'] - - -def test_get_supported_python_versions_computed(tmp_path): - (tmp_path / "setup.py").write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - classifiers=[ - 'Programming Language :: Python :: %s' % v - for v in ['2.7', '3.7'] - ], - ) - """)) - assert cpv.get_supported_python_versions(tmp_path) == ['2.7', '3.7'] - - -def test_get_versions_from_classifiers(): - assert cpv.get_versions_from_classifiers([ - 'Development Status :: 4 - Beta', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License (GPL)', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - ]) == ['2.7', '3.6', '3.7', 'PyPy'] - - -def test_get_versions_from_classifiers_major_only(): - assert cpv.get_versions_from_classifiers([ - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 3', - ]) == ['2', '3'] - - -def test_get_versions_from_classifiers_with_only_suffix(): - assert cpv.get_versions_from_classifiers([ - 'Programming Language :: Python :: 2 :: Only', - ]) == ['2'] - - -def test_get_versions_from_classifiers_with_trailing_whitespace(): - # I was surprised too that this is allowed! - assert cpv.get_versions_from_classifiers([ - 'Programming Language :: Python :: 3.6 ', - ]) == ['3.6'] - - -def test_get_python_requires(tmp_path, monkeypatch): - setup_py = tmp_path / "setup.py" - setup_py.write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - python_requires='>= 3.6', - ) - """)) - monkeypatch.setattr(cpv, 'CURRENT_PYTHON_3_VERSION', 7) - assert cpv.get_python_requires(setup_py) == ['3.6', '3.7'] - - -def test_get_python_requires_not_specified(tmp_path, capsys): - setup_py = tmp_path / "setup.py" - setup_py.write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - ) - """)) - assert cpv.get_python_requires(setup_py) is None - assert capsys.readouterr().err == '' - - -def test_get_setup_py_keyword_syntax_error(tmp_path, capsys): - setup_py = tmp_path / "setup.py" - setup_py.write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - # uh do I need to close parens? what if I forget? ;) - """)) - assert cpv.get_setup_py_keyword(setup_py, 'name') is None - assert 'Could not parse' in capsys.readouterr().err - - -def test_find_call_kwarg_in_ast(): - tree = ast.parse('foo(bar="foo")') - ast.dump(tree) - node = cpv.find_call_kwarg_in_ast(tree, 'foo', 'bar') - assert isinstance(node, ast.Str) - assert node.s == "foo" - - -def test_find_call_kwarg_in_ast_no_arg(capsys): - tree = ast.parse('foo(baz="foo")') - ast.dump(tree) - node = cpv.find_call_kwarg_in_ast(tree, 'foo', 'bar') - assert node is None - assert capsys.readouterr().err == '' - - -def test_find_call_kwarg_in_ast_no_call(capsys): - tree = ast.parse('fooo(bar="foo")') - ast.dump(tree) - node = cpv.find_call_kwarg_in_ast(tree, 'foo', 'bar') - assert node is None - assert 'Could not find foo() call in setup.py' in capsys.readouterr().err - - -@pytest.mark.parametrize('code, expected', [ - ('"hi"', "hi"), - ('"hi\\n"', "hi\n"), - ('["a", "b"]', ["a", "b"]), - ('("a", "b")', ("a", "b")), - ('"-".join(["a", "b"])', "a-b"), -]) -def test_eval_ast_node(code, expected): - tree = ast.parse(f'foo(bar={code})') - node = cpv.find_call_kwarg_in_ast(tree, 'foo', 'bar') - assert node is not None - assert cpv.eval_ast_node(node, 'bar') == expected - - -@pytest.mark.parametrize('code', [ - '[2 * 2]', - '"".join([2 * 2])', -]) -def test_eval_ast_node_failures(code, capsys): - tree = ast.parse(f'foo(bar={code})') - node = cpv.find_call_kwarg_in_ast(tree, 'foo', 'bar') - assert cpv.eval_ast_node(node, 'bar') is None - assert 'Non-literal bar= passed to setup()' in capsys.readouterr().err - - -@pytest.mark.parametrize('constraint, result', [ - ('~= 2.7', ['2.7']), - ('~= 2.7.12', ['2.7']), -]) -def test_parse_python_requires_approximately(constraint, result): - assert cpv.parse_python_requires(constraint) == result - - -def test_parse_python_requires_approximately_not_enough_dots(capsys): - assert cpv.parse_python_requires('~= 2') is None - assert ( - 'Bad python_requires specifier: ~= 2' - ' (~= requires a version with at least one dot)' - in capsys.readouterr().err - ) - - -@pytest.mark.parametrize('constraint, result', [ - ('== 2.7', ['2.7']), - ('== 2.7.*', ['2.7']), - ('== 2.7.12', ['2.7']), - ('== 2.*, >= 2.6', ['2.6', '2.7']), - ('== 3.0', ['3.0']), - ('== 3', ['3.0']), -]) -def test_parse_python_requires_matching_version(constraint, result): - assert cpv.parse_python_requires(constraint) == result - - -def test_parse_python_requires_greater_than(monkeypatch): - monkeypatch.setattr(cpv, 'CURRENT_PYTHON_3_VERSION', 8) - assert cpv.parse_python_requires('>= 3.6') == ['3.6', '3.7', '3.8'] - - -@pytest.mark.parametrize('constraint, result', [ - ('>= 2.7, != 3.*', ['2.7']), - ('>= 2.7.12, != 3.*', ['2.7']), - ('>= 2.7, != 3.0.*, != 3.1.*', ['2.7', '3.2', '3.3']), - # != 3.2 means we reject 3.2.0 but still accept any other 3.2.x - ('>= 2.7, != 3.2', ['2.7', '3.0', '3.1', '3.2', '3.3']), - ('>= 2.7, != 3.2.1', ['2.7', '3.0', '3.1', '3.2', '3.3']), - ('>= 2.7, <= 3', ['2.7', '3.0']), - ('>= 2.7, <= 3.2', ['2.7', '3.0', '3.1', '3.2']), - ('>= 2.7, <= 3.2.1', ['2.7', '3.0', '3.1', '3.2']), - ('>= 3', ['3.0', '3.1', '3.2', '3.3']), -]) -def test_parse_python_requires_greater_than_with_exceptions( - monkeypatch, constraint, result -): - monkeypatch.setattr(cpv, 'CURRENT_PYTHON_3_VERSION', 3) - assert cpv.parse_python_requires(constraint) == result - - -def test_parse_python_requires_multiple_greater_than(monkeypatch): - monkeypatch.setattr(cpv, 'CURRENT_PYTHON_3_VERSION', 7) - assert cpv.parse_python_requires('>= 2.7, >= 3.6') == ['3.6', '3.7'] - - -@pytest.mark.parametrize('constraint, result', [ - ('> 2, < 3.1', ['3.0']), - ('> 2.6, < 3', ['2.7']), - ('> 2.7.12, < 3', ['2.7']), - ('> 2.7.12, < 3.0', ['2.7']), - ('> 2.7.12, < 3.1', ['2.7', '3.0']), - ('> 2.7.12, < 3.0.1', ['2.7', '3.0']), -]) -def test_parse_python_exclusive_ordering(constraint, result): - assert cpv.parse_python_requires(constraint) == result - - -@pytest.mark.parametrize('constraint, result', [ - ('=== 2.7', ['2.7']), - ('=== 2.7.12', ['2.7']), - ('=== 3', []), -]) -def test_parse_python_requires_arbitrary_version(constraint, result): - assert cpv.parse_python_requires(constraint) == result - - -@pytest.mark.parametrize('op', ['~=', '>=', '<=', '>', '<', '===']) -def test_parse_python_requires_unexpected_dot_star(monkeypatch, capsys, op): - monkeypatch.setattr(cpv, 'CURRENT_PYTHON_3_VERSION', 7) - assert cpv.parse_python_requires(f'{op} 3.6.*') is None - assert ( - f'Bad python_requires specifier: {op} 3.6.* ({op} does not allow a .*)' - in capsys.readouterr().err - ) - - -@pytest.mark.parametrize('specifier', [ - '%= 42', - '== nobody.knows', - '!= *.*.*', - 'xyzzy', -]) -def test_parse_python_requires_syntax_errors(capsys, specifier): - assert cpv.parse_python_requires(specifier) is None - assert ( - f'Bad python_requires specifier: {specifier}' - in capsys.readouterr().err - ) - - -def test_get_tox_ini_python_versions(tmp_path): - tox_ini = tmp_path / "tox.ini" - tox_ini.write_text(textwrap.dedent("""\ - [tox] - envlist = py27,py36,py27-docs - """)) - assert cpv.get_tox_ini_python_versions(tox_ini) == ['2.7', '3.6'] - - -def test_get_tox_ini_python_versions_no_tox_ini(tmp_path): - tox_ini = tmp_path / "tox.ini" - assert cpv.get_tox_ini_python_versions(tox_ini) == [] - - -def test_get_tox_ini_python_versions_syntax_error(tmp_path): - tox_ini = tmp_path / "tox.ini" - tox_ini.write_text(textwrap.dedent("""\ - ... - """)) - assert cpv.get_tox_ini_python_versions(tox_ini) == [] - - -def test_get_tox_ini_python_versions_no_tox_section(tmp_path): - tox_ini = tmp_path / "tox.ini" - tox_ini.write_text(textwrap.dedent("""\ - [flake8] - source = foo - """)) - assert cpv.get_tox_ini_python_versions(tox_ini) == [] - - -def test_get_tox_ini_python_versions_no_tox_envlist(tmp_path): - tox_ini = tmp_path / "tox.ini" - tox_ini.write_text(textwrap.dedent("""\ - [tox] - minversion = 3.4.0 - """)) - assert cpv.get_tox_ini_python_versions(tox_ini) == [] - - -@pytest.mark.parametrize('s, expected', [ - ('', []), - ('py36,py37', ['py36', 'py37']), - ('py36, py37', ['py36', 'py37']), - ('\n py36,\n py37', ['py36', 'py37']), - ('py3{6,7},pypy', ['py36', 'py37', 'pypy']), -]) -def test_parse_envlist(s, expected): - assert cpv.parse_envlist(s) == expected - - -@pytest.mark.parametrize('s, expected', [ - ('', ['']), - ('py36', ['py36']), - ('py3{6,7}', ['py36', 'py37']), - ('py3{6,7}-lint', ['py36-lint', 'py37-lint']), - ('py3{6,7}{,-lint}', ['py36', 'py36-lint', 'py37', 'py37-lint']), -]) -def test_brace_expand(s, expected): - assert cpv.brace_expand(s) == expected - - -@pytest.mark.parametrize('s, expected', [ - ('py36', '3.6'), - ('py37-lint', '3.7'), - ('pypy', 'PyPy'), - ('pypy3', 'PyPy3'), - ('flake8', 'flake8'), -]) -def test_tox_env_to_py_version(s, expected): - assert cpv.tox_env_to_py_version(s) == expected - - -@needs_pyyaml -def test_get_travis_yml_python_versions(tmp_path): - travis_yml = tmp_path / ".travis.yml" - travis_yml.write_text(textwrap.dedent("""\ - python: - - 2.7 - - 3.6 - matrix: - include: - - python: 3.7 - - name: something unrelated - jobs: - include: - - python: 3.4 - - name: something unrelated - env: - - TOXENV=py35-docs - - UNRELATED=variable - """)) - assert cpv.get_travis_yml_python_versions(travis_yml) == [ - '2.7', '3.4', '3.5', '3.6', '3.7', - ] - - -@needs_pyyaml -def test_get_travis_yml_python_versions_no_python_only_matrix(tmp_path): - travis_yml = tmp_path / ".travis.yml" - travis_yml.write_text(textwrap.dedent("""\ - matrix: - include: - - python: 3.7 - """)) - assert cpv.get_travis_yml_python_versions(travis_yml) == [ - '3.7', - ] - - -@pytest.mark.parametrize('s, expected', [ - (3.6, '3.6'), - ('3.7', '3.7'), - ('pypy', 'PyPy'), - ('pypy2', 'PyPy'), - ('pypy2.7', 'PyPy'), - ('pypy2.7-5.10.0', 'PyPy'), - ('pypy3', 'PyPy3'), - ('pypy3.5', 'PyPy3'), - ('pypy3.5-5.10.1', 'PyPy3'), - ('3.7-dev', '3.7-dev'), - ('nightly', 'nightly'), -]) -def test_travis_normalize_py_version(s, expected): - assert cpv.travis_normalize_py_version(s) == expected - - -@needs_pyyaml -def test_get_appveyor_yml_python_versions(tmp_path): - appveyor_yml = tmp_path / "appveyor.yml" - appveyor_yml.write_text(textwrap.dedent("""\ - environment: - matrix: - - PYTHON: c:\\python27 - - PYTHON: c:\\python27-x64 - - PYTHON: c:\\python36 - - PYTHON: c:\\python36-x64 - UNRELATED: variable - """)) - assert cpv.get_appveyor_yml_python_versions(appveyor_yml) == [ - '2.7', '3.6', - ] - - -@needs_pyyaml -def test_get_appveyor_yml_python_versions_using_toxenv(tmp_path): - appveyor_yml = tmp_path / "appveyor.yml" - appveyor_yml.write_text(textwrap.dedent("""\ - environment: - matrix: - - TOXENV: py27 - - TOXENV: py37 - """)) - assert cpv.get_appveyor_yml_python_versions(appveyor_yml) == [ - '2.7', '3.7', - ] - - -@pytest.mark.parametrize('s, expected', [ - ('37', '3.7'), - ('c:\\python34', '3.4'), - ('C:\\Python27\\', '2.7'), - ('C:\\Python27-x64', '2.7'), - ('C:\\PYTHON34-X64', '3.4'), -]) -def test_appveyor_normalize_py_version(s, expected): - assert cpv.appveyor_normalize_py_version(s) == expected - - -def test_get_manylinux_python_versions(tmp_path): - manylinux_install_sh = tmp_path / ".manylinux-install.sh" - manylinux_install_sh.write_text(textwrap.dedent(r""" - #!/usr/bin/env bash - - set -e -x - - # Compile wheels - for PYBIN in /opt/python/*/bin; do - if [[ "${PYBIN}" == *"cp27"* ]] || \ - [[ "${PYBIN}" == *"cp34"* ]] || \ - [[ "${PYBIN}" == *"cp35"* ]] || \ - [[ "${PYBIN}" == *"cp36"* ]] || \ - [[ "${PYBIN}" == *"cp37"* ]]; then - "${PYBIN}/pip" install -e /io/ - "${PYBIN}/pip" wheel /io/ -w wheelhouse/ - rm -rf /io/build /io/*.egg-info - fi - done - - # Bundle external shared libraries into the wheels - for whl in wheelhouse/zope.interface*.whl; do - auditwheel repair "$whl" -w /io/wheelhouse/ - done - """.lstrip('\n'))) - assert cpv.get_manylinux_python_versions(manylinux_install_sh) == [ - '2.7', '3.4', '3.5', '3.6', '3.7', - ] - - -def test_important(monkeypatch): - monkeypatch.setattr(cpv, 'CURRENT_PYTHON_3_VERSION', 7) - assert cpv.important({ - '2.7', '3.4', '3.7-dev', '3.8', 'nightly', 'PyPy3', 'Jython' - }) == {'2.7', '3.4'} - - -def test_parse_expect(): - assert cpv.parse_expect('2.7,3.4-3.6') == ['2.7', '3.4', '3.5', '3.6'] - - -def test_parse_expect_bad_range(): - with pytest.raises(ValueError, match=r'bad range: 2\.7-3\.4 \(2 != 3\)'): - cpv.parse_expect('2.7-3.4') - - -def test_parse_expect_bad_number(): - with pytest.raises(ValueError): - cpv.parse_expect('2.x') - - -def test_parse_expect_too_few(): - with pytest.raises(ValueError): - cpv.parse_expect('2') - - -def test_parse_expect_too_many_dots(): - with pytest.raises(ValueError): - cpv.parse_expect('2.7.1') - - -def test_is_package(tmp_path): - (tmp_path / "setup.py").write_text("") - assert cpv.is_package(tmp_path) - - -def test_is_package_no_setup_py(tmp_path): - assert not cpv.is_package(tmp_path) - - -def test_check_not_a_directory(tmp_path, capsys): - assert cpv.check(tmp_path / "xyzzy") is None - assert capsys.readouterr().out == 'not a directory\n' - - -def test_check_not_a_package(tmp_path, capsys): - assert cpv.check(tmp_path) is None - assert capsys.readouterr().out == 'no setup.py -- not a Python package?\n' - - -def test_check_unknown(tmp_path, capsys): - setup_py = tmp_path / "setup.py" - setup_py.write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - ) - """)) - assert cpv.check(tmp_path) is True - assert capsys.readouterr().out == textwrap.dedent("""\ - setup.py says: (empty) - """) - - -def test_check_minimal(tmp_path, capsys): - setup_py = tmp_path / "setup.py" - setup_py.write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - classifiers=[ - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.6', - ], - ) - """)) - assert cpv.check(tmp_path) is True - assert capsys.readouterr().out == textwrap.dedent("""\ - setup.py says: 2.7, 3.6 - """) - - -def test_check_mismatch(tmp_path, capsys): - setup_py = tmp_path / "setup.py" - setup_py.write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - classifiers=[ - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.6', - ], - ) - """)) - tox_ini = tmp_path / "tox.ini" - tox_ini.write_text(textwrap.dedent("""\ - [tox] - envlist = py27 - """)) - assert cpv.check(tmp_path) is False - assert capsys.readouterr().out == textwrap.dedent("""\ - setup.py says: 2.7, 3.6 - tox.ini says: 2.7 - """) - - -def test_check_expectation(tmp_path, capsys): - setup_py = tmp_path / "setup.py" - setup_py.write_text(textwrap.dedent("""\ - from setuptools import setup - setup( - name='foo', - classifiers=[ - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.6', - ], - ) - """)) - assert cpv.check(tmp_path, expect=['2.7', '3.6', '3.7']) is False - assert capsys.readouterr().out == textwrap.dedent("""\ - setup.py says: 2.7, 3.6 - expected: 2.7, 3.6, 3.7 - """) - - -def test_main_help(monkeypatch): - monkeypatch.setattr(sys, 'argv', ['check-python-versions', '--help']) - with pytest.raises(SystemExit): - cpv.main() - - -@pytest.mark.parametrize('arg', [ - 'xyzzy', - '1,2,3', - '2.x', - '1.2.3', - '2.7-3.6', -]) -def test_main_expect_error_handling(monkeypatch, arg, capsys): - monkeypatch.setattr(sys, 'argv', [ - 'check-python-versions', '--expect', arg, - ]) - with pytest.raises(SystemExit): - cpv.main() - assert f'bad value for --expect: {arg}' in capsys.readouterr().err - - -def test_main_here(monkeypatch, capsys): - monkeypatch.setattr(sys, 'argv', [ - 'check-python-versions', - ]) - cpv.main() - assert 'mismatch' not in capsys.readouterr().out - - -def test_main_skip_non_packages(monkeypatch, capsys, tmp_path): - monkeypatch.setattr(sys, 'argv', [ - 'check-python-versions', '--skip-non-packages', str(tmp_path), - ]) - cpv.main() - assert capsys.readouterr().out == '' - - -def test_main_single(monkeypatch, capsys, tmp_path): - monkeypatch.setattr(sys, 'argv', [ - 'check-python-versions', - str(tmp_path / "a"), - ]) - with pytest.raises(SystemExit) as exc_info: - cpv.main() - assert ( - capsys.readouterr().out + str(exc_info.value) + '\n' - ).replace(str(tmp_path), 'tmp') == textwrap.dedent("""\ - not a directory - - mismatch! - """) - - -def test_main_multiple(monkeypatch, capsys, tmp_path): - monkeypatch.setattr(sys, 'argv', [ - 'check-python-versions', - str(tmp_path / "a"), - str(tmp_path / "b"), - ]) - with pytest.raises(SystemExit) as exc_info: - cpv.main() - assert ( - capsys.readouterr().out + str(exc_info.value) + '\n' - ).replace(str(tmp_path) + os.path.sep, 'tmp/') == textwrap.dedent("""\ - tmp/a: - - not a directory - - - tmp/b: - - not a directory - - - mismatch in tmp/a tmp/b! - """) - - -def test_main_multiple_ok(monkeypatch, capsys): - monkeypatch.setattr(sys, 'argv', [ - 'check-python-versions', '.', '.', - ]) - cpv.main() - assert ( - capsys.readouterr().out.endswith('\n\nall ok!\n') - ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8906175 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from check_python_versions.versions import MAX_MINOR_FOR_MAJOR + + +@pytest.fixture +def fix_max_python_3_version(monkeypatch): + def helper(ver): + monkeypatch.setitem(MAX_MINOR_FOR_MAJOR, 3, ver) + return helper diff --git a/tests/parsers/test_appveyor.py b/tests/parsers/test_appveyor.py new file mode 100644 index 0000000..8c23a09 --- /dev/null +++ b/tests/parsers/test_appveyor.py @@ -0,0 +1,147 @@ +import textwrap +from io import StringIO + +import pytest + +try: + import yaml +except ImportError: + yaml = None + +from check_python_versions.parsers.appveyor import ( + appveyor_detect_py_version_pattern, + appveyor_normalize_py_version, + get_appveyor_yml_python_versions, + update_appveyor_yml_python_versions, +) + + +needs_pyyaml = pytest.mark.skipIf(yaml is None, "PyYAML not installed") + + +@needs_pyyaml +def test_get_appveyor_yml_python_versions(tmp_path): + appveyor_yml = tmp_path / "appveyor.yml" + appveyor_yml.write_text(textwrap.dedent("""\ + environment: + matrix: + - PYTHON: c:\\python27 + - PYTHON: c:\\python27-x64 + - PYTHON: c:\\python36 + - PYTHON: c:\\python36-x64 + UNRELATED: variable + """)) + assert get_appveyor_yml_python_versions(appveyor_yml) == [ + '2.7', '3.6', + ] + + +@needs_pyyaml +def test_get_appveyor_yml_python_versions_using_toxenv(tmp_path): + appveyor_yml = tmp_path / "appveyor.yml" + appveyor_yml.write_text(textwrap.dedent("""\ + environment: + matrix: + - TOXENV: py27 + - TOXENV: py37 + """)) + assert get_appveyor_yml_python_versions(appveyor_yml) == [ + '2.7', '3.7', + ] + + +@pytest.mark.parametrize('s, expected', [ + ('37', '3.7'), + ('c:\\python34', '3.4'), + ('C:\\Python27\\', '2.7'), + ('C:\\Python27-x64', '2.7'), + ('C:\\PYTHON34-X64', '3.4'), +]) +def test_appveyor_normalize_py_version(s, expected): + assert appveyor_normalize_py_version(s) == expected + + +@pytest.mark.parametrize('s, expected', [ + ('37', '{}{}'), + ('c:\\python34', 'c:\\python{}{}'), + ('C:\\Python27\\', 'C:\\Python{}{}\\'), + ('C:\\Python27-x64', 'C:\\Python{}{}-x64'), + ('C:\\PYTHON34-X64', 'C:\\PYTHON{}{}-X64'), +]) +def test_appveyor_detect_py_version_pattern(s, expected): + assert appveyor_detect_py_version_pattern(s) == expected + + +def test_update_appveyor_yml_python_versions(): + appveyor_yml = StringIO(textwrap.dedent(r""" + environment: + matrix: + - PYTHON: "c:\\python27" + - PYTHON: "c:\\python36" + """).lstrip('\n')) + result = update_appveyor_yml_python_versions(appveyor_yml, ['2.7', '3.7']) + assert ''.join(result) == textwrap.dedent(r""" + environment: + matrix: + - PYTHON: "c:\\python27" + - PYTHON: "c:\\python37" + """.lstrip('\n')) + + +def test_update_appveyor_yml_python_versions_multiple_of_each(): + appveyor_yml = StringIO(textwrap.dedent("""\ + environment: + matrix: + - PYTHON: c:\\python27 + - PYTHON: c:\\python27-x64 + - PYTHON: c:\\python36 + - PYTHON: c:\\python36-x64 + """)) + result = update_appveyor_yml_python_versions(appveyor_yml, ['2.7', '3.7']) + assert ''.join(result) == textwrap.dedent("""\ + environment: + matrix: + - PYTHON: c:\\python27 + - PYTHON: c:\\python27-x64 + - PYTHON: c:\\python37 + - PYTHON: c:\\python37-x64 + """) + + +def test_update_appveyor_yml_python_complicated_but_oneline(): + appveyor_yml = StringIO(textwrap.dedent("""\ + environment: + matrix: + - PYTHON: c:\\python27 + - PYTHON: c:\\python36 + - { PYTHON: c:\\python27, EXTRA_FEATURE: 1 } + - { PYTHON: c:\\python36, EXTRA_FEATURE: 1 } + """)) + result = update_appveyor_yml_python_versions(appveyor_yml, ['3.6']) + assert ''.join(result) == textwrap.dedent("""\ + environment: + matrix: + - PYTHON: c:\\python36 + - { PYTHON: c:\\python36, EXTRA_FEATURE: 1 } + """) + + +def test_update_appveyor_yml_python_no_understanding(capsys): + appveyor_yml = StringIO(textwrap.dedent("""\ + environment: + matrix: + - FOO: 1 + - BAR: 2 + """)) + appveyor_yml.name = 'appveyor.yml' + result = update_appveyor_yml_python_versions(appveyor_yml, ['3.6']) + assert ''.join(result) == textwrap.dedent("""\ + environment: + matrix: + - FOO: 1 + - BAR: 2 + """) + assert ( + "Did not recognize any PYTHON environments in appveyor.yml" + in capsys.readouterr().err + ) diff --git a/tests/parsers/test_manylinux.py b/tests/parsers/test_manylinux.py new file mode 100644 index 0000000..64086f7 --- /dev/null +++ b/tests/parsers/test_manylinux.py @@ -0,0 +1,138 @@ +import textwrap +from io import StringIO + +from check_python_versions.parsers.manylinux import ( + get_manylinux_python_versions, + update_manylinux_python_versions, +) + + +def test_get_manylinux_python_versions(tmp_path): + manylinux_install_sh = tmp_path / ".manylinux-install.sh" + manylinux_install_sh.write_text(textwrap.dedent(r""" + #!/usr/bin/env bash + + set -e -x + + # Compile wheels + for PYBIN in /opt/python/*/bin; do + if [[ "${PYBIN}" == *"cp27"* ]] || \ + [[ "${PYBIN}" == *"cp34"* ]] || \ + [[ "${PYBIN}" == *"cp35"* ]] || \ + [[ "${PYBIN}" == *"cp36"* ]] || \ + [[ "${PYBIN}" == *"cp37"* ]]; then + "${PYBIN}/pip" install -e /io/ + "${PYBIN}/pip" wheel /io/ -w wheelhouse/ + rm -rf /io/build /io/*.egg-info + fi + done + + # Bundle external shared libraries into the wheels + for whl in wheelhouse/zope.interface*.whl; do + auditwheel repair "$whl" -w /io/wheelhouse/ + done + """.lstrip('\n'))) + assert get_manylinux_python_versions(manylinux_install_sh) == [ + '2.7', '3.4', '3.5', '3.6', '3.7', + ] + + +def test_update_manylinux_python_versions(): + manylinux_install_sh = StringIO(textwrap.dedent(r""" + #!/usr/bin/env bash + + set -e -x + + # Compile wheels + for PYBIN in /opt/python/*/bin; do + if [[ "${PYBIN}" == *"cp27"* ]] || \ + [[ "${PYBIN}" == *"cp34"* ]] || \ + [[ "${PYBIN}" == *"cp35"* ]] || \ + [[ "${PYBIN}" == *"cp36"* ]] || \ + [[ "${PYBIN}" == *"cp37"* ]]; then + "${PYBIN}/pip" install -e /io/ + "${PYBIN}/pip" wheel /io/ -w wheelhouse/ + rm -rf /io/build /io/*.egg-info + fi + done + + # Bundle external shared libraries into the wheels + for whl in wheelhouse/zope.interface*.whl; do + auditwheel repair "$whl" -w /io/wheelhouse/ + done + """).lstrip('\n')) + result = update_manylinux_python_versions( + manylinux_install_sh, ['3.6', '3.7', '3.8']) + assert "".join(result) == textwrap.dedent(r""" + #!/usr/bin/env bash + + set -e -x + + # Compile wheels + for PYBIN in /opt/python/*/bin; do + if [[ "${PYBIN}" == *"cp36"* ]] || \ + [[ "${PYBIN}" == *"cp37"* ]] || \ + [[ "${PYBIN}" == *"cp38"* ]]; then + "${PYBIN}/pip" install -e /io/ + "${PYBIN}/pip" wheel /io/ -w wheelhouse/ + rm -rf /io/build /io/*.egg-info + fi + done + + # Bundle external shared libraries into the wheels + for whl in wheelhouse/zope.interface*.whl; do + auditwheel repair "$whl" -w /io/wheelhouse/ + done + """).lstrip('\n') + + +def test_update_manylinux_python_versions_failure(capsys): + manylinux_install_sh = StringIO(textwrap.dedent(r""" + #!/usr/bin/env bash + + # TBD + """).lstrip('\n')) + manylinux_install_sh.name = '.manylinux-install.sh' + result = update_manylinux_python_versions( + manylinux_install_sh, ['3.6', '3.7', '3.8']) + assert "".join(result) == textwrap.dedent(r""" + #!/usr/bin/env bash + + # TBD + """).lstrip('\n') + assert ( + "Failed to understand .manylinux-install.sh" + in capsys.readouterr().err + ) + + +def test_update_manylinux_python_versions_truncated(capsys): + manylinux_install_sh = StringIO(textwrap.dedent(r""" + #!/usr/bin/env bash + + set -e -x + + # Compile wheels + for PYBIN in /opt/python/*/bin; do + if [[ "${PYBIN}" == *"cp27"* ]] || \ + [[ "${PYBIN}" == *"cp34"* ]] || \ + [[ "${PYBIN}" == *"cp35"* ]] || \ + """).lstrip('\n')) + manylinux_install_sh.name = '.manylinux-install.sh' + result = update_manylinux_python_versions( + manylinux_install_sh, ['3.6', '3.7', '3.8']) + assert "".join(result) == textwrap.dedent(r""" + #!/usr/bin/env bash + + set -e -x + + # Compile wheels + for PYBIN in /opt/python/*/bin; do + if [[ "${PYBIN}" == *"cp27"* ]] || \ + [[ "${PYBIN}" == *"cp34"* ]] || \ + [[ "${PYBIN}" == *"cp35"* ]] || \ + """).lstrip('\n') + assert ( + "Failed to understand .manylinux-install.sh" + in capsys.readouterr().err + ) diff --git a/tests/parsers/test_python.py b/tests/parsers/test_python.py new file mode 100644 index 0000000..07e81c0 --- /dev/null +++ b/tests/parsers/test_python.py @@ -0,0 +1,699 @@ +import ast +import textwrap +from io import StringIO + +import pytest + +from check_python_versions.parsers.python import ( + compute_python_requires, + eval_ast_node, + find_call_kwarg_in_ast, + get_python_requires, + get_setup_py_keyword, + get_supported_python_versions, + get_versions_from_classifiers, + parse_python_requires, + to_literal, + update_call_arg_in_source, + update_classifiers, + update_python_requires, + update_supported_python_versions, +) + + +def test_get_supported_python_versions(tmp_path): + filename = tmp_path / "setup.py" + filename.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + assert get_supported_python_versions(filename) == ['2.7', '3.6'] + + +def test_get_supported_python_versions_computed(tmp_path): + filename = tmp_path / "setup.py" + filename.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: %s' % v + for v in ['2.7', '3.7'] + ], + ) + """)) + assert get_supported_python_versions(filename) == ['2.7', '3.7'] + + +def test_get_supported_python_versions_from_file_object_cannot_run_setup_py(): + fp = StringIO(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: %s' % v + for v in ['2.7', '3.7'] + ], + ) + """)) + fp.name = 'setup.py' + assert get_supported_python_versions(fp) == [] + + +def test_get_versions_from_classifiers(): + assert get_versions_from_classifiers([ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + ]) == ['2.7', '3.6', '3.7', 'PyPy'] + + +def test_get_versions_from_classifiers_major_only(): + assert get_versions_from_classifiers([ + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', + ]) == ['2', '3'] + + +def test_get_versions_from_classifiers_with_only_suffix(): + assert get_versions_from_classifiers([ + 'Programming Language :: Python :: 2 :: Only', + ]) == ['2'] + + +def test_get_versions_from_classifiers_with_trailing_whitespace(): + # I was surprised too that this is allowed! + assert get_versions_from_classifiers([ + 'Programming Language :: Python :: 3.6 ', + ]) == ['3.6'] + + +def test_update_classifiers(): + assert update_classifiers([ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Typing :: Typed', + ], ['2.7', '3.7']) == [ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Typing :: Typed', + ] + + +def test_update_classifiers_drop_major(): + assert update_classifiers([ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Typing :: Typed', + ], ['3.6', '3.7']) == [ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Typing :: Typed', + ] + + +def test_update_classifiers_no_major(): + assert update_classifiers([ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Typing :: Typed', + ], ['2.7', '3.7']) == [ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Typing :: Typed', + ] + + +def test_update_classifiers_none_were_present(): + assert update_classifiers([ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + ], ['2.7', '3.7']) == [ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.7', + ] + + +def test_update_supported_python_versions(tmp_path, capsys): + filename = tmp_path / "setup.py" + filename.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: %s' % v + for v in ['2.7', '3.7'] + ], + ) + """)) + update_supported_python_versions(filename, ['3.7', '3.8']) + assert ( + 'Non-literal classifiers= passed to setup()' + in capsys.readouterr().err + ) + + +def test_get_python_requires(tmp_path, fix_max_python_3_version): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + python_requires='>= 3.6', + ) + """)) + fix_max_python_3_version(7) + assert get_python_requires(setup_py) == ['3.6', '3.7'] + + +def test_get_python_requires_not_specified(tmp_path, capsys): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + ) + """)) + assert get_python_requires(setup_py) is None + assert capsys.readouterr().err == '' + + +def test_get_setup_py_keyword_syntax_error(tmp_path, capsys): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + # uh do I need to close parens? what if I forget? ;) + """)) + assert get_setup_py_keyword(setup_py, 'name') is None + assert 'Could not parse' in capsys.readouterr().err + + +def test_update_python_requires(tmp_path, fix_max_python_3_version): + fix_max_python_3_version(7) + filename = tmp_path / "setup.py" + filename.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + python_requires='>= 3.4', + ) + """)) + result = update_python_requires(filename, ['3.5', '3.6', '3.7']) + assert "".join(result) == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + python_requires='>=3.5', + ) + """) + + +def test_update_python_requires_file_object(fix_max_python_3_version): + fix_max_python_3_version(7) + fp = StringIO(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + python_requires='>= 3.4', + ) + """)) + fp.name = "setup.py" + result = update_python_requires(fp, ['3.5', '3.6', '3.7']) + assert "".join(result) == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + python_requires='>=3.5', + ) + """) + + +def test_update_python_requires_when_missing(capsys): + fp = StringIO(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + ) + """)) + fp.name = "setup.py" + result = update_python_requires(fp, ['3.5', '3.6', '3.7']) + assert result is None + assert capsys.readouterr().err == "" + + +def test_find_call_kwarg_in_ast(): + tree = ast.parse('foo(bar="foo")') + ast.dump(tree) + node = find_call_kwarg_in_ast(tree, 'foo', 'bar') + assert isinstance(node, ast.Str) + assert node.s == "foo" + + +def test_find_call_kwarg_in_ast_no_arg(capsys): + tree = ast.parse('foo(baz="foo")') + ast.dump(tree) + node = find_call_kwarg_in_ast(tree, 'foo', 'bar') + assert node is None + assert capsys.readouterr().err == '' + + +def test_find_call_kwarg_in_ast_no_call(capsys): + tree = ast.parse('fooo(bar="foo")') + ast.dump(tree) + node = find_call_kwarg_in_ast(tree, 'foo', 'bar') + assert node is None + assert 'Could not find foo() call in setup.py' in capsys.readouterr().err + + +@pytest.mark.parametrize('code, expected', [ + ('"hi"', "hi"), + ('"hi\\n"', "hi\n"), + ('["a", "b"]', ["a", "b"]), + ('("a", "b")', ("a", "b")), + ('"-".join(["a", "b"])', "a-b"), + ('["a", "b"] + ["c"]', ["a", "b", "c"]), + ('["a", "b"] + extra', ["a", "b"]), + ('extra + ["a", "b"]', ["a", "b"]), + ('["a", "b", extra]', ["a", "b"]), +]) +def test_eval_ast_node(code, expected): + tree = ast.parse(f'foo(bar={code})') + node = find_call_kwarg_in_ast(tree, 'foo', 'bar') + assert node is not None + assert eval_ast_node(node, 'bar') == expected + + +@pytest.mark.parametrize('code', [ + '[2 * 2]', + '"".join([2 * 2])', + 'extra + more', +]) +def test_eval_ast_node_failures(code, capsys): + tree = ast.parse(f'foo(bar={code})') + node = find_call_kwarg_in_ast(tree, 'foo', 'bar') + assert eval_ast_node(node, 'bar') is None + assert 'Non-literal bar= passed to setup()' in capsys.readouterr().err + + +def test_to_literal(): + assert to_literal("blah") == '"blah"' + assert to_literal("blah", "'") == "'blah'" + + +def test_to_literal_embedded_quote(): + assert to_literal( + "Environment :: Handhelds/PDA's" + ) == '"Environment :: Handhelds/PDA\'s"' + assert to_literal( + "Environment :: Handhelds/PDA's", "'" + ) == '"Environment :: Handhelds/PDA\'s"' + + +def test_to_literal_all_the_classifiers(): + with open('CLASSIFIERS') as f: + for line in f: + classifier = line.strip() + literal = to_literal(classifier) + assert ast.literal_eval(literal) == classifier + + +def test_update_call_arg_in_source_string(): + source_lines = textwrap.dedent("""\ + setup( + foo=1, + bar="x", + baz=2, + ) + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", "y") + assert "".join(result) == textwrap.dedent("""\ + setup( + foo=1, + bar="y", + baz=2, + ) + """) + + +def test_update_call_arg_in_source_string_spaces(): + # This is against PEP-8 but there are setup.py files out there that do + # not follow PEP-8. + source_lines = textwrap.dedent("""\ + setup ( + foo = 1, + bar = 'x', + baz = 2, + ) + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", "y") + assert "".join(result) == textwrap.dedent("""\ + setup ( + foo = 1, + bar = 'y', + baz = 2, + ) + """) + + +def test_update_call_arg_in_source_list(): + source_lines = textwrap.dedent("""\ + setup( + foo=1, + bar=[ + "a", + "b", + + r"c", + ], + baz=2, + ) + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", + ["x", "y"]) + assert "".join(result) == textwrap.dedent("""\ + setup( + foo=1, + bar=[ + "x", + "y", + ], + baz=2, + ) + """) + + +def test_update_call_arg_in_source_preserves_indent_and_quote_style(): + source_lines = textwrap.dedent("""\ + setup(foo=1, + bar=[ + 'a', + 'b', + 'c', + ], + ) + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", + ["x", "y"]) + assert "".join(result) == textwrap.dedent("""\ + setup(foo=1, + bar=[ + 'x', + 'y', + ], + ) + """) + + +def test_update_call_arg_in_source_fixes_closing_bracket(): + source_lines = textwrap.dedent("""\ + setup(foo=1, + bar=[ + 'a', + 'b', + 'c'], + baz=2, + ) + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", + ["x", "y"]) + assert "".join(result) == textwrap.dedent("""\ + setup(foo=1, + bar=[ + 'x', + 'y', + ], + baz=2, + ) + """) + + +def test_update_call_arg_in_source_fixes_opening_bracket(): + source_lines = textwrap.dedent("""\ + setup(foo=1, + bar=['a', + 'b', + 'c'], + baz=2, + ) + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", + ["x", "y"]) + assert "".join(result) == textwrap.dedent("""\ + setup(foo=1, + bar=[ + 'x', + 'y', + ], + baz=2, + ) + """) + + +def test_update_call_arg_in_source_handles_empty_list(): + source_lines = textwrap.dedent("""\ + setup(foo=1, + bar=[], + baz=2, + ) + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", + ["x", "y"]) + assert "".join(result) == textwrap.dedent("""\ + setup(foo=1, + bar=[ + "x", + "y", + ], + baz=2, + ) + """) + + +def test_update_call_arg_in_source_no_function_call(capsys): + source_lines = textwrap.dedent("""\ + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", + ["x", "y"]) + assert result == source_lines + assert "Did not find setup() call" in capsys.readouterr().err + + +def test_update_call_arg_in_source_no_keyword(capsys): + source_lines = textwrap.dedent("""\ + setup() + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", + ["x", "y"]) + assert result == source_lines + assert ( + "Did not find bar= argument in setup() call" + in capsys.readouterr().err + ) + + +def test_update_call_arg_in_source_too_complicated(capsys): + source_lines = textwrap.dedent("""\ + setup( + bar=bar) + """).splitlines(True) + result = update_call_arg_in_source(source_lines, "setup", "bar", + ["x", "y"]) + assert result == source_lines + assert ( + "Did not understand bar= formatting in setup() call" + in capsys.readouterr().err + ) + + +@pytest.mark.parametrize('constraint, result', [ + ('~= 2.7', ['2.7']), + ('~= 2.7.12', ['2.7']), +]) +def test_parse_python_requires_approximately(constraint, result): + assert parse_python_requires(constraint) == result + + +def test_parse_python_requires_approximately_not_enough_dots(capsys): + assert parse_python_requires('~= 2') is None + assert ( + 'Bad python_requires specifier: ~= 2' + ' (~= requires a version with at least one dot)' + in capsys.readouterr().err + ) + + +@pytest.mark.parametrize('constraint, result', [ + ('== 2.7', ['2.7']), + ('== 2.7.*', ['2.7']), + ('== 2.7.12', ['2.7']), + ('== 2.*, >= 2.6', ['2.6', '2.7']), + ('== 3.0', ['3.0']), + ('== 3', ['3.0']), +]) +def test_parse_python_requires_matching_version(constraint, result): + assert parse_python_requires(constraint) == result + + +def test_parse_python_requires_greater_than(fix_max_python_3_version): + fix_max_python_3_version(8) + assert parse_python_requires('>= 3.6') == ['3.6', '3.7', '3.8'] + + +@pytest.mark.parametrize('constraint, result', [ + ('>= 2.7, != 3.*', ['2.7']), + ('>= 2.7.12, != 3.*', ['2.7']), + ('>= 2.7, != 3.0.*, != 3.1.*', ['2.7', '3.2', '3.3']), + # != 3.2 means we reject 3.2.0 but still accept any other 3.2.x + ('>= 2.7, != 3.2', ['2.7', '3.0', '3.1', '3.2', '3.3']), + ('>= 2.7, != 3.2.1', ['2.7', '3.0', '3.1', '3.2', '3.3']), + ('>= 2.7, <= 3', ['2.7', '3.0']), + ('>= 2.7, <= 3.2', ['2.7', '3.0', '3.1', '3.2']), + ('>= 2.7, <= 3.2.1', ['2.7', '3.0', '3.1', '3.2']), + ('>= 3', ['3.0', '3.1', '3.2', '3.3']), +]) +def test_parse_python_requires_greater_than_with_exceptions( + fix_max_python_3_version, constraint, result +): + fix_max_python_3_version(3) + assert parse_python_requires(constraint) == result + + +def test_parse_python_requires_multiple_greater_than(fix_max_python_3_version): + fix_max_python_3_version(7) + assert parse_python_requires('>= 2.7, >= 3.6') == ['3.6', '3.7'] + + +@pytest.mark.parametrize('constraint, result', [ + ('> 2, < 3.1', ['3.0']), + ('> 2.6, < 3', ['2.7']), + ('> 2.7.12, < 3', ['2.7']), + ('> 2.7.12, < 3.0', ['2.7']), + ('> 2.7.12, < 3.1', ['2.7', '3.0']), + ('> 2.7.12, < 3.0.1', ['2.7', '3.0']), +]) +def test_parse_python_requires_exclusive_ordering(constraint, result): + assert parse_python_requires(constraint) == result + + +@pytest.mark.parametrize('constraint, result', [ + ('=== 2.7', ['2.7']), + ('=== 2.7.12', ['2.7']), + ('=== 3', []), +]) +def test_parse_python_requires_arbitrary_version(constraint, result): + assert parse_python_requires(constraint) == result + + +@pytest.mark.parametrize('op', ['~=', '>=', '<=', '>', '<', '===']) +def test_parse_python_requires_unexpected_dot_star(fix_max_python_3_version, + capsys, op): + fix_max_python_3_version(7) + assert parse_python_requires(f'{op} 3.6.*') is None + assert ( + f'Bad python_requires specifier: {op} 3.6.* ({op} does not allow a .*)' + in capsys.readouterr().err + ) + + +@pytest.mark.parametrize('specifier', [ + '%= 42', + '== nobody.knows', + '!= *.*.*', + 'xyzzy', +]) +def test_parse_python_requires_syntax_errors(capsys, specifier): + assert parse_python_requires(specifier) is None + assert ( + f'Bad python_requires specifier: {specifier}' + in capsys.readouterr().err + ) + + +@pytest.mark.parametrize('versions, expected', [ + (['2.7'], '==2.7.*'), + (['3.6', '3.7'], '>=3.6'), + (['2.7', '3.4', '3.5', '3.6', '3.7'], + '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'), +]) +def test_compute_python_requires(versions, expected, fix_max_python_3_version): + fix_max_python_3_version(7) + result = compute_python_requires(versions) + assert result == expected + assert parse_python_requires(result) == versions diff --git a/tests/parsers/test_tox.py b/tests/parsers/test_tox.py new file mode 100644 index 0000000..c4c336f --- /dev/null +++ b/tests/parsers/test_tox.py @@ -0,0 +1,217 @@ +import textwrap +from io import StringIO + +import pytest + +from check_python_versions.parsers.tox import ( + brace_expand, + get_tox_ini_python_versions, + parse_envlist, + tox_env_to_py_version, + update_ini_setting, + update_tox_envlist, + update_tox_ini_python_versions, +) + + +def test_get_tox_ini_python_versions(tmp_path): + tox_ini = tmp_path / "tox.ini" + tox_ini.write_text(textwrap.dedent("""\ + [tox] + envlist = py27,py36,py27-docs + """)) + assert get_tox_ini_python_versions(tox_ini) == ['2.7', '3.6'] + + +def test_get_tox_ini_python_versions_syntax_error(tmp_path): + tox_ini = tmp_path / "tox.ini" + tox_ini.write_text(textwrap.dedent("""\ + ... + """)) + assert get_tox_ini_python_versions(tox_ini) == [] + + +def test_get_tox_ini_python_versions_no_tox_section(tmp_path): + tox_ini = tmp_path / "tox.ini" + tox_ini.write_text(textwrap.dedent("""\ + [flake8] + source = foo + """)) + assert get_tox_ini_python_versions(tox_ini) == [] + + +def test_get_tox_ini_python_versions_no_tox_envlist(tmp_path): + tox_ini = tmp_path / "tox.ini" + tox_ini.write_text(textwrap.dedent("""\ + [tox] + minversion = 3.4.0 + """)) + assert get_tox_ini_python_versions(tox_ini) == [] + + +@pytest.mark.parametrize('s, expected', [ + ('', []), + ('py36,py37', ['py36', 'py37']), + ('py36, py37', ['py36', 'py37']), + ('\n py36,\n py37', ['py36', 'py37']), + ('py3{6,7},pypy', ['py36', 'py37', 'pypy']), +]) +def test_parse_envlist(s, expected): + assert parse_envlist(s) == expected + + +@pytest.mark.parametrize('s, expected', [ + ('', ['']), + ('py36', ['py36']), + ('py3{6,7}', ['py36', 'py37']), + ('py3{6,7}-lint', ['py36-lint', 'py37-lint']), + ('py3{6,7}{,-lint}', ['py36', 'py36-lint', 'py37', 'py37-lint']), +]) +def test_brace_expand(s, expected): + assert brace_expand(s) == expected + + +@pytest.mark.parametrize('s, expected', [ + ('py36', '3.6'), + ('py37-lint', '3.7'), + ('pypy', 'PyPy'), + ('pypy3', 'PyPy3'), + ('flake8', 'flake8'), +]) +def test_tox_env_to_py_version(s, expected): + assert tox_env_to_py_version(s) == expected + + +def test_update_tox_ini_python_versions(): + fp = StringIO(textwrap.dedent("""\ + [tox] + envlist = py26, py27 + """)) + result = update_tox_ini_python_versions(fp, ['3.6', '3.7']) + assert "".join(result) == textwrap.dedent("""\ + [tox] + envlist = py36, py37 + """) + + +def test_update_tox_ini_python_syntax_error(capsys): + fp = StringIO(textwrap.dedent("""\ + [tox + envlist = py26, py27 + """)) + fp.name = 'tox.ini' + result = update_tox_ini_python_versions(fp, ['3.6', '3.7']) + assert "".join(result) == textwrap.dedent("""\ + [tox + envlist = py26, py27 + """) + assert ( + "Could not parse tox.ini:" + in capsys.readouterr().err + ) + + +def test_update_tox_envlist(): + result = update_tox_envlist('py26,py27,pypy,flake8', ['3.6', '3.7']) + assert result == 'py36,py37,pypy,flake8' + + +def test_update_tox_envlist_with_suffixes(): + result = update_tox_envlist( + 'py27,py34,py35,py36,py37,py27-numpy,py37-numpy,pypy,pypy3', + ['3.6', '3.7']) + assert result == 'py36,py37,py37-numpy,pypy,pypy3' + + +def test_update_ini_setting(): + source_lines = textwrap.dedent("""\ + [tox] + envlist = py26,py27 + usedevelop = true + """).splitlines(True) + result = update_ini_setting(source_lines, 'tox', 'envlist', 'py36,py37') + assert "".join(result) == textwrap.dedent("""\ + [tox] + envlist = py36,py37 + usedevelop = true + """) + + +def test_update_ini_setting_from_empty(): + source_lines = textwrap.dedent("""\ + [tox] + envlist = + usedevelop = true + """).splitlines(True) + result = update_ini_setting(source_lines, 'tox', 'envlist', 'py36,py37') + assert "".join(result) == textwrap.dedent("""\ + [tox] + envlist = py36,py37 + usedevelop = true + """) + + +def test_update_ini_setting_multiline(): + source_lines = textwrap.dedent("""\ + [tox] + envlist = + py26,py27 + usedevelop = true + """).splitlines(True) + result = update_ini_setting(source_lines, 'tox', 'envlist', 'py36,py37') + assert "".join(result) == textwrap.dedent("""\ + [tox] + envlist = + py36,py37 + usedevelop = true + """) + + +def test_update_ini_setting_multiline_with_comments(): + source_lines = textwrap.dedent("""\ + [tox] + envlist = + # blah blah + # py26,py27,pypy + py26,py27 + usedevelop = true + """).splitlines(True) + result = update_ini_setting(source_lines, 'tox', 'envlist', 'py36,py37') + assert "".join(result) == textwrap.dedent("""\ + [tox] + envlist = + # blah blah + # py26,py27,pypy + py36,py37 + usedevelop = true + """) + + +def test_update_ini_setting_no_section(capsys): + source_lines = textwrap.dedent("""\ + [toxx] + """).splitlines(True) + result = update_ini_setting(source_lines, 'tox', 'envlist', 'py36,py37') + assert "".join(result) == textwrap.dedent("""\ + [toxx] + """) + assert ( + "Did not find [tox] section in tox.ini" + in capsys.readouterr().err + ) + + +def test_update_ini_setting_no_key(capsys): + source_lines = textwrap.dedent("""\ + [tox] + usedevelop = true + """).splitlines(True) + result = update_ini_setting(source_lines, 'tox', 'envlist', 'py36,py37') + assert "".join(result) == textwrap.dedent("""\ + [tox] + usedevelop = true + """) + assert ( + "Did not find envlist= in [tox] in tox.ini" + in capsys.readouterr().err + ) diff --git a/tests/parsers/test_travis.py b/tests/parsers/test_travis.py new file mode 100644 index 0000000..09e00d2 --- /dev/null +++ b/tests/parsers/test_travis.py @@ -0,0 +1,595 @@ +import textwrap +from io import StringIO + +import pytest + +try: + import yaml +except ImportError: + yaml = None + +from check_python_versions.parsers.travis import ( + add_yaml_node, + drop_yaml_node, + get_travis_yml_python_versions, + travis_normalize_py_version, + update_travis_yml_python_versions, + update_yaml_list, +) + + +needs_pyyaml = pytest.mark.skipIf(yaml is None, "PyYAML not installed") + + +@needs_pyyaml +def test_get_travis_yml_python_versions(tmp_path): + travis_yml = tmp_path / ".travis.yml" + travis_yml.write_text(textwrap.dedent("""\ + python: + - 2.7 + - 3.6 + matrix: + include: + - python: 3.7 + - name: something unrelated + jobs: + include: + - python: 3.4 + - name: something unrelated + env: + - TOXENV=py35-docs + - UNRELATED=variable + """)) + assert get_travis_yml_python_versions(travis_yml) == [ + '2.7', '3.4', '3.5', '3.6', '3.7', + ] + + +@needs_pyyaml +def test_get_travis_yml_python_versions_no_list(tmp_path): + travis_yml = StringIO(textwrap.dedent("""\ + python: 3.7 + """)) + travis_yml.name = '.travis.yml' + assert get_travis_yml_python_versions(travis_yml) == [ + '3.7', + ] + + +@needs_pyyaml +def test_get_travis_yml_python_versions_no_python_only_matrix(tmp_path): + travis_yml = tmp_path / ".travis.yml" + travis_yml.write_text(textwrap.dedent("""\ + matrix: + include: + - python: 3.7 + """)) + assert get_travis_yml_python_versions(travis_yml) == [ + '3.7', + ] + + +@pytest.mark.parametrize('s, expected', [ + (3.6, '3.6'), + ('3.7', '3.7'), + ('pypy', 'PyPy'), + ('pypy2', 'PyPy'), + ('pypy2.7', 'PyPy'), + ('pypy2.7-5.10.0', 'PyPy'), + ('pypy3', 'PyPy3'), + ('pypy3.5', 'PyPy3'), + ('pypy3.5-5.10.1', 'PyPy3'), + ('3.7-dev', '3.7-dev'), + ('nightly', 'nightly'), +]) +def test_travis_normalize_py_version(s, expected): + assert travis_normalize_py_version(s) == expected + + +def test_update_travis_yml_python_versions(): + travis_yml = StringIO(textwrap.dedent("""\ + language: python + python: + - 2.7 + - pypy + install: pip install -e . + script: pytest tests + """)) + travis_yml.name = '.travis.yml' + result = update_travis_yml_python_versions(travis_yml, ["2.7", "3.4"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 2.7 + - 3.4 + - pypy + install: pip install -e . + script: pytest tests + """) + + +def test_update_travis_yml_python_versions_adds_dist_xenial(): + travis_yml = StringIO(textwrap.dedent("""\ + language: python + python: + - 2.7 + - pypy + install: pip install -e . + script: pytest tests + """)) + travis_yml.name = '.travis.yml' + result = update_travis_yml_python_versions(travis_yml, ["2.7", "3.7"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + dist: xenial + python: + - 2.7 + - 3.7 + - pypy2.7-6.0.0 + install: pip install -e . + script: pytest tests + """) + + +def test_update_travis_yml_python_versions_drops_sudo(): + travis_yml = StringIO(textwrap.dedent("""\ + language: python + sudo: false + dist: xenial + python: + - 2.7 + install: pip install -e . + script: pytest tests + """)) + travis_yml.name = '.travis.yml' + result = update_travis_yml_python_versions(travis_yml, ["2.7", "3.7"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + dist: xenial + python: + - 2.7 + - 3.7 + install: pip install -e . + script: pytest tests + """) + + +def test_update_travis_yml_python_versions_drops_matrix(): + travis_yml = StringIO(textwrap.dedent("""\ + language: python + python: + - 2.6 + - 2.7 + matrix: + include: + - python: 3.7 + sudo: required + dist: xenial + install: pip install -e . + script: pytest tests + """)) + travis_yml.name = '.travis.yml' + result = update_travis_yml_python_versions(travis_yml, ["2.7", "3.7"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + dist: xenial + python: + - 2.7 + - 3.7 + install: pip install -e . + script: pytest tests + """) + + +def test_update_travis_yml_python_versions_keeps_matrix(): + travis_yml = StringIO(textwrap.dedent("""\ + language: python + python: + - 2.7 + matrix: + include: + - python: 2.7 + env: MINIMAL=1 + install: pip install -e . + script: pytest tests + """)) + travis_yml.name = '.travis.yml' + result = update_travis_yml_python_versions(travis_yml, ["2.7", "3.7"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + dist: xenial + python: + - 2.7 + - 3.7 + matrix: + include: + - python: 2.7 + env: MINIMAL=1 + install: pip install -e . + script: pytest tests + """) + + +def test_update_travis_yml_python_versions_one_to_many(): + travis_yml = StringIO(textwrap.dedent("""\ + language: python + python: 2.7 + install: pip install -e . + script: pytest tests + """)) + travis_yml.name = '.travis.yml' + result = update_travis_yml_python_versions(travis_yml, ["2.7", "3.4"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 2.7 + - 3.4 + install: pip install -e . + script: pytest tests + """) + + +def test_update_travis_yml_python_versions_matrix(): + travis_yml = StringIO(textwrap.dedent("""\ + language: python + matrix: + exclude: + - python: 2.6 + # this is where the fun begins! + include: + - python: 2.7 + - python: 3.3 + - python: pypy + - name: docs + python: 2.7 + install: pip install sphinx + script: sphinx-build . + - name: flake8 + python: 2.7 + install: pip install flake8 + script: flake8 . + install: pip install -e . + script: pytest tests + """)) + travis_yml.name = '.travis.yml' + result = update_travis_yml_python_versions(travis_yml, ["2.7", "3.4"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + matrix: + exclude: + - python: 2.6 + # this is where the fun begins! + include: + - python: 2.7 + - python: 3.4 + - python: pypy + - name: docs + python: 2.7 + install: pip install sphinx + script: sphinx-build . + - name: flake8 + python: 2.7 + install: pip install flake8 + script: flake8 . + install: pip install -e . + script: pytest tests + """) + + +def test_update_yaml_list(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 2.6 + - 2.7 + install: pip install -e . + script: pytest tests + """).splitlines(True) + result = update_yaml_list(source_lines, "python", ["2.7", "3.7"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 2.7 + - 3.7 + install: pip install -e . + script: pytest tests + """) + + +def test_update_yaml_list_keep_indent_comments_and_pypy(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 2.6 + # XXX: should probably remove 2.6 + - 2.7 + - pypy + - 3.3 + script: pytest tests + """).splitlines(True) + result = update_yaml_list(source_lines, "python", ["2.7", "3.7"], + keep=lambda line: line == 'pypy') + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 2.7 + - 3.7 + # XXX: should probably remove 2.6 + - pypy + script: pytest tests + """) + + +def test_update_yaml_list_not_found(capsys): + source_lines = textwrap.dedent("""\ + language: python + install: pip install -e . + script: pytest tests + """).splitlines(True) + result = update_yaml_list(source_lines, "python", ["2.7", "3.7"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + install: pip install -e . + script: pytest tests + """) + assert ( + "Did not find python: setting in .travis.yml" + in capsys.readouterr().err + ) + + +def test_update_yaml_list_nested_keys_not_found(capsys): + source_lines = textwrap.dedent("""\ + language: python + matrix: + allow_failures: + - python: 3.8 + install: pip install -e . + script: pytest tests + """).splitlines(True) + result = update_yaml_list( + source_lines, ("matrix", "include"), ["python: 2.7"]) + assert "".join(result) == textwrap.dedent("""\ + language: python + matrix: + allow_failures: + - python: 3.8 + install: pip install -e . + script: pytest tests + """) + assert ( + "Did not find matrix.include: setting in .travis.yml" + in capsys.readouterr().err + ) + + +def test_update_yaml_list_nesting_does_not_confuse(): + source_lines = textwrap.dedent("""\ + language: python + matrix: + include: + + - name: flake8 + script: + - flake8 + + - python: 2.7 + env: + - PURE_PYTHON: 1 + allow_failures: + - python: 3.8 + install: pip install -e . + script: pytest tests + """).splitlines(True) + result = update_yaml_list( + source_lines, ("matrix", "include"), [], + keep=lambda job: job.startswith('python:')) + assert "".join(result) == textwrap.dedent("""\ + language: python + matrix: + include: + - python: 2.7 + env: + - PURE_PYTHON: 1 + allow_failures: + - python: 3.8 + install: pip install -e . + script: pytest tests + """) + + +def test_update_yaml_list_nesting_some_garbage(): + source_lines = textwrap.dedent("""\ + language: python + matrix: + include: + - python: 2.7 + garbage + install: pip install -e . + script: pytest tests + """).splitlines(True) + result = update_yaml_list( + source_lines, ("matrix", "include"), ['python: 2.7']) + assert "".join(result) == textwrap.dedent("""\ + language: python + matrix: + include: + - python: 2.7 + garbage + install: pip install -e . + script: pytest tests + """) + + +def test_drop_yaml_node(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 3.6 + matrix: + include: + - python: 3.7 + dist: xenial + sudo: required + script: pytest tests + """).splitlines(True) + result = drop_yaml_node(source_lines, 'matrix') + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + """) + + +def test_drop_yaml_node_when_empty(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 3.6 + matrix: + script: pytest tests + """).splitlines(True) + result = drop_yaml_node(source_lines, 'matrix') + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + """) + + +def test_drop_yaml_node_when_text(): + source_lines = textwrap.dedent("""\ + language: python + sudo: false + python: + - 3.6 + script: pytest tests + """).splitlines(True) + result = drop_yaml_node(source_lines, 'sudo') + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + """) + + +def test_drop_yaml_node_when_last_in_file(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 3.6 + matrix: + include: + - python: 3.7 + dist: xenial + sudo: required + """).splitlines(True) + result = drop_yaml_node(source_lines, 'matrix') + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 3.6 + """) + + +def test_drop_yaml_node_when_duplicate(capsys): + source_lines = textwrap.dedent("""\ + language: python + sudo: false + matrix: + include: + - python: 2.7 + python: + - 3.6 + matrix: + include: + - python: 3.7 + script: pytest tests + """).splitlines(True) + result = drop_yaml_node(source_lines, 'matrix') + assert "".join(result) == textwrap.dedent("""\ + language: python + sudo: false + matrix: + include: + - python: 2.7 + python: + - 3.6 + script: pytest tests + """) + assert ( + "Duplicate matrix: setting in .travis.yml (lines 3 and 8)" + in capsys.readouterr().err + ) + + +def test_add_yaml_node(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + """).splitlines(True) + result = add_yaml_node(source_lines, 'dist', 'xenial') + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + dist: xenial + """) + + +def test_add_yaml_node_before(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + """).splitlines(True) + result = add_yaml_node(source_lines, 'dist', 'xenial', before='python') + assert "".join(result) == textwrap.dedent("""\ + language: python + dist: xenial + python: + - 3.6 + script: pytest tests + """) + + +def test_add_yaml_node_at_end_when_before_not_found(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + """).splitlines(True) + result = add_yaml_node(source_lines, 'dist', 'xenial', before='sudo') + assert "".join(result) == textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + dist: xenial + """) + + +def test_add_yaml_node_before_alternatives(): + source_lines = textwrap.dedent("""\ + language: python + python: + - 3.6 + script: pytest tests + """).splitlines(True) + result = add_yaml_node(source_lines, 'dist', 'xenial', + before=('sudo', 'python')) + assert "".join(result) == textwrap.dedent("""\ + language: python + dist: xenial + python: + - 3.6 + script: pytest tests + """) diff --git a/tests/test___main__.py b/tests/test___main__.py new file mode 100644 index 0000000..b922bac --- /dev/null +++ b/tests/test___main__.py @@ -0,0 +1,11 @@ +import sys + +import pytest + +from check_python_versions import __main__ + + +def test_main(monkeypatch): + monkeypatch.setattr(sys, 'argv', ['check-python-versions', '--help']) + with pytest.raises(SystemExit): + __main__.main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e29c5a1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,762 @@ +import argparse +import os +import re +import sys +import textwrap +from io import StringIO + +import pytest + +import check_python_versions.cli as cpv + + +def test_parse_version_list(): + assert cpv.parse_version_list( + '2.7,3.4-3.6' + ) == ['2.7', '3.4', '3.5', '3.6'] + + +def test_parse_version_list_magic_range(fix_max_python_3_version): + fix_max_python_3_version(7) + assert cpv.parse_version_list( + '2.7,3.4-' + ) == ['2.7', '3.4', '3.5', '3.6', '3.7'] + assert cpv.parse_version_list( + '2.6,-3.4' + ) == ['2.6', '3.0', '3.1', '3.2', '3.3', '3.4'] + + +@pytest.mark.parametrize('v', [ + '4.1-', # unknown major version + '-', # both endpoints missing + '2.7-3.4', # major versions differ +]) +def test_parse_version_list_bad_range(v): + with pytest.raises(argparse.ArgumentTypeError, + match=re.escape(f'bad range: {v}')): + cpv.parse_version_list(v) + + +def test_parse_version_list_bad_number(): + with pytest.raises(argparse.ArgumentTypeError): + cpv.parse_version_list('2.x') + + +def test_parse_version_list_too_few(): + with pytest.raises(argparse.ArgumentTypeError): + cpv.parse_version_list('2') + + +def test_parse_version_list_too_many_dots(): + with pytest.raises(argparse.ArgumentTypeError): + cpv.parse_version_list('2.7.1') + + +def test_is_package(tmp_path): + (tmp_path / "setup.py").write_text("") + assert cpv.is_package(tmp_path) + + +def test_is_package_no_setup_py(tmp_path): + assert not cpv.is_package(tmp_path) + + +def test_check_not_a_directory(tmp_path, capsys): + assert not cpv.check_package(tmp_path / "xyzzy") + assert capsys.readouterr().out == 'not a directory\n' + + +def test_check_not_a_package(tmp_path, capsys): + assert not cpv.check_package(tmp_path) + assert capsys.readouterr().out == 'no setup.py -- not a Python package?\n' + + +def test_check_package(tmp_path): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + ) + """)) + assert cpv.check_package(tmp_path) is True + + +def test_check_unknown(tmp_path, capsys): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + ) + """)) + assert cpv.check_versions(tmp_path) is True + assert capsys.readouterr().out == textwrap.dedent("""\ + setup.py says: (empty) + """) + + +def test_check_minimal(tmp_path, capsys): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + assert cpv.check_versions(tmp_path) is True + assert capsys.readouterr().out == textwrap.dedent("""\ + setup.py says: 2.7, 3.6 + """) + + +def test_check_mismatch(tmp_path, capsys): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + tox_ini = tmp_path / "tox.ini" + tox_ini.write_text(textwrap.dedent("""\ + [tox] + envlist = py27 + """)) + assert cpv.check_versions(tmp_path) is False + assert capsys.readouterr().out == textwrap.dedent("""\ + setup.py says: 2.7, 3.6 + tox.ini says: 2.7 + """) + + +def test_check_expectation(tmp_path, capsys): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + assert cpv.check_versions(tmp_path, expect=['2.7', '3.6', '3.7']) is False + assert capsys.readouterr().out == textwrap.dedent("""\ + setup.py says: 2.7, 3.6 + expected: 2.7, 3.6, 3.7 + """) + + +def test_check_only(tmp_path, capsys): + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + tox_ini = tmp_path / "tox.ini" + tox_ini.write_text(textwrap.dedent("""\ + [tox] + envlist = py27 + """)) + assert cpv.check_versions(tmp_path, only='tox.ini') + assert capsys.readouterr().out == textwrap.dedent("""\ + tox.ini says: 2.7 + """) + + +def test_update_versions(tmp_path, monkeypatch): + monkeypatch.setattr(sys, 'stdin', StringIO('y\n')) + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + cpv.update_versions(tmp_path, add=['3.7']) + assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + ], + ) + """) + + +def test_update_versions_dry_run(tmp_path): + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + replacements = cpv.update_versions(tmp_path, add=['3.7'], dry_run=True) + assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """) + filename = str(tmp_path / "setup.py") + assert "".join(replacements[filename]) == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + ], + ) + """) + + +def test_update_versions_dry_run_two_updaters_one_file(tmp_path): + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + python_requires='>= 2.7', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + replacements = cpv.update_versions( + tmp_path, update=['2.7', '3.4', '3.5', '3.6', '3.7'], dry_run=True, + ) + assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + python_requires='>= 2.7', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """) + filename = str(tmp_path / "setup.py") + assert "".join(replacements[filename]) == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + ], + ) + """) + + +def test_update_versions_diff(tmp_path, capsys): + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + cpv.update_versions(tmp_path, add=['3.7'], diff=True) + assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """) + assert ( + capsys.readouterr().out.replace(str(tmp_path) + os.path.sep, 'tmp/') + ).expandtabs() == textwrap.dedent("""\ + --- tmp/setup.py (original) + +++ tmp/setup.py (updated) + @@ -4,5 +4,6 @@ + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + + 'Programming Language :: Python :: 3.7', + ], + ) + + """) + + +def test_update_versions_no_change(tmp_path): + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + cpv.update_versions(tmp_path, add=['3.6']) + + +def test_update_versions_only(tmp_path): + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + ], + ) + """)) + tox_ini = tmp_path / "tox.ini" + tox_ini.write_text(textwrap.dedent("""\ + [tox] + envlist = py27 + """)) + replacements = cpv.update_versions( + tmp_path, add=['3.6'], only='tox.ini', dry_run=True, + ) + assert set(replacements) == {str(tmp_path / 'tox.ini')} + + +def test_update_versions_computed(tmp_path): + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: %s' % v + for v in ['2.7'] + ], + ) + """)) + replacements = cpv.update_versions( + tmp_path, add=['3.6'], dry_run=True, + ) + assert set(replacements) == set() + + +def test_main_help(monkeypatch): + monkeypatch.setattr(sys, 'argv', ['check-python-versions', '--help']) + with pytest.raises(SystemExit): + cpv.main() + + +@pytest.mark.parametrize('arg', [ + 'xyzzy', + '1,2,3', + '2.x', + '1.2.3', + '2.7-3.6', +]) +def test_main_expect_error_handling(monkeypatch, arg, capsys): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', '--expect', arg, + ]) + with pytest.raises(SystemExit): + cpv.main() + # the error is either 'bad version: ...' or 'bad range: ...' + assert f'--expect: bad' in capsys.readouterr().err + + +@pytest.mark.parametrize('arg', ['--add', '--drop']) +def test_main_conflicting_args(monkeypatch, tmp_path, capsys, arg): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path), + arg, '3.8', + '--update', '3.6-3.7', + ]) + with pytest.raises(SystemExit): + cpv.main() + assert ( + f'argument {arg}: not allowed with argument --update' + in capsys.readouterr().err + ) + + +@pytest.mark.parametrize('arg', ['--diff', '--dry-run']) +def test_main_required_args(monkeypatch, tmp_path, capsys, arg): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path), + arg, + ]) + with pytest.raises(SystemExit): + cpv.main() + assert ( + f'argument {arg}: not allowed without --update/--add/--drop' + in capsys.readouterr().err + ) + + +def test_main_diff_and_expect_and_dry_run_oh_my(monkeypatch, tmp_path, capsys): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path), + '--expect', '3.6-3.7', + '--update', '3.6-3.7', + '--diff', + ]) + with pytest.raises(SystemExit): + cpv.main() + assert ( + 'argument --expect: not allowed with --diff,' + ' unless you also add --dry-run' + in capsys.readouterr().err + ) + + +def test_main_here(monkeypatch, capsys): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + ]) + cpv.main() + assert 'mismatch' not in capsys.readouterr().out + + +def test_main_skip_non_packages(monkeypatch, capsys, tmp_path): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', '--skip-non-packages', str(tmp_path), + ]) + cpv.main() + assert capsys.readouterr().out == '' + + +def test_main_single(monkeypatch, capsys, tmp_path): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path / "a"), + ]) + with pytest.raises(SystemExit) as exc_info: + cpv.main() + assert ( + capsys.readouterr().out + str(exc_info.value) + '\n' + ).replace(str(tmp_path), 'tmp') == textwrap.dedent("""\ + not a directory + + mismatch! + """) + + +def test_main_only(monkeypatch, capsys, tmp_path): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path), + '--only', 'tox.ini,setup.py', + ]) + setup_py = tmp_path / "setup.py" + setup_py.write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + tox_ini = tmp_path / "tox.ini" + tox_ini.write_text(textwrap.dedent("""\ + [tox] + envlist = py27,py36 + """)) + travis_yml = tmp_path / ".travis.yml" + travis_yml.write_text(textwrap.dedent("""\ + python: + - 2.7 + - 3.5 + """)) + cpv.main() + assert ( + capsys.readouterr().out + '\n' + ).replace(str(tmp_path) + os.path.sep, 'tmp/') == textwrap.dedent("""\ + setup.py says: 2.7, 3.6 + tox.ini says: 2.7, 3.6 + + """) + + +def test_main_multiple(monkeypatch, capsys, tmp_path): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path / "a"), + str(tmp_path / "b"), + '--expect', '3.6, 3.7' + ]) + (tmp_path / "a").mkdir() + (tmp_path / "a" / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + with pytest.raises(SystemExit) as exc_info: + cpv.main() + assert ( + capsys.readouterr().out + str(exc_info.value) + '\n' + ).replace(str(tmp_path) + os.path.sep, 'tmp/') == textwrap.dedent("""\ + tmp/a: + + setup.py says: 2.7, 3.6 + expected: 3.6, 3.7 + + + tmp/b: + + not a directory + + + mismatch in tmp/a tmp/b! + """) + + +def test_main_multiple_ok(monkeypatch, capsys): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', '.', '.', + ]) + cpv.main() + assert ( + capsys.readouterr().out.endswith('\n\nall ok!\n') + ) + + +def test_main_update(monkeypatch, capsys, tmp_path): + monkeypatch.setattr(sys, 'stdin', StringIO('y\n')) + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path), + '--add', '3.7,3.8', + ]) + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + cpv.main() + assert ( + capsys.readouterr().out + .replace(str(tmp_path) + os.path.sep, 'tmp/') + .expandtabs() + .replace(' \n', '\n\n') + ) == textwrap.dedent("""\ + --- tmp/setup.py (original) + +++ tmp/setup.py (updated) + @@ -4,5 +4,7 @@ + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + + 'Programming Language :: Python :: 3.7', + + 'Programming Language :: Python :: 3.8', + ], + ) + + Write changes to tmp/setup.py? [y/N] + + setup.py says: 2.7, 3.6, 3.7, 3.8 + """) + assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + ], + ) + """) + + +def test_main_update_rejected(monkeypatch, capsys, tmp_path): + monkeypatch.setattr(sys, 'stdin', StringIO('n\n')) + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path), + '--add', '3.7,3.8', + ]) + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + cpv.main() + assert ( + capsys.readouterr().out + .replace(str(tmp_path) + os.path.sep, 'tmp/') + .expandtabs() + .replace(' \n', '\n\n') + ) == textwrap.dedent("""\ + --- tmp/setup.py (original) + +++ tmp/setup.py (updated) + @@ -4,5 +4,7 @@ + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + + 'Programming Language :: Python :: 3.7', + + 'Programming Language :: Python :: 3.8', + ], + ) + + Write changes to tmp/setup.py? [y/N] + + setup.py says: 2.7, 3.6 + """) + assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """) + + +def test_main_update_diff(monkeypatch, capsys, tmp_path): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path), + '--add', '3.7,3.8', + '--diff', + ]) + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + cpv.main() + assert ( + capsys.readouterr().out + .replace(str(tmp_path) + os.path.sep, 'tmp/') + .expandtabs() + .replace(' \n', '\n\n') + ) == textwrap.dedent("""\ + --- tmp/setup.py (original) + +++ tmp/setup.py (updated) + @@ -4,5 +4,7 @@ + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + + 'Programming Language :: Python :: 3.7', + + 'Programming Language :: Python :: 3.8', + ], + ) + + """) + assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """) + + +def test_main_update_dry_run(monkeypatch, capsys, tmp_path): + monkeypatch.setattr(sys, 'argv', [ + 'check-python-versions', + str(tmp_path), + '--add', '3.7,3.8', + '--dry-run', + ]) + (tmp_path / "setup.py").write_text(textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """)) + cpv.main() + assert ( + capsys.readouterr().out + .replace(str(tmp_path) + os.path.sep, 'tmp/') + .expandtabs() + .replace(' \n', '\n\n') + ) == textwrap.dedent("""\ + setup.py says: 2.7, 3.6, 3.7, 3.8 + """) + assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ + from setuptools import setup + setup( + name='foo', + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6', + ], + ) + """) + + +def test_main_handles_ctrl_c(monkeypatch): + def raise_keyboard_interrupt(): + raise KeyboardInterrupt() + monkeypatch.setattr(cpv, '_main', raise_keyboard_interrupt) + with pytest.raises(SystemExit): + cpv.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..b7f73ab --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,33 @@ +import sys +from io import StringIO + +from check_python_versions.utils import pipe, confirm + + +def test_pipe(): + assert pipe('echo', 'hi') == 'hi\n' + + +def test_confirm_eof(monkeypatch): + monkeypatch.setattr(sys, 'stdin', StringIO()) + assert not confirm("Hello how are you?") + + +def test_confirm_default(monkeypatch): + monkeypatch.setattr(sys, 'stdin', StringIO("\n")) + assert not confirm("Hello how are you?") + + +def test_confirm_no(monkeypatch): + monkeypatch.setattr(sys, 'stdin', StringIO("n\n")) + assert not confirm("Hello how are you?") + + +def test_confirm_yes(monkeypatch): + monkeypatch.setattr(sys, 'stdin', StringIO("y\n")) + assert confirm("Hello how are you?") + + +def test_confirm_neither(monkeypatch): + monkeypatch.setattr(sys, 'stdin', StringIO("t\ny\n")) + assert confirm("Hello how are you?") diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 0000000..31eb514 --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,30 @@ +from check_python_versions.versions import ( + important, + update_version_list, +) + + +def test_important(fix_max_python_3_version): + fix_max_python_3_version(7) + assert important({ + '2.7', '3.4', '3.7-dev', '3.8', 'nightly', 'PyPy3', 'Jython' + }) == {'2.7', '3.4'} + + +def test_update_version_list(): + assert update_version_list(['2.7', '3.4']) == ['2.7', '3.4'] + assert update_version_list(['2.7', '3.4'], add=['3.4', '3.5']) == [ + '2.7', '3.4', '3.5', + ] + assert update_version_list(['2.7', '3.4'], drop=['3.4', '3.5']) == [ + '2.7', + ] + assert update_version_list(['2.7', '3.4'], add=['3.5'], drop=['2.7']) == [ + '3.4', '3.5', + ] + assert update_version_list(['2.7', '3.4'], drop=['3.4', '3.5']) == [ + '2.7', + ] + assert update_version_list(['2.7', '3.4'], update=['3.4', '3.5']) == [ + '3.4', '3.5', + ] diff --git a/tox.ini b/tox.ini index 25c3b02..eb45252 100644 --- a/tox.ini +++ b/tox.ini @@ -4,18 +4,19 @@ envlist = flake8,py36,py37 [testenv] deps = pytest commands = - pytest tests.py {posargs} + pytest {posargs:tests} [testenv:pypy36] basepython = pypy3.6 [testenv:coverage] +usedevelop = true basepython = python3.6 deps = {[testenv]deps} coverage commands = - coverage run -m pytest tests.py {posargs} + coverage run -m pytest tests {posargs} coverage report -m --fail-under=100 [testenv:flake8] @@ -23,4 +24,4 @@ basepython = python3.6 deps = flake8 commands = - flake8 *.py + flake8 src *.py