# This is the quadplay server script. The quadplay server is a stock
# web server with the following extensions:
#
#  - POST commands take a JSON argument with a `command` that is:
#
#    "write_file":    Write the specified contents to disk at the specified location.
#                     Used by the IDE to save files.
#
#    "new_game":      Create a new game by copying and renaming the specified template
#                     and return the URL of the new game, or return an error if the
#                     directory already existed.
#
#    "open":          Open the specified file in finder/explorer or using an external
#                     application editor
#
#    "quit":          Shut down the server itself (only in --nativeapp mode)
#
#    "update":        Update quadplay via git pull or downloading from github, depending on how it was installed
#
#
#  - GET commands:
#
#    - Are limited to the quadplay directory, the directory passed on the
#      command line, and $HOME/my_game
#
#    - Special quad_filepath + '/console/' + ... URLs that on a
#      quadplay server are dynamically generated instead of files:
#
#       - `_assets.json` gives a listing of all assets in the
#         specified query directory and the quadplay built-in
#         directories.  This combines raw and json packaged assets.
#
#       - `_scripts.json` gives a listing of all .pyxl files in
#         the specified query directory and the quadplay built-in
#         scripts directory.
#
#       - `_docs.json` gives a listing of all .md, .html, and
#         .pdf files in the specified query directory.
#
#       - `games.json` gives a listing of all games in subdirectories
#         of examples/, games/, and my_quadplay/ (not recursive). This
#         is backed by an actual file when not using a quadplay
#         server.
#
#       - `_config.json` describes the available external
#         applications, which are a subset of
#         console/external-applications.json. Also includes
#         information on the git version when running from private
#         developer git.
#
#       - `_update_progress.json` reports on the status of the update
#         thread once launched.
#
# A "path" in this code is a file-system path, where '/' means the filesystem true root
#
# A "webpath" is a URL subpath, where '/' means the server root = filesystem root_path

import os, time, platform, sys, threading, socket, argparse, multiprocessing, json, ssl, codecs, glob, shutil, re, base64, random, getpass, inspect, subprocess, workjson, signal, shlex, tkinter, tkinter.messagebox, urllib, urllib.request

quadplay_origin = 'git clone' if os.path.isdir(os.path.join(os.path.dirname(__file__), '../.git')) else 'downloaded release'
if quadplay_origin == 'git clone' and os.path.exists(os.path.join(os.path.dirname(__file__), '../_dev')):
    quadplay_origin = 'development git clone'

# Catch a operator error with git. This binary file should be large if LFS is present:
if quadplay_origin == 'development git clone' and os.stat(os.path.join(os.path.dirname(__file__), '../console/xbox_controller.png')).st_size < 1000:
    print('You are running the development build of quadplay without git LFS, so the binary files are not present. Enable LFS and pull.')
    sys.exit(2)

# If needed, this is how we'd restrict the version. The current implementation does not need to restrict the version:
# if sys.version_info[0] < 3 or sys.version_info[1] < 7:
#     print('Sorry, you are using Python ' + sys.version + ' and quadplay requires Python 3.7 or newer. Download and install from https://python.org')
#     sys.exit(-1)

__doc__ = "quadplay console server"

python_version = str(sys.version_info.major) + '.' + str(sys.version_info.minor)

# Largest common prefix of quad_filepath and game_filepath. Root for web serving
server_root_filepath = None

# Path to the token.txt file
token_absfilepath = None

# Magic path that the browser will use to communicate that it wants an asset listing
# or game listing for import in IDE mode
asset_query_webpath = None
script_query_webpath = None
doc_query_webpath = None
game_query_webpath = None
config_query_webpath = None
update_progress_query_webpath = None

# Subdirectories of server_root_filepath that are permitted for http access.
# These are relative to server_root_filepath and contain a leading slash because
# they are url subpaths relative to quad://
webpath_allowlist = None

isWindows = (platform.system() == 'Windows')
isMacOS   = (platform.system() == 'Darwin')
isLinux   = (platform.system() == 'Linux')

# Authentication token, used to ensure that only browsers launched by this server can
# ask it to POST
token = None

def ignore(*args): pass

# Assigned in main based on the --quiet flag
maybe_print = ignore

##########################################################################
# Version detection

# Parses a version.js file with an embedded 'version = yyyy.mm.dd.hh'
# string and returns it as a single integer for version comparisons,
# as well as the human-readable version string.
def parse_version_js(file_string):
    version_parser = re.compile("^ *const *version *= *['\"]([.0-9]+)*['\"] *;")
    try:
        version_string = version_parser.search(file_string).group(1)

        year, month, day, hour = [int(x) for x in version_string.split('.')]

        # Months and years have varying length. That doesn't matter.  We
        # don't need a linear time number. We need one that monotonically
        # increases. Think of this as parsing a number with an irregular
        # base per digit.
        return {'value': ((year * 12 + month) * 32 + day) * 24 + hour,
                'text': version_string}
    except:

        return {'value': 0, 'text': file_string}

with open(os.path.join(os.path.dirname(__file__), '../console/version.js')) as file:
    installed_quadplay_version = parse_version_js(file.read())

##########################################################################

# Ensure that slashes and case are consistent when on Windows, and make absolute
def canonicalize_filepath(path): return os.path.normcase(os.path.abspath(path)).replace('\\', '/')

def fatal_error(message):
    # Initialize the GUI
    window_root = tkinter.Tk()
    window_root.withdraw()

    # Message Box
    print("Quadplay Error: " + message)
    tkinter.messagebox.showerror("Quadplay Error", message)
    sys.exit(-1)

def parse_args():
    """ parse arguments out of sys.argv """
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.ArgumentDefaultsHelpFormatter
    )

    parser.add_argument(
        '--serve',
        action='store_true',
        default=False,
        help='serving mode - allow other machines to connect to this one to develop on mobile devices'
    )

    parser.add_argument(
        '--nativeapp',
        action='store_true',
        default=False,
        help='Attempt to open quadplay as a native app window on platforms with Chrome or Edge. Defaults to True for the quadplay script and False for the quadplay-server script.'
    )

    parser.add_argument(
        '--quiet',
        action='store_true',
        default=False,
        help='Minimize console output, eliminating the console window itself if possible.'
    )

    parser.add_argument(
        '--my_quadplay',
        default=os.environ.get('MY_QUADPLAY', '~/my_quadplay'),
        help="Set the path to local games, which defaults to my_quadplay in the current user's home directory or the MY_QUADPLAY environment variable."
    )

    parser.add_argument(
        '--noupdatecheck',
        default=False,
        help="Do not check for newer versions of quadplay on github. Never checks when in kiosk mode")

    parser.add_argument(
        'gamepath',
        type=str,
        default='',
        nargs='?',
        help=(
            'Game to load.  If not specified, loads a default game.'
            ' Example: examples/accel_demo'
        )
    )

    parser.add_argument(
        '--kiosk',
        action='store_true',
        default=False,
        help='kiosk mode - launch full screen, without the IDE or other controls'
    )

    parser.add_argument(
        '--play',
        action='store_true',
        default=False,
        help='play mode - launch without the IDE'
    )

    parser.add_argument(
        '--offline',
        action='store_true',
        default=False,
        help='Prevent online multiplayer as both host and guest'
    )

    parser.add_argument(
        "-p",
        "--port",
        type=int,
        default=8000,
        help="Port to run the server on."
    )

    return parser.parse_args()

# Args must be initialized to a constant before launchServer is defined
# because of the way that multiprocess serialization works on ThreadingHTTPServer
args = parse_args()

# Where the user's games are stored. Will be made relative to the CWD later
my_games_filepath = canonicalize_filepath(os.path.expanduser(args.my_quadplay))

if my_games_filepath.find(" ") != -1:
    fatal_error("The my_quadplay directory (" + my_games_filepath + ") may not contain spaces in this release. Use the --my_quadplay command line argument to select a path without spaces in the name.")

if len(my_games_filepath) == 0 or (isWindows and (len(my_games_filepath) < 3 or my_games_filepath[1:3] != ':/')) or (not isWindows and (len(my_games_filepath) < 2 or my_games_filepath[0] != '/')):
    fatal_error("The my_quadplay directory (" + my_games_filepath + ") must be an absolute path.")

if not os.path.exists(my_games_filepath):
    if my_games_filepath == canonicalize_filepath(os.path.expanduser('~/my_quadplay')):
        # Create the games path and put a .gitignore in it if there isn't
        # one already
        os.makedirs(my_games_filepath)
        shutil.copyfile('.gitignore', os.path.join(my_games_filepath, '.gitignore'))
    else:
        fatal_error("The specified my_quadplay directory (" + my_games_filepath + ") could not be found.")

def remove_leading_slash(path): return path[1:] if len(path) > 0 and path[0] == '/' else path
def remove_trailing_slash(path): return path[:-1] if len(path) > 0 and path[-1] == '/' else path

try:
    # Python 3.7+
    from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
    QuadplayHTTPServer = ThreadingHTTPServer
    maybe_print('Initializing Quadplay server using Python ' + python_version + ' ThreadingHTTPServer')

except ImportError:
    try:
        # Python 3+
        from http.server import HTTPServer, SimpleHTTPRequestHandler
        maybe_print('Initializing Quadplay server using Python ' + python_version + ' HTTPServer')
        QuadplayHTTPServer = HTTPServer
    except ImportError:
        # Python 2
        from SimpleHTTPServer import SimpleHTTPRequestHandler
        import SocketServer
        QuadplayHTTPServer = SocketServer.TCPServer
        maybe_print('Initializing Quadplay server using Python ' + python_version + ' SocketServer.TCPServer')

# Returns a string of the line number that calls this function
def this_line_number(): return str(inspect.currentframe().f_back.f_lineno)

##############################################################################

