diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04d3130..4b9af12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,13 @@ on: - cron: '17 3 * * 0' jobs: + typos: + name: Typos + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: crate-ci/typos@master + ruff: name: Ruff runs-on: ubuntu-latest @@ -19,6 +26,24 @@ jobs: pip install ruff ruff check + mypy: + name: Mypy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: "Main Script" + run: | + EXTRA_INSTALL="mypy pytest" + curl -L -O https://tiker.net/ci-support-v0 + . ./ci-support-v0 + + build_py_project_in_venv + python -m mypy gmsh_interop test contrib + pytest3: name: Pytest Conda Py3 runs-on: ubuntu-latest diff --git a/contrib/gmsh-node-tuples.py b/contrib/gmsh-node-tuples.py index 2e90c83..a97f279 100644 --- a/contrib/gmsh-node-tuples.py +++ b/contrib/gmsh-node-tuples.py @@ -1,3 +1,6 @@ +import os +from collections.abc import Sequence + import gmsh @@ -5,13 +8,26 @@ # GMSH_VERSION: %s # DO NOT EDIT -triangle_data = %s +from collections.abc import Sequence +from typing import TypedDict + + +class ElementInfo(TypedDict): + element_name: str + '''Name of the element in GMSH.''' + element_type: int + '''An integer denoting the type of the element.''' + node_tuples: Sequence[tuple[int, ...]] + '''Index tuples that describe the ordering of the nodes.''' + -tetrahedron_data = %s +triangle_data: dict[int, ElementInfo] = %s -quadrangle_data = %s +tetrahedron_data: dict[int, ElementInfo] = %s -hexahedron_data = %s +quadrangle_data: dict[int, ElementInfo] = %s + +hexahedron_data: dict[int, ElementInfo] = %s """ @@ -70,7 +86,11 @@ } -def generate_node_tuples_from_gmsh(eltype, eldim, elvertices, domain="unit"): +def generate_node_tuples_from_gmsh( + eltype: int, + eldim: int, + elvertices: int, + domain: str = "unit") -> Sequence[tuple[int, ...]]: # {{{ get element _name, dim, order, nnodes, nodes, nvertices = ( @@ -89,10 +109,14 @@ def generate_node_tuples_from_gmsh(eltype, eldim, elvertices, domain="unit"): # }}} - return [tuple(node) for node in nodes.astype(int)] + return [tuple(int(x) for x in node) for node in nodes.astype(int)] + +def generate_node_tuples(filename: str | None, *, overwrite: bool = False) -> int: + if not overwrite and filename is not None and os.path.exists(filename): + print(f"ERROR: File already exists (use --force): '{filename}'") + return 1 -def generate_node_tuples(filename): tri_data = {} tet_data = {} qua_data = {} @@ -136,6 +160,7 @@ def generate_node_tuples(filename): gmsh.finalize() from pprint import pformat + txt = (OUTPUT_TEMPLATE % ( gmsh.GMSH_API_VERSION, pformat(tri_data, width=80), @@ -150,12 +175,20 @@ def generate_node_tuples(filename): with open(filename, "w") as fd: fd.write(txt) + return 0 + if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() parser.add_argument("filename", nargs="?", default=None) + parser.add_argument( + "-f", + "--force", + action="store_true", + help="Overwrite existing files", + ) args = parser.parse_args() - generate_node_tuples(args.filename) + raise SystemExit(generate_node_tuples(args.filename, overwrite=args.force)) diff --git a/doc/Makefile b/doc/Makefile index d4bb2cb..d0ac5f2 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -4,7 +4,7 @@ # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build +SPHINXBUILD ?= python $(shell which sphinx-build) SOURCEDIR = . BUILDDIR = _build diff --git a/gmsh_interop/node_tuples.py b/gmsh_interop/node_tuples.py index 20b6f49..4363101 100644 --- a/gmsh_interop/node_tuples.py +++ b/gmsh_interop/node_tuples.py @@ -1,8 +1,21 @@ # GENERATED by gmsh_interop/contrib/gmsh-node-tuples.py -# GMSH_VERSION: 4.7.0 +# GMSH_VERSION: 4.13.1 # DO NOT EDIT -triangle_data = { +from collections.abc import Sequence +from typing import TypedDict + + +class ElementInfo(TypedDict): + element_name: str + """Name of the element in GMSH.""" + element_type: int + """An integer denoting the type of the element.""" + node_tuples: Sequence[tuple[int, ...]] + """Index tuples that describe the ordering of the nodes.""" + + +triangle_data: dict[int, ElementInfo] = { 1: { "element_name": "MSH_TRI_3", "element_type": 2, @@ -339,7 +352,7 @@ }, } -tetrahedron_data = { +tetrahedron_data: dict[int, ElementInfo] = { 1: { "element_name": "MSH_TET_4", "element_type": 4, @@ -1397,7 +1410,7 @@ }, } -quadrangle_data = { +quadrangle_data: dict[int, ElementInfo] = { 1: { "element_name": "MSH_QUA_4", "element_type": 3, @@ -1960,7 +1973,7 @@ }, } -hexahedron_data = { +hexahedron_data: dict[int, ElementInfo] = { 1: { "element_name": "MSH_HEX_8", "element_type": 5, diff --git a/gmsh_interop/py.typed b/gmsh_interop/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/gmsh_interop/reader.py b/gmsh_interop/reader.py index 0ded6a8..288f07e 100644 --- a/gmsh_interop/reader.py +++ b/gmsh_interop/reader.py @@ -23,6 +23,10 @@ THE SOFTWARE. """ +from abc import ABC, abstractmethod +from collections.abc import Iterable, Iterator, MutableSequence, Sequence +from typing import ClassVar, Literal + import numpy as np from pytools import memoize_method @@ -85,13 +89,13 @@ # {{{ tools -def generate_triangle_vertex_tuples(order): +def generate_triangle_vertex_tuples(order: int) -> Iterator[tuple[int, int]]: yield (0, 0) yield (order, 0) yield (0, order) -def generate_triangle_edge_tuples(order): +def generate_triangle_edge_tuples(order: int) -> Iterator[tuple[int, int]]: for i in range(1, order): yield (i, 0) for i in range(1, order): @@ -100,24 +104,25 @@ def generate_triangle_edge_tuples(order): yield (0, order-i) -def generate_triangle_volume_tuples(order): +def generate_triangle_volume_tuples(order: int) -> Iterator[tuple[int, int]]: for i in range(1, order): for j in range(1, order-i): yield (j, i) -def generate_quad_vertex_tuples(dim, order): +def generate_quad_vertex_tuples(dim: int, order: int) -> Iterator[tuple[int, ...]]: from pytools import generate_nonnegative_integer_tuples_below + for tup in generate_nonnegative_integer_tuples_below(2, dim): yield tuple(order * i for i in tup) class LineFeeder: - def __init__(self, line_iterable): + def __init__(self, line_iterable: Iterable[str]) -> None: self.line_iterable = iter(line_iterable) - self.next_line = None + self.next_line: str | None = None - def has_next_line(self): + def has_next_line(self) -> bool: if self.next_line is not None: return True @@ -128,7 +133,7 @@ def has_next_line(self): else: return True - def get_next_line(self): + def get_next_line(self) -> str: if self.next_line is not None: nl = self.next_line self.next_line = None @@ -137,7 +142,7 @@ def get_next_line(self): try: nl = next(self.line_iterable) except StopIteration: - raise GmshFileFormatError("unexpected end of file") from None + raise GmshFileFormatError("Unexpected end of file") from None else: return nl.strip() @@ -146,7 +151,11 @@ def get_next_line(self): # {{{ element info -class GmshElementBase: +IndexArray = np.ndarray[tuple[int, ...], np.dtype[np.integer]] +NodeTuples = Sequence[tuple[int, ...]] + + +class GmshElementBase(ABC): """ .. automethod:: vertex_count .. automethod:: node_count @@ -158,30 +167,42 @@ class GmshElementBase: whose sum is less than or equal to the order of the element. .. automethod:: get_lexicographic_gmsh_node_indices - - (Implemented by subclasses) """ - def __init__(self, order): + + def __init__(self, order: int) -> None: self.order = order @property - def element_type(self): - raise NotImplementedError + @abstractmethod + def dimensions(self) -> int: + pass + + @property + @abstractmethod + def element_type(self) -> int: + pass + + @abstractmethod + def vertex_count(self) -> int: + pass - def vertex_count(self): - raise NotImplementedError + @abstractmethod + def node_count(self) -> int: + pass - def node_count(self): - raise NotImplementedError + @abstractmethod + def gmsh_node_tuples(self) -> NodeTuples: + pass - def lexicographic_node_tuples(self): - raise NotImplementedError + @abstractmethod + def lexicographic_node_tuples(self) -> NodeTuples: + pass @memoize_method - def get_lexicographic_gmsh_node_indices(self): + def get_lexicographic_gmsh_node_indices(self) -> IndexArray: gmsh_tup_to_index = { - tup: i - for i, tup in enumerate(self.gmsh_node_tuples())} + tup: i for i, tup in enumerate(self.gmsh_node_tuples()) + } return np.array([ gmsh_tup_to_index[tup] for tup in self.lexicographic_node_tuples()], @@ -191,11 +212,11 @@ def get_lexicographic_gmsh_node_indices(self): # {{{ simplices class GmshSimplexElementBase(GmshElementBase): - def vertex_count(self): + def vertex_count(self) -> int: return self.dimensions + 1 @memoize_method - def node_count(self): + def node_count(self) -> int: """Return the number of interpolation nodes in this element.""" import math from functools import reduce @@ -205,7 +226,7 @@ def node_count(self): // math.factorial(self.dimensions)) @memoize_method - def lexicographic_node_tuples(self): + def lexicographic_node_tuples(self) -> NodeTuples: from pytools import ( generate_nonnegative_integer_tuples_summing_to_at_most as gnitstam, ) @@ -216,44 +237,46 @@ def lexicographic_node_tuples(self): class GmshPoint(GmshSimplexElementBase): - dimensions = 0 + @property + def dimensions(self) -> int: + return 0 @property - def element_type(self): + def element_type(self) -> int: return 15 @memoize_method - def gmsh_node_tuples(self): + def gmsh_node_tuples(self) -> NodeTuples: return [()] class GmshIntervalElement(GmshSimplexElementBase): - dimensions = 1 + @property + def dimensions(self) -> int: + return 1 @property @memoize_method - def element_type(self): + def element_type(self) -> int: return [1, 8, 26, 27, 28, 62, 63, 64, 65, 66][self.order - 1] @memoize_method - def gmsh_node_tuples(self): - return [(0,), (self.order,), ] + [ - (i,) for i in range(1, self.order)] + def gmsh_node_tuples(self) -> NodeTuples: + return [(0,), (self.order,), ] + [(i,) for i in range(1, self.order)] class GmshIncompleteTriangularElement(GmshSimplexElementBase): - dimensions = 2 - - def __init__(self, order): - self.order = order + @property + def dimensions(self) -> int: + return 2 @property @memoize_method - def element_type(self): + def element_type(self) -> int: return {3: 20, 4: 22, 5: 24}[self.order] @memoize_method - def gmsh_node_tuples(self): + def gmsh_node_tuples(self) -> NodeTuples: result = [] for tup in generate_triangle_vertex_tuples(self.order): result.append(tup) @@ -263,31 +286,35 @@ def gmsh_node_tuples(self): class GmshTriangularElement(GmshSimplexElementBase): - dimensions = 2 + @property + def dimensions(self) -> int: + return 2 @property @memoize_method - def element_type(self): + def element_type(self) -> int: from gmsh_interop.node_tuples import triangle_data return triangle_data[self.order]["element_type"] @memoize_method - def gmsh_node_tuples(self): + def gmsh_node_tuples(self) -> NodeTuples: from gmsh_interop.node_tuples import triangle_data return triangle_data[self.order]["node_tuples"] class GmshTetrahedralElement(GmshSimplexElementBase): - dimensions = 3 + @property + def dimensions(self) -> int: + return 3 @property @memoize_method - def element_type(self): + def element_type(self) -> int: from gmsh_interop.node_tuples import tetrahedron_data return tetrahedron_data[self.order]["element_type"] @memoize_method - def gmsh_node_tuples(self): + def gmsh_node_tuples(self) -> NodeTuples: from gmsh_interop.node_tuples import tetrahedron_data return tetrahedron_data[self.order]["node_tuples"] @@ -297,19 +324,20 @@ def gmsh_node_tuples(self): # {{{ tensor product elements class GmshTensorProductElementBase(GmshElementBase): - def vertex_count(self): - return 2**self.dimensions + def vertex_count(self) -> int: + return int(2**self.dimensions) @memoize_method - def node_count(self): - return (self.order+1) ** self.dimensions + def node_count(self) -> int: + return int((self.order+1) ** self.dimensions) @memoize_method - def lexicographic_node_tuples(self): - """Generate tuples enumerating the node indices present - in this element. Each tuple has a length equal to the dimension - of the element. The tuples constituents are non-negative integers - whose sum is less than or equal to the order of the element. + def lexicographic_node_tuples(self) -> NodeTuples: + """Generate tuples enumerating the node indices present in this element. + + Each tuple has a length equal to the dimension of the element. The + tuples constituents are non-negative integers whose sum is less than or + equal to the order of the element. """ from pytools import generate_nonnegative_integer_tuples_below as gnitb @@ -323,31 +351,35 @@ def lexicographic_node_tuples(self): class GmshQuadrilateralElement(GmshTensorProductElementBase): - dimensions = 2 + @property + def dimensions(self) -> int: + return 2 @property @memoize_method - def element_type(self): + def element_type(self) -> int: from gmsh_interop.node_tuples import quadrangle_data return quadrangle_data[self.order]["element_type"] @memoize_method - def gmsh_node_tuples(self): + def gmsh_node_tuples(self) -> NodeTuples: from gmsh_interop.node_tuples import quadrangle_data return quadrangle_data[self.order]["node_tuples"] class GmshHexahedralElement(GmshTensorProductElementBase): - dimensions = 3 + @property + def dimensions(self) -> int: + return 3 @property @memoize_method - def element_type(self): + def element_type(self) -> int: from gmsh_interop.node_tuples import hexahedron_data return hexahedron_data[self.order]["element_type"] @memoize_method - def gmsh_node_tuples(self): + def gmsh_node_tuples(self) -> NodeTuples: from gmsh_interop.node_tuples import hexahedron_data return hexahedron_data[self.order]["node_tuples"] @@ -358,7 +390,11 @@ def gmsh_node_tuples(self): # {{{ receiver interface -def _gmsh_supported_element_type_map(): +Point = np.ndarray[tuple[int, ...], np.dtype[np.floating]] +Nodes = np.ndarray[tuple[int, ...], np.dtype[np.floating]] + + +def _gmsh_supported_element_type_map() -> dict[int, GmshElementBase]: supported_elements = ( [GmshPoint(0)] + [GmshIntervalElement(n + 1) for n in range(10)] @@ -374,7 +410,8 @@ def _gmsh_supported_element_type_map(): class GmshMeshReceiverBase: """ - .. attribute:: gmsh_element_type_to_info_map + .. autoattribute:: gmsh_element_type_to_info_map + .. automethod:: set_up_nodes .. automethod:: add_node .. automethod:: finalize_nodes @@ -385,31 +422,36 @@ class GmshMeshReceiverBase: .. automethod:: finalize_tags """ - gmsh_element_type_to_info_map = _gmsh_supported_element_type_map() + gmsh_element_type_to_info_map: ClassVar[dict[int, GmshElementBase]] = ( + _gmsh_supported_element_type_map()) - def set_up_nodes(self, count): + def set_up_nodes(self, count: int) -> None: pass - def add_node(self, node_nr, point): + def add_node(self, node_nr: int, point: Point) -> None: pass - def finalize_nodes(self): + def finalize_nodes(self) -> None: pass - def set_up_elements(self, count): + def set_up_elements(self, count: int) -> None: pass - def add_element(self, element_nr, element_type, vertex_nrs, - lexicographic_nodes, tag_numbers): + def add_element(self, + element_nr: int, + element_type: GmshElementBase, + vertex_nrs: IndexArray, + lexicographic_nodes: Nodes, + tag_numbers: Sequence[int]) -> None: pass - def finalize_elements(self): + def finalize_elements(self) -> None: pass - def add_tag(self, name, index, dimension): + def add_tag(self, name: str, index: int, dimension: int) -> None: pass - def finalize_tags(self): + def finalize_tags(self) -> None: pass # }}} @@ -418,62 +460,72 @@ def finalize_tags(self): # {{{ receiver example class GmshMeshReceiverNumPy(GmshMeshReceiverBase): - """GmshReceiver that emulates the semantics of - :class:`meshpy.triangle.MeshInfo` and :class:`meshpy.tet.MeshInfo` by using - similar fields, but instead of loading data into ForeignArrays, load into - NumPy arrays. Since this class is not wrapping any libraries in other - languages -- the Gmsh data is obtained via parsing text -- use :mod:`numpy` - arrays as the base array data structure for convenience. + """GmshReceiver that loads fields into :mod:`numpy` arrays. + + This class emulates the semantics of :class:`meshpy.triangle.MeshInfo` and + :class:`meshpy.tet.MeshInfo` by using similar fields, but instead of loading + data into ForeignArrays, load into :mod:`numpy` arrays. Since this class is + not wrapping any libraries in other languages -- the Gmsh data is obtained + via parsing text -- use :mod:`numpy` arrays as the base array data structure + for convenience. .. versionadded:: 2014.1 """ - def __init__(self): - # Use data fields similar to meshpy.triangle.MeshInfo and - # meshpy.tet.MeshInfo - self.points = None - self.elements = None - self.element_types = None - self.element_markers = None - self.tags = None + def __init__(self) -> None: + # Use data fields similar to meshpy.triangle.MeshInfo and meshpy.tet.MeshInfo + self.points: MutableSequence[Point | None] | None = None + self.elements: MutableSequence[IndexArray | None] | None = None + self.element_types: MutableSequence[GmshElementBase | None] | None = None + self.element_markers: MutableSequence[Sequence[int] | None] | None = None + self.tags: MutableSequence[tuple[str, int, int]] | None = None # Gmsh has no explicit concept of facets or faces; certain faces are a type # of element. Consequently, there are no face markers, but elements can be # grouped together in physical groups that serve as markers. - def set_up_nodes(self, count): + def set_up_nodes(self, count: int) -> None: # Preallocate array of nodes within list; treat None as sentinel value. # Preallocation not done for performance, but to assign values at indices # in random order. self.points = [None] * count - def add_node(self, node_nr, point): + def add_node(self, node_nr: int, point: Point) -> None: + assert self.points is not None self.points[node_nr] = point - def finalize_nodes(self): + def finalize_nodes(self) -> None: pass - def set_up_elements(self, count): + def set_up_elements(self, count: int) -> None: # Preallocation of arrays for assignment elements in random order. self.elements = [None] * count self.element_types = [None] * count self.element_markers = [None] * count self.tags = [] - def add_element(self, element_nr, element_type, vertex_nrs, - lexicographic_nodes, tag_numbers): + def add_element(self, + element_nr: int, + element_type: GmshElementBase, + vertex_nrs: IndexArray, + lexicographic_nodes: Nodes, + tag_numbers: Sequence[int]) -> None: + assert self.elements is not None self.elements[element_nr] = vertex_nrs + assert self.element_types is not None self.element_types[element_nr] = element_type + assert self.element_markers is not None self.element_markers[element_nr] = tag_numbers # TODO: Add lexicographic node information - def finalize_elements(self): + def finalize_elements(self) -> None: pass - def add_tag(self, name, index, dimension): + def add_tag(self, name: str, index: int, dimension: int) -> None: + assert self.tags is not None self.tags.append((name, index, dimension)) - def finalize_tags(self): + def finalize_tags(self) -> None: pass # }}} @@ -485,25 +537,32 @@ class GmshFileFormatError(RuntimeError): pass -def read_gmsh(receiver, filename, force_dimension=None): +def read_gmsh( + receiver: GmshMeshReceiverBase, + filename: str, + force_dimension: int | None = None) -> None: """Read a gmsh mesh file from *filename* and feed it to *receiver*. :param receiver: Implements the :class:`GmshMeshReceiverBase` interface. :param force_dimension: if not None, truncate point coordinates to this many dimensions. """ - mesh_file = open(filename) - try: - result = parse_gmsh(receiver, mesh_file, force_dimension=force_dimension) - finally: - mesh_file.close() - - return result - - -def generate_gmsh(receiver, source, dimensions=None, order=None, other_options=(), - extension="geo", gmsh_executable="gmsh", force_dimension=None, - target_unit=None, output_file_name=None, save_tmp_files_in=None): + with open(filename) as mesh_file: + parse_gmsh(receiver, mesh_file, force_dimension=force_dimension) + + +def generate_gmsh( + receiver: GmshMeshReceiverBase, + source: str | ScriptSource | FileSource | ScriptWithFilesSource, + dimensions: int | None = None, + order: int | None = None, + other_options: tuple[str, ...] = (), + extension: str = "geo", + gmsh_executable: str = "gmsh", + force_dimension: int | None = None, + target_unit: Literal["M", "MM"] | None = None, + output_file_name: str | None = None, + save_tmp_files_in: str | None = None) -> None: """Run gmsh and feed the output to *receiver*. :arg receiver: a class that implements the :class:`GmshMeshReceiverBase` @@ -511,6 +570,7 @@ def generate_gmsh(receiver, source, dimensions=None, order=None, other_options=( :arg source: an instance of :class:`ScriptSource` or :class:`FileSource`. """ from gmsh_interop.runner import GmshRunner + runner = GmshRunner(source, dimensions, order=order, other_options=other_options, extension=extension, gmsh_executable=gmsh_executable, @@ -518,17 +578,16 @@ def generate_gmsh(receiver, source, dimensions=None, order=None, other_options=( output_file_name=output_file_name, save_tmp_files_in=save_tmp_files_in) - runner.__enter__() - try: - result = parse_gmsh(receiver, runner.output_file, - force_dimension=force_dimension) - finally: - runner.__exit__(None, None, None) + with runner: + parse_gmsh( + receiver, + runner.output_file, + force_dimension=force_dimension) - return result - -def parse_gmsh(receiver, line_iterable, force_dimension=None): +def parse_gmsh(receiver: GmshMeshReceiverBase, + line_iterable: Iterable[str], + force_dimension: int | None = None) -> None: """ :arg receiver: this object will be fed the entities encountered in reading the GMSH file. See :class:`GmshMeshReceiverBase` for the @@ -545,7 +604,7 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): next_line = feeder.get_next_line() if not next_line.startswith("$"): raise GmshFileFormatError( - f"expected start of section, '{next_line}' found instead") + f"Expected start of section: found '{next_line}'") section_name = next_line[1:] @@ -553,7 +612,7 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): line_count = 0 while True: next_line = feeder.get_next_line() - if next_line == "$End"+section_name: + if next_line == f"$End{section_name}": break if line_count == 0: @@ -561,7 +620,7 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): if line_count > 0: raise GmshFileFormatError( - "more than one line found in MeshFormat section") + "More than one line found in 'MeshFormat' section") if not version_number.startswith("2."): # https://github.com/inducer/gmsh_interop/issues/18 @@ -572,12 +631,12 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): if version_number not in ["2.1", "2.2"]: from warnings import warn - warn(f"unexpected mesh version number '{version_number}' " - "found, continuing", stacklevel=2) + warn(f"Unexpected mesh version number '{version_number}' " + "found. Continuing anyway!", stacklevel=2) if file_type != "0": raise GmshFileFormatError( - "only ASCII gmsh file type is supported") + f"Only ASCII Gmsh file type is supported: '{file_type}'") line_count += 1 @@ -589,22 +648,25 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): while True: next_line = feeder.get_next_line() - if next_line == "$End"+section_name: + if next_line == f"$End{section_name}": break - parts = next_line.split() - if len(parts) != 4: + node_parts = next_line.split() + if len(node_parts) != 4: raise GmshFileFormatError( - "expected four-component line in $Nodes section") + "Expected four-component line in $Nodes section: " + f"got {node_parts} nodes") - read_node_idx = int(parts[0]) + read_node_idx = int(node_parts[0]) if read_node_idx != node_idx: - raise GmshFileFormatError("out-of-order node index found") + raise GmshFileFormatError( + f"Out-of-order node index found: got node {read_node_idx} " + f"but expected node {node_idx}") if force_dimension is not None: - point = [float(x) for x in parts[1:force_dimension+1]] + point = [float(x) for x in node_parts[1:force_dimension+1]] else: - point = [float(x) for x in parts[1:]] + point = [float(x) for x in node_parts[1:]] receiver.add_node( node_idx-1, @@ -613,7 +675,9 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): node_idx += 1 if node_count+1 != node_idx: - raise GmshFileFormatError("unexpected number of nodes found") + raise GmshFileFormatError( + f"Unexpected number of nodes found: got {node_idx} nodes " + f"but expected {node_count + 1} nodes") receiver.finalize_nodes() @@ -624,37 +688,42 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): element_idx = 1 while True: next_line = feeder.get_next_line() - if next_line == "$End"+section_name: + if next_line == f"$End{section_name}": break - parts = [int(x) for x in next_line.split()] + elem_parts = [int(x) for x in next_line.split()] - if len(parts) < 4: - raise GmshFileFormatError("too few entries in element line") + if len(elem_parts) < 4: + raise GmshFileFormatError( + f"Too few entries in element line: got {elem_parts} " + "but expected a list of at least 4 entries") - read_element_idx = parts[0] + read_element_idx = elem_parts[0] if read_element_idx != element_idx: - raise GmshFileFormatError("out-of-order node index found") + raise GmshFileFormatError( + "Out-of-order element index found: got element " + f"{read_element_idx} but expected element {element_idx}") - el_type_num = parts[1] + el_type_num = elem_parts[1] try: - element_type = \ - receiver.gmsh_element_type_to_info_map[el_type_num] + element_type = receiver.gmsh_element_type_to_info_map[el_type_num] except KeyError: raise GmshFileFormatError( - f"unexpected element type: {el_type_num}" + f"Unexpected element type: {el_type_num}" ) from None - tag_count = parts[2] - tags = parts[3:3+tag_count] + tag_count = elem_parts[2] + tags = elem_parts[3:3+tag_count] # convert to zero-based node_indices = np.array( - [x-1 for x in parts[3+tag_count:]], dtype=np.intp) + [x-1 for x in elem_parts[3+tag_count:]], dtype=np.intp) if element_type.node_count() != len(node_indices): raise GmshFileFormatError( - "unexpected number of nodes in element") + "Unexpected number of nodes in element: got " + f"{len(node_indices)} nodes but expected " + f"{element_type.node_count()} nodes") gmsh_vertex_nrs = node_indices[:element_type.vertex_count()] zero_based_idx = element_idx - 1 @@ -670,7 +739,9 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): element_idx += 1 if element_count+1 != element_idx: - raise GmshFileFormatError("unexpected number of elements found") + raise GmshFileFormatError( + f"Unexpected number of elements found: got {element_idx} " + f"elements but expected {element_count + 1}") receiver.finalize_elements() @@ -680,15 +751,16 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): while True: next_line = feeder.get_next_line() - if next_line == "$End"+section_name: + if next_line == f"$End{section_name}": break - dimension, number, name = next_line.split(" ", 2) - dimension = int(dimension) - number = int(number) + dimension_, number_, name = next_line.split(" ", 2) + dimension = int(dimension_) + number = int(number_) if not name[0] == '"' or not name[-1] == '"': - raise GmshFileFormatError("expected quotes around physical name") + raise GmshFileFormatError( + f"Expected quotes around physical name: <{name}>") receiver.add_tag(name[1:-1], number, dimension) @@ -696,18 +768,19 @@ def parse_gmsh(receiver, line_iterable, force_dimension=None): if name_count+1 != name_idx: raise GmshFileFormatError( - "unexpected number of physical names found") + f"Unexpected number of physical names found: got {name_idx} " + f"names but expected {name_count + 1} names") receiver.finalize_tags() else: # unrecognized section, skip from warnings import warn - warn(f"unrecognized section '{section_name}' in gmsh file", + warn(f"Unrecognized section '{section_name}' in Gmsh file", stacklevel=2) while True: next_line = feeder.get_next_line() - if next_line == "$End"+section_name: + if next_line == f"$End{section_name}": break # }}} diff --git a/gmsh_interop/runner.py b/gmsh_interop/runner.py index 84f4949..bdf8201 100644 --- a/gmsh_interop/runner.py +++ b/gmsh_interop/runner.py @@ -24,6 +24,9 @@ """ import logging +from collections.abc import Iterable +from types import TracebackType +from typing import Literal from packaging.version import Version @@ -49,35 +52,36 @@ class GmshError(RuntimeError): # {{{ tools -def _erase_dir(dir): +def _erase_dir(dir: str) -> None: from os import listdir, rmdir, unlink from os.path import join + for name in listdir(dir): unlink(join(dir, name)) rmdir(dir) class _TempDirManager: - def __init__(self): + def __init__(self) -> None: from tempfile import mkdtemp self.path = mkdtemp() - def sub(self, n): + def sub(self, n: str) -> str: from os.path import join return join(self.path, n) - def clean_up(self): + def clean_up(self) -> None: _erase_dir(self.path) - def error_clean_up(self): + def error_clean_up(self) -> None: _erase_dir(self.path) -class ScriptSource: +class ScriptSource: # noqa: B903 """ .. versionadded:: 2016.1 """ - def __init__(self, source, extension): + def __init__(self, source: str, extension: str) -> None: self.source = source self.extension = extension @@ -86,19 +90,21 @@ class LiteralSource(ScriptSource): """ .. versionadded:: 2014.1 """ - def __init__(self, source, extension): + + def __init__(self, source: str, extension: str) -> None: super().__init__(source, extension) from warnings import warn warn("LiteralSource is deprecated, use ScriptSource instead", - DeprecationWarning, stacklevel=2) + DeprecationWarning, stacklevel=2) -class FileSource: +class FileSource: # noqa: B903 """ .. versionadded:: 2014.1 """ - def __init__(self, filename): + + def __init__(self, filename: str) -> None: self.filename = filename @@ -115,10 +121,14 @@ class ScriptWithFilesSource: The names of files to be copied to the temporary directory where gmsh is run. """ - def __init__(self, source, filenames, source_name="temp.geo"): + + def __init__(self, + source: str, + filenames: Iterable[str], + source_name: str = "temp.geo") -> None: self.source = source self.source_name = source_name - self.filenames = filenames + self.filenames = tuple(filenames) def get_gmsh_version(executable: str = "gmsh") -> Version | None: @@ -149,12 +159,18 @@ def get_gmsh_version_from_string(output: str) -> Version | None: class GmshRunner: - def __init__(self, source, dimensions=None, order=None, - incomplete_elements=None, other_options=(), - extension="geo", gmsh_executable="gmsh", - output_file_name=None, - target_unit=None, - save_tmp_files_in=None): + def __init__( + self, + source: str | ScriptSource | FileSource | ScriptWithFilesSource, + dimensions: int | None = None, + order: int | None = None, + incomplete_elements: bool | None = None, + other_options: tuple[str, ...] = (), + extension: str = "geo", + gmsh_executable: str = "gmsh", + output_file_name: str | None = None, + target_unit: Literal["M", "MM"] | None = None, + save_tmp_files_in: str | None = None) -> None: if isinstance(source, str): from warnings import warn warn("passing a string as 'source' is deprecated -- use " @@ -190,14 +206,14 @@ def __init__(self, source, dimensions=None, order=None, @property @memoize_method - def version(self): + def version(self) -> Version: result = get_gmsh_version(self.gmsh_executable) if result is None: raise AttributeError("version") return result - def __enter__(self): + def __enter__(self) -> "GmshRunner": self.temp_dir_mgr = None temp_dir_mgr = _TempDirManager() try: @@ -264,7 +280,7 @@ def __enter__(self): if self.incomplete_elements is not None: cmdline.extend(["-setstring", - "Mesh.SecondOrderIncomplete", self.incomplete_elements]) + "Mesh.SecondOrderIncomplete", str(int(self.incomplete_elements))]) cmdline.extend(self.other_options) cmdline.append(source_file_name) @@ -275,11 +291,11 @@ def __enter__(self): logger.info("invoking gmsh: '%s'", " ".join(cmdline)) from pytools.prefork import call_capture_output - _retcode, stdout, stderr = call_capture_output(cmdline, working_dir) + _retcode, stdout_b, stderr_b = call_capture_output(cmdline, working_dir) logger.info("return from gmsh") - stdout = stdout.decode("utf-8") - stderr = stderr.decode("utf-8") + stdout = stdout_b.decode("utf-8") + stderr = stderr_b.decode("utf-8") import re error_match = re.match(r"([0-9]+)\s+error", stdout) @@ -287,7 +303,8 @@ def __enter__(self): if error_match is not None or warning_match is not None: # if we have one, we expect to see both - assert error_match is not None or warning_match is not None + assert error_match is not None + assert warning_match is not None num_warnings = int(warning_match.group(1)) num_errors = int(error_match.group(1)) @@ -353,12 +370,16 @@ def __enter__(self): raise self.temp_dir_mgr = temp_dir_mgr + return self except Exception: temp_dir_mgr.clean_up() raise - def __exit__(self, type, value, traceback): + def __exit__(self, + type: type[BaseException] | None, + value: BaseException | None, + traceback: TracebackType | None) -> None: self.output_file.close() if self.temp_dir_mgr is not None: self.temp_dir_mgr.clean_up() diff --git a/pyproject.toml b/pyproject.toml index 8561078..ed6198b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,3 +86,14 @@ combine-as-imports = true known-first-party = ["pytools"] known-local-folder = ["gmsh_interop"] lines-after-imports = 2 + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = [ + "gmsh.*", +] +ignore_missing_imports = true diff --git a/test/test_gmsh.py b/test/test_gmsh.py index ae8bf7f..10de920 100644 --- a/test/test_gmsh.py +++ b/test/test_gmsh.py @@ -20,12 +20,13 @@ THE SOFTWARE. """ + import pytest # {{{ gmsh -def search_on_path(filenames): +def search_on_path(filenames: list[str]) -> str | None: """Find file on system path.""" # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52224 @@ -40,6 +41,8 @@ def search_on_path(filenames): if exists(join(path, filename)): return abspath(join(path, filename)) + return None + GMSH_SPHERE = """ x = 0; y = 1; z = 2; r = 3; lc = 0.3; @@ -101,7 +104,9 @@ def search_on_path(filenames): @pytest.mark.parametrize("dim", [2, 3]) @pytest.mark.parametrize("order", [1, 3]) -def test_simplex_gmsh(dim, order, visualize=False): +def test_simplex_gmsh(dim: int, + order: int, + visualize: bool = False) -> None: if search_on_path(["gmsh"]) is None: pytest.skip("gmsh executable not found") @@ -121,7 +126,9 @@ def test_simplex_gmsh(dim, order, visualize=False): @pytest.mark.parametrize("dim", [2, 3]) @pytest.mark.parametrize("order", [1, 3]) -def test_quad_gmsh(dim, order, visualize=False): +def test_quad_gmsh(dim: int, + order: int, + visualize: bool = False) -> None: if search_on_path(["gmsh"]) is None: pytest.skip("gmsh executable not found") @@ -145,7 +152,7 @@ def test_quad_gmsh(dim, order, visualize=False): # }}} -def test_lex_node_ordering(): +def test_lex_node_ordering() -> None: """Check that lex nodes go through axes 'in order', i.e. that the r-axis is the first one to become non-zero, then s, then t. """