Compare commits

..

6 Commits

Author SHA1 Message Date
880cba3064 chore(lib): moved Sphinx directive from docs to sphinxext 2025-04-18 18:55:47 +02:00
fbf5f42f31 Merge branch 'main' into reorganize 2025-04-18 14:17:07 +02:00
3c8384a908 chore(fmt): auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-01-29 18:29:09 +00:00
4bf4d38fd8 Merge branch 'main' into reorganize 2025-01-29 19:28:57 +01:00
f02ef630cf chore(lib): pyproject.toml config 2024-08-26 09:42:22 +02:00
ae8d5b6aab chore(lib): re-organizing the module
This PR refactors a lot of the code in order to have a better organized codebase. If will that I needed to create a second level of submodules, to better distinguish the different parts of this project.
2024-08-20 11:31:58 +02:00
42 changed files with 1597 additions and 1569 deletions

View File

@ -21,7 +21,7 @@ repos:
exclude: poetry.lock exclude: poetry.lock
args: [--autofix, --trailing-commas] args: [--autofix, --trailing-commas]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.7 rev: v0.11.5
hooks: hooks:
- id: ruff - id: ruff
args: [--fix] args: [--fix]

View File

@ -10,12 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(unreleased)= (unreleased)=
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.5.1...HEAD) ## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.5.1...HEAD)
(unreleased-added)=
### Added
- `manim-slides render` now exits with the same return code as the one returned by `manim render` or `manimgl`.
[@chrjabs](https://github.com/chrjabs) [#545](https://github.com/jeertmans/manim-slides/pull/545)
(unreleased-chore)= (unreleased-chore)=
### Chore ### Chore
@ -24,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
when using one of those extras, but as they were not part of the public API, when using one of those extras, but as they were not part of the public API,
we do not consider this to be a **breaking change**. we do not consider this to be a **breaking change**.
[#542](https://github.com/jeertmans/manim-slides/pull/542) [#542](https://github.com/jeertmans/manim-slides/pull/542)
- Moved `manim_slides.docs.manim_slides_directive` to `manim_slides.sphinxext.manim_slides_directive`.
This is a **breaking change** because documentation configs have
to be updated.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
(v5.5.1)= (v5.5.1)=
## [v5.5.1](https://github.com/jeertmans/manim-slides/compare/v5.5.0...v5.5.1) ## [v5.5.1](https://github.com/jeertmans/manim-slides/compare/v5.5.0...v5.5.1)

View File

@ -37,7 +37,7 @@ extensions = [
"sphinx_copybutton", "sphinx_copybutton",
"sphinx_design", "sphinx_design",
# Custom # Custom
"manim_slides.docs.manim_slides_directive", "manim_slides.sphinxext.manim_slides_directive",
] ]
autodoc_typehints = "both" autodoc_typehints = "both"

View File

@ -92,7 +92,7 @@ This will also show the default value for each option.
If you want to create your own template, the best is to start from the default one. If you want to create your own template, the best is to start from the default one.
You can either download it from the You can either download it from the
[template folder](https://github.com/jeertmans/manim-slides/tree/main/manim_slides/templates) [template folder](https://github.com/jeertmans/manim-slides/tree/main/manim_slides/cli/convert/templates)
or use the `manim-slides convert --to=FORMAT --show-template` command, or use the `manim-slides convert --to=FORMAT --show-template` command,
where `FORMAT` is one of the supported formats. where `FORMAT` is one of the supported formats.

View File

@ -4,7 +4,7 @@ This page contains an exhaustive list of all the commands available with `manim-
```{eval-rst} ```{eval-rst}
.. click:: manim_slides.__main__:cli .. click:: manim_slides.cli.commands:main
:prog: manim-slides :prog: manim-slides
:nested: full :nested: full
``` ```

View File

@ -4,7 +4,7 @@ One of the benefits of the `convert` command is the use of template files.
Currently, only the HTML export uses one. If not specified, the template Currently, only the HTML export uses one. If not specified, the template
will be the one shipped with Manim Slides, see will be the one shipped with Manim Slides, see
[`manim_slides/templates/revealjs.html`](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/templates/revealjs.html). [`manim_slides/cli/convert/templates/revealjs.html`](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/cli/convert/templates/revealjs.html).
Because you can actually use your own template with the `--use-template` Because you can actually use your own template with the `--use-template`
option, possibilities are infinite! option, possibilities are infinite!

View File

@ -30,7 +30,7 @@ manim-slides convert --show-config
## Using a Custom Template ## Using a Custom Template
The default template used for HTML conversion can be found on The default template used for HTML conversion can be found on
[GitHub](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/templates/revealjs.html) [GitHub](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/cli/convert/templates/revealjs.html)
or printed with the `--show-template` option. or printed with the `--show-template` option.
If you wish to use another template, you can do so with the If you wish to use another template, you can do so with the
`--use-template FILE` option. `--use-template FILE` option.

View File

@ -1,6 +1,6 @@
# Manim Slides' Sphinx directive # Manim Slides' Sphinx directive
```{eval-rst} ```{eval-rst}
.. automodule:: manim_slides.docs.manim_slides_directive .. automodule:: manim_slides.sphinxext.manim_slides_directive
:members: ManimSlidesDirective :members: ManimSlidesDirective
``` ```

View File

@ -1,3 +1,10 @@
"""
Manim Slides module.
Submodules are lazily imported, in order to provide a faster import experience
in some cases.
"""
import sys import sys
from types import ModuleType from types import ModuleType
from typing import Any from typing import Any
@ -8,9 +15,7 @@ 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__("manim_slides.slide", None, None, [name])
"manim_slides.slide", None, None, ["Slide", "ThreeDSlide"]
)
return getattr(module, name) return getattr(module, name)
elif name == "ManimSlidesMagic": elif name == "ManimSlidesMagic":
module = __import__( module = __import__(

View File

@ -1,11 +1,8 @@
import json """Manim Slides' main entrypoint."""
import click
import requests
from click_default_group import DefaultGroup
from .__version__ import __version__ from .__version__ import __version__
from .checkhealth import checkhealth from .checkhealth import checkhealth
from .cli.commands import main
from .convert import convert from .convert import convert
from .logger import logger from .logger import logger
from .present import list_scenes, present from .present import list_scenes, present
@ -72,4 +69,4 @@ cli.add_command(render)
cli.add_command(wizard) cli.add_command(wizard)
if __name__ == "__main__": if __name__ == "__main__":
cli() main()

View File

@ -1 +1,3 @@
__version__ = "5.5.1" """Manim Slides' version."""
__version__ = "5.4.2"

View File

@ -0,0 +1,72 @@
"""Manim Slides' CLI."""
import json
import click
import requests
from click_default_group import DefaultGroup
from ..__version__ import __version__
from ..core.logger import logger
from .convert.commands import convert
from .present.commands import list_scenes, present
from .render.commands import render
from .wizard.commands import init, wizard
@click.group(cls=DefaultGroup, default="present", default_if_no_args=True)
@click.option(
"--notify-outdated-version/--silent",
" /-S",
is_flag=True,
default=True,
help="Check if a new version of Manim Slides is available.",
)
@click.version_option(__version__, "-v", "--version")
@click.help_option("-h", "--help")
def main(notify_outdated_version: bool) -> None:
"""
Manim Slides command-line utilities.
If no command is specified, defaults to `present`.
"""
# Code below is mostly a copy from:
# https://github.com/ManimCommunity/manim/blob/main/manim/cli/render/commands.py
if notify_outdated_version:
manim_info_url = "https://pypi.org/pypi/manim-slides/json"
warn_prompt = "Cannot check if latest release of Manim Slides is installed"
try:
req_info: requests.models.Response = requests.get(manim_info_url, timeout=2)
req_info.raise_for_status()
stable = req_info.json()["info"]["version"]
if stable != __version__:
click.echo(
"You are using Manim Slides version "
+ click.style(f"v{__version__}", fg="red")
+ ", but version "
+ click.style(f"v{stable}", fg="green")
+ " is available."
)
click.echo(
"You should consider upgrading via "
+ click.style("pip install -U manim-slides", fg="yellow")
)
except requests.exceptions.HTTPError:
logger.debug(f"HTTP Error: {warn_prompt}")
except requests.exceptions.ConnectionError:
logger.debug(f"Connection Error: {warn_prompt}")
except requests.exceptions.Timeout:
logger.debug(f"Timed Out: {warn_prompt}")
except json.JSONDecodeError:
logger.debug(warn_prompt)
logger.debug(f"Error decoding JSON from {manim_info_url}")
except Exception:
logger.debug(f"Something went wrong: {warn_prompt}")
main.add_command(convert)
main.add_command(init)
main.add_command(list_scenes)
main.add_command(present)
main.add_command(render)
main.add_command(wizard)

View File

@ -4,8 +4,9 @@ from typing import Any, Callable
import click import click
from click import Context, Parameter from click import Context, Parameter
from .defaults import CONFIG_PATH, FOLDER_PATH from ..core.config import list_presentation_configs
from .logger import logger from ..core.defaults import CONFIG_PATH, FOLDER_PATH
from ..core.logger import logger
F = Callable[..., Any] F = Callable[..., Any]
Wrapper = Callable[[F], F] Wrapper = Callable[[F], F]
@ -88,6 +89,68 @@ def folder_path_option(function: F) -> F:
callback=callback, callback=callback,
help="Set slides folder.", help="Set slides folder.",
show_default=True, show_default=True,
is_eager=True, # Needed to expose its value to other callbacks
) )
return wrapper(function) return wrapper(function)
def scenes_argument(function: F) -> F:
"""
Wrap a function to add a scenes arguments.
This function assumes that :func:`folder_path_option` is also used
on the same decorated function.
"""
def callback(ctx: Context, param: Parameter, value: tuple[str]) -> list[Path]:
folder: Path = ctx.params.get("folder")
presentation_config_paths = list_presentation_configs(folder)
scene_names = [path.stem for path in presentation_config_paths]
num_scenes = len(scene_names)
num_digits = len(str(num_scenes))
if num_scenes == 0:
raise click.UsageError(
f"Folder {folder} does not contain "
"any valid config file, did you render the animations first?"
)
paths = []
if value:
for scene_name in value:
try:
i = scene_names.index(scene_name)
paths.append(presentation_config_paths[i])
except ValueError:
raise click.UsageError(
f"Could not find scene `{scene_name}` in: "
+ ", ".join(scene_names)
+ ". Did you make a typo or forgot to render the animations first?"
) from None
else:
click.echo(
"Choose at least one or more scenes from "
"(enter the corresponding number):\n"
+ "\n".join(
f"- {i:{num_digits}d}: {name}"
for i, name in enumerate(scene_names, start=1)
)
)
continue_prompt = True
while continue_prompt:
index = click.prompt(
"Please enter a value", type=click.IntRange(1, num_scenes)
)
paths.append(presentation_config_paths[index - 1])
continue_prompt = click.confirm(
"Do you want to enter an additional scene?"
)
return paths
wrapper: Wrapper = click.argument("scenes", nargs=-1, callback=callback)
return wrapper(function)

View File

@ -37,14 +37,18 @@ from pydantic_core import CoreSchema, core_schema
from pydantic_extra_types.color import Color from pydantic_extra_types.color import Color
from tqdm import tqdm from tqdm import tqdm
from ...core.config import PresentationConfig
from ...core.logger import logger
from ..commons import folder_path_option, scenes_argument, verbosity_option
from . import templates from . import templates
from .commons import folder_path_option, verbosity_option
from .config import PresentationConfig
from .logger import logger
from .present import get_scenes_presentation_config
def open_with_default(file: Path) -> None: def open_with_default(file: Path) -> None:
"""
Open a file with the default application.
:param file: The file to open.
"""
system = platform.system() system = platform.system()
if system == "Darwin": if system == "Darwin":
subprocess.call(("open", str(file))) subprocess.call(("open", str(file)))
@ -142,6 +146,7 @@ class Str(str):
# This fixes pickling issue on Python 3.8 # This fixes pickling issue on Python 3.8
__reduce_ex__ = str.__reduce_ex__ __reduce_ex__ = str.__reduce_ex__
# TODO: do we still need this?
@classmethod @classmethod
def __get_pydantic_core_schema__( def __get_pydantic_core_schema__(
@ -547,6 +552,11 @@ class RevealJS(Converter):
return resources.files(templates).joinpath("revealjs.html").read_text() return resources.files(templates).joinpath("revealjs.html").read_text()
def open(self, file: Path) -> None: def open(self, file: Path) -> None:
"""
Open the HTML file inside a web browser.
:param path: The path to the HTML file.
"""
webbrowser.open(file.absolute().as_uri()) webbrowser.open(file.absolute().as_uri())
def convert_to(self, dest: Path) -> None: # noqa: C901 def convert_to(self, dest: Path) -> None: # noqa: C901
@ -910,7 +920,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
@click.command() @click.command()
@click.argument("scenes", nargs=-1) @scenes_argument
@folder_path_option @folder_path_option
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path)) @click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
@click.option( @click.option(
@ -960,7 +970,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
@show_config_options @show_config_options
@verbosity_option @verbosity_option
def convert( def convert(
scenes: list[str], scenes: list[Path],
folder: Path, folder: Path,
dest: Path, dest: Path,
to: str, to: str,
@ -971,7 +981,7 @@ def convert(
one_file: bool, one_file: bool,
) -> 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 = [PresentationConfig.from_file(scene) for scene in scenes]
try: try:
if to == "auto": if to == "auto":

View File

@ -0,0 +1 @@
"""Manim Slides conversion templates."""

View File

@ -0,0 +1 @@
"""Manim Slides' presentation commands."""

View File

@ -7,9 +7,14 @@ import click
from click import Context, Parameter from click import Context, Parameter
from pydantic import ValidationError from pydantic import ValidationError
from ..commons import config_path_option, folder_path_option, verbosity_option from ...core.config import Config, PresentationConfig, list_presentation_configs
from ..config import Config, PresentationConfig from ...core.logger import logger
from ..logger import logger from ..commons import (
config_path_option,
folder_path_option,
scenes_argument,
verbosity_option,
)
@click.command() @click.command()
@ -18,8 +23,10 @@ from ..logger import logger
@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): scene_names = [path.stem for path in list_presentation_configs(folder)]
click.secho(f"{i}: {scene}", fg="green") num_digits = len(str(len(scene_names)))
for i, scene_name in enumerate(scene_names, start=1):
click.secho(f"{i:{num_digits}d}: {scene_name}", fg="green")
def _list_scenes(folder: Path) -> list[str]: def _list_scenes(folder: Path) -> list[str]:
@ -130,7 +137,7 @@ def start_at_callback(
@click.command() @click.command()
@click.argument("scenes", nargs=-1) @scenes_argument
@config_path_option @config_path_option
@folder_path_option @folder_path_option
@click.option("--start-paused", is_flag=True, help="Start paused.") @click.option("--start-paused", is_flag=True, help="Start paused.")
@ -276,7 +283,7 @@ def present( # noqa: C901
if skip_all: if skip_all:
exit_after_last_slide = True exit_after_last_slide = True
presentation_configs = get_scenes_presentation_config(scenes, folder) presentation_configs = [PresentationConfig.from_file(path) for path in scenes]
if config_path.exists(): if config_path.exists():
try: try:

View File

@ -1,14 +1,4 @@
""" """Manim Slides' rendering commands."""
Alias command to either
``manim render [OPTIONS] [ARGS]...`` or
``manimgl -w [OPTIONS] [ARGS]...``.
This is especially useful for two reasons:
1. You can are sure to execute the rendering command with the same Python environment
as for ``manim-slides``.
2. You can pass options to the config.
"""
import subprocess import subprocess
import sys import sys
@ -44,11 +34,22 @@ def render(ce: bool, gl: bool, args: tuple[str, ...]) -> None:
Use ``manim-slides render --help`` to see help information for Use ``manim-slides render --help`` to see help information for
a specific renderer. a specific renderer.
Alias command to either
``manim render [OPTIONS] [ARGS]...`` or
``manimgl [OPTIONS] [ARGS]...``.
This is especially useful for two reasons:
1. You can are sure to execute the rendering command with the same Python environment
as for ``manim-slides``.
2. You can pass options to the config.
""" """
if ce and gl: if ce and gl:
raise click.UsageError("You cannot specify both --CE and --GL renderers.") raise click.UsageError("You cannot specify both --CE and --GL renderers.")
if gl: if gl:
completed = subprocess.run([sys.executable, "-m", "manimlib", "-w", *args]) subprocess.run([sys.executable, "-m", "manimlib", "-w", *args])
else: else:
completed = subprocess.run([sys.executable, "-m", "manim", "render", *args]) from manim.cli.render.commands import render as render_ce
sys.exit(completed.returncode)
render_ce(args, standalone_mode=False)

View File

@ -0,0 +1 @@
"""Manim Slides' wizard."""

View File

@ -3,10 +3,9 @@ from pathlib import Path
import click import click
from ...core.config import Config
from ...core.logger import logger
from ..commons import config_options, verbosity_option from ..commons import config_options, verbosity_option
from ..config import Config
from ..defaults import CONFIG_PATH
from ..logger import logger
@click.command() @click.command()
@ -37,7 +36,7 @@ def _init(
mode. mode.
""" """
if config_path.exists(): if config_path.exists():
click.secho(f"The `{CONFIG_PATH}` configuration file exists") logger.debug(f"The `{config_path}` configuration file exists")
if not force and not merge: if not force and not merge:
choice = click.prompt( choice = click.prompt(
@ -57,7 +56,7 @@ def _init(
if force: if force:
logger.debug(f"Overwriting `{config_path}` if exists") logger.debug(f"Overwriting `{config_path}` if exists")
elif merge: elif merge:
logger.debug("Merging new config into `{config_path}`") logger.debug(f"Merging new config into `{config_path}`")
if not skip_interactive: if not skip_interactive:
if config_path.exists(): if config_path.exists():
@ -82,4 +81,4 @@ def _init(
config.to_file(config_path) config.to_file(config_path)
click.secho(f"Configuration file successfully saved to `{config_path}`") logger.debug(f"Configuration file successfully saved to `{config_path}`")

View File

@ -1,3 +1,5 @@
"""Manim Slides' configuration tools."""
import json import json
import shutil import shutil
from functools import wraps from functools import wraps
@ -13,6 +15,7 @@ from pydantic import (
FilePath, FilePath,
PositiveInt, PositiveInt,
PrivateAttr, PrivateAttr,
ValidationError,
conset, conset,
field_serializer, field_serializer,
field_validator, field_validator,
@ -26,28 +29,54 @@ Receiver = Callable[..., Any]
class Signal(BaseModel): # type: ignore[misc] class Signal(BaseModel): # type: ignore[misc]
__receivers: list[Receiver] = PrivateAttr(default_factory=list) """Signal that notifies a list of receivers when it is emitted."""
__receivers: set[Receiver] = PrivateAttr(default_factory=set)
def connect(self, receiver: Receiver) -> None: def connect(self, receiver: Receiver) -> None:
self.__receivers.append(receiver) """
Connect a receiver to this signal.
This is a no-op if the receiver was already connected to this signal.
:param receiver: The receiver to connect.
"""
self.__receivers.add(receiver)
def disconnect(self, receiver: Receiver) -> None: def disconnect(self, receiver: Receiver) -> None:
self.__receivers.remove(receiver) """
Disconnect a receiver from this signal.
This is a no-op if the receiver was not connected to this signal.
:param receiver: The receiver to disconnect.
"""
self.__receivers.discard(receiver)
def emit(self, *args: Any) -> None: def emit(self, *args: Any) -> None:
"""
Emit this signal and call each of the attached receivers.
:param args: Positional arguments passed to each receiver.
"""
for receiver in self.__receivers: for receiver in self.__receivers:
receiver(*args) receiver(*args)
def key_id(name: str) -> PositiveInt: def key_id(name: str) -> PositiveInt:
"""Avoid importing Qt too early.""" """
from qtpy.QtCore import Qt Return the id corresponding to the given key name.
:param str: The name of the key, e.g., 'Q'.
:return: The corresponding id.
"""
from qtpy.QtCore import Qt # Avoid importing Qt too early."""
return getattr(Qt, f"Key_{name}") return getattr(Qt, f"Key_{name}")
class Key(BaseModel): # type: ignore[misc] class Key(BaseModel): # type: ignore[misc]
"""Represents a list of key codes, with optionally a name.""" """Represent a list of key codes, with optionally a name."""
ids: conset(PositiveInt, min_length=1) # type: ignore[valid-type] ids: conset(PositiveInt, min_length=1) # type: ignore[valid-type]
name: Optional[str] = None name: Optional[str] = None
@ -58,6 +87,7 @@ class Key(BaseModel): # type: ignore[misc]
self.ids = set(ids) self.ids = set(ids)
def match(self, key_id: int) -> bool: def match(self, key_id: int) -> bool:
"""Return whether a given key id matches this key."""
m = key_id in self.ids m = key_id in self.ids
if m: if m:
@ -135,6 +165,7 @@ class Config(BaseModel): # type: ignore[misc]
"""General Manim Slides config.""" """General Manim Slides config."""
keys: Keys = Field(default_factory=Keys) keys: Keys = Field(default_factory=Keys)
"""The key mapping."""
@classmethod @classmethod
def from_file(cls, path: Path) -> "Config": def from_file(cls, path: Path) -> "Config":
@ -142,11 +173,16 @@ class Config(BaseModel): # type: ignore[misc]
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:
"""Dump the configuration to a file.""" """Dump this 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.""" """
Merge with another config.
:param other: The other config to be merged with.
:return: This config, updated.
"""
self.keys = self.keys.merge_with(other.keys) self.keys = self.keys.merge_with(other.keys)
return self return self
@ -155,11 +191,17 @@ class BaseSlideConfig(BaseModel): # type: ignore
"""Base class for slide config.""" """Base class for slide config."""
loop: bool = False loop: bool = False
"""Whether this slide should loop."""
auto_next: bool = False auto_next: bool = False
"""Whether this slide is skipped upon completion."""
playback_rate: float = 1.0 playback_rate: float = 1.0
"""The speed at which the animation is played (1.0 is normal)."""
reversed_playback_rate: float = 1.0 reversed_playback_rate: float = 1.0
"""The speed at which the reversed animation is played."""
notes: str = "" notes: str = ""
"""The notes attached to this slide."""
dedent_notes: bool = True dedent_notes: bool = True
"""Whether to automatically remove any leading indentation in the notes."""
skip_animations: bool = False skip_animations: bool = False
src: Optional[FilePath] = None src: Optional[FilePath] = None
@ -173,7 +215,11 @@ class BaseSlideConfig(BaseModel): # type: ignore
The wrapped function must follow two criteria: The wrapped function must follow two criteria:
- its last parameter must be ``**kwargs`` (or equivalent); - its last parameter must be ``**kwargs`` (or equivalent);
- and its second last parameter must be ``<arg_name>``. - and its second last parameter must be ``<arg_name>``.
:param arg_name: The name of the argument.
:return: The wrapped function.
""" """
# TODO: improve docs and (maybe) type-hints too
def _wrapper_(fun: Callable[..., Any]) -> Callable[..., Any]: def _wrapper_(fun: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fun) @wraps(fun)
@ -209,6 +255,11 @@ class BaseSlideConfig(BaseModel): # type: ignore
def apply_dedent_notes( def apply_dedent_notes(
self, self,
) -> "BaseSlideConfig": ) -> "BaseSlideConfig":
"""
Remove indentation from notes, if specified.
:return: The config, optionally modified.
"""
if self.dedent_notes: if self.dedent_notes:
self.notes = dedent(self.notes) self.notes = dedent(self.notes)
@ -219,7 +270,9 @@ class PreSlideConfig(BaseSlideConfig):
"""Slide config to be used prior to rendering.""" """Slide config to be used prior to rendering."""
start_animation: int start_animation: int
"""The index of the first animation."""
end_animation: int end_animation: int
"""The index after the last animation."""
@classmethod @classmethod
def from_base_slide_config_and_animation_indices( def from_base_slide_config_and_animation_indices(
@ -228,6 +281,13 @@ class PreSlideConfig(BaseSlideConfig):
start_animation: int, start_animation: int,
end_animation: int, end_animation: int,
) -> "PreSlideConfig": ) -> "PreSlideConfig":
"""
Create a config from a base config and animation indices.
:param base_slide_config: The base config.
:param start_animation: The index of the first animation.
:param end_animation: The index after the last animation.
"""
return cls( return cls(
start_animation=start_animation, start_animation=start_animation,
end_animation=end_animation, end_animation=end_animation,
@ -237,6 +297,12 @@ class PreSlideConfig(BaseSlideConfig):
@field_validator("start_animation", "end_animation") @field_validator("start_animation", "end_animation")
@classmethod @classmethod
def index_is_posint(cls, v: int) -> int: def index_is_posint(cls, v: int) -> int:
"""
Validate that animation indices are positive integers.
:param v: An animation index.
:return: The animation index, if valid.
"""
if v < 0: if v < 0:
raise ValueError("Animation index (start or end) cannot be negative") raise ValueError("Animation index (start or end) cannot be negative")
return v return v
@ -245,7 +311,11 @@ class PreSlideConfig(BaseSlideConfig):
def start_animation_is_before_end( def start_animation_is_before_end(
self, self,
) -> "PreSlideConfig": ) -> "PreSlideConfig":
if self.start_animation > self.end_animation: """
Validate that start and end animation indices satisfy 'start < end'.
:return: The config, if indices are valid.
"""
raise ValueError( raise ValueError(
"Start animation index must be strictly lower than end animation index" "Start animation index must be strictly lower than end animation index"
) )
@ -279,7 +349,9 @@ class SlideConfig(BaseSlideConfig):
"""Slide config to be used after rendering.""" """Slide config to be used after rendering."""
file: FilePath file: FilePath
"""The file containing the animation."""
rev_file: FilePath rev_file: FilePath
"""The file containing the reversed animation."""
@classmethod @classmethod
def from_pre_slide_config_and_files( def from_pre_slide_config_and_files(
@ -289,13 +361,22 @@ class SlideConfig(BaseSlideConfig):
class PresentationConfig(BaseModel): # type: ignore[misc] class PresentationConfig(BaseModel): # type: ignore[misc]
"""Presentation config that contains all necessary information for a presentation."""
slides: list[SlideConfig] = Field(min_length=1) slides: list[SlideConfig] = Field(min_length=1)
"""The non-empty list of slide configs."""
resolution: tuple[PositiveInt, PositiveInt] = (1920, 1080) resolution: tuple[PositiveInt, PositiveInt] = (1920, 1080)
"""The resolution of the animation files."""
background_color: Color = "black" background_color: Color = "black"
"""The background color of the animation files."""
@classmethod @classmethod
def from_file(cls, path: Path) -> "PresentationConfig": def from_file(cls, path: Path) -> "PresentationConfig":
"""Read a presentation configuration from a file.""" """
Read a presentation configuration from a file.
:param path: The path where the config is read from.
"""
with open(path) as f: with open(path) as f:
obj = json.load(f) obj = json.load(f)
@ -312,7 +393,11 @@ 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:
"""Dump the presentation configuration to a file.""" """
Dump the presentation configuration to a file.
:param path: The path to save this config.
"""
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))
@ -323,7 +408,14 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
include_reversed: bool = True, include_reversed: bool = True,
prefix: str = "", prefix: str = "",
) -> None: ) -> None:
"""Copy the files to a given directory.""" """
Copy the files to a given directory and return the corresponding configuration.
:param folder: The folder that will contain the animation files.
:param use_cached: Whether caching should be used to avoid copies when possible.
:param include_reversed: Whether to also copy reversed animation to the folder.
:param prefix: Optional prefix added to each file name.
"""
for slide_config in self.slides: for slide_config in self.slides:
file = slide_config.file file = slide_config.file
rev_file = slide_config.rev_file rev_file = slide_config.rev_file
@ -335,4 +427,6 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
shutil.copy(file, dest) shutil.copy(file, dest)
if include_reversed and (not use_cached or not rev_dest.exists()): if include_reversed and (not use_cached or not rev_dest.exists()):
# TODO: if include_reversed is False, then rev_dev will likely not exist
# and this will cause an issue when decoding.
shutil.copy(rev_file, rev_dest) shutil.copy(rev_file, rev_dest)

View File

@ -1,4 +1,8 @@
"""Manim Slides' defaults."""
from pathlib import Path from pathlib import Path
FOLDER_PATH: Path = Path("./slides") FOLDER_PATH: Path = Path("./slides")
"""Folder where slides are stored."""
CONFIG_PATH: Path = Path(".manim-slides.toml") CONFIG_PATH: Path = Path(".manim-slides.toml")
"""Path to local Manim Slides config."""

View File

@ -31,7 +31,11 @@ HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
def make_logger() -> logging.Logger: def make_logger() -> logging.Logger:
"""Make a logger similar to the one used by Manim.""" """
Make a logger similar to the one used by Manim.
:return: The logger instance.
"""
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
rich_handler = RichHandler( rich_handler = RichHandler(
show_time=True, show_time=True,
@ -47,6 +51,5 @@ def make_logger() -> logging.Logger:
return logger return logger
make_logger() logger = make_logger()
"""The logger instance used across this project."""
logger = logging.getLogger("manim-slides")

View File

@ -116,7 +116,7 @@ class ManimSlidesMagic(Magics): # type: ignore
file) will be moved relative to the video locations. Use-cases include building file) will be moved relative to the video locations. Use-cases include building
documentation with Sphinx and JupyterBook. See also the documentation with Sphinx and JupyterBook. See also the
:mod:`Manim Slides directive for Sphinx :mod:`Manim Slides directive for Sphinx
<manim_slides.docs.manim_slides_directive>`. <manim_slides.sphinxext.manim_slides_directive>`.
Examples Examples
-------- --------

View File

@ -1,10 +1,12 @@
__all__ = [ """Slides module with logic to either import ManimCE or ManimGL."""
__all__ = (
"API_NAME", "API_NAME",
"MANIM", "MANIM",
"MANIMGL", "MANIMGL",
"Slide", "Slide",
"ThreeDSlide", "ThreeDSlide",
] )
import os import os
@ -14,10 +16,10 @@ import sys
class ManimApiNotFoundError(ImportError): class ManimApiNotFoundError(ImportError):
"""Error raised if specified manim API could be imported.""" """Error raised if specified manim API could be imported."""
_msg = "Could not import the specified manim API" _msg = "Could not import the specified manim API: `{api}`."
def __init__(self) -> None: def __init__(self, api: str) -> None:
super().__init__(self._msg) super().__init__(self._msg.format(api=api))
API_NAMES = { API_NAMES = {
@ -26,9 +28,12 @@ API_NAMES = {
"manimlib": "manimlib", "manimlib": "manimlib",
"manimgl": "manimlib", "manimgl": "manimlib",
} }
"""Allowed values for API."""
MANIM_API: str = "MANIM_API" MANIM_API: str = "MANIM_API"
"""API environ variable name."""
FORCE_MANIM_API: str = "FORCE_" + MANIM_API FORCE_MANIM_API: str = "FORCE_" + MANIM_API
"""FORCE API environ variable name."""
API: str = os.environ.get(MANIM_API, "manim").lower() API: str = os.environ.get(MANIM_API, "manim").lower()
@ -53,11 +58,14 @@ if MANIM:
try: try:
from .manim import Slide, ThreeDSlide from .manim import Slide, ThreeDSlide
except ImportError as e: except ImportError as e:
raise ManimApiNotFoundError from e raise ManimApiNotFoundError("manim") from e
elif MANIMGL: elif MANIMGL:
try: try:
from .manimlib import Slide, ThreeDSlide from .manimlib import Slide, ThreeDSlide
except ImportError as e: except ImportError as e:
raise ManimApiNotFoundError from e raise ManimApiNotFoundError("manimlib") from e
else: else:
raise ManimApiNotFoundError raise ValueError(
"This error should never occur. "
"Please report an issue on GitHub if you encounter it."
)

View File

@ -1,6 +1,8 @@
"""Base class for the Slide class."""
from __future__ import annotations from __future__ import annotations
__all__ = ["BaseSlide"] __all__ = ("BaseSlide",)
import platform import platform
import shutil import shutil
@ -15,10 +17,15 @@ from typing import (
import numpy as np import numpy as np
from tqdm import tqdm from tqdm import tqdm
from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig from ..core.config import (
from ..defaults import FOLDER_PATH BaseSlideConfig,
from ..logger import logger PresentationConfig,
from ..utils import concatenate_video_files, merge_basenames, reverse_video_file PreSlideConfig,
SlideConfig,
)
from ..core.defaults import FOLDER_PATH
from ..core.logger import logger
from ..core.utils import concatenate_video_files, merge_basenames, reverse_video_file
from . import MANIM from . import MANIM
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@ -1,3 +1,5 @@
"""Manim's implementation of the Slide class."""
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -5,7 +7,7 @@ from manim import Scene, ThreeDScene, config
from manim.renderer.opengl_renderer import OpenGLRenderer from manim.renderer.opengl_renderer import OpenGLRenderer
from manim.utils.color import rgba_to_color from manim.utils.color import rgba_to_color
from ..config import BaseSlideConfig from ..core.config import BaseSlideConfig
from .base import BaseSlide from .base import BaseSlide

View File

@ -1,3 +1,5 @@
"""ManimGL's implementation of the Slide class."""
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar, Optional from typing import Any, ClassVar, Optional

View File

View File

@ -40,7 +40,7 @@ First, you must include the directive in the Sphinx configuration file:
extensions = [ extensions = [
# ... # ...
"manim_slides.docs.manim_slides_directive", "manim_slides.sphinxext.manim_slides_directive",
] ]
Its basic usage that allows processing **inline content** Its basic usage that allows processing **inline content**

View File

@ -26,7 +26,7 @@ docs = [
] ]
tests = [ tests = [
"importlib-metadata>=8.6.1;python_version<'3.10'", "importlib-metadata>=8.6.1;python_version<'3.10'",
"manim-slides[full,manim,manimgl,pyqt6,pyside6,sphinx-directive]", "manim-slides[full,manimgl,pyqt6,pyside6,sphinx-directive]",
"pytest>=7.4.0", "pytest>=7.4.0",
"pytest-cov>=4.1.0", "pytest-cov>=4.1.0",
"pytest-env>=0.8.2", "pytest-env>=0.8.2",
@ -88,7 +88,7 @@ pyside6-full = ["manim-slides[full,pyside6]"]
sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"] sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"]
[project.scripts] [project.scripts]
manim-slides = "manim_slides.__main__:cli" manim-slides = "manim_slides.cli.commands:main"
[project.urls] [project.urls]
Changelog = "https://github.com/jeertmans/manim-slides/releases" Changelog = "https://github.com/jeertmans/manim-slides/releases"
@ -208,10 +208,9 @@ filterwarnings = [
] ]
[tool.ruff] [tool.ruff]
extend-exclude = ["manim_slides/resources.py"] extend-exclude = ["manim_slides/cli/resources.py"]
extend-include = ["*.ipynb"] extend-include = ["*.ipynb"]
line-length = 88 line-length = 88
target-version = "py39"
[tool.ruff.lint] [tool.ruff.lint]
extend-ignore = [ extend-ignore = [

View File

@ -42,8 +42,3 @@ class BasicSlide(Slide):
class BasicSlideSkipReversing(BasicSlide): class BasicSlideSkipReversing(BasicSlide):
skip_reversing = True skip_reversing = True
class FailingSlide(Slide):
def construct(self):
self.play("this fails to render")

View File

@ -113,37 +113,6 @@ def test_render_basic_slide(
assert local_presentation_config.resolution == presentation_config.resolution assert local_presentation_config.resolution == presentation_config.resolution
@pytest.mark.parametrize(
"renderer",
[
"--CE",
pytest.param(
"--GL",
marks=pytest.mark.skipif(
sys.version_info < (3, 10),
reason="See https://github.com/3b1b/manim/issues/2263.",
),
),
"--CE --renderer=opengl",
],
ids=("CE", "GL", "CE(GL)"),
)
def test_render_failing_slide(
renderer: str,
slides_file: Path,
manimgl_config: Path,
) -> None:
runner = CliRunner()
with runner.isolated_filesystem() as tmp_dir:
shutil.copy(manimgl_config, tmp_dir)
results = runner.invoke(
render, [*renderer.split(" "), str(slides_file), "FailingSlide", "-ql"]
)
assert results.exit_code != 0, results
def test_clear_cache( def test_clear_cache(
slides_file: Path, slides_file: Path,
) -> None: ) -> None:

2664
uv.lock generated

File diff suppressed because it is too large Load Diff