# Throws an exception with the output on a non-zero error code,
# otherwise returns the command's output.
def run_shell_command(cmd):
    maybe_print(cmd)
    with os.popen(cmd) as stream:
        output = stream.read()
        if stream.close():
            raise Exception(output)
        else:
            maybe_print(output)
            return output

class UpdateThread(threading.Thread):

    def __init__(self):
        super(UpdateThread, self).__init__(name='update_thread', daemon=True)
        self._status = 'Running'
        self._status_lock = threading.Lock()

    def set_status(self, value):
        with self._status_lock:
            self._status = value

    def get_status(self):
        with self._status_lock:
            return self._status

    def run(self):
        try:
            if self.update():
                self.set_status('Done. Restart server.')
            else:
                self.set_status('Done. Restart client.')
        except Exception as e:
            maybe_print('Update failed: ' + str(e))
            self.set_status('Failed: ' + str(e))

    # Returns true if the server needs to be restarted.
    # Raises an exception if the update fails.
    def update(self):
        old_dir = os.getcwd()

        # go to the quadplay root dir
        os.chdir(os.path.join(os.path.dirname(__file__), '..'))

        # Track if this script itself changed
        my_previous_timestamp = os.path.getmtime(__file__)

        try:
            if quadplay_origin == 'git clone' or quadplay_origin == 'development git clone':
                # Verify that we have git
                if not shutil.which('git'):
                    raise Exception('Your quadplay was installed via git clone but the updater cannot find the git program to pull the latest version. Delete the .git directory or manually update.')

                # Run git
                run_shell_command('git pull')

            elif quadplay_origin == 'downloaded release':
                os.mkdir('temp')
                run_shell_command('curl --location --output temp/quadplay-install.zip https://github.com/morgan3d/quadplay/archive/refs/heads/main.zip')
                os.chdir('temp')
                try:
                    run_shell_command('tar -xf quadplay-install.zip')

                    # Copy files
                    shutil.copytree('quadplay-main', '..', dirs_exist_ok = True)
                    shutil.rmtree('temp')
                except:
                    # Try to clean up
                    os.chdir('..')
                    shutil.rmtree('temp')
                    raise

            # Success
            os.chdir(old_dir)
            return os.path.getmtime(__file__) != my_previous_timestamp

        except:
            os.chdir(old_dir)
            raise

# The global updating thread. Initialized when the client
# requests the update.
update_thread = None

