Repository: rotki/rotki

Issue:
Detect newer versions and prompt users for upgrading
## Abstract

Unless users follow our social media announcements they don't get to know when a new version is released. It would be nice to have a warning for them that a new version is released.

## Task

- [ ] Write a check that uses the github API to find the latest release and compares it to the one the user is running. This check should run either only at the start or periodically.

- [ ] If the check succeeds then the user should be prompted to click on the link to download and upgrade to the new release after checking the changelog

- [ ] Add a test for this as usual




Discussion:

Resolving patch:
diff --git a/rotkehlchen/externalapis/github.py b/rotkehlchen/externalapis/github.py
new file mode 100644
--- /dev/null
+++ b/rotkehlchen/externalapis/github.py
@@ -0,0 +1,46 @@
+from json.decoder import JSONDecodeError
+from typing import Dict, Tuple
+
+import requests
+
+from rotkehlchen.errors import RemoteError
+from rotkehlchen.utils.serialization import rlk_jsonloads_dict
+
+
+class Github():
+
+    def __init__(self) -> None:
+        self.prefix = 'https://api.github.com/'
+
+    def _query(self, path: str) -> Dict:
+        response = requests.get(f'{self.prefix}{path}')
+
+        if response.status_code != 200:
+            raise RemoteError(
+                f'Github API request {response.url} for {path} failed '
+                f'with HTTP status code {response.status_code} and text '
+                f'{response.text}',
+            )
+
+        try:
+            json_ret = rlk_jsonloads_dict(response.text)
+        except JSONDecodeError:
+            raise RemoteError(f'Github returned invalid JSON response: {response.text}')
+        return json_ret
+
+    def get_latest_release(self) -> Tuple[str, str]:
+        """Returns the latest rotkehlchen release version
+
+        In success returns a tuple (version_tag, version_download_url)
+        Will raise RemoteError in all kind of errors
+        """
+        response = self._query('repos/rotkehlchenio/rotkehlchen/releases/latest')
+        if 'tag_name' not in response:
+            raise RemoteError(
+                'Github latest release did not contain a "tag_name" entry in the response',
+            )
+        if 'html_url' not in response:
+            raise RemoteError(
+                'Github latest release did not contain a "html_url" entry in the response',
+            )
+        return response['tag_name'], response['html_url']
diff --git a/rotkehlchen/server.py b/rotkehlchen/server.py
--- a/rotkehlchen/server.py
+++ b/rotkehlchen/server.py
@@ -31,6 +31,7 @@
 from rotkehlchen.typing import FiatAsset, SupportedBlockchain, Timestamp
 from rotkehlchen.utils.misc import simple_result
 from rotkehlchen.utils.serialization import pretty_json_dumps
+from rotkehlchen.utils.version_check import check_if_version_up_to_date
 
 logger = logging.getLogger(__name__)
 log = RotkehlchenLogsAdapter(logger)
@@ -417,6 +418,19 @@ def remove_ignored_asset(self, asset: str):
         result, message = self.rotkehlchen.data.remove_ignored_asset(asset)
         return {'result': result, 'message': message}
 
+    @staticmethod
+    def version_check():
+        result = check_if_version_up_to_date()
+        if not result:
+            our_version = None
+            latest_version = None
+            url = None
+        else:
+            our_version = result[0]
+            latest_version = result[1]
+            url = result[2]
+        return {'our_version': our_version, 'latest_version': latest_version, 'url': url}
+
     def unlock_user(
             self,
             user: str,
diff --git a/rotkehlchen/utils/version_check.py b/rotkehlchen/utils/version_check.py
new file mode 100644
--- /dev/null
+++ b/rotkehlchen/utils/version_check.py
@@ -0,0 +1,31 @@
+from typing import Optional, Tuple
+
+from pkg_resources import parse_version
+
+from rotkehlchen.errors import RemoteError
+from rotkehlchen.externalapis.github import Github
+from rotkehlchen.utils.misc import get_system_spec
+
+
+def check_if_version_up_to_date() -> Optional[Tuple[str, str, str]]:
+    """Checks if there is a newer Rotkehlchen version available for download
+
+    If not returns None
+    If yes returns a tuple: (our_version_str, latest_version_str, url)
+    """
+    our_version_str = get_system_spec()['rotkehlchen']
+    our_version = parse_version(our_version_str)
+
+    github = Github()
+    try:
+        latest_version_str, url = github.get_latest_release()
+    except RemoteError:
+        # Completely ignore all remote errors. If Github has problems we just don't check now
+        return None
+
+    latest_version = parse_version(latest_version_str)
+
+    if latest_version <= our_version:
+        return None
+
+    return our_version_str, latest_version_str, url


Test patch:
diff --git a/rotkehlchen/tests/test_utils.py b/rotkehlchen/tests/test_utils.py
--- a/rotkehlchen/tests/test_utils.py
+++ b/rotkehlchen/tests/test_utils.py
@@ -1,4 +1,5 @@
 import json
+from unittest.mock import patch
 
 import pytest
 
@@ -6,7 +7,9 @@
 from rotkehlchen.exchanges.data_structures import invert_pair
 from rotkehlchen.fval import FVal
 from rotkehlchen.serialization.serialize import process_result
+from rotkehlchen.tests.utils.mock import MockResponse
 from rotkehlchen.utils.misc import combine_dicts, combine_stat_dicts, iso8601ts_to_timestamp
+from rotkehlchen.utils.version_check import check_if_version_up_to_date
 
 
 def test_process_result():
@@ -78,3 +81,43 @@ def test_combine_stat_dicts():
         'ETH': {'amount': FVal('100.1'), 'usd_value': FVal('11200.1')},
         'BTC': {'amount': FVal('6'), 'usd_value': FVal('30401')},
     }
+
+
+def test_check_if_version_up_to_date():
+    assert check_if_version_up_to_date() is None, 'Current version should always be up to date'
+
+    def mock_github_return(url):  # pylint: disable=unused-argument
+        contents = '{"tag_name": "v99.99.99", "html_url": "https://foo"}'
+        return MockResponse(200, contents)
+
+    with patch('requests.get', side_effect=mock_github_return):
+        result = check_if_version_up_to_date()
+    assert result
+    assert result[0]
+    assert result[1] == 'v99.99.99'
+    assert result[2] == 'https://foo'
+
+    # Also test that bad responses are handled gracefully
+    def mock_non_200_github_return(url):  # pylint: disable=unused-argument
+        contents = '{"tag_name": "v99.99.99", "html_url": "https://foo"}'
+        return MockResponse(501, contents)
+
+    with patch('requests.get', side_effect=mock_non_200_github_return):
+        result = check_if_version_up_to_date()
+        assert not result
+
+    def mock_missing_fields_github_return(url):  # pylint: disable=unused-argument
+        contents = '{"html_url": "https://foo"}'
+        return MockResponse(200, contents)
+
+    with patch('requests.get', side_effect=mock_missing_fields_github_return):
+        result = check_if_version_up_to_date()
+        assert not result
+
+    def mock_invalid_json_github_return(url):  # pylint: disable=unused-argument
+        contents = '{html_url: "https://foo"}'
+        return MockResponse(200, contents)
+
+    with patch('requests.get', side_effect=mock_invalid_json_github_return):
+        result = check_if_version_up_to_date()
+        assert not result