Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 25 additions & 19 deletions src/usethis/_integrations/pydantic/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,29 +126,35 @@ def _fancy_model_dump_base_model(

d: dict[str, ModelRepresentation] = {}
for key, value in model:
default_value = model.__class__.model_fields[key].default

# The value for the reference (for recursion)
value_ref = _get_value_ref(reference, key=key)

# If the model has default value, we usually won't dump it.
# There is an exception though: if we have a reference which we are trying
# to minimize the diff against, then if the diff includes the default
# explicitly, we should include it too.
# This is technically a limitation in what kind of diffs we can express in
# the dump but it's a relatively minor one.

if value_ref is not None:
ref_has_default = value_ref == default_value
field_info = model.__class__.model_fields.get(key)
if field_info is not None:
default_value = field_info.default

# If the model has default value, we usually won't dump it.
# There is an exception though: if we have a reference which we are trying
# to minimize the diff against, then if the diff includes the default
# explicitly, we should include it too.
# This is technically a limitation in what kind of diffs we can express in
# the dump but it's a relatively minor one.

if value_ref is not None:
ref_has_default = value_ref == default_value
else:
ref_has_default = False

if (value == default_value) and not ref_has_default:
continue

# Find the key for display - there might be an alias
display_key = field_info.alias
if display_key is None:
display_key = key
else:
ref_has_default = False

if (value == default_value) and not ref_has_default:
continue

# Find the key for display - there might be an alias
display_key = model.__class__.model_fields[key].alias
if display_key is None:
# Extra field not defined in the model schema (e.g. from prek syntax).
# Always include it using the raw key.
display_key = key

d[display_key] = fancy_model_dump(
Expand Down
88 changes: 88 additions & 0 deletions tests/usethis/_integrations/pre_commit/test_hooks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pathlib import Path

import pytest
from ruamel.yaml import YAML

from usethis._config_file import files_manager
from usethis._integrations.pre_commit import schema
Expand Down Expand Up @@ -237,6 +238,93 @@ def test_hook_order_constant_is_respected_multi(self, tmp_path: Path):
"codespell",
]

def test_prek_extra_fields_preserved(self, tmp_path: Path):
"""Extra keys like `priority` (from prek syntax) are preserved."""
# Arrange
(tmp_path / ".pre-commit-config.yaml").write_text("""\
minimum_prek_version: 0.2.23
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff-check
args: [--fix]
priority: 0
- id: ruff-format
priority: 0
""")

# Act
with change_cwd(tmp_path), files_manager():
add_repo(
schema.LocalRepo(
repo="local",
hooks=[
schema.HookDefinition(
id="deptry",
name="deptry",
entry="uv run --frozen deptry src",
language=schema.Language("system"),
always_run=True,
)
],
)
)

# Assert - parse YAML to verify structure
yaml = YAML()
parsed = yaml.load((tmp_path / ".pre-commit-config.yaml").read_text())
assert parsed["minimum_prek_version"] == "0.2.23"
ruff_repo = parsed["repos"][0]
assert ruff_repo["hooks"][0]["priority"] == 0
assert ruff_repo["hooks"][1]["priority"] == 0
assert any(
hook["id"] == "deptry" for repo in parsed["repos"] for hook in repo["hooks"]
)

def test_prek_arbitrary_extra_keys(self, tmp_path: Path):
"""Arbitrary extra keys on hooks, repos, and top-level are preserved."""
# Arrange
(tmp_path / ".pre-commit-config.yaml").write_text("""\
custom_top_level_key: some_value
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
custom_repo_key: 42
hooks:
- id: ruff-format
custom_hook_key: true
""")

# Act
with change_cwd(tmp_path), files_manager():
add_repo(
schema.LocalRepo(
repo="local",
hooks=[
schema.HookDefinition(
id="codespell",
name="codespell",
entry="codespell .",
language=schema.Language("system"),
)
],
)
)

# Assert - parse YAML to verify extra keys are at correct levels
yaml = YAML()
parsed = yaml.load((tmp_path / ".pre-commit-config.yaml").read_text())
assert parsed["custom_top_level_key"] == "some_value"
ruff_repo = parsed["repos"][0]
assert ruff_repo["custom_repo_key"] == 42
assert ruff_repo["hooks"][0]["custom_hook_key"] is True
assert any(
hook["id"] == "codespell"
for repo in parsed["repos"]
for hook in repo["hooks"]
)


class TestInsertRepo:
def test_predecessor_is_none(
Expand Down
70 changes: 69 additions & 1 deletion tests/usethis/_integrations/pydantic/test_dump.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Literal

from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, ConfigDict, Field, RootModel

from usethis._integrations.pydantic.dump import fancy_model_dump
from usethis._integrations.pydantic.typing_ import ModelRepresentation
Expand Down Expand Up @@ -294,3 +294,71 @@ class MyModel(BaseModel):

# Assert
assert output == {}

class TestExtraFields:
def test_extra_field_included(self):
# Arrange
class MyModel(BaseModel):
model_config = ConfigDict(extra="allow")
x: int

mm = MyModel(x=1, **{"priority": 0})

# Act
output = fancy_model_dump(mm)

# Assert
assert output == {"x": 1, "priority": 0}

def test_extra_field_with_default_fields(self):
# Arrange
class MyModel(BaseModel):
model_config = ConfigDict(extra="allow")
x: int
y: float = 2.0

mm = MyModel(x=1, **{"priority": 0})

# Act
output = fancy_model_dump(mm)

# Assert
assert output == {"x": 1, "priority": 0}

def test_extra_field_with_reference(self):
# Arrange
class MyModel(BaseModel):
model_config = ConfigDict(extra="allow")
x: int
y: float = 2.0

mm = MyModel(x=1, **{"priority": 0})
ref = {"x": 0, "y": 2.0, "priority": 0}

# Act
output = fancy_model_dump(mm, reference=ref)

# Assert
assert output == {"x": 1, "y": 2.0, "priority": 0}

def test_nested_extra_field(self):
# Arrange
class MyInner(BaseModel):
model_config = ConfigDict(extra="allow")
id: str | None = None

class MyOuter(BaseModel):
model_config = ConfigDict(extra="allow")
items: list[MyInner]

inner = MyInner(id="test", **{"priority": 0})
outer = MyOuter(items=[inner], **{"minimum_prek_version": "0.2.23"})

# Act
output = fancy_model_dump(outer)

# Assert
assert output == {
"items": [{"id": "test", "priority": 0}],
"minimum_prek_version": "0.2.23",
}
Loading