[ADD] base_import: support for XLS and ODS files

* added intermediate ``_read_file`` step dispatching between CSV, ODS
  and XLS(X) parsing
  - primary dispatch on mime type, secondary on file extension:
    + OS may not provide a relevant mime type if no software locally
      installed for the filetype (e.g. Windows sends excel files as
      application/octet-stream if excel is not installed, and ODS files as
      zip if OpenOffice/LibreOffice isn't installed)
    + applications may re-register extensions to non-standard mimetypes
      breaking the dispatcher

    So if the mimetype is found trust it, otherwise try with the
    filename's extension (if any)
  - all readers skip lines with only empty cells in their output
  - ODS and XLS content are "CSVified", rows are converted to arrays of
    unicode strings
* UI altered to not assume CSV files everywhere, and avoid returning
  garbage preview data for non-CSV imports

Various:
* added a ``can_import`` utility function to tests.common, can be used
  to skip tests if an optional Python dependency is not installed (but
  one would like tests to run if the dependency is available)
* fixed datetime issue in ir_fields
* added converter for monetary fields (iso float)
* spreadsheet don't have integers, twiddling required to ensure integral
  values won't be serialized as floats (breaking conversion back to
  Python)
* improved some tests by asserting no error is generated (bonus: logs
  the error message if there is one, rather than just saying the result
  is blown)
* because some systems only provide elderly versions of
  XLRD (e.g. current debian stable provides 0.9.2 from April 2013)
  - keep using xldate_as_tuple instead of 0.9.3's xldate_as_datetime,
    workaround is simple
  - check for xlrd.xlsx in case of pre-0.8 XLRD

Authorship:
Ronak Baxi <rba@odoo.com>
Mohammed Shekha <msh@openerp.com>

