diff --git a/pyproject.toml b/pyproject.toml index b6517fa..d921161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ classifiers = [ "Topic :: Scientific/Engineering :: Physics", ] dependencies = [ - "jinja2", "numpy", ] dynamic = [ diff --git a/requirements.in b/requirements.in index 7e602f5..24ce15a 100644 --- a/requirements.in +++ b/requirements.in @@ -1,2 +1 @@ -jinja2 numpy diff --git a/requirements/requires.txt b/requirements/requires.txt index 904ee9f..af47163 100644 --- a/requirements/requires.txt +++ b/requirements/requires.txt @@ -1,2 +1 @@ -jinja2==3.1.2 numpy==1.26.3 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/cmd.py b/src/bmipy/cmd.py index 4095d10..0f859ab 100644 --- a/src/bmipy/cmd.py +++ b/src/bmipy/cmd.py @@ -2,97 +2,11 @@ from __future__ import annotations import argparse -import functools -import inspect import keyword -import re import sys -import jinja2 +from bmipy._template import Template from bmipy._version import __version__ -from bmipy.bmi import Bmi - -try: - import black as blk -except ModuleNotFoundError: - WITH_BLACK = False -else: - WITH_BLACK = True - -BMI_TEMPLATE = """\ -from __future__ import annotations - -import numpy - -from bmipy.bmi import Bmi - - -class {{ name }}(Bmi): -{% for func in funcs %} - def {{ func }}{{ funcs[func].sig }}: - \"\"\"{{ funcs[func].doc }}\"\"\" - raise NotImplementedError("{{ func }}") -{% endfor %} -""" - -err = functools.partial(print, file=sys.stderr) -out = functools.partial(print, file=sys.stderr) - - -def _remove_hints_from_signature(signature: inspect.Signature) -> inspect.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: str) -> bool: - p = re.compile(r"^[^\d\W]\w*\Z", re.UNICODE) - return bool(p.match(name)) and not keyword.iskeyword(name) - - -def render_bmi(name: str, black: bool = True, hints: bool = True) -> str: - """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})") def main(args: tuple[str, ...] | None = None) -> int: @@ -101,48 +15,15 @@ def main(args: tuple[str, ...] | None = None) -> int: parser.add_argument("--version", action="version", version=f"bmipy {__version__}") parser.add_argument("name") - black_parser = parser.add_mutually_exclusive_group() - if WITH_BLACK: - black_parser.add_argument( - "--black", - action="store_true", - dest="black", - default=False, - help="format output with black", - ) - black_parser.add_argument( - "--no-black", - action="store_false", - dest="black", - default=False, - help="format output with black", - ) - hints_group = parser.add_mutually_exclusive_group() - hints_group.add_argument( - "--hints", - action="store_true", - default=True, - dest="hints", - help="include type hint annotation", - ) - hints_group.add_argument( - "--no-hints", - action="store_false", - dest="hints", - default=True, - help="include type hint annotation", - ) - parsed_args = parser.parse_args(args) - if _is_valid_class_name(parsed_args.name): + if parsed_args.name.isidentifier() and not keyword.iskeyword(parsed_args.name): + print(Template(parsed_args.name).render()) + else: print( - render_bmi( - parsed_args.name, black=parsed_args.black, hints=parsed_args.hints - ) + f"💥 💔 💥 {parsed_args.name!r} is not a valid class name in Python", + file=sys.stderr, ) - else: - err(f"💥 💔 💥 {parsed_args.name!r} is not a valid class name in Python") return 1 return 0 diff --git a/tests/cli_test.py b/tests/cli_test.py index 46f981b..4a00343 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1,8 +1,10 @@ from __future__ import annotations +import importlib +import sys + import pytest from bmipy.cmd import main -from bmipy.cmd import WITH_BLACK def test_cli_version(capsys): @@ -26,9 +28,6 @@ def test_cli_help(capsys): def test_cli_default(capsys, tmpdir): - import importlib - import sys - with tmpdir.as_cwd(): assert main(["MyBmi"]) == 0 output = capsys.readouterr().out @@ -39,33 +38,18 @@ def test_cli_default(capsys, tmpdir): assert "MyBmi" in mod.__dict__ -def test_cli_with_hints(capsys, tmpdir): - with tmpdir.as_cwd(): - assert main(["MyBmiWithHints", "--hints"]) == 0 - output = capsys.readouterr().out - assert "->" in output - - -def test_cli_without_hints(capsys, tmpdir): +def test_cli_wraps_lines(capsys, tmpdir): with tmpdir.as_cwd(): - assert main(["MyBmiWithoutHints", "--no-hints"]) == 0 - output = capsys.readouterr().out - assert "->" not in output - - -@pytest.mark.skipif(not WITH_BLACK, reason="black is not installed") -def test_cli_with_black(capsys, tmpdir): - with tmpdir.as_cwd(): - assert main(["MyBmiWithHints", "--black"]) == 0 + assert main(["MyBmi"]) == 0 output = capsys.readouterr().out assert max(len(line) for line in output.splitlines()) <= 88 -def test_cli_without_black(capsys, tmpdir): +def test_cli_with_hints(capsys, tmpdir): with tmpdir.as_cwd(): - assert main(["MyBmiWithHints", "--hints", "--no-black"]) == 0 + assert main(["MyBmiWithHints"]) == 0 output = capsys.readouterr().out - assert max(len(line) for line in output.splitlines()) > 88 + assert "->" in output @pytest.mark.parametrize("bad_name", ["True", "0Bmi"]) diff --git a/tests/template_test.py b/tests/template_test.py new file mode 100644 index 0000000..731df56 --- /dev/null +++ b/tests/template_test.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import pytest +from bmipy._template import dedent_docstring +from bmipy._template import 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!"