diff --git a/neptune/exceptions.py b/neptune/exceptions.py index 3cfd82f0d..333d87f51 100644 --- a/neptune/exceptions.py +++ b/neptune/exceptions.py @@ -114,3 +114,11 @@ class CannotResolveHostname(NeptuneException): def __init__(self, host): super(CannotResolveHostname, self).__init__( "Cannot resolve hostname {}. Please contact Neptune support.".format(host)) + +class UnsupportedClientVersion(NeptuneException): + def __init__(self, version, minVersion, maxVersion): + super(UnsupportedClientVersion, self).__init__( + "This client version ({}) is not supported. Please install neptune-client{}".format( + version, + "==" + str(maxVersion) if maxVersion else ">=" + str(minVersion) + )) diff --git a/neptune/internal/backends/client_config.py b/neptune/internal/backends/client_config.py index 0d2129ac4..c425d76da 100644 --- a/neptune/internal/backends/client_config.py +++ b/neptune/internal/backends/client_config.py @@ -17,9 +17,12 @@ class ClientConfig(object): - def __init__(self, api_url, display_url): + def __init__(self, api_url, display_url, min_recommended_version, min_compatible_version, max_compatible_version): self._api_url = api_url self._display_url = display_url + self._min_recommended_version = min_recommended_version + self._min_compatible_version = min_compatible_version + self._max_compatible_version = max_compatible_version @property def api_url(self): @@ -28,3 +31,15 @@ def api_url(self): @property def display_url(self): return self._display_url + + @property + def min_recommended_version(self): + return self._min_recommended_version + + @property + def min_compatible_version(self): + return self._min_compatible_version + + @property + def max_compatible_version(self): + return self._max_compatible_version diff --git a/neptune/internal/backends/hosted_neptune_backend.py b/neptune/internal/backends/hosted_neptune_backend.py index 15f8bfd08..b64982941 100644 --- a/neptune/internal/backends/hosted_neptune_backend.py +++ b/neptune/internal/backends/hosted_neptune_backend.py @@ -18,12 +18,14 @@ import os import platform import socket +import sys import uuid from functools import partial from http.client import NOT_FOUND, UNPROCESSABLE_ENTITY # pylint:disable=no-name-in-module from io import StringIO from itertools import groupby +import click import requests import six import urllib3 @@ -31,6 +33,7 @@ from bravado.exception import HTTPBadRequest, HTTPNotFound, HTTPUnprocessableEntity, HTTPConflict from bravado.requests_client import RequestsClient from bravado_core.formatter import SwaggerFormat +from packaging import version from requests.exceptions import HTTPError from six.moves import urllib @@ -41,7 +44,7 @@ from neptune.backend import Backend from neptune.checkpoint import Checkpoint from neptune.internal.backends.client_config import ClientConfig -from neptune.exceptions import FileNotFound, DeprecatedApiToken, CannotResolveHostname +from neptune.exceptions import FileNotFound, DeprecatedApiToken, CannotResolveHostname, UnsupportedClientVersion from neptune.experiments import Experiment from neptune.internal.backends.credentials import Credentials from neptune.internal.utils.http import extract_response_field @@ -63,6 +66,10 @@ def __init__(self, api_token=None, proxies=None): if api_token == ANONYMOUS: api_token = ANONYMOUS_API_TOKEN + # This is not a top-level import because of circular dependencies + from neptune import __version__ + self.client_lib_version = __version__ + self.credentials = Credentials(api_token) ssl_verify = True @@ -79,15 +86,13 @@ def __init__(self, api_token=None, proxies=None): backend_client = self._get_swagger_client('{}/api/backend/swagger.json'.format(config_api_url)) self._client_config = self._create_client_config(self.credentials.api_token, backend_client) + self._verify_version() + self._set_swagger_clients(self._client_config, config_api_url, backend_client) self.authenticator = self._create_authenticator(self.credentials.api_token, ssl_verify, proxies) self._http_client.authenticator = self.authenticator - # This is not a top-level import because of circular dependencies - from neptune import __version__ - self.client_lib_version = __version__ - user_agent = 'neptune-client/{lib_version} ({system}, python {python_version})'.format( lib_version=self.client_lib_version, system=platform.platform(), @@ -901,21 +906,64 @@ def _get_swagger_client(self, url): validate_responses=False, formats=[uuid_format] ), - http_client=self._http_client - ) + http_client=self._http_client) @with_api_exceptions_handler def _create_authenticator(self, api_token, ssl_verify, proxies): return NeptuneAuthenticator( self.backend_swagger_client.api.exchangeApiToken(X_Neptune_Api_Token=api_token).response().result, ssl_verify, - proxies - ) + proxies) @with_api_exceptions_handler def _create_client_config(self, api_token, backend_client): config = backend_client.api.getClientConfig(X_Neptune_Api_Token=api_token).response().result - return ClientConfig(api_url=config.apiUrl, display_url=config.applicationUrl) + min_recommended = None + min_compatible = None + max_compatible = None + + if hasattr(config, "pyLibVersions"): + min_recommended = getattr(config.pyLibVersions, "minRecommendedVersion", None) + min_compatible = getattr(config.pyLibVersions, "minCompatibleVersion", None) + max_compatible = getattr(config.pyLibVersions, "maxCompatibleVersion", None) + else: + click.echo( + "ERROR: This client version is not supported by your Neptune instance. Please contant Neptune support.", + sys.stderr) + raise UnsupportedClientVersion(self.client_lib_version, None, "0.4.111") + + return ClientConfig( + api_url=config.apiUrl, + display_url=config.applicationUrl, + min_recommended_version=version.parse(min_recommended) if min_recommended else None, + min_compatible_version=version.parse(min_compatible) if min_compatible else None, + max_compatible_version=version.parse(max_compatible) if max_compatible else None + ) + + def _verify_version(self): + parsed_version = version.parse(self.client_lib_version) + + if self._client_config.min_compatible_version and self._client_config.min_compatible_version > parsed_version: + click.echo( + "ERROR: Minimal supported client version is {} (installed: {}). Please upgrade neptune-client".format( + self._client_config.min_compatible_version, self.client_lib_version), + sys.stderr) + raise UnsupportedClientVersion(self.client_lib_version, + self._client_config.min_compatible_version, + self._client_config.max_compatible_version) + if self._client_config.max_compatible_version and self._client_config.max_compatible_version < parsed_version: + click.echo( + "ERROR: Maximal supported client version is {} (installed: {}). Please downgrade neptune-client".format( + self._client_config.max_compatible_version, self.client_lib_version), + sys.stderr) + raise UnsupportedClientVersion(self.client_lib_version, + self._client_config.min_compatible_version, + self._client_config.max_compatible_version) + if self._client_config.min_recommended_version and self._client_config.min_recommended_version > parsed_version: + click.echo( + "WARNING: There is a new version of neptune-client {} (installed: {}).".format( + self._client_config.min_recommended_version, self.client_lib_version), + sys.stderr) def _set_swagger_clients(self, client_config, client_config_api_addr, client_config_backend_client): self.backend_swagger_client = ( @@ -938,10 +986,5 @@ def _verify_host_resolution(self, api_url, app_url): raise CannotResolveHostname(host) -uuid_format = SwaggerFormat( - format='uuid', - to_python=lambda x: x, - to_wire=lambda x: x, - validate=lambda x: None, - description='' -) +uuid_format = SwaggerFormat(format='uuid', to_python=lambda x: x, + to_wire=lambda x: x, validate=lambda x: None, description='') diff --git a/requirements.txt b/requirements.txt index df78754b3..66acefc88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ requests-oauthlib>=1.0.0 six>=1.12.0 websocket-client>=0.35.0 GitPython>=2.0.8 +packaging diff --git a/tests/neptune/internal/backends/test_hosted_neptune_backend.py b/tests/neptune/internal/backends/test_hosted_neptune_backend.py index 288f971f0..bb104584c 100644 --- a/tests/neptune/internal/backends/test_hosted_neptune_backend.py +++ b/tests/neptune/internal/backends/test_hosted_neptune_backend.py @@ -20,7 +20,7 @@ import mock from mock import MagicMock -from neptune.exceptions import DeprecatedApiToken, CannotResolveHostname +from neptune.exceptions import DeprecatedApiToken, CannotResolveHostname, UnsupportedClientVersion from neptune.internal.backends.hosted_neptune_backend import HostedNeptuneBackend from tests.neptune.api_models import ApiParameter @@ -33,13 +33,54 @@ class TestHostedNeptuneBackend(unittest.TestCase): # pylint:disable=protected-access + @mock.patch('bravado.client.SwaggerClient.from_url') + @mock.patch('neptune.__version__', '0.5.13') + def test_min_compatible_version_ok(self, swagger_client_factory): + # given + self._get_swagger_client_mock(swagger_client_factory, min_compatible='0.5.13') + + # expect + HostedNeptuneBackend(api_token=API_TOKEN) + + @mock.patch('bravado.client.SwaggerClient.from_url') + @mock.patch('neptune.__version__', '0.5.13') + def test_min_compatible_version_fail(self, swagger_client_factory): + # given + self._get_swagger_client_mock(swagger_client_factory, min_compatible='0.5.14') + + # expect + with self.assertRaises(UnsupportedClientVersion) as ex: + HostedNeptuneBackend(api_token=API_TOKEN) + + self.assertTrue("Please install neptune-client>=0.5.14" in str(ex.exception)) + + @mock.patch('bravado.client.SwaggerClient.from_url') + @mock.patch('neptune.__version__', '0.5.13') + def test_max_compatible_version_ok(self, swagger_client_factory): + # given + self._get_swagger_client_mock(swagger_client_factory, max_compatible='0.5.13') + + # expect + HostedNeptuneBackend(api_token=API_TOKEN) + + @mock.patch('bravado.client.SwaggerClient.from_url') + @mock.patch('neptune.__version__', '0.5.13') + def test_max_compatible_version_fail(self, swagger_client_factory): + # given + self._get_swagger_client_mock(swagger_client_factory, max_compatible='0.5.12') + + # expect + with self.assertRaises(UnsupportedClientVersion) as ex: + HostedNeptuneBackend(api_token=API_TOKEN) + + self.assertTrue("Please install neptune-client==0.5.12" in str(ex.exception)) + @mock.patch('bravado.client.SwaggerClient.from_url') @mock.patch('uuid.uuid4') def test_convert_to_api_parameters(self, uuid4, swagger_client_factory): # given - swagger_client = MagicMock() + swagger_client = self._get_swagger_client_mock(swagger_client_factory) swagger_client.get_model.return_value = ApiParameter - swagger_client_factory.return_value = swagger_client # and some_uuid = str(uuid.uuid4()) @@ -76,6 +117,9 @@ def test_convert_to_api_parameters(self, uuid4, swagger_client_factory): @mock.patch('bravado.client.SwaggerClient.from_url') @mock.patch('neptune.internal.backends.credentials.os.getenv', return_value=API_TOKEN) def test_should_take_default_credentials_from_env(self, env, swagger_client_factory): + # given + self._get_swagger_client_mock(swagger_client_factory) + # when backend = HostedNeptuneBackend() @@ -83,7 +127,10 @@ def test_should_take_default_credentials_from_env(self, env, swagger_client_fact self.assertEqual(API_TOKEN, backend.credentials.api_token) @mock.patch('bravado.client.SwaggerClient.from_url') - def test_should_accept_given_api_token(self, _): + def test_should_accept_given_api_token(self, swagger_client_factory): + # given + self._get_swagger_client_mock(swagger_client_factory) + # when session = HostedNeptuneBackend(API_TOKEN) @@ -114,6 +161,28 @@ def test_cannot_resolve_host(self, gethostname_mock): with self.assertRaises(CannotResolveHostname): HostedNeptuneBackend(token) + @staticmethod + def _get_swagger_client_mock( + swagger_client_factory, + min_recommended=None, + min_compatible=None, + max_compatible=None): + py_lib_versions = type('py_lib_versions', (object,), {})() + setattr(py_lib_versions, "minRecommendedVersion", min_recommended) + setattr(py_lib_versions, "minCompatibleVersion", min_compatible) + setattr(py_lib_versions, "maxCompatibleVersion", max_compatible) + + client_config = type('client_config_response_result', (object,), {})() + setattr(client_config, "pyLibVersions", py_lib_versions) + setattr(client_config, "apiUrl", None) + setattr(client_config, "applicationUrl", None) + + swagger_client = MagicMock() + swagger_client.api.getClientConfig.return_value.response.return_value.result = client_config + swagger_client_factory.return_value = swagger_client + + return swagger_client + class SomeClass(object): pass