##############################################################################
# Handles serving from multiple directories. Overrides the default
# restriction to the CWD with its own security allowlist. Instantiated
# per request.
#
# See https://github.com/python/cpython/blob/master/Lib/http/server.py for
# the base class implementation and internal methods
class QuadplayHTTPRequestHandler(SimpleHTTPRequestHandler):
    # Directories that should be excluded from filesystem searches
    special_dir_regex = re.compile('(^|/)(journal|screenshots|metadata|backlot|graveyard)/', re.IGNORECASE)

    # Files that can be written or deleted by the IDE
    mutable_file_regex = re.compile(r'\.(json|xml|pyxl|png|pdf|yml|yaml|html|txt|md|tmx|mp3)$')

    # Files that should not print error messages to the script's
    # terminal if not found (there is no way to stop these from
    # printing errors to the *browser's* console).
    silent_404_file_regex = re.compile(r'(\.debug\.json|(^|/)label(64|128)\.png|favicon\.png)$')

    # Add custom headers
    def end_headers(self):
        # CORS:
        self.send_header('Access-Control-Allow-Origin', '*')

        # Required to enable high precision timer in Firefox and Chrome:
        self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
        self.send_header('Cross-Origin-Opener-Policy', 'same-origin')

        # Try to reduce overcaching
        self.send_header('Cache-Control', 'must-revalidate')
        SimpleHTTPRequestHandler.end_headers(self)

    def list_directory(self, path):
        # Prevent directory listing
        self.send_error(404, 'No permission to list directory (' + this_line_number() + ')')
        return None

    # Returns False on security error
    def prepare_mutating_request(self):
        if args.serve:
           maybe_print('Refused POST/DELETE command while running with --serve')
           return False

        if self.client_address[0] != '127.0.0.1':
           maybe_print('Refused POST/DELETE command from non-local address', self.client_address[0])
           return False

        content_len = int(self.headers['Content-Length'])
        object = json.loads(self.rfile.read(content_len))

        request_token = object['token']
        if request_token != token:
            maybe_print('WARNING: Ignored request without proper token (expected ', token, '/ received ', request_token, ')')
            response = json.dumps('Bad token', separators = (',', ':'));
            self.send_response(401)
            self.send_header('Content-type', 'text/json')
            self.send_header('Content-length', len(response))
            self.end_headers()
            self.wfile.write(response.encode('utf8'))
            return False

        return object

    def do_DELETE(self):
        object = self.prepare_mutating_request()
        if not object: return
        path_parts = self.path.split('?')
        webpath = os.path.normpath(path_parts[0]).replace('\\', '/')

        maybe_print('DELETE', webpath)
        code = 200
        filename = remove_leading_slash(webpath)
        if not re.search(self.mutable_file_regex, filename) or not any([webpath.startswith(prefix) for prefix in webpath_allowlist]):
            maybe_print('WARNING: Ignored illegal request to delete file (' + this_line_number() + ')', filename)
            response_obj = 'Illegal'
            code = 401
        else:
            maybe_print('deleted file', filename)
            os.remove(filename)

        response = json.dumps('OK', separators = (',', ':'));
        self.send_response(code)
        self.send_header('Content-type', 'text/json')
        self.send_header('Content-length', len(response))
        self.end_headers()
        self.wfile.write(response.encode('utf8'))

    # Used for the IDE to write files
    def do_POST(self):
        global update_thread
        # maybe_print('\n\nReceived POST from ', self.client_address[0], '\n\n')

        object = self.prepare_mutating_request()
        if not object: return

        command = object['command']

        response_obj = None
        code = 201
        if command == 'write_file':
            webpath = object['url']
            filename = remove_leading_slash(webpath)
            encoding = object['encoding']
            contents = object['contents']

            if not re.search(self.mutable_file_regex, filename) or not any([webpath.startswith(prefix) for prefix in webpath_allowlist]):
                maybe_print('WARNING: Ignored illegal request to write file (' + this_line_number() + ')', filename)
                if not re.search(self.mutable_file_regex, filename): maybe_print('  Legal file regex is', self.mutable_file_regex)
                if not any([webpath.startswith(prefix) for prefix in webpath_allowlist]): maybe_print('  Web path allow list is', webpath_allowlist)
                response_obj = 'Illegal'
                code = 401
            else:
                if encoding == 'binary':
                    # Contents is base64 encoded for the JSON transmission.
                    # Convert back to bytes
                    contents = base64.standard_b64decode(contents)
                    with open(filename, 'wb') as f:
                        f.write(contents)
                        # See comment below about flushing
                        f.flush()
                        f.close()
                else:
                    with codecs.open(filename, 'w', encoding) as f:
                        f.write(contents)
                        # Documentation says that closing the file flushes it, however
                        # on Windows we get race conditions where new files do not exist
                        # sometimes for a subsequent read even though they have been
                        # closed. So, we flush explicitly here, which either affects
                        # the timing just enough to make it succeed, or hopefully actually
                        # blocks until the file is written.
                        f.flush()
                        f.close()
                maybe_print('Wrote', encoding, 'file', filename)

                if isWindows:
                    # Because of the windows failure to flush files,
                    # there can be a race condition in the case of
                    # file renaming, where the client assumes the write
                    # has finished and then tries to read the same file.
                    # So, check here:
                    max_iterations = 10
                    while not os.path.exists(filename) and max_iterations > 0:
                        time.sleep(0.02)
                        --max_iterations
                response_obj = 'OK'

        elif command == 'quit':
            # Used in nativeapp mode for the browser to tell the
            # (invisible) server to exit. Windows and Linux will
            # detect the browser closing, so this is only essential on
            # macOS where the menu option closes the TAB but leaves
            # the browser process still running.
            print('quadplay server terminated')

            # Hard exit, rather than raising an exception. Needed
            # because of threads
            if isWindows:
                os._exit(0)
            else:
                os.kill(os.getpid(), 9)

            response_obj = 'OK'

        elif command == 'open':
            app = object['app']
            filename = '"' + object['file'] + '"'
            cmd = ''
            if app == '<finder>':
                if isMacOS:
                    cmd = 'open -R "' + filename + '"'
                else:
                    cmd = 'start "" "' + os.path.dirname(filename) + '"'
            elif isMacOS:
                cmd = 'open -a "' + app + '" "' + filename + '"'

            os.system(cmd)
            response_obj = 'OK'

        elif command == 'new_game':
            dir_name = object['dir_name']
            game_name = object['game_name']
            src_url = object['src_url']

            dst_path = my_games_filepath + '/' + dir_name

            # Convert src_url to src_path + src_filename
            src_path = re.sub(r'^https?://[^/]*/', '', src_url)
            src_path = re.sub(r'^quad://', quad_filepath, src_path)
            src_path = remove_leading_slash(os.path.normpath(src_path).replace('\\', '/'))
            if src_path.endswith('.game.json'):
                src_path = src_path.split('/')
                src_filename = src_path[-1]
                src_path = '/'.join(src_path[:-1])
            else:
                # Remove trailing slash
                if src_path[-1] == '/': src_path[:-1]
                src_filename = src_path.split('/')[-1] + '.game.json'

            #src_path = os.path.join(quad_filepath, 'examples/starter')
            #src_filename = 'starter.game.json'

            maybe_print('Created game', dir_name, 'at', dst_path)

            # Copy and rename files
            if os.path.exists(dst_path):
                code = 406
                response_obj = {'message': dst_path + ' already exists'}
            else:
                # Recursively copy directory
                shutil.copytree(src_path, dst_path, ignore = shutil.ignore_patterns('.DS_Store', '*~', '#*'))
                #for filename in ['label64.png', 'label128.png', 'Play.pyxl']:
                #    shutil.copyfile(os.path.join(starter_path, filename), os.path.join(dst_path, filename))
                shutil.copyfile(os.path.join(quad_filepath, '.gitignore'), os.path.join(dst_path, '.gitignore'))

                # Parse the starter game
                game_json = workjson.load(os.path.join(src_path, src_filename))

                # Change the title
                game_json['title'] = game_name
                game_json['screenshot_tag'] = game_name

                # Save the new game
                with open(os.path.join(dst_path, dir_name + '.game.json'), 'wt', encoding='utf8') as f:
                    f.write(workjson.dumps(game_json, indent=4))

                response_obj = {'game': '/' + dst_path + '/'}

        elif command == 'update':
            maybe_print('Updating quadplay...')
            if not update_thread or (update_thread.get_status() != 'Running'):
                update_thread = UpdateThread()
                update_thread.start()
                response_obj = 'OK'
            else:
                maybe_print('Update already in progress.')
                response_obj = 'Update already in progress.'

        response = json.dumps(response_obj, separators = (',', ':'));
        self.send_response(code)
        self.send_header('Content-type', 'text/json')
        self.send_header('Content-length', len(response))
        self.end_headers()
        self.wfile.write(response.encode('utf8'))

    def do_GET(self):
        # Remove the query and collapse any '..' in the path
        path_parts = self.path.split('?')
        webpath = os.path.normpath(path_parts[0]).replace('\\', '/')
        query = path_parts[1] if len(path_parts) > 1 else ''

        #print('-----------------------------')
        #print('GET ' + path_parts[0])
        #print(' webpath = ' + webpath)

        # Security: Check if path has a prefix in webpath_allowlist
        if (not any([webpath == ignore for ignore in ['/favicon.ico', '/apple-touch-icon-precomposed.png', '/apple-touch-icon.png', '/apple-touch-icon-120x120-precomposed.png', '/apple-touch-icon-120x120.png']]) and
            not any([webpath.startswith(prefix) for prefix in webpath_allowlist])):
            self.send_error(404, 'Illegal webpath (' + this_line_number() + '): ' + webpath)
            return

        filepath = remove_leading_slash(webpath)
        self.path = filepath

        if webpath in [config_query_webpath, asset_query_webpath, game_query_webpath, doc_query_webpath, script_query_webpath, update_progress_query_webpath]:
            response_obj = {}
            if webpath == update_progress_query_webpath:
                if update_thread:
                    status = update_thread.get_status()
                    response_obj['status'] = status
                    response_obj['done'] = (status != 'Running')
                    response_obj['restartServer'] = (status == 'Done. Restart server.')
                else:
                    response_obj['status'] = 'No update in progress.'
                    response_obj['restartServer'] = False
                    response_obj['done'] = True

            elif webpath == config_query_webpath:
                if not args.kiosk: response_obj['IDE_USER'] = os.environ['USERNAME' if isWindows else 'USER']
                response_obj['rootPath'] = server_root_filepath
                response_obj['hasFinder'] = isWindows or isMacOS

                if isWindows or isMacOS:
                    applications = []
                    response_obj['applications'] = applications
                    potential_applications = workjson.load(os.path.join(quad_filepath, 'console/external-applications.json'))['Windows' if isWindows else 'macOS']
                    for app in potential_applications:
                        for path in app['paths']:
                            path = os.path.expandvars(os.path.expanduser(path))
                            if os.path.exists(path):
                                # Found this app
                                applications.append({'name': app['name'], 'path': path, 'types': app['types']})
                                #print('FOUND', path)
                                break
                            #else:
                            #    print('Did not find', path)

            elif webpath == script_query_webpath:
                aux_webpath = query[query.index('=') + 1:]
                # Security: check if aux_webpath has a prefix in webpath_allowlist
                if not any([aux_webpath.startswith(prefix) for prefix in webpath_allowlist]):
                    self.send_error(404, 'Illegal webpath (' + this_line_number() + '): ' + aux_webpath)
                    return

                # List the pyxl files
                aux_filepath = remove_leading_slash(aux_webpath)
                response_obj = glob.glob(aux_filepath + '**/*.pyxl', recursive=True)

                # Remove anything in a magic directory
                response_obj = [f for f in response_obj if not self.special_dir_regex.search(f)]

                # Sort all files
                response_obj.sort()

                # List additional files from the built-in scripts folder
                response_obj += ['quad://' + f[len(quad_filepath):] for f in sorted(glob.glob(quad_filepath + 'scripts/*.pyxl'))]

                # Fix slashes on windows
                if isWindows: response_obj = [f.replace('\\', '/') for f in response_obj]

            elif webpath == doc_query_webpath:
                aux_webpath = query[query.index('=') + 1:]

                # Security: check if aux_webpath has a prefix in webpath_allowlist
                if not any([aux_webpath.startswith(prefix) for prefix in webpath_allowlist]):
                    self.send_error(404, 'Illegal webpath: (' + this_line_number() + ')' + aux_webpath)
                    return

                aux_filepath = remove_leading_slash(aux_webpath)
                # List the files
                response_obj =  glob.glob(aux_filepath + '**/*.html', recursive=True)
                response_obj += glob.glob(aux_filepath + '**/*.md',   recursive=True)
                response_obj += glob.glob(aux_filepath + '**/*.pdf',  recursive=True)
                response_obj += glob.glob(aux_filepath + '**/*.txt',  recursive=True)

                # Do not remove any magic directory files; we want journals and such in this list

                # Sort all files
                response_obj.sort()

                # Fix slashes on windows
                if isWindows: response_obj = [f.replace('\\', '/') for f in response_obj]

            elif webpath == asset_query_webpath:
                aux_webpath = query[query.index('=') + 1:]

                # Security: check if aux_webpath has a prefix in webpath_allowlist
                if not any([aux_webpath.startswith(prefix) for prefix in webpath_allowlist]):
                    self.send_error(404, 'Illegal webpath: (' + this_line_number() + ')' + aux_webpath)
                    return

                # Raw files to exclude because they are already in use. Exclude
                # the files from quadplay's own metadata automatically.
                exclude_table = {}
                for f in ['label64.png', 'label128.png', 'preview.png']:
                    exclude_table[remove_leading_slash(aux_webpath) + f] = True

                # Search (quad_filepath | aux_webpath) + type + 's/*.' + type + '.json'
                asset_type_array = ['font', 'sprite', 'sound', 'map', 'data']
                for t in asset_type_array:
                    # Get the json files from the game directory
                    response_obj[t] = glob.glob(remove_leading_slash(aux_webpath + '**/*.' + t + '.json'), recursive=True)

                    # Look in each of the files for the URLs of raw files to exclude
                    for file in response_obj[t]:
                        try:
                            tmp = workjson.load(file)
                            if 'url' in tmp:
                                exclude_table[remove_leading_slash(aux_webpath) + tmp['url']] = True
                        except:
                            maybe_print('Warning: Could not parse ' + file)

                raw_extension = {'sprite': 'png', 'font': 'png', 'sound': 'mp3', 'map': 'tmx'}
                for t in asset_type_array:
                    # Get raw files
                    if t != 'data':
                        raw_files = glob.glob(remove_leading_slash(aux_webpath + '**/*.' + raw_extension[t]), recursive=True)
                    else:
                        raw_files = []

                    # Remove from raw_files anything that is in exclude_table
                    raw_files = [f for f in raw_files if not f in exclude_table]

                    # Sort all files from the game directory
                    response_obj[t] = sorted(response_obj[t] + raw_files)

                    # Remove anything in a magic directory
                    response_obj[t] = [f for f in response_obj[t] if not self.special_dir_regex.search(f)]

                    # Get the json files from the quadplay directory,
                    # sort, and append them to the list
                    response_obj[t] += ['quad://' + f[len(quad_filepath):] for f in sorted(glob.glob(quad_filepath + t + 's/*.' + t + '.json'))]

                    # Fix slashes on windows
                    if isWindows: response_obj[t] = [f.replace('\\', '/') for f in response_obj[t]]

            elif webpath == game_query_webpath:

                # Detect games with the same name as the folder
                redundant_pattern = re.compile(r'(?P<name>/[^/]+)(?P=name)\.game\.json$')
                set = {'examples' : quad_filepath + 'examples',
                       'builtins' : quad_filepath + 'games',
                       'mine'     : my_games_filepath}

                # For alpha testers and internal development
                if os.path.exists(quad_filepath + '_alpha'):
                    set['alpha'] = quad_filepath + '_alpha'
                    set['tests'] = quad_filepath + '_tests'

                for key, path in set.items():
                    # Do not change case on Windows (because this will be part of the json file that is read
                    # on multiple platforms) but *do* fix slashes
                    list = [{'url': path.replace('\\', '/'), 'title': 'TBD'} for path in sorted(glob.glob(path + '/**/*.game.json', recursive=True))]
                    for entry in list:
                        path = entry['url']

                        if key == 'mine':
                            # Make relative to the web root
                            entry['url'] = '/' + entry['url']
                        else:
                            # Shorten built-ins to quad://
                            entry['url'] = 'quad://' + path[len(quad_filepath):]

                        # Shorten games with redundant names to just the directory
                        entry['url'] = redundant_pattern.sub(r'\1/', entry['url'])
                        # Fetch the titles
                        with open(path, 'rt', encoding='utf8') as f:
                            game = workjson.loads(f.read())
                            entry['description'] = game['description'] if 'description' in game else ''
                            entry['title'] = game['title']

                    response_obj[key] = list

            response = json.dumps(response_obj, separators = (',', ':'));
            self.send_response(200)
            self.send_header('Content-type', 'text/json')
            self.send_header('Content-length', len(response))
            self.end_headers()
            self.wfile.write(response.encode('utf8'))

        elif re.search(self.silent_404_file_regex, webpath) and not os.path.exists(filepath):
            # Check to see if this file does not exist. If it does
            # not, return the 404 error but do not print to the terminal
            self.send_response(404)
            self.send_header('Content-type', 'text/json')
            self.send_header('Content-length', 0)
            self.end_headers()
        else:
            # Serve the file
            f = self.send_head()
            if f:
               try:
                   self.copyfile(f, self.wfile)
               except BrokenPipeError:
                   maybe_print('Broken pipe while loading', filepath)
               finally:
                   f.close()

    def log_request(self, code = '-', size = '-'):
        # Overridden to not show all requests
        #self.log_message('"%s" %s %s', self.requestline, str(code), str(size))
        pass

    def log_error(self, format, *args):
        # Overridden to allow a debugging point
        self.log_message(format, *args)

    def translate_path(self, path):
        if path == '/favicon.ico':
            # Browsers sometimes ask for the icon from this
            # location. Send them to the quadplay directory instead
            return quad_filepath + '/console/icons/favicon.ico'

        elif path == '/apple-touch-icon-precomposed.png' or path == '/apple-touch-icon.png':
            # Safari asks for the icon via this path
            return quad_filepath + '/console/icons/favicon-64x64.png'

        elif path == '/apple-touch-icon-120x120-precomposed.png' or path == '/apple-touch-icon-120x120.png':
            # Safari asks for the icon via this path
            return quad_filepath + '/console/icons/favicon-120x120.png'

        elif isWindows and server_root_filepath == '':
            # Need the whole path, since the "cwd" is wrong
            return self.path.split('?')[0]
        else:
            # Intentionally use Python 2 super syntax for potential
            # backward compatibility
            result = SimpleHTTPRequestHandler.translate_path(self, path)
            return result

