From 960ce7a685120562501cf883a557ca9f65404cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Eertmans?= Date: Mon, 28 Nov 2022 13:52:55 +0100 Subject: [PATCH 1/2] chore(lib): add missing type hints and mypy is happy! Closes #34 --- .pre-commit-config.yaml | 3 +- manim_slides/commons.py | 30 ++++++++----- manim_slides/config.py | 38 +++++++++------- manim_slides/convert.py | 18 +++++--- manim_slides/present.py | 96 ++++++++++++++++++++--------------------- manim_slides/slide.py | 4 +- manim_slides/wizard.py | 18 +++++--- 7 files changed, 114 insertions(+), 93 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c6758eeb..8126ae7c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,6 @@ repos: - --non-interactive - --ignore-missing-imports # Disallow dynamic typing - - --disallow-any-unimported - --disallow-any-generics - --disallow-subclassing-any @@ -49,7 +48,7 @@ repos: # Configuring warnings - --warn-unused-ignores - --warn-no-return - - --warn-return-any + - --no-warn-return-any - --warn-redundant-casts # Strict equality diff --git a/manim_slides/commons.py b/manim_slides/commons.py index ea699d25..cf5b05a7 100644 --- a/manim_slides/commons.py +++ b/manim_slides/commons.py @@ -1,4 +1,4 @@ -from typing import Callable +from typing import Any, Callable import click from click import Context, Parameter @@ -6,10 +6,13 @@ from .defaults import CONFIG_PATH, FOLDER_PATH from .manim import logger +F = Callable[..., Any] +Wrapper = Callable[[F], F] -def config_path_option(function: Callable) -> Callable: + +def config_path_option(function: F) -> F: """Wraps a function to add configuration path option.""" - return click.option( + wrapper: Wrapper = click.option( "-c", "--config", "config_path", @@ -18,10 +21,11 @@ def config_path_option(function: Callable) -> Callable: type=click.Path(dir_okay=False), help="Set path to configuration file.", show_default=True, - )(function) + ) + return wrapper(function) -def config_options(function: Callable) -> Callable: +def config_options(function: F) -> F: """Wraps a function to add configuration options.""" function = config_path_option(function) function = click.option( @@ -36,7 +40,7 @@ def config_options(function: Callable) -> Callable: return function -def verbosity_option(function: Callable) -> Callable: +def verbosity_option(function: F) -> F: """Wraps a function to add verbosity option.""" def callback(ctx: Context, param: Parameter, value: bool) -> None: @@ -46,7 +50,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: logger.setLevel(value) - return click.option( + wrapper: Wrapper = click.option( "-v", "--verbosity", type=click.Choice( @@ -59,16 +63,20 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: envvar="MANIM_SLIDES_VERBOSITY", show_envvar=True, callback=callback, - )(function) + ) + return wrapper(function) -def folder_path_option(function: Callable) -> Callable: + +def folder_path_option(function: F) -> F: """Wraps a function to add folder path option.""" - return click.option( + wrapper: Wrapper = click.option( "--folder", metavar="DIRECTORY", default=FOLDER_PATH, type=click.Path(exists=True, file_okay=False), help="Set slides folder.", show_default=True, - )(function) + ) + + return wrapper(function) diff --git a/manim_slides/config.py b/manim_slides/config.py index 264d11f9..e959d0a6 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -3,7 +3,7 @@ import subprocess import tempfile from enum import Enum -from typing import List, Optional, Set +from typing import Callable, Dict, List, Optional, Set, Union from pydantic import BaseModel, root_validator, validator from PySide6.QtCore import Qt @@ -24,7 +24,7 @@ def merge_basenames(files: List[str]) -> str: return os.path.join(dirname, basename + ext) -class Key(BaseModel): +class Key(BaseModel): # type: ignore """Represents a list of key codes, with optionally a name.""" ids: Set[int] @@ -48,7 +48,7 @@ def match(self, key_id: int) -> bool: return m -class Config(BaseModel): +class Config(BaseModel): # type: ignore """General Manim Slides config""" QUIT: Key = Key(ids=[Qt.Key_Q], name="QUIT") @@ -60,8 +60,8 @@ class Config(BaseModel): HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE") @root_validator - def ids_are_unique_across_keys(cls, values): - ids = set() + def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]: + ids: Set[int] = set() for key in values.values(): if len(ids.intersection(key.ids)) != 0: @@ -87,7 +87,7 @@ class SlideType(str, Enum): last = "last" -class SlideConfig(BaseModel): +class SlideConfig(BaseModel): # type: ignore type: SlideType start_animation: int end_animation: int @@ -95,20 +95,22 @@ class SlideConfig(BaseModel): terminated: bool = False @validator("start_animation", "end_animation") - def index_is_posint(cls, v: int): + def index_is_posint(cls, v: int) -> int: if v < 0: raise ValueError("Animation index (start or end) cannot be negative") return v @validator("number") - def number_is_strictly_posint(cls, v: int): + def number_is_strictly_posint(cls, v: int) -> int: if v <= 0: raise ValueError("Slide number cannot be negative or zero") return v @root_validator - def start_animation_is_before_end(cls, values): - if values["start_animation"] >= values["end_animation"]: + def start_animation_is_before_end( + cls, values: Dict[str, Union[SlideType, int, bool]] + ) -> Dict[str, Union[SlideType, int, bool]]: + if values["start_animation"] >= values["end_animation"]: # type: ignore if values["start_animation"] == values["end_animation"] == 0: raise ValueError( @@ -135,12 +137,12 @@ def slides_slice(self) -> slice: return slice(self.start_animation, self.end_animation) -class PresentationConfig(BaseModel): +class PresentationConfig(BaseModel): # type: ignore slides: List[SlideConfig] files: List[str] @validator("files", pre=True, each_item=True) - def is_file_and_exists(cls, v): + def is_file_and_exists(cls, v: str) -> str: if not os.path.exists(v): raise ValueError( f"Animation file {v} does not exist. Are you in the right directory?" @@ -152,7 +154,9 @@ def is_file_and_exists(cls, v): return v @root_validator - def animation_indices_match_files(cls, values): + def animation_indices_match_files( + cls, values: Dict[str, Union[List[SlideConfig], List[str]]] + ) -> Dict[str, Union[List[SlideConfig], List[str]]]: files = values.get("files") slides = values.get("slides") @@ -162,18 +166,20 @@ def animation_indices_match_files(cls, values): n_files = len(files) for slide in slides: - if slide.end_animation > n_files: + if slide.end_animation > n_files: # type: ignore raise ValueError( f"The following slide's contains animations not listed in files {files}: {slide}" ) return values - def move_to(self, dest: str, copy=True) -> "PresentationConfig": + def move_to(self, dest: str, copy: bool = True) -> "PresentationConfig": """ Moves (or copy) the files to a given directory. """ - move = shutil.copy if copy else shutil.move + copy_func: Callable[[str, str], None] = shutil.copy + move_func: Callable[[str, str], None] = shutil.move + move = copy_func if copy else move_func n = len(self.files) for i in range(n): diff --git a/manim_slides/convert.py b/manim_slides/convert.py index 2b2a29c6..0b1ca542 100644 --- a/manim_slides/convert.py +++ b/manim_slides/convert.py @@ -31,11 +31,11 @@ def validate_config_option( return config -class Converter(BaseModel): +class Converter(BaseModel): # type: ignore presentation_configs: List[PresentationConfig] = [] assets_dir: str = "{basename}_assets" - def convert_to(self, dest: str): + def convert_to(self, dest: str) -> None: """Converts self, i.e., a list of presentations, into a given format.""" raise NotImplementedError @@ -105,7 +105,7 @@ def load_template(self) -> str: __name__, "data/revealjs_template.html" ).decode() - def convert_to(self, dest: str): + def convert_to(self, dest: str) -> None: """Converts this configuration into a RevealJS HTML presentation, saved to DEST.""" dirname = os.path.dirname(dest) basename, ext = os.path.splitext(os.path.basename(dest)) @@ -130,7 +130,7 @@ def convert_to(self, dest: str): f.write(content) -def show_config_options(function: Callable) -> Callable: +def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]: """Wraps a function to add a `--show-config` option.""" def callback(ctx: Context, param: Parameter, value: bool) -> None: @@ -191,7 +191,15 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: ) @show_config_options @verbosity_option -def convert(scenes, folder, dest, to, open_result, force, config_options): +def convert( + scenes: List[str], + folder: str, + dest: str, + to: str, + open_result: bool, + force: bool, + config_options: Dict[str, str], +) -> None: """ Convert SCENE(s) into a given format and writes the result in DEST. """ diff --git a/manim_slides/present.py b/manim_slides/present.py index 10459db7..9c6a2fdc 100644 --- a/manim_slides/present.py +++ b/manim_slides/present.py @@ -3,7 +3,7 @@ import sys import time from enum import IntEnum, auto, unique -from typing import List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union import click import cv2 @@ -11,7 +11,7 @@ from pydantic import ValidationError from PySide6 import QtGui from PySide6.QtCore import Qt, QThread, Signal, Slot -from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QPixmap, QResizeEvent from PySide6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget from tqdm import tqdm @@ -67,7 +67,7 @@ def __init__(self, config: PresentationConfig) -> None: self.current_slide_index: int = 0 self.current_animation: int = self.current_slide.start_animation - self.current_file: Optional[str] = None + self.current_file: str = "" self.loaded_animation_cap: int = -1 self.cap = None # cap = cv2.VideoCapture @@ -222,7 +222,7 @@ def current_frame_number(self) -> int: """Returns current frame number.""" return int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES)) - def update_state(self, state) -> Tuple[np.ndarray, State]: + def update_state(self, state: State) -> Tuple[np.ndarray, State]: """ Updates the current state given the previous one. @@ -236,7 +236,7 @@ def update_state(self, state) -> Tuple[np.ndarray, State]: still_playing, frame = self.current_cap.read() if still_playing: self.lastframe = frame - elif state in [state.WAIT, state.PAUSED]: + elif state == state.WAIT or state == state.PAUSED: # type: ignore return self.lastframe, state elif self.current_slide.is_last() and self.current_slide.terminated: return self.lastframe, State.END @@ -268,7 +268,7 @@ def update_state(self, state) -> Tuple[np.ndarray, State]: return self.lastframe, state -class Display(QThread): +class Display(QThread): # type: ignore """Displays one or more presentations one after each other.""" change_video_signal = Signal(np.ndarray) @@ -277,12 +277,12 @@ class Display(QThread): def __init__( self, - presentations, + presentations: List[PresentationConfig], config: Config = DEFAULT_CONFIG, - start_paused=False, - skip_all=False, - record_to=None, - exit_after_last_slide=False, + start_paused: bool = False, + skip_all: bool = False, + record_to: Optional[str] = None, + exit_after_last_slide: bool = False, ) -> None: super().__init__() self.presentations = presentations @@ -444,14 +444,14 @@ def handle_key(self) -> None: self.key = -1 # No more key to be handled - def stop(self): + def stop(self) -> None: """Stops current thread, without doing anything after.""" self.run_flag = False self.wait() -class Info(QWidget): - def __init__(self): +class Info(QWidget): # type: ignore + def __init__(self) -> None: super().__init__() self.setWindowTitle(WINDOW_INFO_NAME) @@ -474,7 +474,7 @@ def __init__(self): self.update_info({}) @Slot(dict) - def update_info(self, info: dict): + def update_info(self, info: Dict[str, Union[str, int]]) -> None: self.animationLabel.setText("Animation: {}".format(info.get("animation", "na"))) self.stateLabel.setText("State: {}".format(info.get("state", "unknown"))) self.slideLabel.setText( @@ -490,27 +490,27 @@ def update_info(self, info: dict): ) -class InfoThread(QThread): - def __init__(self): +class InfoThread(QThread): # type: ignore + def __init__(self) -> None: super().__init__() self.dialog = Info() self.run_flag = True - def start(self): + def start(self) -> None: super().start() self.dialog.show() - def stop(self): + def stop(self) -> None: self.dialog.deleteLater() -class App(QWidget): +class App(QWidget): # type: ignore send_key_signal = Signal(int) def __init__( self, - *args, + *args: Any, config: Config = DEFAULT_CONFIG, fullscreen: bool = False, resolution: Tuple[int, int] = (1980, 1080), @@ -518,7 +518,7 @@ def __init__( aspect_ratio: Qt.AspectRatioMode = Qt.IgnoreAspectRatio, resize_mode: Qt.TransformationMode = Qt.SmoothTransformation, background_color: str = "black", - **kwargs, + **kwargs: Any, ): super().__init__() @@ -543,7 +543,8 @@ def __init__( self.label.setMinimumSize(1, 1) # create the video capture thread - self.thread = Display(*args, config=config, **kwargs) + kwargs["config"] = config + self.thread = Display(*args, **kwargs) # create the info dialog self.info = Info() self.info.show() @@ -563,7 +564,7 @@ def __init__( # start the thread self.thread.start() - def keyPressEvent(self, event): + def keyPressEvent(self, event: QKeyEvent) -> None: key = event.key() if self.config.HIDE_MOUSE.match(key): @@ -577,35 +578,30 @@ def keyPressEvent(self, event): self.send_key_signal.emit(key) event.accept() - def closeAll(self): + def closeAll(self) -> None: logger.debug("Closing all QT windows") self.thread.stop() self.info.deleteLater() self.deleteLater() - def resizeEvent(self, event): + def resizeEvent(self, event: QResizeEvent) -> None: self.pixmap = self.pixmap.scaled( self.width(), self.height(), self.aspect_ratio, self.resize_mode ) self.label.setPixmap(self.pixmap) self.label.resize(self.width(), self.height()) - def closeEvent(self, event): + def closeEvent(self, event: QCloseEvent) -> None: self.closeAll() event.accept() @Slot(np.ndarray) - def update_image(self, cv_img: dict): - """Updates the image_label with a new opencv image""" + def update_image(self, cv_img: np.ndarray) -> None: + """Updates the (image) label with a new opencv image""" self.pixmap = self.convert_cv_qt(cv_img) self.label.setPixmap(self.pixmap) - @Slot(dict) - def update_info(self, info: dict): - """Updates the image_label with a new opencv image""" - pass - - def convert_cv_qt(self, cv_img): + def convert_cv_qt(self, cv_img: np.ndarray) -> np.ndarray: """Convert from an opencv image to QPixmap""" rgb_image = cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB) h, w, ch = rgb_image.shape @@ -633,14 +629,14 @@ def convert_cv_qt(self, cv_img): ) @click.help_option("-h", "--help") @verbosity_option -def list_scenes(folder) -> None: +def list_scenes(folder: str) -> None: """List available scenes.""" for i, scene in enumerate(_list_scenes(folder), start=1): click.secho(f"{i}: {scene}", fg="green") -def _list_scenes(folder) -> List[str]: +def _list_scenes(folder: str) -> List[str]: """Lists available scenes in given directory.""" scenes = [] @@ -792,19 +788,19 @@ def get_scenes_presentation_config( @click.help_option("-h", "--help") @verbosity_option def present( - scenes, - config_path, - folder, - start_paused, - fullscreen, - skip_all, - resolution, - record_to, - exit_after_last_slide, - hide_mouse, - aspect_ratio, - resize_mode, - background_color, + scenes: List[str], + config_path: str, + folder: str, + start_paused: bool, + fullscreen: bool, + skip_all: bool, + resolution: Tuple[int, int], + record_to: Optional[str], + exit_after_last_slide: bool, + hide_mouse: bool, + aspect_ratio: str, + resize_mode: str, + background_color: str, ) -> None: """ Present SCENE(s), one at a time, in order. diff --git a/manim_slides/slide.py b/manim_slides/slide.py index bec70486..a975a4e8 100644 --- a/manim_slides/slide.py +++ b/manim_slides/slide.py @@ -25,7 +25,7 @@ def reverse_video_file(src: str, dst: str) -> None: logger.debug(error.decode()) -class Slide(Scene): +class Slide(Scene): # type:ignore """ Inherits from `manim.Scene` or `manimlib.Scene` and provide necessary tools for slides rendering. """ @@ -222,7 +222,7 @@ def render(self, *args: Any, **kwargs: Any) -> None: self.save_slides() -class ThreeDSlide(Slide, ThreeDScene): +class ThreeDSlide(Slide, ThreeDScene): # type: ignore """ Inherits from `manim.ThreeDScene` or `manimlib.ThreeDScene` and provide necessary tools for slides rendering. diff --git a/manim_slides/wizard.py b/manim_slides/wizard.py index 62a71d15..817dbbf5 100644 --- a/manim_slides/wizard.py +++ b/manim_slides/wizard.py @@ -5,7 +5,7 @@ import click from PySide6.QtCore import Qt -from PySide6.QtGui import QIcon +from PySide6.QtGui import QIcon, QKeyEvent from PySide6.QtWidgets import ( QApplication, QDialog, @@ -31,7 +31,7 @@ keymap[key.value] = key.name.partition("_")[2] -class KeyInput(QDialog): +class KeyInput(QDialog): # type: ignore def __init__(self) -> None: super().__init__() self.key = None @@ -43,13 +43,13 @@ def __init__(self) -> None: self.layout.addWidget(self.label) self.setLayout(self.layout) - def keyPressEvent(self, event: Any) -> None: + def keyPressEvent(self, event: QKeyEvent) -> None: self.key = event.key() self.deleteLater() event.accept() -class Wizard(QWidget): +class Wizard(QWidget): # type: ignore def __init__(self, config: Config): super().__init__() @@ -131,7 +131,7 @@ def openDialog(self, button_number: int, key: Key) -> None: @config_options @click.help_option("-h", "--help") @verbosity_option -def wizard(config_path, force, merge): +def wizard(config_path: str, force: bool, merge: bool) -> None: """Launch configuration wizard.""" return _init(config_path, force, merge, skip_interactive=False) @@ -140,12 +140,16 @@ def wizard(config_path, force, merge): @config_options @click.help_option("-h", "--help") @verbosity_option -def init(config_path, force, merge, skip_interactive=False): +def init( + config_path: str, force: bool, merge: bool, skip_interactive: bool = False +) -> None: """Initialize a new default configuration file.""" return _init(config_path, force, merge, skip_interactive=True) -def _init(config_path, force, merge, skip_interactive=False): +def _init( + config_path: str, force: bool, merge: bool, skip_interactive: bool = False +) -> None: """Actual initialization code for configuration file, with optional interactive mode.""" if os.path.exists(config_path): From 34d490ee6edb9f90db5f9cabb71b6982fd002205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Eertmans?= Date: Mon, 28 Nov 2022 14:03:20 +0100 Subject: [PATCH 2/2] fix(ci): add missing dep for mypy --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8126ae7c..f3f6c8c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,7 @@ repos: rev: 'v0.991' hooks: - id: mypy + additional_dependencies: [types-setuptools] args: - --install-types - --non-interactive