fix: config update logic

This commit is contained in:
Benexl
2025-07-06 17:40:20 +03:00
parent 2bd02c7e99
commit e35683e90a
8 changed files with 79 additions and 49 deletions

View File

@@ -2,6 +2,7 @@ import click
from click.core import ParameterSource
from .. import __version__
from ..core.constants import APP_NAME
from .config import AppConfig, ConfigLoader
from .constants import USER_CONFIG_PATH
from .options import options_from_model
@@ -15,7 +16,12 @@ commands = {
@click.version_option(__version__, "--version")
@click.option("--no-config", is_flag=True, help="Don't load the user config file.")
@click.group(cls=LazyGroup, root="fastanime.cli.commands", lazy_subcommands=commands)
@click.group(
cls=LazyGroup,
root="fastanime.cli.commands",
lazy_subcommands=commands,
context_settings=dict(auto_envvar_prefix=APP_NAME),
)
@options_from_model(AppConfig)
@click.pass_context
def cli(ctx: click.Context, no_config: bool, **kwargs):
@@ -34,7 +40,10 @@ def cli(ctx: click.Context, no_config: bool, **kwargs):
# update app config with command line parameters
for param_name, param_value in ctx.params.items():
source = ctx.get_parameter_source(param_name)
if source == ParameterSource.COMMANDLINE:
if (
source == ParameterSource.ENVIRONMENT
or source == ParameterSource.COMMANDLINE
):
parameter = None
for param in ctx.command.params:
if param.name == param_name:

View File

@@ -21,25 +21,27 @@ CONFIG_HEADER = f"""
def generate_config_ini_from_app_model(app_model: AppConfig) -> str:
"""Generate a configuration file content from a Pydantic model."""
model_schema = AppConfig.model_json_schema()
model_schema = AppConfig.model_json_schema(mode="serialization")
app_model_dict = app_model.model_dump()
config_ini_content = [CONFIG_HEADER]
for section_name, section_model in app_model:
section_class_name = model_schema["properties"][section_name]["$ref"].split(
"/"
)[-1]
section_comment = model_schema["$defs"][section_class_name]["description"]
for section_name, section_dict in app_model_dict.items():
section_ref = model_schema["properties"][section_name].get("$ref")
if not section_ref:
continue
section_class_name = section_ref.split("/")[-1]
section_schema = model_schema["$defs"][section_class_name]
section_comment = section_schema.get("description", "")
config_ini_content.append(f"\n#\n# {section_comment}\n#")
config_ini_content.append(f"[{section_name}]")
for field_name, field_value in section_model:
description = model_schema["$defs"][section_class_name]["properties"][
field_name
].get("description", "")
for field_name, field_value in section_dict.items():
field_properties = section_schema.get("properties", {}).get(field_name, {})
description = field_properties.get("description", "")
if description:
# Wrap long comments for better readability in the .ini file
wrapped_comment = textwrap.fill(
description,
width=78,
@@ -58,4 +60,5 @@ def generate_config_ini_from_app_model(app_model: AppConfig) -> str:
value_str = str(field_value)
config_ini_content.append(f"{field_name} = {value_str}")
return "\n".join(config_ini_content)

View File

@@ -78,7 +78,6 @@ class ConfigLoader:
section: dict(self.parser.items(section))
for section in self.parser.sections()
}
try:
app_config = AppConfig.model_validate(config_dict)
return app_config

View File

@@ -2,7 +2,7 @@ import os
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, PrivateAttr, computed_field, field_validator
from ...core.constants import (
FZF_DEFAULT_OPTS,
@@ -23,23 +23,11 @@ class OtherConfig(BaseModel):
class FzfConfig(OtherConfig):
"""Configuration specific to the FZF selector."""
opts: str = Field(
default_factory=lambda: "\n"
+ "\n".join(
[
f"\t{line}"
for line in FZF_DEFAULT_OPTS.read_text(encoding="utf-8").split()
]
),
description="Command-line options to pass to FZF for theming and behavior.",
)
_opts: str = PrivateAttr(default=FZF_DEFAULT_OPTS.read_text(encoding="utf-8"))
header_color: str = Field(
default="95,135,175", description="RGB color for the main TUI header."
)
header_ascii_art: str = Field(
default="\n" + "\n".join([f"\t{line}" for line in APP_ASCII_ART.split("\n")]),
description="The ASCII art to display in TUI headers.",
)
_header_ascii_art: str = PrivateAttr(default=APP_ASCII_ART)
preview_header_color: str = Field(
default="215,0,95", description="RGB color for preview pane headers."
)
@@ -47,6 +35,32 @@ class FzfConfig(OtherConfig):
default="208,208,208", description="RGB color for preview pane separators."
)
def __init__(self, **kwargs):
opts = kwargs.pop("opts", None)
header_ascii_art = kwargs.pop("header_ascii_art", None)
super().__init__(**kwargs)
if opts:
self._opts = opts
if header_ascii_art:
self._header_ascii_art = header_ascii_art
@computed_field(
description="The FZF options, formatted with leading tabs for the config file."
)
@property
def opts(self) -> str:
return "\n" + "\n".join([f"\t{line}" for line in self._opts.split()])
@computed_field(
description="The ASCII art to display as a header in the FZF interface."
)
@property
def header_ascii_art(self) -> str:
return "\n" + "\n".join(
[f"\t{line}" for line in self._header_ascii_art.split()]
)
class RofiConfig(OtherConfig):
"""Configuration specific to the Rofi selector."""
@@ -203,7 +217,7 @@ class StreamConfig(BaseModel):
default="sub", description="Preferred audio/subtitle language type."
)
server: str = Field(
default="top",
default="TOP",
description="The default server to use from a provider. 'top' uses the first available.",
examples=SERVERS_AVAILABLE,
)

View File

@@ -9,7 +9,6 @@ from pydantic_core import PydanticUndefined
from .config.model import OtherConfig
# Mapping from Python/Pydantic types to Click types
TYPE_MAP = {
str: click.STRING,
int: click.INT,
@@ -49,38 +48,29 @@ def options_from_model(model: type[BaseModel], parent_name: str = "") -> Callabl
"""
decorators = []
# Check if this model inherits from ExternalTool
is_external_tool = issubclass(model, OtherConfig)
model_name = model.__name__.lower().replace("config", "")
# Introspect the model's fields
for field_name, field_info in model.model_fields.items():
# Handle nested models by calling this function recursively
if isinstance(field_info.annotation, type) and issubclass(
field_info.annotation, BaseModel
):
# Apply decorators from the nested model with current model as parent
nested_decorators = options_from_model(field_info.annotation, field_name)
nested_decorator_list = getattr(nested_decorators, "decorators", [])
decorators.extend(nested_decorator_list)
continue
# Determine the option name for the CLI
if is_external_tool:
# For ExternalTool subclasses, use --model_name-field_name format
cli_name = f"--{model_name}-{field_name.replace('_', '-')}"
else:
cli_name = f"--{field_name.replace('_', '-')}"
# Build the arguments for the click.option decorator
kwargs = {
"type": _get_click_type(field_info),
"help": field_info.description or "",
}
# Handle boolean flags (e.g., --foo/--no-foo)
if field_info.annotation is bool:
# Set default value for boolean flags
if field_info.default is not PydanticUndefined:
kwargs["default"] = field_info.default
kwargs["show_default"] = True
@@ -89,9 +79,7 @@ def options_from_model(model: type[BaseModel], parent_name: str = "") -> Callabl
f"{cli_name}/--no-{model_name}-{field_name.replace('_', '-')}"
)
else:
# For non-external tools, we use the --no- prefix directly
cli_name = f"{cli_name}/--no-{field_name.replace('_', '-')}"
# For other types, set default if one is provided in the model
elif field_info.default is not PydanticUndefined:
kwargs["default"] = field_info.default
kwargs["show_default"] = True
@@ -106,6 +94,27 @@ def options_from_model(model: type[BaseModel], parent_name: str = "") -> Callabl
)
)
for field_name, computed_field_info in model.model_computed_fields.items():
if is_external_tool:
cli_name = f"--{model_name}-{field_name.replace('_', '-')}"
else:
cli_name = f"--{field_name.replace('_', '-')}"
kwargs = {
"type": TYPE_MAP[computed_field_info.return_type],
"help": computed_field_info.description or "",
}
decorators.append(
click.option(
cli_name,
cls=ConfigOption,
model_name=model_name,
field_name=field_name,
**kwargs,
)
)
def decorator(f: Callable) -> Callable:
# Apply the decorators in reverse order to the function
for deco in reversed(decorators):

View File

@@ -1,7 +1,7 @@
import os
from importlib import resources
APP_NAME = os.environ.get("FASTANIME_APPNAME", "fastanime")
APP_NAME = os.environ.get("FASTANIME_APP_NAME", "fastanime")
try:
pkg = resources.files("fastanime")

View File

@@ -1,5 +1 @@
"""
his module contains an abstraction for interaction with the anilist api making it easy and efficient
"""
from .api import AniListApi

View File

@@ -28,7 +28,7 @@ MP4_SERVER_JUICY_STREAM_REGEX = re.compile(
)
# graphql files
GQLS = resources.files("fastanime.libs.anime_provider.allanime")
GQLS = resources.files("fastanime.libs.providers.anime.allanime") / "queries"
SEARCH_GQL = Path(str(GQLS / "search.gql"))
ANIME_GQL = Path(str(GQLS / "anime.gql"))
EPISODE_GQL = Path(str(GQLS / "episode.gql"))