TrishGillett/pysdpt3glue

View on GitHub
sdpt3glue/solve_neos.py

Summary

Maintainability
A
2 hrs
Test Coverage
#
# sdpt3glue/solve_neos.py
#
# Copyright (c) 2016 Trish Gillett-Kawamoto
#
# This software is released under the MIT License.
#
# http://opensource.org/licenses/mit-license.php
#
"""
Basic scraper/webdriver to submit an sdpt3 problem on the NEOS webpage.
This version uses selenium because difficulties were encountered using XML-RPC
for problems of this type.
"""

import os
import sys
import contextlib
from time import sleep
import xmlrpclib
from xmlrpclib import Fault
from xmlrpclib import ProtocolError
from selenium import webdriver
from selenium.common.exceptions import WebDriverException


WEBDRIVERS = {
    "Firefox": webdriver.Firefox,
    "PhantomJS": webdriver.PhantomJS
}
""" Webdrivers to try using. """


def _print_fault(err, space=2):
    """ Print message from xmlrpclib.Fault.

    Args:
      err: xmlrpclib.Fault error.
      space: Space size to be added before each message.
    """
    print (
        "{indent}A fault occurred\n"
        "{indent}Fault code: {code}\n"
        "{indent}Fault string: {msg}"
    ).format(code=err.faultCode, msg=err.faultString, indent=" " * space)


def _print_protocol_error(err, space=2):
    """ Print message from xmlrpclib.ProtocolError.

    Args:
      err: xmlrpclib.ProtocolError error.
      space: Space size to be added before each message.
    """
    print (
        "{indent}A protocol error occurred\n"
        "{indent}URL: {url}\n"
        "{indent}HTTP/HTTPS headers: {headers}\n"
        "{indent}Error code: {code}\n"
        "{indent}Error message: {msg}\n"
    ).format(
        url=err.url, msg=err.errmsg, headers=err.headers,
        code=err.errcode, indent=" " * space)


@contextlib.contextmanager
def _get_driver():
    """ Try to make a webdriver according to installed browsers.

    Returns:
      Installed webdriver.

    Raises:
      RuntimeError: When no supported browsers are available.
    """
    for name, driver in WEBDRIVERS.items():
        try:
            res = driver()

        except (StandardError, WebDriverException) as e:
            sys.stderr.write(
                "{0} seems to be not installed: {1}\n".format(name, e))

        else:
            try:
                yield res
            finally:
                res.close()
                res.quit()
            return

    raise WebDriverException("No web drivers are available.")


class NeosError(Exception):
    """ Error caused by Neos server.

    This error is raised when an error occurs during calling Neos.
    """
    pass


def neos_solve(
        matfile_target, discard_matfile=True, no_prompt=False,
        return_id_pwd=False, **_):
    '''
    Submits the Sedumi format .mat file to be solved on NEOS with SDPT3.
    Returns the solve result message from NEOS.

    If no_prompt is True, it won't ask for id and password manually and raise
    NeosError when some errors occur during connection to neos server.

    Raises:
      NeosError: When an error occurs by using Neos server.
    '''
    jobid, pwd = handle_submission(matfile_target, no_prompt=no_prompt)

    neos_int = NeosInterface()
    msg = neos_int.track_and_return(jobid, pwd)

    # Cleanup
    if discard_matfile:
        os.remove(matfile_target)

    if return_id_pwd:
        return msg, jobid, pwd
    else:
        return msg


