Skip to content

Commit dcd0a99

Browse files
committed
fix: Updated to Python 3.14 and pydantic V2 (kinda)
1 parent 3f29b02 commit dcd0a99

File tree

10 files changed

+131
-48
lines changed

10 files changed

+131
-48
lines changed

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,20 @@ authors = [
33
{name = "Smithed Team", email = "[email protected]"},
44
]
55
dependencies = [
6-
"beet @ git+https://github.com/Smithed-MC/beet@fix/overlay-folder-names",
7-
"mecha>=0.95.2",
6+
"beet>=0.113.0b10",
7+
"mecha>=0.102.0b2",
88
"typer>=0.9.0",
99
"tokenstream>=1.7.0",
1010
"backports-strenum>=1.2.8",
1111
"rich>=13.6.0",
12-
"pydantic>=2.5.2",
12+
"pydantic>=2.12.0",
1313
]
1414
description = "Smithed's Python client with CLI, Weld and more"
1515
license = "MIT"
1616
name = "smithed"
1717
readme = "README.md"
18-
requires-python = ">= 3.10"
19-
version = "0.19.0"
18+
requires-python = ">= 3.14"
19+
version = "0.20.0"
2020

2121
[project.scripts]
2222
weld = "smithed.weld:cli"

smithed/weld/merging/handler.py

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,17 @@ class since several methods based on each file are passing similar parameters wi
1313

1414
import logging
1515
from collections import defaultdict
16-
from collections.abc import Callable
16+
from collections.abc import Callable, Iterator
1717
from dataclasses import dataclass, field
1818
from importlib import resources
19-
from typing import Iterator, Literal, cast
19+
from typing import Literal, cast
2020

2121
from beet import Context, DataPack, JsonFile, ListOption, NamespaceFile
2222
from beet.contrib.format_json import get_formatter
2323
from beet.contrib.vanilla import Vanilla
24-
from pydantic.v1 import ValidationError
24+
from pydantic import ValidationError
2525

2626
from smithed.type import JsonDict, JsonTypeT
27-
from ..toolchain.process import PackProcessor
2827

2928
from ..models import (
3029
AppendRule,
@@ -38,9 +37,12 @@ class since several methods based on each file are passing similar parameters wi
3837
ReplaceRule,
3938
Rule,
4039
SmithedJsonFile,
40+
SmithedModel,
4141
ValueSource,
4242
deserialize,
43+
serialize_list_option,
4344
)
45+
from ..toolchain.process import PackProcessor
4446
from .errors import PriorityError
4547
from .parser import append, get, insert, merge, prepend, remove, replace
4648

@@ -52,6 +54,24 @@ class since several methods based on each file are passing similar parameters wi
5254
)
5355

5456

