Skip to content

Commit e0ae241

Browse files
committed
Python 3.14 support, pyproject.toml and CI modernization
1 parent ff361f5 commit e0ae241

9 files changed

Lines changed: 622 additions & 439 deletions

File tree

.editorconfig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ indent_size = 4
66
charset = utf-8
77
trim_trailing_whitespace = true
88
insert_final_newline = true
9+
end_of_line = lf
910

10-
[{*.yaml, *.yml}]
11+
[*.{yaml,yml}]
1112
indent_size = 2

.github/workflows/lint.yml

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
name: Lint
22
on: [push, pull_request]
3+
34
jobs:
45
lint:
56
runs-on: ubuntu-latest
67
steps:
7-
- uses: actions/checkout@v4
8+
- uses: actions/checkout@v5
9+
810
- name: Install uv
9-
uses: astral-sh/setup-uv@v3
11+
uses: astral-sh/setup-uv@v7
12+
1013
- name: Set up Python
1114
run: uv python install 3.12
15+
1216
- name: Install dependencies
13-
run: uv sync --extra dev
14-
- name: Run ruff
15-
run: uv run ruff check .
16-
- name: Run black
17-
run: uv run black --check .
17+
run: uv sync --extra dev --python 3.12
18+
19+
- name: Check formatting with Ruff
20+
run: uv run --python 3.12 ruff format --check .
21+
22+
- name: Run Ruff lint
23+
run: uv run --python 3.12 ruff check .

.github/workflows/python-publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ jobs:
66
deploy:
77
runs-on: ubuntu-latest
88
steps:
9-
- uses: actions/checkout@v4
9+
- uses: actions/checkout@v5
1010
- name: Install uv
11-
uses: astral-sh/setup-uv@v3
11+
uses: astral-sh/setup-uv@v7
1212
- name: Set up Python
1313
run: uv python install 3.12
1414
- name: Build and publish

.github/workflows/tests.yml

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
name: Tests
22
on: [push, pull_request]
3+
34
jobs:
45
tests:
5-
runs-on: ubuntu-latest
66
strategy:
77
fail-fast: false
88
matrix:
9-
python-version: ["3.10", "3.11", "3.12", "3.13"]
9+
os: [ubuntu-latest, macos-latest, windows-latest]
10+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
11+
runs-on: ${{ matrix.os }}
12+
1013
steps:
11-
- uses: actions/checkout@v2
14+
- uses: actions/checkout@v5
15+
16+
- name: Install uv
17+
uses: astral-sh/setup-uv@v7
18+
1219
- name: Set up Python ${{ matrix.python-version }}
13-
uses: actions/setup-python@v2
14-
with:
15-
python-version: ${{ matrix.python-version }}
20+
run: uv python install ${{ matrix.python-version }}
21+
1622
- name: Install dependencies
17-
run: |
18-
python -m pip install --upgrade pip
19-
pip install -e .[test]
23+
run: uv sync --extra test --python ${{ matrix.python-version }}
24+
2025
- name: Run pytest
21-
run: |
22-
pytest -v
26+
run: uv run --python ${{ matrix.python-version }} pytest -v

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ repos:
88
- id: check-toml
99

1010
- repo: https://github.com/astral-sh/ruff-pre-commit
11-
rev: v0.8.4
11+
rev: v0.14.2
1212
hooks:
13-
- id: ruff
14-
args: [--fix, --exit-non-zero-on-fix]
13+
- id: ruff-check
14+
args: [--fix, --exit-zero]
1515
- id: ruff-format

Justfile

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,62 @@
11
@_:
2-
just --list
2+
just --list
33

44
_require-uv:
5-
@uv --version > /dev/null || (echo "Please install uv: https://docs.astral.sh/uv/" && exit 1)
5+
@uv --version > /dev/null || (echo "Please install uv: https://docs.astral.sh/uv/" && exit 1)
66

77
_require-hatch:
88
@hatch --version > /dev/null || (echo "Please install hatch: uv tool install hatch" && exit 1)
99

1010
# check code style and potential issues
1111
lint:
12-
ruff check
13-
14-
# fix automatically fixable linting issues
15-
fix:
16-
ruff check --fix
12+
ruff check
1713

1814
# format code
1915
format:
20-
ruff format
16+
ruff format
17+
18+
# fix automatically fixable linting issues
19+
fix:
20+
ruff check --fix
2121

