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.
Files changed (110) hide show
  1. {unstd-2.0.0 → unstd-2.1.0}/.github/workflows/ci.yml +12 -8
  2. {unstd-2.0.0 → unstd-2.1.0}/.github/workflows/release.yml +6 -1
  3. {unstd-2.0.0 → unstd-2.1.0}/CHANGELOG.md +38 -0
  4. {unstd-2.0.0 → unstd-2.1.0}/PKG-INFO +2 -2
  5. unstd-2.1.0/changelog.d/README.md +14 -0
  6. {unstd-2.0.0 → unstd-2.1.0}/pyproject.toml +7 -2
  7. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/structs.py +7 -1
  8. {unstd-2.0.0 → unstd-2.1.0}/tests/serde/test_structs.py +54 -0
  9. {unstd-2.0.0 → unstd-2.1.0}/uv.lock +599 -549
  10. {unstd-2.0.0 → unstd-2.1.0}/.gitignore +0 -0
  11. {unstd-2.0.0 → unstd-2.1.0}/LICENSE +0 -0
  12. {unstd-2.0.0 → unstd-2.1.0}/NOTICE +0 -0
  13. {unstd-2.0.0 → unstd-2.1.0}/README.md +0 -0
  14. {unstd-2.0.0 → unstd-2.1.0}/bench/README.md +0 -0
  15. {unstd-2.0.0 → unstd-2.1.0}/bench/__init__.py +0 -0
  16. {unstd-2.0.0 → unstd-2.1.0}/bench/__main__.py +0 -0
  17. {unstd-2.0.0 → unstd-2.1.0}/bench/baseline.json +0 -0
  18. {unstd-2.0.0 → unstd-2.1.0}/bench/cases.py +0 -0
  19. {unstd-2.0.0 → unstd-2.1.0}/bench/report.py +0 -0
  20. {unstd-2.0.0 → unstd-2.1.0}/bench/timing.py +0 -0
  21. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/__init__.py +0 -0
  22. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/_extra.py +0 -0
  23. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/audio/README.md +0 -0
  24. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/audio/__init__.py +0 -0
  25. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/audio/wavx.py +0 -0
  26. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/clone/README.md +0 -0
  27. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/clone/__init__.py +0 -0
  28. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/clone/structural.py +0 -0
  29. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/containers/README.md +0 -0
  30. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/containers/__init__.py +0 -0
  31. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/containers/persistent.py +0 -0
  32. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/containers/sorted.py +0 -0
  33. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/README.md +0 -0
  34. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/__init__.py +0 -0
  35. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/_blake3.py +0 -0
  36. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/digest.py +0 -0
  37. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/tls.py +0 -0
  38. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/crypto/token.py +0 -0
  39. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/fs/README.md +0 -0
  40. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/fs/__init__.py +0 -0
  41. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/ids/README.md +0 -0
  42. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/ids/__init__.py +0 -0
  43. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/ids/uid.py +0 -0
  44. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/README.md +0 -0
  45. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/__init__.py +0 -0
  46. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/consecutive.py +0 -0
  47. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/distinct.py +0 -0
  48. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/reshape.py +0 -0
  49. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/iters/select.py +0 -0
  50. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/model/README.md +0 -0
  51. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/model/__init__.py +0 -0
  52. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/model/base.py +0 -0
  53. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/model/settings.py +0 -0
  54. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/pack/README.md +0 -0
  55. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/pack/__init__.py +0 -0
  56. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/pack/binpack.py +0 -0
  57. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/proc/README.md +0 -0
  58. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/proc/__init__.py +0 -0
  59. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/py.typed +0 -0
  60. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rand/README.md +0 -0
  61. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rand/__init__.py +0 -0
  62. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rand/crypto.py +0 -0
  63. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rand/draw.py +0 -0
  64. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rex/README.md +0 -0
  65. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/rex/__init__.py +0 -0
  66. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/README.md +0 -0
  67. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/__init__.py +0 -0
  68. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/b64.py +0 -0
  69. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/jsonx.py +0 -0
  70. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/serde/ndjson.py +0 -0
  71. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/text/README.md +0 -0
  72. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/text/__init__.py +0 -0
  73. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/text/fuzz.py +0 -0
  74. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/time/README.md +0 -0
  75. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/time/__init__.py +0 -0
  76. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/time/timeutil.py +0 -0
  77. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/time/zoned.py +0 -0
  78. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/toml/README.md +0 -0
  79. {unstd-2.0.0 → unstd-2.1.0}/src/unstd/toml/__init__.py +0 -0
  80. {unstd-2.0.0 → unstd-2.1.0}/tests/audio/test_audio.py +0 -0
  81. {unstd-2.0.0 → unstd-2.1.0}/tests/clone/test_clone.py +0 -0
  82. {unstd-2.0.0 → unstd-2.1.0}/tests/conftest.py +0 -0
  83. {unstd-2.0.0 → unstd-2.1.0}/tests/containers/test_containers.py +0 -0
  84. {unstd-2.0.0 → unstd-2.1.0}/tests/crypto/blake3_reference_vectors.json +0 -0
  85. {unstd-2.0.0 → unstd-2.1.0}/tests/crypto/test_adverse.py +0 -0
  86. {unstd-2.0.0 → unstd-2.1.0}/tests/crypto/test_vectors.py +0 -0
  87. {unstd-2.0.0 → unstd-2.1.0}/tests/fs/test_fs.py +0 -0
  88. {unstd-2.0.0 → unstd-2.1.0}/tests/ids/test_uid.py +0 -0
  89. {unstd-2.0.0 → unstd-2.1.0}/tests/iters/test_iters.py +0 -0
  90. {unstd-2.0.0 → unstd-2.1.0}/tests/model/test_model.py +0 -0
  91. {unstd-2.0.0 → unstd-2.1.0}/tests/pack/test_pack.py +0 -0
  92. {unstd-2.0.0 → unstd-2.1.0}/tests/proc/test_proc.py +0 -0
  93. {unstd-2.0.0 → unstd-2.1.0}/tests/properties/README.md +0 -0
  94. {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_clone_invariants.py +0 -0
  95. {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_pack_invariants.py +0 -0
  96. {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_serde_parity.py +0 -0
  97. {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_toml_invariants.py +0 -0
  98. {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_uuid7.py +0 -0
  99. {unstd-2.0.0 → unstd-2.1.0}/tests/properties/test_wav_quant.py +0 -0
  100. {unstd-2.0.0 → unstd-2.1.0}/tests/rand/test_rand.py +0 -0
  101. {unstd-2.0.0 → unstd-2.1.0}/tests/rex/test_rex.py +0 -0
  102. {unstd-2.0.0 → unstd-2.1.0}/tests/serde/test_b64.py +0 -0
  103. {unstd-2.0.0 → unstd-2.1.0}/tests/serde/test_jsonx.py +0 -0
  104. {unstd-2.0.0 → unstd-2.1.0}/tests/serde/test_ndjson.py +0 -0
  105. {unstd-2.0.0 → unstd-2.1.0}/tests/test_bench.py +0 -0
  106. {unstd-2.0.0 → unstd-2.1.0}/tests/test_package.py +0 -0
  107. {unstd-2.0.0 → unstd-2.1.0}/tests/text/test_text.py +0 -0
  108. {unstd-2.0.0 → unstd-2.1.0}/tests/time/test_timeutil.py +0 -0
  109. {unstd-2.0.0 → unstd-2.1.0}/tests/time/test_zoned.py +0 -0
  110. {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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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.0.0
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.21; extra == 'serde'
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.0.0"
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.21",
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: Literal["string", "number"] = "string",
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))