diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..67753be --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,30 @@ +name: unit tests + +permissions: + contents: write + +on: + push: # run on every push or PR to any branch + pull_request: + +jobs: + pre-commit: + name: pre-commit + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - run: pip install pre-commit + + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ hashFiles('.pre-commit-config.yaml') }} + + - run: pre-commit run --show-diff-on-failure --color=always diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml new file mode 100644 index 0000000..23496fa --- /dev/null +++ b/.github/workflows/pypi-release.yml @@ -0,0 +1,39 @@ +name: "PyPI releases" + +on: release + +permissions: + contents: read + +jobs: + build_sdist: + name: Build Python source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + run: pipx run build --sdist + + - uses: actions/upload-artifact@v4 + with: + path: dist/*.tar.gz + + pypi-publish: + name: Upload release to PyPI + if: github.event_name == 'release' && github.event.action == 'published' + needs: + - build_sdist + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/django-tabular-export + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: artifact + path: dist + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2df21bc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,45 @@ +exclude: ".*/vendor/.*" +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + 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-toml + - id: check-xml + - id: check-yaml + - id: debug-statements + - 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://github.com/executablebooks/mdformat + rev: 0.7.22 + hooks: + - id: mdformat + additional_dependencies: + - mdformat-gfm + + - repo: https://github.com/gruntwork-io/pre-commit + rev: v0.1.29 + hooks: + - id: shellcheck + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.10 + hooks: + - id: ruff-check + args: ["--fix", "--exit-non-zero-on-fix"] + - id: ruff-format 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..1fbedf8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,32 +22,34 @@ parent = os.path.dirname(cwd) sys.path.append(parent) -import tabular_export +import tabular_export # noqa: E402 # 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 = "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", + "django-tabular-export Documentation", + "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", + "django-tabular-export Documentation", + ["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", + "django-tabular-export Documentation", + "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 8ef3be0..eb5651c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ django>=4.0,<6.0 -xlsxwriter \ No newline at end of file +xlsxwriter diff --git a/setup.py b/setup.py index f013b75..5f243a1 100755 --- a/setup.py +++ b/setup.py @@ -2,49 +2,46 @@ # 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.2.0', + name="django-tabular-export", + version="1.2.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', + author="Chris Adams", + author_email="cadams@loc.gov", + url="https://github.com/LibraryOfCongress/django-tabular-export", packages=[ - 'tabular_export', + "tabular_export", ], include_package_data=True, install_requires=[ - 'Django', - 'xlsxwriter', + "Django", + "xlsxwriter", ], - test_suite='tests.run_tests.run_tests', - license='CC0', + test_suite="tests.run_tests.run_tests", + license="CC0", zip_safe=False, classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Framework :: Django', - 'Framework :: Django :: 4.0', - 'Framework :: Django :: 4.1', - 'Framework :: Django :: 4.2', - 'Framework :: Django :: 5.0', - 'Framework :: Django :: 5.1', - 'Intended Audience :: Developers', - 'License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication', - 'Natural Language :: English', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', + "Development Status :: 5 - Production/Stable", + "Framework :: Django", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Intended Audience :: Developers", + "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], ) 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 80d3bf5..990b289 100644 --- a/tabular_export/admin.py +++ b/tabular_export/admin.py @@ -10,6 +10,7 @@ The allow you to pass a custom file filename or list of fields which are passed through directly to :func:`flatten_queryset` and :func:`export_to_excel_response` / :func:`export_to_csv_response` """ + from __future__ import absolute_import, division, print_function from functools import wraps @@ -32,27 +33,50 @@ def outer(f): @wraps(f) def inner(modeladmin, request, queryset, filename=None, *args, **kwargs): if filename is None: - filename = '%s.%s' % (force_str(modeladmin.model._meta.verbose_name_plural), suffix) + filename = "%s.%s" % ( + force_str(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, extra_verbose_names=None): +@ensure_filename("xlsx") +def export_to_excel_action( + modeladmin, + request, + queryset, + filename=None, + field_names=None, + extra_verbose_names=None, +): """Django admin action which exports selected records as an Excel XLSX download""" - headers, rows = flatten_queryset(queryset, field_names=field_names, extra_verbose_names=extra_verbose_names) + headers, rows = flatten_queryset( + queryset, field_names=field_names, extra_verbose_names=extra_verbose_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, extra_verbose_names=None): +@ensure_filename("csv") +def export_to_csv_action( + modeladmin, + request, + queryset, + filename=None, + field_names=None, + extra_verbose_names=None, +): """Django admin action which exports the selected records as a CSV download""" - headers, rows = flatten_queryset(queryset, field_names=field_names, extra_verbose_names=extra_verbose_names) + headers, rows = flatten_queryset( + queryset, field_names=field_names, extra_verbose_names=extra_verbose_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 46075d4..7a91d65 100644 --- a/tabular_export/core.py +++ b/tabular_export/core.py @@ -38,7 +38,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 @@ -87,8 +87,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_str(v) @@ -96,12 +96,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 @@ -110,7 +114,7 @@ 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 = export_to_debug_html_response(filename, *args, **kwargs) @@ -126,25 +130,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 @@ -153,7 +160,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 @@ -161,15 +168,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: @@ -215,14 +227,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 88be376..c911cc1 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) + ) def custom_export_to_csv_action(self, request, queryset): # Add a custom action with the extra verbose name "number of tags" @@ -29,9 +32,14 @@ def custom_export_to_csv_action(self, request, queryset): self, request, queryset, - extra_verbose_names={'tags_count': 'number of tags'}, + extra_verbose_names={"tags_count": "number of tags"}, ) - actions = (export_to_excel_action, export_to_csv_action, custom_export_to_csv_action) + actions = ( + export_to_excel_action, + export_to_csv_action, + custom_export_to_csv_action, + ) + 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 307939d..cbc1838 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -10,53 +10,55 @@ 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__), '..'))) +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=( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware'), - ROOT_URLCONF='tests.urls', - TEMPLATES=[ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.request', - 'django.contrib.messages.context_processors.messages', - ], - 'loaders': [ - 'django.template.loaders.app_directories.Loader', - ], - }, - }, - ], - SECRET_KEY=uuid4(),) +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=( + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + ), + ROOT_URLCONF="tests.urls", + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.request", + "django.contrib.messages.context_processors.messages", + ], + "loaders": [ + "django.template.loaders.app_directories.Loader", + ], + }, + }, + ], + SECRET_KEY=uuid4(), +) django.setup() @@ -68,7 +70,7 @@ def get_test_runner(): def run_tests(*args): if not args: - args = ['tests'] + args = ["tests"] test_runner = get_test_runner() @@ -76,5 +78,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 80384f3..2d8a040 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -1,9 +1,9 @@ # encoding: utf-8 from __future__ import absolute_import, division, print_function, unicode_literals -import unittest from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.contrib.auth.models import User + try: from django.urls import reverse except ImportError: @@ -16,79 +16,92 @@ 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') - - data = {'action': 'export_to_excel_action', - 'select_across': 1, - 'index': 0, - ACTION_CHECKBOX_NAME: TestModel.objects.first().pk} + 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, + } 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') - - data = {'action': 'export_to_csv_action', - 'select_across': 1, - 'index': 0, - ACTION_CHECKBOX_NAME: TestModel.objects.first().pk} + 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, + } 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']) - - content = list(i.decode('utf-8') for i in response.streaming_content) + 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) self.assertEqual(len(content), TestModel.objects.count() + 1) - self.assertRegex(content[0], r'^ID,title,tags_count') - self.assertRegex(content[1], r'^1,TEST ITEM 1,0\r\n') - self.assertRegex(content[2], r'^2,TEST ITEM 2,0\r\n') + self.assertRegex(content[0], r"^ID,title,tags_count") + self.assertRegex(content[1], r"^1,TEST ITEM 1,0\r\n") + self.assertRegex(content[2], r"^2,TEST ITEM 2,0\r\n") def test_custom_export_to_csv_action(self): - changelist_url = reverse('admin:tests_testmodel_changelist') - - data = {'action': 'custom_export_to_csv_action', - 'select_across': 1, - 'index': 0, - ACTION_CHECKBOX_NAME: TestModel.objects.first().pk} + changelist_url = reverse("admin:tests_testmodel_changelist") + + data = { + "action": "custom_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']) - - content = list(i.decode('utf-8') for i in response.streaming_content) + 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) self.assertEqual(len(content), TestModel.objects.count() + 1) - self.assertRegex(content[0], r'^ID,title,number of tags') - self.assertRegex(content[1], r'^1,TEST ITEM 1,0\r\n') - self.assertRegex(content[2], r'^2,TEST ITEM 2,0\r\n') + self.assertRegex(content[0], r"^ID,title,number of tags") + self.assertRegex(content[1], r"^1,TEST ITEM 1,0\r\n") + self.assertRegex(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..d962b15 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,96 @@ 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"] + )