if isWindows and args.gamepath:
    # Make drive letters canonical
    if args.gamepath[1] == ':':
        args.gamepath = args.gamepath[0].upper() + args.gamepath[1:]

# Makes a path relative to the server_root_filepath, and thus "absolute"
# for the web server root (not for the filesystem)
def platform_www_abspath(p):
    if isWindows:
        p = p[len(server_root_filepath):]
        if not p.endswith('.game.json'): p = os.path.join(p, '')
        t = os.path.normcase(p).replace('\\', '/')
        if t[0] != '/': t = '/' + t
        return t
    else:
        if server_root_filepath != '/':
            p = p[len(server_root_filepath)+1:]

        if p == '':
            return ''
        elif p.endswith('.game.json'):
            return os.path.join('/', p)
        else:
            return os.path.join('/', p, '')

# Process paths at top level so that they can be inherited by ThreadingHTTPServer
quad_filepath = canonicalize_filepath(os.path.join(os.path.dirname(__file__), '..'))
token_absfilepath = os.path.join(quad_filepath, 'tools/token.txt')

# Compute the serving paths
game_filepath = canonicalize_filepath(args.gamepath)
if game_filepath.endswith('.game.json'): game_filepath = os.path.dirname(game_filepath)
webpath_allowlist = [quad_filepath, game_filepath, my_games_filepath]

# Common ancestor
server_root_filepath = os.path.dirname(os.path.commonprefix([os.path.join(p, '') for p in webpath_allowlist]))

# Strip the common prefix
webpath_allowlist = [platform_www_abspath(p) for p in webpath_allowlist]
my_games_filepath = remove_leading_slash(my_games_filepath[len(server_root_filepath):])

# Remove trailing slash
asset_query_webpath = webpath_allowlist[0]
if len(asset_query_webpath) > 0 and asset_query_webpath[-1] == '/': asset_query_webpath = asset_query_webpath[:-1]
game_query_webpath = asset_query_webpath + '/console/games.json'
config_query_webpath = asset_query_webpath + '/console/_config.json'
script_query_webpath = asset_query_webpath + '/console/_scripts.json'
update_progress_query_webpath = asset_query_webpath + '/console/_update_progress.json'
doc_query_webpath = asset_query_webpath + '/console/_docs.json'
quad_filepath = remove_leading_slash(webpath_allowlist[0])
asset_query_webpath += '/console/_assets.json'

maybe_print = ignore if args.quiet else print

if False:
    # Debug paths
    maybe_print("\nserver_root_filepath   = '" + server_root_filepath + "'")
    maybe_print("quad_filepath          = '" + quad_filepath + "'")
    maybe_print("my_games_filepath      = '" + my_games_filepath + "'")
    maybe_print("asset_query_webpath    = '" + asset_query_webpath + "'")
    maybe_print("script_query_webpath   = '" + script_query_webpath + "'")
    maybe_print("doc_query_webpath      = '" + doc_query_webpath + "'")
    maybe_print("game_query_webpath     = '" + game_query_webpath + "'")
    maybe_print("config_query_webpath   = '" + config_query_webpath + "'")
    maybe_print('webpath_allowlist      = ', webpath_allowlist, '\n')
    sys.exit()

# SSL support doesn't work yet due to self-signed certificate problems
useSSL = False

# Runs in a different process
def launchServer(post_token):
    global httpd, args, server_root_filepath, token

    # Assign back to the global token. We have to do this because
    # Python reinitializes all globals
    token = post_token

    # Serve from the common directory, unless the common directory
    # is the filesystem root and we're on windows where that cannot
    # be the cwd
    old_path = os.getcwd()
    if not isWindows or server_root_filepath != '':
        os.chdir(server_root_filepath)

    attempts = 0
    while attempts < 2:
        try:
            attempts += 1
            # '' = 0.0.0.0 = all local IP addresses, needed for
            # supporting devices other than just localhost
            server_address = ('' if args.serve else 'localhost', args.port)
            httpd = QuadplayHTTPServer(server_address, QuadplayHTTPRequestHandler)

            if useSSL:
                # This path is never used in the current implementation
                httpd.socket = ssl.wrap_socket(httpd.socket,
                                               keyfile='console/ssl/local-key.pem',
                                               certfile='console/ssl/local-cert.pem',
                                               do_handshake_on_connect=False,
                                               server_side=True)
            httpd.serve_forever()
            os.chdir(old_path)
            return

        except OSError as e:
            if attempts < 2 and e.errno == 48:
                print('Cleaning up previous quadplay server instance...')

                # Read the old token
                with open(token_absfilepath, 'r') as file: old_token = file.readline()

                # Send a QUIT command via POST
                try:
                    urllib.request.urlopen(urllib.request.Request(
                        'http://localhost:' + str(server_address[1]) + '/',
                        bytes('{"command":"quit","token":"' + old_token + '"}', 'utf-8'),
                        {'Content-Type': 'application/json;charset=UTF-8'}))
                except:
                    # The server on the other side will terminate immediately
                    # (or, it might be unresponsive!)
                    pass

                # Wait for the OS to clean up the listener socket
                time.sleep(0.1)

                # The loop will now make a second attempt
            else:
                maybe_print(e)
                maybe_print('Not starting a local server, since one is already running.');
                os.chdir(old_path)

#######################################################################

