Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PR: Support mypy #217

Closed
wants to merge 14 commits into from
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ include CHANGELOG.md
include LICENSE.txt
include README.md
recursive-include qtpy/tests *.py *.ui
include qtpy/py.typed
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@ conda install qtpy
```


### mypy

A CLI is offered to help with usage of QtPy. Presently, the 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.

```
--always-false=PYQT4 --always-false=PYQT5 --always-false=PYSIDE --always-true=PYSIDE2
```

If using bash or similar, this can be integrated into the Mypy command line
as follows.

```console
$ env/bin/mypy --package mypackage $(env/bin/qtpy mypy-args)
```


## Contributing

Everyone is welcome to contribute!
Expand Down
6 changes: 4 additions & 2 deletions qtpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,10 @@ class PythonQtWarning(Warning):
warnings.warn('Selected binding "{}" could not be found, '
'using "{}"'.format(initial_api, API), RuntimeWarning)

API_NAME = {'pyqt5': 'PyQt5', 'pyqt': 'PyQt4', 'pyqt4': 'PyQt4',
'pyside': 'PySide', 'pyside2':'PySide2'}[API]
API_NAMES = {'pyqt5': 'PyQt5', 'pyqt': 'PyQt4', 'pyqt4': 'PyQt4',
'pyside': 'PySide', 'pyside2':'PySide2'}
APIS = sorted(name.upper() for name in set(API_NAMES.values()))
API_NAME = API_NAMES[API]

if PYQT4:
import sip
Expand Down
9 changes: 9 additions & 0 deletions qtpy/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import qtpy.cli


def main():
return qtpy.cli.cli()


if __name__ == "__main__":
main()
77 changes: 77 additions & 0 deletions qtpy/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# 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
altendky marked this conversation as resolved.
Show resolved Hide resolved
import sys
import textwrap


class RawDescriptionArgumentDefaultsHelpFormatter(
argparse.RawDescriptionHelpFormatter,
argparse.ArgumentDefaultsHelpFormatter,
):
pass


def cli(args=sys.argv[1:]):
parser = argparse.ArgumentParser(
description="Features in support of development with QtPy.",
formatter_class=RawDescriptionArgumentDefaultsHelpFormatter,
)

parser.set_defaults(func=parser.print_help)

cli_subparsers = parser.add_subparsers()

mypy_args_parser = cli_subparsers.add_parser(
name='mypy-args',
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=PYQT4 --always-false=PYQT5 --always-false=PYSIDE --always-true=PYSIDE2

Use such as:

env/bin/mypy --package mypackage $(env/bin/qtpy mypy-args)
"""
),
formatter_class=RawDescriptionArgumentDefaultsHelpFormatter,
)
mypy_args_parser.set_defaults(func=mypy_args)

arguments = parser.parse_args(args=args)

reserved_parameters = {'func'}
cleaned = {
k: v
for k, v in vars(arguments).items()
if k not in reserved_parameters
}

arguments.func(**cleaned)


def mypy_args():
options = {False: '--always-false', True: '--always-true'}

import qtpy

apis_active = {name: qtpy.API.lower() == name.lower() for name in qtpy.APIS}
print(' '.join(
f'{options[is_active]}={name.upper()}'
for name, is_active
in apis_active.items()
))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
))
))
def main():
cli()
if __name__ == "__main__":
main()

A bit of boilerplate to make it a little easier to run as a script in case the user doesn't actually have QtPy installed, but rather just checked out locally

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like encouraging not installing things. It makes it really hard to make things actually work. And why the indirection through main()? Would want a sys.exit()? Also, when would running this as a script work where running __main__.py or -m qtpy not work? Directly running files that are in packages is asking for a mess.

Copy link
Member

@CAM-Gerlach CAM-Gerlach Mar 23, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points, agreed this isn't necessary or desirable. FWIW, if I'd started this package from scratch, I'd have put the top-level package inside a src directory, pulled the tests outside and run them against the installed package, to avoid any temptation to or accidentally run from the local copy, and verify package build and installation works correctly.

And why the indirection through main()?

Just convention, and probably a foolish one, at least in this case where it doesn't do anything special.

Would want a sys.exit()?

I assume its a moot point, but I'm curious as to the need for sys.exit()? Won't it exit anyway with 0 upon completion, or non-zero on error?

Empty file added qtpy/py.typed
Empty file.
45 changes: 45 additions & 0 deletions qtpy/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from __future__ import absolute_import

import subprocess
import sys

import pytest

import qtpy


subcommands = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
subcommands = [
SUBCOMMANDS = [

As a top-level constant, shouldn't this be ALL_CAPS?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many people do that. I don't like it. No need to be YELLING. I presume it will get changed.

Copy link
Member

@CAM-Gerlach CAM-Gerlach Mar 23, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given Python has no runtime enforcement of constants staying constants, particularly for mutable objects like this list here which are effectively global state, it is particularly important to clearly distinguish them from regular variables. UPPER_CASE is the standard convention for global constants per PEP 8, Google style and pretty much every other Python style guide I'm aware of, is widely adopted in first and third-party packages, and is the convention used in this package.

['mypy'],
['mypy', 'args'],
]


@pytest.mark.parametrize(
argnames=['subcommand'],
argvalues=[[subcommand] for subcommand in subcommands],
ids=[' '.join(subcommand) for subcommand in subcommands],
Comment on lines +19 to +20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
argvalues=[[subcommand] for subcommand in subcommands],
ids=[' '.join(subcommand) for subcommand in subcommands],
argvalues=[[subcommand] for subcommand in SUBCOMMANDS],
ids=[' '.join(subcommand) for subcommand in SUBCOMMANDS],

To match previous

)
def test_cli_help_does_not_fail(subcommand):
# .check_call() over .run(..., check=True) because of py2
subprocess.check_call(
[sys.executable, '-m', 'qtpy', *subcommand, '--help'],
)


def test_cli_mypy_args():
output = subprocess.check_output(
[sys.executable, '-m', 'qtpy', 'mypy', 'args'],
)

if qtpy.PYQT4:
expected = b'--always-true=PYQT4 --always-false=PYQT5 --always-false=PYSIDE --always-false=PYSIDE2\n'
elif qtpy.PYQT5:
expected = b'--always-false=PYQT4 --always-true=PYQT5 --always-false=PYSIDE --always-false=PYSIDE2\n'
elif qtpy.PYSIDE:
expected = b'--always-false=PYQT4 --always-false=PYQT5 --always-true=PYSIDE --always-false=PYSIDE2\n'
elif qtpy.PYSIDE2:
expected = b'--always-false=PYQT4 --always-false=PYQT5 --always-false=PYSIDE --always-true=PYSIDE2\n'
else:
assert False, 'No valid API to test'

assert output == expected
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,7 @@
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5']
'Programming Language :: Python :: 3.5'],
entry_points={'console_scripts': 'qtpy = qtpy.__main__:main [cli]'},
include_package_data=True
)