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:
Jérome Eertmans
2023-07-24 13:58:54 +02:00
committed by GitHub
parent 2b6240c4d3
commit 529a6c534f
14 changed files with 200 additions and 18 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ __pycache__/
/.vscode /.vscode
slides/ slides/
!tests/slides/
.manim-slides.json .manim-slides.json

View File

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

View File

@ -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]
if not QApplication.instance():
app = QApplication(sys.argv) app = QApplication(sys.argv)
else:
app = QApplication.instance()
app.setApplicationName("Manim Slides") app.setApplicationName("Manim Slides")
a = App( a = App(
presentations, presentations,

View File

@ -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}`")

View File

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

View 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"
}

View File

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