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.
This commit is contained in:
Jérome Eertmans
2024-07-23 14:01:59 +02:00
parent c3e1aa0276
commit ae8d5b6aab
32 changed files with 394 additions and 220 deletions

View File

@ -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.

View File

@ -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
```

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
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!

View File

@ -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.

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
from types import ModuleType
from typing import Any
@ -8,9 +15,7 @@ from .__version__ import __version__
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__(

View File

@ -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()

View File

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

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
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 folder_path_option(function: F) -> F:
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)

View File

@ -33,14 +33,18 @@ from pydantic_core import CoreSchema, core_schema
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 @@ class RevealJS(Converter):
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 show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
@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 show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
@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":

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 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 start_at_callback(
@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 start_at_callback(
@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:

View File

View File

@ -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)

View File

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

View File

@ -3,10 +3,9 @@ from pathlib import Path
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}`")

View File

@ -1,3 +1,5 @@
"""Manim Slides' configuration tools."""
import json
import shutil
from functools import wraps
@ -13,6 +15,7 @@ from pydantic import (
FilePath,
PositiveInt,
PrivateAttr,
ValidationError,
field_validator,
model_validator,
)
@ -24,28 +27,54 @@ Receiver = Callable[..., Any]
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,9 @@ class Key(BaseModel): # type: ignore[misc]
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 +168,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 +176,16 @@ class Config(BaseModel): # type: ignore[misc]
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 +194,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 +216,11 @@ class BaseSlideConfig(BaseModel): # type: ignore
The wrapped function must follow two criteria:
- its last parameter must be ``**kwargs`` (or equivalent);
- 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]:
@wraps(fun)
@ -209,6 +257,12 @@ class BaseSlideConfig(BaseModel): # type: ignore
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 +273,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 +284,13 @@ class PreSlideConfig(BaseSlideConfig):
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 +300,12 @@ class PreSlideConfig(BaseSlideConfig):
@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 +315,12 @@ class PreSlideConfig(BaseSlideConfig):
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 +346,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 +358,22 @@ class SlideConfig(BaseSlideConfig):
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 +390,11 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
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 +405,15 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
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 +421,42 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
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

View File

@ -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."""

View File

@ -31,7 +31,11 @@ HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
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."""

View File

@ -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 @@ import sys
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 @@ API_NAMES = {
"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 @@ if MANIM:
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."
)

View File

@ -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 @@ from typing import (
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:

View File

@ -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 import Scene, ThreeDScene, config
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

View File

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

View File

@ -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"