mm-std 0.5.3__tar.gz → 0.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mm_std-0.5.4/.claude/settings.local.json +15 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/.pre-commit-config.yaml +1 -1
- mm_std-0.5.4/CLAUDE.md +13 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/PKG-INFO +1 -1
- {mm_std-0.5.3 → mm_std-0.5.4}/pyproject.toml +11 -11
- {mm_std-0.5.3 → mm_std-0.5.4}/src/mm_std/dict_utils.py +35 -8
- {mm_std-0.5.3 → mm_std-0.5.4}/src/mm_std/json_utils.py +1 -4
- {mm_std-0.5.3 → mm_std-0.5.4}/src/mm_std/random_utils.py +2 -2
- mm_std-0.5.4/tests/test_dict_utils.py +179 -0
- mm_std-0.5.4/tests/test_json_utils.py +205 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/tests/test_random_utils.py +1 -1
- mm_std-0.5.4/tests/test_str_utils.py +207 -0
- mm_std-0.5.4/uv.lock +351 -0
- mm_std-0.5.3/dict.dic +0 -0
- mm_std-0.5.3/requirements.txt +0 -2
- mm_std-0.5.3/tests/test_dict_utils.py +0 -169
- mm_std-0.5.3/tests/test_json_utils.py +0 -263
- mm_std-0.5.3/tests/test_str_utils.py +0 -180
- mm_std-0.5.3/uv.lock +0 -368
- {mm_std-0.5.3 → mm_std-0.5.4}/.gitignore +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/README.md +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/justfile +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/src/mm_std/__init__.py +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/src/mm_std/date_utils.py +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/src/mm_std/py.typed +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/src/mm_std/str_utils.py +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/src/mm_std/subprocess_utils.py +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/tests/__init__.py +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/tests/test_date_utils.py +0 -0
- {mm_std-0.5.3 → mm_std-0.5.4}/tests/test_subprocess_utils.py +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(find:*)",
|
|
5
|
+
"Bash(uv run pytest:*)",
|
|
6
|
+
"Bash(uv run ruff:*)",
|
|
7
|
+
"Bash(uv run:*)",
|
|
8
|
+
"mcp__ide__getDiagnostics",
|
|
9
|
+
"Read(//Users/m/.vscode/extensions/ms-python.vscode-pylance-2025.7.1/dist/typeshed-fallback/stdlib/json/**)",
|
|
10
|
+
"Bash(just lint)",
|
|
11
|
+
"Bash(python:*)"
|
|
12
|
+
],
|
|
13
|
+
"deny": []
|
|
14
|
+
}
|
|
15
|
+
}
|
mm_std-0.5.4/CLAUDE.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Claude Guidelines
|
|
2
|
+
|
|
3
|
+
## Critical Guidelines
|
|
4
|
+
|
|
5
|
+
1. **Always communicate in English** - Regardless of the language the user speaks, always respond in English. All code, comments, and documentation must be in English.
|
|
6
|
+
|
|
7
|
+
2. **Minimal documentation** - Only add comments/documentation when it simplifies understanding and isn't obvious from the code itself. Keep it strictly relevant and concise.
|
|
8
|
+
|
|
9
|
+
3. **Critical thinking** - Always critically evaluate user ideas. Users can make mistakes. Think first about whether the user's idea is good before implementing.
|
|
10
|
+
|
|
11
|
+
4. **Lint after changes** - After making code changes, always run `just lint` to verify code quality and fix any linter issues.
|
|
12
|
+
|
|
13
|
+
5. **No disabling linter rules** - Never use special disabling comments (like `# noqa`, `# type: ignore`, `# ruff: noqa`, etc.) to turn off linter rules without explicit permission. If you believe a rule should be disabled, ask first.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mm-std"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.4"
|
|
4
4
|
description = ""
|
|
5
5
|
requires-python = ">=3.13"
|
|
6
6
|
dependencies = [
|
|
@@ -10,25 +10,25 @@ dependencies = [
|
|
|
10
10
|
requires = ["hatchling"]
|
|
11
11
|
build-backend = "hatchling.build"
|
|
12
12
|
|
|
13
|
-
[
|
|
14
|
-
dev
|
|
15
|
-
"pytest~=8.4.
|
|
16
|
-
"pytest-xdist~=3.
|
|
17
|
-
"ruff~=0.
|
|
18
|
-
"mypy~=1.
|
|
19
|
-
"bandit~=1.8.
|
|
20
|
-
"pre-commit~=4.
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = [
|
|
15
|
+
"pytest~=8.4.2",
|
|
16
|
+
"pytest-xdist~=3.8.0",
|
|
17
|
+
"ruff~=0.14.0",
|
|
18
|
+
"mypy~=1.18.2",
|
|
19
|
+
"bandit~=1.8.6",
|
|
20
|
+
"pre-commit~=4.3.0",
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
[tool.mypy]
|
|
24
|
-
python_version = "3.
|
|
24
|
+
python_version = "3.14"
|
|
25
25
|
warn_no_return = false
|
|
26
26
|
strict = true
|
|
27
27
|
exclude = ["^tests/", "^tmp/"]
|
|
28
28
|
|
|
29
29
|
[tool.ruff]
|
|
30
30
|
line-length = 130
|
|
31
|
-
target-version = "
|
|
31
|
+
target-version = "py314"
|
|
32
32
|
[tool.ruff.lint]
|
|
33
33
|
select = ["ALL"]
|
|
34
34
|
ignore = [
|
|
@@ -1,22 +1,49 @@
|
|
|
1
|
-
from collections import defaultdict
|
|
1
|
+
from collections import OrderedDict, defaultdict
|
|
2
2
|
from collections.abc import Mapping, MutableMapping
|
|
3
3
|
from decimal import Decimal
|
|
4
|
-
from typing import TypeVar,
|
|
4
|
+
from typing import TypeVar, overload
|
|
5
5
|
|
|
6
6
|
K = TypeVar("K")
|
|
7
7
|
V = TypeVar("V")
|
|
8
|
-
# TypeVar bound to MutableMapping with same K, V as defaults parameter
|
|
9
|
-
# 'type: ignore' needed because mypy can't handle TypeVar bounds with other TypeVars
|
|
10
|
-
DictType = TypeVar("DictType", bound=MutableMapping[K, V]) # type: ignore[valid-type]
|
|
11
8
|
|
|
12
9
|
|
|
10
|
+
@overload
|
|
13
11
|
def replace_empty_dict_entries(
|
|
14
|
-
data:
|
|
12
|
+
data: defaultdict[K, V],
|
|
15
13
|
defaults: Mapping[K, V] | None = None,
|
|
16
14
|
treat_zero_as_empty: bool = False,
|
|
17
15
|
treat_false_as_empty: bool = False,
|
|
18
16
|
treat_empty_string_as_empty: bool = True,
|
|
19
|
-
) ->
|
|
17
|
+
) -> defaultdict[K, V]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@overload
|
|
21
|
+
def replace_empty_dict_entries(
|
|
22
|
+
data: OrderedDict[K, V],
|
|
23
|
+
defaults: Mapping[K, V] | None = None,
|
|
24
|
+
treat_zero_as_empty: bool = False,
|
|
25
|
+
treat_false_as_empty: bool = False,
|
|
26
|
+
treat_empty_string_as_empty: bool = True,
|
|
27
|
+
) -> OrderedDict[K, V]: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@overload
|
|
31
|
+
def replace_empty_dict_entries(
|
|
32
|
+
data: dict[K, V],
|
|
33
|
+
defaults: Mapping[K, V] | None = None,
|
|
34
|
+
treat_zero_as_empty: bool = False,
|
|
35
|
+
treat_false_as_empty: bool = False,
|
|
36
|
+
treat_empty_string_as_empty: bool = True,
|
|
37
|
+
) -> dict[K, V]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def replace_empty_dict_entries(
|
|
41
|
+
data: MutableMapping[K, V],
|
|
42
|
+
defaults: Mapping[K, V] | None = None,
|
|
43
|
+
treat_zero_as_empty: bool = False,
|
|
44
|
+
treat_false_as_empty: bool = False,
|
|
45
|
+
treat_empty_string_as_empty: bool = True,
|
|
46
|
+
) -> MutableMapping[K, V]:
|
|
20
47
|
"""
|
|
21
48
|
Replace empty entries in a dictionary with defaults or remove them entirely.
|
|
22
49
|
|
|
@@ -60,4 +87,4 @@ def replace_empty_dict_entries(
|
|
|
60
87
|
new_value = value
|
|
61
88
|
|
|
62
89
|
result[key] = new_value
|
|
63
|
-
return
|
|
90
|
+
return result
|
|
@@ -43,11 +43,8 @@ class ExtendedJSONEncoder(json.JSONEncoder):
|
|
|
43
43
|
serializer: Function that converts objects of this type to JSON-serializable data
|
|
44
44
|
|
|
45
45
|
Raises:
|
|
46
|
-
TypeError: If serializer is not callable
|
|
47
46
|
ValueError: If type_ is a built-in JSON type
|
|
48
47
|
"""
|
|
49
|
-
if not callable(serializer):
|
|
50
|
-
raise TypeError("Serializer must be callable")
|
|
51
48
|
if type_ in (str, int, float, bool, list, dict, type(None)):
|
|
52
49
|
raise ValueError(f"Cannot override built-in JSON type: {type_.__name__}")
|
|
53
50
|
cls._type_handlers[type_] = serializer
|
|
@@ -101,7 +98,7 @@ def _auto_register_optional_types() -> None:
|
|
|
101
98
|
"""Register handlers for optional dependencies if available."""
|
|
102
99
|
# Pydantic models
|
|
103
100
|
try:
|
|
104
|
-
from pydantic import BaseModel # type: ignore[import-not-found]
|
|
101
|
+
from pydantic import BaseModel # type: ignore[import-not-found] # noqa: PLC0415
|
|
105
102
|
|
|
106
103
|
ExtendedJSONEncoder.register(BaseModel, lambda obj: obj.model_dump())
|
|
107
104
|
except ImportError:
|
|
@@ -33,7 +33,7 @@ def random_decimal(from_value: Decimal, to_value: Decimal) -> Decimal:
|
|
|
33
33
|
from_int = int(from_value * multiplier)
|
|
34
34
|
to_int = int(to_value * multiplier)
|
|
35
35
|
|
|
36
|
-
random_int = random.randint(from_int, to_int)
|
|
36
|
+
random_int = random.randint(from_int, to_int) # nosec B311
|
|
37
37
|
return Decimal(random_int) / Decimal(multiplier)
|
|
38
38
|
|
|
39
39
|
|
|
@@ -68,5 +68,5 @@ def random_datetime(
|
|
|
68
68
|
if total_seconds == 0:
|
|
69
69
|
return from_time
|
|
70
70
|
|
|
71
|
-
random_seconds = random.uniform(0, total_seconds)
|
|
71
|
+
random_seconds = random.uniform(0, total_seconds) # nosec B311
|
|
72
72
|
return from_time + timedelta(seconds=random_seconds)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from collections import OrderedDict, defaultdict
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from mm_std import replace_empty_dict_entries
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def sample_data():
|
|
11
|
+
return {"a": 1, "b": None, "c": "hello", "d": "", "e": 0, "f": False}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def complex_data():
|
|
16
|
+
return {
|
|
17
|
+
"none": None,
|
|
18
|
+
"empty_str": "",
|
|
19
|
+
"zero_int": 0,
|
|
20
|
+
"zero_float": 0.0,
|
|
21
|
+
"zero_decimal": Decimal(0),
|
|
22
|
+
"false": False,
|
|
23
|
+
"keep_this": "value",
|
|
24
|
+
"keep_true": True,
|
|
25
|
+
"keep_one": 1,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestReplaceEmptyDictEntries:
|
|
30
|
+
def test_basic_none_removal(self, sample_data):
|
|
31
|
+
result = replace_empty_dict_entries(sample_data)
|
|
32
|
+
assert result == {"a": 1, "c": "hello", "e": 0, "f": False} # empty string removed by default
|
|
33
|
+
assert type(result) is dict
|
|
34
|
+
|
|
35
|
+
def test_none_replacement_with_defaults(self, sample_data):
|
|
36
|
+
defaults = {"b": 42}
|
|
37
|
+
result = replace_empty_dict_entries(sample_data, defaults)
|
|
38
|
+
assert result == {"a": 1, "b": 42, "c": "hello", "e": 0, "f": False} # empty string removed by default
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize(
|
|
41
|
+
"treat_empty_string_as_empty,expected_keys",
|
|
42
|
+
[
|
|
43
|
+
(True, {"a", "c", "e", "f"}), # default behavior - empty strings removed
|
|
44
|
+
(False, {"a", "c", "d", "e", "f"}), # empty strings kept
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
def test_empty_string_handling(self, sample_data, treat_empty_string_as_empty, expected_keys):
|
|
48
|
+
result = replace_empty_dict_entries(sample_data, treat_empty_string_as_empty=treat_empty_string_as_empty)
|
|
49
|
+
assert set(result.keys()) == expected_keys
|
|
50
|
+
|
|
51
|
+
def test_empty_string_with_defaults(self, sample_data):
|
|
52
|
+
defaults = {"d": "default_value"}
|
|
53
|
+
result = replace_empty_dict_entries(sample_data, defaults)
|
|
54
|
+
assert result["d"] == "default_value"
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize(
|
|
57
|
+
"treat_zero_as_empty,expected_has_zero",
|
|
58
|
+
[
|
|
59
|
+
(False, True), # default - zeros kept
|
|
60
|
+
(True, False), # zeros removed
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
def test_zero_handling(self, treat_zero_as_empty, expected_has_zero):
|
|
64
|
+
data = {"a": 1, "b": 0, "c": 0.0, "d": Decimal(0)}
|
|
65
|
+
result = replace_empty_dict_entries(data, treat_zero_as_empty=treat_zero_as_empty)
|
|
66
|
+
|
|
67
|
+
if expected_has_zero:
|
|
68
|
+
assert "b" in result and "c" in result and "d" in result
|
|
69
|
+
else:
|
|
70
|
+
assert "b" not in result and "c" not in result and "d" not in result
|
|
71
|
+
|
|
72
|
+
def test_zero_with_defaults(self):
|
|
73
|
+
data = {"a": 1, "b": 0, "c": 0.0, "d": Decimal(0)}
|
|
74
|
+
defaults = {"b": 10, "c": 3.14, "d": Decimal(100)}
|
|
75
|
+
result = replace_empty_dict_entries(data, defaults, treat_zero_as_empty=True)
|
|
76
|
+
assert result == {"a": 1, "b": 10, "c": 3.14, "d": Decimal(100)}
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize(
|
|
79
|
+
"treat_false_as_empty,expected_has_false",
|
|
80
|
+
[
|
|
81
|
+
(False, True), # default - False kept
|
|
82
|
+
(True, False), # False removed
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
def test_false_handling(self, treat_false_as_empty, expected_has_false):
|
|
86
|
+
data = {"a": True, "b": False, "c": "hello"}
|
|
87
|
+
result = replace_empty_dict_entries(data, treat_false_as_empty=treat_false_as_empty)
|
|
88
|
+
|
|
89
|
+
if expected_has_false:
|
|
90
|
+
assert result["b"] is False
|
|
91
|
+
else:
|
|
92
|
+
assert "b" not in result
|
|
93
|
+
|
|
94
|
+
def test_false_with_defaults(self):
|
|
95
|
+
data = {"a": True, "b": False, "c": "hello"}
|
|
96
|
+
result = replace_empty_dict_entries(data, {"b": True}, treat_false_as_empty=True)
|
|
97
|
+
assert result == {"a": True, "b": True, "c": "hello"}
|
|
98
|
+
|
|
99
|
+
def test_bool_vs_int_precedence(self):
|
|
100
|
+
data = {"a": False, "b": 0, "c": True, "d": 1}
|
|
101
|
+
defaults = {"a": "false_default", "b": "zero_default"}
|
|
102
|
+
result = replace_empty_dict_entries(data, defaults, treat_zero_as_empty=True, treat_false_as_empty=True)
|
|
103
|
+
assert result == {"a": "false_default", "b": "zero_default", "c": True, "d": 1}
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize(
|
|
106
|
+
"dict_type,expected_type",
|
|
107
|
+
[
|
|
108
|
+
(dict, dict),
|
|
109
|
+
(lambda d: defaultdict(list, d), defaultdict),
|
|
110
|
+
(lambda d: OrderedDict(d.items()), OrderedDict),
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
def test_type_preservation(self, sample_data, dict_type, expected_type):
|
|
114
|
+
data = dict_type(sample_data)
|
|
115
|
+
result = replace_empty_dict_entries(data)
|
|
116
|
+
assert isinstance(result, expected_type)
|
|
117
|
+
|
|
118
|
+
def test_defaultdict_factory_preservation(self):
|
|
119
|
+
data = defaultdict(list, {"a": [1, 2], "b": None, "c": []})
|
|
120
|
+
result = replace_empty_dict_entries(data)
|
|
121
|
+
|
|
122
|
+
assert isinstance(result, defaultdict)
|
|
123
|
+
assert result.default_factory is list
|
|
124
|
+
assert result == {"a": [1, 2], "c": []}
|
|
125
|
+
|
|
126
|
+
# Test that default_factory still works
|
|
127
|
+
result["new_key"].append("test") # pyright: ignore[reportArgumentType, reportOptionalMemberAccess]
|
|
128
|
+
assert result["new_key"] == ["test"]
|
|
129
|
+
|
|
130
|
+
def test_ordered_dict_order_preservation(self):
|
|
131
|
+
data = OrderedDict([("a", 1), ("b", None), ("c", 2)])
|
|
132
|
+
result = replace_empty_dict_entries(data)
|
|
133
|
+
|
|
134
|
+
assert type(result) is OrderedDict
|
|
135
|
+
assert list(result.keys()) == ["a", "c"]
|
|
136
|
+
assert result == OrderedDict([("a", 1), ("c", 2)])
|
|
137
|
+
|
|
138
|
+
def test_all_flags_combined(self, complex_data):
|
|
139
|
+
result = replace_empty_dict_entries(
|
|
140
|
+
complex_data, treat_zero_as_empty=True, treat_false_as_empty=True, treat_empty_string_as_empty=True
|
|
141
|
+
)
|
|
142
|
+
assert result == {"keep_this": "value", "keep_true": True, "keep_one": 1}
|
|
143
|
+
|
|
144
|
+
@pytest.mark.parametrize(
|
|
145
|
+
"input_dict,expected",
|
|
146
|
+
[
|
|
147
|
+
({}, {}),
|
|
148
|
+
({"a": 1, "b": "hello", "c": True}, {"a": 1, "b": "hello", "c": True}),
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
def test_edge_cases(self, input_dict, expected):
|
|
152
|
+
result = replace_empty_dict_entries(input_dict)
|
|
153
|
+
assert result == expected
|
|
154
|
+
assert result is not input_dict # Should be a new instance
|
|
155
|
+
|
|
156
|
+
def test_partial_defaults(self):
|
|
157
|
+
data = {"a": None, "b": None, "c": 1}
|
|
158
|
+
defaults = {"a": "replaced"}
|
|
159
|
+
result = replace_empty_dict_entries(data, defaults)
|
|
160
|
+
assert result == {"a": "replaced", "c": 1}
|
|
161
|
+
|
|
162
|
+
@pytest.mark.parametrize(
|
|
163
|
+
"value,treat_zero_as_empty,should_be_removed",
|
|
164
|
+
[
|
|
165
|
+
(-0.0, True, True),
|
|
166
|
+
(+0.0, True, True),
|
|
167
|
+
(Decimal("0.00"), True, True),
|
|
168
|
+
(0.0000001, True, False),
|
|
169
|
+
(-1, True, False),
|
|
170
|
+
],
|
|
171
|
+
)
|
|
172
|
+
def test_numeric_edge_cases(self, value, treat_zero_as_empty, should_be_removed):
|
|
173
|
+
data = {"test_key": value}
|
|
174
|
+
result = replace_empty_dict_entries(data, treat_zero_as_empty=treat_zero_as_empty)
|
|
175
|
+
|
|
176
|
+
if should_be_removed:
|
|
177
|
+
assert "test_key" not in result
|
|
178
|
+
else:
|
|
179
|
+
assert "test_key" in result
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from mm_std.json_utils import ExtendedJSONEncoder, json_dumps
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Color(Enum):
|
|
15
|
+
RED = "red"
|
|
16
|
+
GREEN = "green"
|
|
17
|
+
BLUE = "blue"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Person:
|
|
22
|
+
name: str
|
|
23
|
+
age: int
|
|
24
|
+
birth_date: date
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Address:
|
|
29
|
+
street: str
|
|
30
|
+
city: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CustomType:
|
|
34
|
+
def __init__(self, value: str) -> None:
|
|
35
|
+
self.value = value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def assert_json_serializes_to(obj, expected_json_string):
|
|
39
|
+
result = json.dumps(obj, cls=ExtendedJSONEncoder)
|
|
40
|
+
assert result == expected_json_string
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def assert_json_deserializes_to(obj, expected_dict):
|
|
44
|
+
result = json.loads(json.dumps(obj, cls=ExtendedJSONEncoder))
|
|
45
|
+
assert result == expected_dict
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestExtendedJSONEncoder:
|
|
49
|
+
def test_builtin_types_unchanged(self):
|
|
50
|
+
data = {"str": "hello", "int": 42, "float": 3.14, "bool": True, "list": [1, 2], "dict": {"nested": "value"}, "null": None}
|
|
51
|
+
result = json.dumps(data, cls=ExtendedJSONEncoder)
|
|
52
|
+
expected = json.dumps(data)
|
|
53
|
+
assert result == expected
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize(
|
|
56
|
+
"obj,expected",
|
|
57
|
+
[
|
|
58
|
+
(datetime(2023, 6, 15, 14, 30, 45), '"2023-06-15T14:30:45"'),
|
|
59
|
+
(date(2023, 6, 15), '"2023-06-15"'),
|
|
60
|
+
(UUID("12345678-1234-5678-1234-567812345678"), '"12345678-1234-5678-1234-567812345678"'),
|
|
61
|
+
(Decimal("123.456"), '"123.456"'),
|
|
62
|
+
(Path("/home/user/file.txt"), '"/home/user/file.txt"'),
|
|
63
|
+
(b"hello world", '"hello world"'),
|
|
64
|
+
(Color.RED, '"red"'),
|
|
65
|
+
(ValueError("Something went wrong"), '"Something went wrong"'),
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
def test_basic_type_serialization(self, obj, expected):
|
|
69
|
+
assert_json_serializes_to(obj, expected)
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize(
|
|
72
|
+
"collection,expected_sorted",
|
|
73
|
+
[
|
|
74
|
+
({1, 2, 3}, [1, 2, 3]),
|
|
75
|
+
(frozenset({1, 2, 3}), [1, 2, 3]),
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
def test_set_serialization(self, collection, expected_sorted):
|
|
79
|
+
result = json.loads(json.dumps(collection, cls=ExtendedJSONEncoder))
|
|
80
|
+
assert sorted(result) == expected_sorted
|
|
81
|
+
|
|
82
|
+
def test_complex_number_serialization(self):
|
|
83
|
+
complex_obj = complex(3, 4)
|
|
84
|
+
assert_json_deserializes_to(complex_obj, {"real": 3.0, "imag": 4.0})
|
|
85
|
+
|
|
86
|
+
def test_dataclass_serialization(self):
|
|
87
|
+
person = Person("Alice", 30, date(1993, 6, 15))
|
|
88
|
+
expected = {"name": "Alice", "age": 30, "birth_date": "1993-06-15"}
|
|
89
|
+
assert_json_deserializes_to(person, expected)
|
|
90
|
+
|
|
91
|
+
def test_nested_dataclass_serialization(self):
|
|
92
|
+
person = Person("Bob", 25, date(1998, 12, 25))
|
|
93
|
+
data = {"person": person, "uuid": UUID("12345678-1234-5678-1234-567812345678")}
|
|
94
|
+
expected = {
|
|
95
|
+
"person": {"name": "Bob", "age": 25, "birth_date": "1998-12-25"},
|
|
96
|
+
"uuid": "12345678-1234-5678-1234-567812345678",
|
|
97
|
+
}
|
|
98
|
+
assert_json_deserializes_to(data, expected)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestExtendedJSONEncoderRegistration:
|
|
102
|
+
def test_register_custom_type(self):
|
|
103
|
+
ExtendedJSONEncoder.register(CustomType, lambda obj: f"custom:{obj.value}")
|
|
104
|
+
custom_obj = CustomType("test")
|
|
105
|
+
assert_json_serializes_to(custom_obj, '"custom:test"')
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("builtin_type", [str, int, float, bool, list, dict, type(None)])
|
|
108
|
+
def test_register_builtin_type_raises_error(self, builtin_type):
|
|
109
|
+
with pytest.raises(ValueError, match=f"Cannot override built-in JSON type: {builtin_type.__name__}"):
|
|
110
|
+
ExtendedJSONEncoder.register(builtin_type, lambda obj: obj)
|
|
111
|
+
|
|
112
|
+
def test_register_override_existing_handler(self):
|
|
113
|
+
# Register initial handler
|
|
114
|
+
ExtendedJSONEncoder.register(CustomType, lambda obj: f"first:{obj.value}")
|
|
115
|
+
custom_obj = CustomType("test")
|
|
116
|
+
assert_json_serializes_to(custom_obj, '"first:test"')
|
|
117
|
+
|
|
118
|
+
# Override with new handler
|
|
119
|
+
ExtendedJSONEncoder.register(CustomType, lambda obj: f"second:{obj.value}")
|
|
120
|
+
assert_json_serializes_to(custom_obj, '"second:test"')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestJsonDumps:
|
|
124
|
+
def test_basic_usage_without_type_handlers(self):
|
|
125
|
+
data = {"date": date(2023, 6, 15), "uuid": UUID("12345678-1234-5678-1234-567812345678")}
|
|
126
|
+
expected = {"date": "2023-06-15", "uuid": "12345678-1234-5678-1234-567812345678"}
|
|
127
|
+
result = json.loads(json_dumps(data))
|
|
128
|
+
assert result == expected
|
|
129
|
+
|
|
130
|
+
def test_with_additional_type_handlers(self):
|
|
131
|
+
custom_obj = CustomType("test_value")
|
|
132
|
+
data = {"custom": custom_obj, "date": date(2023, 6, 15)}
|
|
133
|
+
type_handlers = {CustomType: lambda obj: f"handled:{obj.value}"}
|
|
134
|
+
expected = {"custom": "handled:test_value", "date": "2023-06-15"}
|
|
135
|
+
result = json.loads(json_dumps(data, type_handlers=type_handlers))
|
|
136
|
+
assert result == expected
|
|
137
|
+
|
|
138
|
+
def test_type_handlers_override_default(self):
|
|
139
|
+
data = {"date": date(2023, 6, 15)}
|
|
140
|
+
type_handlers = {date: lambda obj: f"custom_date:{obj.isoformat()}"}
|
|
141
|
+
expected = {"date": "custom_date:2023-06-15"}
|
|
142
|
+
result = json.loads(json_dumps(data, type_handlers=type_handlers))
|
|
143
|
+
assert result == expected
|
|
144
|
+
|
|
145
|
+
@pytest.mark.parametrize(
|
|
146
|
+
"kwargs,assertion",
|
|
147
|
+
[
|
|
148
|
+
({"indent": 2}, lambda result: "\n" in result),
|
|
149
|
+
({"ensure_ascii": True}, lambda _: "\\u" in json_dumps({"msg": "hello 世界"}, ensure_ascii=True)),
|
|
150
|
+
({"ensure_ascii": False}, lambda _: "世界" in json_dumps({"msg": "hello 世界"}, ensure_ascii=False)),
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
def test_kwargs_passed_to_json_dumps(self, kwargs, assertion):
|
|
154
|
+
data = {"name": "test", "value": 42}
|
|
155
|
+
result = json_dumps(data, **kwargs)
|
|
156
|
+
assert assertion(result)
|
|
157
|
+
|
|
158
|
+
@pytest.mark.parametrize(
|
|
159
|
+
"type_handlers",
|
|
160
|
+
[
|
|
161
|
+
{},
|
|
162
|
+
None,
|
|
163
|
+
],
|
|
164
|
+
)
|
|
165
|
+
def test_empty_and_none_type_handlers(self, type_handlers):
|
|
166
|
+
data = {"date": date(2023, 6, 15)}
|
|
167
|
+
result = json_dumps(data, type_handlers=type_handlers)
|
|
168
|
+
expected = json_dumps(data)
|
|
169
|
+
assert result == expected
|
|
170
|
+
|
|
171
|
+
def test_complex_nested_structure_with_custom_handlers(self):
|
|
172
|
+
address = Address("123 Main St", "New York")
|
|
173
|
+
person = Person("Alice", 30, date(1993, 6, 15))
|
|
174
|
+
custom = CustomType("special")
|
|
175
|
+
|
|
176
|
+
data = {
|
|
177
|
+
"timestamp": datetime(2023, 6, 15, 14, 30),
|
|
178
|
+
"person": person,
|
|
179
|
+
"address": address,
|
|
180
|
+
"custom": custom,
|
|
181
|
+
"id": UUID("12345678-1234-5678-1234-567812345678"),
|
|
182
|
+
"tags": {"important", "test"},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type_handlers = {
|
|
186
|
+
CustomType: lambda obj: {"type": "custom", "value": obj.value},
|
|
187
|
+
Address: lambda obj: f"{obj.street}, {obj.city}",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
result = json.loads(json_dumps(data, type_handlers=type_handlers))
|
|
191
|
+
|
|
192
|
+
expected = {
|
|
193
|
+
"timestamp": "2023-06-15T14:30:00",
|
|
194
|
+
"person": {"name": "Alice", "age": 30, "birth_date": "1993-06-15"},
|
|
195
|
+
"address": "123 Main St, New York",
|
|
196
|
+
"custom": {"type": "custom", "value": "special"},
|
|
197
|
+
"id": "12345678-1234-5678-1234-567812345678",
|
|
198
|
+
"tags": ["important", "test"],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Check tags separately due to set ordering
|
|
202
|
+
assert sorted(result["tags"]) == sorted(expected["tags"])
|
|
203
|
+
result_without_tags = {k: v for k, v in result.items() if k != "tags"}
|
|
204
|
+
expected_without_tags = {k: v for k, v in expected.items() if k != "tags"}
|
|
205
|
+
assert result_without_tags == expected_without_tags
|
|
@@ -44,7 +44,7 @@ class TestRandomDecimal:
|
|
|
44
44
|
|
|
45
45
|
def test_raises_error_when_from_greater_than_to(self) -> None:
|
|
46
46
|
with pytest.raises(ValueError, match="from_value must be <= to_value"):
|
|
47
|
-
random_decimal(Decimal(
|
|
47
|
+
random_decimal(Decimal(10), Decimal(5))
|
|
48
48
|
|
|
49
49
|
def test_works_with_negative_values(self) -> None:
|
|
50
50
|
from_val = Decimal("-5.5")
|