Skip to content

Commit 25293fe

Browse files
committed
feat: add base config mixin class
1 parent 72767c1 commit 25293fe

8 files changed

Lines changed: 125 additions & 29 deletions

File tree

.claude/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Instructions for Using Agents in Claude
2+
3+
The instructions for using agents in Claude can be found in the @AGENTS.md file in the project root directory.

AGENTS.md

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,3 @@ pypeline run --step CreateVEnv --step PyTest --input python_version=3.13
148148
- `pypeline run` executes with **zero failures**
149149
- All pre-commit checks pass
150150
- New functionality has appropriate test coverage
151-
152-
## Commit Message Format
153-
154-
This repository uses [conventional commits](https://www.conventionalcommits.org). Format:
155-
156-
```
157-
<type>(<scope>): <description>
158-
```
159-
160-
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci`
161-
162-
Example: `feat(core): add async runnable support`

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ dev = [
4747
]
4848

4949
[tool.ruff]
50-
target-version = "py39"
50+
target-version = "py310"
5151
line-length = 180
5252
lint.select = [
5353
"B", # flake8-bugbear

src/py_app_dev/core/config.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1+
import json
2+
import sys
3+
from dataclasses import dataclass
4+
from pathlib import Path
15
from typing import Any, TypeVar
26

37
from mashumaro import DataClassDictMixin
48
from mashumaro.config import BaseConfig
59

10+
if sys.version_info >= (3, 11):
11+
from typing import Self
12+
else:
13+
from typing_extensions import Self
14+
15+
from mashumaro.mixins.json import DataClassJSONMixin
16+
617

718
class BaseConfigDictMixin(DataClassDictMixin):
819
class Config(BaseConfig):
@@ -13,6 +24,38 @@ class Config(BaseConfig):
1324
TConfig = TypeVar("TConfig", bound="BaseConfigDictMixin")
1425

1526

27+
@dataclass
28+
class BaseConfigJsonMixin(DataClassJSONMixin):
29+
"""Shared mixin providing mashumaro config and JSON file I/O."""
30+
31+
class Config(BaseConfig):
32+
omit_none = True
33+
34+
@classmethod
35+
def from_json_file(cls, file_path: Path) -> Self:
36+
return cls.from_dict(json.loads(file_path.read_text()))
37+
38+
@classmethod
39+
def from_file(cls, file_path: Path) -> Self:
40+
match file_path.suffix:
41+
case ".json":
42+
return cls.from_json_file(file_path)
43+
case _:
44+
raise ValueError(f"Unsupported format: {file_path.suffix}")
45+
46+
def to_json_string(self) -> str:
47+
return json.dumps(self.to_dict(), indent=2)
48+
49+
def to_string(self) -> str:
50+
return self.to_json_string()
51+
52+
def to_json_file(self, file_path: Path) -> None:
53+
file_path.write_text(self.to_json_string())
54+
55+
def to_file(self, file_path: Path) -> None:
56+
self.to_json_file(file_path)
57+
58+
1659
def deep_merge(base_dict: dict[Any, Any], new_dict: dict[Any, Any]) -> dict[Any, Any]:
1760
"""Recursively merge two dictionaries, where values in new_dict override values in base_dict."""
1861
result: dict[Any, Any] = {}

src/py_app_dev/core/find.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Utility functions for working with collections and type filtering."""
22

3-
from typing import Any, Callable, Optional, TypeVar
3+
from collections.abc import Callable
4+
from typing import Any, TypeVar
45

56
T = TypeVar("T")
67

@@ -15,7 +16,7 @@ def filter_elements(elements: list[T], filter_fn: Callable[[T], bool]) -> list[T
1516
return [elem for elem in elements if filter_fn(elem)]
1617

1718

18-
def find_first_element_of_type(elements: list[Any], element_type: type[T], filter_fn: Optional[Callable[[T], bool]] = None) -> Optional[T]:
19+
def find_first_element_of_type(elements: list[Any], element_type: type[T], filter_fn: Callable[[T], bool] | None = None) -> T | None:
1920
"""Find the first element of a specific type, optionally matching a filter condition."""
2021
filtered_elements = find_elements_of_type(elements, element_type)
2122
if filter_fn:

src/py_app_dev/core/scoop_wrapper.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from functools import cmp_to_key
66
from pathlib import Path
77
from tempfile import TemporaryDirectory
8-
from typing import Any, Optional
8+
from typing import Any
99

1010
from mashumaro import field_options
1111
from mashumaro.config import BaseConfig
@@ -70,14 +70,14 @@ def to_json_file(self, file_path: Path) -> None:
7070
class ScoopFileElement(BaseConfigJSONMixin):
7171
"""Represents an app or bucket entry in the scoopfile.json."""
7272

73-
_name_lc: Optional[str] = field(default=None, metadata=field_options(alias="name"))
74-
_name_uc: Optional[str] = field(default=None, metadata=field_options(alias="Name"))
73+
_name_lc: str | None = field(default=None, metadata=field_options(alias="name"))
74+
_name_uc: str | None = field(default=None, metadata=field_options(alias="Name"))
7575
#: Source bucket
76-
_source_lc: Optional[str] = field(default=None, metadata=field_options(alias="source"))
77-
_source_uc: Optional[str] = field(default=None, metadata=field_options(alias="Source"))
76+
_source_lc: str | None = field(default=None, metadata=field_options(alias="source"))
77+
_source_uc: str | None = field(default=None, metadata=field_options(alias="Source"))
7878

79-
_version_lc: Optional[str] = field(default=None, metadata=field_options(alias="version"))
80-
_version_uc: Optional[str] = field(default=None, metadata=field_options(alias="Version"))
79+
_version_lc: str | None = field(default=None, metadata=field_options(alias="version"))
80+
_version_uc: str | None = field(default=None, metadata=field_options(alias="Version"))
8181

8282
@property
8383
def name(self) -> str:
@@ -98,7 +98,7 @@ def source(self) -> str:
9898
raise UserNotificationException("ScoopApp must have a 'Source' or 'source' field defined.")
9999

100100
@property
101-
def version(self) -> Optional[str]:
101+
def version(self) -> str | None:
102102
if self._version_uc:
103103
return self._version_uc
104104
elif self._version_lc:

tests/test_cmd_line.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from dataclasses import dataclass, field
33
from enum import Enum, auto
44
from pathlib import Path
5-
from typing import Optional, Union
65

76
import pytest
87

@@ -74,7 +73,7 @@ def test_duplicate_commands():
7473
class MyConfigDataclass:
7574
my_first_arg: Path = field(metadata={"help": "Some help for arg1."})
7675
arg: str = field(default="value1", metadata={"help": "Some help for arg1."})
77-
opt_arg: Optional[str] = field(default=None, metadata={"help": "Some help for arg1."})
76+
opt_arg: str | None = field(default=None, metadata={"help": "Some help for arg1."})
7877
opt_arg_bool: bool | None = field(
7978
default=False,
8079
metadata={
@@ -85,14 +84,14 @@ class MyConfigDataclass:
8584

8685

8786
def test_is_type_optional():
88-
assert is_type_optional(Optional[str])
87+
assert is_type_optional(str | None)
8988
assert not is_type_optional(str)
90-
assert is_type_optional(Union[Path | None, str])
89+
assert is_type_optional((Path | str) | None)
9190
# Test modern union syntax (Python 3.10+)
9291
assert is_type_optional(Path | None)
9392
assert is_type_optional(str | None)
9493
assert not is_type_optional(Path | str) # Union without None should not be optional
95-
assert not is_type_optional(Union[Path, str])
94+
assert not is_type_optional(Path | str)
9695

9796

9897
def test_register_arguments_for_config_dataclass():

tests/test_config.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
import json
12
from dataclasses import dataclass
3+
from pathlib import Path
24
from typing import Any
35

4-
from py_app_dev.core.config import BaseConfigDictMixin, deep_merge, merge_configs
6+
import pytest
7+
8+
from py_app_dev.core.config import (
9+
BaseConfigDictMixin,
10+
BaseConfigJsonMixin,
11+
deep_merge,
12+
merge_configs,
13+
)
514

615

716
@dataclass
@@ -47,3 +56,56 @@ def test_merge_configs_override_none_value():
4756
assert merged.name == "final", "Name should be taken from override"
4857
assert merged.nested == {"k": 1}, "Nested should not be overridden by None"
4958
assert merged.retries == 0, "Retries should be taken from the default value in override"
59+
60+
61+
# ---------- BaseConfigJsonMixin tests ----------
62+
63+
64+
@dataclass
65+
class SampleJsonConfig(BaseConfigJsonMixin):
66+
name: str = ""
67+
count: int = 0
68+
label: str | None = None
69+
metadata: dict[str, Any] | None = None
70+
71+
72+
def test_json_mixin_roundtrip_file(tmp_path: Path) -> None:
73+
original = SampleJsonConfig(name="svc", count=3, metadata={"env": "prod"})
74+
file = tmp_path / "config.json"
75+
76+
original.to_json_file(file)
77+
restored = SampleJsonConfig.from_json_file(file)
78+
79+
assert restored == original
80+
81+
82+
def test_json_mixin_from_file_json(tmp_path: Path) -> None:
83+
file = tmp_path / "config.json"
84+
file.write_text(json.dumps({"name": "app", "count": 7}))
85+
86+
loaded = SampleJsonConfig.from_file(file)
87+
88+
assert loaded.name == "app"
89+
assert loaded.count == 7
90+
91+
92+
def test_json_mixin_from_file_unsupported(tmp_path: Path) -> None:
93+
file = tmp_path / "config.yaml"
94+
file.write_text("name: oops")
95+
96+
with pytest.raises(ValueError, match=r"\.yaml"):
97+
SampleJsonConfig.from_file(file)
98+
99+
100+
def test_json_mixin_omit_none() -> None:
101+
cfg = SampleJsonConfig(name="x", label=None, metadata=None)
102+
parsed = json.loads(cfg.to_json_string())
103+
104+
assert "label" not in parsed
105+
assert "metadata" not in parsed
106+
107+
108+
def test_json_mixin_to_string() -> None:
109+
cfg = SampleJsonConfig(name="a", count=1)
110+
111+
assert cfg.to_string() == cfg.to_json_string()

0 commit comments

Comments
 (0)