def main():
    global webpath_allowlist, server_root_filepath, token

    token = "%0.7X" % random.randrange(0, 0x10000000)

    if not args.quiet:
        if isWindows: os.system('color')
        banner = ['',
            '\033[91m                      ╷       ╷                    ',
            '\033[95m╭───╮ ╷   ╷  ───╮ ╭── │ ╭───╮ │   ───╮ ╷   ╷   ▒▒  ',
            '\033[94m│   │ │   │ ╭── │ │   │ │   │ │  ╭── │ │   │ ▒▒  ▒▒',
            '\033[96m╰── │ ╰───┘ ╰───╯ ╰───╯ │ ──╯ ╰─ ╰───╯ ╰── │   ▒▒  ',
            '\033[37m    ╵                   ╵                ──╯       ','\033[0m']

        for line in banner:
            maybe_print('         ' + line)

    maybe_print('_________________________________________________________________________\n')
    maybe_print('quadplay version ' + installed_quadplay_version['text'] + ' from ' + quadplay_origin + '\n')

    myip = '127.0.0.1'
    if args.serve:
        try:
            maybe_print('Getting IP address...')
            myip = ''

            # just try this first on mac because the other approaches fail so frequently
            if isMacOS:
               myip = (os.popen('ipconfig getifaddr en0').read().strip() or
                       os.popen('ipconfig getifaddr en1').read().strip())
            if not myip: myip = socket.gethostbyname(socket.gethostname())
        except:
            if isMacOS:
                # The above can fail apparently randomly on MacOS (https://bugs.python.org/issue35164)
                # and that has happened to us. This is a workaround:
                maybe_print('gethostbyname failed due to a known MacOS internal error. Falling back to\nipconfig...')

                myip = os.popen('ipconfig getifaddr en0').read().strip()
                if not myip:
                   maybe_print('Could not find en0. Looking for en1...')
                   myip = os.popen('ipconfig getifaddr en1').read().strip()

                if not myip:
                    maybe_print('WARNING: ipconfig could not find a valid en0 or en1 adapter. Server IP address is unknown.')
                    myip = '127.0.0.1'
                else:
                    maybe_print('WARNING: gethostbyname unexpectedly failed. Server IP address is unknown.')
                    myip = '127.0.0.1'

    url = 'http' + ('s' if useSSL else '')+ '://' + myip + ':' + str(args.port)
    if not quad_filepath or quad_filepath[0] != '/': url += '/'
    url += os.path.join(quad_filepath, 'console/app.html?fastReload=1&token=' + token)

    if args.nativeapp: url += '&nativeapp=1'
    if not args.noupdatecheck and not args.kiosk:
        if quadplay_origin == 'development git clone':
            url += '&update=dev'
        elif quadplay_origin == 'git clone':
            url += '&update=git'
        else:
            url += '&update=1'

    # Sanitized username
    url += '&name=' + re.compile('[^A-Za-z0-9_]').sub('', getpass.getuser())

    if args.kiosk:
        url += '&mode=Maximal&kiosk=1'
    elif args.play:
        url += '&mode=Windowed'
    else:
        url += '&IDE=1'

    if args.offline:
        url += '&offline=1'

    if args.gamepath != '':
        t = args.gamepath
        if not t.startswith('http://') and not t.startswith('quad://'):
            t = platform_www_abspath(os.path.abspath(t))
            if t and not isWindows and t[0] != '/': t = '/' + t
        url += '&game=' + t

    if args.serve:
        # Do not support POST in host mode
        maybe_print('Your firewall may need to be configured to load on other devices.\n')
    else:
        url += '&quadserver=1'

    maybe_print('\nServing from:\n\n   ' + url + '\n')

    # Run from the quadplay path
    maybe_print('   quad://  = ' + os.path.join(server_root_filepath, quad_filepath))
    maybe_print('   My games = ' + os.path.join(server_root_filepath, my_games_filepath) + '/')
    maybe_print('   cwd      = ' + remove_trailing_slash(server_root_filepath))
    maybe_print('   args     = ' + str(args), '\n')

    if not args.nativeapp:
        try:
            # For kbhit()
            import msvcrt
        except ImportError:
            import termios, atexit, select
            stdinFd = sys.stdin.fileno()
            new_term = termios.tcgetattr(stdinFd)
            old_term = termios.tcgetattr(stdinFd)
            def set_normal_term():
                termios.tcsetattr(stdinFd, termios.TCSAFLUSH, old_term)

            # New terminal setting unbuffered
            new_term[3] = (new_term[3] & ~termios.ICANON & ~termios.ECHO)
            termios.tcsetattr(stdinFd, termios.TCSAFLUSH, new_term)

            # Support normal-terminal reset at exit
            atexit.register(set_normal_term)

        # Platform independent keyboard key press detection
        def kbhit():
            if isWindows:
                return msvcrt.kbhit()
            else:
                dr, dw, de = select.select([sys.stdin], [], [], 0)
                return dr != []

    httpd = 0

    serverThread = multiprocessing.Process(target=launchServer, args=[token])
    maybe_print('Starting local server thread...')
    serverThread.start()
    time.sleep(1.5)

    if not serverThread.is_alive():
        print('Could not start quadplay server.')
        sys.exit(2)
    else:
        # Store the token so that other servers can force-quit this one
        with open(token_absfilepath, 'wt') as file: file.write(token)

    browser_filepath = None

    # Try to find Edge, then Chromium if they exist because those
    # are the fastest quadplay browsers and support extra kiosk
    # features.
    if isWindows:
        import winreg
        for p in ['MSEdgeHTM', 'ChromeHTML', 'BraveHTML']:
            try:
                command = winreg.QueryValueEx(winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, p + "\\shell\\open\\command", 0, winreg.KEY_READ), "")[0]
                browser_filepath = re.search("\"(.*?)\"", command).group(1)
                if os.path.exists(browser_filepath):
                    break
                else:
                    browser_filepath = None
            except:
                pass
    else:
        if isMacOS:
            browser_list = ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser']
        else: # Linux
            browser_list = ['/usr/bin/chromium-browser']

        for p in browser_list:
            if os.path.exists(p):
                browser_filepath = p
                break

    # Require quotes around URLs so that the shell does not interpret
    # "&" in a URL as a shell option to run in another thread
    #
    # The --start-fullscreen argument works for Edge and Chrome on
    # Windows and macOS. There is no direct command line way to launch
    # Safari or Firefox in fullscreen mode, or to launch in fullscreen
    # using xdg-open on Linux.

    browser_command = ''

    # Monitor the browser and close the server when it exits
    auto_close_server = False

    # We must force a fake user data directory to prevent
    # the chromium script from sharing an existing session, which would cause the
    # call() below to not block: https://askubuntu.com/questions/35392/how-to-launch-a-new-instance-of-google-chrome-from-the-command-line
    # This is also intended to keep processes clear of each other when running multiple instances of quadplay.
    chromium_nativeapp_args = ' --user-data-dir="' + os.path.join(os.path.expanduser('~'), 'quadplay_session') + '" --no-user-gesture-required '
    quadplay_session = os.path.join(os.path.expanduser('~'), 'quadplay_session')
    if not os.path.exists(quadplay_session): os.mkdir(quadplay_session)

    if args.gamepath == '':
        maybe_print('\nLoading default game. You can supply the URL or local relative path to your game on\nthe command line, for example "tools/quadplay-server foo/mygame", to load it directly.\n')

    if isWindows:
        if browser_filepath and args.nativeapp:
            # Use Chrome/Edge if available, in order to present more
            # as a native app.  On recent Windows 10, we can count on
            # Edge being present.
            #
            # Because of the way that windows CMD processes quotes in
            # the command line, it is necessary to put double quotes
            # around the ENTIRE line as well as around the individual
            # arguments when using os.system(), but not for
            # subprocess.call():
            #
            # https://superuser.com/questions/238810/problem-with-quotes-around-file-names-in-windows-command-shell
            browser_command = '"' + browser_filepath + '" -app="' + url + '" ' + chromium_nativeapp_args + (' --start-fullscreen' if args.kiosk else '')
            auto_close_server = True
        else:
            browser_command = 'start "quadplay" "' + url + '"' + (' --start-fullscreen' if args.kiosk else '')
    elif isMacOS:
        if browser_filepath and args.nativeapp:
            auto_close_server = True
            browser_command = '"' + browser_filepath + '" ' + chromium_nativeapp_args + ' -app="' + url + '" ' + (' --start-fullscreen' if args.kiosk else '')
        else:
            browser_command = 'open "' + url + '" ' + (' --args --start-fullscreen' if args.kiosk else '')
    else: # Linux
        if browser_filepath and args.nativeapp:
            browser_command = browser_filepath + ' -app="' + url + '" ' + chromium_nativeapp_args + (' --start-fullscreen' if args.kiosk else '')
            auto_close_server = True
        else:
            browser_command = 'xdg-open "' + url + '"'

    if auto_close_server:
        # Block until the client terminates, but do so in a way that
        # we can interrupt if the server self-terminates first on
        # macOS.
        maybe_print('Launching IDE via ' + browser_command)
        client = subprocess.Popen(shlex.split(browser_command), stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL if args.quiet else None)

        # If another browser process is already running (on Edge Chromium, at least), then
        # the client process may terminate instantly because it is a zygote. Require an explicit
        # close message in this case, which quadplay will send to the server.
        auto_close_with_client = False
        while (not auto_close_with_client or client.poll() == None) and serverThread.is_alive():
            time.sleep(0.2)

        if serverThread.is_alive():
            maybe_print('Client IDE process auto terminated')
        else:
            maybe_print('Server thread auto terminated')

        # Kill the client if it didn't already terminate
        client.terminate()
    else:
        os.system(browser_command)
        # Wait for key stroke
        maybe_print('\n**Press any key to terminate the server when done**\n')
        while not kbhit() and serverThread.is_alive(): time.sleep(0.25)

    maybe_print('\nShutting down...')

    # Attempt to gracefully shut down
    if httpd: threading.Thread(target=lambda : httpd.shutdown())
    time.sleep(1)
    serverThread.terminate()

    return 0

if __name__ == "__main__":
    sys.exit(main())


# -*- coding: utf-8 -*-
import logging

from dataiku.customrecipe import get_recipe_config

from dku_timeseries import ExtremaExtractor
from io_utils import get_input_output
from recipe_config_loading import check_time_column_parameter, check_and_get_groupby_columns, check_python_version, get_extrema_extraction_params

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format='timeseries-preparation plugin %(levelname)s - %(message)s')

check_python_version()

# --- Setup
(input_dataset, output_dataset) = get_input_output()
recipe_config = get_recipe_config()
input_dataset_columns = [column["name"] for column in input_dataset.read_schema()]
check_time_column_parameter(recipe_config, input_dataset_columns)
datetime_column = recipe_config.get('datetime_column')
extrema_column = recipe_config.get('extrema_column')
groupby_columns = check_and_get_groupby_columns(recipe_config, input_dataset_columns)
params = get_extrema_extraction_params(recipe_config)

# --- Run
df = input_dataset.get_dataframe()
extrema_etractor = ExtremaExtractor(params)
output_df = extrema_etractor.compute(df, datetime_column, extrema_column, groupby_columns=groupby_columns)

# --- Write output
output_dataset.write_with_schema(output_df)


#!/usr/bin/env python3
# -*- coding:utf-8 -*-
from gevent.monkey import patch_all
patch_all()

import redis
from gevent.pywsgi import WSGIServer
from flask import Flask, g, request, session
from flask_babel import Babel

from . import settings
from .channel_manager import ChannelManager

app = Flask(__name__)
app.config.from_object(settings)
r = redis.StrictRedis(
    host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB, decode_responses=True)
chan_mgr = ChannelManager(app, r)

babel = Babel(app)

with app.app_context():
    for kwargs in app.config.get("PERSISTENT_CHANNELS", []):
        chan_mgr.new_channel(**kwargs)

@app.before_request
def set_channel_manager():
    g.channel_manager = chan_mgr
    try:
        r.ping()
    except:
        r.connection_pool.reset()
    g.r = r

@babel.localeselector
def get_locale():
    language = request.args.get(
        "lang",
        session.get(
            "language",
            request.accept_languages.best_match(['zh', 'en']),
        ),
    )
    session["language"] = language
    return language

from . import views
from . import webapi
from . import wechat
from . import telegram

def main():
    http_server = WSGIServer(('0.0.0.0', 5000), app)
    print("Serving at 0.0.0.0:5000")
    http_server.serve_forever()

# vim: ts=4 sw=4 sts=4 expandtab


import metagraph as mg
from metagraph import concrete_algorithm, NodeID
from .. import has_cugraph
from typing import Callable, Tuple, Any

