Skip to content

Module creation and CLI interface cleaning #4342

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions manim/cli/cli_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from __future__ import annotations

import re
import sys

from manim._config import console
from manim.constants import CHOOSE_NUMBER_MESSAGE

ESCAPE_CHAR = "CTRL+Z" if sys.platform == "win32" else "CTRL+D"
NOT_FOUND_IMPORT = "Import statement for Manim was not found. Importing is added."

INPUT_CODE_ENTER = f"Enter the animation code & end with an EOF: {ESCAPE_CHAR}:"


def code_input_prompt() -> str:
console.print(INPUT_CODE_ENTER)
code = sys.stdin.read()
if len(code.strip()) == 0:
raise ValueError("Empty input of code")

if not code.startswith("from manim import"):
console.print(NOT_FOUND_IMPORT, style="logging.level.warning")
code = "from manim import *\n" + code
return code


def prompt_user_with_choice(choise_list: list[str]) -> list[int]:
"""Prompt user with chooses and return indices of choised items"""
max_index = len(choise_list)
for count, name in enumerate(choise_list, 1):
console.print(f"{count}: {name}", style="logging.level.info")

user_input = console.input(CHOOSE_NUMBER_MESSAGE)
# CTRL + Z, CTRL + D, Remove common EOF escape chars
cleaned = user_input.strip().removesuffix("\x1a").removesuffix("\x04")
result = re.split(r"\s*,\s*", cleaned)

if not all(a.isnumeric() for a in result):
raise ValueError("Invalid non-numeric input: ", user_input)

indices = [int(i_str.strip()) - 1 for i_str in result]
if all(a <= max_index >= 0 for a in indices):
return indices
else:
raise KeyError("One or more chooses is outside of range")
210 changes: 159 additions & 51 deletions manim/cli/render/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@

from __future__ import annotations

import http.client
import json
import os
import sys
import urllib.error
import urllib.request
import time
from argparse import Namespace
from pathlib import Path
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast

import cloup

Expand All @@ -27,15 +26,33 @@
logger,
tempconfig,
)
from manim.cli.cli_utils import code_input_prompt, prompt_user_with_choice
from manim.cli.render.ease_of_access_options import ease_of_access_options
from manim.cli.render.global_options import global_options
from manim.cli.render.output_options import output_options
from manim.cli.render.render_options import render_options
from manim.constants import EPILOG, RendererType
from manim.utils.module_ops import scene_classes_from_file
from manim.constants import (
EPILOG,
INVALID_NUMBER_MESSAGE,
NO_SCENE_MESSAGE,
SCENE_NOT_FOUND_MESSAGE,
RendererType,
)
from manim.scene.scene_file_writer import SceneFileWriter
from manim.utils.module_ops import (
module_from_file,
module_from_text,
search_classes_from_module,
)

__all__ = ["render"]

if TYPE_CHECKING:
from ...scene.scene import Scene

INPUT_CODE_RENDER = "Rendering animation from typed code"
MULTIPLE_SCENES = "Found multiple scenes. Choose at least one to continue"


class ClickArgs(Namespace):
def __init__(self, args: dict[str, Any]) -> None:
Expand Down Expand Up @@ -75,50 +92,41 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]:

