diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1785d01 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Community Surface Dynamics Modeling System + +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. diff --git a/bmipy/cmd.py b/bmipy/cmd.py new file mode 100644 index 0000000..71cedf6 --- /dev/null +++ b/bmipy/cmd.py @@ -0,0 +1,101 @@ +import inspect +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 name, 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("invalid class name ({0})".format(name)) + + +@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): + """Render a template BMI implementation in Python for class NAME.""" + if _is_valid_class_name(name): + print(render_bmi(name, black=black, hints=hints)) + else: + click.secho( + "💥 💔 💥 '{0}' is not a valid class name in Python".format(name), + err=True, + fg="red", + ) + ctx.exit(code=1) diff --git a/setup.py b/setup.py index 15c8e10..0513763 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,7 @@ name="bmipy", version=versioneer.get_version(), description="Basic Model Interface for Python", + long_description=open("README.rst").read(), author="Eric Hutton", author_email="huttone@colorado.edu", url="http://csdms.colorado.edu", @@ -21,7 +22,8 @@ "Topic :: Scientific/Engineering :: Physics", ], setup_requires=["setuptools"], - install_requires=["numpy"], + install_requires=["black", "click", "jinja2", "numpy"], packages=find_packages(), cmdclass=versioneer.get_cmdclass(), + entry_points={"console_scripts": ["bmipy-render=bmipy.cmd:main"]}, ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b7c47ef --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,72 @@ +from click.testing import CliRunner +import pytest + +from bmipy.cmd import main + + +def test_cli_version(): + runner = CliRunner() + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "version" in result.output + + +def test_cli_help(): + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "help" in result.output + + +def test_cli_default(tmpdir): + import importlib + import sys + + runner = CliRunner() + with tmpdir.as_cwd(): + result = runner.invoke(main, ["MyBmi"]) + assert result.exit_code == 0 + with open("mybmi.py", "w") as fp: + fp.write(result.output) + sys.path.append(".") + mod = importlib.import_module("mybmi") + 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): + runner = CliRunner() + with tmpdir.as_cwd(): + result = runner.invoke(main, ["MyBmiWithHints", "--black"]) + assert result.exit_code == 0 + assert max([len(line) for line in result.output.splitlines()]) <= 88 + + +def test_cli_without_black(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + result = runner.invoke(main, ["MyBmiWithoutHints", "--no-black"]) + assert result.exit_code == 0 + assert max([len(line) for line in result.output.splitlines()]) > 88 + + +@pytest.mark.parametrize("bad_name", ["True", "0Bmi"]) +def test_cli_with_bad_class_name(tmpdir, bad_name): + runner = CliRunner() + result = runner.invoke(main, [bad_name]) + assert result.exit_code == 1