diff --git a/README.md b/README.md index 3d604d0..e27047e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [Pasteboard](https://pypi.org/project/pasteboard/) exposes Python bindings for reading and writing macOS' AppKit [NSPasteboard](https://developer.apple.com/documentation/appkit/nspasteboard). This allows retrieving different formats (HTML/RTF fragments, PDF/PNG/TIFF) and efficient polling of the pasteboard. +Now with type hints! + ## Installation Obviously, this module will only compile on **macOS**: @@ -64,6 +66,30 @@ takes two arguments: You don't need to know this if you're not changing `pasteboard.m` code. There are some integration tests in `tests.py` to check the module works as designed (using [pytest](https://docs.pytest.org/en/latest/) and [hypothesis](https://hypothesis.readthedocs.io/en/latest/)). +This project uses [pre-commit](https://pre-commit.com/) to run some linting hooks when committing. When you first clone the repo, please run: + +``` +pre-commit install +``` + +You may also run the hooks at any time: + +``` +pre-commit run --all-files +``` + +Dependencies are managed via [poetry](https://python-poetry.org/). To install all dependencies, use: + +``` +poetry install +``` + +This will also install development dependencies (`pytest`). To run the tests: + +``` +poetry run pytest tests.py --verbose +``` + ## License From version 0.3.0 and forwards, this library is licensed under the Mozilla Public License Version 2.0. For more information, see `LICENSE`. diff --git a/pyproject.toml b/pyproject.toml index 7633523..9946000 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pasteboard" -version = "0.3.0" +version = "0.3.1" description = "Pasteboard - Python interface for reading from NSPasteboard (macOS clipboard)" authors = ["Toby Fleming "] license = "MPL-2.0" @@ -31,6 +31,7 @@ python = "^3.6" black = "^19.10b0" pytest = "^5.3.5" hypothesis = "^5.5.4" +mypy = "^0.761" [build-system] requires = ["poetry>=1.0.0"] diff --git a/src/pasteboard/__init__.pyi b/src/pasteboard/__init__.pyi new file mode 100644 index 0000000..74c37d6 --- /dev/null +++ b/src/pasteboard/__init__.pyi @@ -0,0 +1,40 @@ +from typing import overload, AnyStr, Optional, Union + + +class PasteboardType: ... + + +HTML: PasteboardType +PDF: PasteboardType +PNG: PasteboardType +RTF: PasteboardType +String: PasteboardType +TIFF: PasteboardType +TabularText: PasteboardType + + +class Pasteboard: + @classmethod + def __init__(self) -> None: ... + + @overload + def get_contents(self) -> str: ... + + @overload + def get_contents( + self, + diff: bool = ..., + ) -> Optional[str]: ... + + @overload + def get_contents( + self, + type: PasteboardType = ..., + diff: bool = ..., + ) -> Union[str, bytes, None]: ... + + def set_contents( + self, + data: AnyStr, + type: PasteboardType = ..., + ) -> bool: ... diff --git a/src/pasteboard/pasteboard.m b/src/pasteboard/pasteboard.m index 8bdba1d..7bdfa3a 100644 --- a/src/pasteboard/pasteboard.m +++ b/src/pasteboard/pasteboard.m @@ -294,7 +294,7 @@ PyObject *__##name = pasteboardtype_new(NSPasteboardType##name, read); \ Py_INCREF(__##name); \ if (PyModule_AddObject(module, QUOTE(name), __##name) < 0) { \ - return NULL; \ + goto except; \ } PyMODINIT_FUNC @@ -315,7 +315,7 @@ PyObject *module = PyModule_Create(&pasteboard_module); if (!module) { - return NULL; + goto except; } // PASTEBOARD_TYPE(Color, ???) @@ -336,8 +336,15 @@ // PASTEBOARD_TYPE(TextFinderOptions, PROP) Py_INCREF((PyObject *)&PasteboardType); - PyModule_AddObject(module, "Pasteboard", (PyObject *)&PasteboardType); + if (PyModule_AddObject(module, "Pasteboard", (PyObject *)&PasteboardType) < 0) { + goto except; + } + goto finally; +except: + Py_XDECREF(module); + module = NULL; +finally: return module; } diff --git a/src/pasteboard/py.typed b/src/pasteboard/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests.py b/tests.py index 36093fa..c2c736c 100644 --- a/tests.py +++ b/tests.py @@ -1,5 +1,6 @@ import pasteboard import pytest +import mypy.api from hypothesis import assume, given, strategies as st @@ -76,3 +77,207 @@ def test_get_set_contents_with_emoji_santa(): ) def test_types_repr(type, name): assert repr(type) == "".format(name) + + +def mypy_run(tmp_path, content): + py = tmp_path / "test.py" + py.write_text(content) + filename = str(py) + normal_report, error_report, exit_status = mypy.api.run([filename, "--strict"]) + return normal_report.replace(filename, "test.py"), error_report, exit_status + + +def test_type_hints_pasteboard_valid(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + """ +from pasteboard import Pasteboard +pb = Pasteboard() +""", + ) + assert exit_status == 0, normal_report + + +def test_type_hints_pasteboard_invalid_args(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + """ +from pasteboard import Pasteboard +pb = Pasteboard("bar") +""", + ) + assert exit_status == 1, normal_report + assert 'Too many arguments for "Pasteboard"' in normal_report + + +def test_type_hints_pasteboard_invalid_kwargs(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + """ +from pasteboard import Pasteboard +pb = Pasteboard(foo="bar") +""", + ) + assert exit_status == 1, normal_report + assert 'Unexpected keyword argument "foo" for "Pasteboard"' in normal_report + + +def test_type_hints_get_contents_valid_no_args(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + """ +from pasteboard import Pasteboard +pb = Pasteboard() +s: str = pb.get_contents() +""", + ) + assert exit_status == 0, normal_report + + +def test_type_hints_get_contents_valid_diff_arg(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + """ +from pasteboard import Pasteboard +pb = Pasteboard() +s = pb.get_contents(diff=True) +if s: + s += "foo" +""", + ) + assert exit_status == 0, normal_report + + +def test_type_hints_get_contents_valid_type_args(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + """ +from pasteboard import Pasteboard, PNG +from typing import Union +pb = Pasteboard() +s = pb.get_contents(type=PNG) +if s: + if isinstance(s, str): + s += "foo" + else: + s += b"foo" +""", + ) + assert exit_status == 0, normal_report + + +def test_type_hints_get_contents_valid_both_args(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + """ +from pasteboard import Pasteboard, PNG +from typing import Union +pb = Pasteboard() +s = pb.get_contents(type=PNG, diff=True) +if s: + if isinstance(s, str): + s += "foo" + else: + s += b"foo" +""", + ) + assert exit_status == 0, normal_report + + +@pytest.mark.parametrize("arg", ['"bar"', 'foo="bar"', 'type="bar"', 'diff="bar"',]) +def test_type_hints_get_contents_invalid_arg(arg, tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + f""" +from pasteboard import Pasteboard +pb = Pasteboard() +pb.get_contents({arg}) +""", + ) + assert exit_status == 1, normal_report + assert "No overload variant" in normal_report + + +@pytest.mark.parametrize("arg", ['"bar"', 'b"bar"',]) +def test_type_hints_set_contents_valid_no_args(arg, tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + f""" +from pasteboard import Pasteboard +pb = Pasteboard() +result: bool = pb.set_contents({arg}) +""", + ) + assert exit_status == 0, normal_report + + +@pytest.mark.parametrize("arg", ['"bar"', 'b"bar"',]) +def test_type_hints_set_contents_valid_type_args(arg, tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + f""" +from pasteboard import Pasteboard, PNG +pb = Pasteboard() +result: bool = pb.set_contents({arg}, type=PNG) +""", + ) + assert exit_status == 0, normal_report + + +def test_type_hints_set_contents_invalid_arg(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + f""" +from pasteboard import Pasteboard +pb = Pasteboard() +result: bool = pb.set_contents(0) +""", + ) + assert exit_status == 1, normal_report + assert '"set_contents" of "Pasteboard" cannot be "int"' in normal_report + + +def test_type_hints_set_contents_invalid_type_arg(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + f""" +from pasteboard import Pasteboard +pb = Pasteboard() +result: bool = pb.set_contents("", type="bar") +""", + ) + assert exit_status == 1, normal_report + msg = 'Argument "type" to "set_contents" of "Pasteboard" has incompatible type "str"; expected "PasteboardType' + assert msg in normal_report + + +def test_type_hints_set_contents_invalid_kwarg(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + f""" +from pasteboard import Pasteboard +pb = Pasteboard() +result: bool = pb.set_contents("", foo="bar") +""", + ) + assert exit_status == 1, normal_report + assert ( + 'Unexpected keyword argument "foo" for "set_contents" of "Pasteboard"' + in normal_report + ) + + +def test_type_hints_set_contents_invalid_result(tmp_path): + normal_report, error_report, exit_status = mypy_run( + tmp_path, + f""" +from pasteboard import Pasteboard +pb = Pasteboard() +result: str = pb.set_contents("") +""", + ) + assert exit_status == 1, normal_report + assert ( + 'Incompatible types in assignment (expression has type "bool", variable has type "str")' + in normal_report + )