diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ec14ef7..b106b80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -51,7 +51,7 @@ jobs: - uses: actions/checkout@v4 - uses: wntrblm/nox@2023.04.22 with: - python-versions: "3.9" + python-versions: "3.12" - name: Lint run: nox --non-interactive --error-on-missing-interpreter --session "lint" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 39861e7..df5d08d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.12.1 hooks: - id: black name: black @@ -23,7 +23,7 @@ repos: additional_dependencies: [".[jupyter]"] - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + rev: 7.0.0 hooks: - id: flake8 additional_dependencies: @@ -42,19 +42,19 @@ repos: language: python - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.15.0 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort files: \.py$ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-builtin-literals - id: check-added-large-files @@ -68,7 +68,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/regebro/pyroma - rev: "4.1" + rev: "4.2" hooks: - id: pyroma args: ["-d", "--min=10", "."] @@ -86,7 +86,7 @@ repos: - cython - repo: https://github.com/PyCQA/pydocstyle - rev: 6.1.1 + rev: 6.3.0 hooks: - id: pydocstyle files: bmipy/.*\.py$ @@ -96,7 +96,7 @@ repos: additional_dependencies: [".[toml]"] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.982 + rev: v1.8.0 hooks: - id: mypy additional_dependencies: [types-all] diff --git a/pyproject.toml b/pyproject.toml index 106f8cc..8da82e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,9 +27,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "black", "click", - "jinja2", "numpy", ] dynamic = ["version"] diff --git a/requirements.in b/requirements.in index 666203b..30fca5f 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,2 @@ -black click -jinja2 numpy diff --git a/requirements/requires.txt b/requirements/requires.txt index 10b006d..eeae57b 100644 --- a/requirements/requires.txt +++ b/requirements/requires.txt @@ -1,4 +1,2 @@ -black==23.10.1 click==8.1.7 -jinja2==3.1.2 numpy==1.26.3 diff --git a/src/bmipy/__init__.py b/src/bmipy/__init__.py index b93094a..13dc3b3 100644 --- a/src/bmipy/__init__.py +++ b/src/bmipy/__init__.py @@ -1,4 +1,5 @@ """The Basic Model Interface (BMI) for Python.""" +from __future__ import annotations from ._version import __version__ from .bmi import Bmi diff --git a/src/bmipy/_template.py b/src/bmipy/_template.py new file mode 100644 index 0000000..b12907e --- /dev/null +++ b/src/bmipy/_template.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import inspect +import os +import textwrap + +from bmipy.bmi import Bmi + + +class Template: + """Create template BMI implementations.""" + + def __init__(self, name: str): + self._name = name + self._funcs = dict(inspect.getmembers(Bmi, inspect.isfunction)) + + def render(self) -> str: + """Render a module that defines a class implementing a Bmi.""" + prefix = f"""\ +from __future__ import annotations + +import numpy as np + +from bmipy.bmi import Bmi + + +class {self._name}(Bmi): +""" + return prefix + (os.linesep * 2).join( + [self._render_func(name) for name in sorted(self._funcs)] + ) + + def _render_func(self, name: str) -> str: + annotations = inspect.get_annotations(self._funcs[name]) + signature = inspect.signature(self._funcs[name], eval_str=False) + + docstring = textwrap.indent( + '"""' + dedent_docstring(self._funcs[name].__doc__) + '"""', " " + ) + + parts = [ + render_function_signature( + name, + tuple(signature.parameters), + annotations, + width=84, + ), + docstring, + f" raise NotImplementedError({name!r})".replace("'", '"'), + ] + + return textwrap.indent(os.linesep.join(parts), " ") + + +def dedent_docstring(text: str | None, tabsize=4) -> str: + """Dedent a docstring, ignoring indentation of the first line. + + Parameters + ---------- + text : str + The text to dedent. + tabsize : int, optional + Specify the number of spaces to replace tabs with. + + Returns + ------- + str + The dendented string. + """ + if not text: + return "" + + lines = text.expandtabs(tabsize).splitlines(keepends=True) + first = lines[0].lstrip() + try: + body = lines[1:] + except IndexError: + body = [""] + return first + textwrap.dedent("".join(body)) + + +def render_function_signature( + name: str, + params: tuple[str, ...] | None = None, + annotations: dict[str, str] | None = None, + tabsize: int = 4, + width: int = 88, +) -> str: + """Render a function signature, wrapping if the generated signature is too long. + + Parameters + ---------- + name : str + The name of the function. + params : tuple of str, optional + Names of each of the parameters. + annotations : dict, optional + Annotations for each parameters as well as the return type. + tabsize : int, optional + The number of spacses represented by a tab. + width : int, optional + The maximum width of a line. + + Returns + ------- + str + The function signature appropriately wrapped. + """ + params = () if params is None else params + annotations = {} if annotations is None else annotations + + prefix = f"def {name}(" + if "return" in annotations: + suffix = f") -> {annotations['return']}:" + else: + suffix = "):" + body = [] + for param in params: + if param in annotations: + param += f": {annotations[param]}" + body.append(param) + + signature = prefix + ", ".join(body) + suffix + if len(signature) <= width: + return signature + + indent = " " * tabsize + + lines = [prefix, indent + ", ".join(body), suffix] + if max(len(line) for line in lines) <= width: + return os.linesep.join(lines) + + return os.linesep.join([prefix] + [f"{indent}{line}," for line in body] + [suffix]) diff --git a/src/bmipy/_version.py b/src/bmipy/_version.py index 90956f3..6ad6d9a 100644 --- a/src/bmipy/_version.py +++ b/src/bmipy/_version.py @@ -1 +1,3 @@ +from __future__ import annotations + __version__ = "2.0.2.dev0" diff --git a/src/bmipy/bmi.py b/src/bmipy/bmi.py index 41ed450..807c67f 100644 --- a/src/bmipy/bmi.py +++ b/src/bmipy/bmi.py @@ -3,6 +3,7 @@ This language specification is derived from the Scientific Interface Definition Language (SIDL) file `bmi.sidl `_. """ +from __future__ import annotations from abc import ABC, abstractmethod diff --git a/src/bmipy/cmd.py b/src/bmipy/cmd.py index f167a6b..21837d7 100644 --- a/src/bmipy/cmd.py +++ b/src/bmipy/cmd.py @@ -1,98 +1,21 @@ """Command line interface that create template BMI implementations.""" -import inspect +from __future__ import annotations + import keyword -import re -import black as blk import click -import jinja2 - -from bmipy import Bmi - -BMI_TEMPLATE = """# -*- coding: utf-8 -*- -{% if with_hints -%} -from typing import Tuple -{%- endif %} - -from bmipy import Bmi -import numpy - - -class {{ name }}(Bmi): -{% for func in funcs %} - def {{ func }}{{ funcs[func].sig }}: - \"\"\"{{ funcs[func].doc }}\"\"\" - raise NotImplementedError("{{ func }}") -{% endfor %} -""" - - -def _remove_hints_from_signature(signature): - """Remove hint annotation from a signature.""" - params = [] - for _, param in signature.parameters.items(): - params.append(param.replace(annotation=inspect.Parameter.empty)) - return signature.replace( - parameters=params, return_annotation=inspect.Signature.empty - ) - -def _is_valid_class_name(name): - p = re.compile(r"^[^\d\W]\w*\Z", re.UNICODE) - return p.match(name) and not keyword.iskeyword(name) - - -def render_bmi(name, black=True, hints=True): - """Render a template BMI implementation in Python. - - Parameters - ---------- - name : str - Name of the new BMI class to implement. - black : bool, optional - If True, reformat the source using black styling. - hints : bool, optiona - If True, include type hint annotation. - - Returns - ------- - str - The contents of a new Python module that contains a template for - a BMI implementation. - """ - if _is_valid_class_name(name): - env = jinja2.Environment() - template = env.from_string(BMI_TEMPLATE) - - funcs = {} - for func_name, func in inspect.getmembers(Bmi, inspect.isfunction): - signature = inspect.signature(func) - if not hints: - signature = _remove_hints_from_signature(signature) - funcs[func_name] = {"sig": signature, "doc": func.__doc__} - - contents = template.render(name=name, funcs=funcs, with_hints=hints) - - if black: - contents = blk.format_file_contents( - contents, fast=True, mode=blk.FileMode() - ) - - return contents - else: - raise ValueError(f"invalid class name ({name})") +from bmipy._template import Template @click.command() @click.version_option() -@click.option("--black / --no-black", default=True, help="format output with black") -@click.option("--hints / --no-hints", default=True, help="include type hint annotation") @click.argument("name") @click.pass_context -def main(ctx, name, black, hints): +def main(ctx: click.Context, name: str): """Render a template BMI implementation in Python for class NAME.""" - if _is_valid_class_name(name): - print(render_bmi(name, black=black, hints=hints)) + if name.isidentifier() and not keyword.iskeyword(name): + print(Template(name).render()) else: click.secho( f"💥 💔 💥 {name!r} is not a valid class name in Python", diff --git a/tests/test_cli.py b/tests/test_cli.py index 839758f..f31ef22 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import importlib import sys import pytest @@ -24,9 +25,6 @@ def test_cli_help(): sys.platform == "win32", reason="See https://github.com/csdms/bmi-python/issues/10" ) def test_cli_default(tmpdir): - import importlib - import sys - runner = CliRunner() with tmpdir.as_cwd(): result = runner.invoke(main, ["MyBmi"]) @@ -38,36 +36,23 @@ def test_cli_default(tmpdir): assert "MyBmi" in mod.__dict__ -def test_cli_with_hints(tmpdir): - runner = CliRunner() - with tmpdir.as_cwd(): - result = runner.invoke(main, ["MyBmiWithHints", "--hints"]) - assert result.exit_code == 0 - assert "->" in result.output - - -def test_cli_without_hints(tmpdir): - runner = CliRunner() - with tmpdir.as_cwd(): - result = runner.invoke(main, ["MyBmiWithoutHints", "--no-hints"]) - assert result.exit_code == 0 - assert "->" not in result.output - - -def test_cli_with_black(tmpdir): +@pytest.mark.skipif( + sys.platform == "win32", reason="See https://github.com/csdms/bmi-python/issues/10" +) +def test_cli_wraps_lines(tmpdir): runner = CliRunner() with tmpdir.as_cwd(): - result = runner.invoke(main, ["MyBmiWithHints", "--black"]) + result = runner.invoke(main, ["MyBmi"]) assert result.exit_code == 0 - assert max([len(line) for line in result.output.splitlines()]) <= 88 + assert max(len(line) for line in result.output.splitlines()) <= 88 -def test_cli_without_black(tmpdir): +def test_cli_with_hints(tmpdir): runner = CliRunner() with tmpdir.as_cwd(): - result = runner.invoke(main, ["MyBmiWithoutHints", "--no-black"]) + result = runner.invoke(main, ["MyBmiWithHints"]) assert result.exit_code == 0 - assert max([len(line) for line in result.output.splitlines()]) > 88 + assert "->" in result.output @pytest.mark.parametrize("bad_name", ["True", "0Bmi"]) diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 0000000..b4c7502 --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,91 @@ +import pytest + +from bmipy._template import dedent_docstring, render_function_signature + + +@pytest.mark.parametrize( + "text", + ( + " Foo", + "\tFoo\n bar ", + "\tFoo\n bar", + "Foo\n bar\n baz\n", + ), +) +def test_dedent_docstring_aligned(text): + fixed_text = dedent_docstring(text) + assert [ + line.lstrip() for line in fixed_text.splitlines() + ] == fixed_text.splitlines() + + +@pytest.mark.parametrize( + "text", ("Foo", " Foo", "\tFoo ", "\n Foo", " Foo\nBar\nBaz") +) +def test_dedent_docstring_lstrip_first_line(text): + fixed_text = dedent_docstring(text) + assert fixed_text[0].lstrip() == fixed_text[0] + + +@pytest.mark.parametrize("text", (None, "", """""")) +def test_dedent_docstring_empty(text): + assert dedent_docstring(text) == "" + + +@pytest.mark.parametrize( + "text,tabsize", + (("\tFoo", 8), ("Foo\n\tBar baz", 2), ("\t\tFoo\tBar\nBaz", 0)), +) +def test_dedent_docstring_tabsize(text, tabsize): + fixed_text = dedent_docstring(text, tabsize) + assert [ + line.lstrip() for line in fixed_text.splitlines() + ] == fixed_text.splitlines() + + +@pytest.mark.parametrize( + "text", + ("Foo\n Bar\n Baz",), +) +def test_dedent_docstring_body_is_left_justified(text): + lines = dedent_docstring(text).splitlines()[1:] + assert any(line.lstrip() == line for line in lines) + + +@pytest.mark.parametrize( + "annotations", + ( + {"bar": "int"}, + {"bar" * 10: "int", "baz" * 10: "int"}, + {"bar" * 20: "int", "baz" * 20: "int"}, + ), +) +def test_render_function_wraps(annotations): + params = list(annotations) + annotations["return"] = "str" + + text = render_function_signature("foo", params, annotations) + assert max(len(line) for line in text.splitlines()) <= 88 + + +@pytest.mark.parametrize( + "annotations", + ( + {}, + {"bar": "int"}, + {"bar" * 10: "int", "baz" * 10: "int"}, + {"bar" * 20: "int", "baz" * 20: "int"}, + ), +) +def test_render_function_is_valid(annotations): + params = list(annotations) + annotations["return"] = "str" + + text = render_function_signature("foo", params, annotations) + generated_code = f"{text}\n return 'FOOBAR!'" + + globs = {} + exec(generated_code, globs) + + assert "foo" in globs + assert globs["foo"](*range(len(params))) == "FOOBAR!"