From a47e1cff36bc70159d8168c9a03214f4ea1f3253 Mon Sep 17 00:00:00 2001 From: Sergey Fedorov Date: Wed, 20 Aug 2025 03:46:39 +0800 Subject: [PATCH] Fixes for pydantic --- viu_media/cli/cli.py | 2 +- .../commands/anilist/commands/downloads.py | 2 +- .../cli/commands/anilist/commands/search.py | 2 +- viu_media/cli/commands/config.py | 2 +- .../cli/commands/registry/commands/search.py | 2 +- viu_media/cli/config/editor.py | 6 +- viu_media/cli/config/generate.py | 9 +-- viu_media/cli/config/loader.py | 2 +- .../cli/interactive/menu/media/episodes.py | 2 +- .../interactive/menu/media/play_downloads.py | 8 +-- .../interactive/menu/media/player_controls.py | 6 +- .../cli/interactive/menu/media/servers.py | 2 +- viu_media/cli/interactive/state.py | 3 +- viu_media/cli/options.py | 55 ++++++------------- viu_media/cli/service/auth/service.py | 4 +- viu_media/cli/service/registry/models.py | 6 +- viu_media/cli/service/registry/service.py | 8 +-- viu_media/cli/service/session/model.py | 3 +- viu_media/cli/service/session/service.py | 4 +- viu_media/core/downloader/model.py | 3 +- viu_media/libs/media_api/types.py | 5 +- viu_media/libs/provider/anime/types.py | 3 +- 22 files changed, 57 insertions(+), 82 deletions(-) diff --git a/viu_media/cli/cli.py b/viu_media/cli/cli.py index 7a6c36a..bf0f4a2 100644 --- a/viu_media/cli/cli.py +++ b/viu_media/cli/cli.py @@ -103,7 +103,7 @@ def cli(ctx: click.Context, **options: "Unpack[Options]"): loader = ConfigLoader(config_path=USER_CONFIG) config = ( - AppConfig.model_validate(cli_overrides) + AppConfig(**cli_overrides) if options["no_config"] else loader.load(cli_overrides) ) diff --git a/viu_media/cli/commands/anilist/commands/downloads.py b/viu_media/cli/commands/anilist/commands/downloads.py index 18be58e..70ebdef 100644 --- a/viu_media/cli/commands/anilist/commands/downloads.py +++ b/viu_media/cli/commands/anilist/commands/downloads.py @@ -107,7 +107,7 @@ def downloads( return if output_json: - print(json.dumps(result.model_dump(mode="json"), indent=2)) + print(result.json(indent=2)) return from ....interactive.session import session diff --git a/viu_media/cli/commands/anilist/commands/search.py b/viu_media/cli/commands/anilist/commands/search.py index d4ae366..88295df 100644 --- a/viu_media/cli/commands/anilist/commands/search.py +++ b/viu_media/cli/commands/anilist/commands/search.py @@ -297,7 +297,7 @@ def search(config: AppConfig, **options: "Unpack[SearchOptions]"): if dump_json: # Use Pydantic's built-in serialization - print(json.dumps(search_result.model_dump(mode="json"))) + print(search_result.json()) else: # Launch interactive session for browsing results from ....interactive.session import session diff --git a/viu_media/cli/commands/config.py b/viu_media/cli/commands/config.py index 8be3a30..f58c4c3 100644 --- a/viu_media/cli/commands/config.py +++ b/viu_media/cli/commands/config.py @@ -93,7 +93,7 @@ def config( elif view_json: import json - print(json.dumps(user_config.model_dump(mode="json"))) + print(user_config.json()) elif generate_desktop_entry: _generate_desktop_entry() elif interactive: diff --git a/viu_media/cli/commands/registry/commands/search.py b/viu_media/cli/commands/registry/commands/search.py index 0ebb696..133e908 100644 --- a/viu_media/cli/commands/registry/commands/search.py +++ b/viu_media/cli/commands/registry/commands/search.py @@ -110,7 +110,7 @@ def search( return if output_json: - print(json.dumps(result.model_dump(mode="json"), indent=2)) + print(result.json(indent=2)) return _display_search_results(console, result, config.general.icons) diff --git a/viu_media/cli/config/editor.py b/viu_media/cli/config/editor.py index cf48b11..cc073b5 100644 --- a/viu_media/cli/config/editor.py +++ b/viu_media/cli/config/editor.py @@ -15,7 +15,7 @@ class InteractiveConfigEditor: """A wizard to guide users through setting up their configuration interactively.""" def __init__(self, current_config: AppConfig): - self.config = current_config.model_copy(deep=True) # Work on a copy + self.config = current_config.copy(deep=True) # Work on a copy def run(self) -> AppConfig: """Starts the interactive configuration wizard.""" @@ -48,7 +48,7 @@ class InteractiveConfigEditor: """Generates prompts for all fields in a given config section.""" print(f"\n--- [bold magenta]{section_name.title()} Settings[/bold magenta] ---") - for field_name, field_info in section_model.model_fields.items(): + for field_name, field_info in section_model.__fields__.items(): # Skip complex multi-line fields as agreed if section_name == "fzf" and field_name in ["opts", "header_ascii_art"]: continue @@ -77,7 +77,7 @@ class InteractiveConfigEditor: """Creates the appropriate InquirerPy prompt for a given Pydantic field.""" field_type = field_info.annotation help_text = textwrap.fill( - field_info.description or "No description available.", width=80 + field_info.field_info.description or "No description available.", width=80 ) message = f"{field_name.replace('_', ' ').title()}:" diff --git a/viu_media/cli/config/generate.py b/viu_media/cli/config/generate.py index d0a3546..b57eb44 100644 --- a/viu_media/cli/config/generate.py +++ b/viu_media/cli/config/generate.py @@ -6,8 +6,7 @@ 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 pydantic.fields import FieldInfo from ...core.config import AppConfig from ...core.constants import APP_ASCII_ART, CLI_NAME, DISCORD_INVITE, REPO_HOME @@ -49,17 +48,13 @@ config_content_parts = [CONFIG_HEADER] for section_name, section_model in app_model: - section_title = section_model.model_config.get("title", section_name.title()) + section_title = getattr(section_model.__config__, "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 "" + for field_name, field_info in section_model.__fields__.items(): + description = field_info.field_info.description or "" if description: wrapped_comment = textwrap.fill( description, @@ -81,7 +76,7 @@ if ( hasattr(field_info, "default") - and field_info.default is not PydanticUndefined + and field_info.default != None ): default_val = ( field_info.default.value @@ -138,13 +133,9 @@ return f'"{str(value)}"' -def _get_field_type_comment(field_info: FieldInfo | ComputedFieldInfo) -> str: +def _get_field_type_comment(field_info: FieldInfo) -> 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 - ) + field_type = field_info.type_ possible_values = [] if field_type is not None: @@ -184,15 +175,15 @@ return "" -def _get_range_info(field_info: FieldInfo | ComputedFieldInfo) -> str: +def _get_range_info(field_info: FieldInfo) -> 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 + and hasattr(field_info.field_info, "metadata") + and field_info.field_info.metadata ): - for constraint in field_info.metadata: + for constraint in field_info.field_info.metadata: constraint_type = type(constraint).__name__ if constraint_type == "Ge" and hasattr(constraint, "ge"): constraints["min"] = constraint.ge diff --git a/viu_media/cli/config/loader.py b/viu_media/cli/config/loader.py index 81b5bd6..8c9ab5d 100644 --- a/viu_media/cli/config/loader.py +++ b/viu_media/cli/config/loader.py @@ -104,7 +104,7 @@ class ConfigLoader: config_dict[section] = values try: - app_config = AppConfig.model_validate(config_dict) + app_config = AppConfig(**config_dict) return app_config except ValidationError as e: error_message = ( diff --git a/viu_media/cli/interactive/menu/media/episodes.py b/viu_media/cli/interactive/menu/media/episodes.py index bbde16d..ec75961 100644 --- a/viu_media/cli/interactive/menu/media/episodes.py +++ b/viu_media/cli/interactive/menu/media/episodes.py @@ -71,7 +71,7 @@ def episodes(ctx: Context, state: State) -> State | InternalDirective: return State( menu_name=MenuName.SERVERS, media_api=state.media_api, - provider=state.provider.model_copy( + provider=state.provider.copy( update={"episode_": chosen_episode, "start_time_": start_time} ), ) diff --git a/viu_media/cli/interactive/menu/media/play_downloads.py b/viu_media/cli/interactive/menu/media/play_downloads.py index 924efe9..5c04a48 100644 --- a/viu_media/cli/interactive/menu/media/play_downloads.py +++ b/viu_media/cli/interactive/menu/media/play_downloads.py @@ -78,7 +78,7 @@ def play_downloads(ctx: Context, state: State) -> State | InternalDirective: return State( menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS, media_api=state.media_api, - provider=state.provider.model_copy( + provider=state.provider.copy( update={"episode_": chosen_episode, "start_time_": start_time} ), ) @@ -149,7 +149,7 @@ def downloads_player_controls( return State( menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS, media_api=state.media_api, - provider=state.provider.model_copy( + provider=state.provider.copy( update={"episode_": next_episode_num, "start_time_": None} ), ) @@ -224,7 +224,7 @@ def _next_episode(ctx: Context, state: State) -> MenuAction: return State( menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS, media_api=state.media_api, - provider=state.provider.model_copy( + provider=state.provider.copy( update={"episode_": next_episode_num, "start_time_": None} ), ) @@ -273,7 +273,7 @@ def _previous_episode(ctx: Context, state: State) -> MenuAction: return State( menu_name=MenuName.DOWNLOADS_PLAYER_CONTROLS, media_api=state.media_api, - provider=state.provider.model_copy( + provider=state.provider.copy( update={"episode_": prev_episode_num, "start_time_": None} ), ) diff --git a/viu_media/cli/interactive/menu/media/player_controls.py b/viu_media/cli/interactive/menu/media/player_controls.py index 19811ac..3618b9d 100644 --- a/viu_media/cli/interactive/menu/media/player_controls.py +++ b/viu_media/cli/interactive/menu/media/player_controls.py @@ -42,7 +42,7 @@ def player_controls(ctx: Context, state: State) -> Union[State, InternalDirectiv return State( menu_name=MenuName.SERVERS, media_api=state.media_api, - provider=state.provider.model_copy(update={"episode_": next_episode_num}), + provider=state.provider.copy(update={"episode_": next_episode_num}), ) # --- Menu Options --- @@ -115,7 +115,7 @@ def _next_episode(ctx: Context, state: State) -> MenuAction: return State( menu_name=MenuName.SERVERS, media_api=state.media_api, - provider=state.provider.model_copy( + provider=state.provider.copy( update={"episode_": next_episode_num} ), ) @@ -149,7 +149,7 @@ def _previous_episode(ctx: Context, state: State) -> MenuAction: return State( menu_name=MenuName.SERVERS, media_api=state.media_api, - provider=state.provider.model_copy( + provider=state.provider.copy( update={"episode_": prev_episode_num} ), ) diff --git a/viu_media/cli/interactive/menu/media/servers.py b/viu_media/cli/interactive/menu/media/servers.py index 7b99137..9ee8642 100644 --- a/viu_media/cli/interactive/menu/media/servers.py +++ b/viu_media/cli/interactive/menu/media/servers.py @@ -102,7 +102,7 @@ def servers(ctx: Context, state: State) -> State | InternalDirective: return State( menu_name=MenuName.PLAYER_CONTROLS, media_api=state.media_api, - provider=state.provider.model_copy( + provider=state.provider.copy( update={ "servers_": server_map, "server_name_": selected_server.name, diff --git a/viu_media/cli/interactive/state.py b/viu_media/cli/interactive/state.py index 5a2df0e..181c975 100644 --- a/viu_media/cli/interactive/state.py +++ b/viu_media/cli/interactive/state.py @@ -51,7 +51,8 @@ class InternalDirective(Enum): class StateModel(BaseModel): - model_config = ConfigDict(frozen=True) + class Config: + frozen=True class MediaApiState(StateModel): diff --git a/viu_media/cli/options.py b/viu_media/cli/options.py index e0ccf17..e121e90 100644 --- a/viu_media/cli/options.py +++ b/viu_media/cli/options.py @@ -6,7 +6,6 @@ import click from pydantic import BaseModel from pydantic.fields import FieldInfo -from pydantic_core import PydanticUndefined from ..core.config.model import OtherConfig @@ -53,11 +51,11 @@ def options_from_model(model: type[BaseModel], parent_name: str = "") -> Callabl model_name = model.__name__.lower().replace("config", "") # Introspect the model's fields - for field_name, field_info in model.model_fields.items(): + for field_name, field_info in model.__fields__.items(): if isinstance(field_info.annotation, type) and issubclass( - field_info.annotation, BaseModel + field_info.type_, BaseModel ): - nested_decorators = options_from_model(field_info.annotation, field_name) + nested_decorators = options_from_model(field_info.type_, field_name) nested_decorator_list = getattr(nested_decorators, "decorators", []) decorators.extend(nested_decorator_list) continue @@ -68,17 +66,17 @@ def options_from_model(model: type[BaseModel], parent_name: str = "") -> Callabl cli_name = f"--{field_name.replace('_', '-')}" kwargs = { "type": _get_click_type(field_info), - "help": field_info.description or "", + "help": field_info.field_info.description or "", } if ( - field_info.annotation is not None - and isinstance(field_info.annotation, type) - and issubclass(field_info.annotation, Enum) + field_info.type_ is not None + and isinstance(field_info.type_, type) + and issubclass(field_info.type_, Enum) ): - kwargs["default"] = field_info.default.value - elif field_info.annotation is bool: - if field_info.default is not PydanticUndefined: + kwargs["default"] = field_info.default.value if field_info.default is not None else None + elif field_info.type_ is bool: + if field_info.default is not None: kwargs["default"] = field_info.default kwargs["show_default"] = True if is_external_tool: @@ -87,7 +85,7 @@ def options_from_model(model: type[BaseModel], parent_name: str = "") -> Callabl ) else: cli_name = f"{cli_name}/--no-{field_name.replace('_', '-')}" - elif field_info.default is not PydanticUndefined: + elif field_info.default is not None: kwargs["default"] = field_info.default kwargs["show_default"] = True @@ -101,27 +99,6 @@ 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): @@ -135,7 +112,7 @@ def options_from_model(model: type[BaseModel], parent_name: str = "") -> Callabl def _get_click_type(field_info: FieldInfo) -> Any: """Maps a Pydantic field's type to a corresponding click type.""" - field_type = field_info.annotation + field_type = field_info.type_ # check if type is enum if ( @@ -158,16 +135,16 @@ def _get_click_type(field_info: FieldInfo) -> Any: return click.Choice(args) # Check for examples in field_info - use as choices - if hasattr(field_info, "examples") and field_info.examples: - return click.Choice(field_info.examples) + if hasattr(field_info.field_info, "examples") and field_info.field_info.examples: + return click.Choice(field_info.field_info.examples) # Check for numeric constraints and create click.Range if field_type in (int, float): constraints = {} # Extract constraints from field_info.metadata - if hasattr(field_info, "metadata") and field_info.metadata: - for constraint in field_info.metadata: + if hasattr(field_info.field_info, "metadata") and field_info.field_info.metadata: + for constraint in field_info.field_info.metadata: constraint_type = type(constraint).__name__ if constraint_type == "Ge" and hasattr(constraint, "ge"): diff --git a/viu_media/cli/service/auth/service.py b/viu_media/cli/service/auth/service.py index 67b9fb4..aed6fb0 100644 --- a/viu_media/cli/service/auth/service.py +++ b/viu_media/cli/service/auth/service.py @@ -43,11 +43,11 @@ class AuthService: with self.path.open("r", encoding="utf-8") as f: data = json.load(f) - self._auth = AuthModel.model_validate(data) + self._auth = AuthModel(**data) return self._auth def _save_auth(self, auth: AuthModel): with self._lock: with AtomicWriter(self.path) as f: - json.dump(auth.model_dump(), f, indent=2) + f.write(auth.json(indent=2)) logger.info(f"Successfully saved user credentials to {self.path}") diff --git a/viu_media/cli/service/registry/models.py b/viu_media/cli/service/registry/models.py index 4420962..af694c4 100644 --- a/viu_media/cli/service/registry/models.py +++ b/viu_media/cli/service/registry/models.py @@ -4,7 +4,7 @@ from enum import Enum from pathlib import Path from typing import Dict, Literal, Optional -from pydantic import BaseModel, Field, computed_field +from pydantic import BaseModel, Field from ....core.utils import converter from ....libs.media_api.types import MediaItem, UserMediaListStatus @@ -67,7 +67,6 @@ class MediaRegistryIndexEntry(BaseModel): start_date: datetime = Field(default_factory=datetime.now) completed_at: datetime = Field(default_factory=datetime.now) - @computed_field @property def watch_completion_percentage(self) -> float: """Watch completion percentage.""" @@ -85,7 +84,6 @@ class MediaRegistryIndex(BaseModel): media_index: Dict[str, MediaRegistryIndexEntry] = Field(default_factory=dict) - @computed_field @property def status_breakdown(self) -> Dict[str, int]: """Get breakdown by user status.""" @@ -94,7 +92,6 @@ class MediaRegistryIndex(BaseModel): breakdown[entry.status.value] = breakdown.get(entry.status.value, 0) + 1 return breakdown - @computed_field @property def media_count_breakdown(self) -> Dict[str, int]: breakdown = {} @@ -102,7 +99,6 @@ class MediaRegistryIndex(BaseModel): breakdown[entry.media_api] = breakdown.get(entry.media_api, 0) + 1 return breakdown - @computed_field @property def media_count(self) -> int: """Get the number of media.""" diff --git a/viu_media/cli/service/registry/service.py b/viu_media/cli/service/registry/service.py index 7f0a411..45c2ba1 100644 --- a/viu_media/cli/service/registry/service.py +++ b/viu_media/cli/service/registry/service.py @@ -62,7 +62,7 @@ class MediaRegistryService: if self._index_file.exists(): with self._index_file.open("r", encoding="utf-8") as f: data = json.load(f) - self._index = MediaRegistryIndex.model_validate(data) + self._index = MediaRegistryIndex(**data) else: self._index = MediaRegistryIndex() self._save_index(self._index) @@ -81,7 +81,7 @@ class MediaRegistryService: with self._lock: index.last_updated = datetime.now() with AtomicWriter(self._index_file) as f: - json.dump(index.model_dump(mode="json"), f, indent=2) + f.write(index.json(indent=2)) logger.debug("saved registry index") @@ -107,7 +107,7 @@ class MediaRegistryService: data = json.load(record_file.open(mode="r", encoding="utf-8")) - record = MediaRecord.model_validate(data) + record = MediaRecord(**data) # logger.debug(f"Loaded media record for {media_id}") return record @@ -141,7 +141,7 @@ class MediaRegistryService: record_file = self._get_media_file_path(media_id) with AtomicWriter(record_file) as f: - json.dump(record.model_dump(mode="json"), f, indent=2, default=str) + f.write(record.json(indent=2)) logger.debug(f"Saved media record for {media_id}") return True diff --git a/viu_media/cli/service/session/model.py b/viu_media/cli/service/session/model.py index 1aa8ddb..085d3b9 100644 --- a/viu_media/cli/service/session/model.py +++ b/viu_media/cli/service/session/model.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import List, Optional -from pydantic import BaseModel, Field, computed_field +from pydantic import BaseModel, Field from ...interactive.state import State @@ -16,7 +16,6 @@ class Session(BaseModel): description: Optional[str] = None is_from_crash: bool = False - @computed_field @property def state_count(self) -> int: return len(self.history) diff --git a/viu_media/cli/service/session/service.py b/viu_media/cli/service/session/service.py index 3cdb625..e930a6f 100644 --- a/viu_media/cli/service/session/service.py +++ b/viu_media/cli/service/session/service.py @@ -65,7 +65,7 @@ class SessionsService: def _save_session(self, session: Session): path = self.dir / f"{session.name}.json" with AtomicWriter(path) as f: - json.dump(session.model_dump(mode="json", by_alias=True), f) + f.write(session.json(by_alias=True)) def _load_session(self, session_name: str) -> Optional[Session]: path = self.dir / f"{session_name}.json" @@ -75,7 +75,7 @@ class SessionsService: with path.open("r", encoding="utf-8") as f: data = json.load(f) - session = Session.model_validate(data) + session = Session(**data) logger.info(f"Session loaded from {path} with {session.state_count} states") return session diff --git a/viu_media/core/downloader/model.py b/viu_media/core/downloader/model.py index 3c7a07b..a7204b8 100644 --- a/viu_media/core/downloader/model.py +++ b/viu_media/core/downloader/model.py @@ -26,4 +26,5 @@ class DownloadResult(BaseModel): anime_title: str = Field(description="Title of the anime") episode_title: str = Field(description="Title of the episode") - model_config = {"arbitrary_types_allowed": True} + class Config: + arbitrary_types_allowed=True diff --git a/viu_media/libs/media_api/types.py b/viu_media/libs/media_api/types.py index 7118f35..630c2b7 100644 --- a/viu_media/libs/media_api/types.py +++ b/viu_media/libs/media_api/types.py @@ -76,7 +76,8 @@ # MODELS class BaseMediaApiModel(BaseModel): - model_config = ConfigDict(frozen=True) + class Config: + frozen=True class MediaImage(BaseMediaApiModel): @@ -408,4 +409,7 @@ _2024 = "2024" _2025 = "2025" _2026 = "2026" - \ No newline at end of file + +AiringScheduleResult.update_forward_refs() +CharacterSearchResult.update_forward_refs() +MediaTagItem.update_forward_refs() diff --git a/viu_media/libs/provider/anime/types.py b/viu_media/libs/provider/anime/types.py index c8afb96..e1a918c 100644 --- a/viu_media/libs/provider/anime/types.py +++ b/viu_media/libs/provider/anime/types.py @@ -37,7 +37,8 @@ class MediaTranslationType(Enum): # MODELS class BaseAnimeProviderModel(BaseModel): - model_config = ConfigDict(frozen=True) + class Config: + frozen=True class PageInfo(BaseAnimeProviderModel): --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ "httpx>=0.28.1", "inquirerpy>=0.3.4", "pycryptodomex>=3.23.0", - "pydantic>=2.11.7", + "pydantic", "rich>=13.9.2", ]