diff --git a/.gitignore b/.gitignore index 8e0cec5..c0e1648 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,5 @@ dmypy.json poetry.lock .DS_Store README.html + +/a.out diff --git a/README.md b/README.md index e27047e..2bd06af 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,19 @@ takes two arguments: `set_contents` will return `True` if the pasteboard was successfully set; otherwise, `False`. It may also throw [RuntimeError](https://docs.python.org/3/library/exceptions.html#RuntimeError) if `data` can't be converted to an AppKit type. +### Getting file URLs + +```pycon +>>> import pasteboard +>>> pb = pasteboard.Pasteboard() +>>> pb.get_file_urls() +('/Users//Documents/foo.txt', '/Users//Documents/bar.txt') +``` + +**Warning** This API is new, and may change in future. + +Returns a `Tuple` of strings, or `None`. Also supports the **diff** parameter analogue to `get_contents`. + ## Development 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/)). diff --git a/build.py b/build.py index c456a08..7594e0a 100644 --- a/build.py +++ b/build.py @@ -6,6 +6,7 @@ pasteboard = Extension( "pasteboard._native", ["src/pasteboard/pasteboard.m"], + extra_compile_args=["-Wall", "-Wextra", "-Wpedantic", "-Werror"], extra_link_args=["-framework", "AppKit"], language="objective-c", ) diff --git a/pyproject.toml b/pyproject.toml index 133ebe2..25f6295 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pasteboard" -version = "0.3.2" +version = "0.3.3" description = "Pasteboard - Python interface for reading from NSPasteboard (macOS clipboard)" authors = ["Toby Fleming "] license = "MPL-2.0" @@ -26,13 +26,17 @@ keywords = ["macOS", "clipboard", "pasteboard"] build = "build.py" [tool.poetry.dependencies] -python = "^3.6" +python = ">=3.6,<4.0" [tool.poetry.dev-dependencies] black = "^20.8b1" -pytest = "^6.1.1" -hypothesis = "^5.37.4" -mypy = "^0.790" +pytest = "^6.2.0" +hypothesis = "^6.0.0" +mypy = "^0.800" +# This version is the last to support Python 3.6... +ipython = "7.16.1" +# ...and jedi is not properly pinned +jedi = "0.17.2" [build-system] requires = ["poetry_core>=1.0.0", "setuptools"] diff --git a/src/pasteboard/__init__.py b/src/pasteboard/__init__.py index 27fdb12..8466ffb 100644 --- a/src/pasteboard/__init__.py +++ b/src/pasteboard/__init__.py @@ -1,5 +1,16 @@ +# Pasteboard - Python interface for reading from NSPasteboard (macOS clipboard) +# Copyright (C) 2017-2021 Toby Fleming +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. import sys as _sys assert _sys.platform == "darwin", "pasteboard only works on macOS" from ._native import * + + +class PasteboardType: + """Make type hints not fail on import - don't use this class""" + + pass diff --git a/src/pasteboard/__init__.pyi b/src/pasteboard/__init__.pyi index 74c37d6..8503d89 100644 --- a/src/pasteboard/__init__.pyi +++ b/src/pasteboard/__init__.pyi @@ -1,9 +1,12 @@ -from typing import overload, AnyStr, Optional, Union - +# Pasteboard - Python interface for reading from NSPasteboard (macOS clipboard) +# Copyright (C) 2017-2021 Toby Fleming +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. +from typing import overload, AnyStr, Optional, Sequence, Union class PasteboardType: ... - HTML: PasteboardType PDF: PasteboardType PNG: PasteboardType @@ -12,29 +15,26 @@ 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: ... + def get_file_urls( + self, + diff: bool = ..., + ) -> Optional[Sequence[str]]: ... diff --git a/src/pasteboard/pasteboard.m b/src/pasteboard/pasteboard.m index 7bdfa3a..8543697 100644 --- a/src/pasteboard/pasteboard.m +++ b/src/pasteboard/pasteboard.m @@ -1,6 +1,6 @@ /* Pasteboard - Python interface for reading from NSPasteboard (macOS clipboard) - Copyright (C) 2017-2020 Toby Fleming + Copyright (C) 2017-2021 Toby Fleming This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can @@ -157,7 +157,7 @@ } PyDoc_STRVAR(pasteboard_get_contents__doc__, -"get_contents(type, diff=False) -> str/bytes/None\n\n" +"get_contents(type: PasteboardType = String, diff: bool = False) -> Union[str, bytes, None]\n\n" "Gets the contents of the pasteboard.\n\n" "type - The NSPasteboardType to get, see module members. Default is 'String'.\n" "diff - Only get the contents if it has changed. Otherwise, `None` is returned. " @@ -237,13 +237,74 @@ } PyDoc_STRVAR(pasteboard_set_contents__doc__, -"set_contents(data, type) -> True/False/None\n\n" +"set_contents(data: Union[str, bytes], type: PasteboardType = String) -> bool\n\n" "Sets the contents of the pasteboard.\n\n" "data - str or bytes-like object. If type is a string type and bytes is not " "UTF-8 encoded, the behaviour is undefined.\n" "type - The NSPasteboardType to get, see module members. Default is 'String'.\n" "Returns `True` if the operation was successful; otherwise, `False`."); +static PyObject * +pasteboard_get_file_urls(PyObject *self, PyObject *args, PyObject *kwargs) +{ + PasteboardState *state = (PasteboardState *)self; + int diff = 0; // FALSE + + static char *kwlist[] = {"diff", NULL}; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|p", kwlist, &diff)) { + return NULL; + } + + long long change_count = [state->board changeCount]; + + if (diff && (change_count == state->change_count)) { + Py_RETURN_NONE; + } + + state->change_count = change_count; + + // hopefully guard against non-file URLs + NSArray *supportedTypes = @[NSPasteboardTypeFileURL]; + NSString *bestType = [state->board availableTypeFromArray:supportedTypes]; + if (!bestType) { + Py_RETURN_NONE; + } + + NSArray *classes = @[[NSURL class]]; + NSDictionary *options = @{}; + NSArray *files = [state->board readObjectsForClasses:classes options:options]; + + Py_ssize_t len = (Py_ssize_t)[files count]; + PyObject *urls = PyTuple_New(len); + Py_ssize_t pos = 0; + for (NSURL *url in files) { + NSString *str = [url path]; + if ([url isFileURL] && str) { + PyTuple_SetItem(urls, pos, PyUnicode_FromString([str UTF8String])); + pos++; + } + } + + if (len != pos) { + if (_PyTuple_Resize(&urls, pos) != 0) { + PyErr_SetString(PyExc_RuntimeError, "Internal error: failed to resize tuple"); + return NULL; + } + } + return urls; +} + +PyDoc_STRVAR(pasteboard_get_file_urls__doc__, +"get_file_urls(diff: bool = False) -> Optional[Sequence[str]]\n\n" +"Gets the contents of the pasteboard as file URLs.\n\n" +"diff - Only get the contents if it has changed. Otherwise, `None` is returned. " +"Can be used for efficiently querying the pasteboard when polling for changes." +"Default is `False`.\n\n" +"Returns a sequence of strings corresponding to the file URL's path. " +"`None` is returned if an error occurred, there is no data of the requested " +"type, or `diff` was set to `True` and the contents has not changed since " +"the last query."); + static PyMethodDef pasteboard_methods[] = { { "get_contents", @@ -257,6 +318,12 @@ METH_VARARGS | METH_KEYWORDS, pasteboard_set_contents__doc__, }, + { + "get_file_urls", + (PyCFunction)pasteboard_get_file_urls, + METH_VARARGS | METH_KEYWORDS, + pasteboard_get_file_urls__doc__, + }, {NULL, NULL, 0, NULL} /* Sentinel */ }; @@ -318,22 +385,14 @@ goto except; } - // PASTEBOARD_TYPE(Color, ???) - // PASTEBOARD_TYPE(FindPanelSearchOptions, PROP) - // PASTEBOARD_TYPE(Font, ???) PASTEBOARD_TYPE(HTML, STRING) - // PASTEBOARD_TYPE(MultipleTextSelection, ???) PASTEBOARD_TYPE(PDF, DATA) PASTEBOARD_TYPE(PNG, DATA) PASTEBOARD_TYPE(RTF, STRING) - // PASTEBOARD_TYPE(RTFD, STRING) - // PASTEBOARD_TYPE(Ruler, ???) - // PASTEBOARD_TYPE(Sound, ???) PASTEBOARD_TYPE(String, STRING) PasteboardType_Default = __String; PASTEBOARD_TYPE(TIFF, DATA) PASTEBOARD_TYPE(TabularText, STRING) - // PASTEBOARD_TYPE(TextFinderOptions, PROP) Py_INCREF((PyObject *)&PasteboardType); if (PyModule_AddObject(module, "Pasteboard", (PyObject *)&PasteboardType) < 0) { diff --git a/test_fileurl.m b/test_fileurl.m new file mode 100644 index 0000000..ef3169a --- /dev/null +++ b/test_fileurl.m @@ -0,0 +1,14 @@ +#include + +int main() { + NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + [pb clearContents]; + NSArray *objects = @[ + [NSURL fileURLWithPath:@"/bin/ls"], + [NSURL URLWithString:@"https://developer.apple.com/"] + ]; + [pb writeObjects:objects]; + [pool drain]; + return 0; +} diff --git a/tests.py b/tests.py index 776663a..bfdc8f5 100644 --- a/tests.py +++ b/tests.py @@ -2,7 +2,10 @@ import pytest import mypy.api -from hypothesis import assume, given, strategies as st +from hypothesis import given, strategies as st +from pasteboard import Pasteboard, PasteboardType +from pathlib import Path +from typing import Tuple STRING_TYPES = ( pasteboard.String, @@ -17,16 +20,15 @@ @given(TEXT) -def test_get_set_contents_default(s): - assume(s.encode("utf-8")) - pb = pasteboard.Pasteboard() +def test_get_set_contents_default(s: str) -> None: + pb = Pasteboard() assert pb.set_contents(s) assert pb.get_contents() == s @given(TEXT) -def test_get_contents_diff_not_none_after_set(s): - pb = pasteboard.Pasteboard() +def test_get_contents_diff_not_none_after_set(s: str) -> None: + pb = Pasteboard() assert pb.set_contents(s) assert pb.get_contents(diff=True) == s assert pb.get_contents(diff=True) is None @@ -34,8 +36,8 @@ def test_get_contents_diff_not_none_after_set(s): @pytest.mark.parametrize("type", STRING_TYPES) @given(TEXT) -def test_get_set_contents_string(type, s): - pb = pasteboard.Pasteboard() +def test_get_set_contents_string(type: PasteboardType, s: str) -> None: + pb = Pasteboard() assert pb.set_contents(s, type=type) assert pb.get_contents(type=type) == s assert pb.get_contents(type=type, diff=True) is None @@ -43,22 +45,22 @@ def test_get_set_contents_string(type, s): @pytest.mark.parametrize("type", BINARY_TYPES) @given(st.binary()) -def test_get_set_contents_data(type, s): - pb = pasteboard.Pasteboard() - assert pb.set_contents(s, type=type) - assert pb.get_contents(type=type) == s +def test_get_set_contents_data(type: PasteboardType, b: bytes) -> None: + pb = Pasteboard() + assert pb.set_contents(b, type=type) + assert pb.get_contents(type=type) == b assert pb.get_contents(type=type, diff=True) is None -def test_get_set_contents_with_null_char(): - pb = pasteboard.Pasteboard() +def test_get_set_contents_with_null_char() -> None: + pb = Pasteboard() assert pb.set_contents("abc\x00def") assert pb.get_contents() == "abc" -def test_get_set_contents_with_emoji_santa(): +def test_get_set_contents_with_emoji_santa() -> None: s = "\x1f385" - pb = pasteboard.Pasteboard() + pb = Pasteboard() assert pb.set_contents(s) assert pb.get_contents() == s @@ -75,11 +77,18 @@ def test_get_set_contents_with_emoji_santa(): (pasteboard.TIFF, "public.tiff"), ], ) -def test_types_repr(type, name): - assert repr(type) == "".format(name) +def test_types_repr(type: PasteboardType, name: str) -> None: + assert repr(type) == f"" + + +def test_file_urls() -> None: + pb = Pasteboard() + assert pb.set_contents("abc\x00def") + assert pb.get_file_urls() is None + assert pb.get_file_urls(diff=True) is None -def mypy_run(tmp_path, content): +def mypy_run(tmp_path: Path, content: str) -> Tuple[str, str, int]: py = tmp_path / "test.py" py.write_text(content) filename = str(py) @@ -87,7 +96,7 @@ def mypy_run(tmp_path, content): return normal_report.replace(filename, "test.py"), error_report, exit_status -def test_type_hints_pasteboard_valid(tmp_path): +def test_type_hints_pasteboard_valid(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, """ @@ -98,7 +107,7 @@ def test_type_hints_pasteboard_valid(tmp_path): assert exit_status == 0, normal_report -def test_type_hints_pasteboard_invalid_args(tmp_path): +def test_type_hints_pasteboard_invalid_args(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, """ @@ -110,7 +119,7 @@ def test_type_hints_pasteboard_invalid_args(tmp_path): assert 'Too many arguments for "Pasteboard"' in normal_report -def test_type_hints_pasteboard_invalid_kwargs(tmp_path): +def test_type_hints_pasteboard_invalid_kwargs(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, """ @@ -122,19 +131,20 @@ def test_type_hints_pasteboard_invalid_kwargs(tmp_path): assert 'Unexpected keyword argument "foo" for "Pasteboard"' in normal_report -def test_type_hints_get_contents_valid_no_args(tmp_path): +def test_type_hints_get_contents_valid_no_args(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, """ from pasteboard import Pasteboard +from typing import Optional pb = Pasteboard() -s: str = pb.get_contents() +s: Optional[str] = pb.get_contents() """, ) assert exit_status == 0, normal_report -def test_type_hints_get_contents_valid_diff_arg(tmp_path): +def test_type_hints_get_contents_valid_diff_arg(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, """ @@ -148,7 +158,7 @@ def test_type_hints_get_contents_valid_diff_arg(tmp_path): assert exit_status == 0, normal_report -def test_type_hints_get_contents_valid_type_args(tmp_path): +def test_type_hints_get_contents_valid_type_args(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, """ @@ -166,7 +176,7 @@ def test_type_hints_get_contents_valid_type_args(tmp_path): assert exit_status == 0, normal_report -def test_type_hints_get_contents_valid_both_args(tmp_path): +def test_type_hints_get_contents_valid_both_args(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, """ @@ -185,7 +195,7 @@ def test_type_hints_get_contents_valid_both_args(tmp_path): @pytest.mark.parametrize("arg", ['"bar"', 'foo="bar"', 'type="bar"', 'diff="bar"']) -def test_type_hints_get_contents_invalid_arg(arg, tmp_path): +def test_type_hints_get_contents_invalid_arg(arg: str, tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, f""" @@ -199,7 +209,7 @@ def test_type_hints_get_contents_invalid_arg(arg, tmp_path): @pytest.mark.parametrize("arg", ['"bar"', 'b"bar"']) -def test_type_hints_set_contents_valid_no_args(arg, tmp_path): +def test_type_hints_set_contents_valid_no_args(arg: str, tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, f""" @@ -212,7 +222,7 @@ def test_type_hints_set_contents_valid_no_args(arg, tmp_path): @pytest.mark.parametrize("arg", ['"bar"', 'b"bar"']) -def test_type_hints_set_contents_valid_type_args(arg, tmp_path): +def test_type_hints_set_contents_valid_type_args(arg: str, tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, f""" @@ -224,7 +234,7 @@ def test_type_hints_set_contents_valid_type_args(arg, tmp_path): assert exit_status == 0, normal_report -def test_type_hints_set_contents_invalid_arg(tmp_path): +def test_type_hints_set_contents_invalid_arg(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, f""" @@ -237,7 +247,7 @@ def test_type_hints_set_contents_invalid_arg(tmp_path): assert '"set_contents" of "Pasteboard" cannot be "int"' in normal_report -def test_type_hints_set_contents_invalid_type_arg(tmp_path): +def test_type_hints_set_contents_invalid_type_arg(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, f""" @@ -251,7 +261,7 @@ def test_type_hints_set_contents_invalid_type_arg(tmp_path): assert msg in normal_report -def test_type_hints_set_contents_invalid_kwarg(tmp_path): +def test_type_hints_set_contents_invalid_kwarg(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, f""" @@ -267,7 +277,7 @@ def test_type_hints_set_contents_invalid_kwarg(tmp_path): ) -def test_type_hints_set_contents_invalid_result(tmp_path): +def test_type_hints_set_contents_invalid_result(tmp_path: Path) -> None: normal_report, error_report, exit_status = mypy_run( tmp_path, f"""