SCENES is an optional list of scenes in the file.
"""
if kwargs["save_as_gif"]:
logger.warning("--save_as_gif is deprecated, please use --format=gif instead!")
kwargs["format"] = "gif"

if kwargs["save_pngs"]:
logger.warning("--save_pngs is deprecated, please use --format=png instead!")
kwargs["format"] = "png"

if kwargs["show_in_file_browser"]:
logger.warning(
"The short form of show_in_file_browser is deprecated and will be moved to support --format.",
)
warn_and_change_deprecated_args(kwargs)

click_args = ClickArgs(kwargs)
if kwargs["jupyter"]:
return click_args

config.digest_args(click_args)
file = Path(config.input_file)

scenes = solve_rendrered_scenes(config.input_file)

if config.renderer == RendererType.OPENGL:
from manim.renderer.opengl_renderer import OpenGLRenderer

try:
renderer = OpenGLRenderer()
keep_running = True
while keep_running:
for SceneClass in scene_classes_from_file(file):
for SceneClass in scenes:
with tempconfig({}):
scene = SceneClass(renderer)
rerun = scene.render()
if rerun or config["write_all"]:
if rerun or config.write_all:
renderer.num_plays = 0
continue
else:
keep_running = False
break
if config["write_all"]:
if config.write_all:
keep_running = False

except Exception:
error_console.print_exception()
sys.exit(1)
else:
for SceneClass in scene_classes_from_file(file):
for SceneClass in scenes:
try:
with tempconfig({}):
scene = SceneClass()
Expand All @@ -128,34 +136,134 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]:
sys.exit(1)

if config.notify_outdated_version:
manim_info_url = "https://pypi.org/pypi/manim/json"
warn_prompt = "Cannot check if latest release of manim is installed"
version_notification()

return kwargs


def version_notification() -> None:
"""Fetch version from Internet or use cache"""
file = Path(os.path.dirname(__file__)) / ".version_cache.log"
stable = None

if file.exists():
with file.open() as f:
last_time = f.readline()
if not time.time() - int(last_time) > 86_400:
stable = f.readline()

if stable is None:
new_stable = fetch_version()
if new_stable:
with file.open(mode="w") as f:
f.write(str(int(time.time())) + "\n" + str(new_stable))
stable = new_stable

if stable != __version__:
console.print(
f"You are using manim version [red]v{__version__}[/red], but version [green]v{stable}[/green] is available.",
)
console.print(
"You should consider upgrading via [yellow]pip install -U manim[/yellow]",
)


def fetch_version() -> str | None:
import http.client
import urllib.error
import urllib.request

manim_info_url = "https://pypi.org/pypi/manim/json"
warn_prompt = "Cannot check if latest release of manim is installed"
request = urllib.request.Request(manim_info_url)
try:
with urllib.request.urlopen(request, timeout=10) as response:
response = cast(http.client.HTTPResponse, response)
json_data = json.loads(response.read())

except (Exception, urllib.error.HTTPError, urllib.error.URLError) as e:
logger.debug(f"{e}: {warn_prompt} ")
return None
except json.JSONDecodeError:
logger.debug(f"Error while decoding JSON from [{manim_info_url}]: warn_prompt")
return None
else:
return str(json_data["info"]["version"])


def warn_and_change_deprecated_args(kwargs: dict[str, Any]) -> None:
"""Helper function to print info about deprecated functions
and mutate inserted dict to contain proper format
"""
if kwargs["save_as_gif"]:
logger.warning("--save_as_gif is deprecated, please use --format=gif instead!")
kwargs["format"] = "gif"

if kwargs["save_pngs"]:
logger.warning("--save_pngs is deprecated, please use --format=png instead!")
kwargs["format"] = "png"

if kwargs["show_in_file_browser"]:
logger.warning(
"The short form of show_in_file_browser is deprecated and will be moved to support --format.",
)


def select_scenes(scene_classes: list[type[Scene]]) -> list[type[Scene]]:
"""Collection of selection checks for inserted scenes"""
if not scene_classes:
logger.error(NO_SCENE_MESSAGE)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'No scene' message never print out because I switched to Exception based feedback and forget to write the try-block.
Fixed in next version.

return []
elif config.write_all:
return scene_classes

result = []
for scene_name in config.scene_names:
found = False
for scene_class in scene_classes:
if scene_class.__name__ == scene_name:
result.append(scene_class)
found = True
break
if not found and (scene_name != ""):
logger.error(SCENE_NOT_FOUND_MESSAGE.format(scene_name))
if result:
return result
if len(scene_classes) == 1:
config.scene_names = [scene_classes[0].__name__]
return [scene_classes[0]]

try:
console.print(f"{MULTIPLE_SCENES}:\n", style="underline white")
scene_indices = prompt_user_with_choice([a.__name__ for a in scene_classes])
except Exception as e:
logger.error(f"{e}\n{INVALID_NUMBER_MESSAGE} ")
sys.exit(2)

classes = [scene_classes[i] for i in scene_indices]

config.scene_names = [scene_class.__name__ for scene_class in classes]
SceneFileWriter.force_output_as_scene_name = True

return classes


def solve_rendrered_scenes(file_path_input: str) -> list[type[Scene]]:
"""Return scenes from file path or create CLI prompt for input"""
from ...scene.scene import Scene

if file_path_input == "-":
try:
with urllib.request.urlopen(
urllib.request.Request(manim_info_url),
timeout=10,
) as response:
response = cast(http.client.HTTPResponse, response)
json_data = json.loads(response.read())
except urllib.error.HTTPError:
logger.debug("HTTP Error: %s", warn_prompt)
except urllib.error.URLError:
logger.debug("URL Error: %s", warn_prompt)
except json.JSONDecodeError:
logger.debug(
"Error while decoding JSON from %r: %s", manim_info_url, warn_prompt
)
except Exception:
logger.debug("Something went wrong: %s", warn_prompt)
else:
stable = json_data["info"]["version"]
if stable != __version__:
console.print(
f"You are using manim version [red]v{__version__}[/red], but version [green]v{stable}[/green] is available.",
)
console.print(
"You should consider upgrading via [yellow]pip install -U manim[/yellow]",
)
code = code_input_prompt()
module = module_from_text(code)
except Exception as e:
logger.error(f" Failed to create from input code: {e}")
sys.exit(2)

return kwargs
logger.info(INPUT_CODE_RENDER)
else:
module = module_from_file(Path(file_path_input))

scenes = search_classes_from_module(module, Scene)

return select_scenes(scenes)
3 changes: 1 addition & 2 deletions manim/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,7 @@
{} is not in the script
"""
CHOOSE_NUMBER_MESSAGE = """
Choose number corresponding to desired scene/arguments.
(Use comma separated list for multiple entries)
Select one or more numbers separated by commas (e.q. 3,1,2).
Choice(s): """
INVALID_NUMBER_MESSAGE = "Invalid scene numbers have been specified. Aborting."
NO_SCENE_MESSAGE = """
Expand Down
7 changes: 2 additions & 5 deletions manim/scene/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import random
import threading
import time
from pathlib import Path
from queue import Queue

import srt
Expand Down Expand Up @@ -54,7 +53,7 @@
from ..utils.family_ops import restructure_list_to_exclude_certain_family_members
from ..utils.file_ops import open_media_file
from ..utils.iterables import list_difference_update, list_update
from ..utils.module_ops import scene_classes_from_file
from ..utils.module_ops import scene_classes_for_gui

if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
Expand Down Expand Up @@ -1570,9 +1569,7 @@ def scene_selection_callback(sender: Any, data: Any) -> None:
config["scene_names"] = (dpg.get_value(sender),)
self.queue.put(("rerun_gui", [], {}))

scene_classes = scene_classes_from_file(
Path(config["input_file"]), full_list=True
) # type: ignore[call-overload]
scene_classes = scene_classes_for_gui(config.input_file, type(self))
scene_names = [scene_class.__name__ for scene_class in scene_classes]

with dpg.window(
Expand Down
Loading
Loading