diff --git a/.editorconfig b/.editorconfig index 585e2ab..7aaea74 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,23 +1,12 @@ -# http://editorconfig.org +# See https://editorconfig.org for format details and +# https://editorconfig.org/#download for editor / IDE integration root = true [*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{py,rst,ini}] indent_style = space indent_size = 4 - -[*.{html,css,scss,json,yml}] -indent_style = space -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false - -[Makefile] -indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..2bcd70e --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 88 diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..81ab233 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,8 @@ +[settings] +default_section=THIRDPARTY +force_grid_wrap=0 +include_trailing_comma=True +known_first_party=example,example_utils +line_length=88 +multi_line_output=3 +use_parentheses=True diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9fcf3ae --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: + - repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.17 + hooks: + - id: isort + - repo: https://github.com/ambv/black + rev: 19.3b0 + hooks: + - id: black + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.2.1 + hooks: + - id: check-added-large-files + args: ["--maxkb=128"] + - id: check-ast + - id: check-byte-order-marker + - id: check-case-conflict + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: detect-aws-credentials + args: ["--allow-missing-credentials"] + - id: detect-private-key + - id: end-of-file-fixer + - id: mixed-line-ending + args: ["--fix=lf"] + - id: pretty-format-json + args: ["--autofix", "--no-sort-keys", "--indent=4"] + - id: trailing-whitespace + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.7 + hooks: + - id: flake8 diff --git a/.travis.yml b/.travis.yml index 45b270c..a43111b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,14 @@ +dist: xenial + language: python cache: - apt: true pip: true python: - "3.5" - "3.6" + - "3.7" - "2.7" install: pip install -r requirements-test.txt diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ac69044..791a15e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -36,5 +36,3 @@ Other v1.0.0 (2016-03-04) ------------------- - Initial Release. [Chris Adams] - - diff --git a/docs/conf.py b/docs/conf.py index e381301..1155bfd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,29 +25,31 @@ import tabular_export # Avoid import errors from our use of Django utilities: -settings.configure(CACHES={'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}}) +settings.configure( + CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}} +) # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'django-tabular-export' +project = u"django-tabular-export" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -60,106 +62,106 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. html_show_copyright = False @@ -167,54 +169,57 @@ # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'django-tabular-exportdoc' +htmlhelp_basename = "django-tabular-exportdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'django-tabular-export.tex', u'django-tabular-export Documentation', - u'Chris Adams', 'manual'), + ( + "index", + "django-tabular-export.tex", + u"django-tabular-export Documentation", + u"Chris Adams", + "manual", + ) ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- @@ -222,12 +227,17 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'django-tabular-export', u'django-tabular-export Documentation', - [u'Chris Adams'], 1) + ( + "index", + "django-tabular-export", + u"django-tabular-export Documentation", + [u"Chris Adams"], + 1, + ) ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -236,19 +246,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'django-tabular-export', u'django-tabular-export Documentation', - u'Chris Adams', 'django-tabular-export', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "django-tabular-export", + u"django-tabular-export Documentation", + u"Chris Adams", + "django-tabular-export", + "One line description of project.", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/requirements.txt b/requirements.txt index 80576c3..5cd3725 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ django -xlsxwriter \ No newline at end of file +xlsxwriter diff --git a/setup.py b/setup.py index 924a903..a69cbf2 100755 --- a/setup.py +++ b/setup.py @@ -2,44 +2,35 @@ # encoding: utf-8 from __future__ import absolute_import, division, print_function -import os -import re -import sys - from setuptools import setup -readme = open('README.rst').read() +readme = open("README.rst").read() setup( - name='django-tabular-export', - version='1.1.0', + name="django-tabular-export", + version="1.1.0", description="""Simple spreadsheet exports from Django""", long_description=readme, - author='Chris Adams', - author_email='cadams@loc.gov', - url='https://github.com/LibraryOfCongress/django-tabular-export', - packages=[ - 'tabular_export', - ], + author="Chris Adams", + author_email="cadams@loc.gov", + url="https://github.com/LibraryOfCongress/django-tabular-export", + packages=["tabular_export"], include_package_data=True, - install_requires=[ - 'Django', - 'xlsxwriter', - ], - test_suite='tests.run_tests.run_tests', - license='CC0', + install_requires=["Django", "xlsxwriter"], + test_suite="tests.run_tests.run_tests", + license="CC0", zip_safe=False, classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Framework :: Django', - 'Framework :: Django :: 1.8', - 'Framework :: Django :: 1.9', - 'Intended Audience :: Developers', - 'License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication', - 'Natural Language :: English', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', + "Development Status :: 5 - Production/Stable", + "Framework :: Django", + "Framework :: Django :: 1.8", + "Framework :: Django :: 1.9", + "Intended Audience :: Developers", + "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "Natural Language :: English", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", ], ) diff --git a/tabular_export/__init__.py b/tabular_export/__init__.py index a6221b3..7863915 100644 --- a/tabular_export/__init__.py +++ b/tabular_export/__init__.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = "1.0.2" diff --git a/tabular_export/admin.py b/tabular_export/admin.py index a583d48..a262de2 100644 --- a/tabular_export/admin.py +++ b/tabular_export/admin.py @@ -32,27 +32,36 @@ def outer(f): @wraps(f) def inner(modeladmin, request, queryset, filename=None, *args, **kwargs): if filename is None: - filename = '%s.%s' % (force_text(modeladmin.model._meta.verbose_name_plural), suffix) + filename = "%s.%s" % ( + force_text(modeladmin.model._meta.verbose_name_plural), + suffix, + ) return f(modeladmin, request, queryset, filename=filename, *args, **kwargs) + return inner + return outer -@ensure_filename('xlsx') -def export_to_excel_action(modeladmin, request, queryset, filename=None, field_names=None): +@ensure_filename("xlsx") +def export_to_excel_action( + modeladmin, request, queryset, filename=None, field_names=None +): """Django admin action which exports selected records as an Excel XLSX download""" headers, rows = flatten_queryset(queryset, field_names=field_names) return export_to_excel_response(filename, headers, rows) -export_to_excel_action.short_description = _('Export to Excel') +export_to_excel_action.short_description = _("Export to Excel") -@ensure_filename('csv') -def export_to_csv_action(modeladmin, request, queryset, filename=None, field_names=None): +@ensure_filename("csv") +def export_to_csv_action( + modeladmin, request, queryset, filename=None, field_names=None +): """Django admin action which exports the selected records as a CSV download""" headers, rows = flatten_queryset(queryset, field_names=field_names) return export_to_csv_response(filename, headers, rows) -export_to_csv_action.short_description = _('Export to CSV') +export_to_csv_action.short_description = _("Export to CSV") diff --git a/tabular_export/core.py b/tabular_export/core.py index 28e5998..90d5d40 100644 --- a/tabular_export/core.py +++ b/tabular_export/core.py @@ -37,7 +37,7 @@ def get_field_names_from_queryset(qs): # We'll set the queryset to include all fields including calculated aggregates # using the same names which a values() queryset would return: - if hasattr(qs, 'values'): + if hasattr(qs, "values"): v_qs = qs.values() else: v_qs = qs @@ -86,8 +86,8 @@ def convert_value_to_unicode(v): """ if v is None: - return u'' - elif hasattr(v, 'isoformat'): + return "" + elif hasattr(v, "isoformat"): return v.isoformat() else: return force_text(v) @@ -95,12 +95,16 @@ def convert_value_to_unicode(v): def set_content_disposition(f): """Ensure that an HttpResponse has the Content-Disposition header set using the input filename= kwarg""" + @wraps(f) def inner(filename, *args, **kwargs): response = f(filename, *args, **kwargs) # See RFC 5987 for the filename* spec: - response['Content-Disposition'] = "attachment; filename*=UTF-8''%s" % urlquote(filename) + response["Content-Disposition"] = "attachment; filename*=UTF-8''%s" % urlquote( + filename + ) return response + return inner @@ -109,11 +113,11 @@ def return_debug_reponse(f): @wraps(f) def inner(filename, *args, **kwargs): - if not getattr(settings, 'TABULAR_RESPONSE_DEBUG', False): + if not getattr(settings, "TABULAR_RESPONSE_DEBUG", False): return f(filename, *args, **kwargs) else: resp = never_cache(export_to_debug_html_response)(filename, *args, **kwargs) - del resp['Content-Disposition'] # Don't trigger a download + del resp["Content-Disposition"] # Don't trigger a download return resp return inner @@ -124,25 +128,28 @@ def export_to_debug_html_response(filename, headers, rows): def output_generator(): # Note the use of bytestrings to avoid unnecessary Unicode-bytes cycles: - yield b'' + yield b"" yield b'TABULAR DEBUG' yield b'' - yield b'' + yield b"" yield b'
' - yield b'' + yield b"" - yield b'' + yield b"" for row in rows: values = map(convert_value_to_unicode, row) - values = [i.encode('utf-8').replace(b'\n', b'
') for i in values] - yield b'' % b'' - yield b'
' - yield b''.join(convert_value_to_unicode(i).encode('utf-8') for i in headers) - yield b'
" + yield b"".join( + convert_value_to_unicode(i).encode("utf-8") for i in headers + ) + yield b"
%s
'.join(values) - yield b'
' + values = [i.encode("utf-8").replace(b"\n", b"
") for i in values] + yield b"%s" % b"".join(values) + yield b"" + yield b"" - return StreamingHttpResponse(output_generator(), - content_type='text/html; charset=UTF-8') + return StreamingHttpResponse( + output_generator(), content_type="text/html; charset=UTF-8" + ) @return_debug_reponse @@ -151,7 +158,7 @@ def export_to_excel_response(filename, headers, rows): """Returns a downloadable HttpResponse using an XLSX payload generated from headers and rows""" # See http://technet.microsoft.com/en-us/library/ee309278%28office.12%29.aspx - content_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" # This cannot be a StreamingHttpResponse because XLSX files are .zip format and # the Python ZipFile library doesn't offer a generator form (which would also @@ -159,15 +166,20 @@ def export_to_excel_response(filename, headers, rows): resp = HttpResponse(content_type=content_type) - workbook = xlsxwriter.Workbook(resp, {'constant_memory': True, - 'in_memory': True, - 'default_date_format': 'yyyy-mm-dd'}) + workbook = xlsxwriter.Workbook( + resp, + { + "constant_memory": True, + "in_memory": True, + "default_date_format": "yyyy-mm-dd", + }, + ) - date_format = workbook.add_format({'num_format': 'yyyy-mm-dd'}) + date_format = workbook.add_format({"num_format": "yyyy-mm-dd"}) worksheet = workbook.add_worksheet() - for y, row in enumerate(chain((headers, ), rows)): + for y, row in enumerate(chain((headers,), rows)): for x, col in enumerate(row): if isinstance(col, datetime.datetime): # xlsxwriter cannot handle timezones: @@ -213,14 +225,16 @@ def row_generator(): # doesn't have a way to emit chunks from ZipFile and StreamingHttpResponse does not # offer a file-like handle. - return StreamingHttpResponse((writer.writerow(row) for row in row_generator()), - content_type='text/csv; charset=utf-8') + return StreamingHttpResponse( + (writer.writerow(row) for row in row_generator()), + content_type="text/csv; charset=utf-8", + ) def force_utf8_encoding(f): @wraps(f) def inner(): for row in f(): - yield [i.encode('utf-8') for i in row] + yield [i.encode("utf-8") for i in row] return inner diff --git a/tests/admin.py b/tests/admin.py index e1348b6..d3e46ad 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -13,15 +13,18 @@ class TestModelAdmin(admin.ModelAdmin): actions = (export_to_excel_action, export_to_csv_action) # For testing, we'll make this more complicated by adding a computed column: - list_display = ('title', 'tags_count') + list_display = ("title", "tags_count") def tags_count(self, obj): return obj.tags_count + tags_count.short_description = "Tags Count" - tags_count.admin_order_field = 'tags_count' + tags_count.admin_order_field = "tags_count" def get_queryset(self, *args, **kwargs): - return self.model.objects.all().annotate(tags_count=Count('tags', distinct=True)) + return self.model.objects.all().annotate( + tags_count=Count("tags", distinct=True) + ) admin.site.register(TestModel, TestModelAdmin) diff --git a/tests/models.py b/tests/models.py index 60467e6..550b415 100644 --- a/tests/models.py +++ b/tests/models.py @@ -7,10 +7,10 @@ class TestModel(models.Model): title = models.CharField(max_length=100) - tags = models.ManyToManyField('TestModelTag') + tags = models.ManyToManyField("TestModelTag") class Meta(object): - ordering = ('pk', ) + ordering = ("pk",) class TestModelTag(models.Model): diff --git a/tests/run_tests.py b/tests/run_tests.py index 0b5a38f..db2eaa9 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -9,36 +9,31 @@ from django.conf import settings from django.test.utils import get_runner -sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))) - -settings.configure(DEBUG=True, - USE_TZ=True, - DATABASES={ - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - } - }, - CACHES={ - 'default': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', - } - }, - INSTALLED_APPS=[ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - - 'tabular_export', - 'tests', - ], - SITE_ID=1, - MIDDLEWARE_CLASSES=('django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware'), - ROOT_URLCONF='tests.urls') +sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))) + +settings.configure( + DEBUG=True, + USE_TZ=True, + DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3"}}, + CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}, + INSTALLED_APPS=[ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "tabular_export", + "tests", + ], + SITE_ID=1, + MIDDLEWARE_CLASSES=( + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + ), + ROOT_URLCONF="tests.urls", +) django.setup() @@ -50,7 +45,7 @@ def get_test_runner(): def run_tests(*args): if not args: - args = ['tests'] + args = ["tests"] test_runner = get_test_runner() @@ -58,5 +53,5 @@ def run_tests(*args): sys.exit(bool(failures)) -if __name__ == '__main__': +if __name__ == "__main__": run_tests(*sys.argv[1:]) diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 9ebac01..10fa813 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -12,58 +12,68 @@ class TestAdminActions(TestCase): """Tests which use the full admin application""" + longMessage = True @classmethod def setUpClass(cls): - User.objects.create_superuser('test_admin', 'root@example.org', 'TEST') + User.objects.create_superuser("test_admin", "root@example.org", "TEST") @classmethod def tearDownClass(cls): - User.objects.filter(username='test_admin').delete() + User.objects.filter(username="test_admin").delete() def setUp(self): super(TestAdminActions, self).setUp() - assert self.client.login(username='test_admin', password='TEST') + assert self.client.login(username="test_admin", password="TEST") - TestModel.objects.create(pk=1, title='TEST ITEM 1') - TestModel.objects.create(pk=2, title='TEST ITEM 2') + TestModel.objects.create(pk=1, title="TEST ITEM 1") + TestModel.objects.create(pk=2, title="TEST ITEM 2") def test_export_to_excel_action(self): - changelist_url = reverse('admin:tests_testmodel_changelist') + changelist_url = reverse("admin:tests_testmodel_changelist") - data = {'action': 'export_to_excel_action', - 'select_across': 1, - 'index': 0, - ACTION_CHECKBOX_NAME: TestModel.objects.first().pk} + data = { + "action": "export_to_excel_action", + "select_across": 1, + "index": 0, + ACTION_CHECKBOX_NAME: TestModel.objects.first().pk, + } response = self.client.post(changelist_url, data) self.assertEqual(response.status_code, 200) - self.assertIn('Content-Disposition', response) - self.assertEqual("attachment; filename*=UTF-8''test%20models.xlsx", - response['Content-Disposition']) - self.assertEqual('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - response['Content-Type']) + self.assertIn("Content-Disposition", response) + self.assertEqual( + "attachment; filename*=UTF-8''test%20models.xlsx", + response["Content-Disposition"], + ) + self.assertEqual( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + response["Content-Type"], + ) assert_is_valid_xlsx(response.content) def test_export_to_csv_action(self): - changelist_url = reverse('admin:tests_testmodel_changelist') + changelist_url = reverse("admin:tests_testmodel_changelist") - data = {'action': 'export_to_csv_action', - 'select_across': 1, - 'index': 0, - ACTION_CHECKBOX_NAME: TestModel.objects.first().pk} + data = { + "action": "export_to_csv_action", + "select_across": 1, + "index": 0, + ACTION_CHECKBOX_NAME: TestModel.objects.first().pk, + } response = self.client.post(changelist_url, data) self.assertEqual(response.status_code, 200) - self.assertIn('Content-Disposition', response) - self.assertEqual("attachment; filename*=UTF-8''test%20models.csv", - response['Content-Disposition']) - self.assertEqual('text/csv; charset=utf-8', - response['Content-Type']) + self.assertIn("Content-Disposition", response) + self.assertEqual( + "attachment; filename*=UTF-8''test%20models.csv", + response["Content-Disposition"], + ) + self.assertEqual("text/csv; charset=utf-8", response["Content-Type"]) - content = list(i.decode('utf-8') for i in response.streaming_content) + content = list(i.decode("utf-8") for i in response.streaming_content) self.assertEqual(len(content), TestModel.objects.count() + 1) - self.assertRegexpMatches(content[0], r'^ID,title,tags_count') - self.assertRegexpMatches(content[1], r'^1,TEST ITEM 1,0\r\n') - self.assertRegexpMatches(content[2], r'^2,TEST ITEM 2,0\r\n') + self.assertRegexpMatches(content[0], r"^ID,title,tags_count") + self.assertRegexpMatches(content[1], r"^1,TEST ITEM 1,0\r\n") + self.assertRegexpMatches(content[2], r"^2,TEST ITEM 2,0\r\n") diff --git a/tests/test_tabular_exporter.py b/tests/test_tabular_exporter.py index 0bbff69..cbeceed 100644 --- a/tests/test_tabular_exporter.py +++ b/tests/test_tabular_exporter.py @@ -12,14 +12,20 @@ from django.test.utils import override_settings from tabular_export.admin import ensure_filename -from tabular_export.core import (convert_value_to_unicode, export_to_csv_response, - export_to_debug_html_response, export_to_excel_response, flatten_queryset, - get_field_names_from_queryset, set_content_disposition) +from tabular_export.core import ( + convert_value_to_unicode, + export_to_csv_response, + export_to_debug_html_response, + export_to_excel_response, + flatten_queryset, + get_field_names_from_queryset, + set_content_disposition, +) from .models import TestModel -def assert_is_valid_xlsx(bytestream, required_filename='xl/worksheets/sheet1.xml'): +def assert_is_valid_xlsx(bytestream, required_filename="xl/worksheets/sheet1.xml"): # We'll confirm that it's returning a valid zip file but will trust the # Excel library's tests for the actual content: @@ -29,32 +35,44 @@ def assert_is_valid_xlsx(bytestream, required_filename='xl/worksheets/sheet1.xml zip_filenames = zf.namelist() if required_filename not in zip_filenames: - raise AssertionError('Expected to find %s in %s' % (required_filename, zip_filenames)) + raise AssertionError( + "Expected to find %s in %s" % (required_filename, zip_filenames) + ) class SimpleUtilityTests(unittest.TestCase): longMessage = True def test_convert_value_to_unicode(self): - self.assertEqual('', convert_value_to_unicode(None)) - self.assertEqual(b'\xc3\x9cnic\xc3\xb0e', convert_value_to_unicode(u'Ünicðe').encode('utf-8')) - self.assertEqual('2015-08-28T00:00:00', - convert_value_to_unicode(datetime.datetime(year=2015, month=8, day=28))) - self.assertEqual('2015-08-28', - convert_value_to_unicode(datetime.date(year=2015, month=8, day=28))) + self.assertEqual("", convert_value_to_unicode(None)) + self.assertEqual( + b"\xc3\x9cnic\xc3\xb0e", convert_value_to_unicode("Ünicðe").encode("utf-8") + ) + self.assertEqual( + "2015-08-28T00:00:00", + convert_value_to_unicode(datetime.datetime(year=2015, month=8, day=28)), + ) + self.assertEqual( + "2015-08-28", + convert_value_to_unicode(datetime.date(year=2015, month=8, day=28)), + ) def test_set_content_disposition(self): # Since this is just supposed to add a key to a dict-like datastructure, we can fake it: def test_f(a1, a2, a3=None): - self.assertEqual(a1, 'not a real file') - self.assertEqual(a2, 'something') - self.assertEqual(a3, 'else') + self.assertEqual(a1, "not a real file") + self.assertEqual(a2, "something") + self.assertEqual(a3, "else") return {} decorated = set_content_disposition(test_f) - self.assertEqual({'Content-Disposition': "attachment; filename*=UTF-8''not%20a%20real%20file"}, - decorated('not a real file', 'something', a3='else')) + self.assertEqual( + { + "Content-Disposition": "attachment; filename*=UTF-8''not%20a%20real%20file" + }, + decorated("not a real file", "something", a3="else"), + ) def test_ensure_filename(self): # This decorator doesn't really need a ModelAdmin instance, just a valid Python object which @@ -64,25 +82,33 @@ def test_ensure_filename(self): class FakeModelAdmin(object): model = TestModel - @ensure_filename('test') - def fake_admin_action(modeladmin, request, queryset, filename=None, *args, **kwargs): + @ensure_filename("test") + def fake_admin_action( + modeladmin, request, queryset, filename=None, *args, **kwargs + ): return filename fake_ma = FakeModelAdmin() # Confirm that the auto-generated filename - self.assertEqual('test models.test', fake_admin_action(fake_ma, None, None), - msg="Standard filenames should be the model's verbose_name_plural with the " - "provided extension") - self.assertEqual('custom', fake_admin_action(fake_ma, None, None, filename='custom'), - msg='Custom filenames should be passed through verbatim') + self.assertEqual( + "test models.test", + fake_admin_action(fake_ma, None, None), + msg="Standard filenames should be the model's verbose_name_plural with the " + "provided extension", + ) + self.assertEqual( + "custom", + fake_admin_action(fake_ma, None, None, filename="custom"), + msg="Custom filenames should be passed through verbatim", + ) class QuerySetTests(TestCase): longMessage = True def test_get_field_names_from_queryset(self): - expected = ['id', 'title'] + expected = ["id", "title"] qs = TestModel.objects.all() # QuerySet, ValuesQuerySet and ValuesListQuerySet should always work: @@ -91,39 +117,45 @@ def test_get_field_names_from_queryset(self): self.assertListEqual(expected, get_field_names_from_queryset(qs.values_list())) def test_get_field_names_from_queryset_extra(self): - expected = ['id', 'title', 'upper_title'] + expected = ["id", "title", "upper_title"] - qs = TestModel.objects.extra(select={'upper_title': 'UPPER(TITLE)'}) + qs = TestModel.objects.extra(select={"upper_title": "UPPER(TITLE)"}) # QuerySet, ValuesQuerySet and ValuesListQuerySet should always work: self.assertListEqual(expected, get_field_names_from_queryset(qs.all())) self.assertListEqual(expected, get_field_names_from_queryset(qs.values())) self.assertListEqual(expected, get_field_names_from_queryset(qs.values_list())) def test_get_field_names_from_queryset_annotate(self): - expected = ['id', 'title', 'tags__count'] + expected = ["id", "title", "tags__count"] - qs = TestModel.objects.annotate(Count('tags')) + qs = TestModel.objects.annotate(Count("tags")) # QuerySet, ValuesQuerySet and ValuesListQuerySet should always work: self.assertListEqual(expected, get_field_names_from_queryset(qs.all())) self.assertListEqual(expected, get_field_names_from_queryset(qs.values())) self.assertListEqual(expected, get_field_names_from_queryset(qs.values_list())) def test_flatten_queryset(self): - TestModel.objects.create(pk=1, title='ABC') + TestModel.objects.create(pk=1, title="ABC") headers, rows = flatten_queryset(TestModel.objects.all()) - self.assertListEqual(['ID', 'title'], headers) - self.assertListEqual(list(rows), [(1, 'ABC')]) - - headers, rows = flatten_queryset(TestModel.objects.all(), field_names=['title']) - self.assertListEqual(['title'], headers) - self.assertListEqual(list(rows), [('ABC', )]) - - headers, rows = flatten_queryset(TestModel.objects.all(), - field_names=['title'], - extra_verbose_names={'title': 'The Title'}) - self.assertListEqual(['The Title'], headers, msg='extra_verbose_names must override default headers') - self.assertListEqual(list(rows), [('ABC', )]) + self.assertListEqual(["ID", "title"], headers) + self.assertListEqual(list(rows), [(1, "ABC")]) + + headers, rows = flatten_queryset(TestModel.objects.all(), field_names=["title"]) + self.assertListEqual(["title"], headers) + self.assertListEqual(list(rows), [("ABC",)]) + + headers, rows = flatten_queryset( + TestModel.objects.all(), + field_names=["title"], + extra_verbose_names={"title": "The Title"}, + ) + self.assertListEqual( + ["The Title"], + headers, + msg="extra_verbose_names must override default headers", + ) + self.assertListEqual(list(rows), [("ABC",)]) class ResponseTests(SimpleTestCase): @@ -131,70 +163,99 @@ class ResponseTests(SimpleTestCase): def get_test_data(self): # This exercises the core types: numbers, strings and dates - return ['Foo Column', 'Bar Column'], ((1, 2), (3, 4), ('abc', 'def'), - (datetime.datetime(year=2015, month=8, day=28), - datetime.date(year=2015, month=8, day=28))) + return ( + ["Foo Column", "Bar Column"], + ( + (1, 2), + (3, 4), + ("abc", "def"), + ( + datetime.datetime(year=2015, month=8, day=28), + datetime.date(year=2015, month=8, day=28), + ), + ), + ) def test_export_to_debug_html_response(self): headers, rows = self.get_test_data() - resp = export_to_debug_html_response('test.html', headers, rows) - self.assertNotIn('Content-Disposition', resp) + resp = export_to_debug_html_response("test.html", headers, rows) + self.assertNotIn("Content-Disposition", resp) def test_export_to_excel_response(self): headers, rows = self.get_test_data() - resp = export_to_excel_response('test.xlsx', headers, rows) - self.assertEqual('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - resp['Content-Type']) - self.assertEqual("attachment; filename*=UTF-8''test.xlsx", resp['Content-Disposition']) + resp = export_to_excel_response("test.xlsx", headers, rows) + self.assertEqual( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + resp["Content-Type"], + ) + self.assertEqual( + "attachment; filename*=UTF-8''test.xlsx", resp["Content-Disposition"] + ) assert_is_valid_xlsx(resp.content) def test_export_to_csv_response(self): headers, rows = self.get_test_data() - resp = export_to_csv_response('test.csv', headers, rows) - content = [i.decode('utf-8') for i in resp.streaming_content] - self.assertEqual('text/csv; charset=utf-8', resp['Content-Type']) - self.assertEqual("attachment; filename*=UTF-8''test.csv", resp['Content-Disposition']) - self.assertEqual(content, ['Foo Column,Bar Column\r\n', - '1,2\r\n', '3,4\r\n', 'abc,def\r\n', - '2015-08-28T00:00:00,2015-08-28\r\n']) + resp = export_to_csv_response("test.csv", headers, rows) + content = [i.decode("utf-8") for i in resp.streaming_content] + self.assertEqual("text/csv; charset=utf-8", resp["Content-Type"]) + self.assertEqual( + "attachment; filename*=UTF-8''test.csv", resp["Content-Disposition"] + ) + self.assertEqual( + content, + [ + "Foo Column,Bar Column\r\n", + "1,2\r\n", + "3,4\r\n", + "abc,def\r\n", + "2015-08-28T00:00:00,2015-08-28\r\n", + ], + ) @override_settings(TABULAR_RESPONSE_DEBUG=True) def test_return_debug_reponse(self): headers, rows = self.get_test_data() - resp = export_to_excel_response('test.xlsx', headers, rows) - self.assertEqual('text/html; charset=UTF-8', resp['Content-Type']) - self.assertNotIn('Content-Disposition', resp) + resp = export_to_excel_response("test.xlsx", headers, rows) + self.assertEqual("text/html; charset=UTF-8", resp["Content-Type"]) + self.assertNotIn("Content-Disposition", resp) - self.assertInHTML('Foo Column', ''.join(i.decode('utf-8') for i in resp.streaming_content)) + self.assertInHTML( + "Foo Column", + "".join(i.decode("utf-8") for i in resp.streaming_content), + ) def test_export_csv_using_generator(self): - headers = ['A Number', 'Status'] + headers = ["A Number", "Status"] def my_generator(): for i in range(0, 1000): - yield (i, u'\N{WARNING SIGN}') + yield (i, "\N{WARNING SIGN}") - resp = export_to_csv_response('numbers.csv', headers, my_generator()) + resp = export_to_csv_response("numbers.csv", headers, my_generator()) self.assertIsInstance(resp, StreamingHttpResponse) - self.assertEqual("attachment; filename*=UTF-8''numbers.csv", resp['Content-Disposition']) + self.assertEqual( + "attachment; filename*=UTF-8''numbers.csv", resp["Content-Disposition"] + ) # exhaust the iterator: - content = list(i.decode('utf-8') for i in resp.streaming_content) + content = list(i.decode("utf-8") for i in resp.streaming_content) # We should have one header row + 1000 content rows: self.assertEqual(len(content), 1001) - self.assertEqual(content[0], u'A Number,Status\r\n') - self.assertEqual(content[-1], u'999,\u26a0\r\n') + self.assertEqual(content[0], "A Number,Status\r\n") + self.assertEqual(content[-1], "999,\u26a0\r\n") def test_export_excel_using_generator(self): - headers = ['A Number', 'Status'] + headers = ["A Number", "Status"] def my_generator(): for i in range(0, 1000): - yield (i, u'\N{WARNING SIGN}') + yield (i, "\N{WARNING SIGN}") - resp = export_to_excel_response('numbers.xlsx', headers, my_generator()) + resp = export_to_excel_response("numbers.xlsx", headers, my_generator()) # xlsxwriter doesn't allow streaming generation of XLSX files: self.assertIsInstance(resp, HttpResponse) - self.assertEqual("attachment; filename*=UTF-8''numbers.xlsx", resp['Content-Disposition']) + self.assertEqual( + "attachment; filename*=UTF-8''numbers.xlsx", resp["Content-Disposition"] + ) diff --git a/tests/urls.py b/tests/urls.py index ecea707..632a9ef 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -4,6 +4,4 @@ from django.conf.urls import include, url from django.contrib import admin -urlpatterns = [ - url(r'^admin/', include(admin.site.urls)), -] +urlpatterns = [url(r"^admin/", include(admin.site.urls))] diff --git a/tox.ini b/tox.ini index a4334b4..ecce8be 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py35, py36 +envlist = py27, py35, py36, py37 [testenv] setenv =