Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
16 changes: 8 additions & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/psf/black
rev: 23.1.0
rev: 23.12.1
hooks:
- id: black
name: black
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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", "."]
Expand All @@ -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$
Expand All @@ -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]
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@ classifiers = [
]
requires-python = ">=3.9"
dependencies = [
"black",
"click",
"jinja2",
"numpy",
]
dynamic = ["version"]
Expand Down
2 changes: 0 additions & 2 deletions requirements.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
black
click
jinja2
numpy
2 changes: 0 additions & 2 deletions requirements/requires.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
black==23.10.1
click==8.1.7
jinja2==3.1.2
numpy==1.26.3
1 change: 1 addition & 0 deletions src/bmipy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""The Basic Model Interface (BMI) for Python."""
from __future__ import annotations

from ._version import __version__
from .bmi import Bmi
Expand Down
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])
2 changes: 2 additions & 0 deletions src/bmipy/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from __future__ import annotations

__version__ = "2.0.2.dev0"
1 change: 1 addition & 0 deletions src/bmipy/bmi.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
This language specification is derived from the Scientific Interface
Definition Language (SIDL) file `bmi.sidl <https://github.com/csdms/bmi>`_.
"""
from __future__ import annotations

from abc import ABC, abstractmethod

Expand Down
89 changes: 6 additions & 83 deletions src/bmipy/cmd.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading