unstd 2.0.0__tar.gz → 2.1.0__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.
- {unstd-2.0.0 → unstd-2.1.0}/.github/workflows/ci.yml +12 -8
- {unstd-2.0.0 → unstd-2.1.0}/.github/workflows/release.yml +6 -1
- {unstd-2.0.0 → unstd-2.1.0}/CHANGELOG.md +38 -0
- {unstd-2.0.0 → unstd-2.1.0}/PKG-INFO +2 -2
- unstd-2.1.0/changelog.d/README.md +14 -0
- {unstd-2.0.0 → unstd-2.1.0}/pyproject.toml +7 -2
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/structs.py +7 -1
- {unstd-2.0.0 → unstd-2.1.0}/tests/serde/test_structs.py +54 -0
- {unstd-2.0.0 → unstd-2.1.0}/uv.lock +599 -549
- {unstd-2.0.0 → unstd-2.1.0}/.gitignore +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/LICENSE +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/NOTICE +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/bench/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/bench/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/bench/__main__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/bench/baseline.json +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/bench/cases.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/bench/report.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/bench/timing.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/_extra.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/audio/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/audio/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/audio/wavx.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/clone/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/clone/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/clone/structural.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/containers/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/containers/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/containers/persistent.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/containers/sorted.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/_blake3.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/digest.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/tls.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/token.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/fs/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/fs/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/ids/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/ids/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/ids/uid.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/consecutive.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/distinct.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/reshape.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/select.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/model/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/model/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/model/base.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/model/settings.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/pack/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/pack/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/pack/binpack.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/proc/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/proc/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/py.typed +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rand/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rand/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rand/crypto.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rand/draw.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rex/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rex/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/b64.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/jsonx.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/ndjson.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/text/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/text/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/text/fuzz.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/time/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/time/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/time/timeutil.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/time/zoned.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/toml/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/src/unstd/toml/__init__.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/audio/test_audio.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/clone/test_clone.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/conftest.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/containers/test_containers.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/crypto/blake3_reference_vectors.json +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/crypto/test_adverse.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/crypto/test_vectors.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/fs/test_fs.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/ids/test_uid.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/iters/test_iters.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/model/test_model.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/pack/test_pack.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/proc/test_proc.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/properties/README.md +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_clone_invariants.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_pack_invariants.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_serde_parity.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_toml_invariants.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_uuid7.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_wav_quant.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/rand/test_rand.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/rex/test_rex.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/serde/test_b64.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/serde/test_jsonx.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/serde/test_ndjson.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/test_bench.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/test_package.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/text/test_text.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/time/test_timeutil.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/time/test_zoned.py +0 -0
- {unstd-2.0.0 → unstd-2.1.0}/tests/toml/test_toml.py +0 -0
|
@@ -14,7 +14,7 @@ permissions:
|
|
|
14
14
|
contents: read
|
|
15
15
|
|
|
16
16
|
concurrency:
|
|
17
|
-
group: ${{ github.workflow }}-${{ github.ref }}
|
|
17
|
+
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
|
|
18
18
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
|
19
19
|
|
|
20
20
|
jobs:
|
|
@@ -29,11 +29,12 @@ jobs:
|
|
|
29
29
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
30
30
|
with:
|
|
31
31
|
persist-credentials: false
|
|
32
|
-
- uses: astral-sh/setup-uv@
|
|
32
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
33
33
|
with:
|
|
34
34
|
enable-cache: false
|
|
35
|
+
version: "latest-known"
|
|
35
36
|
python-version: "3.13"
|
|
36
|
-
- run: uv sync --all-extras --group dev --python 3.13
|
|
37
|
+
- run: uv sync --locked --all-extras --group dev --python 3.13
|
|
37
38
|
- run: uv run --python 3.13 ruff check .
|
|
38
39
|
- run: uv run --python 3.13 ruff format --check .
|
|
39
40
|
- run: uv run --python 3.13 mypy
|
|
@@ -52,11 +53,12 @@ jobs:
|
|
|
52
53
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
53
54
|
with:
|
|
54
55
|
persist-credentials: false
|
|
55
|
-
- uses: astral-sh/setup-uv@
|
|
56
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
56
57
|
with:
|
|
57
58
|
enable-cache: false
|
|
59
|
+
version: "latest-known"
|
|
58
60
|
python-version: ${{ matrix.python-version }}
|
|
59
|
-
- run: uv sync --all-extras --group dev --python ${{ matrix.python-version }}
|
|
61
|
+
- run: uv sync --locked --all-extras --group dev --python ${{ matrix.python-version }}
|
|
60
62
|
- run: uv run --python ${{ matrix.python-version }} pytest -q
|
|
61
63
|
|
|
62
64
|
floors:
|
|
@@ -75,9 +77,10 @@ jobs:
|
|
|
75
77
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
76
78
|
with:
|
|
77
79
|
persist-credentials: false
|
|
78
|
-
- uses: astral-sh/setup-uv@
|
|
80
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
79
81
|
with:
|
|
80
82
|
enable-cache: false
|
|
83
|
+
version: "latest-known"
|
|
81
84
|
python-version: "3.13"
|
|
82
85
|
- run: uv sync --all-extras --group dev --python 3.13 --resolution lowest-direct
|
|
83
86
|
# Which versions the floors admitted — the first thing anyone reading a
|
|
@@ -105,11 +108,12 @@ jobs:
|
|
|
105
108
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
106
109
|
with:
|
|
107
110
|
persist-credentials: false
|
|
108
|
-
- uses: astral-sh/setup-uv@
|
|
111
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
109
112
|
with:
|
|
110
113
|
enable-cache: false
|
|
114
|
+
version: "latest-known"
|
|
111
115
|
python-version: "3.13"
|
|
112
|
-
- run: uv sync --group dev --python 3.13
|
|
116
|
+
- run: uv sync --locked --group dev --python 3.13
|
|
113
117
|
# A diagnostic, not a gate — the zero-closure claim is asserted offline by
|
|
114
118
|
# `tests/test_package.py`. This is here because a failure that reproduces
|
|
115
119
|
# only on the fallback leg is nearly unreadable without knowing which
|
|
@@ -36,8 +36,13 @@ jobs:
|
|
|
36
36
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
37
37
|
with:
|
|
38
38
|
persist-credentials: false
|
|
39
|
-
- uses: astral-sh/setup-uv@
|
|
39
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
40
40
|
with:
|
|
41
41
|
enable-cache: false
|
|
42
|
+
version: "latest-known"
|
|
43
|
+
- name: The tag agrees with package metadata
|
|
44
|
+
env:
|
|
45
|
+
TAG: ${{ github.ref_name }}
|
|
46
|
+
run: test "$TAG" = "v$(uv version --short)"
|
|
42
47
|
- run: uv build
|
|
43
48
|
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
@@ -1,7 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
doc_radar:
|
|
3
|
+
sentinels:
|
|
4
|
+
- file: pyproject.toml
|
|
5
|
+
contains: ['"msgspec>=0.22"', 'exclude-newer = "P2D"']
|
|
6
|
+
- file: .github/workflows/ci.yml
|
|
7
|
+
contains: ['github.sha || github.ref', 'version: "latest-known"', 'uv sync --locked']
|
|
8
|
+
- file: .github/workflows/release.yml
|
|
9
|
+
contains: ['TAG: ${{ github.ref_name }}', 'uv version --short']
|
|
10
|
+
- file: src/unstd/serde/structs.py
|
|
11
|
+
contains: ['Callable[[Decimal], object]', 'decimal_format: _DecimalFormat']
|
|
12
|
+
---
|
|
13
|
+
|
|
1
14
|
# Changelog
|
|
2
15
|
|
|
3
16
|
<!-- towncrier release notes start -->
|
|
4
17
|
|
|
18
|
+
## [2.1.0] - 2026-10-04
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `Codec(decimal_format=...)` accepts a callable over each `Decimal`, so exact
|
|
23
|
+
decimal policies stay exact instead of passing through a float. Callback
|
|
24
|
+
errors propagate, and the same encoder remains usable afterward. This uses
|
|
25
|
+
[msgspec 0.22's encoder support](https://github.com/msgspec/msgspec/releases/tag/0.22.0).
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- We refreshed the optional-backend and development lock, including uuid-utils
|
|
30
|
+
1.0 and msgspec 0.22. Existing UUID generation and codec defaults keep their
|
|
31
|
+
behavior; the serde extra's msgspec floor moves to 0.22.
|
|
32
|
+
- We quarantine new resolver and isolated-build releases for two days. CI uses
|
|
33
|
+
the reviewed lock and checksum-backed uv versions from the pinned
|
|
34
|
+
[setup-uv 10.2 action](https://github.com/astral-sh/setup-uv/releases/tag/v10.2.0).
|
|
35
|
+
The separate floor job still proves the oldest supported direct dependencies.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- We keep each main commit's CI run in its own concurrency group. Newer pushes
|
|
40
|
+
cannot replace pending checks for a release commit, and the release tag must
|
|
41
|
+
agree with the package version before publication.
|
|
42
|
+
|
|
5
43
|
## [2.0.0] - 2026-09-27
|
|
6
44
|
|
|
7
45
|
### Breaking
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: unstd
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: stdlib-faithful accelerated replacements behind optional extras
|
|
5
5
|
Project-URL: Homepage, https://github.com/The-Billy-Company/unstd
|
|
6
6
|
Project-URL: Repository, https://github.com/The-Billy-Company/unstd
|
|
@@ -43,7 +43,7 @@ Requires-Dist: numpy>=2.5; extra == 'rand'
|
|
|
43
43
|
Provides-Extra: rex
|
|
44
44
|
Requires-Dist: irregex>=2.6.0; extra == 'rex'
|
|
45
45
|
Provides-Extra: serde
|
|
46
|
-
Requires-Dist: msgspec>=0.
|
|
46
|
+
Requires-Dist: msgspec>=0.22; extra == 'serde'
|
|
47
47
|
Requires-Dist: orjson>=3.12; extra == 'serde'
|
|
48
48
|
Requires-Dist: pybase64>=1.5; extra == 'serde'
|
|
49
49
|
Provides-Extra: text
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
doc_radar:
|
|
3
|
+
sentinels:
|
|
4
|
+
- file: CHANGELOG.md
|
|
5
|
+
contains: ['## [2.1.0]', 'Codec(decimal_format=...)']
|
|
6
|
+
- file: pyproject.toml
|
|
7
|
+
contains: ['exclude-newer = "P2D"']
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Unreleased changes
|
|
11
|
+
|
|
12
|
+
We keep one fragment per change here; release history stays in the root changelog.
|
|
13
|
+
Fragments describe what changed for a caller. On release, we fold their bodies
|
|
14
|
+
into the root changelog and move their source assertions with them.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unstd"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "stdlib-faithful accelerated replacements behind optional extras"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -45,7 +45,7 @@ dependencies = []
|
|
|
45
45
|
[project.optional-dependencies]
|
|
46
46
|
serde = [
|
|
47
47
|
"orjson>=3.12",
|
|
48
|
-
"msgspec>=0.
|
|
48
|
+
"msgspec>=0.22",
|
|
49
49
|
"pybase64>=1.5",
|
|
50
50
|
]
|
|
51
51
|
ids = [
|
|
@@ -114,6 +114,11 @@ dev = [
|
|
|
114
114
|
"cryptography>=44.0",
|
|
115
115
|
]
|
|
116
116
|
|
|
117
|
+
[tool.uv]
|
|
118
|
+
# Keep development, optional backends and isolated builds behind the same
|
|
119
|
+
# two-day release quarantine as the applications that consume this package.
|
|
120
|
+
exclude-newer = "P2D"
|
|
121
|
+
|
|
117
122
|
[tool.pytest.ini_options]
|
|
118
123
|
testpaths = ["tests"]
|
|
119
124
|
# The repo root, so `tests/test_bench.py` can import the `bench` harness. `bench`
|
|
@@ -46,6 +46,7 @@ decode.
|
|
|
46
46
|
from __future__ import annotations
|
|
47
47
|
|
|
48
48
|
from collections.abc import Callable
|
|
49
|
+
from decimal import Decimal
|
|
49
50
|
from typing import Literal, SupportsFloat, final
|
|
50
51
|
|
|
51
52
|
|
|
@@ -60,6 +61,7 @@ EncHook = Callable[[object], object]
|
|
|
60
61
|
DecHook = Callable[[type, object], object]
|
|
61
62
|
FloatHook = Callable[[str], object]
|
|
62
63
|
Order = Literal["deterministic", "sorted"] | None
|
|
64
|
+
_DecimalFormat = Literal["string", "number"] | Callable[[Decimal], object]
|
|
63
65
|
|
|
64
66
|
__all__ = [
|
|
65
67
|
"Codec",
|
|
@@ -103,7 +105,7 @@ class Codec[T]:
|
|
|
103
105
|
enc_hook: EncHook | None = None,
|
|
104
106
|
dec_hook: DecHook | None = None,
|
|
105
107
|
order: Order = None,
|
|
106
|
-
decimal_format:
|
|
108
|
+
decimal_format: _DecimalFormat = "string",
|
|
107
109
|
uuid_format: Literal["canonical", "hex"] = "canonical",
|
|
108
110
|
strict: bool = True,
|
|
109
111
|
float_hook: FloatHook | None = None,
|
|
@@ -118,6 +120,10 @@ class Codec[T]:
|
|
|
118
120
|
since it never leaves the typed encoder. ``strict=False`` widens decode
|
|
119
121
|
coercion (e.g. a JSON string where the schema expects an int) — msgspec's
|
|
120
122
|
own escape hatch for loose upstream producers.
|
|
123
|
+
|
|
124
|
+
``decimal_format`` also accepts a callable over each ``Decimal``. Use it
|
|
125
|
+
for an exact decimal policy without converting through a binary float;
|
|
126
|
+
its returned value is encoded normally and its exceptions propagate.
|
|
121
127
|
"""
|
|
122
128
|
self.encode = msgspec.json.Encoder(
|
|
123
129
|
enc_hook=enc_hook,
|
|
@@ -9,6 +9,7 @@ faithfulness contract (bytes match constructing msgspec directly), the
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from decimal import Decimal
|
|
12
|
+
from typing import get_type_hints
|
|
12
13
|
from uuid import UUID
|
|
13
14
|
|
|
14
15
|
import msgspec
|
|
@@ -153,6 +154,59 @@ def test_decimal_format_string_is_default() -> None:
|
|
|
153
154
|
assert codec.encode(_HasDecimal(Decimal("1.5"))) == b'{"amount":"1.5"}'
|
|
154
155
|
|
|
155
156
|
|
|
157
|
+
@pytest.mark.parametrize(
|
|
158
|
+
("amount", "expected"),
|
|
159
|
+
[
|
|
160
|
+
("1.5", b'{"amount":"1.50"}'),
|
|
161
|
+
(
|
|
162
|
+
"12345678901234567890.125",
|
|
163
|
+
b'{"amount":"12345678901234567890.12"}',
|
|
164
|
+
),
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
def test_decimal_format_callable_preserves_exact_policy(
|
|
168
|
+
amount: str, expected: bytes
|
|
169
|
+
) -> None:
|
|
170
|
+
def cents(value: Decimal) -> str:
|
|
171
|
+
return format(value.quantize(Decimal("0.01")), "f")
|
|
172
|
+
|
|
173
|
+
codec = structs.Codec(_HasDecimal, decimal_format=cents)
|
|
174
|
+
encoded = codec.encode(_HasDecimal(Decimal(amount)))
|
|
175
|
+
assert encoded == expected
|
|
176
|
+
assert codec.decode(encoded).amount == Decimal(amount).quantize(Decimal("0.01"))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_decimal_format_callable_error_does_not_poison_reused_encoder() -> None:
|
|
180
|
+
def nonnegative(value: Decimal) -> str:
|
|
181
|
+
if value < 0:
|
|
182
|
+
msg = "negative amount"
|
|
183
|
+
raise ValueError(msg)
|
|
184
|
+
return str(value)
|
|
185
|
+
|
|
186
|
+
codec = structs.Codec(_HasDecimal, decimal_format=nonnegative)
|
|
187
|
+
with pytest.raises(ValueError, match="negative amount"):
|
|
188
|
+
codec.encode(_HasDecimal(Decimal(-1)))
|
|
189
|
+
assert codec.encode(_HasDecimal(Decimal("1.25"))) == b'{"amount":"1.25"}'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_decimal_format_callable_rejects_unencodable_return() -> None:
|
|
193
|
+
def unsupported(value: Decimal) -> object:
|
|
194
|
+
return object() if value < 0 else str(value)
|
|
195
|
+
|
|
196
|
+
codec = structs.Codec(_HasDecimal, decimal_format=unsupported)
|
|
197
|
+
with pytest.raises(TypeError, match="unsupported"):
|
|
198
|
+
codec.encode(_HasDecimal(Decimal(-1)))
|
|
199
|
+
assert codec.encode(_HasDecimal(Decimal("2.5"))) == b'{"amount":"2.5"}'
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_codec_annotations_remain_runtime_resolvable() -> None:
|
|
203
|
+
hints = get_type_hints(
|
|
204
|
+
structs.Codec.__init__,
|
|
205
|
+
localns={param.__name__: param for param in structs.Codec.__type_params__},
|
|
206
|
+
)
|
|
207
|
+
assert hints["decimal_format"] == structs._DecimalFormat
|
|
208
|
+
|
|
209
|
+
|
|
156
210
|
def test_uuid_format_hex_drops_hyphens() -> None:
|
|
157
211
|
u = UUID("12345678-1234-5678-1234-567812345678")
|
|
158
212
|
canonical = structs.Codec(_HasUUID).encode(_HasUUID(u))
|