Files
FastAnime/viu_media/cli/config/generate.py
2025-11-18 15:56:28 +03:00

221 lines
7.8 KiB
Python

# viu_media/cli/config/generate.py
import itertools
import json
import textwrap
from enum import Enum
from pathlib import Path
from typing import Any, Literal, get_args, get_origin
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from ...core.config import AppConfig
from ...core.constants import (
APP_ASCII_ART,
CLI_NAME,
DISCORD_INVITE,
REPO_HOME,
SUPPORT_PROJECT_URL,
)
# The header for the config file.
config_asci = "\n".join(
[f"# {line}" for line in APP_ASCII_ART.read_text(encoding="utf-8").split()]
)
CONFIG_HEADER = f"""
# ==============================================================================
#
{config_asci}
#
# ==============================================================================
# This is the Viu configuration file. It uses the TOML format.
# You can modify these values to customize the behavior of Viu.
# For more information on the available options, please refer to the
# official documentation on GitHub.
# ==============================================================================
""".lstrip()
CONFIG_FOOTER = f"""
# ==============================================================================
#
# HOPE YOU ENJOY {CLI_NAME} AND BE SURE TO STAR THE PROJECT ON GITHUB
# {REPO_HOME}
#
# Also join the discord server
# where the anime tech community lives :)
# {DISCORD_INVITE}
# If you like the project and are able to support it please consider buying me a coffee at {SUPPORT_PROJECT_URL}.
# If you would like to connect with me join the discord server from there you can dm for hackathons, or even to tell me a joke 😂
# Otherwise enjoy your terminal anime browser experience 😁
#
# ==============================================================================
""".lstrip()
def generate_config_toml_from_app_model(app_model: AppConfig) -> str:
"""Generate a TOML configuration file content from a Pydantic model with comments."""
config_content_parts = [CONFIG_HEADER]
for section_name, section_model in app_model:
section_title = section_model.model_config.get("title", section_name.title())
config_content_parts.append(f"\n#\n# {section_title}\n#")
config_content_parts.append(f"[{section_name}]")
for field_name, field_info in itertools.chain(
section_model.model_fields.items(),
section_model.model_computed_fields.items(),
):
# --- Generate Comments ---
description = field_info.description or ""
if description:
wrapped_comment = textwrap.fill(
description,
width=78,
initial_indent="# ",
subsequent_indent="# ",
)
config_content_parts.append(f"\n{wrapped_comment}")
field_type_comment = _get_field_type_comment(field_info)
if field_type_comment:
wrapped_comment = textwrap.fill(
field_type_comment,
width=78,
initial_indent="# ",
subsequent_indent="# ",
)
config_content_parts.append(wrapped_comment)
if (
hasattr(field_info, "default")
and field_info.default is not PydanticUndefined
):
default_val = (
field_info.default.value
if isinstance(field_info.default, Enum)
else field_info.default
)
wrapped_comment = textwrap.fill(
f"Default: {_format_toml_value(default_val)}",
width=78,
initial_indent="# ",
subsequent_indent="# ",
)
config_content_parts.append(wrapped_comment)
# --- Generate Key-Value Pair ---
field_value = getattr(section_model, field_name)
if field_value is None:
config_content_parts.append(f"# {field_name} =")
else:
value_str = _format_toml_value(field_value)
config_content_parts.append(f"{field_name} = {value_str}")
config_content_parts.extend(["\n", CONFIG_FOOTER])
return "\n".join(config_content_parts)
def _format_toml_value(value: Any) -> str:
"""
Manually formats a Python value into a TOML-compliant string.
This avoids needing an external TOML writer dependency.
"""
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, Enum):
return f'"{value.value}"'
# Handle strings and Paths, differentiating between single and multi-line
if isinstance(value, (str, Path)):
str_val = str(value)
if "\n" in str_val:
# For multi-line strings, use triple quotes.
# Also, escape any triple quotes that might be in the string itself.
escaped_val = str_val.replace('"""', '\\"\\"\\"')
return f'"""\n{escaped_val}"""'
else:
# For single-line strings, use double quotes and escape relevant characters.
escaped_val = str_val.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped_val}"'
# Fallback for any other types
return f'"{str(value)}"'
def _get_field_type_comment(field_info: FieldInfo | ComputedFieldInfo) -> str:
"""Generate a comment with type information for a field."""
field_type = (
field_info.annotation
if isinstance(field_info, FieldInfo)
else field_info.return_type
)
possible_values = []
if field_type is not None:
if isinstance(field_type, type) and issubclass(field_type, Enum):
possible_values = [member.value for member in field_type]
elif hasattr(field_type, "__origin__") and get_origin(field_type) is Literal:
args = get_args(field_type)
if args:
possible_values = list(args)
if possible_values:
formatted_values = ", ".join(json.dumps(v) for v in possible_values)
return f"Possible values: [ {formatted_values} ]"
type_name = _get_type_name(field_type)
range_info = _get_range_info(field_info)
if range_info:
return f"Type: {type_name} ({range_info})"
elif type_name:
return f"Type: {type_name}"
return ""
def _get_type_name(field_type: Any) -> str:
"""Get a user-friendly name for a field's type."""
if field_type is str:
return "string"
if field_type is int:
return "integer"
if field_type is float:
return "float"
if field_type is bool:
return "boolean"
if field_type is Path:
return "path"
return ""
def _get_range_info(field_info: FieldInfo | ComputedFieldInfo) -> str:
"""Get a string describing the numeric range of a field."""
constraints = {}
if (
isinstance(field_info, FieldInfo)
and hasattr(field_info, "metadata")
and field_info.metadata
):
for constraint in field_info.metadata:
constraint_type = type(constraint).__name__
if constraint_type == "Ge" and hasattr(constraint, "ge"):
constraints["min"] = constraint.ge
elif constraint_type == "Le" and hasattr(constraint, "le"):
constraints["max"] = constraint.le
elif constraint_type == "Gt" and hasattr(constraint, "gt"):
constraints["min"] = constraint.gt + 1
elif constraint_type == "Lt" and hasattr(constraint, "lt"):
constraints["max"] = constraint.lt - 1
if constraints:
min_val = constraints.get("min", "N/A")
max_val = constraints.get("max", "N/A")
return f"Range: {min_val}-{max_val}"
return ""