def handle_submission(matfile_target, no_prompt=False):
    '''
    Submits the Sedumi format .mat file to be solved on NEOS with SDPT3.

    Args:
       matfile_target: the path of the .mat file containing the problem data.

    Returns:
       no_prompt: If False, in the event that problem submission fails the
       user may be prompted to manually submit the problem on the website and
       copy-paste the job ID and password information into a prompt.  If True,
       an error will thrown rather than prompting occurring.  Make sure to use
       True if running headlessly or otherwise unmonitored.

    Raises:
      NeosError: When an error occurs by using Neos server.
    '''
    if not os.path.exists(matfile_target):
        raise ValueError(
            "The matfile {0} doesn't exist".format(matfile_target))

    # any backslashes need to be doubly escaped for the web form
    matfile_target = matfile_target.replace('\\', '\\\\')

    try:
        with _get_driver() as browser:
            browser.get(
                'http://www.neos-server.org/neos/solvers/sdp:sdpt3/MATLAB_BINARY.html')

            # Find the .mat upload box and input the path to ours
            file_upload_element = browser.find_element_by_name("field.2")
            file_upload_element.send_keys(matfile_target)
            assert file_upload_element.get_attribute('value'), \
                "Couldn't input file name, are you sure it exists?"

            # Find the submit button and click it
            submit_xpath = '//input[@type="submit"]'
            submit_button = browser.find_element_by_xpath(submit_xpath)
            submit_button.click()

            # After a few moments, the id and password will appear, and
            # we'll try to grab them automatically.
            source = browser.page_source
            jobid, pwd = extract_id_pwd(source)

    except WebDriverException as e:
        if no_prompt:
            raise NeosError(e)

        # If that fails for any reason, we ask the user to submit the problem
        # manually and copy-paste the lines giving the id and password.
        try:
            jobid, pwd = ask_user_to_submit(matfile_target)

        except EOFError as e:
            raise NeosError(e)

    return jobid, pwd


def extract_id_pwd(source):
    '''
    Given a snippet of the form

    .. code-block:: none

        Job#     : xxxxxxx
        Password : yyyyyyyy

    extracts and returns the strings for job ID and password

    '''
    start = source.find('Job#')
    snippet = source[start:start + 60]
    lines = snippet.split('\n')

    jobid_line = lines[0].strip().split()
    pwd_line = lines[1].strip().split()

    jobid = int(jobid_line[-1])
    pwd = pwd_line[-1]
    return jobid, pwd


def ask_user_to_submit(matfilepath):
    '''
    Instructs the user to manually submit their problem and then feed the ID
    and password back into the program.

    Raises:
      EOFError: When a user sends EOF.
    '''
    print (
        "Selenium submission failed, submit it manually!"
        "Find this files and submit them on the website:"
        "{0}"
        "Once you've submitted your problem, copy and paste the"
        "two lines that look like this"
        "        Job#     : xxxxxxx"
        "        Password : yyyyyyyy"
        "below and hit Enter. Or, you can just enter the job and"
        "password strings on consecutive lines.  Good luck!"
    ).format(matfilepath)

    user_input = ''
    while True:

        while not user_input:
            user_input = raw_input()

        try:
            jobid = int(user_input.strip().split()[-1])
            pwd = raw_input().strip().split()[-1]
            print "\n=============\n"
            return jobid, pwd

        except IndexError:
            print "\nTry again!\n"


class NeosInterface(object):
    """
    An abstract class for connections with the remote NEOS Server for
    Optimization. NeosInterface objects communicate with the NEOS Server via
    XML-RPC. This class wraps the server's services into a few convenient
    methods which, for the time being, are designed with the solution of AMPL
    models in mind.
    """

    def __init__(self, neos_host=None, neos_port=None):
         # Go to xmlrpc and flip on __verbose if you are debugging
         # and want to see ALL xml-rpc communication
        if not neos_host:
            neos_host = "neos-server.org"
        if not neos_port:
            neos_port = 3333

        neos_url = "https://{host}:{port}".format(
            host=neos_host, port=neos_port)
        self.server = xmlrpclib.ServerProxy(neos_url)

    def track_and_return(self, jobid, pwd):
        '''
        Takes a jobid and password for a solve already in progress,
        waits for its status to change to 'Done', and returns the message,
        jobid, and password.
        '''
        k = 20
        while True:

            try:
                status = self.server.getJobStatus(jobid, pwd)

            except Fault as err:
                _print_fault(err)
                print "  Error checking status: {0}  (will try again)".format(
                    sys.exc_info()[0])

            except ProtocolError as err:
                _print_protocol_error(err)
                print "  Error checking status: {0}  (will try again)".format(
                    sys.exc_info()[0])

            else:
                print status
                if status == "Done":
                    break

            sleep(k)

        msg = self.get_final_results(jobid, pwd).data
        return msg

    def get_final_results(self, jobid, pwd):
        '''
        This funtion is 'stubborn', meaning that if it's unable to get the
        results it will wait a few seconds and try again.  Stubborn
        functionality is intended to smooth over lost internet connections or
        computers that may fall asleep and then wake up and resume running
        code.
        '''
        while True:
            try:
                return self.server.getFinalResults(jobid, pwd)
            except Fault as err:
                _print_fault(err)
            except ProtocolError as err:
                _print_protocol_error(err)

            sleep(3)