diff --git a/manim/cli/cli_utils.py b/manim/cli/cli_utils.py new file mode 100644 index 0000000000..5b9afc42f2 --- /dev/null +++ b/manim/cli/cli_utils.py @@ -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") diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index fde82f4970..8c9def3681 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -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 @@ -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: @@ -75,25 +92,16 @@ 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 @@ -101,24 +109,24 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: 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() @@ -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 [] diff --git a/manim/constants.py b/manim/constants.py index 0a3e00da85..84860c7747 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -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 = """ diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 94d8715d35..92a72a0066 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -16,7 +16,6 @@ import threading import time from dataclasses import dataclass -from pathlib import Path from queue import Queue import srt @@ -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 @@ -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( diff --git a/manim/utils/module_ops.py b/manim/utils/module_ops.py index 1b03e374f4..b53284fb11 100644 --- a/manim/utils/module_ops.py +++ b/manim/utils/module_ops.py @@ -1,175 +1,105 @@ +"""Module operations are functions that help to create runtime python modules""" + from __future__ import annotations import importlib.util import inspect -import re import sys import types import warnings from pathlib import Path -from typing import TYPE_CHECKING, Literal, overload +from typing import Any, TypeVar -from manim._config import config, console, logger -from manim.constants import ( - CHOOSE_NUMBER_MESSAGE, - INVALID_NUMBER_MESSAGE, - NO_SCENE_MESSAGE, - SCENE_NOT_FOUND_MESSAGE, -) -from manim.scene.scene_file_writer import SceneFileWriter +T = TypeVar("T") -if TYPE_CHECKING: - from typing import Any - from manim.scene.scene import Scene +def module_from_text(code: str) -> types.ModuleType: + """Creates a input prompt in which user can insert a code that will be asserted and executed. -__all__ = ["scene_classes_from_file"] + Parameters + ---------- + code + code string + """ + module = types.ModuleType("RuntimeTEXT") + try: + # NOTE Code executer: is needed to resolve imports and other code + exec(code, module.__dict__) + return module + except Exception as e: + raise RuntimeError(f"Could not parse code from text: {e}") from e -def get_module(file_name: Path) -> types.ModuleType: - if str(file_name) == "-": - module = types.ModuleType("input_scenes") - logger.info( - "Enter the animation's code & end with an EOF (CTRL+D on Linux/Unix, CTRL+Z on Windows):", - ) - code = sys.stdin.read() - if not code.startswith("from manim import"): - logger.warning( - "Didn't find an import statement for Manim. Importing automatically...", - ) - code = "from manim import *\n" + code - logger.info("Rendering animation from typed code...") - try: - exec(code, module.__dict__) - return module - except Exception as e: - logger.error(f"Failed to render scene: {str(e)}") - sys.exit(2) +def module_from_file(file_path: Path) -> types.ModuleType: + """Resolve a Python module from python file. + + Parameters + ---------- + file_path + location of python file as path-object + """ + if not file_path.exists() and file_path.suffix == ".py": + raise ValueError(f"{file_path} is not a valid python script.") + + module_name = "runtimeFile" + ".".join(file_path.with_suffix("").parts) + + warnings.filterwarnings("default", category=DeprecationWarning, module=module_name) + + try: + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None: + raise ValueError("Failed to create ModuleSpec") + elif spec.loader is None: + raise RuntimeError("ModuleSpec has no loader") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + sys.path.insert(0, str(file_path.parent.absolute())) + spec.loader.exec_module(module) + + except Exception as e: + raise RuntimeError("Module creation from file failed") from e else: - if file_name.exists(): - ext = file_name.suffix - if ext != ".py": - raise ValueError(f"{file_name} is not a valid Manim python script.") - module_name = ".".join(file_name.with_suffix("").parts) - - warnings.filterwarnings( - "default", - category=DeprecationWarning, - module=module_name, - ) - - spec = importlib.util.spec_from_file_location(module_name, file_name) - if isinstance(spec, importlib.machinery.ModuleSpec): - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - sys.path.insert(0, str(file_name.parent.absolute())) - assert spec.loader - spec.loader.exec_module(module) - return module - raise FileNotFoundError(f"{file_name} not found") - else: - raise FileNotFoundError(f"{file_name} not found") - - -def get_scene_classes_from_module(module: types.ModuleType) -> list[type[Scene]]: - from ..scene.scene import Scene - - def is_child_scene(obj: Any, module: types.ModuleType) -> bool: + return module + + +def search_classes_from_module( + module: types.ModuleType, class_type: type[T] +) -> list[type[T]]: + """Search and return all occurrence of specified class-type. + + Parameters + ----------- + module + Module object + class_type + Type of class + """ + + def is_child_scene(obj: Any) -> bool: return ( - inspect.isclass(obj) - and issubclass(obj, Scene) - and obj != Scene + isinstance(obj, type) + and issubclass(obj, class_type) + and obj != class_type and obj.__module__.startswith(module.__name__) ) - return [ - member[1] - for member in inspect.getmembers(module, lambda x: is_child_scene(x, module)) - ] - - -def get_scenes_to_render(scene_classes: list[type[Scene]]) -> list[type[Scene]]: - if not scene_classes: - logger.error(NO_SCENE_MESSAGE) - return [] - 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]] - return prompt_user_for_choice(scene_classes) - - -def prompt_user_for_choice(scene_classes: list[type[Scene]]) -> list[type[Scene]]: - num_to_class = {} - SceneFileWriter.force_output_as_scene_name = True - for count, scene_class in enumerate(scene_classes, 1): - name = scene_class.__name__ - console.print(f"{count}: {name}", style="logging.level.info") - num_to_class[count] = scene_class - try: - user_input = console.input( - f"[log.message] {CHOOSE_NUMBER_MESSAGE} [/log.message]", - ) - scene_classes = [ - num_to_class[int(num_str)] - for num_str in re.split(r"\s*,\s*", user_input.strip()) - ] - config["scene_names"] = [scene_class.__name__ for scene_class in scene_classes] - return scene_classes - except KeyError: - logger.error(INVALID_NUMBER_MESSAGE) - sys.exit(2) - except EOFError: - sys.exit(1) - except ValueError: - logger.error("No scenes were selected. Exiting.") - sys.exit(1) - - -@overload -def scene_classes_from_file( - file_path: Path, require_single_scene: bool, full_list: Literal[True] -) -> list[type[Scene]]: ... - - -@overload -def scene_classes_from_file( - file_path: Path, - require_single_scene: Literal[True], - full_list: Literal[False] = False, -) -> type[Scene]: ... - - -@overload -def scene_classes_from_file( - file_path: Path, - require_single_scene: Literal[False] = False, - full_list: Literal[False] = False, -) -> list[type[Scene]]: ... - - -def scene_classes_from_file( - file_path: Path, require_single_scene: bool = False, full_list: bool = False -) -> type[Scene] | list[type[Scene]]: - module = get_module(file_path) - all_scene_classes = get_scene_classes_from_module(module) - if full_list: - return all_scene_classes - scene_classes_to_render = get_scenes_to_render(all_scene_classes) - if require_single_scene: - assert len(scene_classes_to_render) == 1 - return scene_classes_to_render[0] - return scene_classes_to_render + classes = [member for __void, member in inspect.getmembers(module, is_child_scene)] + + if len(classes) == 0: + raise ValueError(f"Could not found any classes of type {class_type.__name__}") + return classes + + +def scene_classes_for_gui(file_path: str | Path, class_type: type[T]) -> list[type[T]]: + """Special interface only for dearpyGUI to fetch Scene-class instances. + + Parameters + ----------- + path + file path + class_type + Type of class + """ + module = module_from_file(Path(file_path)) + return search_classes_from_module(module, class_type)