Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ cover
/stl/*.c
uv.lock
benchmarks/.cache/
.python-version
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 0 additions & 21 deletions build.cmd

This file was deleted.

5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ dev = [
"mypy>=1.0",
"basedpyright>=1.0",
"pyrefly>=0.1",
"ty>=0.0.1",
]
fast = ["speedups>=2.0.0"]
tox = [
Expand Down Expand Up @@ -99,6 +100,10 @@ enable_error_code = [
]
warn_return_any = false

[[tool.mypy.overrides]]
module = "speedups"
ignore_missing_imports = true


[tool.pyright]
pythonPlatform = "All"
Expand Down
16 changes: 16 additions & 0 deletions speedups.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
6 changes: 4 additions & 2 deletions stl/__about__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
9 changes: 6 additions & 3 deletions stl/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 11 additions & 9 deletions stl/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Comment on lines +146 to 148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logged decorator currently uses __name__ (which resolves to 'stl.base') to construct the logger name. This causes any class decorated with @logged in another module (or in user code) to have its logger incorrectly placed under the stl.base namespace.

To ensure the logger name matches the class's actual defining module (as described in the test comments), use class_.__module__ instead of __name__.

Suggested change
logger_name: str = '.'.join(
part.strip() for part in (__name__, class_.__name__) if part.strip()
)
logger_name: str = '.'.join(
part.strip() for part in (class_.__module__, class_.__name__) if part.strip()
)
References
  1. Dynamically determine the logger name using the decorated class's defining module (class_.__module__) rather than hardcoding the decorator's own module name (__name__) to preserve correct logger hierarchy.


class_.logger = logging.getLogger(logger_name)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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'<Mesh: {self.name!r} {self.data.size} vertices>'
Expand Down
129 changes: 74 additions & 55 deletions stl/main.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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
Expand All @@ -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.

Expand All @@ -89,25 +145,16 @@ 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:
mode = stl.ASCII
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:
Expand All @@ -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:
Expand All @@ -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)
Loading
Loading