2222
# run tests across all supported Python versions
2323
test: _require-hatch
24-
hatch run test:test
25-
26-
# run all quality checks
27-
check: format lint test
24+
hatch run test:test
2825

2926
# build the package
3027
build: _require-uv
31-
uv build
28+
uv build
3229

33-
# setup development environment
30+
# setup or update local dev environment, keeps previously installed extras
3431
dev: _require-uv
35-
uv sync --extra dev
36-
uv run pre-commit install
32+
uv sync --inexact --extra dev
33+
uv run pre-commit install
34+
35+
# run tests with coverage and show a coverage report
36+
coverage:
37+
coverage run -m pytest
38+
coverage report
3739

3840
# clean build artifacts and caches
3941
clean:
40-
rm -rf .venv .pytest_cache .mypy_cache .ruff_cache
41-
find . -type d -name "__pycache__" -exec rm -r {} +
42+
rm -rf .venv .pytest_cache .mypy_cache .ruff_cache
43+
find . -type d -name "__pycache__" -exec rm -r {} +
44+
45+
# static type check with mypy
46+
typecheck: _require-uv
47+
uv run mypy
48+
49+
# check code for common misspellings
50+
spell:
51+
codespell
52+
53+
# run all quality checks
54+
check: format lint coverage typecheck spell
55+
56+
# list available recipes
57+
help:
58+
just --list
59+
60+
alias fmt := format
61+
alias cov := coverage
62+
alias mypy := typecheck

confdantic/confdantic.py

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -68,30 +68,26 @@ class Confdantic(BaseModel):
6868

6969
def to_commented_yaml(self) -> CommentedMap | CommentedSeq:
7070
"""Converts the Confdantic instance to a CommentedMap or CommentedSeq for YAML serialization."""
71-
return self._to_commented_yaml(self)
71+
data = self.model_dump()
72+
return self._to_commented_yaml(data)
7273

7374
def _to_commented_yaml(self, obj: Any) -> CommentedMap | CommentedSeq | Any:
74-
if issubclass(obj.__class__, BaseModel):
75+
if isinstance(obj, dict):
7576
cm = CommentedMap()
76-
for field_name, field in obj.model_fields.items():
77-
value = getattr(obj, field_name)
78-
cm[field_name] = self._to_commented_yaml(value)
79-
80-
comment = get_comment(field, format="yaml")
81-
if comment:
82-
cm.yaml_add_eol_comment(comment, field_name)
77+
for key, value in obj.items():
78+
cm[key] = self._to_commented_yaml(value)
8379

80+
if issubclass(self.__class__, BaseModel) and key in self.__class__.model_fields:
81+
field = self.__class__.model_fields[key]
82+
comment = get_comment(field, format="yaml")
83+
if comment:
84+
cm.yaml_add_eol_comment(comment, key)
8485
return cm
8586
elif isinstance(obj, list):
8687
cs = CommentedSeq()
8788
for item in obj:
8889
cs.append(self._to_commented_yaml(item))
8990
return cs
90-
elif isinstance(obj, dict):
91-
cm = CommentedMap()
92-
for key, value in obj.items():
93-
cm[key] = self._to_commented_yaml(value)
94-
return cm
9591
else:
9692
return obj
9793

@@ -126,7 +122,13 @@ def load(cls, filepath: str):
126122
case _:
127123
raise ValueError(f"Unknown file extension: {ext}")
128124

