diff -Nru pantalaimon-0.8.0/CHANGELOG.md pantalaimon-0.9.1/CHANGELOG.md --- pantalaimon-0.8.0/CHANGELOG.md 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/CHANGELOG.md 2021-01-19 10:10:35.000000000 +0000 @@ -4,6 +4,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.9.1 2021-01-19 + +### Changed + +- [[3baae08]] Bump the allowed nio version + +## 0.9.0 2021-01-19 + +### Fixed + +- [[59051c5]] Fix the notification initialization allowing the DBUS thread to + start again + +### Added + +- [[#79]] Support media uploads, thanks to @aspacca + +[3baae08]: https://github.com/matrix-org/pantalaimon/commit/3baae08ac36e258632e224b655e177a765a939f3 +[59051c5]: https://github.com/matrix-org/pantalaimon/commit/59051c530a343a6887ea0f9ccddd6f6964f6d923 +[#79]: https://github.com/matrix-org/pantalaimon/pull/79 + ## 0.8.0 2020-09-30 ### Changed diff -Nru pantalaimon-0.8.0/debian/changelog pantalaimon-0.9.1/debian/changelog --- pantalaimon-0.8.0/debian/changelog 2020-10-15 10:48:03.000000000 +0000 +++ pantalaimon-0.9.1/debian/changelog 2021-01-25 01:28:10.000000000 +0000 @@ -1,3 +1,18 @@ +pantalaimon (0.9.1-1) unstable; urgency=medium + + [ upstream ] + * new release + + store: Add a missing use-database decorator; + closes: bug#980670, thanks to Lucas Nussbaum and s3v + + [ Jonas Smedegaard ] + * relax build-dependency on python3-matrix-nio + * declare compliance with Debian Policy 4.5.1 + * declare buildsystem explicitly (not in environment) + * use debhelper compatibility level 13 (not 12) + + -- Jonas Smedegaard Mon, 25 Jan 2021 02:28:10 +0100 + pantalaimon (0.8.0-2) unstable; urgency=medium * fix have python3-pantalaimon explicitly depend on python3-prompt-toolkit diff -Nru pantalaimon-0.8.0/debian/control pantalaimon-0.9.1/debian/control --- pantalaimon-0.8.0/debian/control 2020-10-15 10:46:45.000000000 +0000 +++ pantalaimon-0.9.1/debian/control 2021-01-25 01:27:53.000000000 +0000 @@ -5,7 +5,7 @@ Uploaders: Jonas Smedegaard Build-Depends: - debhelper-compat (= 12), + debhelper-compat (= 13), dh-python, dh-sequence-python3, python3, @@ -16,12 +16,12 @@ python3-janus , python3-keyring , python3-matrix-nio (>= 0.14) , - python3-matrix-nio (<< 0.16) , + python3-matrix-nio (<< 0.17) , python3-peewee, python3-prompt-toolkit (>= 2), python3-pytest , python3-setuptools, -Standards-Version: 4.5.0 +Standards-Version: 4.5.1 Homepage: https://github.com/matrix-org/pantalaimon Vcs-Git: https://salsa.debian.org/matrix-team/pantalaimon.git Vcs-Browser: https://salsa.debian.org/matrix-team/pantalaimon diff -Nru pantalaimon-0.8.0/debian/rules pantalaimon-0.9.1/debian/rules --- pantalaimon-0.8.0/debian/rules 2020-05-27 20:51:30.000000000 +0000 +++ pantalaimon-0.9.1/debian/rules 2021-01-25 01:27:20.000000000 +0000 @@ -1,9 +1,7 @@ #!/usr/bin/make -f -export DH_OPTIONS = -O--buildsystem=pybuild +%: + dh "$@" --buildsystem=pybuild override_dh_installchangelogs: dh_installchangelogs CHANGELOG.md - -%: - dh $@ diff -Nru pantalaimon-0.8.0/docs/man/pantalaimon.5 pantalaimon-0.9.1/docs/man/pantalaimon.5 --- pantalaimon-0.8.0/docs/man/pantalaimon.5 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/docs/man/pantalaimon.5 2021-01-19 10:10:35.000000000 +0000 @@ -128,7 +128,7 @@ The following example shows a configured pantalaimon proxy with the name .Em Clocktown , the homeserver URL is set to -.Em https://example.org , +.Em https://localhost:8448 , the pantalaimon proxy is listening for client connections on the address .Em localhost , and port diff -Nru pantalaimon-0.8.0/docs/man/pantalaimon.5.md pantalaimon-0.9.1/docs/man/pantalaimon.5.md --- pantalaimon-0.8.0/docs/man/pantalaimon.5.md 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/docs/man/pantalaimon.5.md 2021-01-19 10:10:35.000000000 +0000 @@ -111,7 +111,7 @@ The following example shows a configured pantalaimon proxy with the name *Clocktown*, the homeserver URL is set to -*https://example.org*, +*https://localhost:8448*, the pantalaimon proxy is listening for client connections on the address *localhost*, and port diff -Nru pantalaimon-0.8.0/pantalaimon/daemon.py pantalaimon-0.9.1/pantalaimon/daemon.py --- pantalaimon-0.8.0/pantalaimon/daemon.py 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/pantalaimon/daemon.py 2021-01-19 10:10:35.000000000 +0000 @@ -17,8 +17,10 @@ import os import urllib.parse import concurrent.futures +from io import BufferedReader, BytesIO from json import JSONDecodeError from typing import Any, Dict +from urllib.parse import urlparse from uuid import uuid4 import aiohttp @@ -35,6 +37,7 @@ OlmTrustError, SendRetryError, DownloadResponse, + UploadResponse, ) from nio.crypto import decrypt_attachment @@ -48,7 +51,7 @@ ) from pantalaimon.index import INDEXING_ENABLED, InvalidQueryError from pantalaimon.log import logger -from pantalaimon.store import ClientInfo, PanStore +from pantalaimon.store import ClientInfo, PanStore, MediaInfo from pantalaimon.thread_messages import ( AcceptSasMessage, CancelSasMessage, @@ -80,6 +83,11 @@ } +class NotDecryptedAvailableError(Exception): + """Exception that signals that no decrypted upload is available""" + pass + + @attr.s class ProxyDaemon: name = attr.ib() @@ -102,6 +110,7 @@ client_info = attr.ib(init=False, default=attr.Factory(dict), type=dict) default_session = attr.ib(init=False, default=None) media_info = attr.ib(init=False, default=None) + upload_info = attr.ib(init=False, default=None) database_name = "pan.db" def __attrs_post_init__(self): @@ -112,6 +121,7 @@ self.store = PanStore(self.data_dir) accounts = self.store.load_users(self.name) self.media_info = self.store.load_media(self.name) + self.upload_info = self.store.load_upload(self.name) for user_id, device_id in accounts: if self.conf.keyring: @@ -824,6 +834,60 @@ body=await response.read(), ) + def _get_upload_and_media_info(self, content_key, content): + content_uri = content[content_key] + + try: + upload_info = self.upload_info[content_uri] + except KeyError: + upload_info = self.store.load_upload(self.name, content_uri) + if not upload_info: + return None, None + + self.upload_info[content_uri] = upload_info + + content_uri = content[content_key] + mxc = urlparse(content_uri) + mxc_server = mxc.netloc.strip("/") + mxc_path = mxc.path.strip("/") + + media_info = self.store.load_media(self.name, mxc_server, mxc_path) + if not media_info: + return None, None + + self.media_info[(mxc_server, mxc_path)] = media_info + + return upload_info, media_info + + async def _map_decrypted_uri(self, content_key, content, request, client): + upload_info, media_info = self._get_upload_and_media_info(content_key, content) + if not upload_info or not media_info: + raise NotDecryptedAvailableError + + response, decrypted_file = await self._load_decrypted_file(media_info.mxc_server, media_info.mxc_path, + upload_info.filename) + + if response is None and decrypted_file is None: + raise NotDecryptedAvailableError + + if not isinstance(response, DownloadResponse): + raise NotDecryptedAvailableError + + decrypted_upload, _ = await client.upload( + data_provider=BufferedReader(BytesIO(decrypted_file)), + content_type=response.content_type, + filename=upload_info.filename, + encrypt=False, + filesize=len(decrypted_file), + ) + + if not isinstance(decrypted_upload, UploadResponse): + raise NotDecryptedAvailableError + + content[content_key] = decrypted_upload.content_uri + + return content + async def send_message(self, request): access_token = self.get_access_token(request) @@ -849,23 +913,55 @@ if request.match_info["event_type"] == "m.reaction": encrypt = False - # The room isn't encrypted just forward the message. - if not encrypt: - return await self.forward_to_web(request, token=client.access_token) - msgtype = request.match_info["event_type"] - txnid = request.match_info.get("txnid", uuid4()) try: content = await request.json() except (JSONDecodeError, ContentTypeError): return self._not_json + # The room isn't encrypted just forward the message. + if not encrypt: + content_msgtype = content.get("msgtype") + if content_msgtype in ["m.image", "m.video", "m.audio", "m.file"] or msgtype == "m.room.avatar": + try: + content = await self._map_decrypted_uri("url", content, request, client) + return await self.forward_to_web(request, data=json.dumps(content), token=client.access_token) + except ClientConnectionError as e: + return web.Response(status=500, text=str(e)) + except (KeyError, NotDecryptedAvailableError): + return await self.forward_to_web(request, token=client.access_token) + + return await self.forward_to_web(request, token=client.access_token) + + txnid = request.match_info.get("txnid", uuid4()) + async def _send(ignore_unverified=False): try: - response = await client.room_send( - room_id, msgtype, content, txnid, ignore_unverified - ) + content_msgtype = content.get("msgtype") + if content_msgtype in ["m.image", "m.video", "m.audio", "m.file"] or msgtype == "m.room.avatar": + upload_info, media_info = self._get_upload_and_media_info("url", content) + if not upload_info or not media_info: + response = await client.room_send( + room_id, msgtype, content, txnid, ignore_unverified + ) + + return web.Response( + status=response.transport_response.status, + content_type=response.transport_response.content_type, + headers=CORS_HEADERS, + body=await response.transport_response.read(), + ) + + media_content = media_info.to_content(content, upload_info.mimetype) + + response = await client.room_send( + room_id, msgtype, media_content, txnid, ignore_unverified + ) + else: + response = await client.room_send( + room_id, msgtype, content, txnid, ignore_unverified + ) return web.Response( status=response.transport_response.status, @@ -1039,11 +1135,52 @@ return web.json_response(result, headers=CORS_HEADERS, status=200) - async def download(self, request): - server_name = request.match_info["server_name"] - media_id = request.match_info["media_id"] - file_name = request.match_info.get("file_name") + async def upload(self, request): + file_name = request.query.get("filename", "") + content_type = request.headers.get("Content-Type", "application/octet-stream") + client = next(iter(self.pan_clients.values())) + body = await request.read() + try: + response, maybe_keys = await client.upload( + data_provider=BufferedReader(BytesIO(body)), + content_type=content_type, + filename=file_name, + encrypt=True, + filesize=len(body), + ) + + if not isinstance(response, UploadResponse): + return web.Response( + status=response.transport_response.status, + content_type=response.transport_response.content_type, + headers=CORS_HEADERS, + body=await response.transport_response.read(), + ) + + self.store.save_upload(self.name, response.content_uri, file_name, content_type) + + mxc = urlparse(response.content_uri) + mxc_server = mxc.netloc.strip("/") + mxc_path = mxc.path.strip("/") + + logger.info(f"Adding media info for {mxc_server}/{mxc_path} to the store") + media_info = MediaInfo(mxc_server, mxc_path, maybe_keys["key"], maybe_keys["iv"], maybe_keys["hashes"]) + self.store.save_media(self.name, media_info) + + return web.Response( + status=response.transport_response.status, + content_type=response.transport_response.content_type, + headers=CORS_HEADERS, + body=await response.transport_response.read(), + ) + + except ClientConnectionError as e: + return web.Response(status=500, text=str(e)) + except SendRetryError as e: + return web.Response(status=503, text=str(e)) + + async def _load_decrypted_file(self, server_name, media_id, file_name): try: media_info = self.media_info[(server_name, media_id)] except KeyError: @@ -1051,36 +1188,30 @@ if not media_info: logger.info(f"No media info found for {server_name}/{media_id}") - return await self.forward_to_web(request) + return None, None self.media_info[(server_name, media_id)] = media_info try: key = media_info.key["k"] hash = media_info.hashes["sha256"] - except KeyError: + except KeyError as e: logger.warn( f"Media info for {server_name}/{media_id} doesn't contain a key or hash." ) - return await self.forward_to_web(request) - + raise e if not self.pan_clients: - return await self.forward_to_web(request) + return None, None client = next(iter(self.pan_clients.values())) try: response = await client.download(server_name, media_id, file_name) except ClientConnectionError as e: - return web.Response(status=500, text=str(e)) + raise e if not isinstance(response, DownloadResponse): - return web.Response( - status=response.transport_response.status, - content_type=response.transport_response.content_type, - headers=CORS_HEADERS, - body=await response.transport_response.read(), - ) + return response, None logger.info(f"Decrypting media {server_name}/{media_id}") @@ -1090,6 +1221,54 @@ pool, decrypt_attachment, response.body, key, hash, media_info.iv ) + return response, decrypted_file + + async def profile(self, request): + access_token = self.get_access_token(request) + + if not access_token: + return self._missing_token + + client = await self._find_client(access_token) + if not client: + return self._unknown_token + + try: + content = await request.json() + except (JSONDecodeError, ContentTypeError): + return self._not_json + + try: + content = await self._map_decrypted_uri("avatar_url", content, request, client) + return await self.forward_to_web(request, data=json.dumps(content), token=client.access_token) + except ClientConnectionError as e: + return web.Response(status=500, text=str(e)) + except (KeyError, NotDecryptedAvailableError): + return await self.forward_to_web(request, token=client.access_token) + + async def download(self, request): + server_name = request.match_info["server_name"] + media_id = request.match_info["media_id"] + file_name = request.match_info.get("file_name") + + try: + response, decrypted_file = await self._load_decrypted_file(server_name, media_id, file_name) + + if response is None and decrypted_file is None: + return await self.forward_to_web(request) + except ClientConnectionError as e: + return web.Response(status=500, text=str(e)) + except KeyError: + return await self.forward_to_web(request) + + if not isinstance(response, DownloadResponse): + return web.Response( + status=response.transport_response.status, + content_type=response.transport_response.content_type, + headers=CORS_HEADERS, + body=await response.transport_response.read(), + ) + return web.Response( status=response.transport_response.status, content_type=response.transport_response.content_type, diff -Nru pantalaimon-0.8.0/pantalaimon/main.py pantalaimon-0.9.1/pantalaimon/main.py --- pantalaimon-0.8.0/pantalaimon/main.py 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/pantalaimon/main.py 2021-01-19 10:10:35.000000000 +0000 @@ -93,6 +93,15 @@ "/_matrix/media/r0/download/{server_name}/{media_id}/{file_name}", proxy.download, ), + web.post( + r"/_matrix/media/r0/upload", + proxy.upload, + ), + web.put( + r"/_matrix/client/r0/profile/{userId}/avatar_url", + proxy.profile, + ), + ] ) app.router.add_route("*", "/" + "{proxyPath:.*}", proxy.router) @@ -250,7 +259,7 @@ "connect to pantalaimon." ) ) -@click.version_option(version="0.8.0", prog_name="pantalaimon") +@click.version_option(version="0.9.1", prog_name="pantalaimon") @click.option( "--log-level", type=click.Choice(["error", "warning", "info", "debug"]), diff -Nru pantalaimon-0.8.0/pantalaimon/panctl.py pantalaimon-0.9.1/pantalaimon/panctl.py --- pantalaimon-0.8.0/pantalaimon/panctl.py 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/pantalaimon/panctl.py 2021-01-19 10:10:35.000000000 +0000 @@ -690,7 +690,7 @@ "the pantalaimon daemon." ) ) -@click.version_option(version="0.8.0", prog_name="panctl") +@click.version_option(version="0.9.1", prog_name="panctl") def main(): loop = asyncio.get_event_loop() glib_loop = GLib.MainLoop() diff -Nru pantalaimon-0.8.0/pantalaimon/store.py pantalaimon-0.9.1/pantalaimon/store.py --- pantalaimon-0.8.0/pantalaimon/store.py 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/pantalaimon/store.py 2021-01-19 10:10:35.000000000 +0000 @@ -15,7 +15,7 @@ import json import os from collections import defaultdict -from typing import List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import attr from nio.crypto import TrustState @@ -31,6 +31,7 @@ MAX_LOADED_MEDIA = 10000 +MAX_LOADED_UPLOAD = 10000 @attr.s @@ -47,6 +48,25 @@ iv = attr.ib(type=str) hashes = attr.ib(type=dict) + def to_content(self, content: Dict, mime_type: str) -> Dict[Any, Any]: + content["file"] = { + "v": "v2", + "key": self.key, + "iv": self.iv, + "hashes": self.hashes, + "url": content["url"], + "mimetype": mime_type, + } + + return content + + +@attr.s +class UploadInfo: + content_uri = attr.ib(type=str) + filename = attr.ib(type=str) + mimetype = attr.ib(type=str) + class DictField(TextField): def python_value(self, value): # pragma: no cover @@ -113,6 +133,18 @@ constraints = [SQL("UNIQUE(server_id, mxc_server, mxc_path)")] +class PanUploadInfo(Model): + server = ForeignKeyField( + model=Servers, column_name="server_id", backref="upload", on_delete="CASCADE" + ) + content_uri = TextField() + filename = TextField() + mimetype = TextField() + + class Meta: + constraints = [SQL("UNIQUE(server_id, content_uri)")] + + @attr.s class ClientInfo: user_id = attr.ib(type=str) @@ -135,6 +167,7 @@ PanSyncTokens, PanFetcherTasks, PanMediaInfo, + PanUploadInfo, ] def __attrs_post_init__(self): @@ -163,6 +196,43 @@ return None @use_database + def save_upload(self, server, content_uri, filename, mimetype): + server = Servers.get(name=server) + + PanUploadInfo.insert( + server=server, + content_uri=content_uri, + filename=filename, + mimetype=mimetype, + ).on_conflict_ignore().execute() + + @use_database + def load_upload(self, server, content_uri=None): + server, _ = Servers.get_or_create(name=server) + + if not content_uri: + upload_cache = LRUCache(maxsize=MAX_LOADED_UPLOAD) + + for i, u in enumerate(server.upload): + if i > MAX_LOADED_UPLOAD: + break + + upload = UploadInfo(u.content_uri, u.filename, u.mimetype) + upload_cache[u.content_uri] = upload + + return upload_cache + else: + u = PanUploadInfo.get_or_none( + PanUploadInfo.server == server, + PanUploadInfo.content_uri == content_uri, + ) + + if not u: + return None + + return UploadInfo(u.content_uri, u.filename, u.mimetype) + + @use_database def save_media(self, server, media): server = Servers.get(name=server) @@ -226,6 +296,7 @@ user=user, room_id=task.room_id, token=task.token ).execute() + @use_database def load_fetcher_tasks(self, server, pan_user): server = Servers.get(name=server) user = ServerUsers.get(server=server, user_id=pan_user) diff -Nru pantalaimon-0.8.0/pantalaimon/ui.py pantalaimon-0.9.1/pantalaimon/ui.py --- pantalaimon-0.8.0/pantalaimon/ui.py 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/pantalaimon/ui.py 2021-01-19 10:10:35.000000000 +0000 @@ -30,6 +30,7 @@ from gi.repository import GLib from pydbus import SessionBus from pydbus.generic import signal + from dbus.mainloop.glib import DBusGMainLoop from nio import RoomKeyRequest, RoomKeyRequestCancellation @@ -447,6 +448,7 @@ config = attr.ib() loop = attr.ib(init=False) + dbus_loop = attr.ib(init=False) store = attr.ib(init=False) users = attr.ib(init=False) devices = attr.ib(init=False) @@ -457,6 +459,7 @@ def __attrs_post_init__(self): self.loop = None + self.dbus_loop = None id_counter = IdCounter() @@ -632,11 +635,12 @@ return True def run(self): + self.dbus_loop = DBusGMainLoop() self.loop = GLib.MainLoop() if self.config.notifications: try: - notify2.init("pantalaimon", mainloop=self.loop) + notify2.init("pantalaimon", mainloop=self.dbus_loop) self.notifications = True except dbus.DBusException: logger.error( @@ -646,6 +650,7 @@ self.notifications = False GLib.timeout_add(100, self.message_callback) + if not self.loop: return diff -Nru pantalaimon-0.8.0/setup.py pantalaimon-0.9.1/setup.py --- pantalaimon-0.8.0/setup.py 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/setup.py 2021-01-19 10:10:35.000000000 +0000 @@ -7,7 +7,7 @@ setup( name="pantalaimon", - version="0.8.0", + version="0.9.1", url="https://github.com/matrix-org/pantalaimon", author="The Matrix.org Team", author_email="poljar@termina.org.uk", @@ -26,15 +26,15 @@ "logbook >= 1.5.3", "peewee >= 3.13.1", "janus >= 0.5", - "cachetools >= 3.0.0" - "prompt_toolkit>2<4", + "cachetools >= 3.0.0", + "prompt_toolkit > 2, < 4", "typing;python_version<'3.5'", - "matrix-nio[e2e] >= 0.14, < 0.16" + "matrix-nio[e2e] >= 0.14, < 0.17" ], extras_require={ "ui": [ "dbus-python >= 1.2, < 1.3", - "PyGObject >= 3.36, < 3.37", + "PyGObject >= 3.36, < 3.39", "pydbus >= 0.6, < 0.7", "notify2 >= 0.3, < 0.4", ] diff -Nru pantalaimon-0.8.0/tests/store_test.py pantalaimon-0.9.1/tests/store_test.py --- pantalaimon-0.8.0/tests/store_test.py 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/tests/store_test.py 2021-01-19 10:10:35.000000000 +0000 @@ -8,7 +8,7 @@ from urllib.parse import urlparse from conftest import faker from pantalaimon.index import INDEXING_ENABLED -from pantalaimon.store import FetchTask, MediaInfo +from pantalaimon.store import FetchTask, MediaInfo, UploadInfo TEST_ROOM = "!SVkFJHzfwvuaIEawgC:localhost" TEST_ROOM2 = "!testroom:localhost" @@ -177,3 +177,25 @@ media_info = media_cache[(mxc_server, mxc_path)] assert media_info == media assert media_info == panstore.load_media(server_name, mxc_server, mxc_path) + + def test_upload_storage(self, panstore): + server_name = "test" + upload_cache = panstore.load_upload(server_name) + assert not upload_cache + + filename = "orange_cat.jpg" + mimetype = "image/jpeg" + event = self.encrypted_media_event + + assert not panstore.load_upload(server_name, event.url) + + upload = UploadInfo(event.url, filename, mimetype) + + panstore.save_upload(server_name, event.url, filename, mimetype) + + upload_cache = panstore.load_upload(server_name) + + assert (event.url) in upload_cache + upload_info = upload_cache[event.url] + assert upload_info == upload + assert upload_info == panstore.load_upload(server_name, event.url) diff -Nru pantalaimon-0.8.0/tox.ini pantalaimon-0.9.1/tox.ini --- pantalaimon-0.8.0/tox.ini 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/tox.ini 2021-01-19 10:10:35.000000000 +0000 @@ -1,11 +1,11 @@ # content of: tox.ini , put in same dir as setup.py [tox] -envlist = py36,py37,coverage +envlist = py38,py39,coverage [testenv] basepython = - py36: python3.6 - py37: python3.7 - py3: python3.7 + py38: python3.8 + py39: python3.9 + py3: python3.9 deps = -rtest-requirements.txt install_command = pip install {opts} {packages} @@ -15,7 +15,7 @@ usedevelop = True [testenv:coverage] -basepython = python3.7 +basepython = python3.9 commands = pytest --cov=pantalaimon --cov-report term-missing coverage xml diff -Nru pantalaimon-0.8.0/.travis.yml pantalaimon-0.9.1/.travis.yml --- pantalaimon-0.8.0/.travis.yml 2020-09-30 09:26:46.000000000 +0000 +++ pantalaimon-0.9.1/.travis.yml 2021-01-19 10:10:35.000000000 +0000 @@ -16,14 +16,14 @@ matrix: include: - - python: 3.6 - env: TOXENV=py36 - - python: 3.7 - env: TOXENV=py37 - - python: 3.7 + - python: 3.8 + env: TOXENV=py38 + - python: 3.9 + env: TOXENV=py39 + - python: 3.9 env: TOXENV=coverage -install: pip install tox-travis PyGObject dbus-python aioresponses +install: pip install tox-travis aioresponses script: tox after_success: