From da869e022aae961400d94b5cf54fd8dc0a4073ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Thu, 9 Aug 2018 17:09:21 +0200 Subject: [PATCH] Submission of benchmark data to mydata.blender.org --- benchmark/space/__init__.py | 13 +- submission/__init__.py | 29 ++ submission/appdirs.py | 552 ++++++++++++++++++++++++++++++++++++ submission/auth.py | 67 +++++ submission/client.py | 190 +++++++++++++ submission/html.py | 80 ++++++ submission/sockutil.py | 58 ++++ 7 files changed, 988 insertions(+), 1 deletion(-) create mode 100644 submission/__init__.py create mode 100644 submission/appdirs.py create mode 100644 submission/auth.py create mode 100644 submission/client.py create mode 100644 submission/html.py create mode 100644 submission/sockutil.py diff --git a/benchmark/space/__init__.py b/benchmark/space/__init__.py index f3b4883..5770929 100644 --- a/benchmark/space/__init__.py +++ b/benchmark/space/__init__.py @@ -837,7 +837,18 @@ class BENCHMARK_OT_share(bpy.types.Operator): bl_idname = "benchmark.share" bl_label = "Share Benchmark Result" - def invoke(self, context, event): + def execute(self, context): + import submission + + make_buttons_default() + print('Submitting benchmark') + try: + submission.submit_benchmark(global_result_dict) + except Exception as ex: + self.report({'ERROR'}, f'Error submitting results:\n{str(ex)[:100]}') + return {'CANCELLED'} + print('Submission done') + make_buttons_green() return {'FINISHED'} class BENCHMARK_OT_opendata_link(bpy.types.Operator): diff --git a/submission/__init__.py b/submission/__init__.py new file mode 100644 index 0000000..2275121 --- /dev/null +++ b/submission/__init__.py @@ -0,0 +1,29 @@ +def submit_benchmark(benchmark_data: dict): + """Submit benchmark data to MyData. + + Authenticates the user via the web browser and Blender ID if necessary. + Authentication tokens are stored on disk and validated before reusing. + """ + + import logging + import os + + from .client import CommunicationError, BenchmarkClient + + mydata_url = os.environ.get('MYDATA') or 'https://mydata.blender.org/' + if 'MYDATA' in os.environ: + # Assume we're debugging here. + logging.basicConfig(level=logging.DEBUG, + format='%(asctime)-15s %(levelname)8s %(name)s %(message)s') + + bc = BenchmarkClient(mydata_url) + + # Make sure we have a token; can start the browser to get one. + token = bc.load_auth_token() + result = bc.submit_benchmark(benchmark_data) + print(result) + + # If we get a location from the MyData server, show it in a browser. + if result.location: + import webbrowser + webbrowser.open_new_tab(result.location) diff --git a/submission/appdirs.py b/submission/appdirs.py new file mode 100644 index 0000000..13485be --- /dev/null +++ b/submission/appdirs.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (c) 2005-2010 ActiveState Software Inc. +# Copyright (c) 2013 Eddy Petrișor + +"""Utilities for determining application-specific dirs. + +See for details and usage. +""" +# Dev Notes: +# - MSDN on where to store app data files: +# http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120 +# - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html +# - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html + +__version_info__ = (1, 4, 0) +__version__ = '.'.join(map(str, __version_info__)) + + +import sys +import os + +PY3 = sys.version_info[0] == 3 + +if PY3: + unicode = str + +if sys.platform.startswith('java'): + import platform + os_name = platform.java_ver()[3][0] + if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc. + system = 'win32' + elif os_name.startswith('Mac'): # "Mac OS X", etc. + system = 'darwin' + else: # "Linux", "SunOS", "FreeBSD", etc. + # Setting this to "linux2" is not ideal, but only Windows or Mac + # are actually checked for and the rest of the module expects + # *sys.platform* style strings. + system = 'linux2' +else: + system = sys.platform + + + +def user_data_dir(appname=None, appauthor=None, version=None, roaming=False): + r"""Return full path to the user-specific data dir for this application. + + "appname" is the name of application. + If None, just the system directory is returned. + "appauthor" (only used on Windows) is the name of the + appauthor or distributing body for this application. Typically + it is the owning company name. This falls back to appname. You may + pass False to disable it. + "version" is an optional version path element to append to the + path. You might want to use this if you want multiple versions + of your app to be able to run independently. If used, this + would typically be ".". + Only applied when appname is present. + "roaming" (boolean, default False) can be set True to use the Windows + roaming appdata directory. That means that for users on a Windows + network setup for roaming profiles, this user data will be + sync'd on login. See + + for a discussion of issues. + + Typical user data directories are: + Mac OS X: ~/Library/Application Support/ + Unix: ~/.local/share/ # or in $XDG_DATA_HOME, if defined + Win XP (not roaming): C:\Documents and Settings\\Application Data\\ + Win XP (roaming): C:\Documents and Settings\\Local Settings\Application Data\\ + Win 7 (not roaming): C:\Users\\AppData\Local\\ + Win 7 (roaming): C:\Users\\AppData\Roaming\\ + + For Unix, we follow the XDG spec and support $XDG_DATA_HOME. + That means, by default "~/.local/share/". + """ + if system == "win32": + if appauthor is None: + appauthor = appname + const = roaming and "CSIDL_APPDATA" or "CSIDL_LOCAL_APPDATA" + path = os.path.normpath(_get_win_folder(const)) + if appname: + if appauthor is not False: + path = os.path.join(path, appauthor, appname) + else: + path = os.path.join(path, appname) + elif system == 'darwin': + path = os.path.expanduser('~/Library/Application Support/') + if appname: + path = os.path.join(path, appname) + else: + path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share")) + if appname: + path = os.path.join(path, appname) + if appname and version: + path = os.path.join(path, version) + return path + + +def site_data_dir(appname=None, appauthor=None, version=None, multipath=False): + """Return full path to the user-shared data dir for this application. + + "appname" is the name of application. + If None, just the system directory is returned. + "appauthor" (only used on Windows) is the name of the + appauthor or distributing body for this application. Typically + it is the owning company name. This falls back to appname. You may + pass False to disable it. + "version" is an optional version path element to append to the + path. You might want to use this if you want multiple versions + of your app to be able to run independently. If used, this + would typically be ".". + Only applied when appname is present. + "multipath" is an optional parameter only applicable to *nix + which indicates that the entire list of data dirs should be + returned. By default, the first item from XDG_DATA_DIRS is + returned, or '/usr/local/share/', + if XDG_DATA_DIRS is not set + + Typical user data directories are: + Mac OS X: /Library/Application Support/ + Unix: /usr/local/share/ or /usr/share/ + Win XP: C:\Documents and Settings\All Users\Application Data\\ + Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.) + Win 7: C:\ProgramData\\ # Hidden, but writeable on Win 7. + + For Unix, this is using the $XDG_DATA_DIRS[0] default. + + WARNING: Do not use this on Windows. See the Vista-Fail note above for why. + """ + if system == "win32": + if appauthor is None: + appauthor = appname + path = os.path.normpath(_get_win_folder("CSIDL_COMMON_APPDATA")) + if appname: + if appauthor is not False: + path = os.path.join(path, appauthor, appname) + else: + path = os.path.join(path, appname) + elif system == 'darwin': + path = os.path.expanduser('/Library/Application Support') + if appname: + path = os.path.join(path, appname) + else: + # XDG default for $XDG_DATA_DIRS + # only first, if multipath is False + path = os.getenv('XDG_DATA_DIRS', + os.pathsep.join(['/usr/local/share', '/usr/share'])) + pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)] + if appname: + if version: + appname = os.path.join(appname, version) + pathlist = [os.sep.join([x, appname]) for x in pathlist] + + if multipath: + path = os.pathsep.join(pathlist) + else: + path = pathlist[0] + return path + + if appname and version: + path = os.path.join(path, version) + return path + + +def user_config_dir(appname=None, appauthor=None, version=None, roaming=False): + r"""Return full path to the user-specific config dir for this application. + + "appname" is the name of application. + If None, just the system directory is returned. + "appauthor" (only used on Windows) is the name of the + appauthor or distributing body for this application. Typically + it is the owning company name. This falls back to appname. You may + pass False to disable it. + "version" is an optional version path element to append to the + path. You might want to use this if you want multiple versions + of your app to be able to run independently. If used, this + would typically be ".". + Only applied when appname is present. + "roaming" (boolean, default False) can be set True to use the Windows + roaming appdata directory. That means that for users on a Windows + network setup for roaming profiles, this user data will be + sync'd on login. See + + for a discussion of issues. + + Typical user data directories are: + Mac OS X: same as user_data_dir + Unix: ~/.config/ # or in $XDG_CONFIG_HOME, if defined + Win *: same as user_data_dir + + For Unix, we follow the XDG spec and support $XDG_CONFIG_HOME. + That means, by deafult "~/.config/". + """ + if system in ["win32", "darwin"]: + path = user_data_dir(appname, appauthor, None, roaming) + else: + path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config")) + if appname: + path = os.path.join(path, appname) + if appname and version: + path = os.path.join(path, version) + return path + + +def site_config_dir(appname=None, appauthor=None, version=None, multipath=False): + """Return full path to the user-shared data dir for this application. + + "appname" is the name of application. + If None, just the system directory is returned. + "appauthor" (only used on Windows) is the name of the + appauthor or distributing body for this application. Typically + it is the owning company name. This falls back to appname. You may + pass False to disable it. + "version" is an optional version path element to append to the + path. You might want to use this if you want multiple versions + of your app to be able to run independently. If used, this + would typically be ".". + Only applied when appname is present. + "multipath" is an optional parameter only applicable to *nix + which indicates that the entire list of config dirs should be + returned. By default, the first item from XDG_CONFIG_DIRS is + returned, or '/etc/xdg/', if XDG_CONFIG_DIRS is not set + + Typical user data directories are: + Mac OS X: same as site_data_dir + Unix: /etc/xdg/ or $XDG_CONFIG_DIRS[i]/ for each value in + $XDG_CONFIG_DIRS + Win *: same as site_data_dir + Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.) + + For Unix, this is using the $XDG_CONFIG_DIRS[0] default, if multipath=False + + WARNING: Do not use this on Windows. See the Vista-Fail note above for why. + """ + if system in ["win32", "darwin"]: + path = site_data_dir(appname, appauthor) + if appname and version: + path = os.path.join(path, version) + else: + # XDG default for $XDG_CONFIG_DIRS + # only first, if multipath is False + path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg') + pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)] + if appname: + if version: + appname = os.path.join(appname, version) + pathlist = [os.sep.join([x, appname]) for x in pathlist] + + if multipath: + path = os.pathsep.join(pathlist) + else: + path = pathlist[0] + return path + + +def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True): + r"""Return full path to the user-specific cache dir for this application. + + "appname" is the name of application. + If None, just the system directory is returned. + "appauthor" (only used on Windows) is the name of the + appauthor or distributing body for this application. Typically + it is the owning company name. This falls back to appname. You may + pass False to disable it. + "version" is an optional version path element to append to the + path. You might want to use this if you want multiple versions + of your app to be able to run independently. If used, this + would typically be ".". + Only applied when appname is present. + "opinion" (boolean) can be False to disable the appending of + "Cache" to the base app data dir for Windows. See + discussion below. + + Typical user cache directories are: + Mac OS X: ~/Library/Caches/ + Unix: ~/.cache/ (XDG default) + Win XP: C:\Documents and Settings\\Local Settings\Application Data\\\Cache + Vista: C:\Users\\AppData\Local\\\Cache + + On Windows the only suggestion in the MSDN docs is that local settings go in + the `CSIDL_LOCAL_APPDATA` directory. This is identical to the non-roaming + app data dir (the default returned by `user_data_dir` above). Apps typically + put cache data somewhere *under* the given dir here. Some examples: + ...\Mozilla\Firefox\Profiles\\Cache + ...\Acme\SuperApp\Cache\1.0 + OPINION: This function appends "Cache" to the `CSIDL_LOCAL_APPDATA` value. + This can be disabled with the `opinion=False` option. + """ + if system == "win32": + if appauthor is None: + appauthor = appname + path = os.path.normpath(_get_win_folder("CSIDL_LOCAL_APPDATA")) + if appname: + if appauthor is not False: + path = os.path.join(path, appauthor, appname) + else: + path = os.path.join(path, appname) + if opinion: + path = os.path.join(path, "Cache") + elif system == 'darwin': + path = os.path.expanduser('~/Library/Caches') + if appname: + path = os.path.join(path, appname) + else: + path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache')) + if appname: + path = os.path.join(path, appname.lower().replace(' ', '-')) + if appname and version: + path = os.path.join(path, version) + return path + + +def user_log_dir(appname=None, appauthor=None, version=None, opinion=True): + r"""Return full path to the user-specific log dir for this application. + + "appname" is the name of application. + If None, just the system directory is returned. + "appauthor" (only used on Windows) is the name of the + appauthor or distributing body for this application. Typically + it is the owning company name. This falls back to appname. You may + pass False to disable it. + "version" is an optional version path element to append to the + path. You might want to use this if you want multiple versions + of your app to be able to run independently. If used, this + would typically be ".". + Only applied when appname is present. + "opinion" (boolean) can be False to disable the appending of + "Logs" to the base app data dir for Windows, and "log" to the + base cache dir for Unix. See discussion below. + + Typical user cache directories are: + Mac OS X: ~/Library/Logs/ + Unix: ~/.cache//log # or under $XDG_CACHE_HOME if defined + Win XP: C:\Documents and Settings\\Local Settings\Application Data\\\Logs + Vista: C:\Users\\AppData\Local\\\Logs + + On Windows the only suggestion in the MSDN docs is that local settings + go in the `CSIDL_LOCAL_APPDATA` directory. (Note: I'm interested in + examples of what some windows apps use for a logs dir.) + + OPINION: This function appends "Logs" to the `CSIDL_LOCAL_APPDATA` + value for Windows and appends "log" to the user cache dir for Unix. + This can be disabled with the `opinion=False` option. + """ + if system == "darwin": + path = os.path.join( + os.path.expanduser('~/Library/Logs'), + appname) + elif system == "win32": + path = user_data_dir(appname, appauthor, version) + version = False + if opinion: + path = os.path.join(path, "Logs") + else: + path = user_cache_dir(appname, appauthor, version) + version = False + if opinion: + path = os.path.join(path, "log") + if appname and version: + path = os.path.join(path, version) + return path + + +class AppDirs(object): + """Convenience wrapper for getting application dirs.""" + def __init__(self, appname, appauthor=None, version=None, roaming=False, + multipath=False): + self.appname = appname + self.appauthor = appauthor + self.version = version + self.roaming = roaming + self.multipath = multipath + + @property + def user_data_dir(self): + return user_data_dir(self.appname, self.appauthor, + version=self.version, roaming=self.roaming) + + @property + def site_data_dir(self): + return site_data_dir(self.appname, self.appauthor, + version=self.version, multipath=self.multipath) + + @property + def user_config_dir(self): + return user_config_dir(self.appname, self.appauthor, + version=self.version, roaming=self.roaming) + + @property + def site_config_dir(self): + return site_config_dir(self.appname, self.appauthor, + version=self.version, multipath=self.multipath) + + @property + def user_cache_dir(self): + return user_cache_dir(self.appname, self.appauthor, + version=self.version) + + @property + def user_log_dir(self): + return user_log_dir(self.appname, self.appauthor, + version=self.version) + + +#---- internal support stuff + +def _get_win_folder_from_registry(csidl_name): + """This is a fallback technique at best. I'm not sure if using the + registry for this guarantees us the correct answer for all CSIDL_* + names. + """ + import _winreg + + shell_folder_name = { + "CSIDL_APPDATA": "AppData", + "CSIDL_COMMON_APPDATA": "Common AppData", + "CSIDL_LOCAL_APPDATA": "Local AppData", + }[csidl_name] + + key = _winreg.OpenKey( + _winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders" + ) + dir, type = _winreg.QueryValueEx(key, shell_folder_name) + return dir + + +def _get_win_folder_with_pywin32(csidl_name): + from win32com.shell import shellcon, shell + dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0) + # Try to make this a unicode path because SHGetFolderPath does + # not return unicode strings when there is unicode data in the + # path. + try: + dir = unicode(dir) + + # Downgrade to short path name if have highbit chars. See + # . + has_high_char = False + for c in dir: + if ord(c) > 255: + has_high_char = True + break + if has_high_char: + try: + import win32api + dir = win32api.GetShortPathName(dir) + except ImportError: + pass + except UnicodeError: + pass + return dir + + +def _get_win_folder_with_ctypes(csidl_name): + import ctypes + + csidl_const = { + "CSIDL_APPDATA": 26, + "CSIDL_COMMON_APPDATA": 35, + "CSIDL_LOCAL_APPDATA": 28, + }[csidl_name] + + buf = ctypes.create_unicode_buffer(1024) + ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf) + + # Downgrade to short path name if have highbit chars. See + # . + has_high_char = False + for c in buf: + if ord(c) > 255: + has_high_char = True + break + if has_high_char: + buf2 = ctypes.create_unicode_buffer(1024) + if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024): + buf = buf2 + + return buf.value + +def _get_win_folder_with_jna(csidl_name): + import array + from com.sun import jna + from com.sun.jna.platform import win32 + + buf_size = win32.WinDef.MAX_PATH * 2 + buf = array.zeros('c', buf_size) + shell = win32.Shell32.INSTANCE + shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf) + dir = jna.Native.toString(buf.tostring()).rstrip("\0") + + # Downgrade to short path name if have highbit chars. See + # . + has_high_char = False + for c in dir: + if ord(c) > 255: + has_high_char = True + break + if has_high_char: + buf = array.zeros('c', buf_size) + kernel = win32.Kernel32.INSTANCE + if kernal.GetShortPathName(dir, buf, buf_size): + dir = jna.Native.toString(buf.tostring()).rstrip("\0") + + return dir + +if system == "win32": + try: + import win32com.shell + _get_win_folder = _get_win_folder_with_pywin32 + except ImportError: + try: + from ctypes import windll + _get_win_folder = _get_win_folder_with_ctypes + except ImportError: + try: + import com.sun.jna + _get_win_folder = _get_win_folder_with_jna + except ImportError: + _get_win_folder = _get_win_folder_from_registry + + +#---- self test code + +if __name__ == "__main__": + appname = "MyApp" + appauthor = "MyCompany" + + props = ("user_data_dir", "site_data_dir", + "user_config_dir", "site_config_dir", + "user_cache_dir", "user_log_dir") + + print("-- app dirs (with optional 'version')") + dirs = AppDirs(appname, appauthor, version="1.0") + for prop in props: + print("%s: %s" % (prop, getattr(dirs, prop))) + + print("\n-- app dirs (without optional 'version')") + dirs = AppDirs(appname, appauthor) + for prop in props: + print("%s: %s" % (prop, getattr(dirs, prop))) + + print("\n-- app dirs (without optional 'appauthor')") + dirs = AppDirs(appname) + for prop in props: + print("%s: %s" % (prop, getattr(dirs, prop))) + + print("\n-- app dirs (with disabled 'appauthor')") + dirs = AppDirs(appname, appauthor=False) + for prop in props: + print("%s: %s" % (prop, getattr(dirs, prop))) diff --git a/submission/auth.py b/submission/auth.py new file mode 100644 index 0000000..190c99b --- /dev/null +++ b/submission/auth.py @@ -0,0 +1,67 @@ +import http.server +import logging +import random +import urllib.parse + +from . import html, sockutil + +log = logging.getLogger(__name__) + + +class TokenHTTPHandler(http.server.BaseHTTPRequestHandler): + """Handle GET requests with tokens on the URL.""" + + def do_GET(self): + # /?token=72157630789362986-5405f8542b549e95 + + qs = urllib.parse.urlsplit(self.path).query + url_vars = urllib.parse.parse_qs(qs) + + self.server.auth_token = url_vars['token'][0] + assert (isinstance(self.server.auth_token, str)) + + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + self.wfile.write(html.auth_okay_html) + + +class TokenHTTPServer(http.server.HTTPServer): + """HTTP server on a random port, which will receive the token.""" + + def __init__(self) -> None: + self.log = log.getChild('TokenHTTPServer') + + self.local_addr = self.listen_port() + self.log.info('Creating HTTP server at %s', self.local_addr) + self.auth_token = None + + http.server.HTTPServer.__init__(self, self.local_addr, TokenHTTPHandler) + + def listen_port(self): + """Returns the hostname and TCP/IP port number to listen on. + + Finds a random free port between 1100 and 20000. + """ + + # Find a random free port + local_addr = ('localhost', int(random.uniform(1100, 20000))) + self.log.debug('Finding free port starting at %s', local_addr) + return sockutil.find_free_port(local_addr) + + def wait_for_token(self, timeout=None): + """Starts the HTTP server, waits for the Token.""" + + if self.auth_token is None: + self.timeout = timeout + self.handle_request() + + if self.auth_token: + self.log.info('Auth token received: %s' % self.auth_token) + + return self.auth_token + + @property + def auth_callback_url(self) -> str: + return f'http://localhost:{self.local_addr[1]}/' diff --git a/submission/client.py b/submission/client.py new file mode 100644 index 0000000..1ab1971 --- /dev/null +++ b/submission/client.py @@ -0,0 +1,190 @@ +import functools +import json +import logging +import typing +import pathlib +import urllib.parse + +import requests + +log = logging.getLogger(__name__) + + +class CommunicationError(requests.exceptions.BaseHTTPError): + """Raised when we get an invalid status code form the MyData server.""" + + def __init__(self, message: str, response: requests.Response): + self.message = message + self.status_code = response.status_code + self.body = response.text + + if response.headers.get('Content-Type', '') == 'application/json': + self.json = response.json() + else: + self.json = None + + def __str__(self): + return f'{self.message}; ' \ + f'status_code={self.status_code}; json={self.json}; body={self.body}' + + +class SubmissionResult: + """Metadata of the submitted benchmark. + + :ivar benchmark_id: the ID string of the benchmark; always set. + :ivar location: an URL where the benchmark can be viewed & managed; may be the empty string + if not known. + """ + + def __init__(self, benchmark_id: str, location: str): + assert benchmark_id + self.benchmark_id = benchmark_id + self.location = location + + def __repr__(self): + return f'' + + +class BenchmarkClient: + default_timeout = 30 # seconds + + def __init__(self, mydata_server: str) -> None: + from requests.adapters import HTTPAdapter + + self.auth_token = None + self.auth_http_server = None + self.session = requests.Session() + self.session.mount('https://', HTTPAdapter(max_retries=5)) + + self.url_generate_token = urllib.parse.urljoin(mydata_server, 'token/generate') + self.url_verify_token = urllib.parse.urljoin(mydata_server, 'token/verify') + self.url_submit = urllib.parse.urljoin(mydata_server, 'benchmarks/submit') + + self.log = log.getChild('BenchmarkClient') + + @functools.lru_cache(maxsize=1) + def _token_storage_path(self) -> pathlib.Path: + """Determine storage location for the auth token.""" + from . import appdirs + + data_dir = appdirs.user_config_dir('blender-benchmark-client', 'Blender Foundation') + token_path = pathlib.Path(data_dir) / 'token.json' + self.log.debug('Tokens are stored in %s', token_path) + return token_path + + def _load_token(self) -> typing.Optional[str]: + """Load the token, return None when non-existant.""" + + tpath = self._token_storage_path() + self.log.info('Loading token from %s', tpath) + if not tpath.exists(): + self.log.info('Token file does not exist') + return None + + try: + with tpath.open('rb') as infile: + tokeninfo = json.load(infile) + except (IOError, OSError): + self.log.exception('Error reading token file %s', tpath) + return None + except json.JSONDecodeError: + self.log.exception('Malformed token file %s', tpath) + return None + return tokeninfo['token'] + + def _save_token(self): + """Save the token to disk. + + The token is stored as very simple JSON document, so that it's easy to + extend later (for example with expiry information). + """ + tpath = self._token_storage_path() + self.log.info('Saving token to %s', tpath) + tpath.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + + with tpath.open('w') as outfile: + json.dump({'token': self.auth_token}, outfile) + + def _start_http_server(self): + """Starts the HTTP server, if it wasn't started already.""" + from . import auth + + if self.auth_http_server is not None: + return + self.auth_http_server = auth.TokenHTTPServer() + + def _stop_http_server(self): + """Stops the HTTP server, if one was started.""" + + if self.auth_http_server is None: + return + self.auth_http_server = None + + def _verify_token(self) -> bool: + """Verify the token with MyData, return True iff still valid.""" + + log.debug('validating token at %s', self.url_verify_token) + resp = self.session.get(self.url_verify_token, + headers={'Authorization': f'Bearer {self.auth_token}'}, + timeout=self.default_timeout) + token_ok = resp.status_code in {200, 204} + if not token_ok: + log.info('Client token is no longer valid, will obtain another one.') + return token_ok + + def load_auth_token(self) -> typing.Optional[str]: + """Load & verify the token, start browser to get new one if needed.""" + + self.auth_token = self._load_token() + if self.auth_token and self._verify_token(): + return self.auth_token + + self.auth_via_browser() + + if not self.auth_token: + self.log.error('Unable to get token') + return None + return self.auth_token + + def auth_via_browser(self): + """Open the webbrowser to request an auth token.""" + import webbrowser + + self._start_http_server() + + params = urllib.parse.urlencode({'auth_callback': self.auth_http_server.auth_callback_url}) + url = f"{self.url_generate_token}?{params}" + + if not webbrowser.open_new_tab(url): + raise SystemError(f'Unable to open a browser to visit {url}') + + self.auth_token = self.auth_http_server.wait_for_token() + self._stop_http_server() + + if self.auth_token: + self._save_token() + + def submit_benchmark(self, benchmark_data: dict) -> SubmissionResult: + """Submit the benchmark to MyData. + + :return: Metadata of the benchmark. + """ + + payload = { + 'data': benchmark_data, + 'schema_version': 0, + } + log.info('Submitting benchmark to %s:\n%s', self.url_submit, + json.dumps(payload, sort_keys=True, indent=4)) + resp = self.session.post(self.url_submit, + json=payload, + headers={'Authorization': f'Bearer {self.auth_token}'}, + timeout=self.default_timeout) + if resp.status_code != 201: + raise CommunicationError(f'Bad status code received', resp) + + result = resp.json() + return SubmissionResult( + result['benchmark_id'], + resp.headers.get('Location') or '' + ) diff --git a/submission/html.py b/submission/html.py new file mode 100644 index 0000000..c6eccd8 --- /dev/null +++ b/submission/html.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +"""HTML code.""" + +auth_okay_html = """ + + + + Blender Benchmark Client + + + + + +
+

Blender Benchmark Client

+ +
+

Your Blender Benchmark Client is now associated with your Blender ID account.

+

You can now close this browser window, and return to the Blender Benchmark Client.

+
+
+ + + +""" # noqa: W293 + +auth_okay_html = auth_okay_html.encode('utf-8') diff --git a/submission/sockutil.py b/submission/sockutil.py new file mode 100644 index 0000000..a7119ef --- /dev/null +++ b/submission/sockutil.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- + +"""Utility functions for working with network sockets. + +Created by Sybren A. Stüvel for Chess IX, Haarlem, The Netherlands. +Licensed under the Apache 2 license. +""" + +import logging +import os +import socket + +LOG = logging.getLogger(__name__) + + +def is_bindable(address): + """Tries to bind a listening socket to the given address. + + Returns True if this works, False otherwise. In any case the socket is + closed before returning. + """ + + sock = None + try: + sock = socket.socket() + if os.name == 'posix': + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(address) + sock.close() + except IOError as ex: + LOG.debug('is_bindable(%s): %s', address, ex) + if sock: + sock.close() + return False + + return True + + +def find_free_port(start_address): + """Incrementally searches for a TCP port that can be bound to. + + :param start_address: (hostname, portnr) tuple defining the host to + bind and the portnumber to start the search + :type start_address: tuple + + :return: the address containing the first port number that was found + to be free. + :rtype: tuple of (hostname, port_nr) + """ + + (hostname, port_nr) = start_address + + LOG.debug('find_free_port(%s)', start_address) + while not is_bindable((hostname, port_nr)): + LOG.debug('find_free_port: %i is not bindable, trying next port', port_nr) + port_nr += 1 + + return hostname, port_nr