├── tests ├── __init__.py ├── uploaded_ ├── ghost ├── requirements.txt ├── static │ ├── blackhat.jpg │ ├── foo.tar.gz │ ├── styles.css │ └── app.js ├── templates │ ├── echo.html │ ├── many_assets.html │ └── home.html ├── app.py └── run.py ├── ghost ├── ext │ ├── __init__.py │ └── django │ │ ├── __init__.py │ │ └── test.py ├── __init__.py ├── logger.py ├── bindings.py ├── test.py └── ghost.py ├── .gitmodules ├── .gitignore ├── setup.cfg ├── docs ├── index.rst └── conf.py ├── MANIFEST.in ├── tox.ini ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/uploaded_: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ghost/ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ghost: -------------------------------------------------------------------------------- 1 | ../ghost/ -------------------------------------------------------------------------------- /ghost/ext/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | -------------------------------------------------------------------------------- /tests/static/blackhat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Todo/Ghost.py/dev/tests/static/blackhat.jpg -------------------------------------------------------------------------------- /tests/static/foo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Todo/Ghost.py/dev/tests/static/foo.tar.gz -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs"] 2 | path = docs 3 | url = git@github.com:jeanphix/Ghost.py.git 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | env 3 | build 4 | __pycache__ 5 | Ghost.py.egg-info 6 | dist 7 | *.swp 8 | *.swo 9 | .tox 10 | .idea 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = docs/ 3 | build-dir = docs/build 4 | all_files = 1 5 | 6 | [upload_sphinx] 7 | upload-dir = docs/build/html -------------------------------------------------------------------------------- /ghost/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .ghost import ( 3 | Ghost, 4 | Error, 5 | Session, 6 | TimeoutError, 7 | __version__, 8 | ) 9 | from .test import GhostTestCase 10 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | API 4 | ============ 5 | 6 | .. module:: ghost 7 | 8 | 9 | Ghost 10 | ----- 11 | 12 | .. autoclass:: Ghost 13 | :members: 14 | 15 | 16 | Session 17 | ------- 18 | 19 | .. autoclass:: Session 20 | :members: 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst *.py 2 | include ghost/ghost.py 3 | include ghost/test.py 4 | include ghost/ext/django/test.py 5 | recursive-include docs * 6 | recursive-exclude docs *.pyc 7 | recursive-exclude docs *.pyo 8 | prune docs/_build 9 | prune docs/_themes/.git 10 | -------------------------------------------------------------------------------- /ghost/ext/django/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | try: 3 | from django.test import LiveServerTestCase 4 | except ImportError: 5 | raise Exception("Ghost.py django extension requires django...") 6 | from ghost.test import BaseGhostTestCase 7 | 8 | 9 | class GhostTestCase(LiveServerTestCase, BaseGhostTestCase): 10 | pass 11 | -------------------------------------------------------------------------------- /tests/templates/echo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 | {{ arg }} 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/templates/many_assets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% for f in js %} 6 | 7 | {% endfor %} 8 | {% for f in css %} 9 | 10 | {% endfor %} 11 | 12 | 13 |

Many assets

14 | 15 | 16 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py27 8 | sitepackages = True 9 | 10 | [testenv] 11 | commands = 12 | # Currently assuming that PySide is already installed, because builds fail for me on OS X 10.6.8, so I'm using pyside in Hombrew 13 | pip install --use-mirrors -r {toxinidir}/tests/requirements.txt 14 | {toxinidir}/tests/run.py -v 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Ghost.py 3 | -------- 4 | 5 | Webkit based webclient. 6 | 7 | """ 8 | from setuptools import setup, find_packages 9 | from ghost import __version__ 10 | 11 | 12 | setup( 13 | name='Ghost.py', 14 | version=__version__, 15 | url='https://github.com/jeanphix/Ghost.py', 16 | license='mit', 17 | author='Jean-Philippe Serafin', 18 | author_email='serafinjp@gmail.com', 19 | description='Webkit based webclient.', 20 | long_description=__doc__, 21 | data_files=[('ghost', ['README.rst',])], 22 | packages=find_packages(), 23 | include_package_data=True, 24 | zip_safe=False, 25 | platforms='any', 26 | classifiers=[ 27 | 'Development Status :: 5 - Production/Stable', 28 | 'Environment :: Web Environment', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python', 33 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 34 | 'Topic :: Software Development :: Libraries :: Python Modules' 35 | ], 36 | ) 37 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ghost.py 2 | ======== 3 | 4 | .. image:: https://drone.io/github.com/jeanphix/Ghost.py/status.png 5 | :target: https://drone.io/github.com/jeanphix/Ghost.py/latest 6 | 7 | 8 | ghost.py is a webkit web client written in python: 9 | 10 | .. code:: python 11 | 12 | from ghost import Ghost 13 | ghost = Ghost() 14 | 15 | with ghost.start() as session: 16 | page, extra_resources = session.open("http://jeanphix.me") 17 | assert page.http_status == 200 and 'jeanphix' in page.content 18 | 19 | 20 | Installation 21 | ------------ 22 | 23 | ghost.py requires either PySide_ (preferred) or PyQt_ Qt_ bindings: 24 | 25 | .. code:: bash 26 | 27 | pip install pyside 28 | pip install ghost.py --pre 29 | 30 | OSX: 31 | 32 | .. code:: bash 33 | 34 | brew install qt 35 | mkvirtualenv foo 36 | pip install -U pip # make sure pip is current 37 | pip install PySide 38 | pyside_postinstall.py -install 39 | pip install Ghost.py 40 | 41 | 42 | .. _PySide: https://pyside.github.io/ 43 | .. _PyQt: http://www.riverbankcomputing.co.uk/software/pyqt/intro 44 | .. _Qt: http://qt-project.org/ 45 | -------------------------------------------------------------------------------- /ghost/logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | from logging import ( 5 | Filter, 6 | Formatter, 7 | getLogger, 8 | ) 9 | 10 | 11 | class SenderFilter(Filter): 12 | def filter(self, record): 13 | record.sender = self.sender 14 | return True 15 | 16 | 17 | class MillisecFormatter(Formatter): 18 | converter = datetime.fromtimestamp 19 | 20 | def formatTime(self, record, datefmt=None): 21 | ct = self.converter(record.created) 22 | if datefmt is not None: 23 | s = ct.strftime(datefmt) 24 | else: 25 | t = ct.strftime("%Y-%m-%dT%H:%M:%S") 26 | s = "%s.%03dZ" % (t, record.msecs) 27 | return s 28 | 29 | 30 | def configure(name, sender, level, handler=None): 31 | logger = getLogger(name) 32 | # Add `ghost_id` to formater 33 | ghost_filter = SenderFilter() 34 | ghost_filter.sender = sender 35 | logger.addFilter(ghost_filter) 36 | # Set the level 37 | logger.setLevel(level) 38 | # Configure handler formater 39 | formatter = MillisecFormatter( 40 | fmt='%(asctime)s [%(levelname)-8s] %(sender)s: %(message)s', 41 | ) 42 | if handler is not None: 43 | handler.setFormatter(formatter) 44 | logger.addHandler(handler) 45 | 46 | return logger 47 | -------------------------------------------------------------------------------- /tests/static/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0 30px; 4 | font-size: 14px; 5 | font-family: "Lato",Helvetica,Arial,sans-serif; 6 | } 7 | 8 | h1 { 9 | margin-top: 20px; 10 | height: 40px; 11 | width: 300px; 12 | } 13 | 14 | h2 { 15 | font-weight: normal; 16 | } 17 | 18 | form { 19 | margin-top: 0; 20 | } 21 | 22 | fieldset { 23 | padding: 0; 24 | border: 0; 25 | } 26 | 27 | main a, input[type=submit] { 28 | display: inline-block; 29 | padding: 0 1em; 30 | line-height: 2em; 31 | height: 2em; 32 | color: #fff; 33 | text-decoration: none; 34 | border: 0; 35 | border-radius: 0.3em; 36 | background: #468ebe; 37 | } 38 | 39 | nav ul { 40 | padding: 0; 41 | } 42 | 43 | nav ul li { 44 | display: inline; 45 | } 46 | 47 | input[type=text], 48 | input[type=email], 49 | input[type=password], 50 | textarea, 51 | select { 52 | box-sizing: border-box; 53 | width: 100%; 54 | border: 1px #bbb solid; 55 | } 56 | 57 | input[type=text], 58 | input[type=email], 59 | input[type=password] { 60 | padding: 0.3em 0.5em; 61 | line-height: 1.5em; 62 | height: 2.5em; 63 | } 64 | 65 | select { 66 | padding: 0.3em 0.5em; 67 | } 68 | 69 | textarea { 70 | padding: 0.5em; 71 | vertical-align: top; 72 | } 73 | 74 | input[type=checkbox], 75 | input[type=radio] { 76 | vertical-align: middle; 77 | } 78 | 79 | input[type=submit] { 80 | width: 100% 81 | } 82 | 83 | iframe { 84 | border: 0; 85 | } 86 | -------------------------------------------------------------------------------- /ghost/bindings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | 4 | PY3 = sys.version > '3' 5 | 6 | if PY3: 7 | unicode = str 8 | long = int 9 | 10 | 11 | bindings = ["PySide", "PyQt4"] 12 | binding = None 13 | 14 | 15 | for name in bindings: 16 | try: 17 | binding = __import__(name) 18 | if name == 'PyQt4': 19 | import sip 20 | sip.setapi('QVariant', 2) 21 | 22 | except ImportError: 23 | continue 24 | break 25 | 26 | 27 | class LazyBinding(object): 28 | class __metaclass__(type): 29 | def __getattr__(self, name): 30 | return self.__class__ 31 | 32 | def __getattr__(self, name): 33 | return self.__class__ 34 | 35 | 36 | def _import(name): 37 | if binding is None: 38 | return LazyBinding() 39 | 40 | name = "%s.%s" % (binding.__name__, name) 41 | module = __import__(name) 42 | for n in name.split(".")[1:]: 43 | module = getattr(module, n) 44 | return module 45 | 46 | 47 | QtCore = _import("QtCore") 48 | QSize = QtCore.QSize 49 | QByteArray = QtCore.QByteArray 50 | QUrl = QtCore.QUrl 51 | QDateTime = QtCore.QDateTime 52 | QtCriticalMsg = QtCore.QtCriticalMsg 53 | QtDebugMsg = QtCore.QtDebugMsg 54 | QtFatalMsg = QtCore.QtFatalMsg 55 | QtWarningMsg = QtCore.QtWarningMsg 56 | qInstallMsgHandler = QtCore.qInstallMsgHandler 57 | 58 | QtGui = _import("QtGui") 59 | QApplication = QtGui.QApplication 60 | QImage = QtGui.QImage 61 | QPainter = QtGui.QPainter 62 | QPrinter = QtGui.QPrinter 63 | QRegion = QtGui.QRegion 64 | 65 | QtNetwork = _import("QtNetwork") 66 | QNetworkRequest = QtNetwork.QNetworkRequest 67 | QNetworkAccessManager = QtNetwork.QNetworkAccessManager 68 | QNetworkCookieJar = QtNetwork.QNetworkCookieJar 69 | QNetworkProxy = QtNetwork.QNetworkProxy 70 | QNetworkCookie = QtNetwork.QNetworkCookie 71 | QSslConfiguration = QtNetwork.QSslConfiguration 72 | QSsl = QtNetwork.QSsl 73 | 74 | QtWebKit = _import('QtWebKit') 75 | -------------------------------------------------------------------------------- /tests/static/app.js: -------------------------------------------------------------------------------- 1 | /*globals alert, confirm, prompt*/ 2 | var promptValue = null, 3 | result = false; 4 | 5 | window.addEventListener('DOMContentLoaded', function () { 6 | "use strict"; 7 | var alertButton = document.getElementById('alert-button'), 8 | confirmButton = document.getElementById('confirm-button'), 9 | promptButton = document.getElementById('prompt-button'), 10 | updateListButton = document.getElementById('update-list-button'); 11 | 12 | alertButton.addEventListener('click', function (e) { 13 | alert('this is an alert'); 14 | e.preventDefault(); 15 | }, false); 16 | 17 | confirmButton.addEventListener('click', function (e) { 18 | if (confirm('this is a confirm')) { 19 | alert('you confirmed!'); 20 | } else { 21 | alert('you denied!'); 22 | } 23 | e.preventDefault(); 24 | }, false); 25 | 26 | promptButton.addEventListener('click', function (e) { 27 | promptValue = prompt("Prompt ?"); 28 | e.preventDefault(); 29 | }, false); 30 | 31 | updateListButton.addEventListener('click', function (e) { 32 | var request = new XMLHttpRequest(); 33 | request.onreadystatechange = function () { 34 | if (this.readyState === this.DONE) { 35 | var data = JSON.parse(this.response), 36 | list = document.getElementById('list'); 37 | data.items.forEach(function (item) { 38 | var li = document.createElement('li'); 39 | li.innerHTML = item; 40 | list.appendChild(li); 41 | }); 42 | } 43 | }; 44 | request.open('GET', updateListButton.href, true); 45 | request.send(null); 46 | e.preventDefault(); 47 | }, false); 48 | 49 | window.setTimeout( 50 | function () { 51 | window.result = true; 52 | }, 53 | 3000 54 | ); 55 | }, false); 56 | -------------------------------------------------------------------------------- /ghost/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import threading 3 | import logging 4 | import time 5 | from unittest import TestCase 6 | from wsgiref.simple_server import make_server 7 | from ghost import Ghost 8 | 9 | 10 | class ServerThread(threading.Thread): 11 | """Starts given WSGI application. 12 | 13 | :param app: The WSGI application to run. 14 | :param port: The port to run on. 15 | """ 16 | def __init__(self, app, port=5000): 17 | self.app = app 18 | self.port = port 19 | super(ServerThread, self).__init__() 20 | 21 | def run(self): 22 | self.http_server = make_server('', self.port, self.app) 23 | self.http_server.serve_forever() 24 | 25 | def join(self, timeout=None): 26 | if hasattr(self, 'http_server'): 27 | self.http_server.shutdown() 28 | del self.http_server 29 | 30 | 31 | class BaseGhostTestCase(TestCase): 32 | display = False 33 | wait_timeout = 5 34 | viewport_size = (800, 600) 35 | log_level = logging.DEBUG 36 | 37 | def __new__(cls, *args, **kwargs): 38 | """Creates Ghost instance.""" 39 | if not hasattr(cls, 'ghost'): 40 | cls.ghost = Ghost( 41 | log_level=cls.log_level, 42 | defaults=dict( 43 | display=cls.display, 44 | viewport_size=cls.viewport_size, 45 | wait_timeout=cls.wait_timeout, 46 | ) 47 | ) 48 | 49 | return super(BaseGhostTestCase, cls).__new__(cls) 50 | 51 | def __call__(self, result=None): 52 | """Does the required setup, doing it here 53 | means you don't have to call super.setUp 54 | in subclasses. 55 | """ 56 | self._pre_setup() 57 | super(BaseGhostTestCase, self).__call__(result) 58 | self._post_teardown() 59 | 60 | def _post_teardown(self): 61 | """Deletes ghost cookies and hide UI if needed.""" 62 | self.session.exit() 63 | 64 | def _pre_setup(self): 65 | """Shows UI if needed. 66 | """ 67 | self.session = self.ghost.start() 68 | if self.display: 69 | self.session.show() 70 | 71 | 72 | class GhostTestCase(BaseGhostTestCase): 73 | """TestCase that provides a ghost instance and manage 74 | an HTTPServer running a WSGI application. 75 | """ 76 | server_class = ServerThread 77 | port = 5000 78 | 79 | def create_app(self): 80 | """Returns your WSGI application for testing. 81 | """ 82 | raise NotImplementedError 83 | 84 | @classmethod 85 | def tearDownClass(cls): 86 | """Stops HTTPServer instance.""" 87 | cls.server_thread.join() 88 | super(GhostTestCase, cls).tearDownClass() 89 | 90 | @classmethod 91 | def setUpClass(cls): 92 | """Starts HTTPServer instance from WSGI application. 93 | """ 94 | cls.server_thread = cls.server_class(cls.create_app(), cls.port) 95 | cls.server_thread.daemon = True 96 | cls.server_thread.start() 97 | while not hasattr(cls.server_thread, 'http_server'): 98 | time.sleep(0.01) 99 | super(GhostTestCase, cls).setUpClass() 100 | -------------------------------------------------------------------------------- /tests/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

Ghost.py

9 | {% with messages = get_flashed_messages() %} 10 | {% if messages %} 11 | 16 | {% endif %} 17 | {% endwith %} 18 |

Links

19 |

link

20 | 27 |

Forms

28 |
29 |
30 |

31 | 32 | 33 |

34 |

35 | 36 | 37 |

38 |

39 | 40 | 41 |

42 |

43 | 44 | 45 |

46 |

47 | 48 | 49 | 50 | 51 |

52 |

53 | 54 | 58 |

59 |

60 | 61 | 62 | 63 | 64 |

65 |

66 | 67 | 68 |

69 | 70 |
71 |
72 |

Javascripts

73 | 76 |

Update list

77 |

Frames

78 | 79 | 80 |

Resources

81 |

82 | 83 | 86 |
87 | 88 | 89 | -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import os 4 | 5 | from flask import ( 6 | abort, 7 | flash, 8 | Flask, 9 | jsonify, 10 | make_response, 11 | redirect, 12 | render_template, 13 | request, 14 | Response, 15 | url_for, 16 | ) 17 | 18 | from werkzeug.datastructures import Headers 19 | 20 | 21 | PY3 = sys.version > '3' 22 | 23 | 24 | app = Flask(__name__) 25 | app.config['CSRF_ENABLED'] = False 26 | app.config['SECRET_KEY'] = 'asecret' 27 | 28 | 29 | @app.route('/', methods=['get', 'post']) 30 | def home(): 31 | if request.method == 'POST': 32 | flash('Form successfully sent.') 33 | file = request.files.get('simple-file') 34 | if file is not None: 35 | file.save(os.path.join( 36 | os.path.dirname(__file__), 37 | "uploaded_%s" % file.filename 38 | )) 39 | return redirect(url_for('home')) 40 | return render_template('home.html') 41 | 42 | 43 | @app.route('/echo/') 44 | def echo(arg): 45 | return render_template('echo.html', arg=arg) 46 | 47 | 48 | @app.route('/no-cache') 49 | def no_cache(): 50 | response = make_response("No cache for me.", 200) 51 | response.headers['Cache-Control'] = ( 52 | 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0' 53 | ) 54 | return response 55 | 56 | 57 | @app.route('/cookie') 58 | def cookie(): 59 | resp = make_response('Response text') 60 | resp.set_cookie('mycookies', 'mycookie value') 61 | return resp 62 | 63 | 64 | @app.route('/set/cookie') 65 | def set_cookie(): 66 | resp = make_response('Response text') 67 | resp.set_cookie('_path', value='/get/', path='/get/') 68 | resp.set_cookie('_path_fail', value='/set/', path='/set/') 69 | resp.set_cookie('_domain', value='127.0.0.1') 70 | resp.set_cookie('_secure_fail', value='sslonly', secure=True) 71 | resp.set_cookie('_expires', value='2147483647', expires=2147483647) 72 | return resp 73 | 74 | 75 | @app.route('/get/cookie') 76 | def get_cookie(): 77 | cookies = { 78 | '_expires': '2147483647', 79 | '_domain': '127.0.0.1', 80 | '_path': '/get/', 81 | } 82 | # make sure only what we expect is received. 83 | if cookies != request.cookies: 84 | return make_response('FAIL') 85 | # print request.cookies 86 | else: 87 | return make_response('OK') 88 | 89 | 90 | @app.route('/protected') 91 | def protected(): 92 | return abort(403) 93 | 94 | 95 | @app.route('/settimeout') 96 | def settimeout(): 97 | return render_template('settimeout.html') 98 | 99 | 100 | @app.route('/items.json') 101 | def items(): 102 | return jsonify(items=['second item', 'third item']) 103 | 104 | 105 | def _check_auth(username, password): 106 | return username == 'admin' and password == 'secret' 107 | 108 | 109 | @app.route('/basic-auth') 110 | def basic_auth(): 111 | auth = request.authorization 112 | if auth is None or not _check_auth(auth.username, auth.password): 113 | return Response( 114 | 'Could not verify your access level for that URL.\n' 115 | 'You have to login with proper credentials', 401, 116 | {'WWW-Authenticate': 'Basic realm="Login Required"'}) 117 | return '

successfully authenticated

' 118 | 119 | 120 | @app.route('/send-file') 121 | def send_file(): 122 | h = Headers() 123 | h.add('Content-type', 'application/octet-stream', charset='utf8') 124 | h.add('Content-disposition', 'attachment', filename='name.tar.gz') 125 | file_path = os.path.join(os.path.dirname(__file__), 'static', 'foo.tar.gz') 126 | if PY3: 127 | f = open(file_path, 'r', encoding='latin-1') 128 | else: 129 | f = open(file_path, 'r') 130 | return Response(f, headers=h) 131 | 132 | 133 | @app.route('/url-hash') 134 | def url_hash(): 135 | return render_template('url_hash.html') 136 | 137 | 138 | @app.route('/url-hash-header') 139 | def url_hash_header(): 140 | response = make_response("Redirecting.", 302) 141 | response.headers['Location'] = url_for('echo', arg='Welcome') + "#/" 142 | return response 143 | 144 | 145 | @app.route('/many-assets') 146 | def many_assets(): 147 | return render_template( 148 | 'many_assets.html', 149 | css=['css%s' % i for i in range(0, 5)], 150 | js=['js%s' % i for i in range(0, 5)] 151 | ) 152 | 153 | 154 | @app.route('/js/.js') 155 | def js_assets(name=None): 156 | return 'var foo = "%s";' % name 157 | 158 | 159 | @app.route('/css/.css') 160 | def css_assets(name=None): 161 | return 'P.%s { color: red; };' % name 162 | 163 | 164 | @app.route('/dump') 165 | def dump(): 166 | return jsonify(dict(headers=dict(request.headers))) 167 | 168 | 169 | if __name__ == '__main__': 170 | app.run() 171 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Logbook documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jul 23 16:54:49 2010. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | from ghost import __version__ 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.extend((os.path.abspath('.'), os.path.abspath('..'))) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ['sphinx.ext.autodoc', ] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'Ghost.py' 45 | copyright = u'2014, Jean-Philippe Serafin' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | version = __version__ 53 | # The full version, including alpha/beta/rc tags. 54 | release = __version__ 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | #html_theme = 'sheet' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | # html_theme_options = { 101 | # 'nosidebar': True, 102 | # } 103 | 104 | # Add any paths that contain custom themes here, relative to this directory. 105 | #html_theme_path = ['.'] 106 | 107 | # The name for this set of Sphinx documents. If None, it defaults to 108 | # " v documentation". 109 | #html_title = "Ghost.py" 110 | 111 | # A shorter title for the navigation bar. Default is the same as html_title. 112 | html_short_title = "Ghost.py " + release 113 | 114 | # The name of an image file (relative to this directory) to place at the top 115 | # of the sidebar. 116 | #html_logo = None 117 | 118 | # The name of an image file (within the static path) to use as favicon of the 119 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 120 | # pixels large. 121 | #html_favicon = None 122 | 123 | # Add any paths that contain custom static files (such as style sheets) here, 124 | # relative to this directory. They are copied after the builtin static files, 125 | # so a file named "default.css" will overwrite the builtin "default.css". 126 | #html_static_path = ['_static'] 127 | 128 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 129 | # using the given strftime format. 130 | #html_last_updated_fmt = '%b %d, %Y' 131 | 132 | # If true, SmartyPants will be used to convert quotes and dashes to 133 | # typographically correct entities. 134 | #html_use_smartypants = True 135 | 136 | # Custom sidebar templates, maps document names to template names. 137 | #html_sidebars = {} 138 | 139 | # Additional templates that should be rendered to pages, maps page names to 140 | # template names. 141 | #html_additional_pages = {} 142 | 143 | # If false, no module index is generated. 144 | #html_domain_indices = True 145 | 146 | # If false, no index is generated. 147 | #html_use_index = True 148 | 149 | # If true, the index is split into individual pages for each letter. 150 | #html_split_index = False 151 | 152 | # If true, links to the reST sources are added to the pages. 153 | #html_show_sourcelink = True 154 | 155 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 156 | #html_show_sphinx = True 157 | 158 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 159 | #html_show_copyright = True 160 | 161 | html_add_permalinks = True 162 | 163 | # If true, an OpenSearch description file will be output, and all pages will 164 | # contain a tag referring to it. The value of this option must be the 165 | # base URL from which the finished HTML is served. 166 | #html_use_opensearch = '' 167 | 168 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 169 | #html_file_suffix = '' 170 | 171 | # Output file base name for HTML help builder. 172 | htmlhelp_basename = 'Ghostdoc' 173 | 174 | 175 | # -- Options for LaTeX output -------------------------------------------------- 176 | 177 | # The paper size ('letter' or 'a4'). 178 | #latex_paper_size = 'letter' 179 | 180 | # The font size ('10pt', '11pt' or '12pt'). 181 | #latex_font_size = '10pt' 182 | 183 | # Grouping the document tree into LaTeX files. List of tuples 184 | # (source start file, target name, title, author, documentclass [howto/manual]). 185 | 186 | # The name of an image file (relative to this directory) to place at the top of 187 | # the title page. 188 | #latex_logo = None 189 | 190 | # For "manual" documents, if this is true, then toplevel headings are parts, 191 | # not chapters. 192 | #latex_use_parts = False 193 | 194 | # If true, show page references after internal links. 195 | #latex_show_pagerefs = False 196 | 197 | # If true, show URL addresses after external links. 198 | #latex_show_urls = False 199 | 200 | # Additional stuff for the LaTeX preamble. 201 | #latex_preamble = '' 202 | 203 | # Documents to append as an appendix to all manuals. 204 | #latex_appendices = [] 205 | 206 | # If false, no module index is generated. 207 | #latex_domain_indices = True 208 | 209 | 210 | # -- Options for manual page output -------------------------------------------- 211 | 212 | # One entry per manual page. List of tuples 213 | # (source start file, name, description, authors, manual section). 214 | 215 | -------------------------------------------------------------------------------- /tests/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | import os 5 | import json 6 | import logging 7 | import unittest 8 | try: 9 | import cookielib 10 | except ImportError: 11 | from http import cookiejar as cookielib 12 | 13 | from app import app 14 | from ghost import GhostTestCase 15 | from ghost.ghost import default_user_agent 16 | 17 | 18 | PY3 = sys.version > '3' 19 | 20 | PORT = 5000 21 | 22 | base_url = 'http://localhost:%s/' % PORT 23 | 24 | 25 | class GhostTest(GhostTestCase): 26 | port = PORT 27 | display = False 28 | log_level = logging.INFO 29 | 30 | @classmethod 31 | def create_app(cls): 32 | return app 33 | 34 | def test_open(self): 35 | page, resources = self.session.open(base_url) 36 | self.assertEqual(page.url, base_url) 37 | self.assertTrue("Ghost.py" in self.session.content) 38 | 39 | def test_open_page_with_no_cache_headers(self): 40 | page, resources = self.session.open("%sno-cache" % base_url) 41 | self.assertIsNotNone(page.content) 42 | self.assertIn("cache for me", page.content) 43 | 44 | def test_open_403(self): 45 | page, resources = self.session.open("%sprotected" % base_url) 46 | self.assertEqual(resources[0].http_status, 403) 47 | 48 | def test_open_404(self): 49 | page, resources = self.session.open("%s404" % base_url) 50 | self.assertEqual(page.http_status, 404) 51 | 52 | def test_evaluate(self): 53 | self.session.open(base_url) 54 | self.assertEqual(self.session.evaluate("x='ghost'; x;")[0], 'ghost') 55 | 56 | def test_extra_resource_content(self): 57 | page, resources = self.session.open(base_url) 58 | self.assertIn('globals alert', resources[4].content) 59 | 60 | def test_extra_resource_binaries(self): 61 | page, resources = self.session.open(base_url) 62 | self.assertEqual( 63 | resources[5].content.__class__.__name__, 64 | 'QByteArray', 65 | ) 66 | 67 | def test_wait_for_selector(self): 68 | page, resources = self.session.open(base_url) 69 | success, resources = self.session.click("#update-list-button") 70 | success, resources = self.session\ 71 | .wait_for_selector("#list li:nth-child(2)") 72 | self.assertEqual(resources[0].url, "%sitems.json" % base_url) 73 | 74 | def test_sleep(self): 75 | page, resources = self.session.open("%s" % base_url) 76 | result, _ = self.session.evaluate("window.result") 77 | self.assertEqual(result, False) 78 | self.session.sleep(4) 79 | result, _ = self.session.evaluate("window.result") 80 | self.assertEqual(result, True) 81 | 82 | def test_wait_for_text(self): 83 | page, resources = self.session.open(base_url) 84 | self.session.click("#update-list-button") 85 | success, resources = self.session.wait_for_text("second item") 86 | 87 | def test_wait_for_timeout(self): 88 | self.session.open("%s" % base_url) 89 | self.assertRaises(Exception, self.session.wait_for_text, "undefined") 90 | 91 | def test_fill(self): 92 | self.session.open(base_url) 93 | values = { 94 | 'text': 'Here is a sample text.', 95 | 'email': 'my@awesome.email', 96 | 'textarea': 'Here is a sample text.\nWith several lines.', 97 | 'checkbox': True, 98 | 'select': 'two', 99 | "radio": "first choice" 100 | } 101 | self.session.fill('form', values) 102 | for field in ['text', 'email', 'textarea']: 103 | value, resssources = self.session\ 104 | .evaluate('document.getElementById("%s").value' % field) 105 | self.assertEqual(value, values[field]) 106 | value, resources = self.session.evaluate( 107 | 'document.getElementById("checkbox").checked') 108 | self.assertEqual(value, True) 109 | value, resources = self.session.evaluate( 110 | "document.querySelector('option[value=two]').selected;") 111 | self.assertTrue(value) 112 | value, resources = self.session.evaluate( 113 | 'document.getElementById("radio-first").checked') 114 | self.assertEqual(value, True) 115 | value, resources = self.session.evaluate( 116 | 'document.getElementById("radio-second").checked') 117 | self.assertEqual(value, False) 118 | 119 | def test_form_submission(self): 120 | self.session.open(base_url) 121 | values = { 122 | 'text': 'Here is a sample text.', 123 | } 124 | self.session.fill('form', values) 125 | page, resources = self.session.call( 126 | 'form', 127 | 'submit', 128 | expect_loading=True, 129 | ) 130 | self.assertIn('Form successfully sent.', self.session.content) 131 | 132 | def test_global_exists(self): 133 | self.session.open("%s" % base_url) 134 | self.assertTrue(self.session.global_exists('myGlobal')) 135 | 136 | def test_resource_headers(self): 137 | page, resources = self.session.open(base_url) 138 | self.assertEqual( 139 | page.headers['Content-Type'], 140 | 'text/html; charset=utf-8', 141 | ) 142 | 143 | def test_click_link(self): 144 | page, resources = self.session.open(base_url) 145 | page, resources = self.session.click('a', expect_loading=True) 146 | self.assertEqual(page.url, "%secho/link" % base_url) 147 | 148 | def test_cookies(self): 149 | self.session.open("%scookie" % base_url) 150 | self.assertEqual(len(self.session.cookies), 1) 151 | 152 | def test_delete_cookies(self): 153 | self.session.open("%scookie" % base_url) 154 | self.session.delete_cookies() 155 | self.assertEqual(len(self.session.cookies), 0) 156 | 157 | def test_save_load_cookies(self): 158 | self.session.delete_cookies() 159 | self.session.open("%sset/cookie" % base_url) 160 | self.session.save_cookies('testcookie.txt') 161 | self.session.delete_cookies() 162 | self.session.load_cookies('testcookie.txt') 163 | self.session.open("%sget/cookie" % base_url) 164 | self.assertTrue('OK' in self.session.content) 165 | 166 | def test_load_cookies_expire_is_none(self): 167 | self.session.delete_cookies() 168 | jar = cookielib.CookieJar() 169 | cookie = cookielib.Cookie(version=0, name='Name', value='1', port=None, 170 | port_specified=False, 171 | domain='www.example.com', 172 | domain_specified=False, 173 | domain_initial_dot=False, path='/', 174 | path_specified=True, secure=False, 175 | expires=None, discard=True, comment=None, 176 | comment_url=None, rest={'HttpOnly': None}, 177 | rfc2109=False) 178 | jar.set_cookie(cookie) 179 | self.session.load_cookies(jar) 180 | 181 | def test_wait_for_alert(self): 182 | self.session.open(base_url) 183 | self.session.click('#alert-button') 184 | msg, resources = self.session.wait_for_alert() 185 | self.assertEqual(msg, 'this is an alert') 186 | 187 | def test_confirm(self): 188 | self.session.open(base_url) 189 | with self.session.confirm(): 190 | self.session.click('#confirm-button') 191 | msg, resources = self.session.wait_for_alert() 192 | self.assertEqual(msg, 'you confirmed!') 193 | 194 | def test_no_confirm(self): 195 | self.session.open(base_url) 196 | with self.session.confirm(False): 197 | self.session.click('#confirm-button') 198 | msg, resources = self.session.wait_for_alert() 199 | self.assertEqual(msg, 'you denied!') 200 | 201 | def test_confirm_callable(self): 202 | self.session.open(base_url) 203 | with self.session.confirm(lambda: False): 204 | self.session.click('#confirm-button') 205 | msg, resources = self.session.wait_for_alert() 206 | self.assertEqual(msg, 'you denied!') 207 | 208 | def test_prompt(self): 209 | self.session.open(base_url) 210 | with self.session.prompt('my value'): 211 | self.session.click('#prompt-button') 212 | value, resources = self.session.evaluate('promptValue') 213 | self.assertEqual(value, 'my value') 214 | 215 | def test_prompt_callable(self): 216 | self.session.open(base_url) 217 | with self.session.prompt(lambda: 'another value'): 218 | self.session.click('#prompt-button') 219 | value, resources = self.session.evaluate('promptValue') 220 | self.assertEqual(value, 'another value') 221 | 222 | def test_popup_messages_collection(self): 223 | self.session.open(base_url, default_popup_response=True) 224 | self.session.click('#confirm-button') 225 | self.assertIn('this is a confirm', self.session.popup_messages) 226 | self.session.click('#prompt-button') 227 | self.assertIn('Prompt ?', self.session.popup_messages) 228 | self.session.click('#alert-button') 229 | self.assertIn('this is an alert', self.session.popup_messages) 230 | 231 | def test_prompt_default_value_true(self): 232 | self.session.open(base_url, default_popup_response=True) 233 | self.session.click('#confirm-button') 234 | msg, resources = self.session.wait_for_alert() 235 | self.assertEqual(msg, 'you confirmed!') 236 | 237 | def test_prompt_default_value_false(self): 238 | self.session.open(base_url, default_popup_response=False) 239 | self.session.click('#confirm-button') 240 | msg, resources = self.session.wait_for_alert() 241 | self.assertEqual(msg, 'you denied!') 242 | 243 | def test_capture_to(self): 244 | self.session.open(base_url) 245 | self.session.capture_to('test.png') 246 | self.assertTrue(os.path.isfile('test.png')) 247 | os.remove('test.png') 248 | 249 | def test_region_for_selector(self): 250 | self.session.open(base_url) 251 | x1, y1, x2, y2 = self.session.region_for_selector('h1') 252 | self.assertEqual(x1, 30) 253 | self.assertEqual(y1, 20) 254 | self.assertEqual(x2, 329) 255 | self.assertEqual(y2, 59) 256 | 257 | def test_capture_selector_to(self): 258 | self.session.open(base_url) 259 | self.session.capture_to('test.png', selector='h1') 260 | self.assertTrue(os.path.isfile('test.png')) 261 | os.remove('test.png') 262 | 263 | def test_set_field_value_checkbox_true(self): 264 | self.session.open(base_url) 265 | self.session.set_field_value('[name=checkbox]', True) 266 | value, resssources = self.session.evaluate( 267 | 'document.getElementById("checkbox").checked') 268 | self.assertEqual(value, True) 269 | 270 | def test_set_field_value_checkbox_false(self): 271 | self.session.open(base_url) 272 | self.session.set_field_value('[name=checkbox]', False) 273 | value, resssources = self.session.evaluate( 274 | 'document.getElementById("checkbox").checked') 275 | self.assertEqual(value, False) 276 | 277 | def test_set_field_value_checkbox_multiple(self): 278 | self.session.open(base_url) 279 | self.session.set_field_value( 280 | '[name=multiple-checkbox]', 281 | 'second choice', 282 | ) 283 | value, resources = self.session.evaluate( 284 | 'document.getElementById("multiple-checkbox-first").checked') 285 | self.assertEqual(value, False) 286 | value, resources = self.session.evaluate( 287 | 'document.getElementById("multiple-checkbox-second").checked') 288 | self.assertEqual(value, True) 289 | 290 | def test_set_field_value_email(self): 291 | expected = 'my@awesome.email' 292 | self.session.open(base_url) 293 | self.session.set_field_value('[name=email]', expected) 294 | value, resssources = self.session\ 295 | .evaluate('document.getElementById("email").value') 296 | self.assertEqual(value, expected) 297 | 298 | def test_set_field_value_text(self): 299 | expected = 'sample text' 300 | self.session.open(base_url) 301 | self.session.set_field_value('[name=text]', expected) 302 | value, resssources = self.session\ 303 | .evaluate('document.getElementById("text").value') 304 | self.assertEqual(value, expected) 305 | 306 | def test_set_field_value_radio(self): 307 | self.session.open(base_url) 308 | self.session.set_field_value('[name=radio]', 'first choice') 309 | value, resources = self.session.evaluate( 310 | 'document.getElementById("radio-first").checked') 311 | self.assertEqual(value, True) 312 | value, resources = self.session.evaluate( 313 | 'document.getElementById("radio-second").checked') 314 | self.assertEqual(value, False) 315 | 316 | def test_set_field_value_textarea(self): 317 | expected = 'sample text\nanother line' 318 | self.session.open(base_url) 319 | self.session.set_field_value('[name=textarea]', expected) 320 | value, resssources = self.session\ 321 | .evaluate('document.getElementById("textarea").value') 322 | self.assertEqual(value, expected) 323 | 324 | def test_set_field_value_select(self): 325 | self.session.open(base_url) 326 | self.session.set_field_value('[name=select]', 'two') 327 | value, resources = self.session.evaluate( 328 | "document.querySelector('option[value=two]').selected;") 329 | self.assertTrue(value) 330 | value, resources = self.session.evaluate( 331 | "document.querySelector('option[value=one]').selected;") 332 | self.assertFalse(value) 333 | 334 | def test_set_field_value_simple_file_field(self): 335 | self.session.open(base_url) 336 | self.session.set_field_value( 337 | '[name=simple-file]', 338 | os.path.join(os.path.dirname(__file__), 'static', 'blackhat.jpg'), 339 | ) 340 | page, resources = self.session.call( 341 | 'form', 342 | 'submit', 343 | expect_loading=True, 344 | ) 345 | file_path = os.path.join( 346 | os.path.dirname(__file__), 'uploaded_blackhat.jpg') 347 | self.assertTrue(os.path.isfile(file_path)) 348 | os.remove(file_path) 349 | 350 | def test_basic_http_auth_success(self): 351 | page, resources = self.session.open( 352 | "%sbasic-auth" % base_url, 353 | auth=('admin', 'secret'), 354 | ) 355 | self.assertEqual(page.http_status, 200) 356 | 357 | def test_basic_http_auth_error(self): 358 | page, resources = self.session.open( 359 | "%sbasic-auth" % base_url, 360 | auth=('admin', 'wrongsecret'), 361 | ) 362 | self.assertEqual(page.http_status, 401) 363 | 364 | def test_unsupported_content(self): 365 | page, resources = self.session.open("%ssend-file" % base_url) 366 | file_path = os.path.join( 367 | os.path.dirname(__file__), 368 | 'static', 369 | 'foo.tar.gz', 370 | ) 371 | if PY3: 372 | f = open(file_path, 'r', encoding='latin-1') 373 | else: 374 | f = open(file_path, 'r') 375 | foo = f.read(1024) 376 | f.close() 377 | 378 | self.assertEqual(resources[0].content, foo) 379 | 380 | def test_url_with_hash(self): 381 | page, resources = self.session.open(base_url) 382 | self.session.evaluate('document.location.hash = "test";') 383 | self.assertIsNotNone(page) 384 | self.assertTrue("Ghost.py" in self.session.content) 385 | 386 | def test_url_with_hash_header(self): 387 | page, resources = self.session.open("%surl-hash-header" % base_url) 388 | self.assertIsNotNone(page) 389 | self.assertTrue("Welcome" in self.session.content) 390 | 391 | def test_many_assets(self): 392 | page, resources = self.session.open("%smany-assets" % base_url) 393 | page, resources = self.session.open("%smany-assets" % base_url) 394 | 395 | def test_frame_ascend(self): 396 | session = self.session 397 | session.open(base_url) 398 | session.frame('first-frame') 399 | self.assertIn('frame 1', session.content) 400 | self.assertNotIn('Ghost.py', session.content) 401 | session.frame() 402 | self.assertNotIn('frame 1', session.content) 403 | self.assertIn('Ghost.py', session.content) 404 | 405 | def test_frame_descend_by_name(self): 406 | session = self.session 407 | session.open(base_url) 408 | self.assertNotIn('frame 1', session.content) 409 | session.frame('first-frame') 410 | self.assertIn('frame 1', session.content) 411 | 412 | def test_frame_descend_by_name_invalid(self): 413 | session = self.session 414 | session.open(base_url) 415 | self.assertRaises(LookupError, session.frame, 'third-frame') 416 | 417 | def test_frame_descend_by_index(self): 418 | session = self.session 419 | session.open(base_url) 420 | self.assertNotIn('frame 2', session.content) 421 | session.frame(1) 422 | self.assertIn('frame 2', session.content) 423 | 424 | def test_frame_descend_by_index_invalid(self): 425 | session = self.session 426 | session.open(base_url) 427 | self.assertRaises(LookupError, session.frame, 10) 428 | 429 | def test_set_user_agent(self): 430 | def get_user_agent(session, **kwargs): 431 | page, resources = self.session.open( 432 | "%sdump" % base_url, 433 | **kwargs 434 | ) 435 | data = json.loads(page.content) 436 | return data['headers']['User-Agent'] 437 | 438 | session = self.session 439 | 440 | self.assertEqual(get_user_agent(session), default_user_agent) 441 | 442 | new_agent = 'New Agent' 443 | 444 | self.assertEqual( 445 | get_user_agent(session, user_agent=new_agent), 446 | new_agent, 447 | ) 448 | 449 | def test_exclude_regex(self): 450 | session = self.ghost.start(exclude="\.(jpg|css)") 451 | page, resources = session.open(base_url) 452 | url_loaded = [r.url for r in resources] 453 | self.assertFalse( 454 | "%sstatic/styles.css" % base_url in url_loaded) 455 | self.assertFalse( 456 | "%sstatic/blackhat.jpg" % base_url in url_loaded) 457 | session.exit() 458 | 459 | if __name__ == '__main__': 460 | unittest.main() 461 | -------------------------------------------------------------------------------- /ghost/ghost.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import os 4 | import time 5 | import uuid 6 | import codecs 7 | import logging 8 | import subprocess 9 | import re 10 | from functools import wraps 11 | try: 12 | from cookielib import Cookie, LWPCookieJar 13 | except ImportError: 14 | from http.cookiejar import Cookie, LWPCookieJar 15 | from contextlib import contextmanager 16 | from .logger import configure 17 | from .bindings import ( 18 | binding, 19 | QtCore, 20 | QSize, 21 | QByteArray, 22 | QUrl, 23 | QDateTime, 24 | QtCriticalMsg, 25 | QtDebugMsg, 26 | QtFatalMsg, 27 | QtWarningMsg, 28 | qInstallMsgHandler, 29 | QApplication, 30 | QImage, 31 | QPainter, 32 | QPrinter, 33 | QRegion, 34 | QtNetwork, 35 | QNetworkRequest, 36 | QNetworkAccessManager, 37 | QNetworkCookieJar, 38 | QNetworkProxy, 39 | QNetworkCookie, 40 | QSslConfiguration, 41 | QSsl, 42 | QtWebKit, 43 | ) 44 | 45 | __version__ = "0.2.3" 46 | 47 | 48 | PY3 = sys.version > '3' 49 | 50 | if PY3: 51 | unicode = str 52 | long = int 53 | basestring = str 54 | 55 | 56 | default_user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/535.2 " +\ 57 | "(KHTML, like Gecko) Chrome/15.0.874.121 Safari/535.2" 58 | 59 | 60 | class Error(Exception): 61 | """Base class for Ghost exceptions.""" 62 | pass 63 | 64 | 65 | class TimeoutError(Error): 66 | """Raised when a request times out""" 67 | pass 68 | 69 | 70 | class QTMessageProxy(object): 71 | def __init__(self, logger): 72 | self.logger = logger 73 | 74 | def __call__(self, msgType, msg): 75 | levels = { 76 | QtDebugMsg: 'debug', 77 | QtWarningMsg: 'warn', 78 | QtCriticalMsg: 'critical', 79 | QtFatalMsg: 'fatal', 80 | } 81 | getattr(self.logger, levels[msgType])(msg) 82 | 83 | 84 | class GhostWebPage(QtWebKit.QWebPage): 85 | """Overrides QtWebKit.QWebPage in order to intercept some graphical 86 | behaviours like alert(), confirm(). 87 | Also intercepts client side console.log(). 88 | """ 89 | def __init__(self, app, session): 90 | self.session = session 91 | super(GhostWebPage, self).__init__() 92 | 93 | def chooseFile(self, frame, suggested_file=None): 94 | filename = self.session._upload_file 95 | self.session.logger.debug('Choosing file %s' % filename) 96 | return filename 97 | 98 | def javaScriptConsoleMessage(self, message, line, source): 99 | """Prints client console message in current output stream.""" 100 | super(GhostWebPage, self).javaScriptConsoleMessage( 101 | message, 102 | line, 103 | source, 104 | ) 105 | log_type = "warn" if "Error" in message else "info" 106 | getattr(self.session.logger, log_type)( 107 | "%s(%d): %s" % (source or '', line, message), 108 | ) 109 | 110 | def javaScriptAlert(self, frame, message): 111 | """Notifies session for alert, then pass.""" 112 | self.session._alert = message 113 | self.session.append_popup_message(message) 114 | self.session.logger.info("alert('%s')" % message) 115 | 116 | def _get_value(self, value): 117 | if callable(value): 118 | return value() 119 | 120 | return value 121 | 122 | def javaScriptConfirm(self, frame, message): 123 | """Checks if session is waiting for confirm, then returns the right 124 | value. 125 | """ 126 | if self.session._confirm_expected is None: 127 | raise Error( 128 | 'You must specified a value to confirm "%s"' % 129 | message, 130 | ) 131 | self.session.append_popup_message(message) 132 | value = self.session._confirm_expected 133 | self.session.logger.info("confirm('%s')" % message) 134 | return self._get_value(value) 135 | 136 | def javaScriptPrompt(self, frame, message, defaultValue, result=None): 137 | """Checks if ghost is waiting for prompt, then enters the right 138 | value. 139 | """ 140 | if self.session._prompt_expected is None: 141 | raise Error( 142 | 'You must specified a value for prompt "%s"' % 143 | message, 144 | ) 145 | self.session.append_popup_message(message) 146 | value = self.session._prompt_expected 147 | self.session.logger.info("prompt('%s')" % message) 148 | value = self._get_value(value) 149 | if value == '': 150 | self.session.logger.warn( 151 | "'%s' prompt filled with empty string" % message, 152 | ) 153 | 154 | if result is None: 155 | # PySide 156 | return True, value 157 | 158 | result.append(unicode(value)) 159 | return True 160 | 161 | def set_user_agent(self, user_agent): 162 | self.user_agent = user_agent 163 | 164 | def userAgentForUrl(self, url): 165 | return self.user_agent 166 | 167 | 168 | def can_load_page(func): 169 | """Decorator that specifies if user can expect page loading from 170 | this action. If expect_loading is set to True, ghost will wait 171 | for page_loaded event. 172 | """ 173 | @wraps(func) 174 | def wrapper(self, *args, **kwargs): 175 | expect_loading = kwargs.pop('expect_loading', False) 176 | 177 | if expect_loading: 178 | self.loaded = False 179 | func(self, *args, **kwargs) 180 | return self.wait_for_page_loaded( 181 | timeout=kwargs.pop('timeout', None)) 182 | return func(self, *args, **kwargs) 183 | return wrapper 184 | 185 | 186 | class HttpResource(object): 187 | """Represents an HTTP resource. 188 | """ 189 | def __init__(self, session, reply, content): 190 | self.session = session 191 | self.url = reply.url().toString() 192 | self.content = content 193 | try: 194 | self.content = unicode(content) 195 | except UnicodeDecodeError: 196 | self.content = content 197 | self.http_status = reply.attribute( 198 | QNetworkRequest.HttpStatusCodeAttribute) 199 | self.session.logger.info( 200 | "Resource loaded: %s %s" % (self.url, self.http_status) 201 | ) 202 | self.headers = {} 203 | for header in reply.rawHeaderList(): 204 | try: 205 | self.headers[unicode(header)] = unicode( 206 | reply.rawHeader(header)) 207 | except UnicodeDecodeError: 208 | # it will lose the header value, 209 | # but at least not crash the whole process 210 | self.session.logger.error( 211 | "Invalid characters in header {0}={1}".format( 212 | header, 213 | reply.rawHeader(header), 214 | ) 215 | ) 216 | self._reply = reply 217 | 218 | 219 | def replyReadyRead(reply): 220 | if not hasattr(reply, 'data'): 221 | reply.data = '' 222 | 223 | reply.data += reply.peek(reply.bytesAvailable()) 224 | 225 | 226 | class NetworkAccessManager(QNetworkAccessManager): 227 | """Subclass QNetworkAccessManager to always cache the reply content 228 | 229 | :param exclude_regex: A regex use to determine wich url exclude 230 | when sending a request 231 | """ 232 | def __init__(self, exclude_regex=None, *args, **kwargs): 233 | self._regex = re.compile(exclude_regex) if exclude_regex else None 234 | super(NetworkAccessManager, self).__init__(*args, **kwargs) 235 | 236 | def createRequest(self, operation, request, data): 237 | if self._regex and self._regex.findall(str(request.url().toString())): 238 | return QNetworkAccessManager.createRequest( 239 | self, QNetworkAccessManager.GetOperation, 240 | QNetworkRequest(QUrl())) 241 | reply = QNetworkAccessManager.createRequest( 242 | self, 243 | operation, 244 | request, 245 | data 246 | ) 247 | reply.readyRead.connect(lambda reply=reply: replyReadyRead(reply)) 248 | time.sleep(0.001) 249 | return reply 250 | 251 | 252 | class Ghost(object): 253 | """`Ghost` manages a Qt application. 254 | 255 | :param log_level: The optional logging level. 256 | :param log_handler: The optional logging handler. 257 | :param plugin_path: Array with paths to plugin directories 258 | (default ['/usr/lib/mozilla/plugins']) 259 | :param defaults: The defaults arguments to pass to new child sessions. 260 | """ 261 | _app = None 262 | 263 | def __init__( 264 | self, 265 | log_level=logging.WARNING, 266 | log_handler=logging.StreamHandler(sys.stderr), 267 | plugin_path=['/usr/lib/mozilla/plugins', ], 268 | defaults=None, 269 | ): 270 | if not binding: 271 | raise Exception("Ghost.py requires PySide or PyQt4") 272 | 273 | self.logger = configure( 274 | 'ghost', 275 | "Ghost", 276 | log_level, 277 | log_handler, 278 | ) 279 | 280 | if ( 281 | sys.platform.startswith('linux') and 282 | 'DISPLAY' not in os.environ 283 | ): 284 | try: 285 | os.environ['DISPLAY'] = ':99' 286 | process = ['Xvfb', ':99', '-pixdepths', '32'] 287 | FNULL = open(os.devnull, 'w') 288 | self.xvfb = subprocess.Popen( 289 | process, 290 | stdout=FNULL, 291 | stderr=subprocess.STDOUT, 292 | ) 293 | except OSError: 294 | raise Error('Xvfb is required to a ghost run outside ' + 295 | 'an X instance') 296 | 297 | self.logger.info('Initializing QT application') 298 | Ghost._app = QApplication.instance() or QApplication(['ghost']) 299 | 300 | qInstallMsgHandler(QTMessageProxy( 301 | configure( 302 | 'qt', 303 | 'QT', 304 | log_level, 305 | log_handler, 306 | ) 307 | )) 308 | if plugin_path: 309 | for p in plugin_path: 310 | Ghost._app.addLibraryPath(p) 311 | 312 | self.defaults = defaults or dict() 313 | 314 | def exit(self): 315 | self._app.quit() 316 | if hasattr(self, 'xvfb'): 317 | self.xvfb.terminate() 318 | 319 | def start(self, **kwargs): 320 | """Starts a new `Session`.""" 321 | kwargs.update(self.defaults) 322 | return Session(self, **kwargs) 323 | 324 | def __del__(self): 325 | self.exit() 326 | 327 | 328 | class Session(object): 329 | """`Session` manages a QWebPage. 330 | 331 | :param ghost: The parent `Ghost` instance. 332 | :param user_agent: The default User-Agent header. 333 | :param wait_timeout: Maximum step duration in second. 334 | :param wait_callback: An optional callable that is periodically 335 | executed until Ghost stops waiting. 336 | :param log_level: The optional logging level. 337 | :param log_handler: The optional logging handler. 338 | :param display: A boolean that tells ghost to displays UI. 339 | :param viewport_size: A tuple that sets initial viewport size. 340 | :param ignore_ssl_errors: A boolean that forces ignore ssl errors. 341 | :param plugins_enabled: Enable plugins (like Flash). 342 | :param java_enabled: Enable Java JRE. 343 | :param download_images: Indicate if the browser should download images 344 | :param exclude: A regex use to determine which url exclude 345 | when sending a request 346 | :param local_storage_enabled: An optional boolean to enable / disable 347 | local storage. 348 | """ 349 | _alert = None 350 | _confirm_expected = None 351 | _prompt_expected = None 352 | _upload_file = None 353 | _app = None 354 | 355 | def __init__( 356 | self, 357 | ghost, 358 | user_agent=default_user_agent, 359 | wait_timeout=8, 360 | wait_callback=None, 361 | display=False, 362 | viewport_size=(800, 600), 363 | ignore_ssl_errors=True, 364 | plugins_enabled=False, 365 | java_enabled=False, 366 | javascript_enabled=True, 367 | download_images=True, 368 | show_scrollbars=True, 369 | exclude=None, 370 | network_access_manager_class=NetworkAccessManager, 371 | web_page_class=GhostWebPage, 372 | local_storage_enabled=True, 373 | ): 374 | self.ghost = ghost 375 | 376 | self.id = str(uuid.uuid4()) 377 | 378 | self.logger = configure( 379 | 'ghost.%s' % self.id, 380 | "Ghost<%s>" % self.id, 381 | ghost.logger.level, 382 | ) 383 | 384 | self.logger.info("Starting new session") 385 | 386 | self.http_resources = [] 387 | 388 | self.wait_timeout = wait_timeout 389 | self.wait_callback = wait_callback 390 | self.ignore_ssl_errors = ignore_ssl_errors 391 | self.loaded = True 392 | 393 | self.display = display 394 | 395 | self.popup_messages = [] 396 | self.page = web_page_class(self.ghost._app, self) 397 | 398 | if network_access_manager_class is not None: 399 | self.page.setNetworkAccessManager( 400 | network_access_manager_class(exclude_regex=exclude)) 401 | 402 | QtWebKit.QWebSettings.setMaximumPagesInCache(0) 403 | QtWebKit.QWebSettings.setObjectCacheCapacities(0, 0, 0) 404 | QtWebKit.QWebSettings.globalSettings().setAttribute( 405 | QtWebKit.QWebSettings.LocalStorageEnabled, local_storage_enabled) 406 | 407 | self.page.setForwardUnsupportedContent(True) 408 | self.page.settings().setAttribute( 409 | QtWebKit.QWebSettings.AutoLoadImages, download_images) 410 | self.page.settings().setAttribute( 411 | QtWebKit.QWebSettings.PluginsEnabled, plugins_enabled) 412 | self.page.settings().setAttribute( 413 | QtWebKit.QWebSettings.JavaEnabled, 414 | java_enabled, 415 | ) 416 | self.page.settings().setAttribute( 417 | QtWebKit.QWebSettings.JavascriptEnabled, javascript_enabled) 418 | 419 | if not show_scrollbars: 420 | self.page.mainFrame().setScrollBarPolicy( 421 | QtCore.Qt.Vertical, 422 | QtCore.Qt.ScrollBarAlwaysOff, 423 | ) 424 | self.page.mainFrame().setScrollBarPolicy( 425 | QtCore.Qt.Horizontal, 426 | QtCore.Qt.ScrollBarAlwaysOff, 427 | ) 428 | 429 | self.set_viewport_size(*viewport_size) 430 | 431 | # Page signals 432 | self.page.loadFinished.connect(self._page_loaded) 433 | self.page.loadStarted.connect(self._page_load_started) 434 | self.page.unsupportedContent.connect(self._unsupported_content) 435 | 436 | self.manager = self.page.networkAccessManager() 437 | self.manager.finished.connect(self._request_ended) 438 | self.manager.sslErrors.connect(self._on_manager_ssl_errors) 439 | 440 | # Cookie jar 441 | self.cookie_jar = QNetworkCookieJar() 442 | self.manager.setCookieJar(self.cookie_jar) 443 | 444 | # User Agent 445 | self.page.set_user_agent(user_agent) 446 | 447 | self.page.networkAccessManager().authenticationRequired\ 448 | .connect(self._authenticate) 449 | self.page.networkAccessManager().proxyAuthenticationRequired\ 450 | .connect(self._authenticate) 451 | 452 | self.main_frame = self.page.mainFrame() 453 | 454 | class GhostQWebView(QtWebKit.QWebView): 455 | def sizeHint(self): 456 | return QSize(*viewport_size) 457 | 458 | self.webview = GhostQWebView() 459 | 460 | if plugins_enabled: 461 | self.webview.settings().setAttribute( 462 | QtWebKit.QWebSettings.PluginsEnabled, True) 463 | if java_enabled: 464 | self.webview.settings().setAttribute( 465 | QtWebKit.QWebSettings.JavaEnabled, True) 466 | 467 | self.webview.setPage(self.page) 468 | 469 | if self.display: 470 | self.show() 471 | 472 | def frame(self, selector=None): 473 | """ Set main frame as current main frame's parent. 474 | 475 | :param frame: An optional name or index of the child to descend to. 476 | """ 477 | if isinstance(selector, basestring): 478 | for frame in self.main_frame.childFrames(): 479 | if frame.frameName() == selector: 480 | self.main_frame = frame 481 | return 482 | # frame not found so we throw an exception 483 | raise LookupError( 484 | "Child frame for name '%s' not found." % selector, 485 | ) 486 | 487 | if isinstance(selector, int): 488 | try: 489 | self.main_frame = self.main_frame.childFrames()[selector] 490 | return 491 | except IndexError: 492 | raise LookupError( 493 | "Child frame at index '%s' not found." % selector, 494 | ) 495 | 496 | # we can't ascend directly to parent frame because it might have been 497 | # deleted 498 | self.main_frame = self.page.mainFrame() 499 | 500 | @can_load_page 501 | def call(self, selector, method): 502 | """Call method on element matching given selector. 503 | 504 | :param selector: A CSS selector to the target element. 505 | :param method: The name of the method to call. 506 | :param expect_loading: Specifies if a page loading is expected. 507 | """ 508 | self.logger.debug('Calling `%s` method on `%s`' % (method, selector)) 509 | element = self.main_frame.findFirstElement(selector) 510 | return element.evaluateJavaScript('this[%s]();' % repr(method)) 511 | 512 | def capture( 513 | self, 514 | region=None, 515 | selector=None, 516 | format=None, 517 | ): 518 | """Returns snapshot as QImage. 519 | 520 | :param region: An optional tuple containing region as pixel 521 | coodinates. 522 | :param selector: A selector targeted the element to crop on. 523 | :param format: The output image format. 524 | """ 525 | 526 | if format is None: 527 | format = QImage.Format_ARGB32_Premultiplied 528 | 529 | self.main_frame.setScrollBarPolicy( 530 | QtCore.Qt.Vertical, 531 | QtCore.Qt.ScrollBarAlwaysOff, 532 | ) 533 | self.main_frame.setScrollBarPolicy( 534 | QtCore.Qt.Horizontal, 535 | QtCore.Qt.ScrollBarAlwaysOff, 536 | ) 537 | frame_size = self.main_frame.contentsSize() 538 | max_size = 23170 * 23170 539 | if frame_size.height() * frame_size.width() > max_size: 540 | self.logger.warn("Frame size is too large.") 541 | default_size = self.page.viewportSize() 542 | if default_size.height() * default_size.width() > max_size: 543 | return None 544 | else: 545 | self.page.setViewportSize(self.main_frame.contentsSize()) 546 | 547 | self.logger.info("Frame size -> " + str(self.page.viewportSize())) 548 | 549 | image = QImage(self.page.viewportSize(), format) 550 | painter = QPainter(image) 551 | 552 | if region is None and selector is not None: 553 | region = self.region_for_selector(selector) 554 | 555 | if region: 556 | x1, y1, x2, y2 = region 557 | w, h = (x2 - x1), (y2 - y1) 558 | reg = QRegion(x1, y1, w, h) 559 | self.main_frame.render(painter, reg) 560 | else: 561 | self.main_frame.render(painter) 562 | 563 | painter.end() 564 | 565 | if region: 566 | x1, y1, x2, y2 = region 567 | w, h = (x2 - x1), (y2 - y1) 568 | image = image.copy(x1, y1, w, h) 569 | 570 | return image 571 | 572 | def capture_to( 573 | self, 574 | path, 575 | region=None, 576 | selector=None, 577 | format=None, 578 | ): 579 | """Saves snapshot as image. 580 | 581 | :param path: The destination path. 582 | :param region: An optional tuple containing region as pixel 583 | coodinates. 584 | :param selector: A selector targeted the element to crop on. 585 | :param format: The output image format. 586 | """ 587 | 588 | if format is None: 589 | format = QImage.Format_ARGB32_Premultiplied 590 | 591 | self.capture(region=region, format=format, 592 | selector=selector).save(path) 593 | 594 | def print_to_pdf( 595 | self, 596 | path, 597 | paper_size=(8.5, 11.0), 598 | paper_margins=(0, 0, 0, 0), 599 | paper_units=None, 600 | zoom_factor=1.0, 601 | ): 602 | """Saves page as a pdf file. 603 | 604 | See qt4 QPrinter documentation for more detailed explanations 605 | of options. 606 | 607 | :param path: The destination path. 608 | :param paper_size: A 2-tuple indicating size of page to print to. 609 | :param paper_margins: A 4-tuple indicating size of each margin. 610 | :param paper_units: Units for pager_size, pager_margins. 611 | :param zoom_factor: Scale the output content. 612 | """ 613 | assert len(paper_size) == 2 614 | assert len(paper_margins) == 4 615 | 616 | if paper_units is None: 617 | paper_units = QPrinter.Inch 618 | 619 | printer = QPrinter(mode=QPrinter.ScreenResolution) 620 | printer.setOutputFormat(QPrinter.PdfFormat) 621 | printer.setPaperSize(QtCore.QSizeF(*paper_size), paper_units) 622 | printer.setPageMargins(*(paper_margins + (paper_units,))) 623 | if paper_margins != (0, 0, 0, 0): 624 | printer.setFullPage(True) 625 | printer.setOutputFileName(path) 626 | if self.webview is None: 627 | self.webview = QtWebKit.QWebView() 628 | self.webview.setPage(self.page) 629 | self.webview.setZoomFactor(zoom_factor) 630 | self.webview.print_(printer) 631 | 632 | @can_load_page 633 | def click(self, selector, btn=0): 634 | """Click the targeted element. 635 | 636 | :param selector: A CSS3 selector to targeted element. 637 | :param btn: The number of mouse button. 638 | 0 - left button, 639 | 1 - middle button, 640 | 2 - right button 641 | """ 642 | if not self.exists(selector): 643 | raise Error("Can't find element to click") 644 | return self.evaluate(""" 645 | (function () { 646 | var element = document.querySelector(%s); 647 | var evt = document.createEvent("MouseEvents"); 648 | evt.initMouseEvent("click", true, true, window, 1, 1, 1, 1, 1, 649 | false, false, false, false, %s, element); 650 | return element.dispatchEvent(evt); 651 | })(); 652 | """ % (repr(selector), str(btn))) 653 | 654 | @contextmanager 655 | def confirm(self, confirm=True): 656 | """Statement that tells Ghost how to deal with javascript confirm(). 657 | 658 | :param confirm: A boolean or a callable to set confirmation. 659 | """ 660 | self._confirm_expected = confirm 661 | yield 662 | self._confirm_expected = None 663 | 664 | @property 665 | def content(self, to_unicode=True): 666 | """Returns current frame HTML as a string. 667 | 668 | :param to_unicode: Whether to convert html to unicode or not 669 | """ 670 | if to_unicode: 671 | return unicode(self.main_frame.toHtml()) 672 | else: 673 | return self.main_frame.toHtml() 674 | 675 | @property 676 | def cookies(self): 677 | """Returns all cookies.""" 678 | return self.cookie_jar.allCookies() 679 | 680 | def delete_cookies(self): 681 | """Deletes all cookies.""" 682 | self.cookie_jar.setAllCookies([]) 683 | 684 | def clear_alert_message(self): 685 | """Clears the alert message""" 686 | self._alert = None 687 | 688 | @can_load_page 689 | def evaluate(self, script): 690 | """Evaluates script in page frame. 691 | 692 | :param script: The script to evaluate. 693 | """ 694 | return ( 695 | self.main_frame.evaluateJavaScript("%s" % script), 696 | self._release_last_resources(), 697 | ) 698 | 699 | def evaluate_js_file(self, path, encoding='utf-8', **kwargs): 700 | """Evaluates javascript file at given path in current frame. 701 | Raises native IOException in case of invalid file. 702 | 703 | :param path: The path of the file. 704 | :param encoding: The file's encoding. 705 | """ 706 | with codecs.open(path, encoding=encoding) as f: 707 | return self.evaluate(f.read(), **kwargs) 708 | 709 | def exists(self, selector): 710 | """Checks if element exists for given selector. 711 | 712 | :param string: The element selector. 713 | """ 714 | return not self.main_frame.findFirstElement(selector).isNull() 715 | 716 | def exit(self): 717 | """Exits all Qt widgets.""" 718 | self.logger.info("Closing session") 719 | self.page.deleteLater() 720 | self.sleep() 721 | del self.webview 722 | del self.cookie_jar 723 | del self.manager 724 | del self.main_frame 725 | 726 | @can_load_page 727 | def fill(self, selector, values): 728 | """Fills a form with provided values. 729 | 730 | :param selector: A CSS selector to the target form to fill. 731 | :param values: A dict containing the values. 732 | """ 733 | if not self.exists(selector): 734 | raise Error("Can't find form") 735 | resources = [] 736 | for field in values: 737 | r, res = self.set_field_value( 738 | "%s [name=%s]" % (selector, repr(field)), values[field]) 739 | resources.extend(res) 740 | return True, resources 741 | 742 | @can_load_page 743 | def fire(self, selector, event): 744 | """Fire `event` on element at `selector` 745 | 746 | :param selector: A selector to target the element. 747 | :param event: The name of the event to trigger. 748 | """ 749 | self.logger.debug('Fire `%s` on `%s`' % (event, selector)) 750 | element = self.main_frame.findFirstElement(selector) 751 | return element.evaluateJavaScript(""" 752 | var event = document.createEvent("HTMLEvents"); 753 | event.initEvent('%s', true, true); 754 | this.dispatchEvent(event); 755 | """ % event) 756 | 757 | def global_exists(self, global_name): 758 | """Checks if javascript global exists. 759 | 760 | :param global_name: The name of the global. 761 | """ 762 | return self.evaluate( 763 | '!(typeof this[%s] === "undefined");' 764 | % repr(global_name) 765 | )[0] 766 | 767 | def hide(self): 768 | """Close the webview.""" 769 | try: 770 | self.webview.close() 771 | except: 772 | raise Error("no webview to close") 773 | 774 | def load_cookies(self, cookie_storage, keep_old=False): 775 | """load from cookielib's CookieJar or Set-Cookie3 format text file. 776 | 777 | :param cookie_storage: file location string on disk or CookieJar 778 | instance. 779 | :param keep_old: Don't reset, keep cookies not overridden. 780 | """ 781 | def toQtCookieJar(PyCookieJar, QtCookieJar): 782 | allCookies = QtCookieJar.allCookies() if keep_old else [] 783 | for pc in PyCookieJar: 784 | qc = toQtCookie(pc) 785 | allCookies.append(qc) 786 | QtCookieJar.setAllCookies(allCookies) 787 | 788 | def toQtCookie(PyCookie): 789 | qc = QNetworkCookie(PyCookie.name, PyCookie.value) 790 | qc.setSecure(PyCookie.secure) 791 | if PyCookie.path_specified: 792 | qc.setPath(PyCookie.path) 793 | if PyCookie.domain != "": 794 | qc.setDomain(PyCookie.domain) 795 | if PyCookie.expires and PyCookie.expires != 0: 796 | t = QDateTime() 797 | t.setTime_t(PyCookie.expires) 798 | qc.setExpirationDate(t) 799 | # not yet handled(maybe less useful): 800 | # py cookie.rest / QNetworkCookie.setHttpOnly() 801 | return qc 802 | 803 | if cookie_storage.__class__.__name__ == 'str': 804 | cj = LWPCookieJar(cookie_storage) 805 | cj.load() 806 | toQtCookieJar(cj, self.cookie_jar) 807 | elif cookie_storage.__class__.__name__.endswith('CookieJar'): 808 | toQtCookieJar(cookie_storage, self.cookie_jar) 809 | else: 810 | raise ValueError('unsupported cookie_storage type.') 811 | 812 | def open( 813 | self, 814 | address, 815 | method='get', 816 | headers={}, 817 | auth=None, 818 | body=None, 819 | default_popup_response=None, 820 | wait=True, 821 | timeout=None, 822 | client_certificate=None, 823 | encode_url=True, 824 | user_agent=None, 825 | ): 826 | """Opens a web page. 827 | 828 | :param address: The resource URL. 829 | :param method: The Http method. 830 | :param headers: An optional dict of extra request hearders. 831 | :param auth: An optional tuple of HTTP auth (username, password). 832 | :param body: An optional string containing a payload. 833 | :param default_popup_response: the default response for any confirm/ 834 | alert/prompt popup from the Javascript (replaces the need for the with 835 | blocks) 836 | :param wait: If set to True (which is the default), this 837 | method call waits for the page load to complete before 838 | returning. Otherwise, it just starts the page load task and 839 | it is the caller's responsibilty to wait for the load to 840 | finish by other means (e.g. by calling wait_for_page_loaded()). 841 | :param timeout: An optional timeout. 842 | :param client_certificate An optional dict with "certificate_path" and 843 | "key_path" both paths corresponding to the certificate and key files 844 | :param encode_url Set to true if the url have to be encoded 845 | :param user_agent An option user agent string. 846 | :return: Page resource, and all loaded resources, unless wait 847 | is False, in which case it returns None. 848 | """ 849 | self.logger.info('Opening %s' % address) 850 | body = body or QByteArray() 851 | try: 852 | method = getattr(QNetworkAccessManager, 853 | "%sOperation" % method.capitalize()) 854 | except AttributeError: 855 | raise Error("Invalid http method %s" % method) 856 | 857 | if user_agent is not None: 858 | self.page.set_user_agent(user_agent) 859 | 860 | if client_certificate: 861 | ssl_conf = QSslConfiguration.defaultConfiguration() 862 | 863 | if "certificate_path" in client_certificate: 864 | try: 865 | certificate = QtNetwork.QSslCertificate.fromPath( 866 | client_certificate["certificate_path"], 867 | QSsl.Pem, 868 | )[0] 869 | except IndexError: 870 | raise Error( 871 | "Can't find certicate in %s" 872 | % client_certificate["certificate_path"] 873 | ) 874 | 875 | ssl_conf.setLocalCertificate(certificate) 876 | 877 | if "key_path" in client_certificate: 878 | private_key = QtNetwork.QSslKey( 879 | open(client_certificate["key_path"]).read(), 880 | QSsl.Rsa, 881 | ) 882 | ssl_conf.setPrivateKey(private_key) 883 | 884 | QSslConfiguration.setDefaultConfiguration(ssl_conf) 885 | 886 | if encode_url: 887 | request = QNetworkRequest(QUrl(address)) 888 | else: 889 | request = QNetworkRequest(QUrl.fromEncoded(address)) 890 | request.CacheLoadControl(0) 891 | for header in headers: 892 | request.setRawHeader(header, headers[header]) 893 | self._auth = auth 894 | self._auth_attempt = 0 # Avoids reccursion 895 | 896 | self.main_frame.load(request, method, body) 897 | self.loaded = False 898 | 899 | if default_popup_response is not None: 900 | self._prompt_expected = default_popup_response 901 | self._confirm_expected = default_popup_response 902 | 903 | if wait: 904 | return self.wait_for_page_loaded(timeout=timeout) 905 | 906 | def scroll_to_anchor(self, anchor): 907 | self.main_frame.scrollToAnchor(anchor) 908 | 909 | @contextmanager 910 | def prompt(self, value=''): 911 | """Statement that tells Ghost how to deal with javascript prompt(). 912 | 913 | :param value: A string or a callable value to fill in prompt. 914 | """ 915 | self._prompt_expected = value 916 | yield 917 | self._prompt_expected = None 918 | 919 | def region_for_selector(self, selector): 920 | """Returns frame region for given selector as tuple. 921 | 922 | :param selector: The targeted element. 923 | """ 924 | geo = self.main_frame.findFirstElement(selector).geometry() 925 | try: 926 | region = (geo.left(), geo.top(), geo.right(), geo.bottom()) 927 | except: 928 | raise Error("can't get region for selector '%s'" % selector) 929 | return region 930 | 931 | def save_cookies(self, cookie_storage): 932 | """Save to cookielib's CookieJar or Set-Cookie3 format text file. 933 | 934 | :param cookie_storage: file location string or CookieJar instance. 935 | """ 936 | def toPyCookieJar(QtCookieJar, PyCookieJar): 937 | for c in QtCookieJar.allCookies(): 938 | PyCookieJar.set_cookie(toPyCookie(c)) 939 | 940 | def toPyCookie(QtCookie): 941 | port = None 942 | port_specified = False 943 | secure = QtCookie.isSecure() 944 | name = str(QtCookie.name()) 945 | value = str(QtCookie.value()) 946 | v = str(QtCookie.path()) 947 | path_specified = bool(v != "") 948 | path = v if path_specified else None 949 | v = str(QtCookie.domain()) 950 | domain_specified = bool(v != "") 951 | domain = v 952 | if domain_specified: 953 | domain_initial_dot = v.startswith('.') 954 | else: 955 | domain_initial_dot = None 956 | v = long(QtCookie.expirationDate().toTime_t()) 957 | # Long type boundary on 32bit platfroms; avoid ValueError 958 | expires = 2147483647 if v > 2147483647 else v 959 | rest = {} 960 | discard = False 961 | return Cookie( 962 | 0, 963 | name, 964 | value, 965 | port, 966 | port_specified, 967 | domain, 968 | domain_specified, 969 | domain_initial_dot, 970 | path, 971 | path_specified, 972 | secure, 973 | expires, 974 | discard, 975 | None, 976 | None, 977 | rest, 978 | ) 979 | 980 | if cookie_storage.__class__.__name__ == 'str': 981 | cj = LWPCookieJar(cookie_storage) 982 | toPyCookieJar(self.cookie_jar, cj) 983 | cj.save() 984 | elif cookie_storage.__class__.__name__.endswith('CookieJar'): 985 | toPyCookieJar(self.cookie_jar, cookie_storage) 986 | else: 987 | raise ValueError('unsupported cookie_storage type.') 988 | 989 | @can_load_page 990 | def set_field_value(self, selector, value, blur=True): 991 | """Sets the value of the field matched by given selector. 992 | 993 | :param selector: A CSS selector that target the field. 994 | :param value: The value to fill in. 995 | :param blur: An optional boolean that force blur when filled in. 996 | """ 997 | self.logger.debug('Setting value "%s" for "%s"' % (value, selector)) 998 | 999 | def _set_checkbox_value(el, value): 1000 | el.setFocus() 1001 | if value is True: 1002 | el.setAttribute('checked', 'checked') 1003 | else: 1004 | el.removeAttribute('checked') 1005 | 1006 | def _set_checkboxes_value(els, value): 1007 | for el in els: 1008 | if el.attribute('value') == value: 1009 | _set_checkbox_value(el, True) 1010 | else: 1011 | _set_checkbox_value(el, False) 1012 | 1013 | def _set_radio_value(els, value): 1014 | for el in els: 1015 | if el.attribute('value') == value: 1016 | el.setFocus() 1017 | el.setAttribute('checked', 'checked') 1018 | 1019 | def _set_text_value(el, value): 1020 | el.setFocus() 1021 | el.setAttribute('value', value) 1022 | 1023 | def _set_select_value(el, value): 1024 | el.setFocus() 1025 | index = 0 1026 | for option in el.findAll('option'): 1027 | if option.attribute('value') == value: 1028 | option.evaluateJavaScript('this.selected = true;') 1029 | el.evaluateJavaScript('this.selectedIndex = %d;' % index) 1030 | break 1031 | index += 1 1032 | 1033 | def _set_textarea_value(el, value): 1034 | el.setFocus() 1035 | el.setPlainText(value) 1036 | 1037 | res, ressources = None, [] 1038 | element = self.main_frame.findFirstElement(selector) 1039 | if element.isNull(): 1040 | raise Error('can\'t find element for %s"' % selector) 1041 | 1042 | tag_name = str(element.tagName()).lower() 1043 | 1044 | if tag_name == "select": 1045 | _set_select_value(element, value) 1046 | elif tag_name == "textarea": 1047 | _set_textarea_value(element, value) 1048 | elif tag_name == "input": 1049 | type_ = str(element.attribute('type')).lower() 1050 | if type_ in [ 1051 | "color", 1052 | "date", 1053 | "datetime", 1054 | "datetime-local", 1055 | "email", 1056 | "hidden", 1057 | "month", 1058 | "number", 1059 | "password", 1060 | "range", 1061 | "search", 1062 | "tel", 1063 | "text", 1064 | "time", 1065 | "url", 1066 | "week", 1067 | "", 1068 | ]: 1069 | _set_text_value(element, value) 1070 | elif type_ == "checkbox": 1071 | els = self.main_frame.findAllElements(selector) 1072 | if els.count() > 1: 1073 | _set_checkboxes_value(els, value) 1074 | else: 1075 | _set_checkbox_value(element, value) 1076 | elif type_ == "radio": 1077 | _set_radio_value( 1078 | self.main_frame.findAllElements(selector), 1079 | value, 1080 | ) 1081 | elif type_ == "file": 1082 | self._upload_file = value 1083 | res, resources = self.click(selector) 1084 | 1085 | self._upload_file = None 1086 | else: 1087 | raise Error('unsupported field tag') 1088 | 1089 | for event in ['input', 'change']: 1090 | self.fire(selector, event) 1091 | 1092 | if blur: 1093 | self.call(selector, 'blur') 1094 | 1095 | return res, ressources 1096 | 1097 | def set_proxy( 1098 | self, 1099 | type_, 1100 | host='localhost', 1101 | port=8888, 1102 | user='', 1103 | password='', 1104 | ): 1105 | """Set up proxy for FURTHER connections. 1106 | 1107 | :param type_: proxy type to use: \ 1108 | none/default/socks5/https/http. 1109 | :param host: proxy server ip or host name. 1110 | :param port: proxy port. 1111 | """ 1112 | _types = { 1113 | 'default': QNetworkProxy.DefaultProxy, 1114 | 'none': QNetworkProxy.NoProxy, 1115 | 'socks5': QNetworkProxy.Socks5Proxy, 1116 | 'https': QNetworkProxy.HttpProxy, 1117 | 'http': QNetworkProxy.HttpCachingProxy 1118 | } 1119 | 1120 | if type_ is None: 1121 | type_ = 'none' 1122 | type_ = type_.lower() 1123 | if type_ in ['none', 'default']: 1124 | self.manager.setProxy(QNetworkProxy(_types[type_])) 1125 | return 1126 | elif type_ in _types: 1127 | proxy = QNetworkProxy( 1128 | _types[type_], 1129 | hostName=host, 1130 | port=port, 1131 | user=user, 1132 | password=password, 1133 | ) 1134 | self.manager.setProxy(proxy) 1135 | else: 1136 | raise ValueError( 1137 | 'Unsupported proxy type: %s' % type_ + 1138 | '\nsupported types are: none/socks5/http/https/default', 1139 | ) 1140 | 1141 | def set_viewport_size(self, width, height): 1142 | """Sets the page viewport size. 1143 | 1144 | :param width: An integer that sets width pixel count. 1145 | :param height: An integer that sets height pixel count. 1146 | """ 1147 | self.page.setViewportSize(QSize(width, height)) 1148 | 1149 | def append_popup_message(self, message): 1150 | self.popup_messages.append(unicode(message)) 1151 | 1152 | def show(self): 1153 | """Show current page inside a QWebView. 1154 | """ 1155 | self.logger.debug('Showing webview') 1156 | self.webview.show() 1157 | self.sleep() 1158 | 1159 | def sleep(self, value=0.1): 1160 | started_at = time.time() 1161 | 1162 | while time.time() <= (started_at + value): 1163 | time.sleep(0.01) 1164 | self.ghost._app.processEvents() 1165 | 1166 | def wait_for(self, condition, timeout_message, timeout=None): 1167 | """Waits until condition is True. 1168 | 1169 | :param condition: A callable that returns the condition. 1170 | :param timeout_message: The exception message on timeout. 1171 | :param timeout: An optional timeout. 1172 | """ 1173 | timeout = self.wait_timeout if timeout is None else timeout 1174 | started_at = time.time() 1175 | while not condition(): 1176 | if time.time() > (started_at + timeout): 1177 | raise TimeoutError(timeout_message) 1178 | self.sleep() 1179 | if self.wait_callback is not None: 1180 | self.wait_callback() 1181 | 1182 | def wait_for_alert(self, timeout=None): 1183 | """Waits for main frame alert(). 1184 | 1185 | :param timeout: An optional timeout. 1186 | """ 1187 | self.wait_for(lambda: self._alert is not None, 1188 | 'User has not been alerted.', timeout) 1189 | msg = self._alert 1190 | self._alert = None 1191 | return msg, self._release_last_resources() 1192 | 1193 | def wait_for_page_loaded(self, timeout=None): 1194 | """Waits until page is loaded, assumed that a page as been requested. 1195 | 1196 | :param timeout: An optional timeout. 1197 | """ 1198 | self.wait_for(lambda: self.loaded, 1199 | 'Unable to load requested page', timeout) 1200 | resources = self._release_last_resources() 1201 | page = None 1202 | 1203 | url = self.main_frame.url().toString() 1204 | url_without_hash = url.split("#")[0] 1205 | 1206 | for resource in resources: 1207 | if url == resource.url or url_without_hash == resource.url: 1208 | page = resource 1209 | 1210 | self.logger.info('Page loaded %s' % url) 1211 | 1212 | return page, resources 1213 | 1214 | def wait_for_selector(self, selector, timeout=None): 1215 | """Waits until selector match an element on the frame. 1216 | 1217 | :param selector: The selector to wait for. 1218 | :param timeout: An optional timeout. 1219 | """ 1220 | self.wait_for( 1221 | lambda: self.exists(selector), 1222 | 'Can\'t find element matching "%s"' % selector, 1223 | timeout, 1224 | ) 1225 | return True, self._release_last_resources() 1226 | 1227 | def wait_while_selector(self, selector, timeout=None): 1228 | """Waits until the selector no longer matches an element on the frame. 1229 | 1230 | :param selector: The selector to wait for. 1231 | :param timeout: An optional timeout. 1232 | """ 1233 | self.wait_for( 1234 | lambda: not self.exists(selector), 1235 | 'Element matching "%s" is still available' % selector, 1236 | timeout, 1237 | ) 1238 | return True, self._release_last_resources() 1239 | 1240 | def wait_for_text(self, text, timeout=None): 1241 | """Waits until given text appear on main frame. 1242 | 1243 | :param text: The text to wait for. 1244 | :param timeout: An optional timeout. 1245 | """ 1246 | self.wait_for( 1247 | lambda: text in self.content, 1248 | 'Can\'t find "%s" in current frame' % text, 1249 | timeout, 1250 | ) 1251 | return True, self._release_last_resources() 1252 | 1253 | def _authenticate(self, mix, authenticator): 1254 | """Called back on basic / proxy http auth. 1255 | 1256 | :param mix: The QNetworkReply or QNetworkProxy object. 1257 | :param authenticator: The QAuthenticator object. 1258 | """ 1259 | if self._auth is not None and self._auth_attempt == 0: 1260 | username, password = self._auth 1261 | authenticator.setUser(username) 1262 | authenticator.setPassword(password) 1263 | self._auth_attempt += 1 1264 | 1265 | def _page_loaded(self): 1266 | """Called back when page is loaded. 1267 | """ 1268 | self.loaded = True 1269 | self.sleep() 1270 | 1271 | def _page_load_started(self): 1272 | """Called back when page load started. 1273 | """ 1274 | self.loaded = False 1275 | 1276 | def _release_last_resources(self): 1277 | """Releases last loaded resources. 1278 | 1279 | :return: The released resources. 1280 | """ 1281 | last_resources = self.http_resources 1282 | self.http_resources = [] 1283 | return last_resources 1284 | 1285 | def _request_ended(self, reply): 1286 | """Adds an HttpResource object to http_resources. 1287 | 1288 | :param reply: The QNetworkReply object. 1289 | """ 1290 | 1291 | if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute): 1292 | self.logger.debug("[%s] bytesAvailable()= %s" % ( 1293 | str(reply.url()), 1294 | reply.bytesAvailable() 1295 | )) 1296 | 1297 | try: 1298 | content = reply.data 1299 | except AttributeError: 1300 | content = reply.readAll() 1301 | 1302 | self.http_resources.append(HttpResource( 1303 | self, 1304 | reply, 1305 | content=content, 1306 | )) 1307 | 1308 | def _unsupported_content(self, reply): 1309 | self.logger.info("Unsupported content %s" % ( 1310 | str(reply.url()), 1311 | )) 1312 | 1313 | reply.readyRead.connect( 1314 | lambda reply=reply: self._reply_download_content(reply)) 1315 | 1316 | def _reply_download_content(self, reply): 1317 | """Adds an HttpResource object to http_resources with unsupported 1318 | content. 1319 | 1320 | :param reply: The QNetworkReply object. 1321 | """ 1322 | if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute): 1323 | self.http_resources.append(HttpResource( 1324 | self, 1325 | reply, 1326 | reply.readAll(), 1327 | )) 1328 | 1329 | def _on_manager_ssl_errors(self, reply, errors): 1330 | url = unicode(reply.url().toString()) 1331 | if self.ignore_ssl_errors: 1332 | reply.ignoreSslErrors() 1333 | else: 1334 | self.logger.warn('SSL certificate error: %s' % url) 1335 | 1336 | def __enter__(self): 1337 | return self 1338 | 1339 | def __exit__(self, exc_type, exc_val, exc_tb): 1340 | self.exit() 1341 | --------------------------------------------------------------------------------