diff --git a/docs/source/platypush/responses/qrcode.rst b/docs/source/platypush/responses/qrcode.rst deleted file mode 100644 index 16699a69c3..0000000000 --- a/docs/source/platypush/responses/qrcode.rst +++ /dev/null @@ -1,5 +0,0 @@ -``qrcode`` -===================================== - -.. automodule:: platypush.message.response.qrcode - :members: diff --git a/docs/source/platypush/responses/ssh.rst b/docs/source/platypush/responses/ssh.rst deleted file mode 100644 index 32ddd61b2e..0000000000 --- a/docs/source/platypush/responses/ssh.rst +++ /dev/null @@ -1,5 +0,0 @@ -``ssh`` -================================== - -.. automodule:: platypush.message.response.ssh - :members: diff --git a/docs/source/responses.rst b/docs/source/responses.rst index 925f56c826..fff5bcdc5b 100644 --- a/docs/source/responses.rst +++ b/docs/source/responses.rst @@ -8,7 +8,5 @@ Responses platypush/responses/google.drive.rst platypush/responses/printer.cups.rst - platypush/responses/qrcode.rst - platypush/responses/ssh.rst platypush/responses/tensorflow.rst platypush/responses/translate.rst diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index 9d6511895f..725014087a 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -9,9 +9,9 @@ "version": "0.1.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", - "axios": "^1.6.7", + "axios": "^1.6.8", "core-js": "^3.37.0", - "cronstrue": "^2.48.0", + "cronstrue": "^2.49.0", "highlight.js": "^11.9.0", "lato-font": "^3.0.0", "mitt": "^2.1.0", @@ -19,8 +19,8 @@ "sass": "^1.75.0", "sass-loader": "^10.5.2", "vue": "^3.4.23", - "vue-router": "^4.3.0", - "vue-skycons": "^4.2.0", + "vue-router": "^4.3.2", + "vue-skycons": "^4.3.4", "w3css": "^2.7.0" }, "devDependencies": { @@ -3773,11 +3773,11 @@ } }, "node_modules/axios": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -4700,9 +4700,9 @@ } }, "node_modules/cronstrue": { - "version": "2.48.0", - "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.48.0.tgz", - "integrity": "sha512-w+VAWjiBJmKYeeK+i0ur3G47LcKNgFuWwb8LVJTaXSS2ExtQ5zdiIVnuysgB3N457gTaSllme0qTpdsJWK/wIg==", + "version": "2.49.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.49.0.tgz", + "integrity": "sha512-FWZBqdStQaPR8ZTBQGALh1EK9Hl1HcG70dyGvD1rKLPafFO3H73o38dz/e8YkIlbLn3JxmBI/f6Doe3Nh+DcEQ==", "bin": { "cronstrue": "bin/cli.js" } @@ -12205,9 +12205,9 @@ } }, "node_modules/vue-router": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.0.tgz", - "integrity": "sha512-dqUcs8tUeG+ssgWhcPbjHvazML16Oga5w34uCUmsk7i0BcnskoLGwjpa15fqMr2Fa5JgVBrdL2MEgqz6XZ/6IQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.2.tgz", + "integrity": "sha512-hKQJ1vDAZ5LVkKEnHhmm1f9pMiWIBNGF5AwU67PdH7TyXCj/a4hTccuUuYCAMgJK6rO/NVYtQIEN3yL8CECa7Q==", "dependencies": { "@vue/devtools-api": "^6.5.1" }, @@ -12219,9 +12219,9 @@ } }, "node_modules/vue-skycons": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/vue-skycons/-/vue-skycons-4.2.0.tgz", - "integrity": "sha512-Zbw9lHXNjorpzReEqsyvPty3NIY2GCMKKZmGLpt+XKUrxEJvqVcPA+OwMxhnIgZvqiZH7b+tvHeGtq2ximf8Vg==" + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/vue-skycons/-/vue-skycons-4.3.4.tgz", + "integrity": "sha512-fa1P6qoi2TBuuG/Us++fHlET+ywpfFbhAYPWjpYHF0/i5MNYbjKKKOmzqbImWen+rwg2drF6UGDgxIYhweXDlQ==" }, "node_modules/vue-style-loader": { "version": "4.1.3", diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 44799999e5..7b6dc547bb 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -9,9 +9,9 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", - "axios": "^1.6.7", + "axios": "^1.6.8", "core-js": "^3.37.0", - "cronstrue": "^2.48.0", + "cronstrue": "^2.49.0", "highlight.js": "^11.9.0", "lato-font": "^3.0.0", "mitt": "^2.1.0", @@ -19,8 +19,8 @@ "sass": "^1.75.0", "sass-loader": "^10.5.2", "vue": "^3.4.23", - "vue-router": "^4.3.0", - "vue-skycons": "^4.2.0", + "vue-router": "^4.3.2", + "vue-skycons": "^4.3.4", "w3css": "^2.7.0" }, "devDependencies": { diff --git a/platypush/components.json.gz b/platypush/components.json.gz index 6f30257671..40eea2e2c0 100644 Binary files a/platypush/components.json.gz and b/platypush/components.json.gz differ diff --git a/platypush/message/event/qrcode.py b/platypush/message/event/qrcode.py index 50260afff0..46eb66d25c 100644 --- a/platypush/message/event/qrcode.py +++ b/platypush/message/event/qrcode.py @@ -1,18 +1,19 @@ from typing import List from platypush.message.event import Event -from platypush.message.response.qrcode import ResultModel - - -class QrcodeEvent(Event): - pass class QrcodeScannedEvent(Event): """ Event triggered when a QR-code or bar code is scanned. """ - def __init__(self, results: List[ResultModel], *args, **kwargs): + + def __init__(self, results: List[dict], *args, **kwargs): + """ + :param results: List of decoded QR code results: + + .. schema:: qrcode.QrcodeDecodedResultSchema(many=True) + """ super().__init__(*args, results=results, **kwargs) diff --git a/platypush/message/response/qrcode.py b/platypush/message/response/qrcode.py deleted file mode 100644 index c31d0f47e3..0000000000 --- a/platypush/message/response/qrcode.py +++ /dev/null @@ -1,61 +0,0 @@ -import base64 -from typing import Optional, List - -from pyzbar.pyzbar import Decoded -from pyzbar.locations import Rect - -from platypush.message import Mapping -from platypush.message.response import Response - - -class QrcodeResponse(Response): - pass - - -class QrcodeGeneratedResponse(QrcodeResponse): - # noinspection PyShadowingBuiltins - def __init__(self, - content: str, - format: str, - data: Optional[str] = None, - image_file: Optional[str] = None, - *args, **kwargs): - super().__init__(*args, output={ - 'text': content, - 'data': data, - 'format': format, - 'image_file': image_file, - }, **kwargs) - - -class RectModel(Mapping): - def __init__(self, rect: Rect): - super().__init__() - self.left = rect.left - self.top = rect.top - self.width = rect.width - self.height = rect.height - - -class ResultModel(Mapping): - def __init__(self, result: Decoded, *args, **kwargs): - super().__init__(*args, **kwargs) - try: - data = result.data.decode() - except (ValueError, TypeError): - data = base64.encodebytes(result.data).decode() - - self.data = data - self.type = result.type - self.rect = dict(RectModel(result.rect)) if result.rect else {} - - -class QrcodeDecodedResponse(QrcodeResponse): - def __init__(self, results: List[Decoded], image_file: Optional[str] = None, *args, **kwargs): - super().__init__(*args, output={ - 'image_file': image_file, - 'results': [dict(ResultModel(result)) for result in results], - }, **kwargs) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/message/response/ssh.py b/platypush/message/response/ssh.py deleted file mode 100644 index bf8d4afbc8..0000000000 --- a/platypush/message/response/ssh.py +++ /dev/null @@ -1,18 +0,0 @@ -from platypush.message.response import Response - - -class SSHResponse(Response): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class SSHKeygenResponse(SSHResponse): - def __init__(self, fingerprint: str, key_file: str, pub_key_file: str, *args, **kwargs): - super().__init__(*args, output={ - 'fingerprint': fingerprint, - 'key_file': key_file, - 'pub_key_file': pub_key_file, - }, **kwargs) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/qrcode/__init__.py b/platypush/plugins/qrcode/__init__.py index b211cb23c0..eb9e758379 100644 --- a/platypush/plugins/qrcode/__init__.py +++ b/platypush/plugins/qrcode/__init__.py @@ -5,16 +5,20 @@ import threading import time from typing import Optional, List +import qrcode +from pyzbar import pyzbar +from PIL import Image + from platypush import Config from platypush.context import get_bus from platypush.message.event.qrcode import QrcodeScannedEvent -from platypush.message.response.qrcode import ( - QrcodeGeneratedResponse, - QrcodeDecodedResponse, - ResultModel, -) from platypush.plugins import Plugin, action from platypush.plugins.camera import CameraPlugin +from platypush.schemas.qrcode import ( + QrcodeDecodedSchema, + QrcodeDecodedResultSchema, + QrcodeGeneratedSchema, +) from platypush.utils import get_plugin_class_by_name @@ -25,8 +29,10 @@ class QrcodePlugin(Plugin): def __init__(self, camera_plugin: Optional[str] = None, **kwargs): """ - :param camera_plugin: Name of the plugin that will be used as a camera to capture images (e.g. - ``camera.cv`` or ``camera.pi``). + :param camera_plugin: Name of the plugin that will be used as a camera + to capture images (e.g. ``camera.cv`` or ``camera.pi``). This is + required if you want to use the ``start_scanning`` action to scan + QR codes from a camera. """ super().__init__(**kwargs) self.camera_plugin = camera_plugin @@ -36,6 +42,7 @@ class QrcodePlugin(Plugin): self, camera_plugin: Optional[str] = None, **config ) -> CameraPlugin: camera_plugin = camera_plugin or self.camera_plugin + assert camera_plugin, 'No camera plugin specified' if not config: config = Config.get(camera_plugin) or {} config['stream_raw_frames'] = True @@ -43,7 +50,7 @@ class QrcodePlugin(Plugin): cls = get_plugin_class_by_name(camera_plugin) assert cls and issubclass( cls, CameraPlugin - ), '{} is not a valid camera plugin'.format(camera_plugin) + ), f'{camera_plugin} is not a valid camera plugin' return cls(**config) @action @@ -53,23 +60,24 @@ class QrcodePlugin(Plugin): output_file: Optional[str] = None, show: bool = False, format: str = 'png', - ) -> QrcodeGeneratedResponse: + ) -> dict: """ Generate a QR code. - If you configured the :class:`platypush.backend.http.HttpBackend` then you can also generate - codes directly from the browser through ``http://:/qrcode?content=...``. + + If you configured the :class:`platypush.backend.http.HttpBackend` then + you can also generate codes directly from the browser through + ``http://:/qrcode?content=...``. :param content: Text, URL or content of the QR code. - :param output_file: If set then the QR code will be exported in the specified image file. - Otherwise, a base64-encoded representation of its binary content will be returned in - the response as ``data``. - :param show: If True, and if the device where the application runs has an active display, - then the generated QR code will be shown on display. + :param output_file: If set then the QR code will be exported in the + specified image file. Otherwise, a base64-encoded representation of + its binary content will be returned in the response as ``data``. + :param show: If True, and if the device where the application runs has + an active display, then the generated QR code will be shown on + display. :param format: Output image format (default: ``png``). - :return: :class:`platypush.message.response.qrcode.QrcodeGeneratedResponse`. + :return: .. schema:: qrcode.QrcodeGeneratedSchema """ - import qrcode - qr = qrcode.make(content) img = qr.get_image() ret = { @@ -79,6 +87,7 @@ class QrcodePlugin(Plugin): if show: img.show() + if output_file: output_file = os.path.abspath(os.path.expanduser(output_file)) img.save(output_file, format=format) @@ -88,40 +97,28 @@ class QrcodePlugin(Plugin): img.save(f, format=format) ret['data'] = base64.encodebytes(f.getvalue()).decode() - return QrcodeGeneratedResponse(**ret) + return dict(QrcodeGeneratedSchema().dump(ret)) @action - def decode(self, image_file: str) -> QrcodeDecodedResponse: + def decode(self, image_file: str) -> dict: """ Decode a QR code from an image file. :param image_file: Path of the image file. + :return: .. schema:: qrcode.QrcodeDecodedSchema """ - from pyzbar import pyzbar - from PIL import Image - image_file = os.path.abspath(os.path.expanduser(image_file)) - img = Image.open(image_file) - results = pyzbar.decode(img) - return QrcodeDecodedResponse(results) - - @staticmethod - def _convert_frame(frame): - import numpy as np - from PIL import Image - - assert isinstance( - frame, np.ndarray - ), 'Image conversion only works with numpy arrays for now (got {})'.format( - type(frame) - ) - mode = 'RGB' - if len(frame.shape) > 2 and frame.shape[2] == 4: - mode = 'RGBA' - - return Image.frombuffer( - mode, (frame.shape[1], frame.shape[0]), frame, 'raw', mode, 0, 1 - ) + with open(image_file, 'rb') as f: + img = Image.open(f) + results = pyzbar.decode(img) + return dict( + QrcodeDecodedSchema().dump( + { + 'results': results, + 'image_file': image_file, + } + ) + ) @action def start_scanning( @@ -129,29 +126,31 @@ class QrcodePlugin(Plugin): camera_plugin: Optional[str] = None, duration: Optional[float] = None, n_codes: Optional[int] = None, - ) -> Optional[List[ResultModel]]: + ) -> Optional[List[dict]]: """ Decode QR-codes and bar codes using a camera. :param camera_plugin: Camera plugin (overrides default ``camera_plugin``). - :param duration: How long the capturing phase should run (default: until ``stop_scanning`` or app termination). + :param duration: How long the capturing phase should run (default: + until ``stop_scanning`` or app termination). :param n_codes: Stop after decoding this number of codes (default: None). - :return: When ``duration`` or ``n_codes`` are specified or ``stop_scanning`` is called, it will return a list of - :class:`platypush.message.response.qrcode.ResultModel` instances with the scanned results, + :return: .. schema:: qrcode.QrcodeDecodedResultSchema(many=True) """ - from pyzbar import pyzbar - assert not self._capturing.is_set(), 'A capturing process is already running' camera = self._get_camera(camera_plugin) codes = [] last_results = {} - last_results_timeout = 10.0 + last_results_timeout = 5.0 last_results_time = 0 self._capturing.set() try: - with camera: + with camera.open( + stream=True, + frames_dir=None, + ) as session: + camera.start_camera(session) start_time = time.time() while ( @@ -159,29 +158,24 @@ class QrcodePlugin(Plugin): and (not duration or time.time() < start_time + duration) and (not n_codes or len(codes) < n_codes) ): - output = camera.get_stream() - with output.ready: - output.ready.wait() - img = self._convert_frame(output.raw_frame) - results = pyzbar.decode(img) - if results: - results = [ - result - for result in QrcodeDecodedResponse(results).output[ - 'results' - ] - if result['data'] not in last_results - or time.time() - >= last_results_time + last_results_timeout - ] + img = camera.capture_frame(session) + results = pyzbar.decode(img) - if results: - codes.extend(results) - get_bus().post(QrcodeScannedEvent(results=results)) - last_results = { - result['data']: result for result in results - } - last_results_time = time.time() + if results: + results = [ + result + for result in QrcodeDecodedResultSchema().dump( + results, many=True + ) + if result['data'] not in last_results + or time.time() >= last_results_time + last_results_timeout + ] + + if results: + codes.extend(results) + get_bus().post(QrcodeScannedEvent(results=results)) + last_results = {result['data']: result for result in results} + last_results_time = time.time() finally: self._capturing.clear() diff --git a/platypush/plugins/qrcode/manifest.yaml b/platypush/plugins/qrcode/manifest.yaml index faee6bfbf4..a88230ac76 100644 --- a/platypush/plugins/qrcode/manifest.yaml +++ b/platypush/plugins/qrcode/manifest.yaml @@ -21,7 +21,7 @@ manifest: - python-numpy - python-pillow - python-qrcode - - pyzbar + # - pyzbar # Only available via yay for now pip: - numpy - qrcode diff --git a/platypush/plugins/ssh/__init__.py b/platypush/plugins/ssh/__init__.py index ee7e5c9b8a..36c026d11b 100644 --- a/platypush/plugins/ssh/__init__.py +++ b/platypush/plugins/ssh/__init__.py @@ -19,15 +19,18 @@ from stat import ( from typing import Optional, Dict, Tuple, List, Union, Any from paramiko import DSSKey, RSAKey, SSHClient, WarningPolicy, SFTPClient -from paramiko.py3compat import u try: - from paramiko import GSS_AUTH_AVAILABLE + from paramiko.util import u +except ImportError: + from paramiko.py3compat import u # type: ignore + +try: + from paramiko import GSS_AUTH_AVAILABLE # type: ignore except ImportError: from paramiko.ssh_gss import GSS_AUTH_AVAILABLE from platypush import Response -from platypush.message.response.ssh import SSHKeygenResponse from platypush.plugins import Plugin, action from platypush.plugins.ssh.tunnel.forward import forward_tunnel from platypush.plugins.ssh.tunnel.reverse import reverse_tunnel, close_tunnel @@ -44,8 +47,8 @@ class SshPlugin(Plugin): self, key_file: Optional[str] = None, passphrase: Optional[str] = None, **kwargs ): """ - :param key_file: Default key file (default: any "id_rsa", "id_dsa", "id_ecdsa", or "id_ed25519" key discoverable - in ``~/.ssh/``. + :param key_file: Default key file (default: any "id_rsa", "id_dsa", + "id_ecdsa", or "id_ed25519" key discoverable in ``~/.ssh/``. :param passphrase: Key file passphrase (default: None). """ super().__init__(**kwargs) @@ -71,38 +74,46 @@ class SshPlugin(Plugin): if host.find('@') >= 0: user, host = host.split('@') if host.find(':') >= 0: - host, port = host.split(':') + host, p = host.split(':') + port = int(p) if not user: user = getpass.getuser() - return host, int(port), user + return host, port, user - # noinspection PyShadowingBuiltins @action def keygen( self, filename: str, - type: str = 'rsa', + type: str = 'rsa', # pylint: disable=redefined-builtin bits: int = 4096, comment: Optional[str] = None, passphrase: Optional[str] = None, - ) -> SSHKeygenResponse: + ) -> dict: """ Generate an SSH keypair. - :param filename: Output file name for the private key (the public key will be stored in .pub). + :param filename: Output file name for the private key (the public key + will be stored in .pub). :param type: Encryption algorithm, either "rsa" or "dsa" (default: "rsa"). :param bits: Key length in bits (default: 4096). :param comment: Key comment (default: None). :param passphrase: Key passphrase (default: None). - :return: :class:`platypush.message.response.ssh.SSHKeygenResponse`. + :return: + + .. code-block:: json + + { + "fingerprint": "SHA256:...", + "key_file": "private_key_file", + "pub_key_file": "public_key_file" + } + """ assert type != 'dsa' or bits <= 1024, 'DSA keys support a maximum of 1024 bits' assert ( type in self.key_dispatch_table - ), 'No such type: {}. Available types: {}'.format( - type, self.key_dispatch_table.keys() - ) + ), f'No such type: {type}. Available types: {self.key_dispatch_table.keys()}' if filename: filename = os.path.abspath(os.path.expanduser(filename)) @@ -110,22 +121,23 @@ class SshPlugin(Plugin): prv = self.key_dispatch_table[type].generate(bits=bits) prv.write_private_key_file(filename=filename, password=passphrase) pub = self.key_dispatch_table[type](filename=filename, password=passphrase) - pub_file = '{filename}.pub'.format(filename=filename) + pub_file = f'{filename}.pub' with open(pub_file, 'w') as f: - f.write('{name} {key}'.format(name=pub.get_name(), key=pub.get_base64())) + f.write(f'{pub.get_name()} {pub.get_base64()}') if comment: f.write(' ' + comment) - hash = u(hexlify(pub.get_fingerprint())) - return SSHKeygenResponse( - fingerprint=hash, key_file=filename, pub_key_file=pub_file - ) + return { + 'fingerprint': u(hexlify(pub.get_fingerprint())), + 'key_file': filename, + 'pub_key_file': pub_file, + } def run(self, *args, **kwargs): try: return super().run(*args, **kwargs) except Exception as e: - raise AssertionError(e) + raise AssertionError(e) from e def _connect( self, @@ -144,9 +156,10 @@ class SshPlugin(Plugin): key = (host, port, user) if key in self._sessions: self.logger.info( - '[Connect] The SSH session is already active: {user}@{host}:{port}'.format( - user=user, host=host, port=port - ) + '[Connect] The SSH session is already active: %s@%s:%d', + user, + host, + port, ) return self._sessions[key] @@ -178,7 +191,7 @@ class SshPlugin(Plugin): return client except Exception as e: self.logger.exception(e) - raise AssertionError('Connection to {} failed: {}'.format(host, str(e))) + raise AssertionError(f'Connection to {host} failed: {type(e)}: {e}') @action def connect( @@ -231,9 +244,7 @@ class SshPlugin(Plugin): key = (host, port, user) if key not in self._sessions: self.logger.info( - '[Disconnect] The SSH session is not active: {user}@{host}:{port}'.format( - user=user, host=host, port=port - ) + '[Disconnect] The SSH session is not active: %s@%s:%d', user, host, port ) session = self._sessions[key] @@ -269,13 +280,13 @@ class SshPlugin(Plugin): def decode(buf: bytes) -> str: try: - buf = buf.decode() + s_buf = buf.decode() except (ValueError, TypeError): - buf = base64.encodebytes(buf).decode() + s_buf = base64.encodebytes(buf).decode() - if buf.endswith('\n'): - buf = buf[:-1] - return buf + if s_buf.endswith('\n'): + s_buf = s_buf[:-1] + return s_buf try: _in, _out, _err = client.exec_command(cmd, timeout=timeout, environment=env) @@ -300,6 +311,9 @@ class SshPlugin(Plugin): @staticmethod def is_directory(sftp: SFTPClient, path: str) -> bool: f = sftp.lstat(path) + if f.st_mode is None: + return False + return S_ISDIR(f.st_mode) @classmethod @@ -308,7 +322,7 @@ class SshPlugin(Plugin): folders = [] for f in sftp.listdir_attr(path): - if S_ISDIR(f.st_mode): + if f.st_mode is not None and S_ISDIR(f.st_mode): folders.append(f.filename) else: files.append(f.filename) @@ -317,8 +331,7 @@ class SshPlugin(Plugin): for folder in folders: new_path = os.path.join(path, folder) - for x in cls.sftp_walk(sftp, new_path): - yield x + yield from cls.sftp_walk(sftp, new_path) def sftp_get( self, @@ -341,10 +354,9 @@ class SshPlugin(Plugin): for file in files: self.logger.info( - 'Downloading file {} from {} to {}'.format( - file, path, new_local_path - ) + 'Downloading file %s from %s to %s', file, path, new_local_path ) + self.sftp_get( sftp, os.path.join(remote_path, path, file), @@ -421,16 +433,17 @@ class SshPlugin(Plugin): try: sftp.mkdir(remote_path) except Exception as e: - self.logger.warning(f'mkdir {remote_path}: {e}') + self.logger.warning( + 'mkdir %s failed: %s: %s', remote_path, type(e), e + ) assert ( recursive - ), '{} is a directory but recursive has been set to False'.format( - local_path - ) + ), f'{local_path} is a directory but recursive has been set to False' + assert self.is_directory( sftp, remote_path - ), '{} is not a directory on the remote host'.format(remote_path) + ), f'{remote_path} is not a directory on the remote host' sftp.chdir(remote_path) os.chdir(local_path) @@ -439,12 +452,14 @@ class SshPlugin(Plugin): try: sftp.mkdir(path) except Exception as e: - self.logger.warning(f'mkdir {remote_path}: {e}') + self.logger.warning( + 'mkdir %s failed: %s: %s', remote_path, type(e), e + ) for file in files: src = os.path.join(path, file) dst = os.path.join(path, file) - self.logger.info('Copying {} to {}'.format(src, dst)) + self.logger.info('Copying %s to %s', src, dst) sftp.put(src, dst) else: if self.is_directory(sftp, remote_path): @@ -475,7 +490,9 @@ class SshPlugin(Plugin): client = self._connect(**kwargs) sftp = client.open_sftp() - def get_file_type(st_mode: int) -> str: + def get_file_type(st_mode: Optional[int]) -> str: + if st_mode is None: + return 'unknown' if S_ISDIR(st_mode): return 'directory' elif S_ISBLK(st_mode): @@ -503,16 +520,24 @@ class SshPlugin(Plugin): 'longname': f.longname, 'attributes': f.attr, 'type': get_file_type(f.st_mode), - 'access_time': datetime.datetime.fromtimestamp(f.st_atime), - 'modify_time': datetime.datetime.fromtimestamp(f.st_mtime), + 'access_time': ( + datetime.datetime.fromtimestamp(f.st_atime) + if f.st_atime + else None + ), + 'modify_time': ( + datetime.datetime.fromtimestamp(f.st_mtime) + if f.st_mtime + else None + ), 'uid': f.st_uid, 'gid': f.st_gid, 'size': f.st_size, } for f in sftp.listdir_attr(path) } - else: - return sftp.listdir(path) + + return sftp.listdir(path) finally: if not keep_alive: host, port, user = self._get_host_port_user(**kwargs) @@ -692,7 +717,7 @@ class SshPlugin(Plugin): sftp = client.open_sftp() try: - return sftp.getcwd() + return sftp.getcwd() or '/' finally: if not keep_alive: host, port, user = self._get_host_port_user(**kwargs) @@ -704,24 +729,29 @@ class SshPlugin(Plugin): local_port: int, remote_host: str, remote_port: int, - bind_addr: Optional[str] = '', + bind_addr: str = '', **kwargs, ): """ - Start an SSH forward tunnel, tunnelling to :. + Start an SSH forward tunnel, tunnelling ```` to + ``:``. :param local_port: Local port. :param remote_host: Remote host. :param remote_port: Remote port. - :param bind_addr: If set, the `local_port` will be bound to this address/subnet (default: '', or 0.0.0.0: any). - :param kwargs: Arguments for :meth:`platypush.plugins.ssh.SshPlugin.connect`. + :param bind_addr: If set, the `local_port` will be bound to this + address/subnet (default: '', or 0.0.0.0: any). + :param kwargs: Arguments for + :meth:`platypush.plugins.ssh.SshPlugin.connect`. """ key = local_port, remote_host, remote_port if key in self._fwd_tunnels: self.logger.info( - 'The tunnel {}:{}:{}:{} is already active'.format( - bind_addr, local_port, remote_host, remote_port - ) + 'The tunnel %s:%d:%s:%d is already active', + bind_addr, + local_port, + remote_host, + remote_port, ) return @@ -753,9 +783,7 @@ class SshPlugin(Plugin): key = (local_port, remote_host, remote_port) if key not in self._fwd_tunnels: self.logger.warning( - 'No such forward tunnel: {}:{}:{}'.format( - local_port, remote_host, remote_port - ) + 'No such forward tunnel: %d:%s:%d', local_port, remote_host, remote_port ) return @@ -772,7 +800,7 @@ class SshPlugin(Plugin): server_port: int, remote_host: str, remote_port: int, - bind_addr: Optional[str] = '', + bind_addr: str = '', **kwargs, ): """ @@ -788,9 +816,11 @@ class SshPlugin(Plugin): key = server_port, remote_host, remote_port if key in self._fwd_tunnels: self.logger.info( - 'The tunnel {}:{}:{}:{} is already active'.format( - bind_addr, server_port, remote_host, remote_port - ) + 'The tunnel %s:%d:%s:%d is already active', + bind_addr, + server_port, + remote_host, + remote_port, ) return @@ -823,9 +853,10 @@ class SshPlugin(Plugin): key = (server_port, remote_host, remote_port) if key not in self._rev_tunnels: self.logger.warning( - 'No such reversed tunnel: {}:{}:{}'.format( - server_port, remote_host, remote_port - ) + 'No such reverse tunnel: %d:%s:%d', + server_port, + remote_host, + remote_port, ) return diff --git a/platypush/schemas/qrcode.py b/platypush/schemas/qrcode.py new file mode 100644 index 0000000000..1a4a755de1 --- /dev/null +++ b/platypush/schemas/qrcode.py @@ -0,0 +1,158 @@ +import base64 + +from marshmallow import EXCLUDE, fields, pre_dump +from marshmallow.schema import Schema + + +class QrcodeGeneratedSchema(Schema): + """ + Schema for a QR code generation response. + """ + + class Meta: # type: ignore + """ + Exclude unknown fields. + """ + + unknown = EXCLUDE + + text = fields.String( + required=True, + metadata={ + 'description': 'Text content of the QR code', + 'example': 'https://platypush.tech', + }, + ) + + data = fields.String( + metadata={ + 'description': 'Base64-encoded content of the QR code', + 'example': 'iVBORw0KGgoAAAANSUhEUgAAAXIAAAFyAQAAAADAX2yk', + } + ) + + format = fields.String( + metadata={ + 'description': 'Format of the QR code image', + 'example': 'png', + }, + ) + + image_file = fields.String( + metadata={ + 'description': 'Path to the generated QR code image file', + 'example': '/tmp/qr_code.png', + }, + ) + + +class QrcodeDecodedRectSchema(Schema): + """ + Schema for a single QR code decoding result rectangle. + """ + + x = fields.Integer( + required=True, + metadata={ + 'description': 'X coordinate of the rectangle in the image', + 'example': 0, + }, + ) + + y = fields.Integer( + required=True, + metadata={ + 'description': 'Y coordinate of the rectangle in the image', + 'example': 0, + }, + ) + + width = fields.Integer( + required=True, + metadata={ + 'description': 'Width of the rectangle', + 'example': 100, + }, + ) + + height = fields.Integer( + required=True, + metadata={ + 'description': 'Height of the rectangle', + 'example': 100, + }, + ) + + +class QrcodeDecodedResultSchema(Schema): + """ + Schema for a single QR code decoding result. + """ + + data = fields.String( + required=True, + metadata={ + 'description': 'Decoded QRcode data, as a base64-encoded string if binary', + 'example': 'https://platypush.tech', + }, + ) + + type = fields.String( + required=True, + metadata={ + 'description': ( + 'Type of code that was decoded. Supports the types available under the ' + '`pyzbar.ZBarSymbol` class: ' + 'https://github.com/NaturalHistoryMuseum/pyzbar/blob/master/pyzbar/wrapper.py#L43' + ), + 'example': 'QRCODE', + }, + ) + + rect = fields.Nested( + QrcodeDecodedRectSchema, + required=True, + metadata={ + 'description': 'Rectangle in the image where the QR code was found', + }, + ) + + @pre_dump + def pre_dump(self, data, **_): + if hasattr(data, '_asdict'): + data = data._asdict() + + try: + data['data'] = data['data'].decode() + except (ValueError, TypeError): + data['data'] = base64.b64encode(data['data']).decode() + + return data + + +class QrcodeDecodedSchema(Schema): + """ + Schema for a QR code decoding response. + """ + + class Meta: # type: ignore + """ + Exclude unknown fields. + """ + + unknown = EXCLUDE + + results = fields.List( + fields.Nested(QrcodeDecodedResultSchema), + required=True, + metadata={ + 'description': 'Decoded QR code results', + }, + ) + + image_file = fields.String( + metadata={ + 'description': 'Path to the image file that was decoded', + 'example': '/tmp/qr_code.png', + }, + ) diff --git a/platypush/utils/mock/modules.py b/platypush/utils/mock/modules.py index e83fd377e1..5426abe110 100644 --- a/platypush/utils/mock/modules.py +++ b/platypush/utils/mock/modules.py @@ -97,6 +97,7 @@ mock_imports = [ "pyotp", "pysmartthings", "pyzbar", + "qrcode", "rtmidi", "samsungtvws", "serial",