diff --git a/MANIFEST.in b/MANIFEST.in index c089429f..3c312e0c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ include README* include SECURITY* include pytest.ini recursive-include qtpy/tests *.py *.ui +include qtpy/py.typed diff --git a/README.md b/README.md index bd272131..a4ed6c3e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,37 @@ conda install qtpy ``` +### Mypy integration + +A Command Line Interface (CLI) is offered to help with usage of QtPy. +Presently, its only feature is to generate command line arguments for Mypy +that will enable it to process the QtPy source files with the same API +as QtPy itself would have selected. + +If you run + +```bash +qtpy mypy-args +``` + +QtPy will output a string of Mypy CLI args that will reflect the currently +selected Qt API. +For example, in an environment where PyQt5 is installed and selected +(or the default fallback, if no binding can be found in the environment), +this would output the following: + +```text +--always-true=PYQT5 --always-false=PYQT6 --always-false=PYSIDE2 --always-false=PYSIDE6 +``` + +Using Bash or a similar shell, this can be injected into +the Mypy command line invocation as follows: + +```bash +mypy --package mypackage $(qtpy mypy-args) +``` + + ## Contributing Everyone is welcome to contribute! diff --git a/qtpy/__init__.py b/qtpy/__init__.py index f9a020b6..f4fd0b6d 100644 --- a/qtpy/__init__.py +++ b/qtpy/__init__.py @@ -101,9 +101,11 @@ class PythonQtWarning(Warning): # Setting a default value for QT_API os.environ.setdefault(QT_API, 'pyqt5') +API_NAMES = {'pyqt5': 'PyQt5', 'pyqt6': 'PyQt6', + 'pyside2':'PySide2', 'pyside6': 'PySide6'} API = os.environ[QT_API].lower() initial_api = API -assert API in (PYQT5_API + PYQT6_API + PYSIDE2_API + PYSIDE6_API) +assert API in API_NAMES is_old_pyqt = is_pyqt46 = False QT5 = PYQT5 = True @@ -201,8 +203,9 @@ class PythonQtWarning(Warning): warnings.warn('Selected binding "{}" could not be found, ' 'using "{}"'.format(initial_api, API), RuntimeWarning) -API_NAME = {'pyqt6': 'PyQt6', 'pyqt5': 'PyQt5', - 'pyside2':'PySide2', 'pyside6': 'PySide6'}[API] + +# Set display name of the Qt API +API_NAME = API_NAMES[API] try: # QtDataVisualization backward compatibility (QtDataVisualization vs. QtDatavisualization) diff --git a/qtpy/__main__.py b/qtpy/__main__.py new file mode 100644 index 00000000..a8f993c0 --- /dev/null +++ b/qtpy/__main__.py @@ -0,0 +1,18 @@ +# ----------------------------------------------------------------------------- +# Copyright © 2009- The QtPy Contributors +# +# Released under the terms of the MIT License +# (see LICENSE.txt for details) +# ----------------------------------------------------------------------------- + +"""Dev CLI entry point for QtPy, a compat layer for the Python Qt bindings.""" + +import qtpy.cli + + +def main(): + return qtpy.cli.main() + + +if __name__ == "__main__": + main() diff --git a/qtpy/cli.py b/qtpy/cli.py new file mode 100644 index 00000000..3a629970 --- /dev/null +++ b/qtpy/cli.py @@ -0,0 +1,88 @@ +# ----------------------------------------------------------------------------- +# Copyright © 2009- The QtPy Contributors +# +# Released under the terms of the MIT License +# (see LICENSE.txt for details) +# ----------------------------------------------------------------------------- + +"""Provide a CLI to allow configuring developer settings, including mypy.""" + +# Standard library imports +import argparse +import textwrap + + +def print_version(): + """Print the current version of the package.""" + import qtpy + print('QtPy version', qtpy.__version__) + + +def generate_mypy_args(): + """Generate a string with always-true/false args to pass to mypy.""" + options = {False: '--always-false', True: '--always-true'} + + import qtpy + + apis_active = {name: qtpy.API == name for name in qtpy.API_NAMES} + mypy_args = ' '.join( + f'{options[is_active]}={name.upper()}' + for name, is_active in apis_active.items() + ) + return mypy_args + + +def print_mypy_args(): + """Print the generated mypy args to stdout.""" + print(generate_mypy_args()) + + +def generate_arg_parser(): + """Generate the argument parser for the dev CLI for QtPy.""" + parser = argparse.ArgumentParser( + description='Features to support development with QtPy.', + ) + parser.set_defaults(func=parser.print_help) + + parser.add_argument( + '--version', action='store_const', dest='func', const=print_version, + help='If passed, will print the version and exit') + + cli_subparsers = parser.add_subparsers( + title='Subcommands', help='Subcommand to run', metavar='Subcommand') + + # Parser for the MyPy args subcommand + mypy_args_parser = cli_subparsers.add_parser( + name='mypy-args', + help='Generate command line arguments for using mypy with QtPy.', + formatter_class=argparse.RawTextHelpFormatter, + description=textwrap.dedent( + """ + Generate command line arguments for using mypy with QtPy. + + This will generate strings similar to the following + which help guide mypy through which library QtPy would have used + so that mypy can get the proper underlying type hints. + + --always-false=PYQT5 --always-false=PYQT6 --always-true=PYSIDE2 --always-false=PYSIDE6 + + It can be used as follows on Bash or a similar shell: + + mypy --package mypackage $(qtpy mypy-args) + """ + ), + ) + mypy_args_parser.set_defaults(func=print_mypy_args) + + return parser + + +def main(args=None): + """Run the development CLI for QtPy.""" + parser = generate_arg_parser() + parsed_args = parser.parse_args(args=args) + + reserved_params = {'func'} + cleaned_args = {key: value for key, value in vars(parsed_args).items() + if key not in reserved_params} + parsed_args.func(**cleaned_args) diff --git a/qtpy/py.typed b/qtpy/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/qtpy/tests/test_cli.py b/qtpy/tests/test_cli.py new file mode 100644 index 00000000..42a10f33 --- /dev/null +++ b/qtpy/tests/test_cli.py @@ -0,0 +1,77 @@ +"""Test the QtPy CLI.""" + +import subprocess +import sys + +import pytest + +import qtpy + + +SUBCOMMANDS = [ + [], + ['mypy-args'], +] + + +@pytest.mark.parametrize( + argnames=['subcommand'], + argvalues=[[subcommand] for subcommand in SUBCOMMANDS], + ids=[' '.join(subcommand) for subcommand in SUBCOMMANDS], +) +def test_cli_help_does_not_fail(subcommand): + subprocess.run( + [sys.executable, '-m', 'qtpy', *subcommand, '--help'], check=True, + ) + + +def test_cli_version(): + output = subprocess.run( + [sys.executable, '-m', 'qtpy', '--version'], + capture_output=True, + check=True, + encoding='utf-8', + ) + assert output.stdout.strip().split()[-1] == qtpy.__version__ + + +def test_cli_mypy_args(): + output = subprocess.run( + [sys.executable, '-m', 'qtpy', 'mypy-args'], + capture_output=True, + check=True, + encoding='utf-8', + ) + + if qtpy.PYQT5: + expected = ' '.join([ + '--always-true=PYQT5', + '--always-false=PYQT6', + '--always-false=PYSIDE2', + '--always-false=PYSIDE6', + ]) + elif qtpy.PYQT6: + expected = ' '.join([ + '--always-false=PYQT5', + '--always-true=PYQT6', + '--always-false=PYSIDE2', + '--always-false=PYSIDE6', + ]) + elif qtpy.PYSIDE2: + expected = ' '.join([ + '--always-false=PYQT5', + '--always-false=PYQT6', + '--always-true=PYSIDE2', + '--always-false=PYSIDE6', + ]) + elif qtpy.PYSIDE6: + expected = ' '.join([ + '--always-false=PYQT5', + '--always-false=PYQT6', + '--always-false=PYSIDE2', + '--always-true=PYSIDE6', + ]) + else: + assert False, 'No valid API to test' + + assert output.stdout.strip() == expected.strip() diff --git a/setup.cfg b/setup.cfg index 1183a59a..63b88881 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,3 +56,7 @@ test = pytest>=6,!=7.0.0,!=7.0.1 pytest-cov>=3.0.0 pytest-qt + +[options.entry_points] +console_scripts = + qtpy = qtpy.__main__:main