diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4662b4b..dbabd3a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: - name: Install tox run: uv pip install '.[tox]' - name: Type-check - run: tox -e pyrefly + run: tox -e pyrefly,mypy,basedpyright,ty docs: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e65f22ca..df4a0146 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ cover /stl/*.c uv.lock benchmarks/.cache/ +.python-version diff --git a/CHANGELOG.md b/CHANGELOG.md index a9697e9e..1305b431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Resolved all mypy and basedpyright findings; malformed + `# type: ignore[ty:...]` suppressions replaced by `typing.cast` or + restructured code. +- Replaced `argparse.FileType` (deprecated since Python 3.14) in the CLI with + path arguments opened after parsing; stdin/stdout `-` semantics unchanged. + This also fixes a latent bug where the output file was truncated at + argument-parsing time, before the input was validated. + +### Changed +- CI now enforces all four type checkers (pyrefly, mypy, basedpyright, ty). + +### Removed +- Legacy `build.cmd` MSVC build script (the speedups extension it built was + removed in 4.0.0). + ## [4.0.0] - 2026-06-17 ### Fixed diff --git a/build.cmd b/build.cmd deleted file mode 100644 index 0e15329e..00000000 --- a/build.cmd +++ /dev/null @@ -1,21 +0,0 @@ -@echo off -:: To build extensions for 64 bit Python 3, we need to configure environment -:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: -:: MS Windows SDK for Windows 7 and .NET Framework 4 -:: -:: More details at: -:: https://github.com/cython/cython/wiki/CythonExtensionsOnWindows - -IF "%DISTUTILS_USE_SDK%"=="1" ( - ECHO Configuring environment to build with MSVC on a 64bit architecture - ECHO Using Windows SDK 7.1 - "C:\Program Files\Microsoft SDKs\Windows\v7.1\Setup\WindowsSdkVer.exe" -q -version:v7.1 - CALL "C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\SetEnv.cmd" /x64 /release - SET MSSdk=1 - REM Need the following to allow tox to see the SDK compiler - SET TOX_TESTENV_PASSENV=DISTUTILS_USE_SDK MSSdk INCLUDE LIB -) ELSE ( - ECHO Using default MSVC build environment -) - -CALL %* diff --git a/pyproject.toml b/pyproject.toml index 73d9a911..8ad1d5be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dev = [ "mypy>=1.0", "basedpyright>=1.0", "pyrefly>=0.1", + "ty>=0.0.1", ] fast = ["speedups>=2.0.0"] tox = [ @@ -99,6 +100,10 @@ enable_error_code = [ ] warn_return_any = false +[[tool.mypy.overrides]] +module = "speedups" +ignore_missing_imports = true + [tool.pyright] pythonPlatform = "All" diff --git a/speedups.pyi b/speedups.pyi new file mode 100644 index 00000000..3545c4a0 --- /dev/null +++ b/speedups.pyi @@ -0,0 +1,16 @@ +# Local stub for the optional speedups package so type +# checkers resolve it without it installed. +from typing import IO + +import numpy as np +import numpy.typing as npt + +def ascii_read( + fh: IO[bytes], + buf: bytes, +) -> tuple[bytes, npt.NDArray[np.void]]: ... +def ascii_write( + fh: IO[bytes], + name: bytes, + arr: npt.NDArray[np.void], +) -> None: ... diff --git a/stl/__about__.py b/stl/__about__.py index b05ae206..cf1dc28f 100644 --- a/stl/__about__.py +++ b/stl/__about__.py @@ -5,9 +5,11 @@ ) try: - __version__: typing.Final[str] = _version('numpy-stl') + _found_version: str = _version('numpy-stl') except PackageNotFoundError: - __version__: typing.Final[str] = '0.0.0' # type: ignore[misc] + _found_version = '0.0.0' + +__version__: typing.Final[str] = _found_version __package_name__: typing.Final[str] = 'numpy-stl' __import_name__: typing.Final[str] = 'stl' diff --git a/stl/_compat.py b/stl/_compat.py index 92e9f1b2..19794574 100644 --- a/stl/_compat.py +++ b/stl/_compat.py @@ -15,12 +15,15 @@ if _speedups_available: try: - from speedups import ( # noqa: F401 - ascii_read, # type: ignore[assignment] - ascii_write, # type: ignore[assignment] + from speedups import ( + ascii_read as _speedups_ascii_read, + ascii_write as _speedups_ascii_write, ) except ImportError: _speedups_available = False + else: + ascii_read = _speedups_ascii_read + ascii_write = _speedups_ascii_write def has_speedups() -> bool: diff --git a/stl/base.py b/stl/base.py index af1d46e8..2d5bfc5a 100644 --- a/stl/base.py +++ b/stl/base.py @@ -143,9 +143,8 @@ def logged(class_: type[_LoggedT]) -> type[_LoggedT]: Returns: The class with logger initialized. """ - logger_name = cast( - 'str', - logger.Logged._Logged__get_name(__name__, class_.__name__), # type: ignore[attr-defined, ty:unresolved-attribute] + logger_name: str = '.'.join( + part.strip() for part in (__name__, class_.__name__) if part.strip() ) class_.logger = logging.getLogger(logger_name) @@ -301,7 +300,7 @@ def __init__( def attr(self) -> _u16_2d: """Per-triangle attribute field (uint16), shape (N, 1).""" # https://github.com/numpy/numpy/pull/30261 - return self.data['attr'] # type: ignore[return-value, ty:invalid-return-type] + return cast('_u16_2d', self.data['attr']) @attr.setter def attr(self, value: '_ArrayLikeInt_co', /) -> None: @@ -325,7 +324,7 @@ def normals(self) -> _f32_2d: [0.0, 0.0, 1.0] """ # https://github.com/numpy/numpy/pull/30261 - return self.data['normals'] # type: ignore[return-value, ty:invalid-return-type] + return cast('_f32_2d', self.data['normals']) @normals.setter def normals(self, value: '_ArrayLikeFloat_co', /) -> None: @@ -335,7 +334,7 @@ def normals(self, value: '_ArrayLikeFloat_co', /) -> None: def vectors(self) -> _f32_3d: """Triangle vertices as (N, 3, 3) array.""" # https://github.com/numpy/numpy/pull/30261 - return self.data['vectors'] # type: ignore[return-value, ty:invalid-return-type] + return cast('_f32_3d', self.data['vectors']) @vectors.setter def vectors(self, value: '_ArrayLikeFloat_co', /) -> None: @@ -474,7 +473,7 @@ def remove_empty_areas(data: _data_1d) -> _data_1d: triangles removed. """ # https://github.com/numpy/numpy/pull/30261 - vectors: _f32_3d = data['vectors'] # type: ignore[assignment, ty:invalid-assignment] + vectors: _f32_3d = cast('_f32_3d', data['vectors']) v0 = vectors[:, 0] v1 = vectors[:, 1] v2 = vectors[:, 2] @@ -554,7 +553,8 @@ def update_areas(self, normals: '_f32_2d | None' = None) -> None: normals = np.cross(self.v1 - self.v0, self.v2 - self.v0) # pyrefly: ignore areas = 0.5 * np.sqrt((normals**2).sum(axis=1)) # pyrefly: ignore - self._areas = areas.reshape((areas.size, 1)) + # https://github.com/numpy/numpy/pull/30261 + self._areas = cast('_f32_2d', areas.reshape((areas.size, 1))) def update_centroids(self) -> None: """Refresh the cached per-triangle centroids.""" @@ -1097,7 +1097,9 @@ def __eq__(self, other: object) -> bool: return True return NotImplemented - __hash__ = object.__hash__ + def __hash__(self) -> int: + # Identity hash, consistent with the identity-based __eq__ above. + return object.__hash__(self) def __repr__(self) -> str: return f'' diff --git a/stl/main.py b/stl/main.py index 104e9d76..f3d08b88 100644 --- a/stl/main.py +++ b/stl/main.py @@ -1,27 +1,27 @@ import argparse import random import sys +import typing from . import stl +if typing.TYPE_CHECKING: # pragma: no cover + from typing import IO + def _get_parser(description: str) -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=description) - # The std stream defaults use the underlying binary buffers: STL - # data is binary and the text wrappers would corrupt it (or raise). parser.add_argument( 'infile', nargs='?', - type=argparse.FileType('rb'), - default=sys.stdin.buffer, - help='STL file to read', + default='-', + help="STL file to read ('-' or omitted reads from stdin)", ) parser.add_argument( 'outfile', nargs='?', - type=argparse.FileType('wb'), - default=sys.stdout.buffer, - help='STL file to write', + default='-', + help="STL file to write ('-' or omitted writes to stdout)", ) parser.add_argument('--name', nargs='?', help='Name of the mesh') parser.add_argument( @@ -46,17 +46,27 @@ def _get_parser(description: str) -> argparse.ArgumentParser: return parser +# The std stream defaults use the underlying binary buffers: STL +# data is binary and the text wrappers would corrupt it (or raise). +def _open_infile(path: str) -> 'IO[bytes]': + if path == '-': + return sys.stdin.buffer + return open(path, 'rb') + + +def _open_outfile(path: str) -> 'IO[bytes]': + if path == '-': + return sys.stdout.buffer + return open(path, 'wb') + + def _get_name(args: argparse.Namespace) -> str: - names = [ - args.name, - getattr(args.outfile, 'name', None), - getattr(args.infile, 'name', None), - ] + names: list[str | None] = [args.name, args.outfile, args.infile] for name in names: if not isinstance(name, str): continue - elif name.startswith('<'): # pragma: no cover + elif name == '-': continue elif r'\AppData\Local\Temp' in name: # pragma: no cover # Windows temp file @@ -67,6 +77,52 @@ def _get_name(args: argparse.Namespace) -> str: return 'numpy-stl-%06d' % random.randint(0, 1_000_000) # noqa: UP031 +def _convert( + parser: argparse.ArgumentParser, + args: argparse.Namespace, + mode: 'stl.Mode', +) -> None: + """Load the input mesh and save it in the requested mode. + + The input is opened and fully loaded before the output is opened, + so a bad input path never truncates an existing output file. + """ + name: str = _get_name(args) + + try: + infile: IO[bytes] = _open_infile(args.infile) + except OSError as exc: + parser.error(f"can't open {args.infile!r}: {exc}") + + try: + stl_file = stl.StlMesh( + filename=name, + fh=infile, + calculate_normals=False, + remove_empty_areas=args.remove_empty_areas, + speedups=not args.disable_speedups, + ) + finally: + if infile is not sys.stdin.buffer: + infile.close() + + try: + outfile: IO[bytes] = _open_outfile(args.outfile) + except OSError as exc: + parser.error(f"can't open {args.outfile!r}: {exc}") + + try: + stl_file.save( + name, + outfile, + mode=mode, + update_normals=not args.use_file_normals, + ) + finally: + if outfile is not sys.stdout.buffer: + outfile.close() + + def main() -> None: """CLI entry point for the ``stl`` command. @@ -89,15 +145,8 @@ def main() -> None: ) args = parser.parse_args() - name = _get_name(args) - stl_file = stl.StlMesh( - filename=name, - fh=args.infile, - calculate_normals=False, - remove_empty_areas=args.remove_empty_areas, - speedups=not args.disable_speedups, - ) + mode: stl.Mode if args.binary: mode = stl.BINARY elif args.ascii: @@ -105,9 +154,7 @@ def main() -> None: else: mode = stl.AUTOMATIC - stl_file.save( - name, args.outfile, mode=mode, update_normals=not args.use_file_normals - ) + _convert(parser, args, mode) def to_ascii() -> None: @@ -117,21 +164,7 @@ def to_ascii() -> None: Supports ``-n`` (keep file normals) and ``-s`` (disable speedups). """ parser = _get_parser('Convert STL files to ASCII (text) format') - args = parser.parse_args() - name = _get_name(args) - stl_file = stl.StlMesh( - filename=name, - fh=args.infile, - calculate_normals=False, - remove_empty_areas=args.remove_empty_areas, - speedups=not args.disable_speedups, - ) - stl_file.save( - name, - args.outfile, - mode=stl.ASCII, - update_normals=not args.use_file_normals, - ) + _convert(parser, parser.parse_args(), stl.ASCII) def to_binary() -> None: @@ -141,18 +174,4 @@ def to_binary() -> None: Supports ``-n`` (keep file normals) and ``-s`` (disable speedups). """ parser = _get_parser('Convert STL files to binary format') - args = parser.parse_args() - name = _get_name(args) - stl_file = stl.StlMesh( - filename=name, - fh=args.infile, - calculate_normals=False, - remove_empty_areas=args.remove_empty_areas, - speedups=not args.disable_speedups, - ) - stl_file.save( - name, - args.outfile, - mode=stl.BINARY, - update_normals=not args.use_file_normals, - ) + _convert(parser, parser.parse_args(), stl.BINARY) diff --git a/stl/ply.py b/stl/ply.py index 893e3f54..cf818106 100644 --- a/stl/ply.py +++ b/stl/ply.py @@ -413,7 +413,7 @@ def _triangulate( def _build_mesh_data( vertices: np.ndarray, triangles: list[tuple[int, int, int]], - mesh_dtype: np.dtype, # type: ignore[type-arg] + mesh_dtype: np.dtype[np.void], ) -> np.ndarray: """Build the structured numpy array for the mesh. @@ -546,7 +546,7 @@ def write_ply( def read_ply( fh: IO[bytes], - mesh_dtype: np.dtype, # type: ignore[type-arg] + mesh_dtype: np.dtype[np.void], ) -> tuple[np.ndarray, str]: """Read a PLY file and return mesh data. diff --git a/stl/stl.py b/stl/stl.py index 56d34e4f..b85c5ba9 100644 --- a/stl/stl.py +++ b/stl/stl.py @@ -539,7 +539,7 @@ def _write_binary( if isinstance(fh, io.BufferedWriter) and fh.seekable(): # Write to a true file. numpy's tofile() needs to query the # file position, which fails on pipes such as stdout. - self.data.tofile(fh) + self.data.tofile(fh) # pyright: ignore[reportUnknownArgumentType] else: # Write to a pseudo buffer (e.g. BytesIO or a pipe). cast('_StatefulWriter', fh).write(self.data.data) @@ -901,7 +901,7 @@ def save_ply( name = '' if isinstance(self.name, bytes): name = self.name.decode('ascii', errors='replace') - elif isinstance(self.name, str): + elif isinstance(self.name, str): # pyright: ignore[reportUnnecessaryIsInstance] name = self.name if fh: diff --git a/tests/test_commandline.py b/tests/test_commandline.py index 901d0c70..ab3de432 100644 --- a/tests/test_commandline.py +++ b/tests/test_commandline.py @@ -2,6 +2,8 @@ import subprocess import sys +import pytest + from stl import main, mesh @@ -104,3 +106,45 @@ def test_to_ascii_stdin_stdout_pipes(binary_file): result = _run_pipe('to_ascii', binary_file) assert result.returncode == 0, result.stderr.decode() assert result.stdout.startswith(b'solid') + + +def test_open_helpers_std_streams(): + assert main._open_infile('-') is sys.stdin.buffer + assert main._open_outfile('-') is sys.stdout.buffer + + +def test_main_std_streams_in_process(binary_file, monkeypatch): + with open(binary_file, 'rb') as fh: + data = fh.read() + + stdin_buffer = io.BytesIO(data) + stdout_buffer = io.BytesIO() + monkeypatch.setattr(sys, 'stdin', io.TextIOWrapper(stdin_buffer)) + monkeypatch.setattr(sys, 'stdout', io.TextIOWrapper(stdout_buffer)) + monkeypatch.setattr(sys, 'argv', ['stl', '-b']) + + main.main() + + loaded = mesh.Mesh.from_file( + 'out.stl', fh=io.BytesIO(stdout_buffer.getvalue()) + ) + assert len(loaded.data) > 0 + + +def test_main_missing_infile(tmpdir, monkeypatch, capsys): + missing = str(tmpdir.join('does-not-exist.stl')) + out = str(tmpdir.join('out.stl')) + monkeypatch.setattr(sys, 'argv', ['stl', missing, out]) + + with pytest.raises(SystemExit): + main.main() + assert "can't open" in capsys.readouterr().err + + +def test_main_unopenable_outfile(binary_file, tmpdir, monkeypatch, capsys): + bad_out = str(tmpdir.join('missing-dir', 'out.stl')) + monkeypatch.setattr(sys, 'argv', ['stl', binary_file, bad_out]) + + with pytest.raises(SystemExit): + main.main() + assert "can't open" in capsys.readouterr().err diff --git a/tests/test_mesh.py b/tests/test_mesh.py index de170784..de87558f 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -5,7 +5,8 @@ import numpy as np import pytest -from stl.base import BaseMesh, RemoveDuplicates +from python_utils.logger import Logged +from stl.base import BaseMesh, RemoveDuplicates, logged from stl.mesh import Mesh from . import utils @@ -420,3 +421,23 @@ def test_is_closed_heuristic(caplog): assert 'not exact' in caplog.text assert not _single_triangle_mesh().check(exact=False) + + +def test_logged_decorator_logger_name(): + # logged() must produce the same dotted name python-utils generates: + # '.'.join of the defining module and class name. + @logged + class _Sample(Logged): + pass + + assert _Sample.logger.name == 'stl.base._Sample' + + +def test_mesh_hash_is_identity_based(): + data = np.zeros(1, dtype=BaseMesh.dtype) + a = Mesh(data.copy(), remove_empty_areas=False) + b = Mesh(data.copy(), remove_empty_areas=False) + + assert hash(a) == object.__hash__(a) + assert a == a # identity equality + assert (a == b) is False # equal content, different identity diff --git a/tox.ini b/tox.ini index f485116f..9d77f280 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,9 @@ env_list = speedups lint pyrefly + mypy + basedpyright + ty docs docs-examples coverage @@ -63,19 +66,36 @@ commands = # ── Type checking ──────────────────────────────────────────── +# Checker versions are pinned for CI stability; bump deliberately. [testenv:pyrefly] description = Type-check with pyrefly deps = . - pyrefly>=0.1 + pyrefly==1.1.1 commands = pyrefly check +[testenv:mypy] +description = Type-check with mypy +deps = + . + mypy==2.1.0 +commands = + mypy + +[testenv:basedpyright] +description = Type-check with basedpyright +deps = + . + basedpyright==1.39.9 +commands = + basedpyright --pythonpath {envpython} + [testenv:ty] -description = Type-check with ty (run explicitly: tox -e ty) +description = Type-check with ty deps = . - ty + ty==0.0.56 commands = ty check stl