├── 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 |
12 | {% for message in messages %}
13 | - {{ message }}
14 | {% endfor %}
15 |
16 | {% endif %}
17 | {% endwith %}
18 | Links
19 | link
20 |
27 | Forms
28 |
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 |
--------------------------------------------------------------------------------