diff --git a/pyproject.toml b/pyproject.toml index 133c6af..95682a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,20 +3,20 @@ authors = [ {name = "Smithed Team", email = "team@smithed.dev"}, ] dependencies = [ - "beet @ git+https://github.com/Smithed-MC/beet@fix/overlay-folder-names", - "mecha>=0.95.2", + "beet>=0.113.0b10", + "mecha>=0.102.0b2", "typer>=0.9.0", "tokenstream>=1.7.0", "backports-strenum>=1.2.8", "rich>=13.6.0", - "pydantic>=2.5.2", + "pydantic>=2.12.0", ] description = "Smithed's Python client with CLI, Weld and more" license = "MIT" name = "smithed" readme = "README.md" -requires-python = ">= 3.10" -version = "0.19.0" +requires-python = ">= 3.14" +version = "0.20.0" [project.scripts] weld = "smithed.weld:cli" diff --git a/smithed/weld/merging/handler.py b/smithed/weld/merging/handler.py index 608eb13..ee1f084 100644 --- a/smithed/weld/merging/handler.py +++ b/smithed/weld/merging/handler.py @@ -13,18 +13,17 @@ class since several methods based on each file are passing similar parameters wi import logging from collections import defaultdict -from collections.abc import Callable +from collections.abc import Callable, Iterator from dataclasses import dataclass, field from importlib import resources -from typing import Iterator, Literal, cast +from typing import Literal, cast from beet import Context, DataPack, JsonFile, ListOption, NamespaceFile from beet.contrib.format_json import get_formatter from beet.contrib.vanilla import Vanilla -from pydantic.v1 import ValidationError +from pydantic import ValidationError from smithed.type import JsonDict, JsonTypeT -from ..toolchain.process import PackProcessor from ..models import ( AppendRule, @@ -38,9 +37,12 @@ class since several methods based on each file are passing similar parameters wi ReplaceRule, Rule, SmithedJsonFile, + SmithedModel, ValueSource, deserialize, + serialize_list_option, ) +from ..toolchain.process import PackProcessor from .errors import PriorityError from .parser import append, get, insert, merge, prepend, remove, replace @@ -52,6 +54,24 @@ class since several methods based on each file are passing similar parameters wi ) +def get_override(entry: SmithedModel | dict) -> bool: + """ Safely get override attribute from entry that may be dict or model object. + + Due to mixing Pydantic V1 (beet's ListOption) and V2 (SmithedModel), + entries may be converted to dicts. This helper handles both cases. + """ + if isinstance(entry, dict): + return entry.get("override", False) or False + return entry.override or False + + +def get_entry_id(entry: SmithedModel | dict) -> str: + """ Safely get id attribute from entry that may be dict or model object. """ + if isinstance(entry, dict): + return entry.get("id", "") + return entry.id + + @dataclass class ConflictsHandler: ctx: Context @@ -97,17 +117,17 @@ def __call__( current_entries = smithed_current.smithed.entries() - if len(current_entries) > 0 and current_entries[0].override: + if len(current_entries) > 0 and get_override(current_entries[0]): logger.critical( - f"Overriding base file at `{path}` with {current_entries[0].id}" + f"Overriding base file at `{path}` with {get_entry_id(current_entries[0])}" ) self.overrides.add(path) return True conflict_entries = smithed_conflict.smithed.entries() - if len(conflict_entries) > 0 and conflict_entries[0].override: + if len(conflict_entries) > 0 and get_override(conflict_entries[0]): logger.critical( - f"Overriding base file at `{path}` with {conflict_entries[0].id}" + f"Overriding base file at `{path}` with {get_entry_id(conflict_entries[0])}" ) self.overrides.add(path) current.data = conflict.data @@ -150,8 +170,8 @@ def __call__( current_entries.extend(conflict_entries) # Save back to current file - raw: JsonDict = deserialize(smithed_current) - current.data["__smithed__"] = raw["__smithed__"] + # Use serialize_list_option to avoid __root__ in the output + current.data["__smithed__"] = serialize_list_option(smithed_current.smithed) current.data = normalize_quotes(current.data) @@ -162,8 +182,12 @@ def parse_smithed_file( ) -> SmithedJsonFile | Literal[False]: """Parses a smithed file and returns the parsed file or False if invalid.""" + # Preprocess data to remove __root__ fields that may have been created + # by ListOption (Pydantic V1) serialization + data = self.clean_list_option_data(file.data) + try: - obj = SmithedJsonFile.parse_obj(file.data) + obj = SmithedJsonFile.model_validate(data) except ValidationError: logger.error("Failed to parse smithed file ", exc_info=True) return False @@ -180,6 +204,28 @@ def parse_smithed_file( return obj + def clean_list_option_data(self, data: JsonDict) -> JsonDict: + """Remove __root__ fields from __smithed__ entries. + + ListOption (Pydantic V1) serializes with __root__ field, which causes + validation errors in Pydantic V2 models with extra="forbid". + """ + data = data.copy() + + if "__smithed__" in data: + smithed = data["__smithed__"] + if isinstance(smithed, list): + cleaned = [] + for entry in smithed: + if isinstance(entry, dict) and "__root__" in entry: + # Extract the actual data from __root__ + cleaned.append(entry["__root__"] if entry["__root__"] else {}) + else: + cleaned.append(entry) + data["__smithed__"] = cleaned + + return data + def grab_vanilla(self, path: str, json_file_type: type[NamespaceFile]) -> JsonDict|None: """Grabs the vanilla file to load as the current file (aka the base).""" @@ -198,9 +244,9 @@ def process(self): logger.info(f"Resolving {json_file_type.__name__}: {path!r}") namespace_file = self.ctx.data[json_file_type] - smithed_file = SmithedJsonFile.parse_obj( - namespace_file[path].data # type: ignore - ) + # Clean data before parsing to remove __root__ fields + data = self.clean_list_option_data(namespace_file[path].data) # type: ignore + smithed_file = SmithedJsonFile.model_validate(data) if smithed_file.smithed.entries(): processed = self.process_file(smithed_file) diff --git a/smithed/weld/models/__init__.py b/smithed/weld/models/__init__.py index e2b8cb9..12a4f19 100644 --- a/smithed/weld/models/__init__.py +++ b/smithed/weld/models/__init__.py @@ -1,5 +1,5 @@ from .conditions import Condition, ConditionInverted, ConditionPackCheck -from .main import SmithedJsonFile, SmithedModel, deserialize +from .main import SmithedJsonFile, SmithedModel, deserialize, serialize_list_option from .priority import Priority from .rules import ( AdditiveRule, @@ -14,24 +14,32 @@ ) from .sources import ReferenceSource, Source, ValueSource +# Rebuild models to resolve forward references after all imports +ConditionInverted.model_rebuild() +ConditionPackCheck.model_rebuild() +SmithedModel.model_rebuild() +SmithedJsonFile.model_rebuild() + __all__ = [ - "deserialize", "AdditiveRule", - "MergeRule", "AppendRule", - "PrependRule", - "InsertRule", - "ReplaceRule", - "RemoveRule", - "Rule", - "RuleHelper", "Condition", "ConditionInverted", "ConditionPackCheck", + "InsertRule", + "MergeRule", + "PrependRule", "Priority", "ReferenceSource", - "ValueSource", - "Source", - "SmithedModel", + "RemoveRule", + "ReplaceRule", + "Rule", + "RuleHelper", "SmithedJsonFile", + "SmithedModel", + "Source", + "ValueSource", + "deserialize", + "serialize_list_option", ] + diff --git a/smithed/weld/models/base.py b/smithed/weld/models/base.py index e097c0b..2af7c63 100644 --- a/smithed/weld/models/base.py +++ b/smithed/weld/models/base.py @@ -1,6 +1,6 @@ from typing import TypeVar -from pydantic.v1 import BaseModel as _BaseModel +from pydantic import BaseModel as _BaseModel T = TypeVar("T") diff --git a/smithed/weld/models/conditions.py b/smithed/weld/models/conditions.py index 73466d3..63aed32 100644 --- a/smithed/weld/models/conditions.py +++ b/smithed/weld/models/conditions.py @@ -13,6 +13,3 @@ class ConditionPackCheck(BaseModel): class ConditionInverted(BaseModel): type: Literal["inverted", "weld:inverted", "smithed:inverted"] conditions: list["Condition"] - - -ConditionInverted.update_forward_refs() diff --git a/smithed/weld/models/main.py b/smithed/weld/models/main.py index c76e9cd..404e6b9 100644 --- a/smithed/weld/models/main.py +++ b/smithed/weld/models/main.py @@ -3,7 +3,7 @@ from typing import Any from beet import ListOption -from pydantic.v1 import Field, root_validator +from pydantic import Field, model_validator from ..merging.parser import get from .base import BaseModel @@ -15,7 +15,34 @@ def deserialize(model: BaseModel, defaults: bool = True): - return json.loads(model.json(by_alias=True, exclude_defaults=not defaults)) + """ Serialize a Pydantic model to dict. + + Uses Pydantic V2 API (model_dump_json) with fallback to V1 (json). + """ + try: + # Pydantic V2 + return json.loads(model.model_dump_json( + by_alias=True, + exclude_defaults=not defaults + )) + except AttributeError: + # Pydantic V1 fallback + return json.loads(model.json(by_alias=True, exclude_defaults=not defaults)) + + +def serialize_list_option(list_option: ListOption, defaults: bool = True) -> list: + """ Serialize a ListOption to a list of dicts. + + Avoids the __root__ field issue by directly serializing the entries. + """ + result = [] + for entry in list_option.entries(): + if isinstance(entry, BaseModel): + result.append(deserialize(entry, defaults)) + else: + # Already a dict + result.append(entry) + return result class SmithedModel(BaseModel, extra="forbid"): @@ -25,16 +52,20 @@ class SmithedModel(BaseModel, extra="forbid"): priority: Priority | None = None rules: list[Rule] = [] - @root_validator + @model_validator(mode="before") def push_down_priorities(cls, values: dict[str, Any]) -> dict[str, Any]: """Push down top-level priority to every rule. If a rule has a priority defined, it will not be overwritten. """ - rules: list[Rule] = values.get("rules") # type: ignore + rules: list[Rule] | None = values.get("rules") # type: ignore priority: Priority | None = values.get("priority") # type: ignore + if rules is None: + rules = [] + values["rules"] = rules + if priority is None: priority = Priority() @@ -42,7 +73,8 @@ def push_down_priorities(cls, values: dict[str, Any]) -> dict[str, Any]: if rule.priority is None: rule.priority = priority - values.pop("priority") + if "priority" in values: + values.pop("priority") return values @@ -55,7 +87,7 @@ class SmithedJsonFile(BaseModel, extra="allow"): default_factory=ListOption, alias="__smithed__" ) - @root_validator + @model_validator(mode="before") def convert_type(cls, values: dict[str, ListOption[SmithedModel]]): if smithed := values.get("smithed"): for model in smithed.entries(): diff --git a/smithed/weld/models/priority.py b/smithed/weld/models/priority.py index 827d543..68997c9 100644 --- a/smithed/weld/models/priority.py +++ b/smithed/weld/models/priority.py @@ -2,7 +2,7 @@ from typing import Literal from beet import ListOption -from pydantic.v1 import validator +from pydantic import field_validator from .base import BaseModel @@ -16,6 +16,6 @@ class Priority(BaseModel): before: ListOption[str] = ListOption() after: ListOption[str] = ListOption() - @validator("before", "after") + @field_validator("before", "after") def convert_fields(cls, value: ListOption[str]): return ListOption(__root__=list(dict.fromkeys(value.entries()))) diff --git a/smithed/weld/models/rules.py b/smithed/weld/models/rules.py index aa785ff..30e7eb4 100644 --- a/smithed/weld/models/rules.py +++ b/smithed/weld/models/rules.py @@ -3,7 +3,7 @@ import logging from typing import Annotated, Literal -from pydantic.v1 import Field, validator +from pydantic import Field, field_validator from .base import BaseModel from .conditions import Condition @@ -19,7 +19,7 @@ class BaseRule(BaseModel): conditions: list[Condition] = [] priority: Priority | None = None - @validator("type") + @field_validator("type") def fix_type(cls, value: str): if value.startswith("smithed:"): return value.replace("smithed:", "weld:") diff --git a/smithed/weld/models/sources.py b/smithed/weld/models/sources.py index 4baac05..3eb5f19 100644 --- a/smithed/weld/models/sources.py +++ b/smithed/weld/models/sources.py @@ -1,6 +1,6 @@ from typing import Annotated, Any, Literal -from pydantic.v1 import Field, validator +from pydantic import Field, field_validator from .base import BaseModel @@ -8,7 +8,7 @@ class _Source(BaseModel): type: str - @validator("type") + @field_validator("type") def fix_type(cls, value: str): if value.startswith("smithed:"): return value.replace("smithed:", "weld:") diff --git a/smithed/weld/webapp/models.py b/smithed/weld/webapp/models.py index 948f3b9..1bdd74d 100644 --- a/smithed/weld/webapp/models.py +++ b/smithed/weld/webapp/models.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import NamedTuple -from pydantic.v1 import BaseModel +from pydantic import BaseModel from streamlit.delta_generator import DeltaGenerator