129-
def save(self, filepath: str, overwrite: bool = True, comments: bool = True):
125+
def save(
126+
self,
127+
filepath: str,
128+
overwrite: bool = True,
129+
comments: bool = True,
130+
serialize_unsupported: bool = False,
131+
):
130132
"""
131133
Save the configuration to a file.
132134
@@ -137,6 +139,7 @@ def save(self, filepath: str, overwrite: bool = True, comments: bool = True):
137139
filepath (str): The path where the configuration file will be saved.
138140
overwrite (bool, optional): Whether to overwrite the file if it already exists. Defaults to True.
139141
comments (bool, optional): Whether to include comments in the saved file. Defaults to True.
142+
serialize_unsupported (bool, optional): Whether to serialize unsupported types as strings. Defaults to False.
140143
141144
Raises:
142145
FileExistsError: If the file already exists and overwrite is False.
@@ -148,11 +151,22 @@ def save(self, filepath: str, overwrite: bool = True, comments: bool = True):
148151
ext = file_ext(filepath)
149152
match ext:
150153
case "toml" | "tml":
151-
return self.save_toml(filepath, overwrite=overwrite, comments=comments)
154+
return self.save_toml(
155+
filepath,
156+
overwrite=overwrite,
157+
comments=comments,
158+
serialize_unsupported=serialize_unsupported,
159+
)
152160
case "yaml" | "yml":
153-
return self.save_yaml(filepath=filepath, overwrite=overwrite)
161+
return self.save_yaml(
162+
filepath=filepath,
163+
overwrite=overwrite,
164+
serialize_unsupported=serialize_unsupported,
165+
)
154166
case "json":
155-
return self.save_json(filepath, overwrite=overwrite)
167+
return self.save_json(
168+
filepath, overwrite=overwrite, serialize_unsupported=serialize_unsupported
169+
)
156170
case _:
157171
raise ValueError(f"Unknown file extension: {ext}")
158172

@@ -173,14 +187,20 @@ def load_toml(cls, filepath: str):
173187
return cls.model_validate(toml.load(f))
174188

175189
@classmethod
176-
def load_json(cls, filepath: str):
177-
with open(filepath) as f:
190+
def load_json(cls, filepath: str, encoding: str = "utf-8"):
191+
with open(filepath, encoding=encoding) as f:
178192
return cls.model_validate(json.load(f))
179193

180-
def save_toml(self, filepath: str, overwrite: bool = True, comments: bool = True) -> None:
194+
def save_toml(
195+
self,
196+
filepath: str,
197+
overwrite: bool = True,
198+
comments: bool = True,
199+
serialize_unsupported: bool = False,
200+
) -> None:
181201
if os.path.exists(filepath) and not overwrite:
182202
raise FileExistsError(filepath)
183-
data = self.model_dump()
203+
data = self.model_dump(mode="json") if serialize_unsupported else self.model_dump()
184204
toml_string = tomlkit.dumps(data)
185205
toml_doc = tomlkit.loads(toml_string)
186206

@@ -189,7 +209,7 @@ def save_toml(self, filepath: str, overwrite: bool = True, comments: bool = True
189209
tomlkit.dump(toml_doc, f)
190210
return
191211

192-
for name, field in self.model_fields.items():
212+
for name, field in self.__class__.model_fields.items():
193213
item = toml_doc.item(name)
194214
comment = get_comment(field, format="toml")
195215
if comment:
@@ -214,26 +234,35 @@ def save_toml(self, filepath: str, overwrite: bool = True, comments: bool = True
214234
with open(filepath, "w") as f:
215235
tomlkit.dump(toml_doc, f)
216236

217-
def save_json(self, filepath: str, overwrite: bool = True) -> None:
237+
def save_json(
238+
self,
239+
filepath: str,
240+
overwrite: bool = True,
241+
serialize_unsupported: bool = False,
242+
) -> None:
218243
if os.path.exists(filepath) and not overwrite:
219244
raise FileExistsError(filepath)
220-
data = self.model_dump()
245+
data = self.model_dump(mode="json") if serialize_unsupported else self.model_dump()
221246
with open(filepath, "w") as f:
222-
json.dump(data, f)
223-
224-
def save_yaml(self, filepath: str, overwrite: bool = True, comments: bool = True) -> None:
247+
json.dump(data, f, indent=4, default=str)
248+
249+
def save_yaml(
250+
self,
251+
filepath: str,
252+
overwrite: bool = True,
253+
comments: bool = True,
254+
serialize_unsupported: bool = False,
255+
) -> None:
225256
if os.path.exists(filepath) and not overwrite:
226257
raise FileExistsError(filepath)
227258

228259
yaml = YAML()
229260
yaml.indent(mapping=2, sequence=4, offset=2)
230261
yaml.preserve_quotes = True
231262

232-
data = self.model_dump()
263+
data = self.model_dump(mode="json") if serialize_unsupported else self.model_dump()
233264
if comments:
234-
data = self.to_commented_yaml()
235-
else:
236-
data = self.model_dump()
265+
data = self._to_commented_yaml(data)
237266
with open(filepath, "w") as f:
238267
yaml.dump(data, f)
239268

0 commit comments

Comments
 (0)