diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fba57905..4cf5687c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -66,17 +66,6 @@ repos: - id: forbid-new-submodules - id: trailing-whitespace -- repo: https://github.com/PyCQA/pydocstyle - rev: 6.3.0 - hooks: - - id: pydocstyle - files: babelizer/.*\.py$ - args: - - --convention=numpy - - --add-select=D417 - exclude: ^babelizer/data - additional_dependencies: [".[toml]"] - - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.8.0 hooks: diff --git a/README.rst b/README.rst index 5d2883aa..9b801179 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ Read all about them in the `Basic Model Interface`_ documentation. Requirements ------------ -The *babelizer* requires Python >=3.9. +The *babelizer* requires Python >=3.10. Apart from Python, the *babelizer* has a number of other requirements, all of which @@ -396,23 +396,13 @@ called *hydrotrend*. python_version = ["3.7", "3.8", "3.9"] os = ["linux", "mac", "windows"] -You can use the ``babelize generate`` command to generate *babel.toml* files. -For example the above *babel.toml* can be generated with the following, +You can use the ``babelize sample-config`` command to generate +a sample *babel.toml* file to get you started. For example, +the above *babel.toml* can be generated with the following, .. code:: bash - $ babelize generate \ - --package=pymt_hydrotrend \ - --summary="PyMT plugin for hydrotrend" \ - --language=c \ - --library=bmi_hydrotrend \ - --header=bmi_hydrotrend.h \ - --entry-point=register_bmi_hydrotrend \ - --name=Hydrotrend \ - --requirement=hydrotrend \ - --os-name=linux,mac,windows \ - --python-version=3.7,3.8,3.9 > babel.toml - + babelize sample-config Use --- @@ -422,13 +412,13 @@ sending output to the current directory .. code:: bash - $ babelize init babel.toml + babelize init babel.toml Update an existing repository .. code:: bash - $ babelize update + babelize update For a complete example of using the *babelizer* to wrap a C library exposing a BMI, diff --git a/babelizer/_datadir.py b/babelizer/_datadir.py new file mode 100644 index 00000000..02ceff72 --- /dev/null +++ b/babelizer/_datadir.py @@ -0,0 +1,10 @@ +import sys + +if sys.version_info >= (3, 12): # pragma: no cover (PY12+) + import importlib.resources as importlib_resources +else: # pragma: no cover ( str: + return str(importlib_resources.files("babelizer") / "data") diff --git a/babelizer/_files/bmi_py.py b/babelizer/_files/bmi_py.py new file mode 100644 index 00000000..33e10cfd --- /dev/null +++ b/babelizer/_files/bmi_py.py @@ -0,0 +1,89 @@ +import os + + +def render(plugin_metadata) -> str: + """Render _bmi.py.""" + languages = { + library["language"] for library in plugin_metadata._meta["library"].values() + } + assert len(languages) == 1 + language = languages.pop() + + if language == "python": + return _render_bmi_py(plugin_metadata) + else: + return _render_bmi_c(plugin_metadata) + + +def _render_bmi_c(plugin_metadata) -> str: + """Render _bmi.py for a non-python library.""" + languages = [ + library["language"] for library in plugin_metadata._meta["library"].values() + ] + language = languages[0] + assert language in ("c", "c++", "fortran") + + imports = [ + f"from {plugin_metadata.get('package', 'name')}.lib import {cls}" + for cls in plugin_metadata._meta["library"] + ] + + names = [ + f" {cls!r},".replace("'", '"') for cls in plugin_metadata._meta["library"] + ] + + return f"""\ +{os.linesep.join(sorted(imports))} + +__all__ = [ +{os.linesep.join(sorted(names))} +]\ +""" + + +def _render_bmi_py(plugin_metadata) -> str: + """Render _bmi.py for a python library.""" + languages = [ + library["language"] for library in plugin_metadata._meta["library"].values() + ] + language = languages[0] + assert language == "python" + + header = """\ +import sys + +if sys.version_info >= (3, 12): # pragma: no cover (PY12+) + import importlib.resources as importlib_resources +else: # pragma: no cover ( str: + """Render a .gitignore file.""" + package_name = plugin_metadata["package"]["name"] + + languages = {library["language"] for library in plugin_metadata["library"].values()} + ignore = { + "*.egg-info/", + "*.py[cod]", + ".coverage", + ".nox/", + "__pycache__/", + "build/", + "dist/", + } + + if "python" not in languages: + ignore |= {"*.o", "*.so"} | { + f"{package_name}/lib/{cls.lower()}.c" for cls in plugin_metadata["library"] + } + + if "fortran" in languages: + ignore |= {"*.mod", "*.smod"} + + return f"{os.linesep.join(sorted(ignore))}" diff --git a/babelizer/_files/init_py.py b/babelizer/_files/init_py.py new file mode 100644 index 00000000..963c9993 --- /dev/null +++ b/babelizer/_files/init_py.py @@ -0,0 +1,25 @@ +import os + + +def render(plugin_metadata) -> str: + """Render __init__.py.""" + package_name = plugin_metadata.get("package", "name") + + imports = [f"from {package_name}._version import __version__"] + imports += [ + f"from {package_name}._bmi import {cls}" + for cls in plugin_metadata._meta["library"] + ] + + names = [ + f" {cls!r},".replace("'", '"') for cls in plugin_metadata._meta["library"] + ] + + return f"""\ +{os.linesep.join(sorted(imports))} + +__all__ = [ + "__version__", +{os.linesep.join(sorted(names))} +]\ +""" diff --git a/babelizer/_files/lib_init_py.py b/babelizer/_files/lib_init_py.py new file mode 100644 index 00000000..3c9227bb --- /dev/null +++ b/babelizer/_files/lib_init_py.py @@ -0,0 +1,22 @@ +import os + + +def render(plugin_metadata) -> str: + """Render lib/__init__.py.""" + package_name = plugin_metadata.get("package", "name") + imports = [ + f"from {package_name}.lib.{cls.lower()} import {cls}" + for cls in plugin_metadata._meta["library"] + ] + + names = [ + f" {cls!r},".replace("'", '"') for cls in plugin_metadata._meta["library"] + ] + + return f"""\ +{os.linesep.join(sorted(imports))} + +__all__ = [ +{os.linesep.join(sorted(names))} +]\ +""" diff --git a/babelizer/_files/license_rst.py b/babelizer/_files/license_rst.py new file mode 100644 index 00000000..a18ebb18 --- /dev/null +++ b/babelizer/_files/license_rst.py @@ -0,0 +1,145 @@ +from datetime import datetime + + +def render(plugin_metadata) -> str: + """Render LICENSE.rst.""" + license_name = plugin_metadata["info"]["package_license"] + kwds = { + "full_name": plugin_metadata["info"]["package_author"], + "year": datetime.now().year, + "project_short_description": plugin_metadata["info"]["summary"], + } + + return LICENSE[license_name].format(**kwds) + + +LICENSE = { + "MIT License": """\ +MIT License +=========== + +Copyright (c) *{year}*, *{full_name}* + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.\ +""", + "BSD License": """\ +BSD License +=========== + +Copyright (c) *{year}*, *{full_name}* +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +OF THE POSSIBILITY OF SUCH DAMAGE. +""", + "ISC License": """\ +ISC License +=========== + +Copyright (c) *{year}*, *{full_name}* + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE.\ +""", + "Apache Software License 2.0": """\ +Apache Software License 2.0 +=========================== + +Copyright (c) *{year}*, *{full_name}* + +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.\ +""", + "GNU General Public License v3": """\ +GNU GENERAL PUBLIC LICENSE +========================== + + Version 3, 29 June 2007 + + {project_short_description} + Copyright (c) *{year}*, *{full_name}* + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +.\ +""", +} diff --git a/babelizer/_files/meson_build.py b/babelizer/_files/meson_build.py new file mode 100644 index 00000000..ac725bb6 --- /dev/null +++ b/babelizer/_files/meson_build.py @@ -0,0 +1,106 @@ +import os +from collections import defaultdict +from collections.abc import Iterable + + +def render(paths: Iterable[str], install: Iterable[str] = ()) -> str: + """Render an example meson.build file. + + Parameters + ---------- + paths : iterable of str + Paths to cython extensions. + install : iterable of str + Paths to files to install. + + Returns + ------- + str + The contents of a meson.build file. + """ + before = """\ +project( + 'package_name', + 'c', + 'cython', + version: '0.1.0', +) + +py_mod = import('python') +py = py_mod.find_installation(pure: false) +py_dep = py.dependency() + +numpy_inc = run_command( + py, + ['-c', 'import numpy; print(numpy.get_include())'], + check: true, +).stdout().strip()\ +""" + + files_to_install = _render_install_block(install) + extensions = os.linesep.join(_render_extension_module(path) for path in paths) + + after = """\ +# Install data files. +# install_subdir( +# 'data/', +# install_dir: py.get_install_dir() / 'package_name/data', +# ) + +# This is a temporary fix for editable installs. +# run_command('cp', '-r', 'data/', 'build')\ +""" + contents = [before] + if files_to_install: + contents.append(files_to_install) + if extensions: + contents.append(extensions) + contents.append(after) + + return (2 * os.linesep).join(contents) + + +def _render_install_block(install: Iterable[str]): + install_sources = defaultdict(list) + for root, fname in (os.path.split(src) for src in install): + install_sources[root].append(fname) + + files_to_install = [] + for subdir, files in sorted(install_sources.items()): + lines = [f" {os.path.join(subdir, f)!r}," for f in files] + files_to_install.append( + f"""\ +py.install_sources( + [ +{os.linesep.join(sorted(lines))} + ], + subdir: {subdir!r}, +)\ +""" + ) + return os.linesep.join(files_to_install) + + +def _render_extension_module(path: str) -> str: + root, ext = os.path.splitext(path) + assert ext == ".pyx", f"{path} does not appear to be a cython file" + + module_name = root.replace(os.path.sep, ".") + + return f"""\ +py.extension_module( + {module_name!r}, + [{path!r}], + include_directories: [ + {os.path.dirname(path)!r}, + numpy_inc, + ], + dependencies: [ + py.dependency(), + # Dependencies required to build the extension. + # dependency('another_package', method : 'pkg-config'), + ], + install: true, + subdir: {os.path.dirname(path)!r}, +)\ +""" diff --git a/babelizer/_files/readme.py b/babelizer/_files/readme.py new file mode 100644 index 00000000..9308fd05 --- /dev/null +++ b/babelizer/_files/readme.py @@ -0,0 +1,11 @@ +from jinja2 import Environment +from jinja2 import FileSystemLoader + +from babelizer._datadir import get_datadir + + +def render(context): + env = Environment(loader=FileSystemLoader(get_datadir())) + template = env.get_template("{{cookiecutter.package_name}}/README.rst") + + return template.render(**context) diff --git a/babelizer/cli.py b/babelizer/cli.py index 9da962d6..be19859f 100644 --- a/babelizer/cli.py +++ b/babelizer/cli.py @@ -3,18 +3,17 @@ import fnmatch import os import pathlib -import sys import tempfile from functools import partial import click import git -if sys.version_info >= (3, 12): # pragma: no cover (PY12+) - import importlib.resources as importlib_resources -else: # pragma: no cover (. - -Also add information on how to contact you by electronic and paper mail. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. -{% endif %} +{{cookiecutter.files['LICENSE.rst']}} diff --git a/babelizer/data/{{cookiecutter.package_name}}/README.rst b/babelizer/data/{{cookiecutter.package_name}}/README.rst index 05ccf4f9..63353026 100644 --- a/babelizer/data/{{cookiecutter.package_name}}/README.rst +++ b/babelizer/data/{{cookiecutter.package_name}}/README.rst @@ -49,7 +49,7 @@ Python and the Python Modeling Toolkit, PyMT. .. code-block:: pycon >>> from pymt.models import {{ babelized_class }} - {% endfor %} + {%- endfor %} .. end-intro diff --git a/babelizer/data/{{cookiecutter.package_name}}/docs/conf.py b/babelizer/data/{{cookiecutter.package_name}}/docs/conf.py index 0395fb7a..57c39b28 100644 --- a/babelizer/data/{{cookiecutter.package_name}}/docs/conf.py +++ b/babelizer/data/{{cookiecutter.package_name}}/docs/conf.py @@ -58,7 +58,7 @@ master_doc = "index" # General information about the project. -project = '{{ cookiecutter.package_name }}' +project = "{{ cookiecutter.package_name }}" copyright = "{% now 'local', '%Y' %}, {{ cookiecutter.info.full_name }}" author = "{{ cookiecutter.info.full_name }}" @@ -81,7 +81,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns : list[str] = [] +exclude_patterns: list[str] = [] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" diff --git a/babelizer/data/{{cookiecutter.package_name}}/noxfile.py b/babelizer/data/{{cookiecutter.package_name}}/noxfile.py index c90c443b..756f3c99 100644 --- a/babelizer/data/{{cookiecutter.package_name}}/noxfile.py +++ b/babelizer/data/{{cookiecutter.package_name}}/noxfile.py @@ -20,7 +20,11 @@ def test(session: nox.Session) -> None: session.install(".[testing]") {%- for babelized_class, _ in cookiecutter.components|dictsort %} - session.run("bmi-test", "{{ cookiecutter.package_name }}.bmi:{{ babelized_class }}", "-vvv") + session.run( + "bmi-test", + "{{ cookiecutter.package_name }}.bmi:{{ babelized_class }}", + "-vvv", + ) {%- endfor %} @@ -82,7 +86,6 @@ def build_docs(session: nox.Session) -> None: "sphinx-build", "-b", "html", - # "-W", "docs", "build/html", ) diff --git a/babelizer/data/{{cookiecutter.package_name}}/requirements-build.txt b/babelizer/data/{{cookiecutter.package_name}}/requirements-build.txt index 6b280963..c92dc98a 100644 --- a/babelizer/data/{{cookiecutter.package_name}}/requirements-build.txt +++ b/babelizer/data/{{cookiecutter.package_name}}/requirements-build.txt @@ -1,10 +1,11 @@ -{%- if cookiecutter.language == 'c' %} +# conda requirements needed to build the project +{%- if cookiecutter.language == 'c' -%} bmi-c c-compiler -{%- elif cookiecutter.language == 'c++' %} +{%- elif cookiecutter.language == 'c++' -%} bmi-cxx cxx-compiler -{%- elif cookiecutter.language == 'fortran' %} +{%- elif cookiecutter.language == 'fortran' -%} bmi-fortran fortran-compiler {%- endif %} diff --git a/babelizer/data/{{cookiecutter.package_name}}/{{cookiecutter.package_name}}/__init__.py b/babelizer/data/{{cookiecutter.package_name}}/{{cookiecutter.package_name}}/__init__.py index dce78834..ae1237e9 100644 --- a/babelizer/data/{{cookiecutter.package_name}}/{{cookiecutter.package_name}}/__init__.py +++ b/babelizer/data/{{cookiecutter.package_name}}/{{cookiecutter.package_name}}/__init__.py @@ -1,13 +1 @@ -from ._bmi import ( -{%- for babelized_class, _ in cookiecutter.components|dictsort %} - {{ babelized_class }}, -{%- endfor %} -) -from ._version import __version__ - -__all__ = [ - "__version__", -{%- for babelized_class, _ in cookiecutter.components|dictsort %} - "{{ babelized_class }}", -{%- endfor %} -] +{{ cookiecutter.files['__init__.py'] }} diff --git a/babelizer/data/{{cookiecutter.package_name}}/{{cookiecutter.package_name}}/_bmi.py b/babelizer/data/{{cookiecutter.package_name}}/{{cookiecutter.package_name}}/_bmi.py index 7b634783..8aeff829 100644 --- a/babelizer/data/{{cookiecutter.package_name}}/{{cookiecutter.package_name}}/_bmi.py +++ b/babelizer/data/{{cookiecutter.package_name}}/{{cookiecutter.package_name}}/_bmi.py @@ -1,35 +1 @@ -{% set classes = [] -%} -{%- for babelized_class in cookiecutter.components -%} - {% set _ = classes.append(babelized_class) %} -{%- endfor -%} - -{%- if cookiecutter.language in ['c', 'c++', 'fortran'] %} - -from .lib import {{ classes|join(', ') }} - -{%- else %} -import sys -if sys.version_info >= (3, 12): # pragma: no cover (PY12+) - import importlib.resources as importlib_resources -else: # pragma: no cover ( str: + return self._meta[key] + + def __iter__(self) -> Generator[str, None, None]: + yield from self._meta + + def __len__(self) -> int: + return len(self._meta) + @classmethod def from_stream(cls, stream, fmt="toml"): """Create an instance of BabelMetadata from a file-like object. @@ -348,7 +359,7 @@ def dump(self, fp, fmt="toml"): fmt : str, optional Format to serialize data. """ - print(self.format(fmt=fmt), file=fp) + print(self.format(fmt=fmt), file=fp, end="") def format(self, fmt="toml"): """Serialize metadata to output format. diff --git a/babelizer/render.py b/babelizer/render.py index 5edb0d46..48ccb174 100644 --- a/babelizer/render.py +++ b/babelizer/render.py @@ -5,12 +5,18 @@ import pathlib import sys -import black as blk import git -import isort from cookiecutter.exceptions import OutputDirExistsException from cookiecutter.main import cookiecutter +try: + import black as blk + import isort +except ModuleNotFoundError: + MAKE_PRETTY = False +else: + MAKE_PRETTY = True + if sys.version_info >= (3, 11): # pragma: no cover (PY11+) import tomllib else: # pragma: no cover ( None: """Test the command line interface.""" + session.install("pre-commit") session.install(".") session.run("babelize", "--version") session.run("babelize", "--help") session.run("babelize", "init", "--help") session.run("babelize", "update", "--help") - session.run("babelize", "sample-config", "--help") + session.run("babelize", "sample-config") + session.run("babelize", "sample-gitignore") + session.run("babelize", "sample-license") + session.run("babelize", "sample-meson-build") + session.run("babelize", "sample-readme") with session.chdir(session.create_tmp()): with open("babel.toml", "w") as fp: @@ -133,6 +138,8 @@ def test_cli(session: nox.Session) -> None: ).strip() with session.chdir(new_folder): session.run("babelize", "update", "--set-version=0.1.1") + with session.chdir(new_folder): + session.run("pre-commit", "run", "--all-files") @nox.session diff --git a/pyproject.toml b/pyproject.toml index 04357a5a..2ff094e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,12 +32,11 @@ classifiers = [ "Topic :: Software Development :: Code Generators", ] dependencies = [ - "black", "click", "cookiecutter", "gitpython", "importlib-resources; python_version < '3.12'", - "isort>=5", + "jinja2", "logoizer@ git+https://github.com/mcflugen/logoizer", "pyyaml", "tomli-w", @@ -67,6 +66,10 @@ dev = [ "pre-commit", "towncrier", ] +format = [ + "black", + "isort>=5", +] docs = [ "furo", "pygments>=2.4",