Compare commits

..

9 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
0c6cd67038 chore(dev): move dev-dependencies inside dependency-groups (#542)
* chore(dev): move dev-dependencies inside dependency-groups

* fix(ci): ci was not broken
2025-04-18 14:15:30 +02:00
a5412a8df2 chore(deps): pre-commit autoupdate (#541)
* chore(deps): pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.2 → v0.11.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.2...v0.11.5)

* chore(fmt): auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-15 12:48:36 +02:00
e9480c9bc7 chore(docs): update features table 2025-04-03 11:19:06 +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
44 changed files with 520 additions and 195 deletions

View File

@ -96,7 +96,7 @@ jobs:
uses: ssciwr/setup-mesa-dist-win@v2
- name: Run pytest
run: uv run --python ${{ matrix.pyversion }} --frozen --extra tests pytest
run: uv run --python ${{ matrix.pyversion }} --frozen --group tests --no-dev pytest
- name: Upload to codecov.io
uses: codecov/codecov-action@v5

View File

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

View File

@ -6,13 +6,13 @@ build:
apt_packages:
- libpango1.0-dev
- ffmpeg
jobs:
post_create_environment:
- asdf plugin add uv
- asdf install uv latest
- asdf global uv latest
- UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --group docs --no-dev --no-cache
sphinx:
builder: html
configuration: docs/source/conf.py
fail_on_warning: true
python:
install:
- method: pip
path: .
extra_requirements:
- docs

View File

@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(unreleased)=
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.5.1...HEAD)
(unreleased-chore)=
### Chore
- Moved `docs` and `tests` extras, as well as `dev-dependencies`,
inside groups in `dependency-groups`. This could break existing code
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**.
[#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](https://github.com/jeertmans/manim-slides/compare/v5.5.0...v5.5.1)
@ -24,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Previously, it was not possible to pause HTML slides, which can be very annoying
when trying to explain something.
[#539](https://github.com/jeertmans/manim-slides/pull/539)
(v5.5.0)=
## [v5.5.0](https://github.com/jeertmans/manim-slides/compare/v5.4.2...v5.5.0)

View File

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

View File

@ -32,7 +32,7 @@ and development dependencies. If not already, please install this tool.
With uv, installation becomes straightforward:
```bash
uv sync --all-extras
uv sync
```
:::{note}

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

@ -10,20 +10,22 @@ The following summarizes the different presentation features Manim Slides offers
| :--- | :---: | :---: | :---: | :---: |
| Basic navigation through slides | Yes | Yes | Yes | Yes (static image) |
| Replay slide | Yes | No | No | N/A |
| Pause animation | Yes | No | No | N/A |
| Pause animation | Yes | Yes | No | N/A |
| Play slide in reverse | Yes | No | No | N/A |
| Slide count | Yes | Yes (optional) | Yes (optional) | N/A |
| Needs Python with Manim Slides installed | Yes | No | No | No
| Requires internet access | No | Yes | No | No |
| Requires internet access | No | Depends[^1] | No | No |
| Auto. play slides | Yes | Yes | Yes | N/A |
| Loops support | Yes | Yes | Yes | N/A |
| Fully customizable | No | Yes (`--use-template` option) | No | No |
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^1] | None |
| Works cross-platforms | Yes | Yes | Partly[^1][^2] | Yes |
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^2] | None |
| Works cross-platforms | Yes | Yes | Partly[^2][^3] | Yes |
:::
[^1]: If you encounter a problem where slides do not automatically play or loops do not work,
[^1]: By default, HTML assets are loaded from the internet, but they can be
pre-downloaded and embedded in the HTML file at conversion time.
[^2]: If you encounter a problem where slides do not automatically play or loops do not work,
please
[file an issue on GitHub](https://github.com/jeertmans/manim-slides/issues/new/choose).
[^2]: PowerPoint online does not seem to support automatic playing of videos,
[^3]: PowerPoint online does not seem to support automatic playing of videos,
so you need LibreOffice Impress on Linux platforms.

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,6 +1,6 @@
# Manim Slides' Sphinx directive
```{eval-rst}
.. automodule:: manim_slides.docs.manim_slides_directive
.. automodule:: manim_slides.sphinxext.manim_slides_directive
: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
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,11 +1,8 @@
import json
import click
import requests
from click_default_group import DefaultGroup
"""Manim Slides' main entrypoint."""
from .__version__ import __version__
from .checkhealth import checkhealth
from .cli.commands import main
from .convert import convert
from .logger import logger
from .present import list_scenes, present
@ -72,4 +69,4 @@ cli.add_command(render)
cli.add_command(wizard)
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
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

@ -37,14 +37,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)))
@ -142,6 +146,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__(
@ -547,6 +552,11 @@ class RevealJS(Converter):
return resources.files(templates).joinpath("revealjs.html").read_text()
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())
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.argument("scenes", nargs=-1)
@scenes_argument
@folder_path_option
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
@click.option(
@ -960,7 +970,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,
@ -971,7 +981,7 @@ def convert(
one_file: bool,
) -> 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,
)
@click.command()
@ -18,8 +23,10 @@ from ..logger import logger
@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")
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 _list_scenes(folder: Path) -> list[str]:
@ -130,7 +137,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.")
@ -276,7 +283,7 @@ def present( # noqa: C901
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

@ -1,14 +1,4 @@
"""
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.
"""
"""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", "-w", *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,
conset,
field_serializer,
field_validator,
@ -26,28 +29,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: conset(PositiveInt, min_length=1) # type: ignore[valid-type]
name: Optional[str] = None
@ -58,6 +87,7 @@ class Key(BaseModel): # type: ignore[misc]
self.ids = 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:
@ -135,6 +165,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":
@ -142,11 +173,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
@ -155,11 +191,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."""
skip_animations: bool = False
src: Optional[FilePath] = None
@ -173,7 +215,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 +255,11 @@ class BaseSlideConfig(BaseModel): # type: ignore
def apply_dedent_notes(
self,
) -> "BaseSlideConfig":
"""
Remove indentation from notes, if specified.
:return: The config, optionally modified.
"""
if self.dedent_notes:
self.notes = dedent(self.notes)
@ -219,7 +270,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 +281,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 +297,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
@ -245,7 +311,11 @@ class PreSlideConfig(BaseSlideConfig):
def start_animation_is_before_end(
self,
) -> "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(
"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."""
file: FilePath
"""The file containing the animation."""
rev_file: FilePath
"""The file containing the reversed animation."""
@classmethod
def from_pre_slide_config_and_files(
@ -289,13 +361,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)
@ -312,7 +393,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))
@ -323,7 +408,14 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
include_reversed: bool = True,
prefix: str = "",
) -> 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:
file = slide_config.file
rev_file = slide_config.rev_file
@ -335,4 +427,6 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
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)

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

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

View File

@ -1,10 +1,12 @@
__all__ = [
"""Slides module with logic to either import ManimCE or ManimGL."""
__all__ = (
"API_NAME",
"MANIM",
"MANIMGL",
"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
import shutil
@ -15,10 +17,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

View File

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

View File

@ -2,6 +2,39 @@
build-backend = "hatchling.build"
requires = ["hatchling", "hatch-fancy-pypi-readme"]
[dependency-groups]
dev = [
{include-group = "docs"},
{include-group = "tests"},
"bump-my-version>=0.20.3",
"pre-commit>=3.5.0",
]
docs = [
"manim-slides[magic,manim,pyqt6,sphinx-directive]",
"furo>=2023.5.20",
"ipykernel>=6.25.1",
"myst-parser>=2.0.0",
"nbsphinx>=0.9.2",
"pandoc>=2.3",
"pygments<2.19", # See: https://github.com/ManimCommunity/manim/issues/4104
"sphinx>=7.0.1",
"sphinxcontrib-programoutput>=0.18",
"sphinx-design>=0.6.1",
"sphinx-click>=4.4.0",
"sphinx-copybutton>=0.5.1",
"sphinxext-opengraph>=0.7.5",
]
tests = [
"importlib-metadata>=8.6.1;python_version<'3.10'",
"manim-slides[full,manimgl,pyqt6,pyside6,sphinx-directive]",
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
"pytest-env>=0.8.2",
"pytest-missing-modules>=0.1.0",
"pytest-qt>=4.2.0",
"setuptools>=73.0.1",
]
[project]
authors = [{name = "Jérome Eertmans", email = "jeertmans@icloud.com"}]
classifiers = [
@ -42,21 +75,6 @@ name = "manim-slides"
requires-python = ">=3.9"
[project.optional-dependencies]
docs = [
"manim-slides[magic,manim,pyqt6,sphinx-directive]",
"furo>=2023.5.20",
"ipykernel>=6.25.1",
"myst-parser>=2.0.0",
"nbsphinx>=0.9.2",
"pandoc>=2.3",
"pygments<2.19", # See: https://github.com/ManimCommunity/manim/issues/4104
"sphinx>=7.0.1",
"sphinxcontrib-programoutput>=0.18",
"sphinx-design>=0.6.1",
"sphinx-click>=4.4.0",
"sphinx-copybutton>=0.5.1",
"sphinxext-opengraph>=0.7.5",
]
full = [
"manim-slides[magic,manim,sphinx-directive]",
]
@ -68,18 +86,9 @@ pyqt6-full = ["manim-slides[full,pyqt6]"]
pyside6 = ["pyside6>=6.6.1,!=6.8.1.1"]
pyside6-full = ["manim-slides[full,pyside6]"]
sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"]
tests = [
"importlib-metadata>=8.6.1;python_version<'3.10'",
"manim-slides[full,manimgl,pyqt6,pyside6,sphinx-directive]",
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
"pytest-env>=0.8.2",
"pytest-missing-modules>=0.1.0",
"pytest-qt>=4.2.0",
]
[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"
@ -199,10 +208,9 @@ filterwarnings = [
]
[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 = [
@ -225,10 +233,3 @@ isort = {known-first-party = ["manim_slides", "tests"]}
[tool.ruff.lint.per-file-ignores]
"docs/source/reference/magic_example.ipynb" = ["F403", "F405"]
"tests/test_slide.py" = ["N801"]
[tool.uv]
dev-dependencies = [
"bump-my-version>=0.20.3",
"pre-commit>=3.5.0",
"setuptools>=73.0.1",
]

155
uv.lock generated
View File

@ -1549,27 +1549,6 @@ dependencies = [
]
[package.optional-dependencies]
docs = [
{ name = "docutils" },
{ name = "furo" },
{ name = "ipykernel" },
{ name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "ipython", version = "8.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "manim" },
{ name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "myst-parser", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "nbsphinx" },
{ name = "pandoc" },
{ name = "pygments" },
{ name = "pyqt6" },
{ name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "sphinx-click" },
{ name = "sphinx-copybutton" },
{ name = "sphinx-design" },
{ name = "sphinxcontrib-programoutput" },
{ name = "sphinxext-opengraph" },
]
full = [
{ name = "docutils" },
{ name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
@ -1611,26 +1590,59 @@ sphinx-directive = [
{ name = "docutils" },
{ name = "manim" },
]
tests = [
{ name = "docutils" },
[package.dev-dependencies]
dev = [
{ name = "bump-my-version" },
{ name = "furo" },
{ name = "importlib-metadata", marker = "python_full_version < '3.10'" },
{ name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "ipython", version = "8.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "manim" },
{ name = "manimgl" },
{ name = "pyqt6" },
{ name = "pyside6" },
{ name = "ipykernel" },
{ name = "manim-slides", extra = ["full", "magic", "manim", "manimgl", "pyqt6", "pyside6", "sphinx-directive"] },
{ name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "myst-parser", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "nbsphinx" },
{ name = "pandoc" },
{ name = "pre-commit" },
{ name = "pygments" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-env" },
{ name = "pytest-missing-modules" },
{ name = "pytest-qt" },
{ name = "setuptools" },
{ name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "sphinx-click" },
{ name = "sphinx-copybutton" },
{ name = "sphinx-design" },
{ name = "sphinxcontrib-programoutput" },
{ name = "sphinxext-opengraph" },
]
[package.dev-dependencies]
dev = [
{ name = "bump-my-version" },
{ name = "pre-commit" },
docs = [
{ name = "furo" },
{ name = "ipykernel" },
{ name = "manim-slides", extra = ["magic", "manim", "pyqt6", "sphinx-directive"] },
{ name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "myst-parser", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "nbsphinx" },
{ name = "pandoc" },
{ name = "pygments" },
{ name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "sphinx-click" },
{ name = "sphinx-copybutton" },
{ name = "sphinx-design" },
{ name = "sphinxcontrib-programoutput" },
{ name = "sphinxext-opengraph" },
]
tests = [
{ name = "importlib-metadata", marker = "python_full_version < '3.10'" },
{ name = "manim-slides", extra = ["full", "manimgl", "pyqt6", "pyside6", "sphinx-directive"] },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-env" },
{ name = "pytest-missing-modules" },
{ name = "pytest-qt" },
{ name = "setuptools" },
]
@ -1640,72 +1652,89 @@ requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.12.3" },
{ name = "click", specifier = ">=8.1.3" },
{ name = "click-default-group", specifier = ">=1.2.2" },
{ name = "docutils", marker = "extra == 'docs'", specifier = ">=0.20.1" },
{ name = "docutils", marker = "extra == 'full'", specifier = ">=0.20.1" },
{ name = "docutils", marker = "extra == 'pyqt6-full'", specifier = ">=0.20.1" },
{ name = "docutils", marker = "extra == 'pyside6-full'", specifier = ">=0.20.1" },
{ name = "docutils", marker = "extra == 'sphinx-directive'", specifier = ">=0.20.1" },
{ name = "docutils", marker = "extra == 'tests'", specifier = ">=0.20.1" },
{ name = "furo", marker = "extra == 'docs'", specifier = ">=2023.5.20" },
{ name = "importlib-metadata", marker = "python_full_version < '3.10' and extra == 'tests'", specifier = ">=8.6.1" },
{ name = "ipykernel", marker = "extra == 'docs'", specifier = ">=6.25.1" },
{ name = "ipython", marker = "extra == 'docs'", specifier = ">=8.12.2" },
{ name = "ipython", marker = "extra == 'full'", specifier = ">=8.12.2" },
{ name = "ipython", marker = "extra == 'magic'", specifier = ">=8.12.2" },
{ name = "ipython", marker = "extra == 'pyqt6-full'", specifier = ">=8.12.2" },
{ name = "ipython", marker = "extra == 'pyside6-full'", specifier = ">=8.12.2" },
{ name = "ipython", marker = "extra == 'tests'", specifier = ">=8.12.2" },
{ name = "jinja2", specifier = ">=3.1.2" },
{ name = "lxml", specifier = ">=4.9.2" },
{ name = "manim", marker = "extra == 'docs'", specifier = ">=0.19" },
{ name = "manim", marker = "extra == 'full'", specifier = ">=0.19" },
{ name = "manim", marker = "extra == 'magic'", specifier = ">=0.19" },
{ name = "manim", marker = "extra == 'manim'", specifier = ">=0.19" },
{ name = "manim", marker = "extra == 'pyqt6-full'", specifier = ">=0.19" },
{ name = "manim", marker = "extra == 'pyside6-full'", specifier = ">=0.19" },
{ name = "manim", marker = "extra == 'sphinx-directive'", specifier = ">=0.19" },
{ name = "manim", marker = "extra == 'tests'", specifier = ">=0.19" },
{ name = "manimgl", marker = "extra == 'manimgl'", specifier = ">=1.7.2" },
{ name = "manimgl", marker = "extra == 'tests'", specifier = ">=1.7.2" },
{ name = "myst-parser", marker = "extra == 'docs'", specifier = ">=2.0.0" },
{ name = "nbsphinx", marker = "extra == 'docs'", specifier = ">=0.9.2" },
{ name = "numpy", specifier = ">=1.19" },
{ name = "pandoc", marker = "extra == 'docs'", specifier = ">=2.3" },
{ name = "pillow", specifier = ">=9.5.0" },
{ name = "pydantic", specifier = ">=2.0.1" },
{ name = "pydantic-extra-types", specifier = ">=2.0.0" },
{ name = "pygments", marker = "extra == 'docs'", specifier = "<2.19" },
{ name = "pyqt6", marker = "extra == 'docs'", specifier = ">=6.7.0" },
{ name = "pyqt6", marker = "extra == 'pyqt6'", specifier = ">=6.7.0" },
{ name = "pyqt6", marker = "extra == 'pyqt6-full'", specifier = ">=6.7.0" },
{ name = "pyqt6", marker = "extra == 'tests'", specifier = ">=6.7.0" },
{ name = "pyside6", marker = "extra == 'pyside6'", specifier = ">=6.6.1,!=6.8.1.1" },
{ name = "pyside6", marker = "extra == 'pyside6-full'", specifier = ">=6.6.1,!=6.8.1.1" },
{ name = "pyside6", marker = "extra == 'tests'", specifier = ">=6.6.1,!=6.8.1.1" },
{ name = "pytest", marker = "extra == 'tests'", specifier = ">=7.4.0" },
{ name = "pytest-cov", marker = "extra == 'tests'", specifier = ">=4.1.0" },
{ name = "pytest-env", marker = "extra == 'tests'", specifier = ">=0.8.2" },
{ name = "pytest-missing-modules", marker = "extra == 'tests'", specifier = ">=0.1.0" },
{ name = "pytest-qt", marker = "extra == 'tests'", specifier = ">=4.2.0" },
{ name = "python-pptx", specifier = ">=0.6.21" },
{ name = "qtpy", specifier = ">=2.4.1" },
{ name = "requests", specifier = ">=2.28.1" },
{ name = "rich", specifier = ">=13.3.2" },
{ name = "rtoml", specifier = ">=0.11.0" },
{ name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0.1" },
{ name = "sphinx-click", marker = "extra == 'docs'", specifier = ">=4.4.0" },
{ name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.5.1" },
{ name = "sphinx-design", marker = "extra == 'docs'", specifier = ">=0.6.1" },
{ name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = ">=0.18" },
{ name = "sphinxext-opengraph", marker = "extra == 'docs'", specifier = ">=0.7.5" },
{ name = "tqdm", specifier = ">=4.64.1" },
]
provides-extras = ["docs", "full", "magic", "manim", "manimgl", "pyqt6", "pyqt6-full", "pyside6", "pyside6-full", "sphinx-directive", "tests"]
provides-extras = ["full", "magic", "manim", "manimgl", "pyqt6", "pyqt6-full", "pyside6", "pyside6-full", "sphinx-directive"]
[package.metadata.requires-dev]
dev = [
{ name = "bump-my-version", specifier = ">=0.20.3" },
{ name = "furo", specifier = ">=2023.5.20" },
{ name = "importlib-metadata", marker = "python_full_version < '3.10'", specifier = ">=8.6.1" },
{ name = "ipykernel", specifier = ">=6.25.1" },
{ name = "manim-slides", extras = ["full", "manimgl", "pyqt6", "pyside6", "sphinx-directive"] },
{ name = "manim-slides", extras = ["magic", "manim", "pyqt6", "sphinx-directive"] },
{ name = "myst-parser", specifier = ">=2.0.0" },
{ name = "nbsphinx", specifier = ">=0.9.2" },
{ name = "pandoc", specifier = ">=2.3" },
{ name = "pre-commit", specifier = ">=3.5.0" },
{ name = "pygments", specifier = "<2.19" },
{ name = "pytest", specifier = ">=7.4.0" },
{ name = "pytest-cov", specifier = ">=4.1.0" },
{ name = "pytest-env", specifier = ">=0.8.2" },
{ name = "pytest-missing-modules", specifier = ">=0.1.0" },
{ name = "pytest-qt", specifier = ">=4.2.0" },
{ name = "setuptools", specifier = ">=73.0.1" },
{ name = "sphinx", specifier = ">=7.0.1" },
{ name = "sphinx-click", specifier = ">=4.4.0" },
{ name = "sphinx-copybutton", specifier = ">=0.5.1" },
{ name = "sphinx-design", specifier = ">=0.6.1" },
{ name = "sphinxcontrib-programoutput", specifier = ">=0.18" },
{ name = "sphinxext-opengraph", specifier = ">=0.7.5" },
]
docs = [
{ name = "furo", specifier = ">=2023.5.20" },
{ name = "ipykernel", specifier = ">=6.25.1" },
{ name = "manim-slides", extras = ["magic", "manim", "pyqt6", "sphinx-directive"] },
{ name = "myst-parser", specifier = ">=2.0.0" },
{ name = "nbsphinx", specifier = ">=0.9.2" },
{ name = "pandoc", specifier = ">=2.3" },
{ name = "pygments", specifier = "<2.19" },
{ name = "sphinx", specifier = ">=7.0.1" },
{ name = "sphinx-click", specifier = ">=4.4.0" },
{ name = "sphinx-copybutton", specifier = ">=0.5.1" },
{ name = "sphinx-design", specifier = ">=0.6.1" },
{ name = "sphinxcontrib-programoutput", specifier = ">=0.18" },
{ name = "sphinxext-opengraph", specifier = ">=0.7.5" },
]
tests = [
{ name = "importlib-metadata", marker = "python_full_version < '3.10'", specifier = ">=8.6.1" },
{ name = "manim-slides", extras = ["full", "manimgl", "pyqt6", "pyside6", "sphinx-directive"] },
{ name = "pytest", specifier = ">=7.4.0" },
{ name = "pytest-cov", specifier = ">=4.1.0" },
{ name = "pytest-env", specifier = ">=0.8.2" },
{ name = "pytest-missing-modules", specifier = ">=0.1.0" },
{ name = "pytest-qt", specifier = ">=4.2.0" },
{ name = "setuptools", specifier = ">=73.0.1" },
]