From 9e62b2dbd1e1ec613f87d2433b852518d99cf8ef Mon Sep 17 00:00:00 2001 From: Oliver Strait Date: Sun, 20 Jul 2025 08:10:24 +0300 Subject: [PATCH 1/5] Cleaner implementation of module and runtime Scene generation. - Module code does not know manim anymore - Two CLI helper function in new cli_utils.py - Commands are only place that has knowledge of wider program - Every CLI choise and desicion is moved inside of Commands --- manim/cli/cli_utils.py | 45 ++++++++ manim/cli/render/commands.py | 190 +++++++++++++++++++++++-------- manim/constants.py | 3 +- manim/utils/module_ops.py | 215 ++++++++++------------------------- 4 files changed, 251 insertions(+), 202 deletions(-) create mode 100644 manim/cli/cli_utils.py diff --git a/manim/cli/cli_utils.py b/manim/cli/cli_utils.py new file mode 100644 index 0000000000..2a6d823c4c --- /dev/null +++ b/manim/cli/cli_utils.py @@ -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") diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index fde82f4970..54fcedb3d8 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -15,7 +15,7 @@ import urllib.request from argparse import Namespace from pathlib import Path -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import cloup @@ -27,15 +27,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: @@ -75,18 +93,7 @@ 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"]: @@ -94,6 +101,8 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: config.digest_args(click_args) file = Path(config.input_file) + scenes = solve_rendrered_scenes(file) + if config.renderer == RendererType.OPENGL: from manim.renderer.opengl_renderer import OpenGLRenderer @@ -101,24 +110,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 +137,121 @@ 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() - 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 + return kwargs + + +def version_notification() -> None: + ### NOTE TODO This has fundamental problem of connecting every time into internet + ### As many times Renders are executed during a day. + ### There should be a caching mechanisim that will safe simple timecode and result in + ### Cached file to be fetched in most of times. + + manim_info_url = "https://pypi.org/pypi/manim/json" + warn_prompt = "Cannot check if latest release of manim is installed" + + 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]", ) - 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]", - ) - return kwargs + +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 get_scenes_to_render(scene_classes: list[type[Scene]]) -> list[type[Scene]]: + 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: Path | str) -> list[type[Scene]]: + """Return scenes from file path or create CLI prompt for input""" + from ...scene.scene import Scene + + if str(file_path_input) == "-": + try: + 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) + + logger.info(INPUT_CODE_RENDER) + else: + module = module_from_file(Path(file_path_input)) + + scenes = search_classes_from_module(module, Scene) + + if not scenes: + logger.error(NO_SCENE_MESSAGE) + return [] + elif config.write_all: + return scenes + else: + return get_scenes_to_render(scenes) 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/utils/module_ops.py b/manim/utils/module_ops.py index 1b03e374f4..037ca4c902 100644 --- a/manim/utils/module_ops.py +++ b/manim/utils/module_ops.py @@ -1,175 +1,84 @@ +"""Module operations are functions that help to create runtime python modules""" + from __future__ import annotations +import importlib.machinery 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.""" + 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 -__all__ = ["scene_classes_from_file"] +def module_from_file(file_path: Path) -> types.ModuleType: + """Resolve a Python module from python file. -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) - 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") + Parameters + ---------- + + file_path + location of 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 isinstance(spec, importlib.machinery.ModuleSpec): + 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) else: - raise FileNotFoundError(f"{file_name} not found") + raise ValueError("Failed to create ModuleSpec") + + except Exception as e: + raise RuntimeError("Module creation from file failed") from e + else: + return module + +def search_classes_from_module( + module: types.ModuleType, class_type: type[T] +) -> list[type[T]]: + """Search and return all occurrence of specified type classes. -def get_scene_classes_from_module(module: types.ModuleType) -> list[type[Scene]]: - from ..scene.scene import Scene + Parameters + ----------- + module + Module object + class_type: + Type of searched classes + """ - def is_child_scene(obj: Any, module: types.ModuleType) -> bool: + 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[1] for 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 From 91c4f3e03c40f65cd0e6349f547301d1dbb4895f Mon Sep 17 00:00:00 2001 From: Oliver Strait Date: Mon, 21 Jul 2025 17:35:25 +0300 Subject: [PATCH 2/5] Add functionality of cached version number --- manim/cli/render/commands.py | 96 ++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index 54fcedb3d8..308faa708c 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -8,11 +8,10 @@ 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 TYPE_CHECKING, Any, cast @@ -100,8 +99,8 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: return click_args config.digest_args(click_args) - file = Path(config.input_file) - scenes = solve_rendrered_scenes(file) + + scenes = solve_rendrered_scenes(config.input_file) if config.renderer == RendererType.OPENGL: from manim.renderer.opengl_renderer import OpenGLRenderer @@ -143,41 +142,53 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: def version_notification() -> None: - ### NOTE TODO This has fundamental problem of connecting every time into internet - ### As many times Renders are executed during a day. - ### There should be a caching mechanisim that will safe simple timecode and result in - ### Cached file to be fetched in most of times. + """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( - urllib.request.Request(manim_info_url), - timeout=10, - ) as response: + with urllib.request.urlopen(request, 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 (Exception, urllib.error.HTTPError, urllib.error.URLError) as e: + logger.debug(f"{e}: {warn_prompt} ") + return None 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) + logger.debug(f"Error while decoding JSON from [{manim_info_url}]: warn_prompt") + return None 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]", - ) + return str(json_data["info"]["version"]) def warn_and_change_deprecated_args(kwargs: dict[str, Any]) -> None: @@ -198,7 +209,14 @@ def warn_and_change_deprecated_args(kwargs: dict[str, Any]) -> None: ) -def get_scenes_to_render(scene_classes: list[type[Scene]]) -> list[type[Scene]]: +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) + return [] + elif config.write_all: + return scene_classes + result = [] for scene_name in config.scene_names: found = False @@ -230,11 +248,11 @@ def get_scenes_to_render(scene_classes: list[type[Scene]]) -> list[type[Scene]]: return classes -def solve_rendrered_scenes(file_path_input: Path | str) -> list[type[Scene]]: +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 str(file_path_input) == "-": + if file_path_input == "-": try: code = code_input_prompt() module = module_from_text(code) @@ -248,10 +266,4 @@ def solve_rendrered_scenes(file_path_input: Path | str) -> list[type[Scene]]: scenes = search_classes_from_module(module, Scene) - if not scenes: - logger.error(NO_SCENE_MESSAGE) - return [] - elif config.write_all: - return scenes - else: - return get_scenes_to_render(scenes) + return select_scenes(scenes) From a23bc182a308f42ca8f9981acd03144877890145 Mon Sep 17 00:00:00 2001 From: Oliver Strait Date: Mon, 21 Jul 2025 18:26:19 +0300 Subject: [PATCH 3/5] Custom api for DearPyGui to process and fetch Scene classes --- manim/scene/scene.py | 7 ++----- manim/utils/module_ops.py | 6 ++++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 94d8715d35..5ad7d9e70f 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, type(self)) 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 037ca4c902..aefa9f75d2 100644 --- a/manim/utils/module_ops.py +++ b/manim/utils/module_ops.py @@ -82,3 +82,9 @@ def is_child_scene(obj: Any) -> bool: if len(classes) == 0: raise ValueError(f"Could not found any classes of type {class_type.__name__}") return classes + + +def scene_classes_for_gui(path: str, class_type: type[T]) -> list[type[T]]: + """Specified interface of dearpyGUI to fetch Scene-class instances""" + module = module_from_file(Path(path)) + return search_classes_from_module(module, class_type) From 8b84a13e80940fe96085c9b396da79a3752493cf Mon Sep 17 00:00:00 2001 From: Oliver Strait Date: Thu, 31 Jul 2025 19:07:13 +0300 Subject: [PATCH 4/5] cli_ utils: - fixing typos and added full docstring - replace regex with python string split module_ops: - full docstrings, typo fixing - handling python internal type-madness with exceptions to please mypy commands: - docstrings, typos, more descriptive names - cleaning version fetch functionality --- manim/cli/cli_utils.py | 31 +++++++----- manim/cli/render/commands.py | 98 ++++++++++++++++++++++-------------- manim/utils/module_ops.py | 49 +++++++++++------- 3 files changed, 110 insertions(+), 68 deletions(-) diff --git a/manim/cli/cli_utils.py b/manim/cli/cli_utils.py index 2a6d823c4c..5b9afc42f2 100644 --- a/manim/cli/cli_utils.py +++ b/manim/cli/cli_utils.py @@ -1,18 +1,18 @@ 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." +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: @@ -24,22 +24,27 @@ def code_input_prompt() -> str: 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): +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) - # CTRL + Z, CTRL + D, Remove common EOF escape chars - cleaned = user_input.strip().removesuffix("\x1a").removesuffix("\x04") - result = re.split(r"\s*,\s*", cleaned) + result = user_input.strip().rstrip(",").split(",") + cleaned = [n.strip() for n in result] - if not all(a.isnumeric() for a in result): - raise ValueError("Invalid non-numeric input: ", user_input) + if not all(a.isnumeric() for a in cleaned): + raise ValueError(f"Invalid non-numeric input(s): {result}") - indices = [int(i_str.strip()) - 1 for i_str in 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 chooses is outside of range") + 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 308faa708c..8c9def3681 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -26,7 +26,7 @@ logger, tempconfig, ) -from manim.cli.cli_utils import code_input_prompt, prompt_user_with_choice +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 @@ -92,7 +92,7 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: SCENES is an optional list of scenes in the file. """ - warn_and_change_deprecated_args(kwargs) + warn_and_change_deprecated_arguments(kwargs) click_args = ClickArgs(kwargs) if kwargs["jupyter"]: @@ -100,7 +100,7 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: config.digest_args(click_args) - scenes = solve_rendrered_scenes(config.input_file) + scenes = scenes_from_input(config.input_file) if config.renderer == RendererType.OPENGL: from manim.renderer.opengl_renderer import OpenGLRenderer @@ -142,26 +142,32 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: 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__: + """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}[/green] is available.", + 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]", @@ -169,6 +175,7 @@ def version_notification() -> None: def fetch_version() -> str | None: + """Fetch latest manim version from PYPI-database""" import http.client import urllib.error import urllib.request @@ -185,15 +192,17 @@ def fetch_version() -> str | None: logger.debug(f"{e}: {warn_prompt} ") return None except json.JSONDecodeError: - logger.debug(f"Error while decoding JSON from [{manim_info_url}]: warn_prompt") + 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 +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!") @@ -210,11 +219,14 @@ def warn_and_change_deprecated_args(kwargs: dict[str, Any]) -> None: 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) - return [] - elif config.write_all: + """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 = [] @@ -229,13 +241,14 @@ def select_scenes(scene_classes: list[type[Scene]]) -> list[type[Scene]]: 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]) + 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) @@ -248,8 +261,14 @@ def select_scenes(scene_classes: list[type[Scene]]) -> list[type[Scene]]: return classes -def solve_rendrered_scenes(file_path_input: str) -> list[type[Scene]]: - """Return scenes from file path or create CLI prompt for input""" +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 == "-": @@ -257,13 +276,16 @@ def solve_rendrered_scenes(file_path_input: str) -> list[type[Scene]]: code = code_input_prompt() module = module_from_text(code) except Exception as e: - logger.error(f" Failed to create from input code: {e}") + logger.error(f"Failed to create from input code: {e}") sys.exit(2) 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) + try: + scenes = search_classes_from_module(module, Scene) + return select_scenes(scenes) + except ValueError: + logger.error(NO_SCENE_MESSAGE) + return [] diff --git a/manim/utils/module_ops.py b/manim/utils/module_ops.py index aefa9f75d2..b53284fb11 100644 --- a/manim/utils/module_ops.py +++ b/manim/utils/module_ops.py @@ -2,7 +2,6 @@ from __future__ import annotations -import importlib.machinery import importlib.util import inspect import sys @@ -15,7 +14,13 @@ 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.""" + """Creates a input prompt in which user can insert a code that will be asserted and executed. + + Parameters + ---------- + code + code string + """ module = types.ModuleType("RuntimeTEXT") try: # NOTE Code executer: is needed to resolve imports and other code @@ -30,9 +35,8 @@ def module_from_file(file_path: Path) -> types.ModuleType: Parameters ---------- - file_path - location of file as path-object + 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.") @@ -40,15 +44,18 @@ def module_from_file(file_path: Path) -> types.ModuleType: 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 isinstance(spec, importlib.machinery.ModuleSpec): - 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) - else: + 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 @@ -59,14 +66,14 @@ def module_from_file(file_path: Path) -> types.ModuleType: def search_classes_from_module( module: types.ModuleType, class_type: type[T] ) -> list[type[T]]: - """Search and return all occurrence of specified type classes. + """Search and return all occurrence of specified class-type. Parameters ----------- module Module object - class_type: - Type of searched classes + class_type + Type of class """ def is_child_scene(obj: Any) -> bool: @@ -77,14 +84,22 @@ def is_child_scene(obj: Any) -> bool: and obj.__module__.startswith(module.__name__) ) - classes = [member[1] for member in inspect.getmembers(module, is_child_scene)] + 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(path: str, class_type: type[T]) -> list[type[T]]: - """Specified interface of dearpyGUI to fetch Scene-class instances""" - module = module_from_file(Path(path)) +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) From adfa8704d23f01620bb4fa5f33b965df1e7129da Mon Sep 17 00:00:00 2001 From: Oliver Strait Date: Thu, 31 Jul 2025 19:40:08 +0300 Subject: [PATCH 5/5] little logic fix. Use Scene and not type(self) --- manim/scene/scene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 5ad7d9e70f..92a72a0066 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -1621,7 +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_for_gui(config.input_file, type(self)) + scene_classes = scene_classes_for_gui(config.input_file, Scene) scene_names = [scene_class.__name__ for scene_class in scene_classes] with dpg.window(