57+
def get_override(entry: SmithedModel | dict) -> bool:
58+
""" Safely get override attribute from entry that may be dict or model object.
59+
60+
Due to mixing Pydantic V1 (beet's ListOption) and V2 (SmithedModel),
61+
entries may be converted to dicts. This helper handles both cases.
62+
"""
63+
if isinstance(entry, dict):
64+
return entry.get("override", False) or False
65+
return entry.override or False
66+
67+
68+
def get_entry_id(entry: SmithedModel | dict) -> str:
69+
""" Safely get id attribute from entry that may be dict or model object. """
70+
if isinstance(entry, dict):
71+
return entry.get("id", "")
72+
return entry.id
73+
74+
5575
@dataclass
5676
class ConflictsHandler:
5777
ctx: Context
@@ -97,17 +117,17 @@ def __call__(
97117

98118
current_entries = smithed_current.smithed.entries()
99119

100-
if len(current_entries) > 0 and current_entries[0].override:
120+
if len(current_entries) > 0 and get_override(current_entries[0]):
101121
logger.critical(
102-
f"Overriding base file at `{path}` with {current_entries[0].id}"
122+
f"Overriding base file at `{path}` with {get_entry_id(current_entries[0])}"
103123
)
104124
self.overrides.add(path)
105125
return True
106126

107127
conflict_entries = smithed_conflict.smithed.entries()
108-
if len(conflict_entries) > 0 and conflict_entries[0].override:
128+
if len(conflict_entries) > 0 and get_override(conflict_entries[0]):
109129
logger.critical(
110-
f"Overriding base file at `{path}` with {conflict_entries[0].id}"
130+
f"Overriding base file at `{path}` with {get_entry_id(conflict_entries[0])}"
111131
)
112132
self.overrides.add(path)
113133
current.data = conflict.data
@@ -150,8 +170,8 @@ def __call__(
150170
current_entries.extend(conflict_entries)
151171

152172
# Save back to current file
153-
raw: JsonDict = deserialize(smithed_current)
154-
current.data["__smithed__"] = raw["__smithed__"]
173+
# Use serialize_list_option to avoid __root__ in the output
174+
current.data["__smithed__"] = serialize_list_option(smithed_current.smithed)
155175

156176
current.data = normalize_quotes(current.data)
157177

@@ -162,8 +182,12 @@ def parse_smithed_file(
162182
) -> SmithedJsonFile | Literal[False]:
163183
"""Parses a smithed file and returns the parsed file or False if invalid."""
164184

185+
# Preprocess data to remove __root__ fields that may have been created
186+
# by ListOption (Pydantic V1) serialization
187+
data = self.clean_list_option_data(file.data)
188+
165189
try:
166-
obj = SmithedJsonFile.parse_obj(file.data)
190+
obj = SmithedJsonFile.model_validate(data)
167191
except ValidationError:
168192
logger.error("Failed to parse smithed file ", exc_info=True)
169193
return False
@@ -180,6 +204,28 @@ def parse_smithed_file(
180204

181205
return obj
182206

207+
def clean_list_option_data(self, data: JsonDict) -> JsonDict:
208+
"""Remove __root__ fields from __smithed__ entries.
209+
210+
ListOption (Pydantic V1) serializes with __root__ field, which causes
211+
validation errors in Pydantic V2 models with extra="forbid".
212+
"""
213+
data = data.copy()
214+
215+
if "__smithed__" in data:
216+
smithed = data["__smithed__"]
217+
if isinstance(smithed, list):
218+
cleaned = []
219+
for entry in smithed:
220+
if isinstance(entry, dict) and "__root__" in entry:
221+
# Extract the actual data from __root__
222+
cleaned.append(entry["__root__"] if entry["__root__"] else {})
223+
else:
224+
cleaned.append(entry)
225+
data["__smithed__"] = cleaned
226+
227+
return data
228+
183229
def grab_vanilla(self, path: str, json_file_type: type[NamespaceFile]) -> JsonDict|None:
184230
"""Grabs the vanilla file to load as the current file (aka the base)."""
185231

@@ -198,9 +244,9 @@ def process(self):
198244
logger.info(f"Resolving {json_file_type.__name__}: {path!r}")
199245

200246
namespace_file = self.ctx.data[json_file_type]
201-
smithed_file = SmithedJsonFile.parse_obj(
202-
namespace_file[path].data # type: ignore
203-
)
247+
# Clean data before parsing to remove __root__ fields
248+
data = self.clean_list_option_data(namespace_file[path].data) # type: ignore
249+
smithed_file = SmithedJsonFile.model_validate(data)
204250

205251
if smithed_file.smithed.entries():
206252
processed = self.process_file(smithed_file)

smithed/weld/models/__init__.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from .conditions import Condition, ConditionInverted, ConditionPackCheck
2-
from .main import SmithedJsonFile, SmithedModel, deserialize
2+
from .main import SmithedJsonFile, SmithedModel, deserialize, serialize_list_option
33
from .priority import Priority
44
from .rules import (
55
AdditiveRule,
@@ -14,24 +14,32 @@
1414
)
1515
from .sources import ReferenceSource, Source, ValueSource
1616

17+
# Rebuild models to resolve forward references after all imports
18+
ConditionInverted.model_rebuild()
19+
ConditionPackCheck.model_rebuild()
20+
SmithedModel.model_rebuild()
21+
SmithedJsonFile.model_rebuild()
22+
1723
__all__ = [
18-
"deserialize",
1924
"AdditiveRule",
20-
"MergeRule",
2125
"AppendRule",
22-
"PrependRule",
23-
"InsertRule",
24-
"ReplaceRule",
25-
"RemoveRule",
26-
"Rule",
27-
"RuleHelper",
2826
"Condition",
2927
"ConditionInverted",
3028
"ConditionPackCheck",
29+
"InsertRule",
30+
"MergeRule",
31+
"PrependRule",
3132
"Priority",
3233
"ReferenceSource",
33-
"ValueSource",
34-
"Source",
35-
"SmithedModel",
34+
"RemoveRule",
35+
"ReplaceRule",
36+
"Rule",
37+
"RuleHelper",
3638
"SmithedJsonFile",
39+
"SmithedModel",
40+
"Source",
41+
"ValueSource",
42+
"deserialize",
43+
"serialize_list_option",
3744
]
45+

smithed/weld/models/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import TypeVar
22

3-
from pydantic.v1 import BaseModel as _BaseModel
3+
from pydantic import BaseModel as _BaseModel
44

55
T = TypeVar("T")
66

smithed/weld/models/conditions.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,3 @@ class ConditionPackCheck(BaseModel):
1313
class ConditionInverted(BaseModel):
1414
type: Literal["inverted", "weld:inverted", "smithed:inverted"]
1515
conditions: list["Condition"]
16-
17-
18-
ConditionInverted.update_forward_refs()

smithed/weld/models/main.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any
44

55
from beet import ListOption
6-
from pydantic.v1 import Field, root_validator
6+
from pydantic import Field, model_validator
77

88
from ..merging.parser import get
99
from .base import BaseModel
@@ -15,7 +15,34 @@
1515

1616

1717
def deserialize(model: BaseModel, defaults: bool = True):
18-
return json.loads(model.json(by_alias=True, exclude_defaults=not defaults))
18+
""" Serialize a Pydantic model to dict.
19+
20+
Uses Pydantic V2 API (model_dump_json) with fallback to V1 (json).
21+
"""
22+
try:
23+
# Pydantic V2
24+
return json.loads(model.model_dump_json(
25+
by_alias=True,
26+
exclude_defaults=not defaults
27+
))
28+
except AttributeError:
29+
# Pydantic V1 fallback
30+
return json.loads(model.json(by_alias=True, exclude_defaults=not defaults))
31+
32+
33+
def serialize_list_option(list_option: ListOption, defaults: bool = True) -> list:
34+
""" Serialize a ListOption to a list of dicts.
35+
36+
Avoids the __root__ field issue by directly serializing the entries.
37+
"""
38+
result = []
39+
for entry in list_option.entries():
40+
if isinstance(entry, BaseModel):
41+
result.append(deserialize(entry, defaults))
42+
else:
43+
# Already a dict
44+
result.append(entry)
45+
return result
1946

2047

2148
class SmithedModel(BaseModel, extra="forbid"):
@@ -25,24 +52,29 @@ class SmithedModel(BaseModel, extra="forbid"):
2552
priority: Priority | None = None
2653
rules: list[Rule] = []
2754

28-
@root_validator
55+
@model_validator(mode="before")
2956
def push_down_priorities(cls, values: dict[str, Any]) -> dict[str, Any]:
3057
"""Push down top-level priority to every rule.
3158
3259
If a rule has a priority defined, it will not be overwritten.
3360
"""
3461

35-
rules: list[Rule] = values.get("rules") # type: ignore
62+
rules: list[Rule] | None = values.get("rules") # type: ignore
3663
priority: Priority | None = values.get("priority") # type: ignore
3764

65+
if rules is None:
66+
rules = []
67+
values["rules"] = rules
68+
3869
if priority is None:
3970
priority = Priority()
4071

4172
for rule in rules:
4273
if rule.priority is None:
4374
rule.priority = priority
4475

45-
values.pop("priority")
76+
if "priority" in values:
77+
values.pop("priority")
4678

4779
return values
4880

@@ -55,7 +87,7 @@ class SmithedJsonFile(BaseModel, extra="allow"):
5587
default_factory=ListOption, alias="__smithed__"
5688
)
5789

58-
@root_validator
90+
@model_validator(mode="before")
5991
def convert_type(cls, values: dict[str, ListOption[SmithedModel]]):
6092
if smithed := values.get("smithed"):
6193
for model in smithed.entries():

smithed/weld/models/priority.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Literal
33

44
from beet import ListOption
5-
from pydantic.v1 import validator
5+
from pydantic import field_validator
66

77
from .base import BaseModel
88

@@ -16,6 +16,6 @@ class Priority(BaseModel):
1616
before: ListOption[str] = ListOption()
1717
after: ListOption[str] = ListOption()
1818

19-
@validator("before", "after")
19+
@field_validator("before", "after")
2020
def convert_fields(cls, value: ListOption[str]):
2121
return ListOption(__root__=list(dict.fromkeys(value.entries())))

smithed/weld/models/rules.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from typing import Annotated, Literal
55

6-
from pydantic.v1 import Field, validator
6+
from pydantic import Field, field_validator
77

88
from .base import BaseModel
99
from .conditions import Condition
@@ -19,7 +19,7 @@ class BaseRule(BaseModel):
1919
conditions: list[Condition] = []
2020
priority: Priority | None = None
2121

22-
@validator("type")
22+
@field_validator("type")
2323
def fix_type(cls, value: str):
2424
if value.startswith("smithed:"):
2525
return value.replace("smithed:", "weld:")

smithed/weld/models/sources.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from typing import Annotated, Any, Literal
22

3-
from pydantic.v1 import Field, validator
3+
from pydantic import Field, field_validator
44

55
from .base import BaseModel
66

77

88
class _Source(BaseModel):
99
type: str
1010

11-
@validator("type")
11+
@field_validator("type")
1212
def fix_type(cls, value: str):
1313
if value.startswith("smithed:"):
1414
return value.replace("smithed:", "weld:")

smithed/weld/webapp/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from dataclasses import dataclass
22
from typing import NamedTuple
33

4-
from pydantic.v1 import BaseModel
4+
from pydantic import BaseModel
55
from streamlit.delta_generator import DeltaGenerator
66

77

0 commit comments

Comments
 (0)