typedmultidict 0.2.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.
- typedmultidict-0.2.0/.gitattributes +1 -0
- typedmultidict-0.2.0/.gitignore +13 -0
- typedmultidict-0.2.0/CHANGELOG.md +44 -0
- typedmultidict-0.2.0/CONTRIBUTING.md +22 -0
- typedmultidict-0.2.0/LICENSE +21 -0
- typedmultidict-0.2.0/PKG-INFO +213 -0
- typedmultidict-0.2.0/README.md +176 -0
- typedmultidict-0.2.0/pyproject.toml +71 -0
- typedmultidict-0.2.0/requirements-dev.txt +3 -0
- typedmultidict-0.2.0/scripts/check.ps1 +16 -0
- typedmultidict-0.2.0/scripts/release.ps1 +28 -0
- typedmultidict-0.2.0/src/typedmultidict/__init__.py +14 -0
- typedmultidict-0.2.0/src/typedmultidict/_cimultidict.py +31 -0
- typedmultidict-0.2.0/src/typedmultidict/_multidict.py +170 -0
- typedmultidict-0.2.0/src/typedmultidict/_pairs.py +37 -0
- typedmultidict-0.2.0/src/typedmultidict/_state.py +48 -0
- typedmultidict-0.2.0/src/typedmultidict/_storage.py +167 -0
- typedmultidict-0.2.0/src/typedmultidict/_views.py +60 -0
- typedmultidict-0.2.0/src/typedmultidict/py.typed +0 -0
- typedmultidict-0.2.0/tests/test_api.py +164 -0
- typedmultidict-0.2.0/tests/test_ci.py +95 -0
- typedmultidict-0.2.0/tests/test_contract.py +26 -0
- typedmultidict-0.2.0/tests/test_docs.py +67 -0
- typedmultidict-0.2.0/tests/test_e2e_wheel.py +93 -0
- typedmultidict-0.2.0/tests/test_edges.py +75 -0
- typedmultidict-0.2.0/tests/test_equality.py +109 -0
- typedmultidict-0.2.0/tests/test_input.py +93 -0
- typedmultidict-0.2.0/tests/test_iteration.py +73 -0
- typedmultidict-0.2.0/tests/test_model.py +166 -0
- typedmultidict-0.2.0/tests/test_multidict.py +142 -0
- typedmultidict-0.2.0/tests/test_oracle.py +92 -0
- typedmultidict-0.2.0/tests/test_review_fixes.py +126 -0
- typedmultidict-0.2.0/tests/test_typing.py +106 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `getone(key, default)`, `popone(key[, default])`, `merge(other)`, `to_dict()` and
|
|
14
|
+
`copy()`.
|
|
15
|
+
- `allitems()` and `allvalues()` return live, sized views of every pair or value.
|
|
16
|
+
- `update` accepts keyword arguments for string keys, like `MutableMapping.update`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Lookups, `in`, `len()` and adding no longer scan every pair: an index keeps them
|
|
21
|
+
constant time whatever the size.
|
|
22
|
+
- `update` replaces the values of each incoming key in place instead of keeping only the
|
|
23
|
+
first value of a multi-dict source, and `popitem` removes only the last pair added.
|
|
24
|
+
- `popall(key, default)` accepts a default of any type.
|
|
25
|
+
- Bulk input is checked in full before anything changes: `md.extend(md)` no longer hangs,
|
|
26
|
+
and a malformed element raises `TypeError` or `ValueError` with its position.
|
|
27
|
+
- Changing a multi-dict while iterating over it or its views raises `RuntimeError`.
|
|
28
|
+
- Comparing with a plain mapping is equal only when every key has exactly one value, and
|
|
29
|
+
a plain and a case-insensitive multi-dict compare with case folded.
|
|
30
|
+
- Pickles hold plain pairs and name the classes by their public path
|
|
31
|
+
(`typedmultidict.MultiDict`); pickles written by 0.1.0 still load. Copies and pickles
|
|
32
|
+
of subclasses keep their attributes and slots, and a multi-dict may contain itself.
|
|
33
|
+
- `CIMultiDict` raises `TypeError` for a non-`str` key on write and treats it as absent on
|
|
34
|
+
read, instead of failing with `AttributeError`.
|
|
35
|
+
|
|
36
|
+
## [0.1.0]
|
|
37
|
+
|
|
38
|
+
- First release.
|
|
39
|
+
- `MultiDict[K, V]`: typed, insertion-ordered multi-value mapping implementing
|
|
40
|
+
`MutableMapping`, with `add`, `getone`, `getall`, `popall`, `extend` and `allitems`.
|
|
41
|
+
- `CIMultiDict[V]`: case-insensitive string-keyed variant that preserves the original
|
|
42
|
+
casing of each key.
|
|
43
|
+
- Pure Python, zero runtime dependencies, `py.typed`, `mypy --strict` clean. Unlike the
|
|
44
|
+
C-extension `multidict`, it needs no build step.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for taking the time. This project keeps a small, predictable shape.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install -r requirements-dev.txt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Before you open a pull request
|
|
12
|
+
|
|
13
|
+
- Keep public API in `src/typedmultidict/__init__.py`; internals live in `_`-prefixed modules.
|
|
14
|
+
- Add a test for the behaviour you change. Tests inject time, disk and network; they
|
|
15
|
+
never touch the real thing.
|
|
16
|
+
- Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
|
|
17
|
+
- Set `TYPEDMULTIDICT_E2E=1` to also build the wheel, install it in a fresh venv and
|
|
18
|
+
exercise it. `multidict` and `werkzeug` are dev dependencies used only as reference
|
|
19
|
+
implementations in the tests; the library never imports them.
|
|
20
|
+
- One logical change per commit, imperative subject line.
|
|
21
|
+
|
|
22
|
+
Open an issue first if the change is large or shifts the public API.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: typedmultidict
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Pure-Python typed ordered multi-value dictionary with a case-insensitive variant
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/typedmultidict
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/typedmultidict/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/typedmultidict/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: dictionary,headers,mapping,multi-value,multidict,typed
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build; extra == 'dev'
|
|
29
|
+
Requires-Dist: hypothesis; extra == 'dev'
|
|
30
|
+
Requires-Dist: multidict; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine; extra == 'dev'
|
|
35
|
+
Requires-Dist: werkzeug; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# typedmultidict
|
|
39
|
+
|
|
40
|
+
Pure-Python typed ordered multi-value dictionary with a case-insensitive variant.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pip install typedmultidict
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
A `MultiDict[K, V]` is a normal mapping over the first value of each key, plus methods
|
|
51
|
+
that see every value. Pairs keep their insertion order across keys.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from typedmultidict import MultiDict
|
|
55
|
+
|
|
56
|
+
params: MultiDict[str, str] = MultiDict([("tag", "python"), ("page", "2")])
|
|
57
|
+
params.add("tag", "typing")
|
|
58
|
+
|
|
59
|
+
print(params["tag"]) # python
|
|
60
|
+
print(params.getall("tag")) # ['python', 'typing']
|
|
61
|
+
print(len(params), len(params.allitems())) # 2 3
|
|
62
|
+
print(list(params.allitems())) # [('tag', 'python'), ('page', '2'), ('tag', 'typing')]
|
|
63
|
+
print(dict(params)) # {'tag': 'python', 'page': '2'}
|
|
64
|
+
print(params.to_dict()) # {'tag': ['python', 'typing'], 'page': ['2']}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Assigning replaces every value of a key in the position of its first one; removing can
|
|
68
|
+
take the first value, every value, or the last pair added:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from typedmultidict import MultiDict
|
|
72
|
+
|
|
73
|
+
md = MultiDict([("a", 1), ("b", 2), ("a", 3), ("a", 4)])
|
|
74
|
+
md["a"] = 9
|
|
75
|
+
print(md) # MultiDict([('a', 9), ('b', 2)])
|
|
76
|
+
|
|
77
|
+
md.extend([("a", 5), ("c", 6)])
|
|
78
|
+
print(md.popone("a"), md.getall("a")) # 9 [5]
|
|
79
|
+
print(md.popall("a"), "a" in md) # [5] False
|
|
80
|
+
print(md.popitem(), md) # ('c', 6) MultiDict([('b', 2)])
|
|
81
|
+
print(md.getone("missing", "default")) # default
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`extend` appends, `merge` only adds keys that are not present yet, and `update` replaces
|
|
85
|
+
the values of each incoming key in place (extra new values go to the end, extra old
|
|
86
|
+
values are dropped):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from typedmultidict import MultiDict
|
|
90
|
+
|
|
91
|
+
md = MultiDict([("a", 1), ("b", 2), ("a", 3)])
|
|
92
|
+
md.merge([("a", 0), ("c", 4)])
|
|
93
|
+
print(md) # MultiDict([('a', 1), ('b', 2), ('a', 3), ('c', 4)])
|
|
94
|
+
md.update([("a", 7), ("a", 8), ("a", 9)], b=0)
|
|
95
|
+
print(md) # MultiDict([('a', 7), ('b', 0), ('a', 8), ('c', 4), ('a', 9)])
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`CIMultiDict[V]` has `str` keys compared case-insensitively. Every pair keeps the spelling
|
|
99
|
+
it was added with, and keys are listed in the spelling of their first pair:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from typedmultidict import CIMultiDict
|
|
103
|
+
|
|
104
|
+
headers: CIMultiDict[str] = CIMultiDict()
|
|
105
|
+
headers.add("Set-Cookie", "a=1")
|
|
106
|
+
headers.add("set-cookie", "b=2")
|
|
107
|
+
headers.add("Content-Type", "text/html")
|
|
108
|
+
|
|
109
|
+
print(headers["SET-COOKIE"]) # a=1
|
|
110
|
+
print(headers.getall("set-cookie")) # ['a=1', 'b=2']
|
|
111
|
+
print(list(headers)) # ['Set-Cookie', 'Content-Type']
|
|
112
|
+
print(5 in headers) # False
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Views from `allitems()` and `allvalues()` follow the mapping. Changing the mapping while
|
|
116
|
+
iterating over it, or over any of its views, raises `RuntimeError`. As in `multidict`,
|
|
117
|
+
every write counts, even replacing a value in place (which `dict` allows):
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from typedmultidict import MultiDict
|
|
121
|
+
|
|
122
|
+
md = MultiDict([("a", 1)])
|
|
123
|
+
pairs = md.allitems()
|
|
124
|
+
md.add("a", 2)
|
|
125
|
+
print(len(pairs), ("a", 2) in pairs) # 2 True
|
|
126
|
+
try:
|
|
127
|
+
for key in md:
|
|
128
|
+
md.add("b", 3)
|
|
129
|
+
except RuntimeError as error:
|
|
130
|
+
print(error) # multi-dict changed during iteration
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Equality, copies and pickles:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import pickle
|
|
137
|
+
|
|
138
|
+
from typedmultidict import CIMultiDict, MultiDict
|
|
139
|
+
|
|
140
|
+
print(MultiDict([("a", 1), ("b", 2)]) == MultiDict([("b", 2), ("a", 1)])) # False
|
|
141
|
+
print(MultiDict([("a", 1), ("b", 2)]) == {"b": 2, "a": 1}) # True
|
|
142
|
+
print(MultiDict([("a", 1), ("a", 2)]) == {"a": 1}) # False
|
|
143
|
+
print(CIMultiDict([("A", 1)]) == MultiDict([("a", 1)])) # True
|
|
144
|
+
|
|
145
|
+
headers = CIMultiDict([("X-Id", "1")])
|
|
146
|
+
print(pickle.loads(pickle.dumps(headers))) # CIMultiDict([('X-Id', '1')])
|
|
147
|
+
print(headers.copy() == headers, headers.copy() is headers) # True False
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## API
|
|
151
|
+
|
|
152
|
+
| Name | What it does |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| `MultiDict[K, V](initial=None)` | Ordered multi-value mapping. `initial` is a mapping, a multi-dict (every pair is copied) or an iterable of pairs. `K` must be hashable. |
|
|
155
|
+
| `CIMultiDict[V](initial=None)` | `MultiDict[str, V]` whose keys compare with `str.lower`. A non-`str` key raises `TypeError` when added and is absent when looked up. |
|
|
156
|
+
| `md[key]`, `get`, `in`, `keys`, `items`, `values`, `len()`, iteration | The `Mapping` view: first value of each key, keys once each. |
|
|
157
|
+
| `md[key] = value` | Replaces every value of `key` with one, in the place of the first. |
|
|
158
|
+
| `del md[key]`, `pop(key[, default])` | Remove every value of `key` (`pop` returns the first). |
|
|
159
|
+
| `setdefault(key[, default])` | First value of `key`, or adds `default`. |
|
|
160
|
+
| `add(key, value)` | Appends one more pair. |
|
|
161
|
+
| `extend(other)` | Appends every pair of `other`. |
|
|
162
|
+
| `merge(other)` | Appends the pairs whose key is not present yet. |
|
|
163
|
+
| `update(other=(), /, **kwargs)` | Replaces the values of each incoming key in place. |
|
|
164
|
+
| `getone(key[, default])` | First value, else `default`, else `KeyError`. |
|
|
165
|
+
| `getall(key)` | New list of every value of `key`, empty if absent. |
|
|
166
|
+
| `popone(key[, default])` | Removes and returns the first value only. |
|
|
167
|
+
| `popall(key[, default])` | Removes and returns every value as a list. |
|
|
168
|
+
| `popitem()` | Removes and returns the last pair added (`KeyError` when empty). |
|
|
169
|
+
| `allitems()`, `allvalues()` | Live views of every pair or value; `len()` counts pairs. |
|
|
170
|
+
| `to_dict()` | New `dict` from each key to a new list of its values. |
|
|
171
|
+
| `copy()`, `clear()` | Shallow copy of the same class; remove everything. |
|
|
172
|
+
|
|
173
|
+
Bulk input (`initial`, `extend`, `merge`, `update`) is read and checked in full first: an
|
|
174
|
+
element that is not a pair raises `TypeError`, one of the wrong length raises
|
|
175
|
+
`ValueError`, both with its position, and the mapping is left unchanged. Lookups cost the
|
|
176
|
+
same whatever the size; removals are linear in the number of pairs.
|
|
177
|
+
|
|
178
|
+
### Compared with `multidict` and Werkzeug
|
|
179
|
+
|
|
180
|
+
The method names and their behaviour follow the `multidict` package, with two
|
|
181
|
+
differences that keep the type a consistent `Mapping`: `len()` counts keys (use
|
|
182
|
+
`len(md.allitems())` for pairs), and `keys`, `items` and `values` show the first value of
|
|
183
|
+
each key, as Werkzeug's `MultiDict` does. `getall` returns an empty list for a missing
|
|
184
|
+
key instead of raising, and `pop` removes every value of the key.
|
|
185
|
+
|
|
186
|
+
Like `dict`, a multi-dict is not safe to change from several threads at once without a
|
|
187
|
+
lock. Equality between a plain and a case-insensitive multi-dict folds case, so it is not
|
|
188
|
+
transitive across the two kinds. Any other mapping, a `multidict.MultiDict` included, is
|
|
189
|
+
compared as a plain mapping.
|
|
190
|
+
|
|
191
|
+
## Why
|
|
192
|
+
|
|
193
|
+
HTTP headers and query strings routinely carry repeated keys, so a mapping that keeps every
|
|
194
|
+
value is a common need. The existing options each fall short for a plain, typed project:
|
|
195
|
+
|
|
196
|
+
- `multidict` (the aiohttp one) is a C extension; its pure-Python fallback runs many times
|
|
197
|
+
slower and still needs a build step for the fast path.
|
|
198
|
+
- `orderedmultidict` depends on `six` and ships no type hints.
|
|
199
|
+
- The `MultiDict` types in `werkzeug` and `starlette` come bundled with a whole framework.
|
|
200
|
+
|
|
201
|
+
`typedmultidict` is a single, standalone, fully typed implementation with `py.typed`, zero
|
|
202
|
+
runtime dependencies and no compilation. Reach for it when you want multi-value semantics
|
|
203
|
+
and `mypy --strict` support without pulling in a framework or a native wheel.
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT. See [LICENSE](LICENSE).
|
|
208
|
+
|
|
209
|
+
## Links
|
|
210
|
+
|
|
211
|
+
- Website: https://lianes8studio.com
|
|
212
|
+
- GitHub: https://github.com/JaviLianes8
|
|
213
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# typedmultidict
|
|
2
|
+
|
|
3
|
+
Pure-Python typed ordered multi-value dictionary with a case-insensitive variant.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install typedmultidict
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
A `MultiDict[K, V]` is a normal mapping over the first value of each key, plus methods
|
|
14
|
+
that see every value. Pairs keep their insertion order across keys.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from typedmultidict import MultiDict
|
|
18
|
+
|
|
19
|
+
params: MultiDict[str, str] = MultiDict([("tag", "python"), ("page", "2")])
|
|
20
|
+
params.add("tag", "typing")
|
|
21
|
+
|
|
22
|
+
print(params["tag"]) # python
|
|
23
|
+
print(params.getall("tag")) # ['python', 'typing']
|
|
24
|
+
print(len(params), len(params.allitems())) # 2 3
|
|
25
|
+
print(list(params.allitems())) # [('tag', 'python'), ('page', '2'), ('tag', 'typing')]
|
|
26
|
+
print(dict(params)) # {'tag': 'python', 'page': '2'}
|
|
27
|
+
print(params.to_dict()) # {'tag': ['python', 'typing'], 'page': ['2']}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Assigning replaces every value of a key in the position of its first one; removing can
|
|
31
|
+
take the first value, every value, or the last pair added:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from typedmultidict import MultiDict
|
|
35
|
+
|
|
36
|
+
md = MultiDict([("a", 1), ("b", 2), ("a", 3), ("a", 4)])
|
|
37
|
+
md["a"] = 9
|
|
38
|
+
print(md) # MultiDict([('a', 9), ('b', 2)])
|
|
39
|
+
|
|
40
|
+
md.extend([("a", 5), ("c", 6)])
|
|
41
|
+
print(md.popone("a"), md.getall("a")) # 9 [5]
|
|
42
|
+
print(md.popall("a"), "a" in md) # [5] False
|
|
43
|
+
print(md.popitem(), md) # ('c', 6) MultiDict([('b', 2)])
|
|
44
|
+
print(md.getone("missing", "default")) # default
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`extend` appends, `merge` only adds keys that are not present yet, and `update` replaces
|
|
48
|
+
the values of each incoming key in place (extra new values go to the end, extra old
|
|
49
|
+
values are dropped):
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from typedmultidict import MultiDict
|
|
53
|
+
|
|
54
|
+
md = MultiDict([("a", 1), ("b", 2), ("a", 3)])
|
|
55
|
+
md.merge([("a", 0), ("c", 4)])
|
|
56
|
+
print(md) # MultiDict([('a', 1), ('b', 2), ('a', 3), ('c', 4)])
|
|
57
|
+
md.update([("a", 7), ("a", 8), ("a", 9)], b=0)
|
|
58
|
+
print(md) # MultiDict([('a', 7), ('b', 0), ('a', 8), ('c', 4), ('a', 9)])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`CIMultiDict[V]` has `str` keys compared case-insensitively. Every pair keeps the spelling
|
|
62
|
+
it was added with, and keys are listed in the spelling of their first pair:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from typedmultidict import CIMultiDict
|
|
66
|
+
|
|
67
|
+
headers: CIMultiDict[str] = CIMultiDict()
|
|
68
|
+
headers.add("Set-Cookie", "a=1")
|
|
69
|
+
headers.add("set-cookie", "b=2")
|
|
70
|
+
headers.add("Content-Type", "text/html")
|
|
71
|
+
|
|
72
|
+
print(headers["SET-COOKIE"]) # a=1
|
|
73
|
+
print(headers.getall("set-cookie")) # ['a=1', 'b=2']
|
|
74
|
+
print(list(headers)) # ['Set-Cookie', 'Content-Type']
|
|
75
|
+
print(5 in headers) # False
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Views from `allitems()` and `allvalues()` follow the mapping. Changing the mapping while
|
|
79
|
+
iterating over it, or over any of its views, raises `RuntimeError`. As in `multidict`,
|
|
80
|
+
every write counts, even replacing a value in place (which `dict` allows):
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from typedmultidict import MultiDict
|
|
84
|
+
|
|
85
|
+
md = MultiDict([("a", 1)])
|
|
86
|
+
pairs = md.allitems()
|
|
87
|
+
md.add("a", 2)
|
|
88
|
+
print(len(pairs), ("a", 2) in pairs) # 2 True
|
|
89
|
+
try:
|
|
90
|
+
for key in md:
|
|
91
|
+
md.add("b", 3)
|
|
92
|
+
except RuntimeError as error:
|
|
93
|
+
print(error) # multi-dict changed during iteration
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Equality, copies and pickles:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import pickle
|
|
100
|
+
|
|
101
|
+
from typedmultidict import CIMultiDict, MultiDict
|
|
102
|
+
|
|
103
|
+
print(MultiDict([("a", 1), ("b", 2)]) == MultiDict([("b", 2), ("a", 1)])) # False
|
|
104
|
+
print(MultiDict([("a", 1), ("b", 2)]) == {"b": 2, "a": 1}) # True
|
|
105
|
+
print(MultiDict([("a", 1), ("a", 2)]) == {"a": 1}) # False
|
|
106
|
+
print(CIMultiDict([("A", 1)]) == MultiDict([("a", 1)])) # True
|
|
107
|
+
|
|
108
|
+
headers = CIMultiDict([("X-Id", "1")])
|
|
109
|
+
print(pickle.loads(pickle.dumps(headers))) # CIMultiDict([('X-Id', '1')])
|
|
110
|
+
print(headers.copy() == headers, headers.copy() is headers) # True False
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## API
|
|
114
|
+
|
|
115
|
+
| Name | What it does |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `MultiDict[K, V](initial=None)` | Ordered multi-value mapping. `initial` is a mapping, a multi-dict (every pair is copied) or an iterable of pairs. `K` must be hashable. |
|
|
118
|
+
| `CIMultiDict[V](initial=None)` | `MultiDict[str, V]` whose keys compare with `str.lower`. A non-`str` key raises `TypeError` when added and is absent when looked up. |
|
|
119
|
+
| `md[key]`, `get`, `in`, `keys`, `items`, `values`, `len()`, iteration | The `Mapping` view: first value of each key, keys once each. |
|
|
120
|
+
| `md[key] = value` | Replaces every value of `key` with one, in the place of the first. |
|
|
121
|
+
| `del md[key]`, `pop(key[, default])` | Remove every value of `key` (`pop` returns the first). |
|
|
122
|
+
| `setdefault(key[, default])` | First value of `key`, or adds `default`. |
|
|
123
|
+
| `add(key, value)` | Appends one more pair. |
|
|
124
|
+
| `extend(other)` | Appends every pair of `other`. |
|
|
125
|
+
| `merge(other)` | Appends the pairs whose key is not present yet. |
|
|
126
|
+
| `update(other=(), /, **kwargs)` | Replaces the values of each incoming key in place. |
|
|
127
|
+
| `getone(key[, default])` | First value, else `default`, else `KeyError`. |
|
|
128
|
+
| `getall(key)` | New list of every value of `key`, empty if absent. |
|
|
129
|
+
| `popone(key[, default])` | Removes and returns the first value only. |
|
|
130
|
+
| `popall(key[, default])` | Removes and returns every value as a list. |
|
|
131
|
+
| `popitem()` | Removes and returns the last pair added (`KeyError` when empty). |
|
|
132
|
+
| `allitems()`, `allvalues()` | Live views of every pair or value; `len()` counts pairs. |
|
|
133
|
+
| `to_dict()` | New `dict` from each key to a new list of its values. |
|
|
134
|
+
| `copy()`, `clear()` | Shallow copy of the same class; remove everything. |
|
|
135
|
+
|
|
136
|
+
Bulk input (`initial`, `extend`, `merge`, `update`) is read and checked in full first: an
|
|
137
|
+
element that is not a pair raises `TypeError`, one of the wrong length raises
|
|
138
|
+
`ValueError`, both with its position, and the mapping is left unchanged. Lookups cost the
|
|
139
|
+
same whatever the size; removals are linear in the number of pairs.
|
|
140
|
+
|
|
141
|
+
### Compared with `multidict` and Werkzeug
|
|
142
|
+
|
|
143
|
+
The method names and their behaviour follow the `multidict` package, with two
|
|
144
|
+
differences that keep the type a consistent `Mapping`: `len()` counts keys (use
|
|
145
|
+
`len(md.allitems())` for pairs), and `keys`, `items` and `values` show the first value of
|
|
146
|
+
each key, as Werkzeug's `MultiDict` does. `getall` returns an empty list for a missing
|
|
147
|
+
key instead of raising, and `pop` removes every value of the key.
|
|
148
|
+
|
|
149
|
+
Like `dict`, a multi-dict is not safe to change from several threads at once without a
|
|
150
|
+
lock. Equality between a plain and a case-insensitive multi-dict folds case, so it is not
|
|
151
|
+
transitive across the two kinds. Any other mapping, a `multidict.MultiDict` included, is
|
|
152
|
+
compared as a plain mapping.
|
|
153
|
+
|
|
154
|
+
## Why
|
|
155
|
+
|
|
156
|
+
HTTP headers and query strings routinely carry repeated keys, so a mapping that keeps every
|
|
157
|
+
value is a common need. The existing options each fall short for a plain, typed project:
|
|
158
|
+
|
|
159
|
+
- `multidict` (the aiohttp one) is a C extension; its pure-Python fallback runs many times
|
|
160
|
+
slower and still needs a build step for the fast path.
|
|
161
|
+
- `orderedmultidict` depends on `six` and ships no type hints.
|
|
162
|
+
- The `MultiDict` types in `werkzeug` and `starlette` come bundled with a whole framework.
|
|
163
|
+
|
|
164
|
+
`typedmultidict` is a single, standalone, fully typed implementation with `py.typed`, zero
|
|
165
|
+
runtime dependencies and no compilation. Reach for it when you want multi-value semantics
|
|
166
|
+
and `mypy --strict` support without pulling in a framework or a native wheel.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT. See [LICENSE](LICENSE).
|
|
171
|
+
|
|
172
|
+
## Links
|
|
173
|
+
|
|
174
|
+
- Website: https://lianes8studio.com
|
|
175
|
+
- GitHub: https://github.com/JaviLianes8
|
|
176
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "typedmultidict"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Pure-Python typed ordered multi-value dictionary with a case-insensitive variant"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
|
|
14
|
+
keywords = ["multidict", "multi-value", "dictionary", "mapping", "headers", "typed"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"ruff",
|
|
33
|
+
"mypy",
|
|
34
|
+
"pytest",
|
|
35
|
+
"hypothesis",
|
|
36
|
+
"build",
|
|
37
|
+
"twine",
|
|
38
|
+
# Test-only reference implementations; the library never imports them.
|
|
39
|
+
"multidict",
|
|
40
|
+
"werkzeug",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://lianes8studio.com"
|
|
45
|
+
Repository = "https://github.com/JaviLianes8/typedmultidict"
|
|
46
|
+
Issues = "https://github.com/JaviLianes8/typedmultidict/issues"
|
|
47
|
+
Changelog = "https://github.com/JaviLianes8/typedmultidict/blob/main/CHANGELOG.md"
|
|
48
|
+
LinkedIn = "https://www.linkedin.com/in/jlianes/"
|
|
49
|
+
Funding = "https://buymeacoffee.com/jlianesglrs"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.sdist]
|
|
52
|
+
exclude = [".github", "IMPROVEMENTS-round*.md"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.wheel]
|
|
55
|
+
packages = ["src/typedmultidict"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
line-length = 100
|
|
59
|
+
target-version = "py310"
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
63
|
+
|
|
64
|
+
[tool.mypy]
|
|
65
|
+
python_version = "3.10"
|
|
66
|
+
strict = true
|
|
67
|
+
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
testpaths = ["tests"]
|
|
70
|
+
pythonpath = ["src"]
|
|
71
|
+
addopts = "-q"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Local quality gate. Run before every commit. No CI runs in the cloud.
|
|
2
|
+
# $ErrorActionPreference does NOT stop on a native command's non-zero exit, so each tool's
|
|
3
|
+
# exit code is checked explicitly: a red gate must fail, never pass silently.
|
|
4
|
+
$ErrorActionPreference = "Stop"
|
|
5
|
+
|
|
6
|
+
function Invoke-Step($label, [scriptblock]$cmd) {
|
|
7
|
+
Write-Host "== $label =="
|
|
8
|
+
& $cmd
|
|
9
|
+
if ($LASTEXITCODE -ne 0) { Write-Host "FAILED: $label (exit $LASTEXITCODE)"; exit $LASTEXITCODE }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
Invoke-Step "ruff format" { ruff format --check . }
|
|
13
|
+
Invoke-Step "ruff lint" { ruff check . }
|
|
14
|
+
Invoke-Step "mypy" { mypy --strict src tests }
|
|
15
|
+
Invoke-Step "pytest" { pytest }
|
|
16
|
+
Write-Host "gate green"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Build and upload to PyPI. Run scripts/check.ps1 first.
|
|
2
|
+
# The name must not already exist on PyPI; the token is read from a secret store, never git.
|
|
3
|
+
$ErrorActionPreference = "Stop"
|
|
4
|
+
|
|
5
|
+
# Works on both Windows PowerShell 5.1 and pwsh 7 (generic catch, no PS7-only types).
|
|
6
|
+
$taken = $true
|
|
7
|
+
try {
|
|
8
|
+
Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/typedmultidict/json" | Out-Null
|
|
9
|
+
} catch {
|
|
10
|
+
if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
|
|
11
|
+
}
|
|
12
|
+
if ($taken) { throw "The name 'typedmultidict' already exists on PyPI. Choose another before publishing." }
|
|
13
|
+
|
|
14
|
+
$token = $env:PYPI_TOKEN
|
|
15
|
+
if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
|
|
16
|
+
$token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
|
|
17
|
+
}
|
|
18
|
+
if (-not $token) {
|
|
19
|
+
throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
|
|
23
|
+
python -m build
|
|
24
|
+
python -m twine check dist/*
|
|
25
|
+
|
|
26
|
+
$env:TWINE_USERNAME = "__token__"
|
|
27
|
+
$env:TWINE_PASSWORD = $token
|
|
28
|
+
try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Pure-Python typed ordered multi-value dictionary with a case-insensitive variant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._cimultidict import CIMultiDict
|
|
6
|
+
from ._multidict import MultiDict
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
# Pickles name a class by its module: point them at the public one, which never moves.
|
|
11
|
+
CIMultiDict.__module__ = __name__
|
|
12
|
+
MultiDict.__module__ = __name__
|
|
13
|
+
|
|
14
|
+
__all__ = ["CIMultiDict", "MultiDict"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Case-insensitive multi-value mapping for HTTP-style string keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from typing import ClassVar, TypeVar
|
|
7
|
+
|
|
8
|
+
from ._multidict import MultiDict
|
|
9
|
+
|
|
10
|
+
V = TypeVar("V")
|
|
11
|
+
|
|
12
|
+
# Never equal to a stored token, so a key of the wrong type is simply absent on lookup.
|
|
13
|
+
_NO_MATCH = object()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CIMultiDict(MultiDict[str, V]):
|
|
17
|
+
"""Keys compare case-insensitively (``str.lower``); each pair keeps its own spelling.
|
|
18
|
+
|
|
19
|
+
Keys must be ``str`` (or a subclass): adding any other key raises ``TypeError``, while
|
|
20
|
+
looking one up simply finds nothing.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_folds_case: ClassVar[bool] = True
|
|
24
|
+
|
|
25
|
+
def _token(self, key: str) -> Hashable:
|
|
26
|
+
if not isinstance(key, str):
|
|
27
|
+
raise TypeError(f"{type(self).__name__} keys must be str, not {type(key).__name__}")
|
|
28
|
+
return str.lower(key)
|
|
29
|
+
|
|
30
|
+
def _probe(self, key: object) -> Hashable:
|
|
31
|
+
return str.lower(key) if isinstance(key, str) else _NO_MATCH
|