diff --git a/docs/source/faq.md b/docs/source/faq.md index 21403523..4ae8d99e 100644 --- a/docs/source/faq.md +++ b/docs/source/faq.md @@ -94,7 +94,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. 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, where `FORMAT` is one of the supported formats. diff --git a/docs/source/reference/cli.md b/docs/source/reference/cli.md index d0830b6d..6768b68b 100644 --- a/docs/source/reference/cli.md +++ b/docs/source/reference/cli.md @@ -4,7 +4,7 @@ This page contains an exhaustive list of all the commands available with `manim- ```{eval-rst} -.. click:: manim_slides.__main__:cli +.. click:: manim_slides.cli.commands:main :prog: manim-slides :nested: full ``` diff --git a/docs/source/reference/customize_html.md b/docs/source/reference/customize_html.md index 56122782..f0eb405c 100644 --- a/docs/source/reference/customize_html.md +++ b/docs/source/reference/customize_html.md @@ -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 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` option, possibilities are infinite! diff --git a/docs/source/reference/html.md b/docs/source/reference/html.md index 24785c0d..11985a4a 100644 --- a/docs/source/reference/html.md +++ b/docs/source/reference/html.md @@ -30,7 +30,7 @@ manim-slides convert --show-config ## Using a Custom Template 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. If you wish to use another template, you can do so with the `--use-template FILE` option. diff --git a/manim_slides/__init__.py b/manim_slides/__init__.py index 8fc746db..57832dee 100644 --- a/manim_slides/__init__.py +++ b/manim_slides/__init__.py @@ -1,3 +1,10 @@ +""" +Manim Slides module. + +Submodules are lazily imported, in order to provide a faster import experience +in some cases. +""" + import sys from types import ModuleType from typing import Any @@ -8,9 +15,7 @@ class Module(ModuleType): def __getattr__(self, name: str) -> Any: if name == "Slide" or name == "ThreeDSlide": - module = __import__( - "manim_slides.slide", None, None, ["Slide", "ThreeDSlide"] - ) + module = __import__("manim_slides.slide", None, None, [name]) return getattr(module, name) elif name == "ManimSlidesMagic": module = __import__( diff --git a/manim_slides/__main__.py b/manim_slides/__main__.py index 90932379..0c4162e0 100644 --- a/manim_slides/__main__.py +++ b/manim_slides/__main__.py @@ -1,73 +1,6 @@ -import json +"""Manim Slides' main entrypoint.""" -import click -import requests -from click_default_group import DefaultGroup - -from .__version__ import __version__ -from .convert import convert -from .logger import logger -from .present import list_scenes, present -from .render import render -from .wizard 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 cli(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}") - - -cli.add_command(convert) -cli.add_command(init) -cli.add_command(list_scenes) -cli.add_command(present) -cli.add_command(render) -cli.add_command(wizard) +from .cli.commands import main if __name__ == "__main__": - cli() + main() diff --git a/manim_slides/__version__.py b/manim_slides/__version__.py index 4d2666fb..7694e1a0 100644 --- a/manim_slides/__version__.py +++ b/manim_slides/__version__.py @@ -1 +1,3 @@ +"""Manim Slides' version.""" + __version__ = "5.1.7" diff --git a/manim_slides/cli/commands.py b/manim_slides/cli/commands.py new file mode 100644 index 00000000..e5ad9ebc --- /dev/null +++ b/manim_slides/cli/commands.py @@ -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) diff --git a/manim_slides/commons.py b/manim_slides/cli/commons.py similarity index 51% rename from manim_slides/commons.py rename to manim_slides/cli/commons.py index e5eebf1e..e9b0aec0 100644 --- a/manim_slides/commons.py +++ b/manim_slides/cli/commons.py @@ -4,8 +4,9 @@ import click from click import Context, Parameter -from .defaults import CONFIG_PATH, FOLDER_PATH -from .logger import logger +from ..core.config import list_presentation_configs +from ..core.defaults import CONFIG_PATH, FOLDER_PATH +from ..core.logger import logger F = Callable[..., Any] Wrapper = Callable[[F], F] @@ -88,6 +89,68 @@ def callback(ctx: Context, param: Parameter, value: Path) -> Path: callback=callback, help="Set slides folder.", show_default=True, + is_eager=True, # Needed to expose its value to other callbacks ) 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) diff --git a/manim_slides/templates/__init__.py b/manim_slides/cli/convert/__init__.py similarity index 100% rename from manim_slides/templates/__init__.py rename to manim_slides/cli/convert/__init__.py diff --git a/manim_slides/convert.py b/manim_slides/cli/convert/commands.py similarity index 97% rename from manim_slides/convert.py rename to manim_slides/cli/convert/commands.py index 774f7998..ce96f2e9 100644 --- a/manim_slides/convert.py +++ b/manim_slides/cli/convert/commands.py @@ -33,14 +33,18 @@ from pydantic_extra_types.color import Color 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 .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: + """ + Open a file with the default application. + + :param file: The file to open. + """ system = platform.system() if system == "Darwin": subprocess.call(("open", str(file))) @@ -134,6 +138,7 @@ class Str(str): # This fixes pickling issue on Python 3.8 __reduce_ex__ = str.__reduce_ex__ + # TODO: do we still need this? @classmethod def __get_pydantic_core_schema__( @@ -381,6 +386,11 @@ def load_template(self) -> str: return resources.files(templates).joinpath("revealjs.html").read_text() def open(self, file: Path) -> bool: + """ + Open the HTML file inside a web browser. + + :param path: The path to the HTML file. + """ return webbrowser.open(file.absolute().as_uri()) def convert_to(self, dest: Path) -> None: @@ -635,7 +645,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: @click.command() -@click.argument("scenes", nargs=-1) +@scenes_argument @folder_path_option @click.argument("dest", type=click.Path(dir_okay=False, path_type=Path)) @click.option( @@ -674,7 +684,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: @show_config_options @verbosity_option def convert( - scenes: list[str], + scenes: list[Path], folder: Path, dest: Path, to: str, @@ -684,7 +694,7 @@ def convert( template: Optional[Path], ) -> None: """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: if to == "auto": diff --git a/manim_slides/cli/convert/templates/__init__.py b/manim_slides/cli/convert/templates/__init__.py new file mode 100644 index 00000000..e7a0f2fc --- /dev/null +++ b/manim_slides/cli/convert/templates/__init__.py @@ -0,0 +1 @@ +"""Manim Slides conversion templates.""" diff --git a/manim_slides/templates/revealjs.html b/manim_slides/cli/convert/templates/revealjs.html similarity index 100% rename from manim_slides/templates/revealjs.html rename to manim_slides/cli/convert/templates/revealjs.html diff --git a/manim_slides/cli/present/__init__.py b/manim_slides/cli/present/__init__.py new file mode 100644 index 00000000..ae8c1e70 --- /dev/null +++ b/manim_slides/cli/present/__init__.py @@ -0,0 +1 @@ +"""Manim Slides' presentation commands.""" diff --git a/manim_slides/present/__init__.py b/manim_slides/cli/present/commands.py similarity index 71% rename from manim_slides/present/__init__.py rename to manim_slides/cli/present/commands.py index f566890e..e4ac4de3 100644 --- a/manim_slides/present/__init__.py +++ b/manim_slides/cli/present/commands.py @@ -7,9 +7,14 @@ from click import Context, Parameter from pydantic import ValidationError -from ..commons import config_path_option, folder_path_option, verbosity_option -from ..config import Config, PresentationConfig -from ..logger import logger +from ...core.config import Config, PresentationConfig, list_presentation_configs +from ...core.logger import logger +from ..commons import ( + config_path_option, + folder_path_option, + scenes_argument, + verbosity_option, +) PREFERRED_QT_VERSIONS = ("6.5.1", "6.5.2") @@ -35,83 +40,10 @@ def warn_if_non_desirable_pyside6_version() -> None: @verbosity_option def list_scenes(folder: Path) -> 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: Path) -> list[str]: - """List available scenes in given directory.""" - scenes = [] - - for filepath in folder.glob("*.json"): - try: - _ = PresentationConfig.from_file(filepath) - scenes.append(filepath.stem) - except ( - Exception - ) as e: # Could not parse this file as a proper presentation config - logger.warn( - f"Something went wrong with parsing presentation config `{filepath}`: {e}" - ) - - logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.") - - return scenes - - -def prompt_for_scenes(folder: Path) -> list[str]: - """Prompt the user to select scenes within a given folder.""" - scene_choices = dict(enumerate(_list_scenes(folder), start=1)) - - for i, scene in scene_choices.items(): - click.secho(f"{i}: {scene}", fg="green") - - click.echo() - - click.echo("Choose number corresponding to desired scene/arguments.") - click.echo("(Use comma separated list for multiple entries)") - - def value_proc(value: Optional[str]) -> list[str]: - indices = list(map(int, (value or "").strip().replace(" ", "").split(","))) - - if not all(0 < i <= len(scene_choices) for i in indices): - raise click.UsageError("Please only enter numbers displayed on the screen.") - - return [scene_choices[i] for i in indices] - - if len(scene_choices) == 0: - raise click.UsageError( - "No scenes were found, are you in the correct directory?" - ) - - while True: - try: - scenes = click.prompt("Choice(s)", value_proc=value_proc) - return scenes # type: ignore - except ValueError as e: - raise click.UsageError(str(e)) from None - - -def get_scenes_presentation_config( - scenes: list[str], folder: Path -) -> list[PresentationConfig]: - """Return a list of presentation configurations based on the user input.""" - if len(scenes) == 0: - scenes = prompt_for_scenes(folder) - - presentation_configs = [] - for scene in scenes: - config_file = folder / f"{scene}.json" - if not config_file.exists(): - raise click.UsageError( - f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class" - ) - try: - presentation_configs.append(PresentationConfig.from_file(config_file)) - except ValidationError as e: - raise click.UsageError(str(e)) from None - - return presentation_configs + scene_names = [path.stem for path in list_presentation_configs(folder)] + 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 start_at_callback( @@ -147,7 +79,7 @@ def str_to_int_or_none(value: str) -> Optional[int]: @click.command() -@click.argument("scenes", nargs=-1) +@scenes_argument @config_path_option @folder_path_option @click.option("--start-paused", is_flag=True, help="Start paused.") @@ -253,7 +185,7 @@ def str_to_int_or_none(value: str) -> Optional[int]: @click.help_option("-h", "--help") @verbosity_option def present( - scenes: list[str], + scenes: list[Path], config_path: Path, folder: Path, start_paused: bool, @@ -285,7 +217,7 @@ def present( if skip_all: 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(): try: diff --git a/manim_slides/present/player.py b/manim_slides/cli/present/player.py similarity index 100% rename from manim_slides/present/player.py rename to manim_slides/cli/present/player.py diff --git a/manim_slides/qt_utils.py b/manim_slides/cli/qt_utils.py similarity index 100% rename from manim_slides/qt_utils.py rename to manim_slides/cli/qt_utils.py diff --git a/manim_slides/cli/render/__init__.py b/manim_slides/cli/render/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/manim_slides/render.py b/manim_slides/cli/render/commands.py similarity index 70% rename from manim_slides/render.py rename to manim_slides/cli/render/commands.py index 1d069b0d..597cf264 100644 --- a/manim_slides/render.py +++ b/manim_slides/cli/render/commands.py @@ -1,14 +1,4 @@ -""" -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. -""" +"""Manim Slides' rendering commands.""" import subprocess import sys @@ -44,10 +34,22 @@ def render(ce: bool, gl: bool, args: tuple[str, ...]) -> None: Use ``manim-slides render --help`` to see help information for 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: raise click.UsageError("You cannot specify both --CE and --GL renderers.") if gl: subprocess.run([sys.executable, "-m", "manimlib", *args]) else: - subprocess.run([sys.executable, "-m", "manim", "render", *args]) + from manim.cli.render.commands import render as render_ce + + render_ce(args, standalone_mode=False) diff --git a/manim_slides/resources.py b/manim_slides/cli/resources.py similarity index 100% rename from manim_slides/resources.py rename to manim_slides/cli/resources.py diff --git a/manim_slides/cli/wizard/__init__.py b/manim_slides/cli/wizard/__init__.py new file mode 100644 index 00000000..a6513e66 --- /dev/null +++ b/manim_slides/cli/wizard/__init__.py @@ -0,0 +1 @@ +"""Manim Slides' wizard.""" diff --git a/manim_slides/wizard/__init__.py b/manim_slides/cli/wizard/commands.py similarity index 86% rename from manim_slides/wizard/__init__.py rename to manim_slides/cli/wizard/commands.py index 0dcbc3cb..6fe87432 100644 --- a/manim_slides/wizard/__init__.py +++ b/manim_slides/cli/wizard/commands.py @@ -3,10 +3,9 @@ import click +from ...core.config import Config +from ...core.logger import logger from ..commons import config_options, verbosity_option -from ..config import Config -from ..defaults import CONFIG_PATH -from ..logger import logger @click.command() @@ -37,7 +36,7 @@ def _init( mode. """ 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: choice = click.prompt( @@ -57,7 +56,7 @@ def _init( if force: logger.debug(f"Overwriting `{config_path}` if exists") elif merge: - logger.debug("Merging new config into `{config_path}`") + logger.debug(f"Merging new config into `{config_path}`") if not skip_interactive: if config_path.exists(): @@ -82,4 +81,4 @@ def _init( 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}`") diff --git a/manim_slides/wizard/wizard.py b/manim_slides/cli/wizard/wizard.py similarity index 100% rename from manim_slides/wizard/wizard.py rename to manim_slides/cli/wizard/wizard.py diff --git a/manim_slides/config.py b/manim_slides/core/config.py similarity index 66% rename from manim_slides/config.py rename to manim_slides/core/config.py index b2b44183..c80f1d2c 100644 --- a/manim_slides/config.py +++ b/manim_slides/core/config.py @@ -1,3 +1,5 @@ +"""Manim Slides' configuration tools.""" + import json import shutil from functools import wraps @@ -13,6 +15,7 @@ FilePath, PositiveInt, PrivateAttr, + ValidationError, field_validator, model_validator, ) @@ -24,28 +27,54 @@ 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: - 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: - 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: + """ + Emit this signal and call each of the attached receivers. + + :param args: Positional arguments passed to each receiver. + """ for receiver in self.__receivers: receiver(*args) 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}") 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: list[PositiveInt] = Field(unique=True) name: Optional[str] = None @@ -63,6 +92,7 @@ def set_ids(self, *ids: int) -> None: self.ids = list(set(ids)) def match(self, key_id: int) -> bool: + """Return whether a given key id matches this key.""" m = key_id in self.ids if m: @@ -136,6 +166,7 @@ class Config(BaseModel): # type: ignore[misc] """General Manim Slides config.""" keys: Keys = Field(default_factory=Keys) + """The key mapping.""" @classmethod def from_file(cls, path: Path) -> "Config": @@ -143,11 +174,16 @@ def from_file(cls, path: Path) -> "Config": return cls.model_validate(rtoml.load(path)) # type: ignore 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) 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) return self @@ -156,11 +192,17 @@ class BaseSlideConfig(BaseModel): # type: ignore """Base class for slide config.""" loop: bool = False + """Whether this slide should loop.""" auto_next: bool = False + """Whether this slide is skipped upon completion.""" playback_rate: float = 1.0 + """The speed at which the animation is played (1.0 is normal).""" reversed_playback_rate: float = 1.0 + """The speed at which the reversed animation is played.""" notes: str = "" + """The notes attached to this slide.""" dedent_notes: bool = True + """Whether to automatically remove any leading indentation in the notes.""" @classmethod def wrapper(cls, arg_name: str) -> Callable[..., Any]: @@ -172,7 +214,11 @@ def wrapper(cls, arg_name: str) -> Callable[..., Any]: The wrapped function must follow two criteria: - its last parameter must be ``**kwargs`` (or equivalent); - and its second last parameter must be ````. + + :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]: @wraps(fun) @@ -209,6 +255,12 @@ def __wrapper__(*args: Any, **kwargs: Any) -> Any: # noqa: N807 def apply_dedent_notes( cls, base_slide_config: "BaseSlideConfig" ) -> "BaseSlideConfig": + """ + Remove indentation from notes, if specified. + + :param base_slide_config: The current config. + :return: The config, optionally modified. + """ if base_slide_config.dedent_notes: base_slide_config.notes = dedent(base_slide_config.notes) @@ -219,7 +271,9 @@ class PreSlideConfig(BaseSlideConfig): """Slide config to be used prior to rendering.""" start_animation: int + """The index of the first animation.""" end_animation: int + """The index after the last animation.""" @classmethod def from_base_slide_config_and_animation_indices( @@ -228,6 +282,13 @@ def from_base_slide_config_and_animation_indices( start_animation: int, end_animation: int, ) -> "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( start_animation=start_animation, end_animation=end_animation, @@ -237,6 +298,12 @@ def from_base_slide_config_and_animation_indices( @field_validator("start_animation", "end_animation") @classmethod 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: raise ValueError("Animation index (start or end) cannot be negative") return v @@ -246,6 +313,12 @@ def index_is_posint(cls, v: int) -> int: def start_animation_is_before_end( cls, pre_slide_config: "PreSlideConfig" ) -> "PreSlideConfig": + """ + Validate that start and end animation indices satisfy `start < end`. + + :param pre_slide_config: The current config. + :return: The config, if indices are valid. + """ if pre_slide_config.start_animation >= pre_slide_config.end_animation: if pre_slide_config.start_animation == pre_slide_config.end_animation == 0: raise ValueError( @@ -271,7 +344,9 @@ class SlideConfig(BaseSlideConfig): """Slide config to be used after rendering.""" file: FilePath + """The file containing the animation.""" rev_file: FilePath + """The file containing the reversed animation.""" @classmethod def from_pre_slide_config_and_files( @@ -281,13 +356,22 @@ def from_pre_slide_config_and_files( class PresentationConfig(BaseModel): # type: ignore[misc] + """Presentation config that contains all necessary information for a presentation.""" + slides: list[SlideConfig] = Field(min_length=1) + """The non-empty list of slide configs.""" resolution: tuple[PositiveInt, PositiveInt] = (1920, 1080) + """The resolution of the animation files.""" background_color: Color = "black" + """The background color of the animation files.""" @classmethod 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: obj = json.load(f) @@ -304,7 +388,11 @@ def from_file(cls, path: Path) -> "PresentationConfig": return cls.model_validate(obj) # type: ignore 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: f.write(self.model_dump_json(indent=2)) @@ -315,7 +403,15 @@ def copy_to( include_reversed: bool = True, prefix: str = "", ) -> "PresentationConfig": - """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. + """ + slides = [] for slide_config in self.slides: file = slide_config.file rev_file = slide_config.rev_file @@ -323,13 +419,42 @@ def copy_to( dest = folder / f"{prefix}{file.name}" rev_dest = folder / f"{prefix}{rev_file.name}" - slide_config.file = dest - slide_config.rev_file = rev_dest + slides.append(slide_config.model_copy(file=dest, rev_file=rev_dest)) if not use_cached or not dest.exists(): shutil.copy(file, dest) 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) - return self + return self.model_copy(slides=slides) + + +def list_presentation_configs(folder: Path) -> list[Path]: + """ + List all presentation configs in a given folder. + + :param folder: The folder to search the presentation configs. + :return: The list of paths that map to valid presentation configs. + """ + paths = [] + + for filepath in folder.glob("*.json"): + try: + _ = PresentationConfig.from_file(filepath) + paths.append(filepath) + except ( + ValidationError, + json.JSONDecodeError, + ) as e: # Could not parse this file as a proper presentation config + logger.warn( + f"Something went wrong with parsing presentation config `{filepath}`: {e}." + ) + + logger.debug( + f"Found {len(paths)} valid presentation configuration files in `{folder}`." + ) + + return paths diff --git a/manim_slides/defaults.py b/manim_slides/core/defaults.py similarity index 50% rename from manim_slides/defaults.py rename to manim_slides/core/defaults.py index 33e07b79..f08f382e 100644 --- a/manim_slides/defaults.py +++ b/manim_slides/core/defaults.py @@ -1,4 +1,8 @@ +"""Manim Slides' defaults.""" + from pathlib import Path FOLDER_PATH: Path = Path("./slides") +"""Folder where slides are stored.""" CONFIG_PATH: Path = Path(".manim-slides.toml") +"""Path to local Manim Slides config.""" diff --git a/manim_slides/logger.py b/manim_slides/core/logger.py similarity index 85% rename from manim_slides/logger.py rename to manim_slides/core/logger.py index 877b5baf..db46a97f 100644 --- a/manim_slides/logger.py +++ b/manim_slides/core/logger.py @@ -31,7 +31,11 @@ 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 rich_handler = RichHandler( show_time=True, @@ -47,6 +51,5 @@ def make_logger() -> logging.Logger: return logger -make_logger() - -logger = logging.getLogger("manim-slides") +logger = make_logger() +"""The logger instance used across this project.""" diff --git a/manim_slides/utils.py b/manim_slides/core/utils.py similarity index 100% rename from manim_slides/utils.py rename to manim_slides/core/utils.py diff --git a/manim_slides/slide/__init__.py b/manim_slides/slide/__init__.py index c0388ed5..d8e713ca 100644 --- a/manim_slides/slide/__init__.py +++ b/manim_slides/slide/__init__.py @@ -1,10 +1,12 @@ -__all__ = [ +"""Slides module with logic to either import ManimCE or ManimGL.""" + +__all__ = ( "MANIM", "MANIMGL", "API_NAME", "Slide", "ThreeDSlide", -] +) import os @@ -14,10 +16,10 @@ class ManimApiNotFoundError(ImportError): """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: - super().__init__(self._msg) + def __init__(self, api: str) -> None: + super().__init__(self._msg.format(api=api)) API_NAMES = { @@ -26,9 +28,12 @@ def __init__(self) -> None: "manimlib": "manimlib", "manimgl": "manimlib", } +"""Allowed values for API.""" MANIM_API: str = "MANIM_API" +"""API environ variable name.""" FORCE_MANIM_API: str = "FORCE_" + MANIM_API +"""FORCE API environ variable name.""" API: str = os.environ.get(MANIM_API, "manim").lower() @@ -53,11 +58,14 @@ def __init__(self) -> None: try: from .manim import Slide, ThreeDSlide except ImportError as e: - raise ManimApiNotFoundError from e + raise ManimApiNotFoundError("manim") from e elif MANIMGL: try: from .manimlib import Slide, ThreeDSlide except ImportError as e: - raise ManimApiNotFoundError from e + raise ManimApiNotFoundError("manimlib") from e else: - raise ManimApiNotFoundError + raise ValueError( + "This error should never occur. " + "Please report an issue on GitHub if you encounter it." + ) diff --git a/manim_slides/slide/base.py b/manim_slides/slide/base.py index 4c35b0d4..4fe277a3 100644 --- a/manim_slides/slide/base.py +++ b/manim_slides/slide/base.py @@ -1,6 +1,8 @@ +"""Base class for the Slide class.""" + from __future__ import annotations -__all__ = ["BaseSlide"] +__all__ = ("BaseSlide",) import platform from abc import abstractmethod @@ -14,10 +16,15 @@ import numpy as np from tqdm import tqdm -from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig -from ..defaults import FOLDER_PATH -from ..logger import logger -from ..utils import concatenate_video_files, merge_basenames, reverse_video_file +from ..core.config import ( + BaseSlideConfig, + PresentationConfig, + 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 if TYPE_CHECKING: diff --git a/manim_slides/slide/manim.py b/manim_slides/slide/manim.py index d3db2355..60a6f555 100644 --- a/manim_slides/slide/manim.py +++ b/manim_slides/slide/manim.py @@ -1,3 +1,5 @@ +"""Manim's implementation of the Slide class.""" + from pathlib import Path from typing import Any, Optional @@ -5,7 +7,7 @@ from manim.renderer.opengl_renderer import OpenGLRenderer from manim.utils.color import rgba_to_color -from ..config import BaseSlideConfig +from ..core.config import BaseSlideConfig from .base import BaseSlide diff --git a/manim_slides/slide/manimlib.py b/manim_slides/slide/manimlib.py index 0aba17e3..74d80dc4 100644 --- a/manim_slides/slide/manimlib.py +++ b/manim_slides/slide/manimlib.py @@ -1,3 +1,5 @@ +"""ManimGL's implementation of the Slide class.""" + from pathlib import Path from typing import Any, ClassVar, Optional diff --git a/pyproject.toml b/pyproject.toml index ae0ad5be..2a10a5ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ tests = [ ] [project.scripts] -manim-slides = "manim_slides.__main__:cli" +manim-slides = "manim_slides.cli.commands:main" [project.urls] Changelog = "https://github.com/jeertmans/manim-slides/releases" @@ -186,10 +186,9 @@ env = [ ] [tool.ruff] -extend-exclude = ["manim_slides/resources.py"] +extend-exclude = ["manim_slides/cli/resources.py"] extend-include = ["*.ipynb"] line-length = 88 -target-version = "py39" [tool.ruff.lint] extend-ignore = [