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 5 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
50 changes: 50 additions & 0 deletions manim/cli/cli_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

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:
"""Little CLI interface in which user can insert code."""
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_list(items: list[str]) -> list[int]:
"""Prompt user with choices and return indices of chosen items

Parameters
-----------
items
list of strings representing items to be chosen
"""
max_index = len(items) - 1
for count, name in enumerate(items, 1):
console.print(f"{count}: {name}", style="logging.level.info")

user_input = console.input(CHOOSE_NUMBER_MESSAGE)
result = user_input.strip().rstrip(",").split(",")
cleaned = [n.strip() for n in result]

if not all(a.isnumeric() for a in cleaned):
raise ValueError(f"Invalid non-numeric input(s): {result}")

indices = [int(int_str) - 1 for int_str in cleaned]
if all(a <= max_index >= 0 for a in indices):
return indices
else:
raise KeyError("One or more choice is outside of range")
232 changes: 181 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_list
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_arguments(kwargs)

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

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

scenes = scenes_from_input(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,156 @@ 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:
"""Compare used version to latest version of manim.
Version info is fetched from internet once a day and cached into a file.
"""
stable_version = None

cache_file = Path(os.path.dirname(__file__)) / ".version_cache.log"

if cache_file.exists():
with cache_file.open() as f:
cache_lifetime = int(f.readline())
if time.time() < cache_lifetime:
stable_version = f.readline()

if stable_version is None:
version = fetch_version()
if version is None:
return None

with cache_file.open(mode="w") as f:
timecode = int(time.time()) + 86_400
f.write(str(timecode) + "\n" + str(version))
stable_version = version

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


def fetch_version() -> str | None:
"""Fetch latest manim version from PYPI-database"""
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_arguments(kwargs: dict[str, Any]) -> None:
"""Helper function to print info about deprecated arguments
and mutate inserted dictionary to use new 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]]:
"""Assortment of selection functionality in which one or more Scenes are selected from list.

Parameters
----------
scene_classes
list of scene classes that
"""
if 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_list([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 scenes_from_input(file_path_input: str) -> list[type[Scene]]:
"""Return scenes from file path or create CLI prompt for input

Parameters
----------
file_path_input
file path or '-' that will open a code prompt
"""
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))

try:
scenes = search_classes_from_module(module, Scene)
return select_scenes(scenes)
except ValueError:
logger.error(NO_SCENE_MESSAGE)
return []
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 @@ -16,7 +16,6 @@
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from queue import Queue

import srt
Expand Down Expand Up @@ -56,7 +55,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 @@ -1622,9 +1621,7 @@ def scene_selection_callback(sender: Any, data: Any) -> None:
config["scene_names"] = (dpg.get_value(sender),)
self.queue.put(SceneInteractRerun("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, Scene)
scene_names = [scene_class.__name__ for scene_class in scene_classes]

with dpg.window(
Expand Down
Loading
Loading