chore(ci): enhance current lint rules (#289)

* chore(ci): enhance current lint rules

* run linter

* fix all warnings

* fix(convert): release VideoCapture

* fix(ci): properly close file

* better tests

* fix(ci): setup opengl

* Revert "fix(ci): setup opengl"

This reverts commit a33f53a1c04f909d7660f2b5221c763a9ef97d53.

* fix(ci): skipif Windows in workflows

* actually xfail
This commit is contained in:
Jérome Eertmans
2023-10-18 19:24:14 +02:00
committed by GitHub
parent 498e33ad8d
commit 5daa94b823
20 changed files with 153 additions and 151 deletions

View File

@ -17,6 +17,7 @@ jobs:
MANIM_SLIDES_VERBOSITY: debug MANIM_SLIDES_VERBOSITY: debug
PYTHONFAULTHANDLER: 1 PYTHONFAULTHANDLER: 1
DISPLAY: :99 DISPLAY: :99
GITHUB_WORKFLOWS: 1
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -68,7 +69,7 @@ jobs:
- name: Run pytest - name: Run pytest
if: matrix.os != 'ubuntu-latest' || matrix.pyversion != '3.11' if: matrix.os != 'ubuntu-latest' || matrix.pyversion != '3.11'
run: poetry run pytest -x -n auto run: poetry run pytest
- name: Run pytest and coverage - name: Run pytest and coverage
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11' if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'

View File

@ -6,11 +6,6 @@ repos:
- id: check-toml - id: check-toml
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.11.0 rev: v2.11.0
hooks: hooks:
@ -29,12 +24,6 @@ repos:
- id: blacken-docs - id: blacken-docs
additional_dependencies: additional_dependencies:
- black==23.9.1 - black==23.9.1
- repo: https://github.com/PyCQA/docformatter
rev: v1.7.5
hooks:
- id: docformatter
additional_dependencies: [tomli]
args: [--in-place, --config, ./pyproject.toml]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.292 rev: v0.0.292
hooks: hooks:

View File

@ -1,4 +1,3 @@
# flake8: noqa: F401
import sys import sys
from types import ModuleType from types import ModuleType
from typing import Any, List from typing import Any, List
@ -6,7 +5,7 @@ from typing import Any, List
from .__version__ import __version__ from .__version__ import __version__
class module(ModuleType): class Module(ModuleType):
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
if name == "Slide" or name == "ThreeDSlide": if name == "Slide" or name == "ThreeDSlide":
module = __import__( module = __import__(
@ -48,7 +47,7 @@ class module(ModuleType):
old_module = sys.modules["manim_slides"] old_module = sys.modules["manim_slides"]
new_module = sys.modules["manim_slides"] = module("manim_slides") new_module = sys.modules["manim_slides"] = Module("manim_slides")
new_module.__dict__.update( new_module.__dict__.update(
{ {

View File

@ -12,7 +12,7 @@ Wrapper = Callable[[F], F]
def config_path_option(function: F) -> F: def config_path_option(function: F) -> F:
"""Wraps a function to add configuration path option.""" """Wrap a function to add configuration path option."""
wrapper: Wrapper = click.option( wrapper: Wrapper = click.option(
"-c", "-c",
"--config", "--config",
@ -27,7 +27,7 @@ def config_path_option(function: F) -> F:
def config_options(function: F) -> F: def config_options(function: F) -> F:
"""Wraps a function to add configuration options.""" """Wrap a function to add configuration options."""
function = config_path_option(function) function = config_path_option(function)
function = click.option( function = click.option(
"-f", "--force", is_flag=True, help="Overwrite any existing configuration file." "-f", "--force", is_flag=True, help="Overwrite any existing configuration file."
@ -42,7 +42,7 @@ def config_options(function: F) -> F:
def verbosity_option(function: F) -> F: def verbosity_option(function: F) -> F:
"""Wraps a function to add verbosity option.""" """Wrap a function to add verbosity option."""
def callback(ctx: Context, param: Parameter, value: str) -> None: def callback(ctx: Context, param: Parameter, value: str) -> None:
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
@ -69,7 +69,7 @@ def verbosity_option(function: F) -> F:
def folder_path_option(function: F) -> F: def folder_path_option(function: F) -> F:
"""Wraps a function to add folder path option.""" """Wrap a function to add folder path option."""
wrapper: Wrapper = click.option( wrapper: Wrapper = click.option(
"--folder", "--folder",
metavar="DIRECTORY", metavar="DIRECTORY",

View File

@ -80,6 +80,7 @@ class Keys(BaseModel): # type: ignore[misc]
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE") HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
@model_validator(mode="before") @model_validator(mode="before")
@classmethod
def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]: def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]:
ids: Set[int] = set() ids: Set[int] = set()
@ -121,14 +122,15 @@ class Config(BaseModel): # type: ignore[misc]
@classmethod @classmethod
def from_file(cls, path: Path) -> "Config": def from_file(cls, path: Path) -> "Config":
"""Reads a configuration from a file.""" """Read a configuration from a file."""
return cls.model_validate(rtoml.load(path)) # type: ignore return cls.model_validate(rtoml.load(path)) # type: ignore
def to_file(self, path: Path) -> None: def to_file(self, path: Path) -> None:
"""Dumps the configuration to a file.""" """Dump the configuration to a file."""
rtoml.dump(self.model_dump(), path, pretty=True) rtoml.dump(self.model_dump(), path, pretty=True)
def merge_with(self, other: "Config") -> "Config": def merge_with(self, other: "Config") -> "Config":
"""Merge with another config."""
self.keys = self.keys.merge_with(other.keys) self.keys = self.keys.merge_with(other.keys)
return self return self
@ -146,6 +148,7 @@ class PreSlideConfig(BaseModel): # type: ignore
return v return v
@model_validator(mode="after") @model_validator(mode="after")
@classmethod
def start_animation_is_before_end( def start_animation_is_before_end(
cls, pre_slide_config: "PreSlideConfig" cls, pre_slide_config: "PreSlideConfig"
) -> "PreSlideConfig": ) -> "PreSlideConfig":
@ -185,8 +188,8 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
@classmethod @classmethod
def from_file(cls, path: Path) -> "PresentationConfig": def from_file(cls, path: Path) -> "PresentationConfig":
"""Reads a presentation configuration from a file.""" """Read a presentation configuration from a file."""
with open(path, "r") as f: with open(path) as f:
obj = json.load(f) obj = json.load(f)
slides = obj.setdefault("slides", []) slides = obj.setdefault("slides", [])
@ -202,7 +205,7 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
return cls.model_validate(obj) # type: ignore return cls.model_validate(obj) # type: ignore
def to_file(self, path: Path) -> None: def to_file(self, path: Path) -> None:
"""Dumps the presentation configuration to a file.""" """Dump the presentation configuration to a file."""
with open(path, "w") as f: with open(path, "w") as f:
f.write(self.model_dump_json(indent=2)) f.write(self.model_dump_json(indent=2))

View File

@ -9,7 +9,7 @@ from base64 import b64encode
from enum import Enum from enum import Enum
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Type, Union from typing import Any, Callable, ClassVar, Dict, List, Optional, Type, Union
import click import click
import cv2 import cv2
@ -60,13 +60,13 @@ def validate_config_option(
except ValueError: except ValueError:
raise click.BadParameter( raise click.BadParameter(
f"Configuration options `{c_option}` could not be parsed into a proper (key, value) pair. Please use an `=` sign to separate key from value." f"Configuration options `{c_option}` could not be parsed into a proper (key, value) pair. Please use an `=` sign to separate key from value."
) ) from None
return config return config
def file_to_data_uri(file: Path) -> str: def file_to_data_uri(file: Path) -> str:
"""Reads a video and returns the corresponding data-uri.""" """Read a video and return the corresponding data-uri."""
b64 = b64encode(file.read_bytes()).decode("ascii") b64 = b64encode(file.read_bytes()).decode("ascii")
mime_type = mimetypes.guess_type(file)[0] or "video/mp4" mime_type = mimetypes.guess_type(file)[0] or "video/mp4"
@ -79,24 +79,24 @@ class Converter(BaseModel): # type: ignore
template: Optional[Path] = None template: Optional[Path] = None
def convert_to(self, dest: Path) -> None: def convert_to(self, dest: Path) -> None:
"""Converts self, i.e., a list of presentations, into a given format.""" """Convert self, i.e., a list of presentations, into a given format."""
raise NotImplementedError raise NotImplementedError
def load_template(self) -> str: def load_template(self) -> str:
""" """
Returns the template as a string. Return the template as a string.
An empty string is returned if no template is used. An empty string is returned if no template is used.
""" """
return "" return ""
def open(self, file: Path) -> Any: def open(self, file: Path) -> Any:
"""Opens a file, generated with converter, using appropriate application.""" """Open a file, generated with converter, using appropriate application."""
raise NotImplementedError raise NotImplementedError
@classmethod @classmethod
def from_string(cls, s: str) -> Type["Converter"]: def from_string(cls, s: str) -> Type["Converter"]:
"""Returns the appropriate converter from a string name.""" """Return the appropriate converter from a string name."""
return { return {
"html": RevealJS, "html": RevealJS,
"pdf": PDF, "pdf": PDF,
@ -117,7 +117,7 @@ class Str(str):
return core_schema.str_schema() return core_schema.str_schema()
def __str__(self) -> str: def __str__(self) -> str:
"""Ensures that the string is correctly quoted.""" """Ensure that the string is correctly quoted."""
if self in ["true", "false", "null"]: if self in ["true", "false", "null"]:
return self return self
else: else:
@ -303,7 +303,7 @@ class RevealJS(Converter):
auto_animate_easing: AutoAnimateEasing = AutoAnimateEasing.ease auto_animate_easing: AutoAnimateEasing = AutoAnimateEasing.ease
auto_animate_duration: float = 1.0 auto_animate_duration: float = 1.0
auto_animate_unmatched: JsBool = JsBool.true auto_animate_unmatched: JsBool = JsBool.true
auto_animate_styles: List[str] = [ auto_animate_styles: ClassVar[List[str]] = [
"opacity", "opacity",
"color", "color",
"background-color", "background-color",
@ -346,7 +346,7 @@ class RevealJS(Converter):
model_config = ConfigDict(use_enum_values=True, extra="forbid") model_config = ConfigDict(use_enum_values=True, extra="forbid")
def load_template(self) -> str: def load_template(self) -> str:
"""Returns the RevealJS HTML template as a string.""" """Return the RevealJS HTML template as a string."""
if isinstance(self.template, Path): if isinstance(self.template, Path):
return self.template.read_text() return self.template.read_text()
@ -359,8 +359,10 @@ class RevealJS(Converter):
return webbrowser.open(file.absolute().as_uri()) return webbrowser.open(file.absolute().as_uri())
def convert_to(self, dest: Path) -> None: def convert_to(self, dest: Path) -> None:
"""Converts this configuration into a RevealJS HTML presentation, saved to """
DEST.""" Convert this configuration into a RevealJS HTML presentation, saved to
DEST.
"""
if self.data_uri: if self.data_uri:
assets_dir = Path("") # Actually we won't care. assets_dir = Path("") # Actually we won't care.
else: else:
@ -409,7 +411,7 @@ class PDF(Converter):
return open_with_default(file) return open_with_default(file)
def convert_to(self, dest: Path) -> None: def convert_to(self, dest: Path) -> None:
"""Converts this configuration into a PDF presentation, saved to DEST.""" """Convert this configuration into a PDF presentation, saved to DEST."""
def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image: def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image:
cap = cv2.VideoCapture(str(file)) cap = cv2.VideoCapture(str(file))
@ -419,6 +421,7 @@ class PDF(Converter):
cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1) cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1)
ret, frame = cap.read() ret, frame = cap.read()
cap.release()
if ret: if ret:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
@ -462,7 +465,7 @@ class PowerPoint(Converter):
return open_with_default(file) return open_with_default(file)
def convert_to(self, dest: Path) -> None: def convert_to(self, dest: Path) -> None:
"""Converts this configuration into a PowerPoint presentation, saved to DEST.""" """Convert this configuration into a PowerPoint presentation, saved to DEST."""
prs = pptx.Presentation() prs = pptx.Presentation()
prs.slide_width = self.width * 9525 prs.slide_width = self.width * 9525
prs.slide_height = self.height * 9525 prs.slide_height = self.height * 9525
@ -493,10 +496,12 @@ class PowerPoint(Converter):
def save_first_image_from_video_file(file: Path) -> Optional[str]: def save_first_image_from_video_file(file: Path) -> Optional[str]:
cap = cv2.VideoCapture(file.as_posix()) cap = cv2.VideoCapture(file.as_posix())
ret, frame = cap.read() ret, frame = cap.read()
cap.release()
if ret: if ret:
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".png") f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".png")
cv2.imwrite(f.name, frame) cv2.imwrite(f.name, frame)
f.close()
return f.name return f.name
else: else:
logger.warn("Failed to read first image from video file") logger.warn("Failed to read first image from video file")
@ -535,7 +540,7 @@ class PowerPoint(Converter):
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]: def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
"""Wraps a function to add a `--show-config` option.""" """Wrap a function to add a `--show-config` option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None: def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
@ -547,7 +552,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
presentation_configs=[PresentationConfig()] presentation_configs=[PresentationConfig()]
) )
for key, value in converter.dict().items(): for key, value in converter.dict().items():
click.echo(f"{key}: {repr(value)}") click.echo(f"{key}: {value!r}")
ctx.exit() ctx.exit()
@ -563,7 +568,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]: def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
"""Wraps a function to add a `--show-template` option.""" """Wrap a function to add a `--show-template` option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None: def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
@ -637,7 +642,6 @@ def convert(
template: Optional[Path], template: Optional[Path],
) -> None: ) -> None:
"""Convert SCENE(s) into a given format and writes the result in DEST.""" """Convert SCENE(s) into a given format and writes the result in DEST."""
presentation_configs = get_scenes_presentation_config(scenes, folder) presentation_configs = get_scenes_presentation_config(scenes, folder)
try: try:
@ -664,4 +668,4 @@ def convert(
_msg = error["msg"] _msg = error["msg"]
msg.append(f"Option '{option}': {_msg}") msg.append(f"Option '{option}': {_msg}")
raise click.UsageError("\n".join(msg)) raise click.UsageError("\n".join(msg)) from None

View File

@ -114,7 +114,7 @@ directive:
A list of methods, separated by spaces, A list of methods, separated by spaces,
that is rendered in a reference block after the source code. that is rendered in a reference block after the source code.
""" """ # noqa: D400, D415
from __future__ import annotations from __future__ import annotations
import csv import csv
@ -123,7 +123,6 @@ import re
import sys import sys
from pathlib import Path from pathlib import Path
from timeit import timeit from timeit import timeit
from typing import Tuple
import jinja2 import jinja2
from docutils import nodes from docutils import nodes
@ -182,10 +181,11 @@ class ManimSlidesDirective(Directive):
See the module docstring for documentation. See the module docstring for documentation.
""" """
has_content = True has_content = True
required_arguments = 1 required_arguments = 1
optional_arguments = 0 optional_arguments = 0
option_spec = { option_spec = { # noqa: RUF012
"hide_source": bool, "hide_source": bool,
"quality": lambda arg: directives.choice( "quality": lambda arg: directives.choice(
arg, arg,
@ -198,7 +198,7 @@ class ManimSlidesDirective(Directive):
} }
final_argument_whitespace = True final_argument_whitespace = True
def run(self): def run(self): # noqa: C901
# Rendering is skipped if the tag skip-manim is present, # Rendering is skipped if the tag skip-manim is present,
# or if we are making the pot-files # or if we are making the pot-files
should_skip = ( should_skip = (
@ -227,7 +227,7 @@ class ManimSlidesDirective(Directive):
global classnamedict global classnamedict
def split_file_cls(arg: str) -> Tuple[Path, str]: def split_file_cls(arg: str) -> tuple[Path, str]:
if ":" in arg: if ":" in arg:
file, cls = arg.split(":", maxsplit=1) file, cls = arg.split(":", maxsplit=1)
_, file = self.state.document.settings.env.relfn2path(file) _, file = self.state.document.settings.env.relfn2path(file)
@ -314,7 +314,7 @@ class ManimSlidesDirective(Directive):
try: try:
with tempconfig(example_config): with tempconfig(example_config):
print(f"Rendering {clsname}...") print(f"Rendering {clsname}...") # noqa: T201
run_time = timeit(lambda: exec("\n".join(code), globals()), number=1) run_time = timeit(lambda: exec("\n".join(code), globals()), number=1)
video_dir = config.get_dir("video_dir") video_dir = config.get_dir("video_dir")
except Exception as e: except Exception as e:
@ -375,7 +375,7 @@ def _log_rendering_times(*args):
if len(data) == 0: if len(data) == 0:
sys.exit() sys.exit()
print("\nRendering Summary\n-----------------\n") print("\nRendering Summary\n-----------------\n") # noqa: T201
max_file_length = max(len(row[0]) for row in data) max_file_length = max(len(row[0]) for row in data)
for key, group in it.groupby(data, key=lambda row: row[0]): for key, group in it.groupby(data, key=lambda row: row[0]):
@ -383,15 +383,17 @@ def _log_rendering_times(*args):
group = list(group) group = list(group)
if len(group) == 1: if len(group) == 1:
row = group[0] row = group[0]
print(f"{key}{row[2].rjust(7, '.')}s {row[1]}") print(f"{key}{row[2].rjust(7, '.')}s {row[1]}") # noqa: T201
continue continue
time_sum = sum(float(row[2]) for row in group) time_sum = sum(float(row[2]) for row in group)
print( print( # noqa: T201
f"{key}{f'{time_sum:.3f}'.rjust(7, '.')}s => {len(group)} EXAMPLES", f"{key}{f'{time_sum:.3f}'.rjust(7, '.')}s => {len(group)} EXAMPLES",
) )
for row in group: for row in group:
print(f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}") print( # noqa: T201
print("") f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}"
)
print("") # noqa: T201
def _delete_rendering_times(*args): def _delete_rendering_times(*args):

View File

@ -21,7 +21,7 @@ You can install them manually, or with the extra keyword:
Note that you will still need to install Manim's platform-specific dependencies, Note that you will still need to install Manim's platform-specific dependencies,
see see
`their installation page <https://docs.manim.community/en/stable/installation.html>`_. `their installation page <https://docs.manim.community/en/stable/installation.html>`_.
""" """ # noqa: D400, D415
from __future__ import annotations from __future__ import annotations
@ -30,7 +30,7 @@ import mimetypes
import shutil import shutil
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any
from IPython import get_ipython from IPython import get_ipython
from IPython.core.interactiveshell import InteractiveShell from IPython.core.interactiveshell import InteractiveShell
@ -49,15 +49,15 @@ from ..present import get_scenes_presentation_config
class ManimSlidesMagic(Magics): # type: ignore class ManimSlidesMagic(Magics): # type: ignore
def __init__(self, shell: InteractiveShell) -> None: def __init__(self, shell: InteractiveShell) -> None:
super().__init__(shell) super().__init__(shell)
self.rendered_files: Dict[Path, Path] = {} self.rendered_files: dict[Path, Path] = {}
@needs_local_scope @needs_local_scope
@line_cell_magic @line_cell_magic
def manim_slides( def manim_slides( # noqa: C901
self, self,
line: str, line: str,
cell: Optional[str] = None, cell: str | None = None,
local_ns: Dict[str, Any] = {}, local_ns: dict[str, Any] | None = None,
) -> None: ) -> None:
r""" r"""
Render Manim Slides contained in IPython cells. Works as a line or cell magic. Render Manim Slides contained in IPython cells. Works as a line or cell magic.
@ -118,7 +118,6 @@ class ManimSlidesMagic(Magics): # type: ignore
Examples Examples
-------- --------
First make sure to put ``from manim_slides import ManimSlidesMagic``, First make sure to put ``from manim_slides import ManimSlidesMagic``,
or even ``from manim_slides import *`` or even ``from manim_slides import *``
in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides
@ -144,6 +143,8 @@ class ManimSlidesMagic(Magics): # type: ignore
option should be set to ``None``. This can also be done by passing ``--progress_bar None`` as a option should be set to ``None``. This can also be done by passing ``--progress_bar None`` as a
CLI flag. CLI flag.
""" """
if local_ns is None:
local_ns = {}
if cell: if cell:
exec(cell, local_ns) exec(cell, local_ns)
@ -173,8 +174,8 @@ class ManimSlidesMagic(Magics): # type: ignore
renderer = OpenGLRenderer() renderer = OpenGLRenderer()
try: try:
SceneClass = local_ns[config["scene_names"][0]] scene_cls = local_ns[config["scene_names"][0]]
scene = SceneClass(renderer=renderer) scene = scene_cls(renderer=renderer)
scene.render() scene.render()
finally: finally:
# Shader cache becomes invalid as the context is destroyed # Shader cache becomes invalid as the context is destroyed

View File

@ -1,6 +1,7 @@
""" """
Logger utils, mostly copied from Manim Community: Logger utils, mostly copied from Manim Community.
Source code:
https://github.com/ManimCommunity/manim/blob/d5b65b844b8ce8ff5151a2f56f9dc98cebbc1db4/manim/_config/logger_utils.py#L29-L101 https://github.com/ManimCommunity/manim/blob/d5b65b844b8ce8ff5151a2f56f9dc98cebbc1db4/manim/_config/logger_utils.py#L29-L101
""" """

View File

@ -26,13 +26,12 @@ ASPECT_RATIO_MODES = {
@verbosity_option @verbosity_option
def list_scenes(folder: Path) -> None: def list_scenes(folder: Path) -> None:
"""List available scenes.""" """List available scenes."""
for i, scene in enumerate(_list_scenes(folder), start=1): for i, scene in enumerate(_list_scenes(folder), start=1):
click.secho(f"{i}: {scene}", fg="green") click.secho(f"{i}: {scene}", fg="green")
def _list_scenes(folder: Path) -> List[str]: def _list_scenes(folder: Path) -> List[str]:
"""Lists available scenes in given directory.""" """List available scenes in given directory."""
scenes = [] scenes = []
for filepath in folder.glob("*.json"): for filepath in folder.glob("*.json"):
@ -52,8 +51,7 @@ def _list_scenes(folder: Path) -> List[str]:
def prompt_for_scenes(folder: Path) -> List[str]: def prompt_for_scenes(folder: Path) -> List[str]:
"""Prompts the user to select scenes within a given folder.""" """Prompt the user to select scenes within a given folder."""
scene_choices = dict(enumerate(_list_scenes(folder), start=1)) scene_choices = dict(enumerate(_list_scenes(folder), start=1))
for i, scene in scene_choices.items(): for i, scene in scene_choices.items():
@ -82,14 +80,13 @@ def prompt_for_scenes(folder: Path) -> List[str]:
scenes = click.prompt("Choice(s)", value_proc=value_proc) scenes = click.prompt("Choice(s)", value_proc=value_proc)
return scenes # type: ignore return scenes # type: ignore
except ValueError as e: except ValueError as e:
raise click.UsageError(str(e)) raise click.UsageError(str(e)) from None
def get_scenes_presentation_config( def get_scenes_presentation_config(
scenes: List[str], folder: Path scenes: List[str], folder: Path
) -> List[PresentationConfig]: ) -> List[PresentationConfig]:
"""Returns a list of presentation configurations based on the user input.""" """Return a list of presentation configurations based on the user input."""
if len(scenes) == 0: if len(scenes) == 0:
scenes = prompt_for_scenes(folder) scenes = prompt_for_scenes(folder)
@ -103,7 +100,7 @@ def get_scenes_presentation_config(
try: try:
presentation_configs.append(PresentationConfig.from_file(config_file)) presentation_configs.append(PresentationConfig.from_file(config_file))
except ValidationError as e: except ValidationError as e:
raise click.UsageError(str(e)) raise click.UsageError(str(e)) from None
return presentation_configs return presentation_configs
@ -125,7 +122,7 @@ def start_at_callback(
f"start index can only be an integer or an empty string, not `{value}`", f"start index can only be an integer or an empty string, not `{value}`",
ctx=ctx, ctx=ctx,
param=param, param=param,
) ) from None
values_tuple = values.split(",") values_tuple = values.split(",")
n_values = len(values_tuple) n_values = len(values_tuple)
@ -243,7 +240,6 @@ def present(
Use ``manim-slide list-scenes`` to list all available Use ``manim-slide list-scenes`` to list all available
scenes in a given folder. scenes in a given folder.
""" """
if skip_all: if skip_all:
exit_after_last_slide = True exit_after_last_slide = True
@ -253,7 +249,7 @@ def present(
try: try:
config = Config.from_file(config_path) config = Config.from_file(config_path)
except ValidationError as e: except ValidationError as e:
raise click.UsageError(str(e)) raise click.UsageError(str(e)) from None
else: else:
logger.debug("No configuration file found, using default configuration.") logger.debug("No configuration file found, using default configuration.")
config = Config() config = Config()

View File

@ -9,7 +9,7 @@ from PySide6.QtWidgets import QDialog, QGridLayout, QLabel, QMainWindow
from ..config import Config, PresentationConfig, SlideConfig from ..config import Config, PresentationConfig, SlideConfig
from ..logger import logger from ..logger import logger
from ..resources import * # noqa: F401, F403 from ..resources import * # noqa: F403
WINDOW_NAME = "Manim Slides" WINDOW_NAME = "Manim Slides"
@ -337,10 +337,10 @@ class Player(QMainWindow): # type: ignore[misc]
else: else:
self.setCursor(Qt.BlankCursor) self.setCursor(Qt.BlankCursor)
def closeEvent(self, event: QCloseEvent) -> None: def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.quit() self.quit()
def keyPressEvent(self, event: QKeyEvent) -> None: def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
key = event.key() key = event.key()
self.dispatch(key) self.dispatch(key)
event.accept() event.accept()

View File

@ -43,7 +43,6 @@ class Wipe(AnimationGroup): # type: ignore[misc]
Examples Examples
-------- --------
.. manim-slides:: WipeClassExample .. manim-slides:: WipeClassExample
from manim import * from manim import *
@ -99,7 +98,6 @@ class Zoom(AnimationGroup): # type: ignore[misc]
Examples Examples
-------- --------
.. manim-slides:: ZoomClassExample .. manim-slides:: ZoomClassExample
from manim import * from manim import *

View File

@ -19,6 +19,8 @@ if MANIM:
else: else:
Mobject = Any Mobject = Any
LEFT: np.ndarray = np.array([-1.0, 0.0, 0.0])
class BaseSlide: class BaseSlide:
def __init__( def __init__(
@ -36,61 +38,61 @@ class BaseSlide:
@property @property
def _ffmpeg_bin(self) -> Path: def _ffmpeg_bin(self) -> Path:
"""Returns the path to the ffmpeg binaries.""" """Return the path to the ffmpeg binaries."""
return FFMPEG_BIN return FFMPEG_BIN
@property @property
@abstractmethod @abstractmethod
def _frame_height(self) -> float: def _frame_height(self) -> float:
"""Returns the scene's frame height.""" """Return the scene's frame height."""
... ...
@property @property
@abstractmethod @abstractmethod
def _frame_width(self) -> float: def _frame_width(self) -> float:
"""Returns the scene's frame width.""" """Return the scene's frame width."""
... ...
@property @property
@abstractmethod @abstractmethod
def _background_color(self) -> str: def _background_color(self) -> str:
"""Returns the scene's background color.""" """Return the scene's background color."""
... ...
@property @property
@abstractmethod @abstractmethod
def _resolution(self) -> Tuple[int, int]: def _resolution(self) -> Tuple[int, int]:
"""Returns the scene's resolution used during rendering.""" """Return the scene's resolution used during rendering."""
... ...
@property @property
@abstractmethod @abstractmethod
def _partial_movie_files(self) -> List[Path]: def _partial_movie_files(self) -> List[Path]:
"""Returns a list of partial movie files, a.k.a animations.""" """Return a list of partial movie files, a.k.a animations."""
... ...
@property @property
@abstractmethod @abstractmethod
def _show_progress_bar(self) -> bool: def _show_progress_bar(self) -> bool:
"""Returns True if progress bar should be displayed.""" """Return True if progress bar should be displayed."""
... ...
@property @property
@abstractmethod @abstractmethod
def _leave_progress_bar(self) -> bool: def _leave_progress_bar(self) -> bool:
"""Returns True if progress bar should be left after completed.""" """Return True if progress bar should be left after completed."""
... ...
@property @property
@abstractmethod @abstractmethod
def _start_at_animation_number(self) -> Optional[int]: def _start_at_animation_number(self) -> Optional[int]:
"""If set, returns the animation number at which rendering start.""" """If set, return the animation number at which rendering start."""
... ...
@property @property
def canvas(self) -> MutableMapping[str, Mobject]: def canvas(self) -> MutableMapping[str, Mobject]:
""" """
Returns the canvas associated to the current slide. Return the canvas associated to the current slide.
The canvas is a mapping between names and Mobjects, The canvas is a mapping between names and Mobjects,
for objects that are assumed to stay in multiple slides. for objects that are assumed to stay in multiple slides.
@ -99,7 +101,6 @@ class BaseSlide:
Examples Examples
-------- --------
.. manim-slides:: CanvasExample .. manim-slides:: CanvasExample
from manim import * from manim import *
@ -154,7 +155,7 @@ class BaseSlide:
def add_to_canvas(self, **objects: Mobject) -> None: def add_to_canvas(self, **objects: Mobject) -> None:
""" """
Adds objects to the canvas, using key values as names. Add objects to the canvas, using key values as names.
:param objects: A mapping between names and Mobjects. :param objects: A mapping between names and Mobjects.
@ -168,19 +169,21 @@ class BaseSlide:
self._canvas.update(objects) self._canvas.update(objects)
def remove_from_canvas(self, *names: str) -> None: def remove_from_canvas(self, *names: str) -> None:
"""Removes objects from the canvas.""" """Remove objects from the canvas."""
for name in names: for name in names:
self._canvas.pop(name) self._canvas.pop(name)
@property @property
def canvas_mobjects(self) -> ValuesView[Mobject]: def canvas_mobjects(self) -> ValuesView[Mobject]:
"""Returns Mobjects contained in the canvas.""" """Return Mobjects contained in the canvas."""
return self.canvas.values() return self.canvas.values()
@property @property
def mobjects_without_canvas(self) -> Sequence[Mobject]: def mobjects_without_canvas(self) -> Sequence[Mobject]:
"""Returns the list of objects contained in the scene, minus those present in """
the canvas.""" Return the list of objects contained in the scene, minus those present in
the canvas.
"""
return [ return [
mobject for mobject in self.mobjects if mobject not in self.canvas_mobjects # type: ignore[attr-defined] mobject for mobject in self.mobjects if mobject not in self.canvas_mobjects # type: ignore[attr-defined]
] ]
@ -188,7 +191,7 @@ class BaseSlide:
@property @property
def wait_time_between_slides(self) -> float: def wait_time_between_slides(self) -> float:
r""" r"""
Returns the wait duration (in seconds) added between two slides. Return the wait duration (in seconds) added between two slides.
By default, this value is set to 0. By default, this value is set to 0.
@ -203,7 +206,6 @@ class BaseSlide:
Examples Examples
-------- --------
.. manim-slides:: WithoutWaitExample .. manim-slides:: WithoutWaitExample
from manim import * from manim import *
@ -246,13 +248,13 @@ class BaseSlide:
self._wait_time_between_slides = max(wait_time, 0.0) self._wait_time_between_slides = max(wait_time, 0.0)
def play(self, *args: Any, **kwargs: Any) -> None: def play(self, *args: Any, **kwargs: Any) -> None:
"""Overloads `self.play` and increment animation count.""" """Overload `self.play` and increment animation count."""
super().play(*args, **kwargs) # type: ignore[misc] super().play(*args, **kwargs) # type: ignore[misc]
self._current_animation += 1 self._current_animation += 1
def next_slide(self) -> None: def next_slide(self) -> None:
""" """
Creates a new slide with previous animations. Create a new slide with previous animations.
This usually means that the user will need to press some key before the This usually means that the user will need to press some key before the
next slide is played. By default, this is the right arrow key. next slide is played. By default, this is the right arrow key.
@ -269,7 +271,6 @@ class BaseSlide:
Examples Examples
-------- --------
The following contains 3 slides: The following contains 3 slides:
#. the first with nothing on it; #. the first with nothing on it;
@ -307,8 +308,7 @@ class BaseSlide:
self._pause_start_animation = self._current_animation self._pause_start_animation = self._current_animation
def _add_last_slide(self) -> None: def _add_last_slide(self) -> None:
"""Adds a 'last' slide to the end of slides.""" """Add a 'last' slide to the end of slides."""
if ( if (
len(self._slides) > 0 len(self._slides) > 0
and self._current_animation == self._slides[-1].end_animation and self._current_animation == self._slides[-1].end_animation
@ -325,7 +325,7 @@ class BaseSlide:
def start_loop(self) -> None: def start_loop(self) -> None:
""" """
Starts a loop. End it with :func:`end_loop`. Start a loop. End it with :func:`end_loop`.
A loop will automatically replay the slide, i.e., everything between A loop will automatically replay the slide, i.e., everything between
:func:`start_loop` and :func:`end_loop`, upon reaching end. :func:`start_loop` and :func:`end_loop`, upon reaching end.
@ -342,7 +342,6 @@ class BaseSlide:
Examples Examples
-------- --------
The following contains one slide that will loop endlessly. The following contains one slide that will loop endlessly.
.. manim-slides:: LoopExample .. manim-slides:: LoopExample
@ -370,7 +369,7 @@ class BaseSlide:
def end_loop(self) -> None: def end_loop(self) -> None:
""" """
Ends an existing loop. End an existing loop.
See :func:`start_loop` for more details. See :func:`start_loop` for more details.
""" """
@ -390,7 +389,7 @@ class BaseSlide:
def _save_slides(self, use_cache: bool = True) -> None: def _save_slides(self, use_cache: bool = True) -> None:
""" """
Saves slides, optionally using cached files. Save slides, optionally using cached files.
Note that cached files only work with Manim. Note that cached files only work with Manim.
""" """
@ -462,11 +461,11 @@ class BaseSlide:
def wipe( def wipe(
self, self,
*args: Any, *args: Any,
direction: np.ndarray = np.array([-1.0, 0.0, 0.0]), direction: np.ndarray = LEFT,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
""" """
Plays a wipe animation that will shift all the current objects outside of the Play a wipe animation that will shift all the current objects outside of the
current scene's scope, and all the future objects inside. current scene's scope, and all the future objects inside.
:param args: Positional arguments passed to :param args: Positional arguments passed to
@ -477,7 +476,6 @@ class BaseSlide:
Examples Examples
-------- --------
.. manim-slides:: WipeExample .. manim-slides:: WipeExample
from manim import * from manim import *
@ -522,7 +520,7 @@ class BaseSlide:
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
""" """
Plays a zoom animation that will fade out all the current objects, and fade in Play a zoom animation that will fade out all the current objects, and fade in
all the future objects. Objects are faded in a direction that goes towards the all the future objects. Objects are faded in a direction that goes towards the
camera. camera.
@ -533,7 +531,6 @@ class BaseSlide:
Examples Examples
-------- --------
.. manim-slides:: ZoomExample .. manim-slides:: ZoomExample
from manim import * from manim import *

View File

@ -7,8 +7,10 @@ from .base import BaseSlide
class Slide(BaseSlide, Scene): # type: ignore[misc] class Slide(BaseSlide, Scene): # type: ignore[misc]
"""Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools """
for slides rendering.""" Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools
for slides rendering.
"""
@property @property
def _ffmpeg_bin(self) -> Path: def _ffmpeg_bin(self) -> Path:
@ -83,7 +85,6 @@ class ThreeDSlide(Slide, ThreeDScene): # type: ignore[misc]
Examples Examples
-------- --------
.. manim-slides:: ThreeDExample .. manim-slides:: ThreeDExample
from manim import * from manim import *

View File

@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import Any, List, Optional, Tuple from typing import Any, ClassVar, Dict, List, Optional, Tuple
from manimlib import Scene, ThreeDCamera from manimlib import Scene, ThreeDCamera
from manimlib.utils.file_ops import get_sorted_integer_files from manimlib.utils.file_ops import get_sorted_integer_files
@ -28,18 +28,14 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
@property @property
def _background_color(self) -> str: def _background_color(self) -> str:
"""Returns the scene's background color."""
return self.camera_config["background_color"].hex # type: ignore return self.camera_config["background_color"].hex # type: ignore
@property @property
def _resolution(self) -> Tuple[int, int]: def _resolution(self) -> Tuple[int, int]:
"""Returns the scene's resolution used during rendering."""
return self.camera_config["pixel_width"], self.camera_config["pixel_height"] return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
@property @property
def _partial_movie_files(self) -> List[Path]: def _partial_movie_files(self) -> List[Path]:
"""Returns a list of partial movie files, a.k.a animations."""
kwargs = { kwargs = {
"remove_non_integer_files": True, "remove_non_integer_files": True,
"extension": self.file_writer.movie_file_extension, "extension": self.file_writer.movie_file_extension,
@ -70,7 +66,7 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
class ThreeDSlide(Slide): class ThreeDSlide(Slide):
CONFIG = { CONFIG: ClassVar[Dict[str, Any]] = {
"camera_class": ThreeDCamera, "camera_class": ThreeDCamera,
} }
pass pass

View File

@ -9,7 +9,6 @@ from .logger import logger
def concatenate_video_files(ffmpeg_bin: Path, files: List[Path], dest: Path) -> None: def concatenate_video_files(ffmpeg_bin: Path, files: List[Path], dest: Path) -> None:
"""Concatenate multiple video files into one.""" """Concatenate multiple video files into one."""
f = tempfile.NamedTemporaryFile(mode="w", delete=False) f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{path.absolute()}'\n" for path in files) f.writelines(f"file '{path.absolute()}'\n" for path in files)
f.close() f.close()

View File

@ -22,7 +22,7 @@ from .commons import config_options, verbosity_option
from .config import Config, Key from .config import Config, Key
from .defaults import CONFIG_PATH from .defaults import CONFIG_PATH
from .logger import logger from .logger import logger
from .resources import * # noqa: F401, F403 from .resources import * # noqa: F403
WINDOW_NAME: str = "Configuration Wizard" WINDOW_NAME: str = "Configuration Wizard"
@ -43,7 +43,7 @@ class KeyInput(QDialog): # type: ignore
self.layout.addWidget(self.label) self.layout.addWidget(self.label)
self.setLayout(self.layout) self.setLayout(self.layout)
def keyPressEvent(self, event: QKeyEvent) -> None: def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
self.key = event.key() self.key = event.key()
self.deleteLater() self.deleteLater()
event.accept() event.accept()
@ -58,11 +58,11 @@ class Wizard(QWidget): # type: ignore
self.icon = QIcon(":/icon.png") self.icon = QIcon(":/icon.png")
self.setWindowIcon(self.icon) self.setWindowIcon(self.icon)
QBtn = QDialogButtonBox.Save | QDialogButtonBox.Cancel button = QDialogButtonBox.Save | QDialogButtonBox.Cancel
self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox = QDialogButtonBox(button)
self.buttonBox.accepted.connect(self.saveConfig) self.buttonBox.accepted.connect(self.save_config)
self.buttonBox.rejected.connect(self.closeWithoutSaving) self.buttonBox.rejected.connect(self.close_without_saving)
self.buttons = [] self.buttons = []
@ -83,7 +83,7 @@ class Wizard(QWidget): # type: ignore
) )
self.buttons.append(button) self.buttons.append(button)
button.clicked.connect( button.clicked.connect(
partial(self.openDialog, i, getattr(self.config.keys, key)) partial(self.open_dialog, i, getattr(self.config.keys, key))
) )
self.layout.addWidget(button, i, 1) self.layout.addWidget(button, i, 1)
@ -91,16 +91,16 @@ class Wizard(QWidget): # type: ignore
self.setLayout(self.layout) self.setLayout(self.layout)
def closeWithoutSaving(self) -> None: def close_without_saving(self) -> None:
logger.debug("Closing configuration wizard without saving") logger.debug("Closing configuration wizard without saving")
self.deleteLater() self.deleteLater()
sys.exit(0) sys.exit(0)
def closeEvent(self, event: Any) -> None: def closeEvent(self, event: Any) -> None: # noqa: N802
self.closeWithoutSaving() self.closeWithoutSaving()
event.accept() event.accept()
def saveConfig(self) -> None: def save_config(self) -> None:
try: try:
Config.model_validate(self.config.dict()) Config.model_validate(self.config.dict())
except ValueError: except ValueError:
@ -116,7 +116,7 @@ class Wizard(QWidget): # type: ignore
self.deleteLater() self.deleteLater()
def openDialog(self, button_number: int, key: Key) -> None: def open_dialog(self, button_number: int, key: Key) -> None:
button = self.buttons[button_number] button = self.buttons[button_number]
dialog = KeyInput() dialog = KeyInput()
dialog.exec_() dialog.exec_()
@ -149,9 +149,10 @@ def init(
def _init( def _init(
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
) -> None: ) -> None:
"""Actual initialization code for configuration file, with optional interactive """
mode.""" Actual initialization code for configuration file, with optional interactive
mode.
"""
if config_path.exists(): if config_path.exists():
click.secho(f"The `{CONFIG_PATH}` configuration file exists") click.secho(f"The `{CONFIG_PATH}` configuration file exists")

View File

@ -5,14 +5,6 @@ requires = ["setuptools", "poetry-core>=1.0.0"]
[tool.black] [tool.black]
target-version = ["py38"] target-version = ["py38"]
[tool.docformatter]
black = true
pre-summary-newline = true
[tool.isort]
profile = "black"
py_version = 38
[tool.mypy] [tool.mypy]
disallow_untyped_decorators = false disallow_untyped_decorators = false
install_types = true install_types = true
@ -129,7 +121,22 @@ filterwarnings = [
] ]
[tool.ruff] [tool.ruff]
ignore = [ extend-exclude = ["manim_slides/resources.py"]
extend-ignore = [
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D106",
"D107",
"D203",
"D205",
"D212",
"E501" "E501"
] ]
extend-select = ["B", "C90", "D", "I", "N", "RUF", "UP", "T"]
isort = {known-first-party = ['manim_slides', 'tests']}
line-length = 88
target-version = "py38" target-version = "py38"

View File

@ -67,7 +67,7 @@ def test_format_enum(enum_type: EnumMeta) -> None:
assert expected == got assert expected == got
got = "{enum}".format(enum=enum) got = "{enum}".format(enum=enum) # noqa: UP032
assert expected == got assert expected == got

View File

@ -1,3 +1,4 @@
import os
import random import random
import shutil import shutil
import subprocess import subprocess
@ -43,7 +44,13 @@ cli = pytest.mark.parametrize(
["cli"], ["cli"],
[ [
[manim_cli], [manim_cli],
[manimgl_cli], pytest.param(
manimgl_cli,
marks=pytest.mark.xfail(
sys.platform == "win32" and os.environ.get("GITHUB_WORKFLOWS"),
reason="OpenGL cannot be installed on Windows in GitHub workflows",
),
),
], ],
) )
@ -89,7 +96,7 @@ def test_render_basic_slide(
def assert_constructs(cls: type) -> type: def assert_constructs(cls: type) -> type:
class Wrapper: class Wrapper:
@classmethod @classmethod
def test_render(_) -> None: def test_render(_) -> None: # noqa: N804
cls().construct() cls().construct()
return Wrapper return Wrapper
@ -98,7 +105,7 @@ def assert_constructs(cls: type) -> type:
def assert_renders(cls: type) -> type: def assert_renders(cls: type) -> type:
class Wrapper: class Wrapper:
@classmethod @classmethod
def test_render(_) -> None: def test_render(_) -> None: # noqa: N804
cls().render() cls().render()
return Wrapper return Wrapper
@ -118,7 +125,7 @@ class TestSlide:
@assert_renders @assert_renders
class TestMultipleAnimationsInLastSlide(Slide): class TestMultipleAnimationsInLastSlide(Slide):
"""This is used to check against solution for issue #161.""" """Check against solution for issue #161."""
def construct(self) -> None: def construct(self) -> None:
circle = Circle(color=BLUE) circle = Circle(color=BLUE)
@ -135,7 +142,7 @@ class TestSlide:
@assert_renders @assert_renders
class TestFileTooLong(Slide): class TestFileTooLong(Slide):
"""This is used to check against solution for issue #123.""" """Check against solution for issue #123."""
def construct(self) -> None: def construct(self) -> None:
circle = Circle(radius=3, color=BLUE) circle = Circle(radius=3, color=BLUE)