Skip to content

Commit 54f2881

Browse files
Copilotletmaik
andauthored
Add mypy type checking and py.typed marker (#139)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: letmaik <530988+letmaik@users.noreply.github.com> Co-authored-by: Maik Riechert <letmaik@outlook.com>
1 parent 5f52673 commit 54f2881

File tree

10 files changed

+87
-39
lines changed

10 files changed

+87
-39
lines changed

dev-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# test dependencies
22
pytest
3+
mypy
34
# https://github.com/opencv/opencv-python/issues/291#issuecomment-841816850
45
opencv-python; sys_platform != 'darwin'
56
imageio

mypy.ini

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[mypy]
2+
# Minimum Python version that the codebase should be compatible with
3+
python_version = 3.10
4+
# Exclude test helper modules with complex setup patterns
5+
exclude = test/win-dshow-capture/
6+
7+
# Test-specific native modules
8+
[mypy-pyvirtualcam_win_dshow_capture.*]
9+
ignore_missing_imports = True
10+
11+
# Libraries without type stubs
12+
[mypy-cv2]
13+
ignore_missing_imports = True
14+
15+
[mypy-imageio]
16+
ignore_missing_imports = True
17+
18+
[mypy-pybind11]
19+
ignore_missing_imports = True
20+

pyvirtualcam/camera.py

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional, Dict, Type
1+
from typing import Optional, Dict, Type, TYPE_CHECKING, Callable
22
from abc import ABC, abstractmethod
33
import platform
44
import time
@@ -105,20 +105,24 @@ def register_backend(name: str, clazz):
105105
BACKENDS[name] = clazz
106106

107107
if platform.system() == 'Windows':
108-
from pyvirtualcam import _native_windows_obs, _native_windows_unity_capture
109-
register_backend('obs', _native_windows_obs.Camera)
110-
register_backend('unitycapture', _native_windows_unity_capture.Camera)
108+
if not TYPE_CHECKING:
109+
from pyvirtualcam import _native_windows_obs, _native_windows_unity_capture
110+
register_backend('obs', _native_windows_obs.Camera)
111+
register_backend('unitycapture', _native_windows_unity_capture.Camera)
111112
elif platform.system() == 'Darwin':
112113
# Darwin 22 is used on macOS 13
113114
if int(platform.release().split(".")[0]) >= 22:
114-
from pyvirtualcam import _native_macos_obs_cmioextension
115-
register_backend('obs', _native_macos_obs_cmioextension.Camera)
115+
if not TYPE_CHECKING:
116+
from pyvirtualcam import _native_macos_obs_cmioextension
117+
register_backend('obs', _native_macos_obs_cmioextension.Camera)
116118
else:
117-
from pyvirtualcam import _native_macos_obs_dal
118-
register_backend('obs', _native_macos_obs_dal.Camera)
119+
if not TYPE_CHECKING:
120+
from pyvirtualcam import _native_macos_obs_dal
121+
register_backend('obs', _native_macos_obs_dal.Camera)
119122
elif platform.system() == 'Linux':
120-
from pyvirtualcam import _native_linux_v4l2loopback
121-
register_backend('v4l2loopback', _native_linux_v4l2loopback.Camera)
123+
if not TYPE_CHECKING:
124+
from pyvirtualcam import _native_linux_v4l2loopback
125+
register_backend('v4l2loopback', _native_linux_v4l2loopback.Camera)
122126

123127
class PixelFormat(Enum):
124128
""" Pixel formats.
@@ -206,28 +210,29 @@ def __init__(self, width: int, height: int, fps: float, *,
206210
backends = [(backend, BACKENDS[backend])]
207211
else:
208212
backends = list(BACKENDS.items())
209-
self._backend = None
213+
backend_instance: Optional[Backend] = None
210214
errors = []
211215
for name, clazz in backends:
212216
try:
213-
self._backend = clazz(
217+
backend_instance = clazz(
214218
width=width, height=height, fps=fps,
215219
fourcc=encode_fourcc(fmt.value),
216220
device=device,
217221
**kw)
218222
except Exception as e:
219223
errors.append(f"'{name}' backend: {e}")
220224
else:
221-
self._backend_name = name
225+
self._backend_name: str = name
222226
break
223-
if self._backend is None:
227+
if backend_instance is None:
224228
raise RuntimeError('\n'.join(errors))
225-
226-
self._width = width
227-
self._height = height
228-
self._fps = fps
229-
self._fmt = fmt
230-
self._print_fps = print_fps
229+
230+
self._backend: Backend = backend_instance
231+
self._width: int = width
232+
self._height: int = height
233+
self._fps: float = fps
234+
self._fmt: PixelFormat = fmt
235+
self._print_fps: bool = print_fps
231236

232237
frame_shape = FrameShapes[fmt](width, height)
233238
if isinstance(frame_shape, int):
@@ -239,20 +244,20 @@ def check_frame_shape(frame: np.ndarray):
239244
if frame.shape != frame_shape:
240245
raise ValueError(f"unexpected frame shape: {frame.shape} != {frame_shape}")
241246

242-
self._check_frame_shape = check_frame_shape
247+
self._check_frame_shape: Callable[[np.ndarray], None] = check_frame_shape
243248

244-
self._fps_counter = FPSCounter(fps)
245-
self._fps_last_printed = time.perf_counter()
246-
self._frames_sent = 0
247-
self._last_frame_t = None
248-
self._extra_time_per_frame = 0
249+
self._fps_counter: FPSCounter = FPSCounter(fps)
250+
self._fps_last_printed: float = time.perf_counter()
251+
self._frames_sent: int = 0
252+
self._last_frame_t: float = time.perf_counter()
253+
self._extra_time_per_frame: float = 0.0
254+
self._closed: bool = False
249255

250256
def __enter__(self):
251257
return self
252258

253-
def __exit__(self, exc_type, exc_value, traceback) -> bool:
259+
def __exit__(self, exc_type, exc_value, traceback) -> None:
254260
self.close()
255-
return False
256261

257262
def __del__(self):
258263
self.close()
@@ -319,9 +324,9 @@ def close(self) -> None:
319324
This method is automatically called when using ``with`` or
320325
when this instance goes out of scope.
321326
"""
322-
if self._backend is not None:
327+
if not self._closed:
323328
self._backend.close()
324-
self._backend = None
329+
self._closed = True
325330

326331
def send(self, frame: np.ndarray) -> None:
327332
"""Send a frame to the virtual camera device.

pyvirtualcam/py.typed

Whitespace-only changes.

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ def build_extensions(self):
166166
],
167167
ext_modules=ext_modules,
168168
packages = find_packages(),
169+
package_data={'pyvirtualcam': ['py.typed']},
169170
setup_requires=['pybind11>=2.6.0'],
170171
install_requires=['numpy'],
171172
cmdclass={'build_ext': BuildExt},

test/test_camera.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def check_native_fmt(cam):
134134
cam.send(np.zeros(cam.height * cam.width * 2, np.uint8))
135135

136136
@pytest.mark.skipif(
137-
os.environ.get('CI') and platform.system() == 'Darwin',
137+
bool(os.environ.get('CI')) and platform.system() == 'Darwin',
138138
reason='disabled due to high fluctuations in CI, manually verified on MacBook Pro')
139139
def test_sleep_until_next_frame():
140140
target_fps = 20

test/test_capture.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import os
22
import signal
3-
from typing import Union
3+
from typing import Union, Tuple
44
import sys
5-
import signal
65
import subprocess
76
import json
87
import traceback
@@ -30,12 +29,13 @@
3029
if platform.system() == 'Windows':
3130
import pyvirtualcam_win_dshow_capture as dshow
3231

33-
def capture_rgb(device: str, width: int, height: int):
32+
def capture_rgb(device: Union[str, int], width: int, height: int) -> Tuple[np.ndarray, float]:
33+
assert isinstance(device, str), "Windows requires device to be a string"
3434
rgb, timestamp_ms = dshow.capture(device, width, height)
3535
return rgb, timestamp_ms
3636

3737
elif platform.system() in ['Linux', 'Darwin']:
38-
def capture_rgb(device: Union[str, int], width: int, height: int):
38+
def capture_rgb(device: Union[str, int], width: int, height: int) -> Tuple[np.ndarray, float]:
3939
print(f'Opening device {device} for capture')
4040
vc = cv2.VideoCapture(device)
4141
assert vc.isOpened()
@@ -272,7 +272,7 @@ def send_frames(backend: str, fmt: PixelFormat, info_path: Path, mode: str):
272272

273273
def capture_frame(backend: str, fmt: PixelFormat, info_path: Path, mode: str):
274274
if platform.system() == 'Darwin':
275-
device = 0
275+
device: Union[str, int] = 0
276276
else:
277277
with open(info_path) as f:
278278
device = json.load(f)

test/test_mypy.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Test mypy type checking on the source and test folders."""
2+
import subprocess
3+
import sys
4+
from pathlib import Path
5+
6+
7+
def test_mypy():
8+
"""Run mypy on the source and test folders."""
9+
# Build absolute paths based on test file location
10+
test_dir = Path(__file__).parent
11+
repo_root = test_dir.parent
12+
13+
result = subprocess.run(
14+
[sys.executable, '-m', 'mypy', '--install-types', '--non-interactive',
15+
'pyvirtualcam/', 'test/'],
16+
capture_output=True,
17+
text=True,
18+
cwd=str(repo_root)
19+
)
20+
assert result.returncode == 0, f"mypy failed:\n{result.stdout}\n{result.stderr}"

test/test_util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pyvirtualcam.util
66

77
@pytest.mark.skipif(
8-
os.environ.get('CI') and platform.system() == 'Darwin',
8+
bool(os.environ.get('CI')) and platform.system() == 'Darwin',
99
reason='disabled due to high fluctuations in CI, manually verified on MacBook Pro')
1010
def test_fps_counter():
1111
target_fps = 20

test/win-dshow-capture/setup.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# https://github.com/pybind/python_example/blob/master/setup.py
22

3+
from typing import Dict, List
34
import glob
45
from setuptools import setup, Extension, find_packages
56
from setuptools.command.build_ext import build_ext
@@ -11,7 +12,7 @@ class get_pybind_include(object):
1112
until it is actually installed, so that the ``get_include()``
1213
method can be invoked. """
1314

14-
def __str__(self):
15+
def __str__(self) -> str:
1516
import pybind11
1617
return pybind11.get_include()
1718

@@ -71,7 +72,7 @@ class BuildExt(build_ext):
7172
'msvc': ['/EHsc'],
7273
'unix': [],
7374
}
74-
l_opts = {
75+
l_opts: Dict[str, List[str]] = {
7576
'msvc': [],
7677
'unix': [],
7778
}

0 commit comments

Comments
 (0)