From 3830fa0fd9deb1129013087343ee340cf1befe5e Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Fri, 21 Sep 2018 11:58:30 +0200 Subject: [PATCH 01/37] Initial move of the code of CuraPluginOAuth2Module CURA-5744 --- cura/OAuth2/AuthorizationHelpers.py | 127 +++++++++++++++++ cura/OAuth2/AuthorizationRequestHandler.py | 105 ++++++++++++++ cura/OAuth2/AuthorizationRequestServer.py | 25 ++++ cura/OAuth2/AuthorizationService.py | 151 +++++++++++++++++++++ cura/OAuth2/LocalAuthorizationServer.py | 67 +++++++++ cura/OAuth2/Models.py | 60 ++++++++ cura/OAuth2/__init__.py | 2 + 7 files changed, 537 insertions(+) create mode 100644 cura/OAuth2/AuthorizationHelpers.py create mode 100644 cura/OAuth2/AuthorizationRequestHandler.py create mode 100644 cura/OAuth2/AuthorizationRequestServer.py create mode 100644 cura/OAuth2/AuthorizationService.py create mode 100644 cura/OAuth2/LocalAuthorizationServer.py create mode 100644 cura/OAuth2/Models.py create mode 100644 cura/OAuth2/__init__.py diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py new file mode 100644 index 0000000000..10041f70ce --- /dev/null +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -0,0 +1,127 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import random +from _sha512 import sha512 +from base64 import b64encode +from typing import Optional + +import requests + +# As this module is specific for Cura plugins, we can rely on these imports. +from UM.Logger import Logger + +# Plugin imports need to be relative to work in final builds. +from .models import AuthenticationResponse, UserProfile, OAuth2Settings + + +class AuthorizationHelpers: + """Class containing several helpers to deal with the authorization flow.""" + + def __init__(self, settings: "OAuth2Settings"): + self._settings = settings + self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) + + @property + def settings(self) -> "OAuth2Settings": + """Get the OAuth2 settings object.""" + return self._settings + + def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str)->\ + Optional["AuthenticationResponse"]: + """ + Request the access token from the authorization server. + :param authorization_code: The authorization code from the 1st step. + :param verification_code: The verification code needed for the PKCE extension. + :return: An AuthenticationResponse object. + """ + return self.parseTokenResponse(requests.post(self._token_url, data={ + "client_id": self._settings.CLIENT_ID, + "redirect_uri": self._settings.CALLBACK_URL, + "grant_type": "authorization_code", + "code": authorization_code, + "code_verifier": verification_code, + "scope": self._settings.CLIENT_SCOPES + })) + + def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> Optional["AuthenticationResponse"]: + """ + Request the access token from the authorization server using a refresh token. + :param refresh_token: + :return: An AuthenticationResponse object. + """ + return self.parseTokenResponse(requests.post(self._token_url, data={ + "client_id": self._settings.CLIENT_ID, + "redirect_uri": self._settings.CALLBACK_URL, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": self._settings.CLIENT_SCOPES + })) + + @staticmethod + def parseTokenResponse(token_response: "requests.request") -> Optional["AuthenticationResponse"]: + """ + Parse the token response from the authorization server into an AuthenticationResponse object. + :param token_response: The JSON string data response from the authorization server. + :return: An AuthenticationResponse object. + """ + token_data = None + + try: + token_data = json.loads(token_response.text) + except ValueError: + Logger.log("w", "Could not parse token response data: %s", token_response.text) + + if not token_data: + return AuthenticationResponse(success=False, err_message="Could not read response.") + + if token_response.status_code not in (200, 201): + return AuthenticationResponse(success=False, err_message=token_data["error_description"]) + + return AuthenticationResponse(success=True, + token_type=token_data["token_type"], + access_token=token_data["access_token"], + refresh_token=token_data["refresh_token"], + expires_in=token_data["expires_in"], + scope=token_data["scope"]) + + def parseJWT(self, access_token: str) -> Optional["UserProfile"]: + """ + Calls the authentication API endpoint to get the token data. + :param access_token: The encoded JWT token. + :return: Dict containing some profile data. + """ + token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { + "Authorization": "Bearer {}".format(access_token) + }) + if token_request.status_code not in (200, 201): + Logger.log("w", "Could not retrieve token data from auth server: %s", token_request.text) + return None + user_data = token_request.json().get("data") + if not user_data or not isinstance(user_data, dict): + Logger.log("w", "Could not parse user data from token: %s", user_data) + return None + return UserProfile( + user_id = user_data["user_id"], + username = user_data["username"], + profile_image_url = user_data.get("profile_image_url", "") + ) + + @staticmethod + def generateVerificationCode(code_length: int = 16) -> str: + """ + Generate a 16-character verification code. + :param code_length: + :return: + """ + return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) + + @staticmethod + def generateVerificationCodeChallenge(verification_code: str) -> str: + """ + Generates a base64 encoded sha512 encrypted version of a given string. + :param verification_code: + :return: The encrypted code in base64 format. + """ + encoded = sha512(verification_code.encode()).digest() + return b64encode(encoded, altchars = b"_-").decode() diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py new file mode 100644 index 0000000000..eb703fc5c1 --- /dev/null +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -0,0 +1,105 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional, Callable + +from http.server import BaseHTTPRequestHandler +from urllib.parse import parse_qs, urlparse + +# Plugin imports need to be relative to work in final builds. +from .AuthorizationHelpers import AuthorizationHelpers +from .models import AuthenticationResponse, ResponseData, HTTP_STATUS, ResponseStatus + + +class AuthorizationRequestHandler(BaseHTTPRequestHandler): + """ + This handler handles all HTTP requests on the local web server. + It also requests the access token for the 2nd stage of the OAuth flow. + """ + + def __init__(self, request, client_address, server): + super().__init__(request, client_address, server) + + # These values will be injected by the HTTPServer that this handler belongs to. + self.authorization_helpers = None # type: AuthorizationHelpers + self.authorization_callback = None # type: Callable[[AuthenticationResponse], None] + self.verification_code = None # type: str + + def do_GET(self): + """Entry point for GET requests""" + + # Extract values from the query string. + parsed_url = urlparse(self.path) + query = parse_qs(parsed_url.query) + + # Handle the possible requests + if parsed_url.path == "/callback": + server_response, token_response = self._handleCallback(query) + else: + server_response = self._handleNotFound() + token_response = None + + # Send the data to the browser. + self._sendHeaders(server_response.status, server_response.content_type, server_response.redirect_uri) + + if server_response.data_stream: + # If there is data in the response, we send it. + self._sendData(server_response.data_stream) + + if token_response: + # Trigger the callback if we got a response. + # This will cause the server to shut down, so we do it at the very end of the request handling. + self.authorization_callback(token_response) + + def _handleCallback(self, query: dict) -> ("ResponseData", Optional["AuthenticationResponse"]): + """ + Handler for the callback URL redirect. + :param query: Dict containing the HTTP query parameters. + :return: HTTP ResponseData containing a success page to show to the user. + """ + if self._queryGet(query, "code"): + # If the code was returned we get the access token. + token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode( + self._queryGet(query, "code"), self.verification_code) + + elif self._queryGet(query, "error_code") == "user_denied": + # Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog). + token_response = AuthenticationResponse( + success=False, + err_message="Please give the required permissions when authorizing this application." + ) + + else: + # We don't know what went wrong here, so instruct the user to check the logs. + token_response = AuthenticationResponse( + success=False, + error_message="Something unexpected happened when trying to log in, please try again." + ) + + return ResponseData( + status=HTTP_STATUS["REDIRECT"], + data_stream=b"Redirecting...", + redirect_uri=self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else + self.authorization_helpers.settings.AUTH_FAILED_REDIRECT + ), token_response + + @staticmethod + def _handleNotFound() -> "ResponseData": + """Handle all other non-existing server calls.""" + return ResponseData(status=HTTP_STATUS["NOT_FOUND"], content_type="text/html", data_stream=b"Not found.") + + def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None: + """Send out the headers""" + self.send_response(status.code, status.message) + self.send_header("Content-type", content_type) + if redirect_uri: + self.send_header("Location", redirect_uri) + self.end_headers() + + def _sendData(self, data: bytes) -> None: + """Send out the data""" + self.wfile.write(data) + + @staticmethod + def _queryGet(query_data: dict, key: str, default=None) -> Optional[str]: + """Helper for getting values from a pre-parsed query string""" + return query_data.get(key, [default])[0] diff --git a/cura/OAuth2/AuthorizationRequestServer.py b/cura/OAuth2/AuthorizationRequestServer.py new file mode 100644 index 0000000000..ee428bc236 --- /dev/null +++ b/cura/OAuth2/AuthorizationRequestServer.py @@ -0,0 +1,25 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from http.server import HTTPServer + +from .AuthorizationHelpers import AuthorizationHelpers + + +class AuthorizationRequestServer(HTTPServer): + """ + The authorization request callback handler server. + This subclass is needed to be able to pass some data to the request handler. + This cannot be done on the request handler directly as the HTTPServer creates an instance of the handler after init. + """ + + def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: + """Set the authorization helpers instance on the request handler.""" + self.RequestHandlerClass.authorization_helpers = authorization_helpers + + def setAuthorizationCallback(self, authorization_callback) -> None: + """Set the authorization callback on the request handler.""" + self.RequestHandlerClass.authorization_callback = authorization_callback + + def setVerificationCode(self, verification_code: str) -> None: + """Set the verification code on the request handler.""" + self.RequestHandlerClass.verification_code = verification_code diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py new file mode 100644 index 0000000000..f425e3a003 --- /dev/null +++ b/cura/OAuth2/AuthorizationService.py @@ -0,0 +1,151 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import webbrowser +from typing import Optional +from urllib.parse import urlencode + +# As this module is specific for Cura plugins, we can rely on these imports. +from UM.Logger import Logger +from UM.Signal import Signal + +# Plugin imports need to be relative to work in final builds. +from .LocalAuthorizationServer import LocalAuthorizationServer +from .AuthorizationHelpers import AuthorizationHelpers +from .models import OAuth2Settings, AuthenticationResponse, UserProfile + + +class AuthorizationService: + """ + The authorization service is responsible for handling the login flow, + storing user credentials and providing account information. + """ + + # Emit signal when authentication is completed. + onAuthStateChanged = Signal() + + # Emit signal when authentication failed. + onAuthenticationError = Signal() + + def __init__(self, preferences, settings: "OAuth2Settings"): + self._settings = settings + self._auth_helpers = AuthorizationHelpers(settings) + self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) + self._auth_data = None # type: Optional[AuthenticationResponse] + self._user_profile = None # type: Optional[UserProfile] + self._cura_preferences = preferences + self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) + self._loadAuthData() + + def getUserProfile(self) -> Optional["UserProfile"]: + """ + Get the user data that is stored in the JWT token. + :return: Dict containing some user data. + """ + if not self._user_profile: + # If no user profile was stored locally, we try to get it from JWT. + self._user_profile = self._parseJWT() + if not self._user_profile: + # If there is still no user profile from the JWT, we have to log in again. + return None + return self._user_profile + + def _parseJWT(self) -> Optional["UserProfile"]: + """ + Tries to parse the JWT if all the needed data exists. + :return: UserProfile if found, otherwise None. + """ + if not self._auth_data: + # If no auth data exists, we should always log in again. + return None + user_data = self._auth_helpers.parseJWT(self._auth_data.access_token) + if user_data: + # If the profile was found, we return it immediately. + return user_data + # The JWT was expired or invalid and we should request a new one. + self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token) + if not self._auth_data: + # The token could not be refreshed using the refresh token. We should login again. + return None + return self._auth_helpers.parseJWT(self._auth_data.access_token) + + def getAccessToken(self) -> Optional[str]: + """ + Get the access token response data. + :return: Dict containing token data. + """ + if not self.getUserProfile(): + # We check if we can get the user profile. + # If we can't get it, that means the access token (JWT) was invalid or expired. + return None + return self._auth_data.access_token + + def refreshAccessToken(self) -> None: + """ + Refresh the access token when it expired. + """ + self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)) + self.onAuthStateChanged.emit(logged_in=True) + + def deleteAuthData(self): + """Delete authentication data from preferences and locally.""" + self._storeAuthData() + self.onAuthStateChanged.emit(logged_in=False) + + def startAuthorizationFlow(self) -> None: + """Start a new OAuth2 authorization flow.""" + + Logger.log("d", "Starting new OAuth2 flow...") + + # Create the tokens needed for the code challenge (PKCE) extension for OAuth2. + # This is needed because the CuraDrivePlugin is a untrusted (open source) client. + # More details can be found at https://tools.ietf.org/html/rfc7636. + verification_code = self._auth_helpers.generateVerificationCode() + challenge_code = self._auth_helpers.generateVerificationCodeChallenge(verification_code) + + # Create the query string needed for the OAuth2 flow. + query_string = urlencode({ + "client_id": self._settings.CLIENT_ID, + "redirect_uri": self._settings.CALLBACK_URL, + "scope": self._settings.CLIENT_SCOPES, + "response_type": "code", + "state": "CuraDriveIsAwesome", + "code_challenge": challenge_code, + "code_challenge_method": "S512" + }) + + # Open the authorization page in a new browser window. + webbrowser.open_new("{}?{}".format(self._auth_url, query_string)) + + # Start a local web server to receive the callback URL on. + self._server.start(verification_code) + + def _onAuthStateChanged(self, auth_response: "AuthenticationResponse") -> None: + """Callback method for an authentication flow.""" + if auth_response.success: + self._storeAuthData(auth_response) + self.onAuthStateChanged.emit(logged_in=True) + else: + self.onAuthenticationError.emit(logged_in=False, error_message=auth_response.err_message) + self._server.stop() # Stop the web server at all times. + + def _loadAuthData(self) -> None: + """Load authentication data from preferences if available.""" + self._cura_preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") + try: + preferences_data = json.loads(self._cura_preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) + if preferences_data: + self._auth_data = AuthenticationResponse(**preferences_data) + self.onAuthStateChanged.emit(logged_in=True) + except ValueError as err: + Logger.log("w", "Could not load auth data from preferences: %s", err) + + def _storeAuthData(self, auth_data: Optional["AuthenticationResponse"] = None) -> None: + """Store authentication data in preferences and locally.""" + self._auth_data = auth_data + if auth_data: + self._user_profile = self.getUserProfile() + self._cura_preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data))) + else: + self._user_profile = None + self._cura_preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py new file mode 100644 index 0000000000..5dc05786bf --- /dev/null +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -0,0 +1,67 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import threading +from http.server import HTTPServer +from typing import Optional, Callable + +# As this module is specific for Cura plugins, we can rely on these imports. +from UM.Logger import Logger + +# Plugin imports need to be relative to work in final builds. +from .AuthorizationHelpers import AuthorizationHelpers +from .AuthorizationRequestServer import AuthorizationRequestServer +from .AuthorizationRequestHandler import AuthorizationRequestHandler +from .models import AuthenticationResponse + + +class LocalAuthorizationServer: + def __init__(self, auth_helpers: "AuthorizationHelpers", + auth_state_changed_callback: "Callable[[AuthenticationResponse], any]", + daemon: bool): + """ + :param auth_helpers: An instance of the authorization helpers class. + :param auth_state_changed_callback: A callback function to be called when the authorization state changes. + :param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped + at shutdown. Their resources (e.g. open files) may never be released. + """ + self._web_server = None # type: Optional[HTTPServer] + self._web_server_thread = None # type: Optional[threading.Thread] + self._web_server_port = auth_helpers.settings.CALLBACK_PORT + self._auth_helpers = auth_helpers + self._auth_state_changed_callback = auth_state_changed_callback + self._daemon = daemon + + def start(self, verification_code: "str") -> None: + """ + Starts the local web server to handle the authorization callback. + :param verification_code: The verification code part of the OAuth2 client identification. + """ + if self._web_server: + # If the server is already running (because of a previously aborted auth flow), we don't have to start it. + # We still inject the new verification code though. + self._web_server.setVerificationCode(verification_code) + return + + Logger.log("d", "Starting local web server to handle authorization callback on port %s", + self._web_server_port) + + # Create the server and inject the callback and code. + self._web_server = AuthorizationRequestServer(("0.0.0.0", self._web_server_port), + AuthorizationRequestHandler) + self._web_server.setAuthorizationHelpers(self._auth_helpers) + self._web_server.setAuthorizationCallback(self._auth_state_changed_callback) + self._web_server.setVerificationCode(verification_code) + + # Start the server on a new thread. + self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon) + self._web_server_thread.start() + + def stop(self) -> None: + """ Stops the web server if it was running. Also deletes the objects. """ + + Logger.log("d", "Stopping local web server...") + + if self._web_server: + self._web_server.server_close() + self._web_server = None + self._web_server_thread = None diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py new file mode 100644 index 0000000000..08bed7e6d9 --- /dev/null +++ b/cura/OAuth2/Models.py @@ -0,0 +1,60 @@ +# Copyright (c) 2018 Ultimaker B.V. +from typing import Optional + + +class BaseModel: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +# OAuth OAuth2Settings data template. +class OAuth2Settings(BaseModel): + CALLBACK_PORT = None # type: Optional[str] + OAUTH_SERVER_URL = None # type: Optional[str] + CLIENT_ID = None # type: Optional[str] + CLIENT_SCOPES = None # type: Optional[str] + CALLBACK_URL = None # type: Optional[str] + AUTH_DATA_PREFERENCE_KEY = None # type: Optional[str] + AUTH_SUCCESS_REDIRECT = "https://ultimaker.com" # type: str + AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str + + +# User profile data template. +class UserProfile(BaseModel): + user_id = None # type: Optional[str] + username = None # type: Optional[str] + profile_image_url = None # type: Optional[str] + + +# Authentication data template. +class AuthenticationResponse(BaseModel): + """Data comes from the token response with success flag and error message added.""" + success = True # type: bool + token_type = None # type: Optional[str] + access_token = None # type: Optional[str] + refresh_token = None # type: Optional[str] + expires_in = None # type: Optional[str] + scope = None # type: Optional[str] + err_message = None # type: Optional[str] + + +# Response status template. +class ResponseStatus(BaseModel): + code = 200 # type: int + message = "" # type str + + +# Response data template. +class ResponseData(BaseModel): + status = None # type: Optional[ResponseStatus] + data_stream = None # type: Optional[bytes] + redirect_uri = None # type: Optional[str] + content_type = "text/html" # type: str + + +# Possible HTTP responses. +HTTP_STATUS = { + "OK": ResponseStatus(code=200, message="OK"), + "NOT_FOUND": ResponseStatus(code=404, message="NOT FOUND"), + "REDIRECT": ResponseStatus(code=302, message="REDIRECT") +} diff --git a/cura/OAuth2/__init__.py b/cura/OAuth2/__init__.py new file mode 100644 index 0000000000..f3f6970c54 --- /dev/null +++ b/cura/OAuth2/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. From 3ae223334f550474e536600ff3519385e18241eb Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Fri, 21 Sep 2018 12:02:11 +0200 Subject: [PATCH 02/37] Removed relative imports Since the oauth module isn't just in a plugin anymore, there is no need for any of the relative imports CURA-5744 --- cura/OAuth2/AuthorizationHelpers.py | 4 +--- cura/OAuth2/AuthorizationRequestHandler.py | 5 ++--- cura/OAuth2/AuthorizationRequestServer.py | 2 +- cura/OAuth2/AuthorizationService.py | 6 +++--- cura/OAuth2/LocalAuthorizationServer.py | 8 ++++---- 5 files changed, 11 insertions(+), 14 deletions(-) diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index 10041f70ce..a122290c38 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -8,11 +8,9 @@ from typing import Optional import requests -# As this module is specific for Cura plugins, we can rely on these imports. from UM.Logger import Logger -# Plugin imports need to be relative to work in final builds. -from .models import AuthenticationResponse, UserProfile, OAuth2Settings +from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings class AuthorizationHelpers: diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py index eb703fc5c1..923787d33f 100644 --- a/cura/OAuth2/AuthorizationRequestHandler.py +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -5,9 +5,8 @@ from typing import Optional, Callable from http.server import BaseHTTPRequestHandler from urllib.parse import parse_qs, urlparse -# Plugin imports need to be relative to work in final builds. -from .AuthorizationHelpers import AuthorizationHelpers -from .models import AuthenticationResponse, ResponseData, HTTP_STATUS, ResponseStatus +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS, ResponseStatus class AuthorizationRequestHandler(BaseHTTPRequestHandler): diff --git a/cura/OAuth2/AuthorizationRequestServer.py b/cura/OAuth2/AuthorizationRequestServer.py index ee428bc236..270c558167 100644 --- a/cura/OAuth2/AuthorizationRequestServer.py +++ b/cura/OAuth2/AuthorizationRequestServer.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from http.server import HTTPServer -from .AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers class AuthorizationRequestServer(HTTPServer): diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index f425e3a003..eb68d5c0a4 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -10,9 +10,9 @@ from UM.Logger import Logger from UM.Signal import Signal # Plugin imports need to be relative to work in final builds. -from .LocalAuthorizationServer import LocalAuthorizationServer -from .AuthorizationHelpers import AuthorizationHelpers -from .models import OAuth2Settings, AuthenticationResponse, UserProfile +from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.Models import OAuth2Settings, AuthenticationResponse, UserProfile class AuthorizationService: diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py index 5dc05786bf..9979eaaa08 100644 --- a/cura/OAuth2/LocalAuthorizationServer.py +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -8,10 +8,10 @@ from typing import Optional, Callable from UM.Logger import Logger # Plugin imports need to be relative to work in final builds. -from .AuthorizationHelpers import AuthorizationHelpers -from .AuthorizationRequestServer import AuthorizationRequestServer -from .AuthorizationRequestHandler import AuthorizationRequestHandler -from .models import AuthenticationResponse +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.AuthorizationRequestServer import AuthorizationRequestServer +from cura.OAuth2.AuthorizationRequestHandler import AuthorizationRequestHandler +from cura.OAuth2.Models import AuthenticationResponse class LocalAuthorizationServer: From d0fc4878c29a04bbcfcb289775d93a04b62b9517 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Fri, 21 Sep 2018 13:54:37 +0200 Subject: [PATCH 03/37] Fix number of mypy mistakes CURA-5744 --- cura/OAuth2/AuthorizationHelpers.py | 9 ++++----- cura/OAuth2/AuthorizationRequestHandler.py | 11 ++++++----- cura/OAuth2/AuthorizationService.py | 18 ++++++++++++++---- cura/OAuth2/LocalAuthorizationServer.py | 13 ++++++++----- cura/OAuth2/Models.py | 2 +- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index a122290c38..06cc0a6061 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -16,7 +16,7 @@ from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settin class AuthorizationHelpers: """Class containing several helpers to deal with the authorization flow.""" - def __init__(self, settings: "OAuth2Settings"): + def __init__(self, settings: "OAuth2Settings") -> None: self._settings = settings self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) @@ -25,8 +25,7 @@ class AuthorizationHelpers: """Get the OAuth2 settings object.""" return self._settings - def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str)->\ - Optional["AuthenticationResponse"]: + def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str)-> "AuthenticationResponse": """ Request the access token from the authorization server. :param authorization_code: The authorization code from the 1st step. @@ -42,7 +41,7 @@ class AuthorizationHelpers: "scope": self._settings.CLIENT_SCOPES })) - def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> Optional["AuthenticationResponse"]: + def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> AuthenticationResponse: """ Request the access token from the authorization server using a refresh token. :param refresh_token: @@ -57,7 +56,7 @@ class AuthorizationHelpers: })) @staticmethod - def parseTokenResponse(token_response: "requests.request") -> Optional["AuthenticationResponse"]: + def parseTokenResponse(token_response: requests.models.Response) -> AuthenticationResponse: """ Parse the token response from the authorization server into an AuthenticationResponse object. :param token_response: The JSON string data response from the authorization server. diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py index 923787d33f..d13639c45d 100644 --- a/cura/OAuth2/AuthorizationRequestHandler.py +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Callable +from typing import Optional, Callable, Tuple, Dict, Any, List from http.server import BaseHTTPRequestHandler from urllib.parse import parse_qs, urlparse @@ -49,16 +49,17 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): # This will cause the server to shut down, so we do it at the very end of the request handling. self.authorization_callback(token_response) - def _handleCallback(self, query: dict) -> ("ResponseData", Optional["AuthenticationResponse"]): + def _handleCallback(self, query: Dict[Any, List]) -> Tuple["ResponseData", Optional["AuthenticationResponse"]]: """ Handler for the callback URL redirect. :param query: Dict containing the HTTP query parameters. :return: HTTP ResponseData containing a success page to show to the user. """ - if self._queryGet(query, "code"): + code = self._queryGet(query, "code") + if code: # If the code was returned we get the access token. token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode( - self._queryGet(query, "code"), self.verification_code) + code, self.verification_code) elif self._queryGet(query, "error_code") == "user_denied": # Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog). @@ -99,6 +100,6 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): self.wfile.write(data) @staticmethod - def _queryGet(query_data: dict, key: str, default=None) -> Optional[str]: + def _queryGet(query_data: Dict[Any, List], key: str, default=None) -> Optional[str]: """Helper for getting values from a pre-parsed query string""" return query_data.get(key, [default])[0] diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index eb68d5c0a4..4c66170c32 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -27,7 +27,7 @@ class AuthorizationService: # Emit signal when authentication failed. onAuthenticationError = Signal() - def __init__(self, preferences, settings: "OAuth2Settings"): + def __init__(self, preferences, settings: "OAuth2Settings") -> None: self._settings = settings self._auth_helpers = AuthorizationHelpers(settings) self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) @@ -55,7 +55,7 @@ class AuthorizationService: Tries to parse the JWT if all the needed data exists. :return: UserProfile if found, otherwise None. """ - if not self._auth_data: + if not self._auth_data or self._auth_data.access_token is None: # If no auth data exists, we should always log in again. return None user_data = self._auth_helpers.parseJWT(self._auth_data.access_token) @@ -63,10 +63,13 @@ class AuthorizationService: # If the profile was found, we return it immediately. return user_data # The JWT was expired or invalid and we should request a new one. + if self._auth_data.refresh_token is None: + return None self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token) - if not self._auth_data: + if not self._auth_data or self._auth_data.access_token is None: # The token could not be refreshed using the refresh token. We should login again. return None + return self._auth_helpers.parseJWT(self._auth_data.access_token) def getAccessToken(self) -> Optional[str]: @@ -78,16 +81,23 @@ class AuthorizationService: # We check if we can get the user profile. # If we can't get it, that means the access token (JWT) was invalid or expired. return None + + if self._auth_data is None: + return None + return self._auth_data.access_token def refreshAccessToken(self) -> None: """ Refresh the access token when it expired. """ + if self._auth_data is None or self._auth_data.refresh_token is None: + Logger.log("w", "Unable to refresh acces token, since there is no refresh token.") + return self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)) self.onAuthStateChanged.emit(logged_in=True) - def deleteAuthData(self): + def deleteAuthData(self) -> None: """Delete authentication data from preferences and locally.""" self._storeAuthData() self.onAuthStateChanged.emit(logged_in=False) diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py index 9979eaaa08..d6a4bf5216 100644 --- a/cura/OAuth2/LocalAuthorizationServer.py +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import threading from http.server import HTTPServer -from typing import Optional, Callable +from typing import Optional, Callable, Any # As this module is specific for Cura plugins, we can rely on these imports. from UM.Logger import Logger @@ -16,22 +16,22 @@ from cura.OAuth2.Models import AuthenticationResponse class LocalAuthorizationServer: def __init__(self, auth_helpers: "AuthorizationHelpers", - auth_state_changed_callback: "Callable[[AuthenticationResponse], any]", - daemon: bool): + auth_state_changed_callback: "Callable[[AuthenticationResponse], Any]", + daemon: bool) -> None: """ :param auth_helpers: An instance of the authorization helpers class. :param auth_state_changed_callback: A callback function to be called when the authorization state changes. :param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped at shutdown. Their resources (e.g. open files) may never be released. """ - self._web_server = None # type: Optional[HTTPServer] + self._web_server = None # type: Optional[AuthorizationRequestServer] self._web_server_thread = None # type: Optional[threading.Thread] self._web_server_port = auth_helpers.settings.CALLBACK_PORT self._auth_helpers = auth_helpers self._auth_state_changed_callback = auth_state_changed_callback self._daemon = daemon - def start(self, verification_code: "str") -> None: + def start(self, verification_code: str) -> None: """ Starts the local web server to handle the authorization callback. :param verification_code: The verification code part of the OAuth2 client identification. @@ -42,6 +42,9 @@ class LocalAuthorizationServer: self._web_server.setVerificationCode(verification_code) return + if self._web_server_port is None: + raise Exception("Unable to start server without specifying the port.") + Logger.log("d", "Starting local web server to handle authorization callback on port %s", self._web_server_port) diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py index 08bed7e6d9..a6b91cae26 100644 --- a/cura/OAuth2/Models.py +++ b/cura/OAuth2/Models.py @@ -9,7 +9,7 @@ class BaseModel: # OAuth OAuth2Settings data template. class OAuth2Settings(BaseModel): - CALLBACK_PORT = None # type: Optional[str] + CALLBACK_PORT = None # type: Optional[int] OAUTH_SERVER_URL = None # type: Optional[str] CLIENT_ID = None # type: Optional[str] CLIENT_SCOPES = None # type: Optional[str] From 060ea0b762ae6dce1a1f041f56c7617346be118a Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Fri, 21 Sep 2018 14:12:31 +0200 Subject: [PATCH 04/37] Fixed up final bit of mypy issues CURA-5744 --- cura/OAuth2/AuthorizationRequestHandler.py | 27 +++++++++++++--------- cura/OAuth2/AuthorizationRequestServer.py | 13 +++++++---- cura/OAuth2/AuthorizationService.py | 16 ++++++------- cura/OAuth2/LocalAuthorizationServer.py | 13 +++++------ cura/OAuth2/Models.py | 2 +- 5 files changed, 39 insertions(+), 32 deletions(-) diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py index d13639c45d..0558db784f 100644 --- a/cura/OAuth2/AuthorizationRequestHandler.py +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -1,12 +1,15 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Callable, Tuple, Dict, Any, List +from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING from http.server import BaseHTTPRequestHandler from urllib.parse import parse_qs, urlparse from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers -from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS, ResponseStatus +from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS + +if TYPE_CHECKING: + from cura.OAuth2.Models import ResponseStatus class AuthorizationRequestHandler(BaseHTTPRequestHandler): @@ -15,15 +18,15 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): It also requests the access token for the 2nd stage of the OAuth flow. """ - def __init__(self, request, client_address, server): + def __init__(self, request, client_address, server) -> None: super().__init__(request, client_address, server) # These values will be injected by the HTTPServer that this handler belongs to. - self.authorization_helpers = None # type: AuthorizationHelpers - self.authorization_callback = None # type: Callable[[AuthenticationResponse], None] - self.verification_code = None # type: str + self.authorization_helpers = None # type: Optional[AuthorizationHelpers] + self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]] + self.verification_code = None # type: Optional[str] - def do_GET(self): + def do_GET(self) -> None: """Entry point for GET requests""" # Extract values from the query string. @@ -44,7 +47,7 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): # If there is data in the response, we send it. self._sendData(server_response.data_stream) - if token_response: + if token_response and self.authorization_callback is not None: # Trigger the callback if we got a response. # This will cause the server to shut down, so we do it at the very end of the request handling. self.authorization_callback(token_response) @@ -56,7 +59,7 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): :return: HTTP ResponseData containing a success page to show to the user. """ code = self._queryGet(query, "code") - if code: + if code and self.authorization_helpers is not None and self.verification_code is not None: # If the code was returned we get the access token. token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode( code, self.verification_code) @@ -74,6 +77,8 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): success=False, error_message="Something unexpected happened when trying to log in, please try again." ) + if self.authorization_helpers is None: + return ResponseData(), token_response return ResponseData( status=HTTP_STATUS["REDIRECT"], @@ -83,7 +88,7 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): ), token_response @staticmethod - def _handleNotFound() -> "ResponseData": + def _handleNotFound() -> ResponseData: """Handle all other non-existing server calls.""" return ResponseData(status=HTTP_STATUS["NOT_FOUND"], content_type="text/html", data_stream=b"Not found.") @@ -100,6 +105,6 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): self.wfile.write(data) @staticmethod - def _queryGet(query_data: Dict[Any, List], key: str, default=None) -> Optional[str]: + def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str]=None) -> Optional[str]: """Helper for getting values from a pre-parsed query string""" return query_data.get(key, [default])[0] diff --git a/cura/OAuth2/AuthorizationRequestServer.py b/cura/OAuth2/AuthorizationRequestServer.py index 270c558167..514a4ab5de 100644 --- a/cura/OAuth2/AuthorizationRequestServer.py +++ b/cura/OAuth2/AuthorizationRequestServer.py @@ -1,8 +1,11 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from http.server import HTTPServer +from typing import Callable, Any, TYPE_CHECKING -from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +if TYPE_CHECKING: + from cura.OAuth2.Models import AuthenticationResponse + from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers class AuthorizationRequestServer(HTTPServer): @@ -14,12 +17,12 @@ class AuthorizationRequestServer(HTTPServer): def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: """Set the authorization helpers instance on the request handler.""" - self.RequestHandlerClass.authorization_helpers = authorization_helpers + self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore - def setAuthorizationCallback(self, authorization_callback) -> None: + def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None: """Set the authorization callback on the request handler.""" - self.RequestHandlerClass.authorization_callback = authorization_callback + self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore def setVerificationCode(self, verification_code: str) -> None: """Set the verification code on the request handler.""" - self.RequestHandlerClass.verification_code = verification_code + self.RequestHandlerClass.verification_code = verification_code # type: ignore diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 4c66170c32..33ea419ff5 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -2,17 +2,18 @@ # Cura is released under the terms of the LGPLv3 or higher. import json import webbrowser -from typing import Optional +from typing import Optional, TYPE_CHECKING from urllib.parse import urlencode -# As this module is specific for Cura plugins, we can rely on these imports. from UM.Logger import Logger from UM.Signal import Signal -# Plugin imports need to be relative to work in final builds. from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers -from cura.OAuth2.Models import OAuth2Settings, AuthenticationResponse, UserProfile +from cura.OAuth2.Models import AuthenticationResponse + +if TYPE_CHECKING: + from cura.OAuth2.Models import UserProfile, OAuth2Settings class AuthorizationService: @@ -32,7 +33,7 @@ class AuthorizationService: self._auth_helpers = AuthorizationHelpers(settings) self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) self._auth_data = None # type: Optional[AuthenticationResponse] - self._user_profile = None # type: Optional[UserProfile] + self._user_profile = None # type: Optional["UserProfile"] self._cura_preferences = preferences self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) self._loadAuthData() @@ -75,7 +76,6 @@ class AuthorizationService: def getAccessToken(self) -> Optional[str]: """ Get the access token response data. - :return: Dict containing token data. """ if not self.getUserProfile(): # We check if we can get the user profile. @@ -130,7 +130,7 @@ class AuthorizationService: # Start a local web server to receive the callback URL on. self._server.start(verification_code) - def _onAuthStateChanged(self, auth_response: "AuthenticationResponse") -> None: + def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: """Callback method for an authentication flow.""" if auth_response.success: self._storeAuthData(auth_response) @@ -150,7 +150,7 @@ class AuthorizationService: except ValueError as err: Logger.log("w", "Could not load auth data from preferences: %s", err) - def _storeAuthData(self, auth_data: Optional["AuthenticationResponse"] = None) -> None: + def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: """Store authentication data in preferences and locally.""" self._auth_data = auth_data if auth_data: diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py index d6a4bf5216..d1d07b5c91 100644 --- a/cura/OAuth2/LocalAuthorizationServer.py +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -1,22 +1,21 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import threading -from http.server import HTTPServer -from typing import Optional, Callable, Any +from typing import Optional, Callable, Any, TYPE_CHECKING -# As this module is specific for Cura plugins, we can rely on these imports. from UM.Logger import Logger -# Plugin imports need to be relative to work in final builds. from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.AuthorizationRequestServer import AuthorizationRequestServer from cura.OAuth2.AuthorizationRequestHandler import AuthorizationRequestHandler -from cura.OAuth2.Models import AuthenticationResponse + +if TYPE_CHECKING: + from cura.OAuth2.Models import AuthenticationResponse class LocalAuthorizationServer: def __init__(self, auth_helpers: "AuthorizationHelpers", - auth_state_changed_callback: "Callable[[AuthenticationResponse], Any]", + auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], daemon: bool) -> None: """ :param auth_helpers: An instance of the authorization helpers class. @@ -62,7 +61,7 @@ class LocalAuthorizationServer: def stop(self) -> None: """ Stops the web server if it was running. Also deletes the objects. """ - Logger.log("d", "Stopping local web server...") + Logger.log("d", "Stopping local oauth2 web server...") if self._web_server: self._web_server.server_close() diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py index a6b91cae26..796fdf8746 100644 --- a/cura/OAuth2/Models.py +++ b/cura/OAuth2/Models.py @@ -46,7 +46,7 @@ class ResponseStatus(BaseModel): # Response data template. class ResponseData(BaseModel): - status = None # type: Optional[ResponseStatus] + status = None # type: ResponseStatus data_stream = None # type: Optional[bytes] redirect_uri = None # type: Optional[str] content_type = "text/html" # type: str From b54383e685a0fc5909a4a3f3c9a3d414bf0e8c44 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Fri, 21 Sep 2018 16:43:32 +0200 Subject: [PATCH 05/37] Added account object to API CURA-5744 --- cura/API/Account.py | 88 +++++++++++++++++++++++++++++ cura/API/__init__.py | 5 +- cura/OAuth2/AuthorizationService.py | 1 + 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 cura/API/Account.py diff --git a/cura/API/Account.py b/cura/API/Account.py new file mode 100644 index 0000000000..377464f438 --- /dev/null +++ b/cura/API/Account.py @@ -0,0 +1,88 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Tuple, Optional, Dict + +from PyQt5.QtCore.QObject import QObject, pyqtSignal, pyqtSlot, pyqtProperty + +from UM.Message import Message +from cura.OAuth2.AuthorizationService import AuthorizationService +from cura.OAuth2.Models import OAuth2Settings +from UM.Application import Application + +from UM.i18n import i18nCatalog +i18n_catalog = i18nCatalog("cura") + + +## The account API provides a version-proof bridge to use Ultimaker Accounts +# +# Usage: +# ``from cura.API import CuraAPI +# api = CuraAPI() +# api.account.login() +# api.account.logout() +# api.account.userProfile # Who is logged in`` +# +class Account(QObject): + # Signal emitted when user logged in or out. + loginStateChanged = pyqtSignal() + + def __init__(self, parent = None) -> None: + super().__init__(parent) + self._callback_port = 32118 + self._oauth_root = "https://account.ultimaker.com" + self._cloud_api_root = "https://api.ultimaker.com" + + self._oauth_settings = OAuth2Settings( + OAUTH_SERVER_URL= self._oauth_root, + CALLBACK_PORT=self._callback_port, + CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), + CLIENT_ID="um---------------ultimaker_cura_drive_plugin", + CLIENT_SCOPES="user.read drive.backups.read drive.backups.write", + AUTH_DATA_PREFERENCE_KEY="cura_drive/auth_data", + AUTH_SUCCESS_REDIRECT="{}/cura-drive/v1/auth-success".format(self._cloud_api_root), + AUTH_FAILED_REDIRECT="{}/cura-drive/v1/auth-error".format(self._cloud_api_root) + ) + + self._authorization_service = AuthorizationService(Application.getInstance().getPreferences(), self._oauth_settings) + + self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) + self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) + + self._error_message = None + self._logged_in = False + + @pyqtProperty(bool, notify=loginStateChanged) + def isLoggedIn(self) -> bool: + return self._logged_in + + def _onLoginStateChanged(self, logged_in: bool = False, error_message: Optional[str] = None) -> None: + if error_message: + if self._error_message: + self._error_message.hide() + self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed")) + self._error_message.show() + + if self._logged_in != logged_in: + self._logged_in = logged_in + self.loginStateChanged.emit() + + def login(self) -> None: + if self._logged_in: + # Nothing to do, user already logged in. + return + self._authorization_service.startAuthorizationFlow() + + # Get the profile of the logged in user + # @returns None if no user is logged in, a dict containing user_id, username and profile_image_url + @pyqtProperty("QVariantMap", notify = loginStateChanged) + def userProfile(self) -> Optional[Dict[str, Optional[str]]]: + user_profile = self._authorization_service.getUserProfile() + if not user_profile: + return None + return user_profile.__dict__ + + def logout(self) -> None: + if not self._logged_in: + return # Nothing to do, user isn't logged in. + + self._authorization_service.deleteAuthData() diff --git a/cura/API/__init__.py b/cura/API/__init__.py index 64d636903d..d6d9092219 100644 --- a/cura/API/__init__.py +++ b/cura/API/__init__.py @@ -3,6 +3,8 @@ from UM.PluginRegistry import PluginRegistry from cura.API.Backups import Backups from cura.API.Interface import Interface +from cura.API.Account import Account + ## The official Cura API that plug-ins can use to interact with Cura. # @@ -10,7 +12,6 @@ from cura.API.Interface import Interface # this API provides a version-safe interface with proper deprecation warnings # etc. Usage of any other methods than the ones provided in this API can cause # plug-ins to be unstable. - class CuraAPI: # For now we use the same API version to be consistent. @@ -21,3 +22,5 @@ class CuraAPI: # Interface API interface = Interface() + + account = Account() diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 33ea419ff5..868dbe8034 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -49,6 +49,7 @@ class AuthorizationService: if not self._user_profile: # If there is still no user profile from the JWT, we have to log in again. return None + return self._user_profile def _parseJWT(self) -> Optional["UserProfile"]: From 081b2a28fe6a3b6d8136ec6e8bc67745262e9ede Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Fri, 21 Sep 2018 17:23:30 +0200 Subject: [PATCH 06/37] Expose Account API to QML This is done by adding the API as an SingletonType to Cura. CURA-5744 --- cura/API/Account.py | 18 +++++++++++++++++- cura/API/__init__.py | 10 ++++++++-- cura/CuraApplication.py | 10 ++++++++++ resources/qml/Cura.qml | 3 +-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 377464f438..7ccd995be3 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from typing import Tuple, Optional, Dict -from PyQt5.QtCore.QObject import QObject, pyqtSignal, pyqtSlot, pyqtProperty +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty from UM.Message import Message from cura.OAuth2.AuthorizationService import AuthorizationService @@ -66,12 +66,27 @@ class Account(QObject): self._logged_in = logged_in self.loginStateChanged.emit() + @pyqtSlot() def login(self) -> None: if self._logged_in: # Nothing to do, user already logged in. return self._authorization_service.startAuthorizationFlow() + @pyqtProperty(str, notify=loginStateChanged) + def userName(self): + user_profile = self._authorization_service.getUserProfile() + if not user_profile: + return None + return user_profile.username + + @pyqtProperty(str, notify = loginStateChanged) + def profileImageUrl(self): + user_profile = self._authorization_service.getUserProfile() + if not user_profile: + return None + return user_profile.profile_image_url + # Get the profile of the logged in user # @returns None if no user is logged in, a dict containing user_id, username and profile_image_url @pyqtProperty("QVariantMap", notify = loginStateChanged) @@ -81,6 +96,7 @@ class Account(QObject): return None return user_profile.__dict__ + @pyqtSlot() def logout(self) -> None: if not self._logged_in: return # Nothing to do, user isn't logged in. diff --git a/cura/API/__init__.py b/cura/API/__init__.py index d6d9092219..54f5c1f8b0 100644 --- a/cura/API/__init__.py +++ b/cura/API/__init__.py @@ -1,5 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from PyQt5.QtCore import QObject, pyqtProperty + from UM.PluginRegistry import PluginRegistry from cura.API.Backups import Backups from cura.API.Interface import Interface @@ -12,7 +14,7 @@ from cura.API.Account import Account # this API provides a version-safe interface with proper deprecation warnings # etc. Usage of any other methods than the ones provided in this API can cause # plug-ins to be unstable. -class CuraAPI: +class CuraAPI(QObject): # For now we use the same API version to be consistent. VERSION = PluginRegistry.APIVersion @@ -23,4 +25,8 @@ class CuraAPI: # Interface API interface = Interface() - account = Account() + _account = Account() + + @pyqtProperty(QObject, constant = True) + def account(self) -> Account: + return CuraAPI._account diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index a94814502e..cd0cfb95d6 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -204,6 +204,7 @@ class CuraApplication(QtApplication): self._quality_profile_drop_down_menu_model = None self._custom_quality_profile_drop_down_menu_model = None + self._cura_API = None self._physics = None self._volume = None @@ -894,6 +895,12 @@ class CuraApplication(QtApplication): self._custom_quality_profile_drop_down_menu_model = CustomQualityProfilesDropDownMenuModel(self) return self._custom_quality_profile_drop_down_menu_model + def getCuraAPI(self, *args, **kwargs): + if self._cura_API is None: + from cura.API import CuraAPI + self._cura_API = CuraAPI() + return self._cura_API + ## Registers objects for the QML engine to use. # # \param engine The QML engine. @@ -942,6 +949,9 @@ class CuraApplication(QtApplication): qmlRegisterSingletonType(ContainerManager, "Cura", 1, 0, "ContainerManager", ContainerManager.getInstance) qmlRegisterType(SidebarCustomMenuItemsModel, "Cura", 1, 0, "SidebarCustomMenuItemsModel") + from cura.API import CuraAPI + qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI) + # As of Qt5.7, it is necessary to get rid of any ".." in the path for the singleton to work. actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml"))) qmlRegisterSingletonType(actions_url, "Cura", 1, 0, "Actions") diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml index 07154a0729..b3367471ad 100644 --- a/resources/qml/Cura.qml +++ b/resources/qml/Cura.qml @@ -8,7 +8,7 @@ import QtQuick.Layouts 1.1 import QtQuick.Dialogs 1.2 import UM 1.3 as UM -import Cura 1.0 as Cura +import Cura 1.1 as Cura import "Menus" @@ -21,7 +21,6 @@ UM.MainWindow property bool showPrintMonitor: false backgroundColor: UM.Theme.getColor("viewport_background") - // This connection is here to support legacy printer output devices that use the showPrintMonitor signal on Application to switch to the monitor stage // It should be phased out in newer plugin versions. Connections From 1e5177a44f1258d45c4ed188b9114e7c2bdf5e92 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Mon, 24 Sep 2018 17:04:20 +0200 Subject: [PATCH 07/37] Added unit tests for authorization service CURA-5744 --- cura/OAuth2/AuthorizationService.py | 6 +- tests/TestOAuth2.py | 90 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tests/TestOAuth2.py diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 868dbe8034..0f57621a47 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -93,7 +93,7 @@ class AuthorizationService: Refresh the access token when it expired. """ if self._auth_data is None or self._auth_data.refresh_token is None: - Logger.log("w", "Unable to refresh acces token, since there is no refresh token.") + Logger.log("w", "Unable to refresh access token, since there is no refresh token.") return self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)) self.onAuthStateChanged.emit(logged_in=True) @@ -148,8 +148,8 @@ class AuthorizationService: if preferences_data: self._auth_data = AuthenticationResponse(**preferences_data) self.onAuthStateChanged.emit(logged_in=True) - except ValueError as err: - Logger.log("w", "Could not load auth data from preferences: %s", err) + except ValueError: + Logger.logException("w", "Could not load auth data from preferences") def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: """Store authentication data in preferences and locally.""" diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py new file mode 100644 index 0000000000..10578eaeb0 --- /dev/null +++ b/tests/TestOAuth2.py @@ -0,0 +1,90 @@ +from unittest.mock import MagicMock, patch + +from UM.Preferences import Preferences +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.AuthorizationService import AuthorizationService +from cura.OAuth2.Models import OAuth2Settings, AuthenticationResponse, UserProfile + +CALLBACK_PORT = 32118 +OAUTH_ROOT = "https://account.ultimaker.com" +CLOUD_API_ROOT = "https://api.ultimaker.com" + +OAUTH_SETTINGS = OAuth2Settings( + OAUTH_SERVER_URL= OAUTH_ROOT, + CALLBACK_PORT=CALLBACK_PORT, + CALLBACK_URL="http://localhost:{}/callback".format(CALLBACK_PORT), + CLIENT_ID="", + CLIENT_SCOPES="", + AUTH_DATA_PREFERENCE_KEY="test/auth_data", + AUTH_SUCCESS_REDIRECT="{}/auth-success".format(CLOUD_API_ROOT), + AUTH_FAILED_REDIRECT="{}/auth-error".format(CLOUD_API_ROOT) + ) + +FAILED_AUTH_RESPONSE = AuthenticationResponse(success = False, err_message = "FAILURE!") + +SUCCESFULL_AUTH_RESPONSE = AuthenticationResponse(access_token = "beep", refresh_token = "beep?") + +MALFORMED_AUTH_RESPONSE = AuthenticationResponse() + + +def test_cleanAuthService(): + # Ensure that when setting up an AuthorizationService, no data is set. + authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) + assert authorization_service.getUserProfile() is None + assert authorization_service.getAccessToken() is None + + +def test_failedLogin(): + authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) + authorization_service.onAuthenticationError.emit = MagicMock() + authorization_service.onAuthStateChanged.emit = MagicMock() + + # Let the service think there was a failed response + authorization_service._onAuthStateChanged(FAILED_AUTH_RESPONSE) + + # Check that the error signal was triggered + assert authorization_service.onAuthenticationError.emit.call_count == 1 + + # Since nothing changed, this should still be 0. + assert authorization_service.onAuthStateChanged.emit.call_count == 0 + + # Validate that there is no user profile or token + assert authorization_service.getUserProfile() is None + assert authorization_service.getAccessToken() is None + + +def test_loginAndLogout(): + preferences = Preferences() + authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) + authorization_service.onAuthenticationError.emit = MagicMock() + authorization_service.onAuthStateChanged.emit = MagicMock() + + # Let the service think there was a succesfull response + with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()): + authorization_service._onAuthStateChanged(SUCCESFULL_AUTH_RESPONSE) + + # Ensure that the error signal was not triggered + assert authorization_service.onAuthenticationError.emit.call_count == 0 + + # Since we said that it went right this time, validate that we got a signal. + assert authorization_service.onAuthStateChanged.emit.call_count == 1 + assert authorization_service.getUserProfile() is not None + assert authorization_service.getAccessToken() == "beep" + + # Check that we stored the authentication data, so next time the user won't have to log in again. + assert preferences.getValue("test/auth_data") is not None + + # We're logged in now, also check if logging out works + authorization_service.deleteAuthData() + assert authorization_service.onAuthStateChanged.emit.call_count == 2 + assert authorization_service.getUserProfile() is None + + # Ensure the data is gone after we logged out. + assert preferences.getValue("test/auth_data") == "{}" + + +def test_wrongServerResponses(): + authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) + with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()): + authorization_service._onAuthStateChanged(MALFORMED_AUTH_RESPONSE) + assert authorization_service.getUserProfile() is None \ No newline at end of file From fe85c020b1bed640277df95160d3e77ef2a0e614 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Mon, 24 Sep 2018 17:12:45 +0200 Subject: [PATCH 08/37] Fixed incorrect OAuth2 settings CURA-5744 --- cura/API/Account.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 7ccd995be3..19ee0123d7 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -36,11 +36,11 @@ class Account(QObject): OAUTH_SERVER_URL= self._oauth_root, CALLBACK_PORT=self._callback_port, CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), - CLIENT_ID="um---------------ultimaker_cura_drive_plugin", - CLIENT_SCOPES="user.read drive.backups.read drive.backups.write", - AUTH_DATA_PREFERENCE_KEY="cura_drive/auth_data", - AUTH_SUCCESS_REDIRECT="{}/cura-drive/v1/auth-success".format(self._cloud_api_root), - AUTH_FAILED_REDIRECT="{}/cura-drive/v1/auth-error".format(self._cloud_api_root) + CLIENT_ID="um---------------ultimaker_cura", + CLIENT_SCOPES="user.read drive.backups.read drive.backups.write.client.package.download", + AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", + AUTH_SUCCESS_REDIRECT="{}/auth-success".format(self._cloud_api_root), + AUTH_FAILED_REDIRECT="{}//auth-error".format(self._cloud_api_root) ) self._authorization_service = AuthorizationService(Application.getInstance().getPreferences(), self._oauth_settings) From 7360313ff7555253fdf01390d582574ed745bffd Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Mon, 24 Sep 2018 17:26:08 +0200 Subject: [PATCH 09/37] Add LocalAuthServer test This is to ensure that once we try to login, it actually attempts to start the local server CURA-5744 --- tests/TestOAuth2.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 10578eaeb0..708dd2d41b 100644 --- a/tests/TestOAuth2.py +++ b/tests/TestOAuth2.py @@ -1,8 +1,10 @@ +import webbrowser from unittest.mock import MagicMock, patch from UM.Preferences import Preferences from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.AuthorizationService import AuthorizationService +from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer from cura.OAuth2.Models import OAuth2Settings, AuthenticationResponse, UserProfile CALLBACK_PORT = 32118 @@ -53,6 +55,24 @@ def test_failedLogin(): assert authorization_service.getAccessToken() is None +def test_localAuthServer(): + preferences = Preferences() + authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) + with patch.object(webbrowser, "open_new") as webrowser_open: + with patch.object(LocalAuthorizationServer, "start") as start_auth_server: + with patch.object(LocalAuthorizationServer, "stop") as stop_auth_server: + authorization_service.startAuthorizationFlow() + assert webrowser_open.call_count == 1 + + # Ensure that the Authorization service tried to start the server. + assert start_auth_server.call_count == 1 + assert stop_auth_server.call_count == 0 + authorization_service._onAuthStateChanged(FAILED_AUTH_RESPONSE) + + # Ensure that it stopped the server. + assert stop_auth_server.call_count == 1 + + def test_loginAndLogout(): preferences = Preferences() authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) From f16a9c62b52723c4974d6d333f3c293aebd9ce78 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Mon, 24 Sep 2018 17:28:19 +0200 Subject: [PATCH 10/37] Fix typo CL-5744 --- cura/API/Account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 19ee0123d7..cb80131425 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -37,7 +37,7 @@ class Account(QObject): CALLBACK_PORT=self._callback_port, CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), CLIENT_ID="um---------------ultimaker_cura", - CLIENT_SCOPES="user.read drive.backups.read drive.backups.write.client.package.download", + CLIENT_SCOPES="user.read drive.backups.read drive.backups.write client.package.download", AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", AUTH_SUCCESS_REDIRECT="{}/auth-success".format(self._cloud_api_root), AUTH_FAILED_REDIRECT="{}//auth-error".format(self._cloud_api_root) From b48adf5b3e4fd68f3329105d17a668021738df9f Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Mon, 24 Sep 2018 17:37:06 +0200 Subject: [PATCH 11/37] Typing fixes CURA-5744 --- cura/API/Account.py | 4 ++-- cura/OAuth2/AuthorizationRequestHandler.py | 6 +++--- cura/OAuth2/LocalAuthorizationServer.py | 3 +-- tests/TestOAuth2.py | 10 +++++----- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index cb80131425..6bb5b4e50d 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Tuple, Optional, Dict +from typing import Optional, Dict from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty @@ -48,7 +48,7 @@ class Account(QObject): self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) - self._error_message = None + self._error_message = None # type: Optional[Message] self._logged_in = False @pyqtProperty(bool, notify=loginStateChanged) diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py index 0558db784f..3b5b0c34d8 100644 --- a/cura/OAuth2/AuthorizationRequestHandler.py +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -5,11 +5,11 @@ from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING from http.server import BaseHTTPRequestHandler from urllib.parse import parse_qs, urlparse -from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS if TYPE_CHECKING: from cura.OAuth2.Models import ResponseStatus + from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers class AuthorizationRequestHandler(BaseHTTPRequestHandler): @@ -22,7 +22,7 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): super().__init__(request, client_address, server) # These values will be injected by the HTTPServer that this handler belongs to. - self.authorization_helpers = None # type: Optional[AuthorizationHelpers] + self.authorization_helpers = None # type: Optional["AuthorizationHelpers"] self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]] self.verification_code = None # type: Optional[str] @@ -52,7 +52,7 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): # This will cause the server to shut down, so we do it at the very end of the request handling. self.authorization_callback(token_response) - def _handleCallback(self, query: Dict[Any, List]) -> Tuple["ResponseData", Optional["AuthenticationResponse"]]: + def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]: """ Handler for the callback URL redirect. :param query: Dict containing the HTTP query parameters. diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py index d1d07b5c91..488a33941d 100644 --- a/cura/OAuth2/LocalAuthorizationServer.py +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -5,13 +5,12 @@ from typing import Optional, Callable, Any, TYPE_CHECKING from UM.Logger import Logger -from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.AuthorizationRequestServer import AuthorizationRequestServer from cura.OAuth2.AuthorizationRequestHandler import AuthorizationRequestHandler if TYPE_CHECKING: from cura.OAuth2.Models import AuthenticationResponse - + from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers class LocalAuthorizationServer: def __init__(self, auth_helpers: "AuthorizationHelpers", diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 708dd2d41b..7deb712aea 100644 --- a/tests/TestOAuth2.py +++ b/tests/TestOAuth2.py @@ -29,14 +29,14 @@ SUCCESFULL_AUTH_RESPONSE = AuthenticationResponse(access_token = "beep", refresh MALFORMED_AUTH_RESPONSE = AuthenticationResponse() -def test_cleanAuthService(): +def test_cleanAuthService() -> None: # Ensure that when setting up an AuthorizationService, no data is set. authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) assert authorization_service.getUserProfile() is None assert authorization_service.getAccessToken() is None -def test_failedLogin(): +def test_failedLogin() -> None: authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) authorization_service.onAuthenticationError.emit = MagicMock() authorization_service.onAuthStateChanged.emit = MagicMock() @@ -55,7 +55,7 @@ def test_failedLogin(): assert authorization_service.getAccessToken() is None -def test_localAuthServer(): +def test_localAuthServer() -> None: preferences = Preferences() authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) with patch.object(webbrowser, "open_new") as webrowser_open: @@ -73,7 +73,7 @@ def test_localAuthServer(): assert stop_auth_server.call_count == 1 -def test_loginAndLogout(): +def test_loginAndLogout() -> None: preferences = Preferences() authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) authorization_service.onAuthenticationError.emit = MagicMock() @@ -103,7 +103,7 @@ def test_loginAndLogout(): assert preferences.getValue("test/auth_data") == "{}" -def test_wrongServerResponses(): +def test_wrongServerResponses() -> None: authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()): authorization_service._onAuthStateChanged(MALFORMED_AUTH_RESPONSE) From 067e59a254a66a4939cc0ecf20454a841c5257d0 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 26 Sep 2018 17:06:09 +0200 Subject: [PATCH 12/37] Add logged_in as argument to loginStateChanged callback CURA-5744 --- cura/API/Account.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 6bb5b4e50d..c4499fb750 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -24,7 +24,7 @@ i18n_catalog = i18nCatalog("cura") # class Account(QObject): # Signal emitted when user logged in or out. - loginStateChanged = pyqtSignal() + loginStateChanged = pyqtSignal(bool) def __init__(self, parent = None) -> None: super().__init__(parent) @@ -64,7 +64,7 @@ class Account(QObject): if self._logged_in != logged_in: self._logged_in = logged_in - self.loginStateChanged.emit() + self.loginStateChanged.emit(logged_in) @pyqtSlot() def login(self) -> None: From 16ff1c371236d8dc01daee4694858b51ee5250e7 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 26 Sep 2018 17:12:00 +0200 Subject: [PATCH 13/37] Add property for the accessToken CURA-5744 --- cura/API/Account.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cura/API/Account.py b/cura/API/Account.py index c4499fb750..c30b8d4586 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -87,6 +87,10 @@ class Account(QObject): return None return user_profile.profile_image_url + @pyqtProperty(str, notify=loginStateChanged) + def accessToken(self) -> Optional[str]: + return self._authorization_service.getAccessToken() + # Get the profile of the logged in user # @returns None if no user is logged in, a dict containing user_id, username and profile_image_url @pyqtProperty("QVariantMap", notify = loginStateChanged) From d5dbf91a4f90892aa81871386589f2d3f35008d4 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 26 Sep 2018 17:23:36 +0200 Subject: [PATCH 14/37] Switch unit test to use decoratior instead of with waterfall CURA-5744 --- tests/TestOAuth2.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 7deb712aea..312d71fd5f 100644 --- a/tests/TestOAuth2.py +++ b/tests/TestOAuth2.py @@ -55,22 +55,22 @@ def test_failedLogin() -> None: assert authorization_service.getAccessToken() is None -def test_localAuthServer() -> None: +@patch.object(LocalAuthorizationServer, "stop") +@patch.object(LocalAuthorizationServer, "start") +@patch.object(webbrowser, "open_new") +def test_localAuthServer(webbrowser_open, start_auth_server, stop_auth_server) -> None: preferences = Preferences() authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) - with patch.object(webbrowser, "open_new") as webrowser_open: - with patch.object(LocalAuthorizationServer, "start") as start_auth_server: - with patch.object(LocalAuthorizationServer, "stop") as stop_auth_server: - authorization_service.startAuthorizationFlow() - assert webrowser_open.call_count == 1 + authorization_service.startAuthorizationFlow() + assert webbrowser_open.call_count == 1 - # Ensure that the Authorization service tried to start the server. - assert start_auth_server.call_count == 1 - assert stop_auth_server.call_count == 0 - authorization_service._onAuthStateChanged(FAILED_AUTH_RESPONSE) + # Ensure that the Authorization service tried to start the server. + assert start_auth_server.call_count == 1 + assert stop_auth_server.call_count == 0 + authorization_service._onAuthStateChanged(FAILED_AUTH_RESPONSE) - # Ensure that it stopped the server. - assert stop_auth_server.call_count == 1 + # Ensure that it stopped the server. + assert stop_auth_server.call_count == 1 def test_loginAndLogout() -> None: From 52ffe39c07cf040e5e44c503807a4075ef4b3fee Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 27 Sep 2018 10:33:50 +0200 Subject: [PATCH 15/37] Small fixes in settings --- cura/API/Account.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index c30b8d4586..7944290f6e 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -36,11 +36,11 @@ class Account(QObject): OAUTH_SERVER_URL= self._oauth_root, CALLBACK_PORT=self._callback_port, CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), - CLIENT_ID="um---------------ultimaker_cura", + CLIENT_ID="um---------------ultimaker_cura_drive_plugin", CLIENT_SCOPES="user.read drive.backups.read drive.backups.write client.package.download", AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", AUTH_SUCCESS_REDIRECT="{}/auth-success".format(self._cloud_api_root), - AUTH_FAILED_REDIRECT="{}//auth-error".format(self._cloud_api_root) + AUTH_FAILED_REDIRECT="{}/auth-error".format(self._cloud_api_root) ) self._authorization_service = AuthorizationService(Application.getInstance().getPreferences(), self._oauth_settings) From 246d12a596c8122516d15d82742ecc3dca369aad Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 27 Sep 2018 10:48:22 +0200 Subject: [PATCH 16/37] Remove client.package.download scope until that is deployed on production --- cura/API/Account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 7944290f6e..e28f943009 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -37,7 +37,7 @@ class Account(QObject): CALLBACK_PORT=self._callback_port, CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), CLIENT_ID="um---------------ultimaker_cura_drive_plugin", - CLIENT_SCOPES="user.read drive.backups.read drive.backups.write client.package.download", + CLIENT_SCOPES="user.read drive.backups.read drive.backups.write", AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", AUTH_SUCCESS_REDIRECT="{}/auth-success".format(self._cloud_api_root), AUTH_FAILED_REDIRECT="{}/auth-error".format(self._cloud_api_root) From 1c8804ff2c637425d4b80b5472787e8b1e8d1e3c Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 11:03:17 +0200 Subject: [PATCH 17/37] Changed documentation style to doxygen CURA-5744 --- cura/OAuth2/AuthorizationHelpers.py | 54 ++++++++-------------- cura/OAuth2/AuthorizationRequestHandler.py | 23 +++------ cura/OAuth2/AuthorizationRequestServer.py | 16 +++---- cura/OAuth2/AuthorizationService.py | 38 +++++++-------- cura/OAuth2/LocalAuthorizationServer.py | 28 +++++------ 5 files changed, 62 insertions(+), 97 deletions(-) diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index 06cc0a6061..7141b83279 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -13,25 +13,22 @@ from UM.Logger import Logger from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings +# Class containing several helpers to deal with the authorization flow. class AuthorizationHelpers: - """Class containing several helpers to deal with the authorization flow.""" - def __init__(self, settings: "OAuth2Settings") -> None: self._settings = settings self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) @property + # The OAuth2 settings object. def settings(self) -> "OAuth2Settings": - """Get the OAuth2 settings object.""" return self._settings + # Request the access token from the authorization server. + # \param authorization_code: The authorization code from the 1st step. + # \param verification_code: The verification code needed for the PKCE extension. + # \return: An AuthenticationResponse object. def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str)-> "AuthenticationResponse": - """ - Request the access token from the authorization server. - :param authorization_code: The authorization code from the 1st step. - :param verification_code: The verification code needed for the PKCE extension. - :return: An AuthenticationResponse object. - """ return self.parseTokenResponse(requests.post(self._token_url, data={ "client_id": self._settings.CLIENT_ID, "redirect_uri": self._settings.CALLBACK_URL, @@ -41,12 +38,10 @@ class AuthorizationHelpers: "scope": self._settings.CLIENT_SCOPES })) + # Request the access token from the authorization server using a refresh token. + # \param refresh_token: + # \return: An AuthenticationResponse object. def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> AuthenticationResponse: - """ - Request the access token from the authorization server using a refresh token. - :param refresh_token: - :return: An AuthenticationResponse object. - """ return self.parseTokenResponse(requests.post(self._token_url, data={ "client_id": self._settings.CLIENT_ID, "redirect_uri": self._settings.CALLBACK_URL, @@ -56,12 +51,10 @@ class AuthorizationHelpers: })) @staticmethod + # Parse the token response from the authorization server into an AuthenticationResponse object. + # \param token_response: The JSON string data response from the authorization server. + # \return: An AuthenticationResponse object. def parseTokenResponse(token_response: requests.models.Response) -> AuthenticationResponse: - """ - Parse the token response from the authorization server into an AuthenticationResponse object. - :param token_response: The JSON string data response from the authorization server. - :return: An AuthenticationResponse object. - """ token_data = None try: @@ -82,12 +75,10 @@ class AuthorizationHelpers: expires_in=token_data["expires_in"], scope=token_data["scope"]) + # Calls the authentication API endpoint to get the token data. + # \param access_token: The encoded JWT token. + # \return: Dict containing some profile data. def parseJWT(self, access_token: str) -> Optional["UserProfile"]: - """ - Calls the authentication API endpoint to get the token data. - :param access_token: The encoded JWT token. - :return: Dict containing some profile data. - """ token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { "Authorization": "Bearer {}".format(access_token) }) @@ -105,20 +96,15 @@ class AuthorizationHelpers: ) @staticmethod + # Generate a 16-character verification code. + # \param code_length: How long should the code be? def generateVerificationCode(code_length: int = 16) -> str: - """ - Generate a 16-character verification code. - :param code_length: - :return: - """ return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) @staticmethod + # Generates a base64 encoded sha512 encrypted version of a given string. + # \param verification_code: + # \return: The encrypted code in base64 format. def generateVerificationCodeChallenge(verification_code: str) -> str: - """ - Generates a base64 encoded sha512 encrypted version of a given string. - :param verification_code: - :return: The encrypted code in base64 format. - """ encoded = sha512(verification_code.encode()).digest() return b64encode(encoded, altchars = b"_-").decode() diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py index 3b5b0c34d8..7e0a659a56 100644 --- a/cura/OAuth2/AuthorizationRequestHandler.py +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -12,12 +12,9 @@ if TYPE_CHECKING: from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +# This handler handles all HTTP requests on the local web server. +# It also requests the access token for the 2nd stage of the OAuth flow. class AuthorizationRequestHandler(BaseHTTPRequestHandler): - """ - This handler handles all HTTP requests on the local web server. - It also requests the access token for the 2nd stage of the OAuth flow. - """ - def __init__(self, request, client_address, server) -> None: super().__init__(request, client_address, server) @@ -27,8 +24,6 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): self.verification_code = None # type: Optional[str] def do_GET(self) -> None: - """Entry point for GET requests""" - # Extract values from the query string. parsed_url = urlparse(self.path) query = parse_qs(parsed_url.query) @@ -52,12 +47,10 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): # This will cause the server to shut down, so we do it at the very end of the request handling. self.authorization_callback(token_response) + # Handler for the callback URL redirect. + # \param query: Dict containing the HTTP query parameters. + # \return: HTTP ResponseData containing a success page to show to the user. def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]: - """ - Handler for the callback URL redirect. - :param query: Dict containing the HTTP query parameters. - :return: HTTP ResponseData containing a success page to show to the user. - """ code = self._queryGet(query, "code") if code and self.authorization_helpers is not None and self.verification_code is not None: # If the code was returned we get the access token. @@ -88,12 +81,11 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): ), token_response @staticmethod + # Handle all other non-existing server calls. def _handleNotFound() -> ResponseData: - """Handle all other non-existing server calls.""" return ResponseData(status=HTTP_STATUS["NOT_FOUND"], content_type="text/html", data_stream=b"Not found.") def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None: - """Send out the headers""" self.send_response(status.code, status.message) self.send_header("Content-type", content_type) if redirect_uri: @@ -101,10 +93,9 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): self.end_headers() def _sendData(self, data: bytes) -> None: - """Send out the data""" self.wfile.write(data) @staticmethod + # Convenience Helper for getting values from a pre-parsed query string def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str]=None) -> Optional[str]: - """Helper for getting values from a pre-parsed query string""" return query_data.get(key, [default])[0] diff --git a/cura/OAuth2/AuthorizationRequestServer.py b/cura/OAuth2/AuthorizationRequestServer.py index 514a4ab5de..288e348ea9 100644 --- a/cura/OAuth2/AuthorizationRequestServer.py +++ b/cura/OAuth2/AuthorizationRequestServer.py @@ -8,21 +8,19 @@ if TYPE_CHECKING: from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +# The authorization request callback handler server. +# This subclass is needed to be able to pass some data to the request handler. +# This cannot be done on the request handler directly as the HTTPServer creates an instance of the handler after +# init. class AuthorizationRequestServer(HTTPServer): - """ - The authorization request callback handler server. - This subclass is needed to be able to pass some data to the request handler. - This cannot be done on the request handler directly as the HTTPServer creates an instance of the handler after init. - """ - + # Set the authorization helpers instance on the request handler. def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: - """Set the authorization helpers instance on the request handler.""" self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore + # Set the authorization callback on the request handler. def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None: - """Set the authorization callback on the request handler.""" self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore + # Set the verification code on the request handler. def setVerificationCode(self, verification_code: str) -> None: - """Set the verification code on the request handler.""" self.RequestHandlerClass.verification_code = verification_code # type: ignore diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 0f57621a47..04891b8d76 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -38,11 +38,11 @@ class AuthorizationService: self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) self._loadAuthData() + # Get the user profile as obtained from the JWT (JSON Web Token). + # If the JWT is not yet parsed, calling this will take care of that. + # \return UserProfile if a user is logged in, None otherwise. + # \sa _parseJWT def getUserProfile(self) -> Optional["UserProfile"]: - """ - Get the user data that is stored in the JWT token. - :return: Dict containing some user data. - """ if not self._user_profile: # If no user profile was stored locally, we try to get it from JWT. self._user_profile = self._parseJWT() @@ -52,11 +52,9 @@ class AuthorizationService: return self._user_profile + # Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there. + # \return UserProfile if it was able to parse, None otherwise. def _parseJWT(self) -> Optional["UserProfile"]: - """ - Tries to parse the JWT if all the needed data exists. - :return: UserProfile if found, otherwise None. - """ if not self._auth_data or self._auth_data.access_token is None: # If no auth data exists, we should always log in again. return None @@ -74,10 +72,8 @@ class AuthorizationService: return self._auth_helpers.parseJWT(self._auth_data.access_token) + # Get the access token as provided by the repsonse data. def getAccessToken(self) -> Optional[str]: - """ - Get the access token response data. - """ if not self.getUserProfile(): # We check if we can get the user profile. # If we can't get it, that means the access token (JWT) was invalid or expired. @@ -88,24 +84,22 @@ class AuthorizationService: return self._auth_data.access_token + # Try to refresh the access token. This should be used when it has expired. def refreshAccessToken(self) -> None: - """ - Refresh the access token when it expired. - """ if self._auth_data is None or self._auth_data.refresh_token is None: Logger.log("w", "Unable to refresh access token, since there is no refresh token.") return self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)) self.onAuthStateChanged.emit(logged_in=True) + # Delete the authentication data that we have stored locally (eg; logout) def deleteAuthData(self) -> None: - """Delete authentication data from preferences and locally.""" - self._storeAuthData() - self.onAuthStateChanged.emit(logged_in=False) + if self._auth_data is not None: + self._storeAuthData() + self.onAuthStateChanged.emit(logged_in=False) + # Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login. def startAuthorizationFlow(self) -> None: - """Start a new OAuth2 authorization flow.""" - Logger.log("d", "Starting new OAuth2 flow...") # Create the tokens needed for the code challenge (PKCE) extension for OAuth2. @@ -131,8 +125,8 @@ class AuthorizationService: # Start a local web server to receive the callback URL on. self._server.start(verification_code) + # Callback method for the authentication flow. def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: - """Callback method for an authentication flow.""" if auth_response.success: self._storeAuthData(auth_response) self.onAuthStateChanged.emit(logged_in=True) @@ -140,8 +134,8 @@ class AuthorizationService: self.onAuthenticationError.emit(logged_in=False, error_message=auth_response.err_message) self._server.stop() # Stop the web server at all times. + # Load authentication data from preferences. def _loadAuthData(self) -> None: - """Load authentication data from preferences if available.""" self._cura_preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") try: preferences_data = json.loads(self._cura_preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) @@ -151,8 +145,8 @@ class AuthorizationService: except ValueError: Logger.logException("w", "Could not load auth data from preferences") + # Store authentication data in preferences. def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: - """Store authentication data in preferences and locally.""" self._auth_data = auth_data if auth_data: self._user_profile = self.getUserProfile() diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py index 488a33941d..5a282d8135 100644 --- a/cura/OAuth2/LocalAuthorizationServer.py +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -12,16 +12,17 @@ if TYPE_CHECKING: from cura.OAuth2.Models import AuthenticationResponse from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers + class LocalAuthorizationServer: + # The local LocalAuthorizationServer takes care of the oauth2 callbacks. + # Once the flow is completed, this server should be closed down again by calling stop() + # \param auth_helpers: An instance of the authorization helpers class. + # \param auth_state_changed_callback: A callback function to be called when the authorization state changes. + # \param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped + # at shutdown. Their resources (e.g. open files) may never be released. def __init__(self, auth_helpers: "AuthorizationHelpers", auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], daemon: bool) -> None: - """ - :param auth_helpers: An instance of the authorization helpers class. - :param auth_state_changed_callback: A callback function to be called when the authorization state changes. - :param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped - at shutdown. Their resources (e.g. open files) may never be released. - """ self._web_server = None # type: Optional[AuthorizationRequestServer] self._web_server_thread = None # type: Optional[threading.Thread] self._web_server_port = auth_helpers.settings.CALLBACK_PORT @@ -29,11 +30,9 @@ class LocalAuthorizationServer: self._auth_state_changed_callback = auth_state_changed_callback self._daemon = daemon + # Starts the local web server to handle the authorization callback. + # \param verification_code: The verification code part of the OAuth2 client identification. def start(self, verification_code: str) -> None: - """ - Starts the local web server to handle the authorization callback. - :param verification_code: The verification code part of the OAuth2 client identification. - """ if self._web_server: # If the server is already running (because of a previously aborted auth flow), we don't have to start it. # We still inject the new verification code though. @@ -43,12 +42,10 @@ class LocalAuthorizationServer: if self._web_server_port is None: raise Exception("Unable to start server without specifying the port.") - Logger.log("d", "Starting local web server to handle authorization callback on port %s", - self._web_server_port) + Logger.log("d", "Starting local web server to handle authorization callback on port %s", self._web_server_port) # Create the server and inject the callback and code. - self._web_server = AuthorizationRequestServer(("0.0.0.0", self._web_server_port), - AuthorizationRequestHandler) + self._web_server = AuthorizationRequestServer(("0.0.0.0", self._web_server_port), AuthorizationRequestHandler) self._web_server.setAuthorizationHelpers(self._auth_helpers) self._web_server.setAuthorizationCallback(self._auth_state_changed_callback) self._web_server.setVerificationCode(verification_code) @@ -57,9 +54,8 @@ class LocalAuthorizationServer: self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon) self._web_server_thread.start() + # Stops the web server if it was running. It also does some cleanup. def stop(self) -> None: - """ Stops the web server if it was running. Also deletes the objects. """ - Logger.log("d", "Stopping local oauth2 web server...") if self._web_server: From 506ec5109d51a2627ec76a3906554c99e3e18970 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 11:37:22 +0200 Subject: [PATCH 18/37] Moved loading of the authentication to the account CURA-5744 --- cura/API/Account.py | 2 +- cura/OAuth2/AuthorizationService.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index e28f943009..e0cc4013ac 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -44,7 +44,7 @@ class Account(QObject): ) self._authorization_service = AuthorizationService(Application.getInstance().getPreferences(), self._oauth_settings) - + self._authorization_service.loadAuthDataFromPreferences() self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 04891b8d76..a118235499 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -36,7 +36,6 @@ class AuthorizationService: self._user_profile = None # type: Optional["UserProfile"] self._cura_preferences = preferences self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) - self._loadAuthData() # Get the user profile as obtained from the JWT (JSON Web Token). # If the JWT is not yet parsed, calling this will take care of that. @@ -135,7 +134,7 @@ class AuthorizationService: self._server.stop() # Stop the web server at all times. # Load authentication data from preferences. - def _loadAuthData(self) -> None: + def loadAuthDataFromPreferences(self) -> None: self._cura_preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") try: preferences_data = json.loads(self._cura_preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) From 0ccbabd857c7ba651a22044ede777b4c3a230e59 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 11:37:44 +0200 Subject: [PATCH 19/37] Switch SHA512 implementation to use the one from hashlib CURA-5744 --- cura/OAuth2/AuthorizationHelpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index 7141b83279..4d485b3bda 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import json import random -from _sha512 import sha512 +from hashlib import sha512 from base64 import b64encode from typing import Optional From 649f1c8961151d0b2fed756793a97a49578a0282 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 11:42:12 +0200 Subject: [PATCH 20/37] Make it optional for the AuthService to have a preference object This should make it easier if we ever want to re-use the authService, since it no longer has a hard link with the Preferences CURA-5744 --- cura/OAuth2/AuthorizationService.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index a118235499..e9e3a7e65b 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -14,6 +14,7 @@ from cura.OAuth2.Models import AuthenticationResponse if TYPE_CHECKING: from cura.OAuth2.Models import UserProfile, OAuth2Settings + from UM.Preferences import Preferences class AuthorizationService: @@ -28,15 +29,18 @@ class AuthorizationService: # Emit signal when authentication failed. onAuthenticationError = Signal() - def __init__(self, preferences, settings: "OAuth2Settings") -> None: + def __init__(self, preferences: Optional["Preferences"], settings: "OAuth2Settings") -> None: self._settings = settings self._auth_helpers = AuthorizationHelpers(settings) self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) self._auth_data = None # type: Optional[AuthenticationResponse] self._user_profile = None # type: Optional["UserProfile"] - self._cura_preferences = preferences + self._preferences = preferences self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) + if self._preferences: + self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") + # Get the user profile as obtained from the JWT (JSON Web Token). # If the JWT is not yet parsed, calling this will take care of that. # \return UserProfile if a user is logged in, None otherwise. @@ -135,9 +139,11 @@ class AuthorizationService: # Load authentication data from preferences. def loadAuthDataFromPreferences(self) -> None: - self._cura_preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") + if self._preferences is None: + Logger.logException("e", "Unable to load authentication data, since no preference has been set!") + return try: - preferences_data = json.loads(self._cura_preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) + preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) if preferences_data: self._auth_data = AuthenticationResponse(**preferences_data) self.onAuthStateChanged.emit(logged_in=True) @@ -149,7 +155,7 @@ class AuthorizationService: self._auth_data = auth_data if auth_data: self._user_profile = self.getUserProfile() - self._cura_preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data))) + self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data))) else: self._user_profile = None - self._cura_preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) + self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) From 202cf698c3061f1dee712b69a18ee8ae256f8310 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 11:56:19 +0200 Subject: [PATCH 21/37] Change callback for succes / failure to the new location CURA-5744 --- cura/API/Account.py | 4 ++-- tests/TestOAuth2.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index e0cc4013ac..18d9d5df03 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -39,8 +39,8 @@ class Account(QObject): CLIENT_ID="um---------------ultimaker_cura_drive_plugin", CLIENT_SCOPES="user.read drive.backups.read drive.backups.write", AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", - AUTH_SUCCESS_REDIRECT="{}/auth-success".format(self._cloud_api_root), - AUTH_FAILED_REDIRECT="{}/auth-error".format(self._cloud_api_root) + AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root), + AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root) ) self._authorization_service = AuthorizationService(Application.getInstance().getPreferences(), self._oauth_settings) diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 312d71fd5f..22bf0656ef 100644 --- a/tests/TestOAuth2.py +++ b/tests/TestOAuth2.py @@ -18,8 +18,8 @@ OAUTH_SETTINGS = OAuth2Settings( CLIENT_ID="", CLIENT_SCOPES="", AUTH_DATA_PREFERENCE_KEY="test/auth_data", - AUTH_SUCCESS_REDIRECT="{}/auth-success".format(CLOUD_API_ROOT), - AUTH_FAILED_REDIRECT="{}/auth-error".format(CLOUD_API_ROOT) + AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(OAUTH_ROOT), + AUTH_FAILED_REDIRECT="{}/app/auth-error".format(OAUTH_ROOT) ) FAILED_AUTH_RESPONSE = AuthenticationResponse(success = False, err_message = "FAILURE!") From 47c5dbaf840cf2f4eca3575b46ea200d3229689e Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 13:07:37 +0200 Subject: [PATCH 22/37] Add extra unit test that tests the storing and loading of data to the preferences --- tests/TestOAuth2.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 22bf0656ef..78585804f5 100644 --- a/tests/TestOAuth2.py +++ b/tests/TestOAuth2.py @@ -55,6 +55,23 @@ def test_failedLogin() -> None: assert authorization_service.getAccessToken() is None +@patch.object(AuthorizationService, "getUserProfile", return_value=UserProfile()) +def test_storeAuthData(get_user_profile) -> None: + preferences = Preferences() + authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) + + # Write stuff to the preferences. + authorization_service._storeAuthData(SUCCESFULL_AUTH_RESPONSE) + preference_value = preferences.getValue(OAUTH_SETTINGS.AUTH_DATA_PREFERENCE_KEY) + # Check that something was actually put in the preferences + assert preference_value is not None and preference_value != {} + + # Create a second auth service, so we can load the data. + second_auth_service = AuthorizationService(preferences, OAUTH_SETTINGS) + second_auth_service.loadAuthDataFromPreferences() + assert second_auth_service.getAccessToken() == SUCCESFULL_AUTH_RESPONSE.access_token + + @patch.object(LocalAuthorizationServer, "stop") @patch.object(LocalAuthorizationServer, "start") @patch.object(webbrowser, "open_new") From 6d402806ac2cbc914d701b58b50ac6a796c40a39 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 27 Sep 2018 13:35:18 +0200 Subject: [PATCH 23/37] Ensure logged in state is not always set to False after loading from preferences --- cura/API/Account.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 18d9d5df03..d91276fb56 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -28,6 +28,10 @@ class Account(QObject): def __init__(self, parent = None) -> None: super().__init__(parent) + + self._error_message = None # type: Optional[Message] + self._logged_in = False + self._callback_port = 32118 self._oauth_root = "https://account.ultimaker.com" self._cloud_api_root = "https://api.ultimaker.com" @@ -48,9 +52,6 @@ class Account(QObject): self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) - self._error_message = None # type: Optional[Message] - self._logged_in = False - @pyqtProperty(bool, notify=loginStateChanged) def isLoggedIn(self) -> bool: return self._logged_in From f8369703ed4775c6e9ae2f5da3c32451f3c8b4a9 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 13:45:46 +0200 Subject: [PATCH 24/37] Connect signals before loading auth data CURA-5744 --- cura/API/Account.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 18d9d5df03..3f328d71ef 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -42,15 +42,14 @@ class Account(QObject): AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root), AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root) ) - - self._authorization_service = AuthorizationService(Application.getInstance().getPreferences(), self._oauth_settings) - self._authorization_service.loadAuthDataFromPreferences() - self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) - self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) - self._error_message = None # type: Optional[Message] self._logged_in = False + self._authorization_service = AuthorizationService(Application.getInstance().getPreferences(), self._oauth_settings) + self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) + self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) + self._authorization_service.loadAuthDataFromPreferences() + @pyqtProperty(bool, notify=loginStateChanged) def isLoggedIn(self) -> bool: return self._logged_in From 853ccbdb71df307d4de6a47981235b5531af3f55 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 14:00:28 +0200 Subject: [PATCH 25/37] Fix mypy typing issue --- cura/OAuth2/AuthorizationService.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index e9e3a7e65b..16f525625e 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -140,7 +140,7 @@ class AuthorizationService: # Load authentication data from preferences. def loadAuthDataFromPreferences(self) -> None: if self._preferences is None: - Logger.logException("e", "Unable to load authentication data, since no preference has been set!") + Logger.log("e", "Unable to load authentication data, since no preference has been set!") return try: preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) @@ -152,6 +152,10 @@ class AuthorizationService: # Store authentication data in preferences. def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: + if self._preferences is None: + Logger.log("e", "Unable to save authentication data, since no preference has been set!") + return + self._auth_data = auth_data if auth_data: self._user_profile = self.getUserProfile() From 3b70e5eb6bcbab6da11f6864b85a6a23ae0ccc30 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 20:01:55 +0200 Subject: [PATCH 26/37] Fix typing For some reason, my MyPy started acting up once I started using the PythonPath while calling it. --- cura/CuraApplication.py | 6 + .../Models/SettingVisibilityPresetsModel.py | 8 +- cura/Machines/QualityManager.py | 6 +- cura/OAuth2/AuthorizationHelpers.py | 4 +- cura/OAuth2/Models.py | 2 +- cura/Scene/ConvexHullDecorator.py | 135 ++++++++++-------- cura/Settings/ContainerManager.py | 7 +- cura/Settings/MachineManager.py | 2 +- plugins/SimulationView/SimulationView.py | 41 +++--- 9 files changed, 120 insertions(+), 91 deletions(-) diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 5ff4161fea..04c9ea88db 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -61,6 +61,7 @@ from cura.Scene.CuraSceneController import CuraSceneController from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.SettingFunction import SettingFunction +from cura.Settings.CuraContainerRegistry import CuraContainerRegistry from cura.Settings.MachineNameValidator import MachineNameValidator from cura.Machines.Models.BuildPlateModel import BuildPlateModel @@ -242,6 +243,8 @@ class CuraApplication(QtApplication): from cura.Settings.CuraContainerRegistry import CuraContainerRegistry self._container_registry_class = CuraContainerRegistry + # Redefined here in order to please the typing. + self._container_registry = None # type: CuraContainerRegistry from cura.CuraPackageManager import CuraPackageManager self._package_manager_class = CuraPackageManager @@ -266,6 +269,9 @@ class CuraApplication(QtApplication): help = "FOR TESTING ONLY. Trigger an early crash to show the crash dialog.") self._cli_parser.add_argument("file", nargs = "*", help = "Files to load after starting the application.") + def getContainerRegistry(self) -> "CuraContainerRegistry": + return self._container_registry + def parseCliOptions(self): super().parseCliOptions() diff --git a/cura/Machines/Models/SettingVisibilityPresetsModel.py b/cura/Machines/Models/SettingVisibilityPresetsModel.py index d5fa51d20a..7e098197a9 100644 --- a/cura/Machines/Models/SettingVisibilityPresetsModel.py +++ b/cura/Machines/Models/SettingVisibilityPresetsModel.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional +from typing import Optional, List, Dict, Union import os import urllib.parse from configparser import ConfigParser @@ -60,7 +60,7 @@ class SettingVisibilityPresetsModel(ListModel): def _populate(self) -> None: from cura.CuraApplication import CuraApplication - items = [] + items = [] # type: List[Dict[str, Union[str, int, List[str]]]] for file_path in Resources.getAllResourcesOfType(CuraApplication.ResourceTypes.SettingVisibilityPreset): try: mime_type = MimeTypeDatabase.getMimeTypeForFile(file_path) @@ -79,7 +79,7 @@ class SettingVisibilityPresetsModel(ListModel): if not parser.has_option("general", "name") or not parser.has_option("general", "weight"): continue - settings = [] + settings = [] # type: List[str] for section in parser.sections(): if section == 'general': continue @@ -98,7 +98,7 @@ class SettingVisibilityPresetsModel(ListModel): except Exception: Logger.logException("e", "Failed to load setting preset %s", file_path) - items.sort(key = lambda k: (int(k["weight"]), k["id"])) + items.sort(key = lambda k: (int(k["weight"]), k["id"])) # type: ignore # Put "custom" at the top items.insert(0, {"id": "custom", "name": "Custom selection", diff --git a/cura/Machines/QualityManager.py b/cura/Machines/QualityManager.py index 21abb5a9cc..d924f4c83e 100644 --- a/cura/Machines/QualityManager.py +++ b/cura/Machines/QualityManager.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional, cast, Dict, List from PyQt5.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot from UM.Application import Application + from UM.ConfigurationErrorMessage import ConfigurationErrorMessage from UM.Logger import Logger from UM.Util import parseBool @@ -40,7 +41,8 @@ class QualityManager(QObject): def __init__(self, container_registry: "ContainerRegistry", parent = None) -> None: super().__init__(parent) - self._application = Application.getInstance() # type: CuraApplication + from cura.CuraApplication import CuraApplication + self._application = CuraApplication.getInstance() # type: CuraApplication self._material_manager = self._application.getMaterialManager() self._container_registry = container_registry @@ -458,7 +460,7 @@ class QualityManager(QObject): # stack and clear the user settings. @pyqtSlot(str) def createQualityChanges(self, base_name: str) -> None: - machine_manager = Application.getInstance().getMachineManager() + machine_manager = CuraApplication.getInstance().getMachineManager() global_stack = machine_manager.activeMachine if not global_stack: diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index 4d485b3bda..6cb53d2252 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -36,7 +36,7 @@ class AuthorizationHelpers: "code": authorization_code, "code_verifier": verification_code, "scope": self._settings.CLIENT_SCOPES - })) + })) # type: ignore # Request the access token from the authorization server using a refresh token. # \param refresh_token: @@ -48,7 +48,7 @@ class AuthorizationHelpers: "grant_type": "refresh_token", "refresh_token": refresh_token, "scope": self._settings.CLIENT_SCOPES - })) + })) # type: ignore @staticmethod # Parse the token response from the authorization server into an AuthenticationResponse object. diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py index 796fdf8746..83fc22554f 100644 --- a/cura/OAuth2/Models.py +++ b/cura/OAuth2/Models.py @@ -14,7 +14,7 @@ class OAuth2Settings(BaseModel): CLIENT_ID = None # type: Optional[str] CLIENT_SCOPES = None # type: Optional[str] CALLBACK_URL = None # type: Optional[str] - AUTH_DATA_PREFERENCE_KEY = None # type: Optional[str] + AUTH_DATA_PREFERENCE_KEY = "" # type: str AUTH_SUCCESS_REDIRECT = "https://ultimaker.com" # type: str AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str diff --git a/cura/Scene/ConvexHullDecorator.py b/cura/Scene/ConvexHullDecorator.py index 31e21df6bf..8532f40890 100644 --- a/cura/Scene/ConvexHullDecorator.py +++ b/cura/Scene/ConvexHullDecorator.py @@ -5,9 +5,11 @@ from PyQt5.QtCore import QTimer from UM.Application import Application from UM.Math.Polygon import Polygon + from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Settings.ContainerRegistry import ContainerRegistry + from cura.Settings.ExtruderManager import ExtruderManager from cura.Scene import ConvexHullNode @@ -18,6 +20,8 @@ from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: from UM.Scene.SceneNode import SceneNode from cura.Settings.GlobalStack import GlobalStack + from UM.Mesh.MeshData import MeshData + from UM.Math.Matrix import Matrix ## The convex hull decorator is a scene node decorator that adds the convex hull functionality to a scene node. @@ -33,17 +37,17 @@ class ConvexHullDecorator(SceneNodeDecorator): # Make sure the timer is created on the main thread self._recompute_convex_hull_timer = None # type: Optional[QTimer] - - if Application.getInstance() is not None: - Application.getInstance().callLater(self.createRecomputeConvexHullTimer) + from cura.CuraApplication import CuraApplication + if CuraApplication.getInstance() is not None: + CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer) self._raft_thickness = 0.0 - self._build_volume = Application.getInstance().getBuildVolume() + self._build_volume = CuraApplication.getInstance().getBuildVolume() self._build_volume.raftThicknessChanged.connect(self._onChanged) - Application.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged) - Application.getInstance().getController().toolOperationStarted.connect(self._onChanged) - Application.getInstance().getController().toolOperationStopped.connect(self._onChanged) + CuraApplication.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged) + CuraApplication.getInstance().getController().toolOperationStarted.connect(self._onChanged) + CuraApplication.getInstance().getController().toolOperationStopped.connect(self._onChanged) self._onGlobalStackChanged() @@ -61,9 +65,9 @@ class ConvexHullDecorator(SceneNodeDecorator): previous_node.parentChanged.disconnect(self._onChanged) super().setNode(node) - - self._node.transformationChanged.connect(self._onChanged) - self._node.parentChanged.connect(self._onChanged) + # Mypy doesn't understand that self._node is no longer optional, so just use the node. + node.transformationChanged.connect(self._onChanged) + node.parentChanged.connect(self._onChanged) self._onChanged() @@ -78,9 +82,9 @@ class ConvexHullDecorator(SceneNodeDecorator): hull = self._compute2DConvexHull() - if self._global_stack and self._node and hull is not None: + if self._global_stack and self._node is not None and hull is not None: # Parent can be None if node is just loaded. - if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): + if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node): hull = hull.getMinkowskiHull(Polygon(numpy.array(self._global_stack.getProperty("machine_head_polygon", "value"), numpy.float32))) hull = self._add2DAdhesionMargin(hull) return hull @@ -92,6 +96,13 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._compute2DConvexHeadFull() + @staticmethod + def hasGroupAsParent(node: "SceneNode") -> bool: + parent = node.getParent() + if parent is None: + return False + return bool(parent.callDecoration("isGroup")) + ## Get convex hull of the object + head size # In case of printing all at once this is the same as the convex hull. # For one at the time this is area with intersection of mirrored head @@ -100,8 +111,10 @@ class ConvexHullDecorator(SceneNodeDecorator): return None if self._global_stack: - if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): + if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node): head_with_fans = self._compute2DConvexHeadMin() + if head_with_fans is None: + return None head_with_fans_with_adhesion_margin = self._add2DAdhesionMargin(head_with_fans) return head_with_fans_with_adhesion_margin return None @@ -114,7 +127,7 @@ class ConvexHullDecorator(SceneNodeDecorator): return None if self._global_stack: - if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): + if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node): # Printing one at a time and it's not an object in a group return self._compute2DConvexHull() return None @@ -153,15 +166,17 @@ class ConvexHullDecorator(SceneNodeDecorator): def _init2DConvexHullCache(self) -> None: # Cache for the group code path in _compute2DConvexHull() - self._2d_convex_hull_group_child_polygon = None - self._2d_convex_hull_group_result = None + self._2d_convex_hull_group_child_polygon = None # type: Optional[Polygon] + self._2d_convex_hull_group_result = None # type: Optional[Polygon] # Cache for the mesh code path in _compute2DConvexHull() - self._2d_convex_hull_mesh = None - self._2d_convex_hull_mesh_world_transform = None - self._2d_convex_hull_mesh_result = None + self._2d_convex_hull_mesh = None # type: Optional[MeshData] + self._2d_convex_hull_mesh_world_transform = None # type: Optional[Matrix] + self._2d_convex_hull_mesh_result = None # type: Optional[Polygon] def _compute2DConvexHull(self) -> Optional[Polygon]: + if self._node is None: + return None if self._node.callDecoration("isGroup"): points = numpy.zeros((0, 2), dtype=numpy.int32) for child in self._node.getChildren(): @@ -187,47 +202,47 @@ class ConvexHullDecorator(SceneNodeDecorator): return offset_hull else: - offset_hull = None - if self._node.getMeshData(): - mesh = self._node.getMeshData() - world_transform = self._node.getWorldTransformation() - - # Check the cache - if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform: - return self._2d_convex_hull_mesh_result - - vertex_data = mesh.getConvexHullTransformedVertices(world_transform) - # Don't use data below 0. - # TODO; We need a better check for this as this gives poor results for meshes with long edges. - # Do not throw away vertices: the convex hull may be too small and objects can collide. - # vertex_data = vertex_data[vertex_data[:,1] >= -0.01] - - if len(vertex_data) >= 4: - # Round the vertex data to 1/10th of a mm, then remove all duplicate vertices - # This is done to greatly speed up further convex hull calculations as the convex hull - # becomes much less complex when dealing with highly detailed models. - vertex_data = numpy.round(vertex_data, 1) - - vertex_data = vertex_data[:, [0, 2]] # Drop the Y components to project to 2D. - - # Grab the set of unique points. - # - # This basically finds the unique rows in the array by treating them as opaque groups of bytes - # which are as long as the 2 float64s in each row, and giving this view to numpy.unique() to munch. - # See http://stackoverflow.com/questions/16970982/find-unique-rows-in-numpy-array - vertex_byte_view = numpy.ascontiguousarray(vertex_data).view( - numpy.dtype((numpy.void, vertex_data.dtype.itemsize * vertex_data.shape[1]))) - _, idx = numpy.unique(vertex_byte_view, return_index=True) - vertex_data = vertex_data[idx] # Select the unique rows by index. - - hull = Polygon(vertex_data) - - if len(vertex_data) >= 3: - convex_hull = hull.getConvexHull() - offset_hull = self._offsetHull(convex_hull) - else: + offset_hull = Polygon([]) + mesh = self._node.getMeshData() + if mesh is None: return Polygon([]) # Node has no mesh data, so just return an empty Polygon. + world_transform = self._node.getWorldTransformation() + + # Check the cache + if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform: + return self._2d_convex_hull_mesh_result + + vertex_data = mesh.getConvexHullTransformedVertices(world_transform) + # Don't use data below 0. + # TODO; We need a better check for this as this gives poor results for meshes with long edges. + # Do not throw away vertices: the convex hull may be too small and objects can collide. + # vertex_data = vertex_data[vertex_data[:,1] >= -0.01] + + if len(vertex_data) >= 4: # type: ignore # mypy and numpy don't play along well just yet. + # Round the vertex data to 1/10th of a mm, then remove all duplicate vertices + # This is done to greatly speed up further convex hull calculations as the convex hull + # becomes much less complex when dealing with highly detailed models. + vertex_data = numpy.round(vertex_data, 1) + + vertex_data = vertex_data[:, [0, 2]] # Drop the Y components to project to 2D. + + # Grab the set of unique points. + # + # This basically finds the unique rows in the array by treating them as opaque groups of bytes + # which are as long as the 2 float64s in each row, and giving this view to numpy.unique() to munch. + # See http://stackoverflow.com/questions/16970982/find-unique-rows-in-numpy-array + vertex_byte_view = numpy.ascontiguousarray(vertex_data).view( + numpy.dtype((numpy.void, vertex_data.dtype.itemsize * vertex_data.shape[1]))) + _, idx = numpy.unique(vertex_byte_view, return_index=True) + vertex_data = vertex_data[idx] # Select the unique rows by index. + + hull = Polygon(vertex_data) + + if len(vertex_data) >= 3: + convex_hull = hull.getConvexHull() + offset_hull = self._offsetHull(convex_hull) + # Store the result in the cache self._2d_convex_hull_mesh = mesh self._2d_convex_hull_mesh_world_transform = world_transform @@ -338,7 +353,7 @@ class ConvexHullDecorator(SceneNodeDecorator): ## Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property). def _getSettingProperty(self, setting_key: str, prop: str = "value") -> Any: - if not self._global_stack: + if self._global_stack is None or self._node is None: return None per_mesh_stack = self._node.callDecoration("getStack") if per_mesh_stack: @@ -358,7 +373,7 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._global_stack.getProperty(setting_key, prop) ## Returns True if node is a descendant or the same as the root node. - def __isDescendant(self, root: "SceneNode", node: "SceneNode") -> bool: + def __isDescendant(self, root: "SceneNode", node: Optional["SceneNode"]) -> bool: if node is None: return False if root is node: diff --git a/cura/Settings/ContainerManager.py b/cura/Settings/ContainerManager.py index e1a1495dac..3cfca1a944 100644 --- a/cura/Settings/ContainerManager.py +++ b/cura/Settings/ContainerManager.py @@ -28,10 +28,10 @@ if TYPE_CHECKING: from cura.Machines.MaterialNode import MaterialNode from cura.Machines.QualityChangesGroup import QualityChangesGroup from UM.PluginRegistry import PluginRegistry - from UM.Settings.ContainerRegistry import ContainerRegistry from cura.Settings.MachineManager import MachineManager from cura.Machines.MaterialManager import MaterialManager from cura.Machines.QualityManager import QualityManager + from cura.Settings.CuraContainerRegistry import CuraContainerRegistry catalog = i18nCatalog("cura") @@ -52,7 +52,7 @@ class ContainerManager(QObject): self._application = application # type: CuraApplication self._plugin_registry = self._application.getPluginRegistry() # type: PluginRegistry - self._container_registry = self._application.getContainerRegistry() # type: ContainerRegistry + self._container_registry = self._application.getContainerRegistry() # type: CuraContainerRegistry self._machine_manager = self._application.getMachineManager() # type: MachineManager self._material_manager = self._application.getMaterialManager() # type: MaterialManager self._quality_manager = self._application.getQualityManager() # type: QualityManager @@ -391,7 +391,8 @@ class ContainerManager(QObject): continue mime_type = self._container_registry.getMimeTypeForContainer(container_type) - + if mime_type is None: + continue entry = { "type": serialize_type, "mime": mime_type, diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 0059b7aad2..063f894d23 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -1148,7 +1148,7 @@ class MachineManager(QObject): self._fixQualityChangesGroupToNotSupported(quality_changes_group) quality_changes_container = self._empty_quality_changes_container - quality_container = self._empty_quality_container + quality_container = self._empty_quality_container # type: Optional[InstanceContainer] if quality_changes_group.node_for_global and quality_changes_group.node_for_global.getContainer(): quality_changes_container = cast(InstanceContainer, quality_changes_group.node_for_global.getContainer()) if quality_group is not None and quality_group.node_for_global and quality_group.node_for_global.getContainer(): diff --git a/plugins/SimulationView/SimulationView.py b/plugins/SimulationView/SimulationView.py index 8d739654d4..edf950e55a 100644 --- a/plugins/SimulationView/SimulationView.py +++ b/plugins/SimulationView/SimulationView.py @@ -21,6 +21,7 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Selection import Selection from UM.Signal import Signal +from UM.View.CompositePass import CompositePass from UM.View.GL.OpenGL import OpenGL from UM.View.GL.OpenGLContext import OpenGLContext @@ -36,7 +37,7 @@ from .SimulationViewProxy import SimulationViewProxy import numpy import os.path -from typing import Optional, TYPE_CHECKING, List +from typing import Optional, TYPE_CHECKING, List, cast if TYPE_CHECKING: from UM.Scene.SceneNode import SceneNode @@ -64,7 +65,7 @@ class SimulationView(View): self._minimum_layer_num = 0 self._current_layer_mesh = None self._current_layer_jumps = None - self._top_layers_job = None + self._top_layers_job = None # type: Optional["_CreateTopLayersJob"] self._activity = False self._old_max_layers = 0 @@ -78,10 +79,10 @@ class SimulationView(View): self._ghost_shader = None # type: Optional["ShaderProgram"] self._layer_pass = None # type: Optional[SimulationPass] - self._composite_pass = None # type: Optional[RenderPass] - self._old_layer_bindings = None + self._composite_pass = None # type: Optional[CompositePass] + self._old_layer_bindings = None # type: Optional[List[str]] self._simulationview_composite_shader = None # type: Optional["ShaderProgram"] - self._old_composite_shader = None + self._old_composite_shader = None # type: Optional["ShaderProgram"] self._global_container_stack = None # type: Optional[ContainerStack] self._proxy = SimulationViewProxy() @@ -204,9 +205,11 @@ class SimulationView(View): if not self._ghost_shader: self._ghost_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "color.shader")) - self._ghost_shader.setUniformValue("u_color", Color(*Application.getInstance().getTheme().getColor("layerview_ghost").getRgb())) + theme = CuraApplication.getInstance().getTheme() + if theme is not None: + self._ghost_shader.setUniformValue("u_color", Color(*theme.getColor("layerview_ghost").getRgb())) - for node in DepthFirstIterator(scene.getRoot()): + for node in DepthFirstIterator(scene.getRoot()): # type: ignore # We do not want to render ConvexHullNode as it conflicts with the bottom layers. # However, it is somewhat relevant when the node is selected, so do render it then. if type(node) is ConvexHullNode and not Selection.isSelected(node.getWatchedNode()): @@ -347,7 +350,7 @@ class SimulationView(View): self._old_max_layers = self._max_layers ## Recalculate num max layers new_max_layers = 0 - for node in DepthFirstIterator(scene.getRoot()): + for node in DepthFirstIterator(scene.getRoot()): # type: ignore layer_data = node.callDecoration("getLayerData") if not layer_data: continue @@ -398,7 +401,7 @@ class SimulationView(View): def calculateMaxPathsOnLayer(self, layer_num: int) -> None: # Update the currentPath scene = self.getController().getScene() - for node in DepthFirstIterator(scene.getRoot()): + for node in DepthFirstIterator(scene.getRoot()): # type: ignore layer_data = node.callDecoration("getLayerData") if not layer_data: continue @@ -474,15 +477,17 @@ class SimulationView(View): self._onGlobalStackChanged() if not self._simulationview_composite_shader: - self._simulationview_composite_shader = OpenGL.getInstance().createShaderProgram(os.path.join(PluginRegistry.getInstance().getPluginPath("SimulationView"), "simulationview_composite.shader")) - theme = Application.getInstance().getTheme() - self._simulationview_composite_shader.setUniformValue("u_background_color", Color(*theme.getColor("viewport_background").getRgb())) - self._simulationview_composite_shader.setUniformValue("u_outline_color", Color(*theme.getColor("model_selection_outline").getRgb())) + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath("SimulationView")) + self._simulationview_composite_shader = OpenGL.getInstance().createShaderProgram(os.path.join(plugin_path, "simulationview_composite.shader")) + theme = CuraApplication.getInstance().getTheme() + if theme is not None: + self._simulationview_composite_shader.setUniformValue("u_background_color", Color(*theme.getColor("viewport_background").getRgb())) + self._simulationview_composite_shader.setUniformValue("u_outline_color", Color(*theme.getColor("model_selection_outline").getRgb())) if not self._composite_pass: - self._composite_pass = self.getRenderer().getRenderPass("composite") + self._composite_pass = cast(CompositePass, self.getRenderer().getRenderPass("composite")) - self._old_layer_bindings = self._composite_pass.getLayerBindings()[:] # make a copy so we can restore to it later + self._old_layer_bindings = self._composite_pass.getLayerBindings()[:] # make a copy so we can restore to it later self._composite_pass.getLayerBindings().append("simulationview") self._old_composite_shader = self._composite_pass.getCompositeShader() self._composite_pass.setCompositeShader(self._simulationview_composite_shader) @@ -496,8 +501,8 @@ class SimulationView(View): self._nozzle_node.setParent(None) self.getRenderer().removeRenderPass(self._layer_pass) if self._composite_pass: - self._composite_pass.setLayerBindings(self._old_layer_bindings) - self._composite_pass.setCompositeShader(self._old_composite_shader) + self._composite_pass.setLayerBindings(cast(List[str], self._old_layer_bindings)) + self._composite_pass.setCompositeShader(cast(ShaderProgram, self._old_composite_shader)) return False @@ -606,7 +611,7 @@ class _CreateTopLayersJob(Job): def run(self) -> None: layer_data = None - for node in DepthFirstIterator(self._scene.getRoot()): + for node in DepthFirstIterator(self._scene.getRoot()): # type: ignore layer_data = node.callDecoration("getLayerData") if layer_data: break From dd150bbab979521c621f37b69b30b367229bedca Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 12:06:57 +0200 Subject: [PATCH 27/37] Resolve circular imports for CuraAPI --- cura/API/Account.py | 18 ++++++++++----- cura/API/Backups.py | 10 ++++++--- cura/API/Interface/Settings.py | 13 +++++++---- cura/API/Interface/__init__.py | 11 +++++++-- cura/API/__init__.py | 35 +++++++++++++++++++++++------ cura/Backups/BackupsManager.py | 10 +++++---- cura/CuraApplication.py | 11 ++++----- cura/OAuth2/AuthorizationService.py | 4 +++- 8 files changed, 81 insertions(+), 31 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 93738a78e9..bc1ce8c2b9 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,15 +1,18 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Dict +from typing import Optional, Dict, TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty +from UM.i18n import i18nCatalog from UM.Message import Message + from cura.OAuth2.AuthorizationService import AuthorizationService from cura.OAuth2.Models import OAuth2Settings -from UM.Application import Application -from UM.i18n import i18nCatalog +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + i18n_catalog = i18nCatalog("cura") @@ -26,8 +29,9 @@ class Account(QObject): # Signal emitted when user logged in or out. loginStateChanged = pyqtSignal(bool) - def __init__(self, parent = None) -> None: + def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) + self._application = application self._error_message = None # type: Optional[Message] self._logged_in = False @@ -47,7 +51,11 @@ class Account(QObject): AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root) ) - self._authorization_service = AuthorizationService(Application.getInstance().getPreferences(), self._oauth_settings) + self._authorization_service = AuthorizationService(self._oauth_settings) + + def initialize(self) -> None: + self._authorization_service.initialize(self._application.getPreferences()) + self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) self._authorization_service.loadAuthDataFromPreferences() diff --git a/cura/API/Backups.py b/cura/API/Backups.py index f31933c844..8e5cd7b83a 100644 --- a/cura/API/Backups.py +++ b/cura/API/Backups.py @@ -1,9 +1,12 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Tuple, Optional +from typing import Tuple, Optional, TYPE_CHECKING from cura.Backups.BackupsManager import BackupsManager +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + ## The back-ups API provides a version-proof bridge between Cura's # BackupManager and plug-ins that hook into it. @@ -13,9 +16,10 @@ from cura.Backups.BackupsManager import BackupsManager # api = CuraAPI() # api.backups.createBackup() # api.backups.restoreBackup(my_zip_file, {"cura_release": "3.1"})`` - class Backups: - manager = BackupsManager() # Re-used instance of the backups manager. + + def __init__(self, application: "CuraApplication") -> None: + self.manager = BackupsManager(application) ## Create a new back-up using the BackupsManager. # \return Tuple containing a ZIP file with the back-up data and a dict diff --git a/cura/API/Interface/Settings.py b/cura/API/Interface/Settings.py index 2889db7022..371c40c14c 100644 --- a/cura/API/Interface/Settings.py +++ b/cura/API/Interface/Settings.py @@ -1,7 +1,11 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from cura.CuraApplication import CuraApplication +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + ## The Interface.Settings API provides a version-proof bridge between Cura's # (currently) sidebar UI and plug-ins that hook into it. @@ -19,8 +23,9 @@ from cura.CuraApplication import CuraApplication # api.interface.settings.addContextMenuItem(data)`` class Settings: - # Re-used instance of Cura: - application = CuraApplication.getInstance() # type: CuraApplication + + def __init__(self, application: "CuraApplication") -> None: + self.application = application ## Add items to the sidebar context menu. # \param menu_item dict containing the menu item to add. @@ -30,4 +35,4 @@ class Settings: ## Get all custom items currently added to the sidebar context menu. # \return List containing all custom context menu items. def getContextMenuItems(self) -> list: - return self.application.getSidebarCustomMenuItems() \ No newline at end of file + return self.application.getSidebarCustomMenuItems() diff --git a/cura/API/Interface/__init__.py b/cura/API/Interface/__init__.py index b38118949b..742254a1a4 100644 --- a/cura/API/Interface/__init__.py +++ b/cura/API/Interface/__init__.py @@ -1,9 +1,15 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import TYPE_CHECKING + from UM.PluginRegistry import PluginRegistry from cura.API.Interface.Settings import Settings +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + + ## The Interface class serves as a common root for the specific API # methods for each interface element. # @@ -20,5 +26,6 @@ class Interface: # For now we use the same API version to be consistent. VERSION = PluginRegistry.APIVersion - # API methods specific to the settings portion of the UI - settings = Settings() + def __init__(self, application: "CuraApplication") -> None: + # API methods specific to the settings portion of the UI + self.settings = Settings(application) diff --git a/cura/API/__init__.py b/cura/API/__init__.py index 54f5c1f8b0..e9aba86a41 100644 --- a/cura/API/__init__.py +++ b/cura/API/__init__.py @@ -1,5 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional, TYPE_CHECKING + from PyQt5.QtCore import QObject, pyqtProperty from UM.PluginRegistry import PluginRegistry @@ -7,6 +9,9 @@ from cura.API.Backups import Backups from cura.API.Interface import Interface from cura.API.Account import Account +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + ## The official Cura API that plug-ins can use to interact with Cura. # @@ -19,14 +24,30 @@ class CuraAPI(QObject): # For now we use the same API version to be consistent. VERSION = PluginRegistry.APIVersion - # Backups API - backups = Backups() + def __init__(self, application: "CuraApplication") -> None: + super().__init__(parent = application) + self._application = application - # Interface API - interface = Interface() + # Accounts API + self._account = Account(self._application) - _account = Account() + # Backups API + self._backups = Backups(self._application) + + # Interface API + self._interface = Interface(self._application) + + def initialize(self) -> None: + self._account.initialize() @pyqtProperty(QObject, constant = True) - def account(self) -> Account: - return CuraAPI._account + def account(self) -> "Account": + return self._account + + @property + def backups(self) -> "Backups": + return self._backups + + @property + def interface(self) -> "Interface": + return self._interface \ No newline at end of file diff --git a/cura/Backups/BackupsManager.py b/cura/Backups/BackupsManager.py index 67e2a222f1..a4d8528960 100644 --- a/cura/Backups/BackupsManager.py +++ b/cura/Backups/BackupsManager.py @@ -1,11 +1,13 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, TYPE_CHECKING from UM.Logger import Logger from cura.Backups.Backup import Backup -from cura.CuraApplication import CuraApplication + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication ## The BackupsManager is responsible for managing the creating and restoring of @@ -13,8 +15,8 @@ from cura.CuraApplication import CuraApplication # # Back-ups themselves are represented in a different class. class BackupsManager: - def __init__(self): - self._application = CuraApplication.getInstance() + def __init__(self, application: "CuraApplication") -> None: + self._application = application ## Get a back-up of the current configuration. # \return A tuple containing a ZipFile (the actual back-up) and a dict diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 04c9ea88db..9d6a2361a1 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -44,6 +44,7 @@ from UM.Operations.RemoveSceneNodeOperation import RemoveSceneNodeOperation from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.SetTransformOperation import SetTransformOperation +from cura.API import CuraAPI from cura.Arranging.Arrange import Arrange from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob @@ -204,7 +205,7 @@ class CuraApplication(QtApplication): self._quality_profile_drop_down_menu_model = None self._custom_quality_profile_drop_down_menu_model = None - self._cura_API = None + self._cura_API = CuraAPI(self) self._physics = None self._volume = None @@ -713,6 +714,9 @@ class CuraApplication(QtApplication): default_visibility_profile = self._setting_visibility_presets_model.getItem(0) self.getPreferences().setDefault("general/visible_settings", ";".join(default_visibility_profile["settings"])) + # Initialize Cura API + self._cura_API.initialize() + # Detect in which mode to run and execute that mode if self._is_headless: self.runWithoutGUI() @@ -900,10 +904,7 @@ class CuraApplication(QtApplication): self._custom_quality_profile_drop_down_menu_model = CustomQualityProfilesDropDownMenuModel(self) return self._custom_quality_profile_drop_down_menu_model - def getCuraAPI(self, *args, **kwargs): - if self._cura_API is None: - from cura.API import CuraAPI - self._cura_API = CuraAPI() + def getCuraAPI(self, *args, **kwargs) -> "CuraAPI": return self._cura_API ## Registers objects for the QML engine to use. diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 16f525625e..df068cc43e 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -29,7 +29,7 @@ class AuthorizationService: # Emit signal when authentication failed. onAuthenticationError = Signal() - def __init__(self, preferences: Optional["Preferences"], settings: "OAuth2Settings") -> None: + def __init__(self, settings: "OAuth2Settings", preferences: Optional["Preferences"] = None) -> None: self._settings = settings self._auth_helpers = AuthorizationHelpers(settings) self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) @@ -38,6 +38,8 @@ class AuthorizationService: self._preferences = preferences self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) + def initialize(self, preferences: Optional["Preferences"] = None) -> None: + self._preferences = preferences if self._preferences: self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") From 6e467721700ce3aa79f55cf4bcf364d40aba5da6 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 12:25:03 +0200 Subject: [PATCH 28/37] Fix imports in Backup --- cura/Backups/Backup.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cura/Backups/Backup.py b/cura/Backups/Backup.py index cc47df770e..b9045a59b1 100644 --- a/cura/Backups/Backup.py +++ b/cura/Backups/Backup.py @@ -4,18 +4,18 @@ import io import os import re - import shutil - -from typing import Dict, Optional from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile +from typing import Dict, Optional, TYPE_CHECKING from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message from UM.Platform import Platform from UM.Resources import Resources -from cura.CuraApplication import CuraApplication + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication ## The back-up class holds all data about a back-up. @@ -29,7 +29,8 @@ class Backup: # Re-use translation catalog. catalog = i18nCatalog("cura") - def __init__(self, zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: + def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: + self.application = application self.zip_file = zip_file # type: Optional[bytes] self.meta_data = meta_data # type: Optional[Dict[str, str]] @@ -41,12 +42,12 @@ class Backup: Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) # Ensure all current settings are saved. - CuraApplication.getInstance().saveSettings() + self.application.saveSettings() # We copy the preferences file to the user data directory in Linux as it's in a different location there. # When restoring a backup on Linux, we move it back. if Platform.isLinux(): - preferences_file_name = CuraApplication.getInstance().getApplicationName() + preferences_file_name = self.application.getApplicationName() preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Copying preferences file from %s to %s", preferences_file, backup_preferences_file) @@ -112,7 +113,7 @@ class Backup: "Tried to restore a Cura backup without having proper data or meta data.")) return False - current_version = CuraApplication.getInstance().getVersion() + current_version = self.application.getVersion() version_to_restore = self.meta_data.get("cura_release", "master") if current_version != version_to_restore: # Cannot restore version older or newer than current because settings might have changed. @@ -128,7 +129,7 @@ class Backup: # Under Linux, preferences are stored elsewhere, so we copy the file to there. if Platform.isLinux(): - preferences_file_name = CuraApplication.getInstance().getApplicationName() + preferences_file_name = self.application.getApplicationName() preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) From 3a01b63343668781cfcbb2bec84e8ec8d68d19b9 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 12:33:16 +0200 Subject: [PATCH 29/37] Fix refactor and tests --- cura/Backups/BackupsManager.py | 2 +- cura/OAuth2/AuthorizationService.py | 3 ++- tests/TestOAuth2.py | 22 ++++++++++++++-------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/cura/Backups/BackupsManager.py b/cura/Backups/BackupsManager.py index a4d8528960..6dfb4ae8bd 100644 --- a/cura/Backups/BackupsManager.py +++ b/cura/Backups/BackupsManager.py @@ -23,7 +23,7 @@ class BackupsManager: # containing some metadata (like version). def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]: self._disableAutoSave() - backup = Backup() + backup = Backup(self._application) backup.makeFromCurrent() self._enableAutoSave() # We don't return a Backup here because we want plugins only to interact with our API and not full objects. diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index df068cc43e..65b31f1ed7 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -39,7 +39,8 @@ class AuthorizationService: self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) def initialize(self, preferences: Optional["Preferences"] = None) -> None: - self._preferences = preferences + if preferences is not None: + self._preferences = preferences if self._preferences: self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 78585804f5..608d529e9f 100644 --- a/tests/TestOAuth2.py +++ b/tests/TestOAuth2.py @@ -31,15 +31,17 @@ MALFORMED_AUTH_RESPONSE = AuthenticationResponse() def test_cleanAuthService() -> None: # Ensure that when setting up an AuthorizationService, no data is set. - authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) + authorization_service = AuthorizationService(OAUTH_SETTINGS, Preferences()) + authorization_service.initialize() assert authorization_service.getUserProfile() is None assert authorization_service.getAccessToken() is None def test_failedLogin() -> None: - authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) + authorization_service = AuthorizationService(OAUTH_SETTINGS, Preferences()) authorization_service.onAuthenticationError.emit = MagicMock() authorization_service.onAuthStateChanged.emit = MagicMock() + authorization_service.initialize() # Let the service think there was a failed response authorization_service._onAuthStateChanged(FAILED_AUTH_RESPONSE) @@ -58,7 +60,8 @@ def test_failedLogin() -> None: @patch.object(AuthorizationService, "getUserProfile", return_value=UserProfile()) def test_storeAuthData(get_user_profile) -> None: preferences = Preferences() - authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) + authorization_service = AuthorizationService(OAUTH_SETTINGS, preferences) + authorization_service.initialize() # Write stuff to the preferences. authorization_service._storeAuthData(SUCCESFULL_AUTH_RESPONSE) @@ -67,7 +70,8 @@ def test_storeAuthData(get_user_profile) -> None: assert preference_value is not None and preference_value != {} # Create a second auth service, so we can load the data. - second_auth_service = AuthorizationService(preferences, OAUTH_SETTINGS) + second_auth_service = AuthorizationService(OAUTH_SETTINGS, preferences) + second_auth_service.initialize() second_auth_service.loadAuthDataFromPreferences() assert second_auth_service.getAccessToken() == SUCCESFULL_AUTH_RESPONSE.access_token @@ -77,7 +81,7 @@ def test_storeAuthData(get_user_profile) -> None: @patch.object(webbrowser, "open_new") def test_localAuthServer(webbrowser_open, start_auth_server, stop_auth_server) -> None: preferences = Preferences() - authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) + authorization_service = AuthorizationService(OAUTH_SETTINGS, preferences) authorization_service.startAuthorizationFlow() assert webbrowser_open.call_count == 1 @@ -92,9 +96,10 @@ def test_localAuthServer(webbrowser_open, start_auth_server, stop_auth_server) - def test_loginAndLogout() -> None: preferences = Preferences() - authorization_service = AuthorizationService(preferences, OAUTH_SETTINGS) + authorization_service = AuthorizationService(OAUTH_SETTINGS, preferences) authorization_service.onAuthenticationError.emit = MagicMock() authorization_service.onAuthStateChanged.emit = MagicMock() + authorization_service.initialize() # Let the service think there was a succesfull response with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()): @@ -121,7 +126,8 @@ def test_loginAndLogout() -> None: def test_wrongServerResponses() -> None: - authorization_service = AuthorizationService(Preferences(), OAUTH_SETTINGS) + authorization_service = AuthorizationService(OAUTH_SETTINGS, Preferences()) + authorization_service.initialize() with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()): authorization_service._onAuthStateChanged(MALFORMED_AUTH_RESPONSE) - assert authorization_service.getUserProfile() is None \ No newline at end of file + assert authorization_service.getUserProfile() is None From 6e2f7e72b699183bab9890bc6802f1fa95a71408 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 12:34:00 +0200 Subject: [PATCH 30/37] Fix missing argument --- cura/Backups/BackupsManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/Backups/BackupsManager.py b/cura/Backups/BackupsManager.py index 6dfb4ae8bd..a0d3881209 100644 --- a/cura/Backups/BackupsManager.py +++ b/cura/Backups/BackupsManager.py @@ -41,7 +41,7 @@ class BackupsManager: self._disableAutoSave() - backup = Backup(zip_file = zip_file, meta_data = meta_data) + backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data) restored = backup.restore() if restored: # At this point, Cura will need to restart for the changes to take effect. From 7ae6800a14ada0b2c2ef81a1deaf0b1484b66e85 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 14:01:28 +0200 Subject: [PATCH 31/37] Fix imports in QualityManager --- cura/CuraApplication.py | 2 +- cura/Machines/QualityManager.py | 12 ++++-------- cura/Scene/ConvexHullDecorator.py | 1 - 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 04c9ea88db..6fb79403cc 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -681,7 +681,7 @@ class CuraApplication(QtApplication): Logger.log("i", "Initializing quality manager") from cura.Machines.QualityManager import QualityManager - self._quality_manager = QualityManager(container_registry, parent = self) + self._quality_manager = QualityManager(self, parent = self) self._quality_manager.initialize() Logger.log("i", "Initializing machine manager") diff --git a/cura/Machines/QualityManager.py b/cura/Machines/QualityManager.py index d924f4c83e..ce19624c21 100644 --- a/cura/Machines/QualityManager.py +++ b/cura/Machines/QualityManager.py @@ -5,8 +5,6 @@ from typing import TYPE_CHECKING, Optional, cast, Dict, List from PyQt5.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot -from UM.Application import Application - from UM.ConfigurationErrorMessage import ConfigurationErrorMessage from UM.Logger import Logger from UM.Util import parseBool @@ -22,7 +20,6 @@ if TYPE_CHECKING: from cura.Settings.GlobalStack import GlobalStack from .QualityChangesGroup import QualityChangesGroup from cura.CuraApplication import CuraApplication - from UM.Settings.ContainerRegistry import ContainerRegistry # @@ -39,12 +36,11 @@ class QualityManager(QObject): qualitiesUpdated = pyqtSignal() - def __init__(self, container_registry: "ContainerRegistry", parent = None) -> None: + def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) - from cura.CuraApplication import CuraApplication - self._application = CuraApplication.getInstance() # type: CuraApplication + self._application = application self._material_manager = self._application.getMaterialManager() - self._container_registry = container_registry + self._container_registry = self._application.getContainerRegistry() self._empty_quality_container = self._application.empty_quality_container self._empty_quality_changes_container = self._application.empty_quality_changes_container @@ -460,7 +456,7 @@ class QualityManager(QObject): # stack and clear the user settings. @pyqtSlot(str) def createQualityChanges(self, base_name: str) -> None: - machine_manager = CuraApplication.getInstance().getMachineManager() + machine_manager = self._application.getMachineManager() global_stack = machine_manager.activeMachine if not global_stack: diff --git a/cura/Scene/ConvexHullDecorator.py b/cura/Scene/ConvexHullDecorator.py index 8532f40890..bdb4cbcba8 100644 --- a/cura/Scene/ConvexHullDecorator.py +++ b/cura/Scene/ConvexHullDecorator.py @@ -9,7 +9,6 @@ from UM.Math.Polygon import Polygon from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Settings.ContainerRegistry import ContainerRegistry - from cura.Settings.ExtruderManager import ExtruderManager from cura.Scene import ConvexHullNode From 3bc91f15c34814e3de424ec025b2f070ff7a223a Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 14:17:00 +0200 Subject: [PATCH 32/37] Fix mypy complains --- cura/OAuth2/AuthorizationHelpers.py | 40 +++++++++++++++-------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index 6cb53d2252..0a1447297c 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -4,7 +4,7 @@ import json import random from hashlib import sha512 from base64 import b64encode -from typing import Optional +from typing import Dict, Optional import requests @@ -24,37 +24,39 @@ class AuthorizationHelpers: def settings(self) -> "OAuth2Settings": return self._settings + # Gets a dictionary with data that need to be used for any HTTP authorization request. + def getCommonRequestDataDict(self) -> Dict[str, str]: + data_dict = {"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", + "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", + "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "", + } + return data_dict + # Request the access token from the authorization server. # \param authorization_code: The authorization code from the 1st step. # \param verification_code: The verification code needed for the PKCE extension. # \return: An AuthenticationResponse object. - def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str)-> "AuthenticationResponse": - return self.parseTokenResponse(requests.post(self._token_url, data={ - "client_id": self._settings.CLIENT_ID, - "redirect_uri": self._settings.CALLBACK_URL, - "grant_type": "authorization_code", - "code": authorization_code, - "code_verifier": verification_code, - "scope": self._settings.CLIENT_SCOPES - })) # type: ignore + def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse": + data = self.getCommonRequestDataDict() + data["grant_type"] = "authorization_code" + data["code"] = authorization_code + data["code_verifier"] = verification_code + return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore # Request the access token from the authorization server using a refresh token. # \param refresh_token: # \return: An AuthenticationResponse object. - def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> AuthenticationResponse: - return self.parseTokenResponse(requests.post(self._token_url, data={ - "client_id": self._settings.CLIENT_ID, - "redirect_uri": self._settings.CALLBACK_URL, - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "scope": self._settings.CLIENT_SCOPES - })) # type: ignore + def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": + data = self.getCommonRequestDataDict() + data["grant_type"] = "refresh_token" + data["refresh_token"] = refresh_token + return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore @staticmethod # Parse the token response from the authorization server into an AuthenticationResponse object. # \param token_response: The JSON string data response from the authorization server. # \return: An AuthenticationResponse object. - def parseTokenResponse(token_response: requests.models.Response) -> AuthenticationResponse: + def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse": token_data = None try: From 5761d28f7f8620c4735c80ea442ec7a6ad4de877 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 14:24:21 +0200 Subject: [PATCH 33/37] Use old data dict code style, more readable --- cura/OAuth2/AuthorizationHelpers.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index 0a1447297c..c149f74ab2 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -37,19 +37,27 @@ class AuthorizationHelpers: # \param verification_code: The verification code needed for the PKCE extension. # \return: An AuthenticationResponse object. def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse": - data = self.getCommonRequestDataDict() - data["grant_type"] = "authorization_code" - data["code"] = authorization_code - data["code_verifier"] = verification_code + data = { + "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", + "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", + "grant_type": "authorization_code", + "code": authorization_code, + "code_verifier": verification_code, + "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "", + } return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore # Request the access token from the authorization server using a refresh token. # \param refresh_token: # \return: An AuthenticationResponse object. def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": - data = self.getCommonRequestDataDict() - data["grant_type"] = "refresh_token" - data["refresh_token"] = refresh_token + data = { + "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", + "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "", + } return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore @staticmethod From 1bee201cfd2db5453e34e06d98ccd166518bfd26 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 14:24:40 +0200 Subject: [PATCH 34/37] Remove unused code --- cura/OAuth2/AuthorizationHelpers.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index c149f74ab2..f75ad9c9f9 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -24,14 +24,6 @@ class AuthorizationHelpers: def settings(self) -> "OAuth2Settings": return self._settings - # Gets a dictionary with data that need to be used for any HTTP authorization request. - def getCommonRequestDataDict(self) -> Dict[str, str]: - data_dict = {"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", - "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", - "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "", - } - return data_dict - # Request the access token from the authorization server. # \param authorization_code: The authorization code from the 1st step. # \param verification_code: The verification code needed for the PKCE extension. From c04c7654c107394be9cadff097c424368fc1aca7 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Fri, 28 Sep 2018 14:31:36 +0200 Subject: [PATCH 35/37] Make Backup._application private --- cura/Backups/Backup.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cura/Backups/Backup.py b/cura/Backups/Backup.py index b9045a59b1..82157a163a 100644 --- a/cura/Backups/Backup.py +++ b/cura/Backups/Backup.py @@ -30,7 +30,7 @@ class Backup: catalog = i18nCatalog("cura") def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: - self.application = application + self._application = application self.zip_file = zip_file # type: Optional[bytes] self.meta_data = meta_data # type: Optional[Dict[str, str]] @@ -42,12 +42,12 @@ class Backup: Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) # Ensure all current settings are saved. - self.application.saveSettings() + self._application.saveSettings() # We copy the preferences file to the user data directory in Linux as it's in a different location there. # When restoring a backup on Linux, we move it back. if Platform.isLinux(): - preferences_file_name = self.application.getApplicationName() + preferences_file_name = self._application.getApplicationName() preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Copying preferences file from %s to %s", preferences_file, backup_preferences_file) @@ -113,7 +113,7 @@ class Backup: "Tried to restore a Cura backup without having proper data or meta data.")) return False - current_version = self.application.getVersion() + current_version = self._application.getVersion() version_to_restore = self.meta_data.get("cura_release", "master") if current_version != version_to_restore: # Cannot restore version older or newer than current because settings might have changed. @@ -129,7 +129,7 @@ class Backup: # Under Linux, preferences are stored elsewhere, so we copy the file to there. if Platform.isLinux(): - preferences_file_name = self.application.getApplicationName() + preferences_file_name = self._application.getApplicationName() preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) From acb7df710c6f810c1ddba90258359f7a922028b6 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 1 Oct 2018 15:37:28 +0200 Subject: [PATCH 36/37] Fix getting cura application instance --- cura/Backups/Backup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cura/Backups/Backup.py b/cura/Backups/Backup.py index 82157a163a..897d5fa979 100644 --- a/cura/Backups/Backup.py +++ b/cura/Backups/Backup.py @@ -36,7 +36,7 @@ class Backup: ## Create a back-up from the current user config folder. def makeFromCurrent(self) -> None: - cura_release = CuraApplication.getInstance().getVersion() + cura_release = self._application.getVersion() version_data_dir = Resources.getDataStoragePath() Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) @@ -59,7 +59,7 @@ class Backup: if archive is None: return files = archive.namelist() - + # Count the metadata items. We do this in a rather naive way at the moment. machine_count = len([s for s in files if "machine_instances/" in s]) - 1 material_count = len([s for s in files if "materials/" in s]) - 1 From 18361b94343dd1f443aa4074c6f8c5e28b38c904 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Tue, 2 Oct 2018 10:31:05 +0200 Subject: [PATCH 37/37] Ensure that CuraAPI can be called in the same way as before This should prevent another API break. CURA-5744 --- cura/API/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cura/API/__init__.py b/cura/API/__init__.py index e9aba86a41..ad07452c1a 100644 --- a/cura/API/__init__.py +++ b/cura/API/__init__.py @@ -23,10 +23,22 @@ class CuraAPI(QObject): # For now we use the same API version to be consistent. VERSION = PluginRegistry.APIVersion + __instance = None # type: "CuraAPI" + _application = None # type: CuraApplication - def __init__(self, application: "CuraApplication") -> None: - super().__init__(parent = application) - self._application = application + # This is done to ensure that the first time an instance is created, it's forced that the application is set. + # The main reason for this is that we want to prevent consumers of API to have a dependency on CuraApplication. + # Since the API is intended to be used by plugins, the cura application should have already created this. + def __new__(cls, application: Optional["CuraApplication"] = None): + if cls.__instance is None: + if application is None: + raise Exception("Upon first time creation, the application must be set.") + cls.__instance = super(CuraAPI, cls).__new__(cls) + cls._application = application + return cls.__instance + + def __init__(self, application: Optional["CuraApplication"] = None) -> None: + super().__init__(parent = CuraAPI._application) # Accounts API self._account = Account(self._application)