Task 10792
Closes #7285
This commit is contained in:
Xavier Morel
2015-08-26 16:50:09 +02:00
parent aa6ee3fddf
commit f5a1693f06
11 changed files with 360 additions and 44 deletions
+110 -16
View File
@@ -1,7 +1,10 @@
import csv
import datetime
import io
import itertools
import logging
import operator
import os
try:
from cStringIO import StringIO
@@ -12,10 +15,38 @@ import psycopg2
from openerp.osv import orm, fields
from openerp.tools.translate import _
from openerp.tools import DEFAULT_SERVER_DATE_FORMAT, \
DEFAULT_SERVER_DATETIME_FORMAT
FIELDS_RECURSION_LIMIT = 2
ERROR_PREVIEW_BYTES = 200
_logger = logging.getLogger(__name__)
try:
import xlrd
try:
from xlrd import xlsx
except ImportError:
xlsx = None
except ImportError:
xlrd = xlsx = None
try:
import odf_ods_reader
except ImportError:
odf_ods_reader = None
FILE_TYPE_DICT = {
'text/csv': ('csv', True, None),
'application/vnd.ms-excel': ('xls', xlrd, 'xlrd'),
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ('xlsx', xlsx, 'xlrd >= 0.8'),
'application/vnd.oasis.opendocument.spreadsheet': ('ods', odf_ods_reader, 'odfpy')
}
EXTENSIONS = {
'.' + ext: handler
for mime, (ext, handler, req) in FILE_TYPE_DICT.iteritems()
}
class ir_import(orm.TransientModel):
_name = 'base_import.import'
# allow imports to survive for 12h in case user is slow
@@ -26,7 +57,7 @@ class ir_import(orm.TransientModel):
'file': fields.binary(
'File', help="File to check and/or import, raw binary (not base64)"),
'file_name': fields.char('File Name'),
'file_type': fields.char('File Type'),
'file_type': fields.char(string='File Type'),
}
def get_fields(self, cr, uid, model, context=None,
@@ -126,6 +157,69 @@ class ir_import(orm.TransientModel):
# TODO: cache on model?
return fields
def _read_file(self, file_type, record, options):
(file_extension, handler, req) = FILE_TYPE_DICT.get(file_type, (None, None, None))
if handler:
return getattr(self, '_read_' + file_extension)(record, options)
# fallback on file extensions as mime types can be unreliable (e.g.
# software setting incorrect mime types, or non-installed software
# leading to browser not sending mime types)
if record.file_name:
p, ext = os.path.splitext(record.file_name)
if ext and EXTENSIONS.get(ext):
return getattr(self, '_read_' + ext[1:])(record, options)
if req:
raise ImportError(_("Unable to load \"{extension}\" file: requires Python module \"{modname}\"").format(extension=file_extension, modname=req))
raise ValueError(_("Unsupported file format \"{}\", import only supports CSV, ODS, XLS and XLSX").format(file_type))
def _read_xls(self, record, options):
book = xlrd.open_workbook(file_contents=record.file)
sheet = book.sheet_by_index(0)
# emulate Sheet.get_rows for pre-0.9.4
for row in itertools.imap(sheet.row, range(sheet.nrows)):
values = []
for cell in row:
if cell.ctype is xlrd.XL_CELL_NUMBER:
is_float = cell.value % 1 != 0.0
values.append(
unicode(cell.value)
if is_float
else unicode(int(cell.value))
)
elif cell.ctype is xlrd.XL_CELL_DATE:
is_datetime = cell.value % 1 != 0.0
# emulate xldate_as_datetime for pre-0.9.3
dt = datetime.datetime(*xlrd.xldate.xldate_as_tuple(
cell.value, book.datemode))
values.append(
dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
if is_datetime
else dt.strftime(DEFAULT_SERVER_DATE_FORMAT)
)
elif cell.ctype is xlrd.XL_CELL_BOOLEAN:
values.append(u'True' if cell.value else u'False')
elif cell.ctype is xlrd.XL_CELL_ERROR:
raise ValueError(
_("Error cell found while reading XLS/XLSX file: %s") %
xlrd.error_text_from_code.get(
cell.value, "unknown error code %s" % cell.value)
)
else:
values.append(cell.value)
if any(x for x in values if x.strip()):
yield values
_read_xlsx = _read_xls
def _read_ods(self, record, options):
doc = odf_ods_reader.ODSReader(file=io.BytesIO(record.file))
return (
row
for row in doc.getFirstSheet()
if any(x for x in row if x.strip())
)
def _read_csv(self, record, options):
""" Returns a CSV-parsed iterator of all empty lines in the file
@@ -137,15 +231,13 @@ class ir_import(orm.TransientModel):
quotechar=str(options['quoting']),
delimiter=str(options['separator']))
def nonempty(row):
return any(x for x in row if x.strip())
csv_nonempty = itertools.ifilter(nonempty, csv_iterator)
# TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet
encoding = options.get('encoding', 'utf-8')
return itertools.imap(
lambda row: [item.decode(encoding) for item in row],
csv_nonempty)
return (
[item.decode(encoding) for item in row]
for row in csv_iterator
if any(x for x in row if x.strip())
)
def _match_header(self, header, fields, options):
""" Attempts to match a given header to a field of the
@@ -211,10 +303,10 @@ class ir_import(orm.TransientModel):
return None, None
headers = next(rows)
return headers, dict(
(index, [field['name'] for field in self._match_header(header, fields, options)] or None)
return headers, {
index: [field['name'] for field in self._match_header(header, fields, options)] or None
for index, header in enumerate(headers)
)
}
def parse_preview(self, cr, uid, id, options, count=10, context=None):
""" Generates a preview of the uploaded files, and performs
@@ -236,7 +328,7 @@ class ir_import(orm.TransientModel):
fields = self.get_fields(cr, uid, record.res_model, context=context)
try:
rows = self._read_csv(record, options)
rows = self._read_file(record.file_type, record, options)
headers, matches = self._match_headers(rows, fields, options)
# Match should have consumed the first row (iif headers), get
@@ -253,15 +345,17 @@ class ir_import(orm.TransientModel):
# Due to lazy generators, UnicodeDecodeError (for
# instance) may only be raised when serializing the
# preview to a list in the return.
_logger.debug("Error during CSV parsing preview", exc_info=True)
_logger.debug("Error during parsing preview", exc_info=True)
preview = None
if record.file_type == 'text/csv':
preview = record.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1')
return {
'error': str(e),
# iso-8859-1 ensures decoding will always succeed,
# even if it yields non-printable characters. This is
# in case of UnicodeDecodeError (or csv.Error
# compounded with UnicodeDecodeError)
'preview': record.file[:ERROR_PREVIEW_BYTES]
.decode( 'iso-8859-1'),
'preview': preview,
}
def _convert_import_data(self, record, fields, options, context=None):
@@ -287,7 +381,7 @@ class ir_import(orm.TransientModel):
# Get only list of actually imported fields
import_fields = filter(None, fields)
rows_to_import = self._read_csv(record, options)
rows_to_import = self._read_file(record.file_type, record, options)
if options.get('headers'):
rows_to_import = itertools.islice(
rows_to_import, 1, None)
+98
View File
@@ -0,0 +1,98 @@
# Copyright 2011 Marco Conti
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# sourced from https://github.com/marcoconti83/read-ods-with-odfpy
# further altered locally
from odf import opendocument
from odf.table import Table, TableRow, TableCell
from odf.text import P
class ODSReader(object):
# loads the file
def __init__(self, file=None, content=None, clonespannedcolumns=None):
if not content:
self.clonespannedcolumns = clonespannedcolumns
self.doc = opendocument.load(file)
else:
self.clonespannedcolumns = clonespannedcolumns
self.doc = content
self.SHEETS = {}
for sheet in self.doc.spreadsheet.getElementsByType(Table):
self.readSheet(sheet)
# reads a sheet in the sheet dictionary, storing each sheet as an
# array (rows) of arrays (columns)
def readSheet(self, sheet):
name = sheet.getAttribute("name")
rows = sheet.getElementsByType(TableRow)
arrRows = []
# for each row
for row in rows:
arrCells = []
cells = row.getElementsByType(TableCell)
# for each cell
for count, cell in enumerate(cells, start=1):
# repeated value?
repeat = 0
if count != len(cells):
repeat = cell.getAttribute("numbercolumnsrepeated")
if not repeat:
repeat = 1
spanned = int(cell.getAttribute('numbercolumnsspanned') or 0)
# clone spanned cells
if self.clonespannedcolumns is not None and spanned > 1:
repeat = spanned
ps = cell.getElementsByType(P)
textContent = u""
# for each text/text:span node
for p in ps:
for n in p.childNodes:
if n.nodeType == 1 and n.tagName == "text:span":
for c in n.childNodes:
if c.nodeType == 3:
textContent = u'{}{}'.format(textContent, n.data)
if n.nodeType == 3:
textContent = u'{}{}'.format(textContent, n.data)
if textContent:
if not textContent.startswith("#"): # ignore comments cells
for rr in range(int(repeat)): # repeated?
arrCells.append(textContent)
else:
for rr in range(int(repeat)):
arrCells.append("")
# if row contained something
if arrCells:
arrRows.append(arrCells)
#else:
# print ("Empty or commented row (", row_comment, ")")
self.SHEETS[name] = arrRows
# returns a sheet as an array (rows) of arrays (columns)
def getSheet(self, name):
return self.SHEETS[name]
def getFirstSheet(self):
return next(iter(self.SHEETS.itervalues()))
+2 -1
View File
@@ -144,7 +144,7 @@ var DataImport = Widget.extend(ControlPanelMixin, {
this.id = null;
this.Import = new Model('base_import.import');
this.session = session;
action.display_name = _t('Import a CSV File'); // Displayed in the breadcrumbs
action.display_name = _t('Import a File'); // Displayed in the breadcrumbs
},
start: function () {
var self = this;
@@ -237,6 +237,7 @@ var DataImport = Widget.extend(ControlPanelMixin, {
if (!this.$('input.oe_import_file').val()) { return; }
this.$el.removeClass('oe_import_preview oe_import_error');
this.$el.find('.oe_import_toggle').toggle((this.$('input.oe_import_file')[0].files[0].type == "text/csv"));
jsonp(this.$el, {
url: '/base_import/set_file'
}, this.proxy('settings_changed'));
+8 -11
View File
@@ -5,15 +5,12 @@
<input type="hidden" name="session_id"
t-att-value="widget.session.session_id"/>
<input type="hidden" name="import_id"/>
<p>Select the <a
href="http://en.wikipedia.org/wiki/Comma-separated_values"
target="_blank">.CSV</a>
file to import. If you need a sample importable file, you
<p>Select the file to import. If you need a sample importable file, you
can use the export tool to generate one.</p>
<div class="oe_import_box">
<label t-attf-for="file_#{_id}" autofocus="autofocus">CSV File:</label>
<label t-attf-for="file_#{_id}" autofocus="autofocus">File:</label>
<input type="file" id-attf-id="file_#{_id}"
name="file" class="oe_import_file" accept=".csv"/>
name="file" class="oe_import_file" accept=".csv, .xls, .xlsx, .ods"/>
<button type="button" class="oe_import_file_reload"
disabled="disabled"
title="Reload data to check changes.">
@@ -21,7 +18,7 @@
</button>
<div class="oe_import_with_file">
<a href="#" class="oe_import_toggle">
File Format Options…</a>
CSV Format Options…</a>
<div class="oe_import_toggled oe_import_options">
<p t-foreach="widget.opts" t-as="option">
<!-- no @name, avoid submission when file_update called -->
@@ -363,11 +360,11 @@
</t>
<t t-name="ImportView.preview.error">
<div class="oe_import_report oe_import_report_error">
<p>Import preview failed due to: <t t-esc="error"/>. The issue is
usually an incorrect file encoding.</p>
<p>Here is the start of the file we could not import:</p>
<p>Import preview failed due to: <t t-esc="error"/>.</p>
<p>For CSV files, the issue could be an incorrect encoding.</p>
<p t-if="preview">Here is the start of the file we could not import:</p>
</div>
<pre><t t-esc="preview"/></pre>
<pre t-if="preview"><t t-esc="preview"/></pre>
</t>
<ul t-name="ImportView.error">
<li t-foreach="errors" t-as="error"
Binary file not shown.
Binary file not shown.
Binary file not shown.
+121 -12
View File
@@ -1,6 +1,9 @@
# -*- encoding: utf-8 -*-
import unittest2
from openerp.tests.common import TransactionCase
import unittest
from openerp.tests.common import TransactionCase, can_import
from openerp.modules.module import get_module_resource
from .. import models
@@ -189,6 +192,8 @@ class test_preview(TransactionCase):
id = Import.create(self.cr, self.uid, {
'res_model': 'res.users',
'file': u"로그인,언어\nbob,1\n".encode('euc_kr'),
'file_type': 'text/csv',
'file_name': 'kr_data.csv',
})
return Import, id
@@ -217,14 +222,15 @@ class test_preview(TransactionCase):
})
self.assertTrue('error' in result)
def test_success(self):
def test_csv_success(self):
Import = self.registry('base_import.import')
id = Import.create(self.cr, self.uid, {
'res_model': 'base_import.tests.models.preview',
'file': 'name,Some Value,Counter\n'
'foo,1,2\n'
'bar,3,4\n'
'qux,5,6\n'
'qux,5,6\n',
'file_type': 'text/csv'
})
result = Import.parse_preview(self.cr, self.uid, id, {
@@ -232,7 +238,7 @@ class test_preview(TransactionCase):
'separator': ',',
'headers': True,
})
self.assertIsNone(result.get('error'))
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None})
self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter'])
# Order depends on iteration order of fields_get
@@ -250,6 +256,100 @@ class test_preview(TransactionCase):
# Ensure we only have the response fields we expect
self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview'])
@unittest.skipUnless(can_import('xlrd'), "XLRD module not available")
def test_xls_success(self):
Import = self.registry('base_import.import')
xls_file_path = get_module_resource('base_import', 'tests', 'test.xls')
file_content = open(xls_file_path, 'rb').read()
id = Import.create(self.cr, self.uid, {
'res_model': 'base_import.tests.models.preview',
'file': file_content,
'file_type': 'application/vnd.ms-excel'
})
result = Import.parse_preview(self.cr, self.uid, id, {
'headers': True,
})
self.assertIsNone(result.get('error'))
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None})
self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter'])
self.assertItemsEqual(result['fields'], [
ID_FIELD,
{'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []},
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []},
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []},
])
self.assertEqual(result['preview'], [
['foo', '1', '2'],
['bar', '3', '4'],
['qux', '5', '6'],
])
# Ensure we only have the response fields we expect
self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview'])
@unittest.skipUnless(can_import('xlrd.xlsx'), "XLRD/XLSX not available")
def test_xlsx_success(self):
Import = self.registry('base_import.import')
xlsx_file_path = get_module_resource('base_import', 'tests', 'test.xlsx')
file_content = open(xlsx_file_path, 'rb').read()
id = Import.create(self.cr, self.uid, {
'res_model': 'base_import.tests.models.preview',
'file': file_content,
'file_type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
result = Import.parse_preview(self.cr, self.uid, id, {
'headers': True,
})
self.assertIsNone(result.get('error'))
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None})
self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter'])
self.assertItemsEqual(result['fields'], [
ID_FIELD,
{'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []},
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []},
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []},
])
self.assertEqual(result['preview'], [
['foo', '1', '2'],
['bar', '3', '4'],
['qux', '5', '6'],
])
# Ensure we only have the response fields we expect
self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview'])
@unittest.skipUnless(can_import('odf'), "ODFPY not available")
def test_ods_success(self):
Import = self.registry('base_import.import')
ods_file_path = get_module_resource('base_import', 'tests', 'test.ods')
file_content = open(ods_file_path, 'rb').read()
id = Import.create(self.cr, self.uid, {
'res_model': 'base_import.tests.models.preview',
'file': file_content,
'file_type': 'application/vnd.oasis.opendocument.spreadsheet'
})
result = Import.parse_preview(self.cr, self.uid, id, {
'headers': True,
})
self.assertIsNone(result.get('error'))
self.assertEqual(result['matches'], {0: ['name'], 1: ['somevalue'], 2: None})
self.assertEqual(result['headers'], ['name', 'Some Value', 'Counter'])
self.assertItemsEqual(result['fields'], [
ID_FIELD,
{'id': 'name', 'name': 'name', 'string': 'Name', 'required':False, 'fields': []},
{'id': 'somevalue', 'name': 'somevalue', 'string': 'Some Value', 'required':True, 'fields': []},
{'id': 'othervalue', 'name': 'othervalue', 'string': 'Other Variable', 'required':False, 'fields': []},
])
self.assertEqual(result['preview'], [
['foo', '1', '2'],
['bar', '3', '4'],
['aux', '5', '6'],
])
# Ensure we only have the response fields we expect
self.assertItemsEqual(result.keys(), ['matches', 'headers', 'fields', 'preview'])
class test_convert_import_data(TransactionCase):
""" Tests conversion of base_import.import input into data which
can be fed to Model.import_data
@@ -261,7 +361,9 @@ class test_convert_import_data(TransactionCase):
'file': 'name,Some Value,Counter\n'
'foo,1,2\n'
'bar,3,4\n'
'qux,5,6\n'
'qux,5,6\n',
'file_type': 'text/csv'
})
record = Import.browse(self.cr, self.uid, id)
data, fields = Import._convert_import_data(
@@ -285,7 +387,8 @@ class test_convert_import_data(TransactionCase):
'file': 'name,Some Value,Counter\n'
'foo,1,2\n'
'bar,3,4\n'
'qux,5,6\n'
'qux,5,6\n',
'file_type': 'text/csv'
})
record = Import.browse(self.cr, self.uid, id)
data, fields = Import._convert_import_data(
@@ -309,7 +412,8 @@ class test_convert_import_data(TransactionCase):
'file': 'name,Some Value,Counter\n'
'foo,1,2\n'
',3,\n'
',5,6\n'
',5,6\n',
'file_type': 'text/csv'
})
record = Import.browse(self.cr, self.uid, id)
data, fields = Import._convert_import_data(
@@ -331,7 +435,8 @@ class test_convert_import_data(TransactionCase):
'\n'
'bar,2\n'
' \n'
'\t \n'
'\t \n',
'file_type': 'text/csv'
})
record = Import.browse(self.cr, self.uid, id)
data, fields = Import._convert_import_data(
@@ -350,7 +455,9 @@ class test_convert_import_data(TransactionCase):
id = Import.create(self.cr, self.uid, {
'res_model': 'base_import.tests.models.preview',
'file': 'name,Some Value,Counter\n'
'foo,1,2\n'
'foo,1,2\n',
'file_type': 'text/csv'
})
record = Import.browse(self.cr, self.uid, id)
@@ -366,7 +473,8 @@ class test_convert_import_data(TransactionCase):
id = Import.create(self.cr, self.uid, {
'res_model': 'base_import.tests.models.preview',
'file': 'name,Some Value,Counter\n'
'foo,1,2\n'
'foo,1,2\n',
'file_type': 'text/csv'
})
record = Import.browse(self.cr, self.uid, id)
@@ -397,7 +505,8 @@ class test_failures(TransactionCase):
Import = self.env['base_import.import']
imp = Import.create({
'res_model': 'ir.attachment',
'file': fout.getvalue()
'file': fout.getvalue(),
'file_type': 'text/csv'
})
[results] = imp.do(
['name', 'db_datas'],
+1 -1
View File
@@ -18,7 +18,7 @@ class base_config_settings(osv.osv_memory):
'module_portal': fields.boolean('Activate the customer portal',
help="""Give your customers access to their documents."""),
'module_auth_oauth': fields.boolean('Use external authentication providers, sign in with Google...'),
'module_base_import': fields.boolean("Allow users to import data from CSV files"),
'module_base_import': fields.boolean("Allow users to import data from CSV/XLS/XLSX/ODS files"),
'module_google_drive': fields.boolean('Attach Google documents to any record',
help="""This installs the module google_docs."""),
'module_google_calendar': fields.boolean('Allow the users to synchronize their calendar with Google Calendar',
+4 -3
View File
@@ -7,7 +7,7 @@ import time
import psycopg2
import pytz
from openerp import models, api, _
from openerp import models, fields, api, _
from openerp.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT, ustr
REFERENCING_FIELDS = {None, 'id', '.id'}
@@ -189,6 +189,8 @@ class ir_fields_converter(models.AbstractModel):
value
)
_str_to_monetary = _str_to_float
@api.model
def _str_id(self, model, field, value):
return value, []
@@ -231,8 +233,7 @@ class ir_fields_converter(models.AbstractModel):
@api.model
def _str_to_datetime(self, model, field, value):
try:
parsed_value = datetime.datetime.strptime(
value, DEFAULT_SERVER_DATETIME_FORMAT)
parsed_value = fields.Datetime.from_string(value)
except ValueError:
raise self._format_import_error(
ValueError,
+16
View File
@@ -6,6 +6,7 @@ helpers and classes to write tests.
"""
import errno
import glob
import importlib
import json
import logging
import os
@@ -392,3 +393,18 @@ class HttpCase(TransactionCase):
phantomtest = os.path.join(os.path.dirname(__file__), 'phantomtest.js')
cmd = ['phantomjs', phantomtest, json.dumps(options)]
self.phantom_run(cmd, timeout)
def can_import(module):
""" Checks if <module> can be imported, returns ``True`` if it can be,
``False`` otherwise.
To use with ``unittest.skipUnless`` for tests conditional on *optional*
dependencies, which may or may be present but must still be tested if
possible.
"""
try:
importlib.import_module(module)
except ImportError:
return False
else:
return True