if has_cugraph:
    import cugraph
    import cudf
    import cupy
    from .types import CuGraph, CuGraphBipartiteGraph, CuGraphEdgeSet, CuGraphEdgeMap
    from ..cudf.types import CuDFVector, CuDFNodeSet, CuDFNodeMap

    # TODO only works for unweighted graphs ; make this only work for unweighted graphs
    # @concrete_algorithm("centrality.betweenness")
    # def cugraph_betweenness_centrality(
    #         graph: CuGraph,
    #         nodes: mg.Optional[CuDFNodeSet], # TODO verify this is correct
    #         normalize: bool,
    # ) -> CuDFNodeMap:
    #     node_to_score_df = cugraph.betweenness_centrality(graph.value, k=None if nodes is None else nodes.value.tolist(), normalized=False) # TODO use 'weights' param
    #     node_to_score_df = node_to_score_df.set_index("vertex")
    #     return CuDFNodeMap(node_to_score_df, "betweenness_centrality",)

    @concrete_algorithm("clustering.connected_components")
    def connected_components(graph: CuGraph) -> CuDFNodeMap:
        series = cugraph.weakly_connected_components(graph.value).set_index("vertices")[
            "labels"
        ]
        return CuDFNodeMap(series)

    @concrete_algorithm("clustering.strongly_connected_components")
    def strongly_connected_components(graph: CuGraph) -> CuDFNodeMap:
        series = cugraph.strongly_connected_components(graph.value).set_index(
            "vertices"
        )["labels"]
        return CuDFNodeMap(series)

    @concrete_algorithm("centrality.pagerank")
    def cugraph_pagerank(
        graph: CuGraph, damping: float, maxiter: int, tolerance: float
    ) -> CuDFNodeMap:
        pagerank = cugraph.pagerank(
            graph.value, alpha=damping, max_iter=maxiter, tol=tolerance
        ).set_index("vertex")["pagerank"]
        return CuDFNodeMap(pagerank)

    @concrete_algorithm("traversal.bfs_iter")
    def breadth_first_search(
        graph: CuGraph, source_node: NodeID, depth_limit: int
    ) -> CuDFVector:
        bfs_df = cugraph.bfs(graph.value, source_node)
        bfs_df = bfs_df[bfs_df.predecessor.isin(bfs_df.vertex) | (bfs_df.distance == 0)]
        bfs_ordered_vertices = bfs_df.sort_values("distance").vertex.reset_index(
            drop=True
        )
        return CuDFVector(bfs_ordered_vertices)

    @concrete_algorithm("clustering.triangle_count")
    def cugraph_triangle_count(graph: CuGraph) -> int:
        return cugraph.triangles(graph.value) // 3

    # TODO cupy.ufunc's reduce not supported https://docs.cupy.dev/en/stable/reference/ufunc.html#universal-functions-ufunc
    # @concrete_algorithm("util.graph.aggregate_edges")
    # def cugraph_graph_aggregate_edges(
    #     graph: CuGraph,
    #     func: Callable[[Any, Any], Any],
    #     initial_value: Any,
    #     in_edges: bool,
    #     out_edges: bool,
    # ) -> CuDFNodeMap:
    #     pass

    @concrete_algorithm("util.graph.filter_edges")
    def cugraph_graph_filter_edges(
        graph: CuGraph, func: Callable[[Any], bool]
    ) -> CuGraph:
        new_g = cugraph.DiGraph() if graph.value.is_directed() else cugraph.Graph()
        # TODO do something more optimal when there's an adjlist and no edgelist
        df = graph.value.view_edge_list()
        keep_mask = df["weights"].applymap(func).values
        # TODO reset_index is workaround for https://github.com/rapidsai/cugraph/issues/1080
        keep_df = df.iloc[keep_mask].reset_index(drop=True)
        new_g.from_cudf_edgelist(
            keep_df, source="src", destination="dst", edge_attr="weights"
        )
        # TODO handle node weights
        graph_nodes = cudf.concat([df["src"], df["dst"]]).unique()
        new_g_nodes = cudf.concat([keep_df["src"], keep_df["dst"]]).unique()
        any_nodes_lost = (~graph_nodes.isin(new_g_nodes)).any()
        return CuGraph(new_g, graph_nodes)

    def _assign_uniform_weight(
        input_graph: cugraph.Graph, default_value: Any
    ) -> cugraph.Graph:
        g = cugraph.DiGraph() if input_graph.is_directed() else cugraph.Graph()
        if input_graph.edgelist is not None:
            # TODO avoid copying the 'src' and 'dst' columns twice
            gdf = input_graph.view_edge_list().copy()
            gdf["weight"] = cupy.full(len(gdf), default_value)
            g.from_cudf_edgelist(
                gdf, source="src", destination="dst", edge_attr="weight"
            )
        else:
            offset_col, index_col, _ = g.view_adj_list()
            value_col = cudf.Series(cupy.full(len(offset_col), default_value))
            g.from_cudf_adjlist(offset_col, index_col, value_col)
        return g

    @concrete_algorithm("util.edgemap.from_edgeset")
    def cugraph_edge_map_from_edgeset(
        edgeset: CuGraphEdgeSet, default_value: Any
    ) -> CuGraphEdgeMap:
        g = _assign_uniform_weight(edgeset.value, default_value)
        return CuGraphEdgeMap(g)

    @concrete_algorithm("util.graph.assign_uniform_weight")
    def cugraph_graph_assign_uniform_weight(graph: CuGraph, weight: Any) -> CuGraph:
        new_edges = _assign_uniform_weight(graph.value, weight)
        new_nodes = None if graph.nodes is None else graph.nodes.copy()
        return CuGraph(new_edges, new_nodes, graph.has_node_weights)

    @concrete_algorithm("util.graph.build")
    def cugraph_graph_build(
        edges: mg.Union[CuGraphEdgeSet, CuGraphEdgeMap],
        nodes: mg.Optional[mg.Union[CuDFNodeSet, CuDFNodeMap]],
    ) -> CuGraph:
        return CuGraph(edges.value, nodes.value, isinstance(nodes, CuDFNodeMap))

    @concrete_algorithm("bipartite.graph_projection")
    def graph_projection(bgraph: CuGraphBipartiteGraph, nodes_retained: int) -> CuGraph:
        g = cugraph.DiGraph() if bgraph.value.is_directed() else cugraph.Graph()
        two_hop_neighbors_df = bgraph.value.get_two_hop_neighbors()
        nodes_to_keep = bgraph.value.sets()[nodes_retained]
        keep_mask = two_hop_neighbors_df.first.isin(nodes_to_keep)
        edge_list_df = two_hop_neighbors_df[keep_mask]
        g.from_cudf_edgelist(edge_list_df, source="first", destination="second")
        nodes = nodes_to_keep.set_index(nodes_to_keep)
        return CuGraph(g, nodes=nodes)

    @concrete_algorithm("clustering.louvain_community")
    def cugraph_louvain_community(graph: CuGraph) -> Tuple[CuDFNodeMap, float]:
        label_df, modularity_score = cugraph.louvain(graph.value)
        label_series = label_df.set_index("vertex")["partition"]
        orphan_mask: cupy.ndarray = ~graph.nodes.index.isin(label_series.index)
        orphan_nodes = graph.nodes.index[orphan_mask]
        orphan_count = orphan_mask.astype(int).sum().item()
        max_label = label_df.index.max()
        orphan_labels = cupy.arange(max_label + 1, max_label + 1 + orphan_count)
        orphan_series = cudf.Series(orphan_labels, index=orphan_nodes)
        label_series = cudf.concat([label_series, orphan_series])
        # TODO more orphan should worsen the modularity score but do not in this implementation
        return (
            CuDFNodeMap(label_series),
            modularity_score,
        )


import os
import sys
import json

def convert_notebook_to_script(notebook_dir):
    # Remove the .ipynb extension if it is present
    if notebook_dir.endswith(".ipynb"):
        notebook_dir = os.path.splitext(notebook_dir)[0]

    # Check if the notebook file exists
    notebook_file = notebook_dir + ".ipynb"
    if not os.path.isfile(notebook_file):
        print(f"Notebook file {notebook_file} does not exist.")
        return

    # Load the notebook as a JSON object
    with open(notebook_file, "r") as f:
        notebook = json.load(f)

    # Extract the code cells
    code_cells = [cell for cell in notebook["cells"] if cell["cell_type"] == "code"]

    # Create a Python script file with the same name as the notebook
    script_file = notebook_dir + ".py"

    # Write the code cells to the Python script file
    with open(script_file, "w") as f:
        for cell in code_cells:
            # Concatenate all the strings in the cell["source"] list
            source = "".join(cell["source"])
            if source.strip() == "":
                continue
            f.write(source)
            f.write("\n\n")

    print(f"Notebook {notebook_file} converted to Python script {script_file}.")

if __name__ == "__main__":
    # Get the directory of the notebook file from the command line argument
    if len(sys.argv) != 2:
        print("Usage: python notebook_to_script.py <notebook_directory>")
        sys.exit(1)
    notebook_dir = sys.argv[1]

    # Convert the notebook to a Python script
    convert_notebook_to_script(notebook_dir)


# collections 集合模块 提供了很多 集 合 类

# 1.namedtuple (tuple变形)
from collections import namedtuple
# namedtuple是一个函数 用来创建一个自定义的tuple对象 并且规定了tuple元素的个数
# 通过属性名进行输出
Point=namedtuple('Point',['x','hhhh'])
Point2=namedtuple('Point2',['x','y','z'])
p2=Point2(1,2,3)
p=Point(1,2)
print(p.x)
print(p.hhhh)
print(p2.x)
print(p2.y)
print(p2.z)
# 我们想 这样用namedtuple很方便的定义一种数据类型
# 首先具备tuple的不变性 其次可以通过属性引用
Circle=namedtuple('Circle',['X','Y','Z'])

