mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-20 12:05:56 +08:00
fix(lib/cli): relative paths, empty slides, and tests (#223)
* fix(lib/cli): relative paths, empty slides, and tests This fixes two issues: 1. Empty slides are now reported as error, to prevent indexing error; 2. Changing the folder path will now produce an absolute path to slides, which was not the case before and would lead to a "file does not exist error". A few tests were also added to cover those * fix(lib): fix from_file, remove useless field, and more * chore(tests): remove print * [pre-commit.ci] 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>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,6 +10,7 @@ __pycache__/
|
|||||||
/.vscode
|
/.vscode
|
||||||
|
|
||||||
slides/
|
slides/
|
||||||
|
!tests/slides/
|
||||||
|
|
||||||
.manim-slides.json
|
.manim-slides.json
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -7,7 +8,14 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, FilePath, PositiveInt, field_validator, model_validator
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
FilePath,
|
||||||
|
PositiveInt,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
from pydantic_extra_types.color import Color
|
from pydantic_extra_types.color import Color
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
@ -71,6 +79,17 @@ class Config(BaseModel): # type: ignore
|
|||||||
PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
|
PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
|
||||||
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
|
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: Path) -> "Config":
|
||||||
|
"""Reads a configuration from a file."""
|
||||||
|
with open(path, "r") as f:
|
||||||
|
return cls.model_validate_json(f.read()) # type: ignore
|
||||||
|
|
||||||
|
def to_file(self, path: Path) -> None:
|
||||||
|
"""Dumps the configuration to a file."""
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(self.model_dump_json(indent=2))
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]:
|
def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]:
|
||||||
ids: Set[int] = set()
|
ids: Set[int] = set()
|
||||||
@ -104,7 +123,7 @@ class SlideConfig(BaseModel): # type: ignore
|
|||||||
start_animation: int
|
start_animation: int
|
||||||
end_animation: int
|
end_animation: int
|
||||||
number: int
|
number: int
|
||||||
terminated: bool = False
|
terminated: bool = Field(False, exclude=True)
|
||||||
|
|
||||||
@field_validator("start_animation", "end_animation")
|
@field_validator("start_animation", "end_animation")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -151,11 +170,31 @@ class SlideConfig(BaseModel): # type: ignore
|
|||||||
|
|
||||||
|
|
||||||
class PresentationConfig(BaseModel): # type: ignore
|
class PresentationConfig(BaseModel): # type: ignore
|
||||||
slides: List[SlideConfig]
|
slides: List[SlideConfig] = Field(min_length=1)
|
||||||
files: List[FilePath]
|
files: List[FilePath]
|
||||||
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
|
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
|
||||||
background_color: Color = "black"
|
background_color: Color = "black"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: Path) -> "PresentationConfig":
|
||||||
|
"""Reads a presentation configuration from a file."""
|
||||||
|
with open(path, "r") as f:
|
||||||
|
obj = json.load(f)
|
||||||
|
|
||||||
|
if files := obj.get("files", None):
|
||||||
|
# First parent is ../slides
|
||||||
|
# so we take the parent of this parent
|
||||||
|
parent = Path(path).parents[1]
|
||||||
|
for i in range(len(files)):
|
||||||
|
files[i] = parent / files[i]
|
||||||
|
|
||||||
|
return cls.model_validate(obj) # type: ignore
|
||||||
|
|
||||||
|
def to_file(self, path: Path) -> None:
|
||||||
|
"""Dumps the presentation configuration to a file."""
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(self.model_dump_json(indent=2))
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def animation_indices_match_files(
|
def animation_indices_match_files(
|
||||||
cls, config: "PresentationConfig"
|
cls, config: "PresentationConfig"
|
||||||
|
@ -786,7 +786,7 @@ def _list_scenes(folder: Path) -> List[str]:
|
|||||||
|
|
||||||
for filepath in folder.glob("*.json"):
|
for filepath in folder.glob("*.json"):
|
||||||
try:
|
try:
|
||||||
_ = PresentationConfig.parse_file(filepath)
|
_ = PresentationConfig.from_file(filepath)
|
||||||
scenes.append(filepath.stem)
|
scenes.append(filepath.stem)
|
||||||
except (
|
except (
|
||||||
Exception
|
Exception
|
||||||
@ -851,7 +851,7 @@ def get_scenes_presentation_config(
|
|||||||
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
presentation_configs.append(PresentationConfig.parse_file(config_file))
|
presentation_configs.append(PresentationConfig.from_file(config_file))
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise click.UsageError(str(e))
|
raise click.UsageError(str(e))
|
||||||
|
|
||||||
@ -1047,7 +1047,7 @@ def present(
|
|||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
try:
|
try:
|
||||||
config = Config.parse_file(config_path)
|
config = Config.from_file(config_path)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise click.UsageError(str(e))
|
raise click.UsageError(str(e))
|
||||||
else:
|
else:
|
||||||
@ -1070,7 +1070,11 @@ def present(
|
|||||||
if start_at[2]:
|
if start_at[2]:
|
||||||
start_at_animation_number = start_at[2]
|
start_at_animation_number = start_at[2]
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
if not QApplication.instance():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
else:
|
||||||
|
app = QApplication.instance()
|
||||||
|
|
||||||
app.setApplicationName("Manim Slides")
|
app.setApplicationName("Manim Slides")
|
||||||
a = App(
|
a = App(
|
||||||
presentations,
|
presentations,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@ -102,7 +102,7 @@ class Wizard(QWidget): # type: ignore
|
|||||||
|
|
||||||
def saveConfig(self) -> None:
|
def saveConfig(self) -> None:
|
||||||
try:
|
try:
|
||||||
Config.parse_obj(self.config.dict())
|
Config.model_validate(self.config.dict())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
msg = QMessageBox()
|
msg = QMessageBox()
|
||||||
msg.setIcon(QMessageBox.Critical)
|
msg.setIcon(QMessageBox.Critical)
|
||||||
@ -130,7 +130,7 @@ class Wizard(QWidget): # type: ignore
|
|||||||
@config_options
|
@config_options
|
||||||
@click.help_option("-h", "--help")
|
@click.help_option("-h", "--help")
|
||||||
@verbosity_option
|
@verbosity_option
|
||||||
def wizard(config_path: str, force: bool, merge: bool) -> None:
|
def wizard(config_path: Path, force: bool, merge: bool) -> None:
|
||||||
"""Launch configuration wizard."""
|
"""Launch configuration wizard."""
|
||||||
return _init(config_path, force, merge, skip_interactive=False)
|
return _init(config_path, force, merge, skip_interactive=False)
|
||||||
|
|
||||||
@ -140,18 +140,18 @@ def wizard(config_path: str, force: bool, merge: bool) -> None:
|
|||||||
@click.help_option("-h", "--help")
|
@click.help_option("-h", "--help")
|
||||||
@verbosity_option
|
@verbosity_option
|
||||||
def init(
|
def init(
|
||||||
config_path: str, force: bool, merge: bool, skip_interactive: bool = False
|
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a new default configuration file."""
|
"""Initialize a new default configuration file."""
|
||||||
return _init(config_path, force, merge, skip_interactive=True)
|
return _init(config_path, force, merge, skip_interactive=True)
|
||||||
|
|
||||||
|
|
||||||
def _init(
|
def _init(
|
||||||
config_path: str, force: bool, merge: bool, skip_interactive: bool = False
|
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Actual initialization code for configuration file, with optional interactive mode."""
|
"""Actual initialization code for configuration file, with optional interactive mode."""
|
||||||
|
|
||||||
if os.path.exists(config_path):
|
if config_path.exists():
|
||||||
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
||||||
|
|
||||||
if not force and not merge:
|
if not force and not merge:
|
||||||
@ -175,8 +175,8 @@ def _init(
|
|||||||
logger.debug("Merging new config into `{config_path}`")
|
logger.debug("Merging new config into `{config_path}`")
|
||||||
|
|
||||||
if not skip_interactive:
|
if not skip_interactive:
|
||||||
if os.path.exists(config_path):
|
if config_path.exists():
|
||||||
config = Config.parse_file(config_path)
|
config = Config.from_file(config_path)
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("Manim Slides Wizard")
|
app.setApplicationName("Manim Slides Wizard")
|
||||||
@ -187,9 +187,8 @@ def _init(
|
|||||||
config = window.config
|
config = window.config
|
||||||
|
|
||||||
if merge:
|
if merge:
|
||||||
config = Config.parse_file(config_path).merge_with(config)
|
config = Config.from_file(config_path).merge_with(config)
|
||||||
|
|
||||||
with open(config_path, "w") as config_file:
|
config.to_file(config_path)
|
||||||
config_file.write(config.json(indent=2))
|
|
||||||
|
|
||||||
click.secho(f"Configuration file successfully saved to `{config_path}`")
|
click.secho(f"Configuration file successfully saved to `{config_path}`")
|
||||||
|
@ -1,3 +1,13 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from manim_slides.logger import make_logger
|
from manim_slides.logger import make_logger
|
||||||
|
|
||||||
_ = make_logger() # This is run so that "PERF" level is created
|
_ = make_logger() # This is run so that "PERF" level is created
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def folder_path() -> Iterator[Path]:
|
||||||
|
yield (Path(__file__).parent / "slides").resolve()
|
||||||
|
32
tests/slides/BasicExample.json
Normal file
32
tests/slides/BasicExample.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"slides": [
|
||||||
|
{
|
||||||
|
"type": "slide",
|
||||||
|
"start_animation": 0,
|
||||||
|
"end_animation": 1,
|
||||||
|
"number": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "loop",
|
||||||
|
"start_animation": 1,
|
||||||
|
"end_animation": 2,
|
||||||
|
"number": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "last",
|
||||||
|
"start_animation": 2,
|
||||||
|
"end_animation": 3,
|
||||||
|
"number": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"slides/files/BasicExample/1413466013_3346521118_223132457.mp4",
|
||||||
|
"slides/files/BasicExample/1672018281_3136302242_2191168284.mp4",
|
||||||
|
"slides/files/BasicExample/1672018281_1369283980_3942561600.mp4"
|
||||||
|
],
|
||||||
|
"resolution": [
|
||||||
|
1920,
|
||||||
|
1080
|
||||||
|
],
|
||||||
|
"background_color": "black"
|
||||||
|
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -97,3 +97,7 @@ class TestPresentationConfig:
|
|||||||
|
|
||||||
def test_bump_to_json(self, presentation_config: PresentationConfig) -> None:
|
def test_bump_to_json(self, presentation_config: PresentationConfig) -> None:
|
||||||
_ = presentation_config.model_dump_json(indent=2)
|
_ = presentation_config.model_dump_json(indent=2)
|
||||||
|
|
||||||
|
def test_empty_presentation_config(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_ = PresentationConfig(slides=[], files=[])
|
||||||
|
93
tests/test_main.py
Normal file
93
tests/test_main.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from manim_slides.__main__ import cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_help() -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
results = runner.invoke(cli, ["-S", "--help"])
|
||||||
|
|
||||||
|
assert results.exit_code == 0
|
||||||
|
|
||||||
|
results = runner.invoke(cli, ["-S", "-h"])
|
||||||
|
|
||||||
|
assert results.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_to_present(folder_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
results = runner.invoke(
|
||||||
|
cli, ["BasicExample", "--folder", str(folder_path), "-s"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_present(folder_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
results = runner.invoke(
|
||||||
|
cli, ["present", "BasicExample", "--folder", str(folder_path), "-s"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert(folder_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
results = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"convert",
|
||||||
|
"BasicExample",
|
||||||
|
"basic_example.html",
|
||||||
|
"--folder",
|
||||||
|
str(folder_path),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_init() -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
results = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"init",
|
||||||
|
"--force",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_scenes(folder_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
results = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"list-scenes",
|
||||||
|
"--folder",
|
||||||
|
str(folder_path),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results.exit_code == 0
|
||||||
|
assert "BasicExample" in results.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_wizard() -> None:
|
||||||
|
# TODO
|
||||||
|
pass
|
Reference in New Issue
Block a user