Skip to content

Commit

Permalink
Merge branch 'master' into mcflugen/use-argparse
Browse files Browse the repository at this point in the history
  • Loading branch information
mcflugen committed Jan 8, 2024
2 parents ed7584a + 3447c93 commit 7d0e591
Show file tree
Hide file tree
Showing 7 changed files with 240 additions and 152 deletions.
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ classifiers = [
"Topic :: Scientific/Engineering :: Physics",
]
dependencies = [
"jinja2",
"numpy",
]
dynamic = [
Expand Down
1 change: 0 additions & 1 deletion requirements.in
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
jinja2
numpy
1 change: 0 additions & 1 deletion requirements/requires.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
jinja2==3.1.2
numpy==1.26.3
133 changes: 133 additions & 0 deletions src/bmipy/_template.py
Original file line number Diff line number Diff line change
@@ -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])
131 changes: 6 additions & 125 deletions src/bmipy/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
32 changes: 8 additions & 24 deletions tests/cli_test.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -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"])
Expand Down
Loading

0 comments on commit 7d0e591

Please sign in to comment.