# 2.deque（list变形（list索引访问元素 插入删除元素很慢））
# (双向列表 适用于队列和栈)
from collections import  deque
q=deque(['a','b','c'])
q.append('x')
q.appendleft('y')
q.pop()
q.popleft()
print(q)

# 3.defaultdict（dict变形（使用dict时 如果Key不存在 你还去引用它 会抛出KeyError））
# （Key不存在 返回一个默认值）
from collections import  defaultdict
dd = defaultdict(lambda :'N/A')
dd['key1']='abc'
print(dd['key1'])
print(dd['key2'])
# 输出'N/A' 默认值调用函数时返回 函数在创建defaultdict对象时传入
# 它只是dict的拓展 不传入默认值的时候跟dict一样

# 4.OrderedDict（有序的dict）
from collections import OrderedDict
# dict的key 是无序的
d = dict([('a', 1), ('b', 2), ('c', 3)])
print(d)
# OrderedDict的key 是有序的
od = OrderedDict([('a', 1), ('b', 2), ('c', 3)])
print(od)
# 例子 实现一个FIFO的dict（先进先出）容量超出限制，最早删除最早添加的Key
class LastUpdatedOrderedDict(OrderedDict):
    def __init__(self,capacity):
        super(LastUpdatedOrderedDict,self).__init__()
        self._capccity=capacity
    def __setitem__(self, key, value):
        # 按一定的方式存储和key相关的value
        # __setitem__是在设置类实例属性(类似a['k']='kk'之类的)的时候调用的
        # __getitem__是在调用类实例属性(类似t=a['k']之类的)的时候被调用的
        # https://blog.csdn.net/weixin_42557907/article/details/81589691
        containsKey=1 if key in self else 0
        # 这语句真妙。。。 完全保证集合容量不超范围 并且对于下面添加元素也有帮助
        if len(self)-containsKey>=self._capccity:
            last=self.popitem(last=False)
            # 删除用popitem函数 并且设置参数last=False大概意思是删除第一个吧
            print('Romove',last)
        if containsKey:
            # 集合中有这个key-value 进行更新
            del self[key]
            print('set:',(key,value))
        else:
            print('add:',(key,value))
        OrderedDict.__setitem__(self,key,value)

#5.ChainMap（一组dict串起来组成一个逻辑上的dict 它本身也是一个dict）
# （查找的时候 会按照顺序在内部的dict依次查找）
# （一连串的dict 这么理解）
# 应用程序往往需要传入参数 参数可以通过命令行传入 看可以通过环境变量传入 还可以有默认参数
# 使用ChainMap实现参数的优先级查找
# 先查找命令行参数->(如果没有传入)查找环境变量—>(如果没有)使用默认参数

# ChainMap用法详见 !!关于命令行.py!!

# 6.Counter（计数器 可以统计字符出现的个数）
# 本质也是dict的子类
from collections import Counter
d=Counter('aababc')
print(d)
c=Counter()
for ch in 'programming':
    c[ch] = c[ch] + 1
print(c)
c.update("hello")
print(c)


import numpy as np
from flask import Flask, request, render_template
import pickle

app = Flask(__name__)
model = pickle.load(open('model.pkl', 'rb'))

@app.route('/')
def home():
    return render_template('index.html')

@app.route('/predict',methods=["POST"])
def predict():
    '''
    For rendering results on HTML GUI
    '''
    int_features = [int(x) for x in request.form.values()]
    final_features = [np.array(int_features)]
    prediction = model.predict(final_features)

    if prediction == 0:
        output = "Extremely Weak. Please go to a doctor to check."
    elif prediction == 1:
        output = "Weak"
    elif prediction == 2:
        output = "Normal"
    elif prediction == 3:
        output = "Overweight"
    elif prediction == 4:
        output = "Obesity. Please go to a doctor to check."
    elif prediction == 5:
        output = "Extreme Obesity. Please go to a doctor to check."
    return render_template("index.html", prediction_text = output)

if __name__ == "__main__":
    app.run(port = 5000, debug=True)


from urllib.parse import urlparse

from normalizers.registry import (
    register_facets_normalizer,
    register_nlp_preprocessor,
)
from normalizers.lib.normalizers import (
    update_from_theme_taxonomy,
    common_normalizer,
    check_blacklist_whitelist,
    add_counts,
)
from normalizers.lib.nlp import common_preprocess
import logging

logger = logging.getLogger(__file__)

@register_facets_normalizer("eionet")
def normalize_eionet(doc, config):
    logger.info("NORMALIZE EIONET")
    logger.info(doc["raw_value"].get("@id", ""))
    logger.info(doc["raw_value"].get("@type", ""))
    logger.info(doc)
    ct_normalize_config = config["site"].get("normalize", {})

    if not check_blacklist_whitelist(
        doc,
        ct_normalize_config.get("blacklist", []),
        ct_normalize_config.get("whitelist", []),
    ):
        logger.info("blacklisted")
        return None
    logger.info("whitelisted")

    normalized_doc = common_normalizer(doc, config)
    if not normalized_doc:
        return None

    normalized_doc["issued"] = doc["raw_value"].get("publication_date", normalized_doc.get("issued"))

    normalized_doc["cluster_name"] = "etc"

    doc_loc = urlparse(normalized_doc["id"]).path
    doc_loc_parts = doc_loc.strip("/").split("/")
    if doc_loc_parts[0] == "etcs" and len(doc_loc_parts) > 1:
        if doc_loc_parts[1] == "etc-atni":
            #            normalized_doc["cluster_name"] = doc_loc_parts[1]
            normalized_doc["topic"] = ["term2", "term29"]

        if doc_loc_parts[1] == "etc-bd":
            #            normalized_doc["cluster_name"] = doc_loc_parts[1]
            normalized_doc["topic"] = ["term4"]

        if doc_loc_parts[1] == "etc-cca":
            #            normalized_doc["cluster_name"] = doc_loc_parts[1]
            normalized_doc["topic"] = ["term10"]

        if doc_loc_parts[1] == "etc-cme":
            #            normalized_doc["cluster_name"] = doc_loc_parts[1]
            normalized_doc["topic"] = ["term11", "term14"]

        if doc_loc_parts[1] == "etc-icm":
            #            normalized_doc["cluster_name"] = doc_loc_parts[1]
            normalized_doc["topic"] = ["term45", "term34"]

        if doc_loc_parts[1] == "etc-uls":
            #            normalized_doc["cluster_name"] = doc_loc_parts[1]
            normalized_doc["topic"] = ["term23", "term35"]

        if doc_loc_parts[1] == "etc-wmge":
            #            normalized_doc["cluster_name"] = doc_loc_parts[1]
            normalized_doc["topic"] = ["term44"]

    normalized_doc["topic"] = update_from_theme_taxonomy(
        normalized_doc["topic"],
        config.get("full_config", {}).get("theme_taxonomy", {}),
    )

    normalized_doc = add_counts(normalized_doc)
    return normalized_doc

@register_nlp_preprocessor("eionet")
def preprocess_eionet(doc, config):
    dict_doc = common_preprocess(doc, config)

    return dict_doc


"""
Gently warm the caches of tartanregister.gov.uk.
"""
import argparse
import json
import os
import random
import shutil
import time

import requests

from tartangan.utils.fs import maybe_makedirs

def scrape_tartans(args):
    """
    Slowly, serially download images so as not to wear out our welcome.
    """
    maybe_makedirs(args.output_path, exist_ok=True)
    print('Scraping tartans')
    # prepare list of ids to scrape, possibly resume work
    ids_to_scrape = load_state(args.state)
    if ids_to_scrape is None:
        ids_to_scrape = list(range(1, args.max_id))
        random.shuffle(ids_to_scrape)
        errors = []
    else:
        errors = load_state(args.errors) or []
    num_processed = 0
    while ids_to_scrape:
        page_id = ids_to_scrape.pop()
        url = args.url_template.format(
            page_id=page_id, width=args.size, height=args.size
        )
        print(url)
        filename = os.path.join(args.output_path, f'{page_id}.jpg')
        error = download_image_url(url, filename)
        if error:
            errors.append([page_id, error])
            print(error)
        num_processed += 1
        if num_processed % args.save_state_freq == 0:
            save_state(ids_to_scrape, args.state)
            save_state(errors, args.errors)
        # we're decent people who just want some images
        time.sleep(args.sleep)

def load_state(filename):
    if not os.path.exists(filename):
        return None
    with open(filename, 'r') as infile:
        state = json.load(infile)
    return state

def save_state(state, filename):
    with open(filename, 'w') as outfile:
        json.dump(state, outfile)

def download_image_url(url, output_filename):
    """
    https://stackoverflow.com/questions/13137817/how-to-download-image-using-requests
    """
    res = requests.get(url, stream=True)
    if res.status_code == 200 and res.headers['content-type'] in ('image/jpeg',):
        with open(output_filename, 'wb') as outfile:
            res.raw.decode_content = True
            shutil.copyfileobj(res.raw, outfile)
    else:
        content = res.content.decode('utf-8')
        if 'The tartan details provided cannot be converted' in content:
            return [res.status_code, 'Tartan not found']
        return [res.status_code, content]

def main():
    p = argparse.ArgumentParser()
    p.add_argument('output_path')
    p.add_argument(
        '--url-template',
        default='https://www.tartanregister.gov.uk/imageCreation?ref={page_id}&width={width}&height={height}'
    )
    p.add_argument('--state', default='scraper_state.json')
    p.add_argument('--errors', default='scraper_errors.json')
    p.add_argument('--size', type=int, default=750)
    p.add_argument('--sleep', type=float, default=1.)
    p.add_argument('--save-state-freq', type=int, default=5)
    p.add_argument('--max-id', type=int, default=12698)
    args = p.parse_args()
    scrape_tartans(args)

if __name__ == '__main__':
    main()


from typing import List

