diff --git a/docs/source/reference_index/utilities_misc.rst b/docs/source/reference_index/utilities_misc.rst index 1fe9962079..874a20ef86 100644 --- a/docs/source/reference_index/utilities_misc.rst +++ b/docs/source/reference_index/utilities_misc.rst @@ -10,6 +10,7 @@ Module Index :toctree: ../reference ~utils.bezier + cli ~utils.color ~utils.commands ~utils.config_ops diff --git a/manim/_config/cli_colors.py b/manim/_config/cli_colors.py index c39f24d716..5b1d151bdb 100644 --- a/manim/_config/cli_colors.py +++ b/manim/_config/cli_colors.py @@ -1,13 +1,14 @@ from __future__ import annotations import configparser +from typing import Any from cloup import Context, HelpFormatter, HelpTheme, Style __all__ = ["parse_cli_ctx"] -def parse_cli_ctx(parser: configparser.SectionProxy) -> Context: +def parse_cli_ctx(parser: configparser.SectionProxy) -> dict[str, Any]: formatter_settings: dict[str, str | int] = { "indent_increment": int(parser["indent_increment"]), "width": int(parser["width"]), diff --git a/manim/cli/__init__.py b/manim/cli/__init__.py index e69de29bb2..f1aa512e02 100644 --- a/manim/cli/__init__.py +++ b/manim/cli/__init__.py @@ -0,0 +1,17 @@ +"""The Manim CLI, and the available commands for ``manim``. + +This page is a work in progress. Please run ``manim`` or ``manim --help`` in +your terminal to find more information on the following commands. + +Available commands +------------------ + +.. autosummary:: + :toctree: ../reference + + cfg + checkhealth + init + plugins + render +""" diff --git a/manim/cli/cfg/group.py b/manim/cli/cfg/group.py index 113818e245..13834311ab 100644 --- a/manim/cli/cfg/group.py +++ b/manim/cli/cfg/group.py @@ -11,22 +11,23 @@ import contextlib from ast import literal_eval from pathlib import Path +from typing import Any, cast import cloup from rich.errors import StyleSyntaxError from rich.style import Style -from ... import cli_ctx_settings, console -from ..._config.utils import config_file_paths, make_config_parser -from ...constants import EPILOG -from ...utils.file_ops import guarantee_existence, open_file +from manim._config import cli_ctx_settings, console +from manim._config.utils import config_file_paths, make_config_parser +from manim.constants import EPILOG +from manim.utils.file_ops import guarantee_existence, open_file RICH_COLOUR_INSTRUCTIONS: str = """ [red]The default colour is used by the input statement. If left empty, the default colour will be used.[/red] [magenta] For a full list of styles, visit[/magenta] [green]https://rich.readthedocs.io/en/latest/style.html[/green] """ -RICH_NON_STYLE_ENTRIES: str = ["log.width", "log.height", "log.timestamps"] +RICH_NON_STYLE_ENTRIES: list[str] = ["log.width", "log.height", "log.timestamps"] __all__ = [ "value_from_string", @@ -41,7 +42,8 @@ def value_from_string(value: str) -> str | int | bool: - """Extracts the literal of proper datatype from a string. + """Extract the literal of proper datatype from a ``value`` string. + Parameters ---------- value @@ -49,49 +51,60 @@ def value_from_string(value: str) -> str | int | bool: Returns ------- - Union[:class:`str`, :class:`int`, :class:`bool`] - Returns the literal of appropriate datatype. + :class:`str` | :class:`int` | :class:`bool` + The literal of appropriate datatype. """ with contextlib.suppress(SyntaxError, ValueError): value = literal_eval(value) return value -def _is_expected_datatype(value: str, expected: str, style: bool = False) -> bool: - """Checks whether `value` is the same datatype as `expected`, - and checks if it is a valid `style` if `style` is true. +def _is_expected_datatype( + value: str, expected: str, validate_style: bool = False +) -> bool: + """Check whether the literal from ``value`` is the same datatype as the + literal from ``expected``. If ``validate_style`` is ``True``, also check if + the style given by ``value`` is valid, according to ``rich``. Parameters ---------- value - The string of the value to check (obtained from reading the user input). + The string of the value to check, obtained from reading the user input. expected - The string of the literal datatype must be matched by `value`. Obtained from - reading the cfg file. - style - Whether or not to confirm if `value` is a style, by default False + The string of the literal datatype which must be matched by ``value``. + This is obtained from reading the ``cfg`` file. + validate_style + Whether or not to confirm if ``value`` is a valid style, according to + ``rich``. Default is ``False``. Returns ------- :class:`bool` - Whether or not `value` matches the datatype of `expected`. + Whether or not the literal from ``value`` matches the datatype of the + literal from ``expected``. """ - value = value_from_string(value) - expected = type(value_from_string(expected)) + value_literal = value_from_string(value) + ExpectedLiteralType = type(value_from_string(expected)) - return isinstance(value, expected) and (is_valid_style(value) if style else True) + return isinstance(value_literal, ExpectedLiteralType) and ( + (isinstance(value_literal, str) and is_valid_style(value_literal)) + if validate_style + else True + ) def is_valid_style(style: str) -> bool: - """Checks whether the entered color is a valid color according to rich + """Checks whether the entered color style is valid, according to ``rich``. + Parameters ---------- style The style to check whether it is valid. + Returns ------- - Boolean - Returns whether it is valid style or not according to rich. + :class:`bool` + Whether the color style is valid or not, according to ``rich``. """ try: Style.parse(style) @@ -100,16 +113,20 @@ def is_valid_style(style: str) -> bool: return False -def replace_keys(default: dict) -> dict: - """Replaces _ to . and vice versa in a dictionary for rich +def replace_keys(default: dict[str, Any]) -> dict[str, Any]: + """Replace ``_`` with ``.`` and vice versa in a dictionary's keys for + ``rich``. + Parameters ---------- default - The dictionary to check and replace + The dictionary whose keys will be checked and replaced. + Returns ------- :class:`dict` - The dictionary which is modified by replacing _ with . and vice versa + The dictionary whose keys are modified by replacing ``_`` with ``.`` + and vice versa. """ for key in default: if "_" in key: @@ -133,7 +150,7 @@ def replace_keys(default: dict) -> dict: help="Manages Manim configuration files.", ) @cloup.pass_context -def cfg(ctx): +def cfg(ctx: cloup.Context) -> None: """Responsible for the cfg subcommand.""" pass @@ -147,7 +164,7 @@ def cfg(ctx): help="Specify if this config is for user or the working directory.", ) @cloup.option("-o", "--open", "openfile", is_flag=True) -def write(level: str = None, openfile: bool = False) -> None: +def write(level: str | None = None, openfile: bool = False) -> None: config_paths = config_file_paths() console.print( "[yellow bold]Manim Configuration File Writer[/yellow bold]", @@ -166,7 +183,7 @@ def write(level: str = None, openfile: bool = False) -> None: action = "save this as" for category in parser: console.print(f"{category}", style="bold green underline") - default = parser[category] + default = cast(dict[str, Any], parser[category]) if category == "logger": console.print(RICH_COLOUR_INSTRUCTIONS) default = replace_keys(default) @@ -249,7 +266,7 @@ def write(level: str = None, openfile: bool = False) -> None: @cfg.command(context_settings=cli_ctx_settings) -def show(): +def show() -> None: parser = make_config_parser() rich_non_style_entries = [a.replace(".", "_") for a in RICH_NON_STYLE_ENTRIES] for category in parser: @@ -269,7 +286,7 @@ def show(): @cfg.command(context_settings=cli_ctx_settings) @cloup.option("-d", "--directory", default=Path.cwd()) @cloup.pass_context -def export(ctx, directory): +def export(ctx: cloup.Context, directory: str) -> None: directory_path = Path(directory) if directory_path.absolute == Path.cwd().absolute: console.print( diff --git a/manim/cli/checkhealth/checks.py b/manim/cli/checkhealth/checks.py index 4f1d82f41a..ec9c07dec7 100644 --- a/manim/cli/checkhealth/checks.py +++ b/manim/cli/checkhealth/checks.py @@ -6,58 +6,74 @@ import os import shutil -from typing import Callable +from typing import Callable, Protocol, cast __all__ = ["HEALTH_CHECKS"] -HEALTH_CHECKS = [] + +class HealthCheckFunction(Protocol): + description: str + recommendation: str + skip_on_failed: list[str] + post_fail_fix_hook: Callable[..., object] | None + __name__: str + + def __call__(self) -> bool: ... + + +HEALTH_CHECKS: list[HealthCheckFunction] = [] def healthcheck( description: str, recommendation: str, - skip_on_failed: list[Callable | str] | None = None, - post_fail_fix_hook: Callable | None = None, -): + skip_on_failed: list[HealthCheckFunction | str] | None = None, + post_fail_fix_hook: Callable[..., object] | None = None, +) -> Callable[[Callable[[], bool]], HealthCheckFunction]: """Decorator used for declaring health checks. - This decorator attaches some data to a function, - which is then added to a list containing all checks. + This decorator attaches some data to a function, which is then added to a + a list containing all checks. Parameters ---------- description - A brief description of this check, displayed when - the checkhealth subcommand is run. + A brief description of this check, displayed when the ``checkhealth`` + subcommand is run. recommendation Help text which is displayed in case the check fails. skip_on_failed - A list of check functions which, if they fail, cause - the current check to be skipped. + A list of check functions which, if they fail, cause the current check + to be skipped. post_fail_fix_hook - A function that is supposed to (interactively) help - to fix the detected problem, if possible. This is - only called upon explicit confirmation of the user. + A function that is meant to (interactively) help to fix the detected + problem, if possible. This is only called upon explicit confirmation of + the user. Returns ------- - A check function, as required by the checkhealth subcommand. + Callable[Callable[[], bool], :class:`HealthCheckFunction`] + A decorator which converts a function into a health check function, as + required by the ``checkhealth`` subcommand. """ + new_skip_on_failed: list[str] if skip_on_failed is None: - skip_on_failed = [] - skip_on_failed = [ - skip.__name__ if callable(skip) else skip for skip in skip_on_failed - ] + new_skip_on_failed = [] + else: + new_skip_on_failed = [ + skip.__name__ if callable(skip) else skip for skip in skip_on_failed + ] - def decorator(func): - func.description = description - func.recommendation = recommendation - func.skip_on_failed = skip_on_failed - func.post_fail_fix_hook = post_fail_fix_hook - HEALTH_CHECKS.append(func) - return func + def wrapper(func: Callable[[], bool]) -> HealthCheckFunction: + health_func = cast(HealthCheckFunction, func) + health_func.description = description + health_func.recommendation = recommendation + health_func.skip_on_failed = new_skip_on_failed + health_func.post_fail_fix_hook = post_fail_fix_hook + HEALTH_CHECKS.append(health_func) + return health_func - return decorator + return wrapper @healthcheck( @@ -75,7 +91,14 @@ def decorator(func): "PATH variable." ), ) -def is_manim_on_path(): +def is_manim_on_path() -> bool: + """Check whether ``manim`` is in ``PATH``. + + Returns + ------- + :class:`bool` + Whether ``manim`` is in ``PATH`` or not. + """ path_to_manim = shutil.which("manim") return path_to_manim is not None @@ -91,10 +114,30 @@ def is_manim_on_path(): ), skip_on_failed=[is_manim_on_path], ) -def is_manim_executable_associated_to_this_library(): +def is_manim_executable_associated_to_this_library() -> bool: + """Check whether the ``manim`` executable in ``PATH`` is associated to this + library. To verify this, the executable should look like this: + + .. code-block:: python + + #!/.../python + import sys + from manim.__main__ import main + + if __name__ == "__main__": + sys.exit(main()) + + + Returns + ------- + :class:`bool` + Whether the ``manim`` executable in ``PATH`` is associated to this + library or not. + """ path_to_manim = shutil.which("manim") - with open(path_to_manim, "rb") as f: - manim_exec = f.read() + assert path_to_manim is not None + with open(path_to_manim, "rb") as manim_binary: + manim_exec = manim_binary.read() # first condition below corresponds to the executable being # some sort of python script. second condition happens when @@ -114,7 +157,14 @@ def is_manim_executable_associated_to_this_library(): "LaTeX distribution on your operating system." ), ) -def is_latex_available(): +def is_latex_available() -> bool: + """Check whether ``latex`` is in ``PATH`` and can be executed. + + Returns + ------- + :class:`bool` + Whether ``latex`` is in ``PATH`` and can be executed or not. + """ path_to_latex = shutil.which("latex") return path_to_latex is not None and os.access(path_to_latex, os.X_OK) @@ -129,6 +179,13 @@ def is_latex_available(): ), skip_on_failed=[is_latex_available], ) -def is_dvisvgm_available(): +def is_dvisvgm_available() -> bool: + """Check whether ``dvisvgm`` is in ``PATH`` and can be executed. + + Returns + ------- + :class:`bool` + Whether ``dvisvgm`` is in ``PATH`` and can be executed or not. + """ path_to_dvisvgm = shutil.which("dvisvgm") return path_to_dvisvgm is not None and os.access(path_to_dvisvgm, os.X_OK) diff --git a/manim/cli/checkhealth/commands.py b/manim/cli/checkhealth/commands.py index 228aac00dc..3750f63d4f 100644 --- a/manim/cli/checkhealth/commands.py +++ b/manim/cli/checkhealth/commands.py @@ -11,7 +11,7 @@ import click import cloup -from manim.cli.checkhealth.checks import HEALTH_CHECKS +from manim.cli.checkhealth.checks import HEALTH_CHECKS, HealthCheckFunction __all__ = ["checkhealth"] @@ -19,13 +19,13 @@ @cloup.command( context_settings=None, ) -def checkhealth(): +def checkhealth() -> None: """This subcommand checks whether Manim is installed correctly and has access to its required (and optional) system dependencies. """ click.echo(f"Python executable: {sys.executable}\n") click.echo("Checking whether your installation of Manim Community is healthy...") - failed_checks = [] + failed_checks: list[HealthCheckFunction] = [] for check in HEALTH_CHECKS: click.echo(f"- {check.description} ... ", nl=False) @@ -63,7 +63,7 @@ def checkhealth(): import manim as mn class CheckHealthDemo(mn.Scene): - def _inner_construct(self): + def _inner_construct(self) -> None: banner = mn.ManimBanner().shift(mn.UP * 0.5) self.play(banner.create()) self.wait(0.5) @@ -80,7 +80,7 @@ def _inner_construct(self): mn.FadeOut(text_tex_group, shift=mn.DOWN), ) - def construct(self): + def construct(self) -> None: self.execution_time = timeit.timeit(self._inner_construct, number=1) with mn.tempconfig({"preview": True, "disable_caching": True}): diff --git a/manim/cli/default_group.py b/manim/cli/default_group.py index 03b2e5b2fb..579a3e3a05 100644 --- a/manim/cli/default_group.py +++ b/manim/cli/default_group.py @@ -6,62 +6,183 @@ This is a vendored version of https://github.com/click-contrib/click-default-group/ under the BSD 3-Clause "New" or "Revised" License. - This library isn't used as a dependency as we need to inherit from ``cloup.Group`` instead - of ``click.Group``. + This library isn't used as a dependency, as we need to inherit from + :class:`cloup.Group` instead of :class:`click.Group`. """ from __future__ import annotations import warnings +from typing import TYPE_CHECKING, Any, Callable import cloup +from manim.utils.deprecation import deprecated + __all__ = ["DefaultGroup"] +if TYPE_CHECKING: + from click import Command, Context + class DefaultGroup(cloup.Group): - """Invokes a subcommand marked with ``default=True`` if any subcommand not + """Invokes a subcommand marked with ``default=True`` if any subcommand is not chosen. + + Parameters + ---------- + *args + Positional arguments to forward to :class:`cloup.Group`. + **kwargs + Keyword arguments to forward to :class:`cloup.Group`. The keyword + ``ignore_unknown_options`` must be set to ``False``. + + Attributes + ---------- + default_cmd_name : str | None + The name of the default command, if specified through the ``default`` + keyword argument. Otherwise, this is set to ``None``. + default_if_no_args : bool + Whether to include or not the default command, if no command arguments + are supplied. This can be specified through the ``default_if_no_args`` + keyword argument. Default is ``False``. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): # To resolve as the default command. if not kwargs.get("ignore_unknown_options", True): raise ValueError("Default group accepts unknown options") self.ignore_unknown_options = True - self.default_cmd_name = kwargs.pop("default", None) - self.default_if_no_args = kwargs.pop("default_if_no_args", False) + self.default_cmd_name: str | None = kwargs.pop("default", None) + self.default_if_no_args: bool = kwargs.pop("default_if_no_args", False) super().__init__(*args, **kwargs) - def set_default_command(self, command): - """Sets a command function as the default command.""" + def set_default_command(self, command: Command) -> None: + """Sets a command function as the default command. + + Parameters + ---------- + command + The command to set as default. + """ cmd_name = command.name self.add_command(command) self.default_cmd_name = cmd_name - def parse_args(self, ctx, args): - if not args and self.default_if_no_args: + def parse_args(self, ctx: Context, args: list[str]) -> list[str]: + """Parses the list of ``args`` by forwarding it to + :meth:`cloup.Group.parse_args`. Before doing so, if + :attr:`default_if_no_args` is set to ``True`` and ``args`` is empty, + this function appends to it the name of the default command specified + by :attr:`default_cmd_name`. + + Parameters + ---------- + ctx + The Click context. + args + A list of arguments. If it's empty and :attr:`default_if_no_args` + is ``True``, append the name of the default command to it. + + Returns + ------- + list[str] + The parsed arguments. + """ + if not args and self.default_if_no_args and self.default_cmd_name: args.insert(0, self.default_cmd_name) - return super().parse_args(ctx, args) - - def get_command(self, ctx, cmd_name): - if cmd_name not in self.commands: + parsed_args: list[str] = super().parse_args(ctx, args) + return parsed_args + + def get_command(self, ctx: Context, cmd_name: str) -> Command | None: + """Get a command function by its name, by forwarding the arguments to + :meth:`cloup.Group.get_command`. If ``cmd_name`` does not match any of + the command names in :attr:`commands`, attempt to get the default command + instead. + + Parameters + ---------- + ctx + The Click context. + cmd_name + The name of the command to get. + + Returns + ------- + :class:`click.Command` | None + The command, if found. Otherwise, ``None``. + """ + if cmd_name not in self.commands and self.default_cmd_name: # No command name matched. - ctx.arg0 = cmd_name + ctx.meta["arg0"] = cmd_name cmd_name = self.default_cmd_name return super().get_command(ctx, cmd_name) - def resolve_command(self, ctx, args): - base = super() - cmd_name, cmd, args = base.resolve_command(ctx, args) - if hasattr(ctx, "arg0"): - args.insert(0, ctx.arg0) - cmd_name = cmd.name + def resolve_command( + self, ctx: Context, args: list[str] + ) -> tuple[str | None, Command | None, list[str]]: + """Given a list of ``args`` given by a CLI, find a command which + matches the first element, and return its name (``cmd_name``), the + command function itself (``cmd``) and the rest of the arguments which + shall be passed to the function (``cmd_args``). If not found, return + ``None``, ``None`` and the rest of the arguments. + + After resolving the command, if the Click context given by ``ctx`` + contains an ``arg0`` attribute in its :attr:`click.Context.meta` + dictionary, insert it as the first element of the returned + ``cmd_args``. + + Parameters + ---------- + ctx + The Click context. + cmd_name + The name of the command to get. + + Returns + ------- + cmd_name : str | None + The command name, if found. Otherwise, ``None``. + cmd : :class:`click.Command` | None + The command, if found. Otherwise, ``None``. + cmd_args : list[str] + The rest of the arguments to be passed to ``cmd``. + """ + cmd_name, cmd, args = super().resolve_command(ctx, args) + if "arg0" in ctx.meta: + args.insert(0, ctx.meta["arg0"]) + if cmd is not None: + cmd_name = cmd.name return cmd_name, cmd, args - def command(self, *args, **kwargs): + @deprecated + def command( + self, *args: Any, **kwargs: Any + ) -> Callable[[Callable[..., object]], Command]: + """Return a decorator which converts any function into the default + subcommand for this :class:`DefaultGroup`. + + .. warning:: + This method is deprecated. Use the ``default`` parameter of + :class:`DefaultGroup` or :meth:`set_default_command` instead. + + Parameters + ---------- + *args + Positional arguments to pass to :meth:`cloup.Group.command`. + **kwargs + Keyword arguments to pass to :meth:`cloup.Group.command`. + + Returns + ------- + Callable[[Callable[..., object]], click.Command] + A decorator which transforms its input into this + :class:`DefaultGroup`'s default subcommand. + """ default = kwargs.pop("default", False) - decorator = super().command(*args, **kwargs) + decorator: Callable[[Callable[..., object]], Command] = super().command( + *args, **kwargs + ) if not default: return decorator warnings.warn( @@ -70,7 +191,7 @@ def command(self, *args, **kwargs): stacklevel=1, ) - def _decorator(f): + def _decorator(f: Callable) -> Command: cmd = decorator(f) self.set_default_command(cmd) return cmd diff --git a/manim/cli/init/commands.py b/manim/cli/init/commands.py index 1ca5b9c05c..dd9d64837f 100644 --- a/manim/cli/init/commands.py +++ b/manim/cli/init/commands.py @@ -10,13 +10,14 @@ import configparser from pathlib import Path +from typing import Any import click import cloup -from ... import console -from ...constants import CONTEXT_SETTINGS, EPILOG, QUALITIES -from ...utils.file_ops import ( +from manim._config import console +from manim.constants import CONTEXT_SETTINGS, EPILOG, QUALITIES +from manim.utils.file_ops import ( add_import_statement, copy_template_files, get_template_names, @@ -34,15 +35,15 @@ __all__ = ["select_resolution", "update_cfg", "project", "scene"] -def select_resolution(): +def select_resolution() -> tuple[int, int]: """Prompts input of type click.Choice from user. Presents options from QUALITIES constant. Returns ------- - :class:`tuple` - Tuple containing height and width. + tuple[int, int] + Tuple containing height and width. """ - resolution_options = [] + resolution_options: list[tuple[int, int]] = [] for quality in QUALITIES.items(): resolution_options.append( (quality[1]["pixel_height"], quality[1]["pixel_width"]), @@ -54,18 +55,21 @@ def select_resolution(): show_default=False, default="480p", ) - return [res for res in resolution_options if f"{res[0]}p" == choice][0] + matches = [res for res in resolution_options if f"{res[0]}p" == choice] + return matches[0] -def update_cfg(cfg_dict: dict, project_cfg_path: Path): - """Updates the manim.cfg file after reading it from the project_cfg_path. +def update_cfg(cfg_dict: dict[str, Any], project_cfg_path: Path) -> None: + """Update the ``manim.cfg`` file after reading it from the specified + ``project_cfg_path``. Parameters ---------- cfg_dict - values used to update manim.cfg found project_cfg_path. + Values used to update ``manim.cfg`` which is found in + ``project_cfg_path``. project_cfg_path - Path of manim.cfg file. + Path of the ``manim.cfg`` file. """ config = configparser.ConfigParser() config.read(project_cfg_path) @@ -85,7 +89,7 @@ def update_cfg(cfg_dict: dict, project_cfg_path: Path): context_settings=CONTEXT_SETTINGS, epilog=EPILOG, ) -@cloup.argument("project_name", type=Path, required=False) +@cloup.argument("project_name", type=cloup.Path(path_type=Path), required=False) @cloup.option( "-d", "--default", @@ -94,13 +98,14 @@ def update_cfg(cfg_dict: dict, project_cfg_path: Path): help="Default settings for project creation.", nargs=1, ) -def project(default_settings, **args): +def project(default_settings: bool, **kwargs: Any) -> None: """Creates a new project. PROJECT_NAME is the name of the folder in which the new project will be initialized. """ - if args["project_name"]: - project_name = args["project_name"] + project_name: Path + if kwargs["project_name"]: + project_name = kwargs["project_name"] else: project_name = click.prompt("Project Name", type=Path) @@ -117,7 +122,7 @@ def project(default_settings, **args): ) else: project_name.mkdir() - new_cfg = {} + new_cfg: dict[str, Any] = {} new_cfg_path = Path.resolve(project_name / "manim.cfg") if not default_settings: @@ -145,23 +150,23 @@ def project(default_settings, **args): ) @cloup.argument("scene_name", type=str, required=True) @cloup.argument("file_name", type=str, required=False) -def scene(**args): +def scene(**kwargs: Any) -> None: """Inserts a SCENE to an existing FILE or creates a new FILE. SCENE is the name of the scene that will be inserted. FILE is the name of file in which the SCENE will be inserted. """ - template_name = click.prompt( + template_name: str = click.prompt( "template", type=click.Choice(get_template_names(), False), default="Default", ) scene = (get_template_path() / f"{template_name}.mtp").resolve().read_text() - scene = scene.replace(template_name + "Template", args["scene_name"], 1) + scene = scene.replace(template_name + "Template", kwargs["scene_name"], 1) - if args["file_name"]: - file_name = Path(args["file_name"]) + if kwargs["file_name"]: + file_name = Path(kwargs["file_name"]) if file_name.suffix != ".py": file_name = file_name.with_suffix(file_name.suffix + ".py") @@ -190,7 +195,7 @@ def scene(**args): help="Create a new project or insert a new scene.", ) @cloup.pass_context -def init(ctx): +def init(ctx: cloup.Context) -> None: pass diff --git a/manim/cli/plugins/commands.py b/manim/cli/plugins/commands.py index d47325cd03..994e074242 100644 --- a/manim/cli/plugins/commands.py +++ b/manim/cli/plugins/commands.py @@ -10,8 +10,8 @@ import cloup -from ...constants import CONTEXT_SETTINGS, EPILOG -from ...plugins.plugins_flags import list_plugins +from manim.constants import CONTEXT_SETTINGS, EPILOG +from manim.plugins.plugins_flags import list_plugins __all__ = ["plugins"] @@ -29,6 +29,16 @@ is_flag=True, help="List available plugins.", ) -def plugins(list_available): +def plugins(list_available: bool) -> None: + """Print a list of all available plugins when calling ``manim plugins -l`` + or ``manim plugins --list``. + + Parameters + ---------- + list_available + If the ``-l`` or ``-list`` option is passed to ``manim plugins``, this + parameter will be set to ``True``, which will print a list of all + available plugins. + """ if list_available: list_plugins() diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index 4a1810a11d..fde82f4970 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -13,77 +13,83 @@ import sys import urllib.error import urllib.request +from argparse import Namespace from pathlib import Path -from typing import cast +from typing import Any, cast import cloup -from ... import __version__, config, console, error_console, logger -from ..._config import tempconfig -from ...constants import EPILOG, RendererType -from ...utils.module_ops import scene_classes_from_file -from .ease_of_access_options import ease_of_access_options -from .global_options import global_options -from .output_options import output_options -from .render_options import render_options +from manim import __version__ +from manim._config import ( + config, + console, + error_console, + logger, + tempconfig, +) +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 __all__ = ["render"] +class ClickArgs(Namespace): + def __init__(self, args: dict[str, Any]) -> None: + for name in args: + setattr(self, name, args[name]) + + def _get_kwargs(self) -> list[tuple[str, Any]]: + return list(self.__dict__.items()) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ClickArgs): + return NotImplemented + return vars(self) == vars(other) + + def __contains__(self, key: str) -> bool: + return key in self.__dict__ + + def __repr__(self) -> str: + return str(self.__dict__) + + @cloup.command( context_settings=None, no_args_is_help=True, epilog=EPILOG, ) -@cloup.argument("file", type=Path, required=True) +@cloup.argument("file", type=cloup.Path(path_type=Path), required=True) @cloup.argument("scene_names", required=False, nargs=-1) @global_options @output_options @render_options @ease_of_access_options -def render( - **args, -): +def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: """Render SCENE(S) from the input FILE. FILE is the file path of the script or a config file. SCENES is an optional list of scenes in the file. """ - if args["save_as_gif"]: + if kwargs["save_as_gif"]: logger.warning("--save_as_gif is deprecated, please use --format=gif instead!") - args["format"] = "gif" + kwargs["format"] = "gif" - if args["save_pngs"]: + if kwargs["save_pngs"]: logger.warning("--save_pngs is deprecated, please use --format=png instead!") - args["format"] = "png" + kwargs["format"] = "png" - if args["show_in_file_browser"]: + 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.", ) - class ClickArgs: - def __init__(self, args): - for name in args: - setattr(self, name, args[name]) - - def _get_kwargs(self): - return list(self.__dict__.items()) - - def __eq__(self, other): - if not isinstance(other, ClickArgs): - return NotImplemented - return vars(self) == vars(other) - - def __contains__(self, key): - return key in self.__dict__ - - def __repr__(self): - return str(self.__dict__) - - click_args = ClickArgs(args) - if args["jupyter"]: + click_args = ClickArgs(kwargs) + if kwargs["jupyter"]: return click_args config.digest_args(click_args) @@ -152,4 +158,4 @@ def __repr__(self): "You should consider upgrading via [yellow]pip install -U manim[/yellow]", ) - return args + return kwargs diff --git a/manim/cli/render/global_options.py b/manim/cli/render/global_options.py index 5941d4fd68..32f9547b0c 100644 --- a/manim/cli/render/global_options.py +++ b/manim/cli/render/global_options.py @@ -2,22 +2,55 @@ import logging import re +import sys +from typing import TYPE_CHECKING from cloup import Choice, option, option_group +if TYPE_CHECKING: + from click import Context, Option + __all__ = ["global_options"] logger = logging.getLogger("manim") -def validate_gui_location(ctx, param, value): - if value: - try: - x_offset, y_offset = map(int, re.split(r"[;,\-]", value)) - return (x_offset, y_offset) - except Exception: - logger.error("GUI location option is invalid.") - exit() +def validate_gui_location( + ctx: Context, param: Option, value: str | None +) -> tuple[int, int] | None: + """If the ``value`` string is given, extract from it the GUI location, + which should be in any of these formats: 'x;y', 'x,y' or 'x-y'. + + Parameters + ---------- + ctx + The Click context. + param + A Click option. + value + The optional string which will be parsed. + + Returns + ------- + tuple[int, int] | None + If ``value`` is ``None``, the return value is ``None``. Otherwise, it's + the ``(x, y)`` location for the GUI. + + Raises + ------ + ValueError + If ``value`` has an invalid format. + """ + if value is None: + return None + + try: + x_offset, y_offset = map(int, re.split(r"[;,\-]", value)) + except Exception: + logger.error("GUI location option is invalid.") + sys.exit() + + return (x_offset, y_offset) global_options = option_group( diff --git a/manim/cli/render/render_options.py b/manim/cli/render/render_options.py index fabcb8d877..0f069c04e0 100644 --- a/manim/cli/render/render_options.py +++ b/manim/cli/render/render_options.py @@ -2,40 +2,104 @@ import logging import re +import sys +from typing import TYPE_CHECKING from cloup import Choice, option, option_group from manim.constants import QUALITIES, RendererType +if TYPE_CHECKING: + from click import Context, Option + __all__ = ["render_options"] logger = logging.getLogger("manim") -def validate_scene_range(ctx, param, value): +def validate_scene_range( + ctx: Context, param: Option, value: str | None +) -> tuple[int] | tuple[int, int] | None: + """If the ``value`` string is given, extract from it the scene range, which + should be in any of these formats: 'start', 'start;end', 'start,end' or + 'start-end'. Otherwise, return ``None``. + + Parameters + ---------- + ctx + The Click context. + param + A Click option. + value + The optional string which will be parsed. + + Returns + ------- + tuple[int] | tuple[int, int] | None + If ``value`` is ``None``, the return value is ``None``. Otherwise, it's + the scene range, given by a tuple which may contain a single value + ``start`` or two values ``start`` and ``end``. + + Raises + ------ + ValueError + If ``value`` has an invalid format. + """ + if value is None: + return None + try: start = int(value) return (start,) except Exception: pass - if value: - try: - start, end = map(int, re.split(r"[;,\-]", value)) - return start, end - except Exception: - logger.error("Couldn't determine a range for -n option.") - exit() + try: + start, end = map(int, re.split(r"[;,\-]", value)) + except Exception: + logger.error("Couldn't determine a range for -n option.") + sys.exit() + + return start, end -def validate_resolution(ctx, param, value): - if value: - try: - start, end = map(int, re.split(r"[;,\-]", value)) - return (start, end) - except Exception: - logger.error("Resolution option is invalid.") - exit() +def validate_resolution( + ctx: Context, param: Option, value: str | None +) -> tuple[int, int] | None: + """If the ``value`` string is given, extract from it the resolution, which + should be in any of these formats: 'W;H', 'W,H' or 'W-H'. Otherwise, return + ``None``. + + Parameters + ---------- + ctx + The Click context. + param + A Click option. + value + The optional string which will be parsed. + + Returns + ------- + tuple[int, int] | None + If ``value`` is ``None``, the return value is ``None``. Otherwise, it's + the resolution as a ``(W, H)`` tuple. + + Raises + ------ + ValueError + If ``value`` has an invalid format. + """ + if value is None: + return None + + try: + width, height = map(int, re.split(r"[;,\-]", value)) + except Exception: + logger.error("Resolution option is invalid.") + sys.exit() + + return width, height render_options = option_group( @@ -72,7 +136,7 @@ def validate_resolution(ctx, param, value): "--quality", default=None, type=Choice( - reversed([q["flag"] for q in QUALITIES.values() if q["flag"]]), # type: ignore[arg-type] + list(reversed([q["flag"] for q in QUALITIES.values() if q["flag"]])), case_sensitive=False, ), help="Render quality at the follow resolution framerates, respectively: " diff --git a/manim/constants.py b/manim/constants.py index a6c2f9199c..0a3e00da85 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import Enum +from typing import TypedDict import numpy as np from cloup import Context @@ -197,8 +198,16 @@ DEGREES = TAU / 360 """The exchange rate between radians and degrees.""" + +class QualityDict(TypedDict): + flag: str | None + pixel_height: int + pixel_width: int + frame_rate: int + + # Video qualities -QUALITIES: dict[str, dict[str, str | int | None]] = { +QUALITIES: dict[str, QualityDict] = { "fourk_quality": { "flag": "k", "pixel_height": 2160, diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index cdb63ceaa5..ead0d0d82b 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -216,7 +216,11 @@ def interpolate(self, *args, **kwargs): class OpenGLRenderer: - def __init__(self, file_writer_class=SceneFileWriter, skip_animations=False): + def __init__( + self, + file_writer_class: type[SceneFileWriter] = SceneFileWriter, + skip_animations: bool = False, + ) -> None: # Measured in pixel widths, used for vector graphics self.anti_alias_width = 1.5 self._file_writer_class = file_writer_class diff --git a/manim/scene/scene.py b/manim/scene/scene.py index fe0f993413..f171709b4f 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -100,12 +100,12 @@ def construct(self): def __init__( self, - renderer=None, - camera_class=Camera, - always_update_mobjects=False, - random_seed=None, - skip_animations=False, - ): + renderer: CairoRenderer | OpenGLRenderer | None = None, + camera_class: type[Camera] = Camera, + always_update_mobjects: bool = False, + random_seed: int | None = None, + skip_animations: bool = False, + ) -> None: self.camera_class = camera_class self.always_update_mobjects = always_update_mobjects self.random_seed = random_seed diff --git a/manim/utils/file_ops.py b/manim/utils/file_ops.py index 05a31e6e46..e4fbdebfe5 100644 --- a/manim/utils/file_ops.py +++ b/manim/utils/file_ops.py @@ -188,7 +188,7 @@ def modify_atime(file_path: str) -> None: os.utime(file_path, times=(time.time(), Path(file_path).stat().st_mtime)) -def open_file(file_path, in_browser=False): +def open_file(file_path: Path, in_browser: bool = False) -> None: current_os = platform.system() if current_os == "Windows": os.startfile(file_path if not in_browser else file_path.parent) @@ -251,7 +251,7 @@ def get_template_path() -> Path: return Path.resolve(Path(__file__).parent.parent / "templates") -def add_import_statement(file: Path): +def add_import_statement(file: Path) -> None: """Prepends an import statement in a file Parameters diff --git a/mypy.ini b/mypy.ini index 12c0a1dd69..65a77f1d00 100644 --- a/mypy.ini +++ b/mypy.ini @@ -59,10 +59,10 @@ ignore_errors = True ignore_errors = True [mypy-manim.cli.*] -ignore_errors = True +ignore_errors = False [mypy-manim.cli.cfg.*] -ignore_errors = True +ignore_errors = False [mypy-manim.gui.*] ignore_errors = True