def asc(inpt : str) -> str:
    A=[]
    for k in inpt:
        A.append(k)
    B=[]
    C=[]
    D=[]
    X=""
    for i in A:
        if i=="1" or i=="2" or i=="3" or i=="4" or i=="5" or i=="6" or i=="7" or i=="8" or i=="9" or i=="0":
            c=0
            for j in range(0,len(A)):
                if i==A[j]:
                    c+=1
            if c%2!=0:
                B.append(int(i))
            else:
                C.append(i)
        else:
            C.append(i)
    B.sort()
    for n in B:
        D.append(str(n))
    D.extend(C)
    for m in D:
        X+=m
    return X

def sortings(lst : List[str]) -> List[str]:
    L=[]
    l=[]
    M=[]
    N=[]
    n=[]
    P=[]
    Q=[]
    for i in lst:
        L.append(asc(i))
    for j in L:
        c=0
        for b in range(0,26):
            if (chr(b+65) in j) or (chr(b+97) in j):
                c+=1
        if c==0 and j!='':
            M.append(j)
        elif j=='':
            M.append(None)
        else:
            for k in range(0,len(j)):
                if j[k]!="0" and j[k]!="1" and j[k]!="2" and j[k]!="3" and j[k]!="4" and j[k]!="5" and j[k]!="6" and j[k]!="7" and j[k]!="8" and j[k]!="9":
                    if k==0:
                        M.append(None)
                        break
                    else:
                        M.append(j[0:k])
                        break
    for a in range(0,len(M)):
        if type(M[a])==str:
            N.append(int(M[a]))
            l.append(M[a])
        else:
            l.append("-1")
    N.sort()
    for d in range(0,len(N)):
        for e in range(0,len(M)):
            if ((N[d])==int(l[e])) and (e not in n):
                n.append(e)
    for f in n:
        P.append(L[f])
    for g in L:
        if g not in P:
            Q.append(g)
    P.extend(Q)
    return P

if __name__=="__main__":
    lst = []
    num = int(input())
    for i in range(num):
        inpt = input()
        lst.append(inpt)
    lst = sortings(lst)
    print (lst)


import magic

def get_mime(filepath):
    mime = magic.Magic(mime=True)
    return mime.from_file(filepath)

def get_mime_memory_file(file):
    initial_pos = file.tell()
    file.seek(0)
    mime_type = magic.from_buffer(file.read(1024), mime=True)
    file.seek(initial_pos)
    return mime_type


from py_agent.credentials import get_credential
from py_agent.utils import listen_for
import dropbox
import re

@listen_for({'event_type': ('==', 'archive_article')})
def add_article_to_dropbox(event=None, handler=None):
    print("adding article")
    dbx = dropbox.Dropbox(get_credential("dropbox"))
    data = event.data
    markdown = []

    markdown.append('## ' + data['title'])

    markdown.append('Category: #' + data['category'].lower().replace(" ", "_"))

    markdown.append('**Keywords**: ' + ', '.join(data['keywords']))
    markdown.append('url: ' + data['url'])
    if len(data['notes']) > 0:
        markdown.append('### Notes')
        for note in data['notes']:
            markdown.append('- ' + note)

    markdown.append('### Summary')
    markdown.append(data['summary'])

    sanitized_title = re.sub(r"[^a-zA-Z0-9]+", ' ', data['title'])
    upload_path='/notes/articles/{}/{}.md'.format(data['category'], sanitized_title)

    existing_file = None
    try:
        # if the file doesn't exist, the api throws an exception
        f = dbx.files_download(upload_path)[1]
        existing_file = f.content.decode('utf-8')
    except:
        pass

    to_upload = '\n\n'.join(markdown)
    if existing_file:
        to_upload = existing_file + '\n\n\n\n' + to_upload

    dbx.files_upload(to_upload.encode('utf-8'),
        upload_path, mode=dropbox.files.WriteMode.overwrite)


import httpx

from proxy import TestCase

class TestHttp2WithProxy(TestCase):

    def test_http2_via_proxy(self) -> None:
        assert self.PROXY
        response = httpx.get(
            'https://httpbin.org/get',
            headers={'accept': 'application/json'},
            verify=httpx.create_ssl_context(http2=True),
            timeout=httpx.Timeout(timeout=5.0),
            proxies={
                'all://': 'http://localhost:%d' % self.PROXY.flags.port,
            },
        )
        self.assertEqual(response.status_code, 200)

    # def test_http2_streams_over_proxy_keep_alive_connection(self) -> None:
    #     pass


import sys
from PyQt5.QtWidgets import QApplication, QWidget, QDesktopWidget, QGridLayout, QLabel
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtCore import Qt

INITIAL_SPELL = ["flash", "flash", "flash", "flash", "flash", "teleport", "smite", "ignite", "heal", "exhaust"]
SPELL_LIST = ["flash", "ghost", "teleport", "smite", "ignite", "heal", "cleanse", "barrier", "exhaust"]

class MainWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.spell_list = []
        self.rune_list = []
        self.sub_window = None
        self.init_ui()

    def init_ui(self):
        self.setWindowTitle('LOL SpellTimer')
        self.setWindowIcon(QIcon('image/icon.webp'))
        self.setFixedSize(450, 450)
        self.center()
        self.setStyleSheet("background-color: #060A33;")

        grid = QGridLayout()
        self.setLayout(grid)

        self.add_text(grid, 'SPELL 1', 0, 1)
        self.add_text(grid, 'SPELL 2', 0, 2)
        self.add_text(grid, 'RUNE', 0, 3)

        self.add_image(grid, 'lane/top.png', 1, 0)
        self.add_image(grid, 'lane/jgl.png', 2, 0)
        self.add_image(grid, 'lane/mid.png', 3, 0)
        self.add_image(grid, 'lane/bot.png', 4, 0)
        self.add_image(grid, 'lane/sup.png', 5, 0)

        for i in range(10):
            spell_label = SpellQLabel(self, i, INITIAL_SPELL[i])
            self.spell_list.append(spell_label)
            grid.addWidget(spell_label, i%5 + 1, i//5 + 1, Qt.AlignCenter)

        for i in range(1, 6):
            rune_label = RuneQLabel()
            self.rune_list.append(rune_label)
            grid.addWidget(rune_label, i, 3, Qt.AlignCenter)
        self.show()

    def center(self):
        qr = self.frameGeometry()
        cp = QDesktopWidget().availableGeometry().center()
        qr.moveCenter(cp)
        self.move(qr.topLeft())

    def add_text(self, layout, text, x, y):
        label = QLabel(text, self)
        label.setStyleSheet("color: white;")
        label_font = label.font()
        label_font.setFamily('Bahnschrift Condensed')
        label_font.setPointSize(14)
        label.setFont(label_font)
        layout.addWidget(label, x, y, Qt.AlignCenter)

    def add_image(self, layout, img_name, x, y):
        label = QLabel()
        label.setPixmap(QPixmap('image/' + img_name))
        layout.addWidget(label, x, y, Qt.AlignCenter)

    def open_sub_window(self, i):
        if self.sub_window is None:
            self.sub_window = SubWindow(self, i)
            self.sub_window.show()
        else:
            self.sub_window.close()
            self.sub_window = None

    def change_spell(self, i, new_spell):
        old_spell = self.spell_list[i].get_spell()
        pair_spell_label = self.spell_list[(i+5) % 10]
        if pair_spell_label.get_spell() == new_spell:
            pair_spell_label.set_spell(old_spell)
        self.spell_list[i].set_spell(new_spell)
        self.sub_window.close()
        self.sub_window = None

class RuneQLabel(QLabel):
    def __init__(self):
        super().__init__()
        self.rune_on = False
        self.setPixmap(QPixmap('image/rune/rune_off.png'))

    def mousePressEvent(self, event):
        if self.rune_on:
            self.setPixmap(QPixmap('image/rune/rune_off.png'))
        else:
            self.setPixmap(QPixmap('image/rune/rune_on.png'))
        self.rune_on = not self.rune_on

    def rune_status(self):
        return self.rune_on

class SpellQLabel(QLabel):
    def __init__(self, main_window, idx, init_spell):
        super().__init__()
        self.main_window = main_window
        self.idx = idx
        self.set_spell(init_spell)

    def set_spell(self, spell):
        self.spell = spell
        self.setPixmap(QPixmap('image/spell/' + spell + '.png'))

    def get_spell(self):
        return self.spell

    def mousePressEvent(self, event):
        self.main_window.open_sub_window(self.idx)

class SubWindow(QWidget):
    def __init__(self, main_window, i):
        super().__init__()
        self.main_window = main_window
        self.idx = i
        self.init_ui()

    def init_ui(self):
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
        self.setFixedSize(160, 160)
        self.setStyleSheet("background-color: #34495E;")
        self.move_ui()

        grid = QGridLayout()
        self.setLayout(grid)
        for i in range(9):
            spell_label = SpellSubQLabel(self, SPELL_LIST[i])
            grid.addWidget(spell_label, i // 3, i % 3, Qt.AlignCenter)

    def move_ui(self):
        main_pos = self.main_window.pos()
        dx_list = [171, 278]
        dy_list = [146, 217, 289, 360, 432]
        self.move(main_pos.x() + dx_list[self.idx // 5], main_pos.y() + dy_list[self.idx % 5])

    def change_spell(self, new_spell):
        self.main_window.change_spell(self.idx, new_spell)

class SpellSubQLabel(QLabel):
    def __init__(self, sub_window, spell):
        super().__init__()
        self.sub_window = sub_window
        self.spell = spell
        self.setPixmap(QPixmap('image/spell/' + spell + '.png'))

    def mousePressEvent(self, event):
        self.sub_window.change_spell(self.spell)

if __name__ == "__main__":
    q_app = QApplication(sys.argv)
    main_window = MainWindow()
    main_window.show()
    sys.exit(q_app.exec_())