http-sf 1.2.0__tar.gz → 1.3.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.
- {http_sf-1.2.0 → http_sf-1.3.0}/PKG-INFO +17 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/README.md +15 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/__init__.py +11 -12
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/__main__.py +0 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/bare_item.py +6 -6
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/byteseq.py +2 -1
- http_sf-1.3.0/http_sf/compat.py +203 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/date.py +9 -3
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/dictionary.py +2 -3
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/innerlist.py +3 -4
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/integer.py +3 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/item.py +2 -2
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/list.py +0 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/parameters.py +1 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/token.py +2 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/types.py +2 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/util.py +8 -5
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/PKG-INFO +17 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/SOURCES.txt +4 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/requires.txt +1 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/pyproject.toml +2 -33
- {http_sf-1.2.0 → http_sf-1.3.0}/test/test.py +5 -1
- http_sf-1.3.0/test/test_compat.py +129 -0
- http_sf-1.3.0/test/test_compat_full.py +187 -0
- http_sf-1.3.0/test/test_error_contract.py +69 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/MANIFEST.in +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/boolean.py +1 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/decimal.py +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/display_string.py +1 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/errors.py +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/py.typed +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/retrofit.py +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/state.py +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/string.py +1 -1
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/dependency_links.txt +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/top_level.txt +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/setup.cfg +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/test/test_callback.py +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/test/test_perf.py +0 -0
- {http_sf-1.2.0 → http_sf-1.3.0}/test/test_position.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: http-sf
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Parse and serialise HTTP Structured Fields
|
|
5
5
|
Author-email: Mark Nottingham <mnot@mnot.net>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,6 +13,7 @@ Requires-Dist: typing_extensions
|
|
|
13
13
|
Provides-Extra: dev
|
|
14
14
|
Requires-Dist: mypy; extra == "dev"
|
|
15
15
|
Requires-Dist: black; extra == "dev"
|
|
16
|
+
Requires-Dist: isort; extra == "dev"
|
|
16
17
|
Requires-Dist: pylint; extra == "dev"
|
|
17
18
|
Requires-Dist: pytest; extra == "dev"
|
|
18
19
|
Requires-Dist: pytest-md; extra == "dev"
|
|
@@ -138,6 +139,21 @@ When using `ser`, if an Item or Inner List doesn't have parameters, they can be
|
|
|
138
139
|
Note that `ser` produces a string, not a bytes-like object.
|
|
139
140
|
|
|
140
141
|
|
|
142
|
+
## Migrating from `http_sfv`
|
|
143
|
+
|
|
144
|
+
If you have code that uses the deprecated [http_sfv](https://pypi.org/project/http-sfv/) package, a drop-in compatibility layer is available in `http_sf.compat`. It provides the same object-oriented `Dictionary`, `List`, `Item`, `InnerList`, `Token`, and `DisplayString` classes built on top of this library's functional API, so existing code typically only needs an import change:
|
|
145
|
+
|
|
146
|
+
~~~ python
|
|
147
|
+
# Before
|
|
148
|
+
from http_sfv import Dictionary, List, Item, Token
|
|
149
|
+
|
|
150
|
+
# After
|
|
151
|
+
from http_sf.compat import Dictionary, List, Item, Token
|
|
152
|
+
~~~
|
|
153
|
+
|
|
154
|
+
The compat layer exposes the same `.parse(bytes)` / `str(...)` workflow, `.value` / `.params` attributes, structural list/dict behaviour, and Item equality semantics (compared by value, ignoring parameters) as `http_sfv`. New code should prefer the functional `parse` / `ser` API documented above.
|
|
155
|
+
|
|
156
|
+
|
|
141
157
|
## Command Line Use
|
|
142
158
|
|
|
143
159
|
You can validate and examine the data model of a field value by calling the library on the command line, using `-d`, `-l` and `-i` to denote dictionaries, lists or items respectively; e.g.,
|
|
@@ -117,6 +117,21 @@ When using `ser`, if an Item or Inner List doesn't have parameters, they can be
|
|
|
117
117
|
Note that `ser` produces a string, not a bytes-like object.
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
## Migrating from `http_sfv`
|
|
121
|
+
|
|
122
|
+
If you have code that uses the deprecated [http_sfv](https://pypi.org/project/http-sfv/) package, a drop-in compatibility layer is available in `http_sf.compat`. It provides the same object-oriented `Dictionary`, `List`, `Item`, `InnerList`, `Token`, and `DisplayString` classes built on top of this library's functional API, so existing code typically only needs an import change:
|
|
123
|
+
|
|
124
|
+
~~~ python
|
|
125
|
+
# Before
|
|
126
|
+
from http_sfv import Dictionary, List, Item, Token
|
|
127
|
+
|
|
128
|
+
# After
|
|
129
|
+
from http_sf.compat import Dictionary, List, Item, Token
|
|
130
|
+
~~~
|
|
131
|
+
|
|
132
|
+
The compat layer exposes the same `.parse(bytes)` / `str(...)` workflow, `.value` / `.params` attributes, structural list/dict behaviour, and Item equality semantics (compared by value, ignoring parameters) as `http_sfv`. New code should prefer the functional `parse` / `ser` API documented above.
|
|
133
|
+
|
|
134
|
+
|
|
120
135
|
## Command Line Use
|
|
121
136
|
|
|
122
137
|
You can validate and examine the data model of a field value by calling the library on the command line, using `-d`, `-l` and `-i` to denote dictionaries, lists or items respectively; e.g.,
|
|
@@ -25,28 +25,27 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
|
25
25
|
THE SOFTWARE.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
|
-
__version__ = "1.
|
|
28
|
+
__version__ = "1.3.0"
|
|
29
29
|
|
|
30
|
-
from typing import
|
|
30
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
31
31
|
|
|
32
32
|
from http_sf.dictionary import parse_dictionary, ser_dictionary
|
|
33
|
+
from http_sf.errors import StructuredFieldError
|
|
33
34
|
from http_sf.item import parse_item, ser_item
|
|
34
35
|
from http_sf.list import parse_list, ser_list
|
|
35
36
|
from http_sf.retrofit import retrofit
|
|
37
|
+
from http_sf.state import ParserState
|
|
36
38
|
from http_sf.types import (
|
|
37
|
-
StructuredType,
|
|
38
|
-
Token,
|
|
39
|
-
DisplayString,
|
|
40
|
-
OnDuplicateKeyType,
|
|
41
39
|
DictionaryType,
|
|
42
|
-
|
|
43
|
-
ItemType,
|
|
40
|
+
DisplayString,
|
|
44
41
|
InnerListType,
|
|
42
|
+
ItemType,
|
|
43
|
+
ListType,
|
|
44
|
+
OnDuplicateKeyType,
|
|
45
|
+
StructuredType,
|
|
46
|
+
Token,
|
|
45
47
|
)
|
|
46
|
-
from http_sf.util import discard_ows,
|
|
47
|
-
from http_sf.state import ParserState
|
|
48
|
-
from http_sf.errors import StructuredFieldError
|
|
49
|
-
|
|
48
|
+
from http_sf.util import discard_ows, from_json, to_json
|
|
50
49
|
|
|
51
50
|
__all__ = [
|
|
52
51
|
"parse",
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
2
|
from decimal import Decimal
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import Any, Callable, Dict, Type
|
|
4
4
|
|
|
5
5
|
from http_sf.boolean import parse_boolean, ser_boolean
|
|
6
|
-
from http_sf.byteseq import parse_byteseq, ser_byteseq
|
|
6
|
+
from http_sf.byteseq import BYTE_DELIMIT, parse_byteseq, ser_byteseq
|
|
7
7
|
from http_sf.date import parse_date, ser_date
|
|
8
8
|
from http_sf.decimal import ser_decimal
|
|
9
9
|
from http_sf.display_string import parse_display_string, ser_display_string
|
|
10
|
-
from http_sf.integer import parse_number, ser_integer
|
|
11
|
-
from http_sf.string import parse_string, ser_string
|
|
12
|
-
from http_sf.token import parse_token, ser_token
|
|
13
|
-
from http_sf.types import BareItemType,
|
|
10
|
+
from http_sf.integer import NUMBER_START_CHARS, parse_number, ser_integer
|
|
11
|
+
from http_sf.string import DQUOTE, parse_string, ser_string
|
|
12
|
+
from http_sf.token import TOKEN_START_CHARS, parse_token, ser_token
|
|
13
|
+
from http_sf.types import BareItemType, DisplayString, Token
|
|
14
14
|
|
|
15
15
|
from .errors import StructuredFieldError
|
|
16
16
|
from .state import ParserState
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import binascii
|
|
3
3
|
from string import ascii_letters, digits
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
from .errors import StructuredFieldError
|
|
6
|
+
from .state import ParserState
|
|
6
7
|
|
|
7
8
|
BYTE_DELIMIT = ord(b":")
|
|
8
9
|
B64CONTENT = set((ascii_letters + digits + "+/=").encode("ascii"))
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backwards-compatibility layer that mimics the object-oriented API of the
|
|
3
|
+
deprecated http_sfv package on top of http_sf's functional API.
|
|
4
|
+
|
|
5
|
+
Allows existing http_sfv users to migrate by changing only their imports:
|
|
6
|
+
|
|
7
|
+
from http_sf.compat import Dictionary, List, Item, InnerList, Token, DisplayString
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections import UserDict, UserList
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
Dict as _Dict,
|
|
14
|
+
Iterable,
|
|
15
|
+
List as _List,
|
|
16
|
+
Mapping,
|
|
17
|
+
Optional,
|
|
18
|
+
Union,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from typing_extensions import SupportsIndex
|
|
22
|
+
|
|
23
|
+
from . import parse as _parse, ser as _ser
|
|
24
|
+
from .innerlist import ser_innerlist
|
|
25
|
+
from .item import ser_item
|
|
26
|
+
from .parameters import ser_params
|
|
27
|
+
from .types import (
|
|
28
|
+
BareItemType,
|
|
29
|
+
DisplayString,
|
|
30
|
+
Token,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Dictionary",
|
|
35
|
+
"List",
|
|
36
|
+
"Item",
|
|
37
|
+
"InnerList",
|
|
38
|
+
"Parameters",
|
|
39
|
+
"Token",
|
|
40
|
+
"DisplayString",
|
|
41
|
+
"structures",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Parameters(dict): # type: ignore[type-arg]
|
|
46
|
+
"""Parameter mapping with structured-field serialisation."""
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return ser_params(self)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
SingleItemType = Union[BareItemType, "Item"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Item:
|
|
56
|
+
"""A bare Item with optional Parameters."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, value: Optional[BareItemType] = None) -> None:
|
|
59
|
+
self.value: Optional[BareItemType] = value
|
|
60
|
+
self.params: Parameters = Parameters()
|
|
61
|
+
|
|
62
|
+
def parse(self, data: bytes) -> None:
|
|
63
|
+
result = _parse(data, tltype="item")
|
|
64
|
+
assert isinstance(result, tuple)
|
|
65
|
+
value, params = result
|
|
66
|
+
self.value = value
|
|
67
|
+
self.params = Parameters(params)
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
return ser_item((self.value, dict(self.params))) # type: ignore[arg-type]
|
|
71
|
+
|
|
72
|
+
def __eq__(self, other: Any) -> bool:
|
|
73
|
+
if isinstance(other, Item):
|
|
74
|
+
return bool(self.value == other.value)
|
|
75
|
+
return bool(self.value == other)
|
|
76
|
+
|
|
77
|
+
def __ne__(self, other: Any) -> bool:
|
|
78
|
+
return not self.__eq__(other)
|
|
79
|
+
|
|
80
|
+
def __hash__(self) -> int:
|
|
81
|
+
return hash(self.value)
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return f"<http_sf.compat.Item value={self.value!r} params={dict(self.params)!r}>"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
AllItemType = Union[BareItemType, Item, "InnerList", _List[Any]]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _itemise(thing: AllItemType) -> Union[Item, "InnerList"]:
|
|
91
|
+
if isinstance(thing, (Item, InnerList)):
|
|
92
|
+
return thing
|
|
93
|
+
if isinstance(thing, list):
|
|
94
|
+
return InnerList(thing)
|
|
95
|
+
return Item(thing)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _to_functional(thing: Union[Item, "InnerList"]) -> Any:
|
|
99
|
+
if isinstance(thing, InnerList):
|
|
100
|
+
members = [_to_functional(i) for i in thing.data]
|
|
101
|
+
return (members, dict(thing.params))
|
|
102
|
+
return (thing.value, dict(thing.params))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _from_functional(value: Any) -> Union[Item, "InnerList"]:
|
|
106
|
+
if isinstance(value, tuple):
|
|
107
|
+
inner, params = value
|
|
108
|
+
if isinstance(inner, list):
|
|
109
|
+
il = InnerList()
|
|
110
|
+
for elem in inner:
|
|
111
|
+
il.data.append(_from_functional(elem))
|
|
112
|
+
il.params = Parameters(params)
|
|
113
|
+
return il
|
|
114
|
+
item = Item(inner)
|
|
115
|
+
item.params = Parameters(params)
|
|
116
|
+
return item
|
|
117
|
+
# bare value (shouldn't typically happen from parse, but ser accepts it)
|
|
118
|
+
return Item(value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class InnerList(UserList): # type: ignore[type-arg]
|
|
122
|
+
def __init__(self, values: Optional[Iterable[AllItemType]] = None) -> None:
|
|
123
|
+
UserList.__init__(self, [_itemise(v) for v in values or []])
|
|
124
|
+
self.params: Parameters = Parameters()
|
|
125
|
+
|
|
126
|
+
def __str__(self) -> str:
|
|
127
|
+
members = [_to_functional(i) for i in self.data]
|
|
128
|
+
return ser_innerlist((members, dict(self.params)))
|
|
129
|
+
|
|
130
|
+
def __setitem__(
|
|
131
|
+
self,
|
|
132
|
+
index: Union[SupportsIndex, slice],
|
|
133
|
+
value: Union[AllItemType, Iterable[AllItemType]],
|
|
134
|
+
) -> None:
|
|
135
|
+
if isinstance(index, slice):
|
|
136
|
+
self.data[index] = [_itemise(v) for v in value] # type: ignore[union-attr]
|
|
137
|
+
else:
|
|
138
|
+
self.data[index] = _itemise(value) # type: ignore[arg-type]
|
|
139
|
+
|
|
140
|
+
def append(self, item: AllItemType) -> None:
|
|
141
|
+
self.data.append(_itemise(item))
|
|
142
|
+
|
|
143
|
+
def insert(self, i: int, item: AllItemType) -> None:
|
|
144
|
+
self.data.insert(i, _itemise(item))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Dictionary(UserDict): # type: ignore[type-arg]
|
|
148
|
+
def __init__(
|
|
149
|
+
self, data: Optional[Mapping[str, AllItemType]] = None
|
|
150
|
+
) -> None:
|
|
151
|
+
UserDict.__init__(self)
|
|
152
|
+
if data is not None:
|
|
153
|
+
for key, val in data.items():
|
|
154
|
+
self[key] = val
|
|
155
|
+
|
|
156
|
+
def parse(self, data: bytes) -> None:
|
|
157
|
+
parsed: _Dict[str, Any] = _parse(data, tltype="dictionary") # type: ignore[assignment]
|
|
158
|
+
self.data.clear()
|
|
159
|
+
for key, val in parsed.items():
|
|
160
|
+
self.data[key] = _from_functional(val)
|
|
161
|
+
|
|
162
|
+
def __setitem__(self, key: str, value: AllItemType) -> None:
|
|
163
|
+
self.data[key] = _itemise(value)
|
|
164
|
+
|
|
165
|
+
def __str__(self) -> str:
|
|
166
|
+
if not self.data:
|
|
167
|
+
raise ValueError("No contents; field should not be emitted")
|
|
168
|
+
return _ser({k: _to_functional(v) for k, v in self.data.items()})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class List(UserList): # type: ignore[type-arg]
|
|
172
|
+
def __init__(self, values: Optional[Iterable[AllItemType]] = None) -> None:
|
|
173
|
+
UserList.__init__(self, [_itemise(v) for v in values or []])
|
|
174
|
+
|
|
175
|
+
def parse(self, data: bytes) -> None:
|
|
176
|
+
parsed: _List[Any] = _parse(data, tltype="list") # type: ignore[assignment]
|
|
177
|
+
self.data.clear()
|
|
178
|
+
for elem in parsed:
|
|
179
|
+
self.data.append(_from_functional(elem))
|
|
180
|
+
|
|
181
|
+
def __str__(self) -> str:
|
|
182
|
+
if not self.data:
|
|
183
|
+
raise ValueError("No contents; field should not be emitted")
|
|
184
|
+
return _ser([_to_functional(v) for v in self.data])
|
|
185
|
+
|
|
186
|
+
def __setitem__(
|
|
187
|
+
self,
|
|
188
|
+
index: Union[SupportsIndex, slice],
|
|
189
|
+
value: Union[AllItemType, Iterable[AllItemType]],
|
|
190
|
+
) -> None:
|
|
191
|
+
if isinstance(index, slice):
|
|
192
|
+
self.data[index] = [_itemise(v) for v in value] # type: ignore[union-attr]
|
|
193
|
+
else:
|
|
194
|
+
self.data[index] = _itemise(value) # type: ignore[arg-type]
|
|
195
|
+
|
|
196
|
+
def append(self, item: AllItemType) -> None:
|
|
197
|
+
self.data.append(_itemise(item))
|
|
198
|
+
|
|
199
|
+
def insert(self, i: int, item: AllItemType) -> None:
|
|
200
|
+
self.data.insert(i, _itemise(item))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
structures = {"dictionary": Dictionary, "list": List, "item": Item}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
3
|
from .errors import StructuredFieldError
|
|
4
4
|
from .integer import parse_integer
|
|
5
|
+
from .state import ParserState
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def parse_date(state: ParserState) -> datetime:
|
|
@@ -11,7 +12,12 @@ def parse_date(state: ParserState) -> datetime:
|
|
|
11
12
|
raise StructuredFieldError(
|
|
12
13
|
"Non-integer Date", position=state.cursor, offending_char=None
|
|
13
14
|
)
|
|
14
|
-
|
|
15
|
+
try:
|
|
16
|
+
return datetime.fromtimestamp(value, tz=timezone.utc)
|
|
17
|
+
except (ValueError, OSError) as why:
|
|
18
|
+
raise StructuredFieldError(
|
|
19
|
+
"Date value out of range", position=state.cursor, offending_char=None
|
|
20
|
+
) from why
|
|
15
21
|
|
|
16
22
|
|
|
17
23
|
def ser_date(inval: datetime) -> str:
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Optional, cast
|
|
2
2
|
|
|
3
3
|
from .errors import StructuredFieldError
|
|
4
4
|
from .innerlist import parse_item_or_inner_list, ser_item_or_inner_list
|
|
5
5
|
from .parameters import parse_params, ser_params
|
|
6
6
|
from .state import ParserState
|
|
7
7
|
from .types import DictionaryType, ItemType, OnDuplicateKeyType
|
|
8
|
-
from .util import discard_http_ows,
|
|
9
|
-
|
|
8
|
+
from .util import discard_http_ows, parse_key, ser_key
|
|
10
9
|
|
|
11
10
|
EQUALS = ord(b"=")
|
|
12
11
|
COMMA = ord(b",")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import List,
|
|
1
|
+
from typing import List, Optional, Union, cast
|
|
2
2
|
|
|
3
3
|
from .errors import StructuredFieldError
|
|
4
4
|
from .item import parse_item, ser_item
|
|
@@ -6,14 +6,13 @@ from .parameters import parse_params, ser_params
|
|
|
6
6
|
from .state import ParserState
|
|
7
7
|
from .types import (
|
|
8
8
|
InnerListType,
|
|
9
|
-
ItemType,
|
|
10
|
-
ParamsType,
|
|
11
9
|
ItemOrInnerListType,
|
|
10
|
+
ItemType,
|
|
12
11
|
OnDuplicateKeyType,
|
|
12
|
+
ParamsType,
|
|
13
13
|
)
|
|
14
14
|
from .util import discard_ows
|
|
15
15
|
|
|
16
|
-
|
|
17
16
|
PAREN_OPEN = ord(b"(")
|
|
18
17
|
PAREN_CLOSE = ord(b")")
|
|
19
18
|
INNERLIST_DELIMS = set(b" )")
|
|
@@ -61,7 +61,9 @@ def parse_number(state: ParserState) -> Union[int, Decimal]:
|
|
|
61
61
|
elif _type is INTEGER and char == PERIOD:
|
|
62
62
|
# spec says 12, but character is appended afterwards
|
|
63
63
|
if num_length > 13:
|
|
64
|
-
raise
|
|
64
|
+
raise StructuredFieldError(
|
|
65
|
+
"Decimal too long.", position=state.cursor, offending_char=None
|
|
66
|
+
)
|
|
65
67
|
state.cursor += 1
|
|
66
68
|
num_length = state.cursor - num_start - 1
|
|
67
69
|
if char in DIGITS:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Optional, Tuple
|
|
2
2
|
|
|
3
3
|
from .bare_item import parse_bare_item, ser_bare_item
|
|
4
4
|
from .parameters import parse_params, ser_params
|
|
5
5
|
from .state import ParserState
|
|
6
|
-
from .types import BareItemType, ItemType,
|
|
6
|
+
from .types import BareItemType, ItemType, OnDuplicateKeyType, ParamsType
|
|
7
7
|
|
|
8
8
|
PAREN_OPEN = ord(b"(")
|
|
9
9
|
SEMICOLON = ord(b";")
|
|
@@ -3,7 +3,7 @@ from typing import Optional
|
|
|
3
3
|
from .bare_item import parse_bare_item, ser_bare_item
|
|
4
4
|
from .errors import StructuredFieldError
|
|
5
5
|
from .state import ParserState
|
|
6
|
-
from .types import BareItemType,
|
|
6
|
+
from .types import BareItemType, OnDuplicateKeyType, ParamsType
|
|
7
7
|
from .util import discard_ows, parse_key, ser_key
|
|
8
8
|
|
|
9
9
|
PAREN_OPEN = ord(b"(")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from string import ascii_letters, digits
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
from http_sf.state import ParserState
|
|
4
|
+
from http_sf.types import Token
|
|
4
5
|
|
|
5
6
|
TOKEN_START_CHARS = set((ascii_letters + "*").encode("ascii"))
|
|
6
7
|
TOKEN_CHARS = set((ascii_letters + digits + ":/!#$%&'*+-.^_`|~").encode("ascii"))
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from collections import UserString
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
from decimal import Decimal
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Any, Callable, Dict, List, Tuple, Union
|
|
5
|
+
|
|
5
6
|
from typing_extensions import TypeAlias
|
|
6
7
|
|
|
7
8
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import base64
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
from decimal import Decimal
|
|
4
2
|
import json
|
|
5
3
|
import re
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from decimal import Decimal
|
|
6
6
|
from string import ascii_lowercase, ascii_uppercase, digits
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from .types import StructuredType, Token, DisplayString, JsonDict
|
|
10
|
-
from .state import ParserState
|
|
11
9
|
from .errors import StructuredFieldError
|
|
10
|
+
from .state import ParserState
|
|
11
|
+
from .types import DisplayString, JsonDict, StructuredType, Token
|
|
12
12
|
|
|
13
13
|
SPACE = ord(b" ")
|
|
14
14
|
HTTP_OWS = set(b" \t")
|
|
@@ -115,7 +115,10 @@ def json_load(inobj: JsonDict) -> Any:
|
|
|
115
115
|
if objtype == "binary":
|
|
116
116
|
return base64.b32decode(inobj["value"])
|
|
117
117
|
if objtype == "date":
|
|
118
|
-
|
|
118
|
+
try:
|
|
119
|
+
return datetime.fromtimestamp(inobj["value"], tz=timezone.utc)
|
|
120
|
+
except (ValueError, OSError):
|
|
121
|
+
return int(inobj["value"])
|
|
119
122
|
if objtype == "displaystring":
|
|
120
123
|
return DisplayString(inobj["value"])
|
|
121
124
|
raise ValueError(f"Unknown object type - {inobj}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: http-sf
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Parse and serialise HTTP Structured Fields
|
|
5
5
|
Author-email: Mark Nottingham <mnot@mnot.net>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,6 +13,7 @@ Requires-Dist: typing_extensions
|
|
|
13
13
|
Provides-Extra: dev
|
|
14
14
|
Requires-Dist: mypy; extra == "dev"
|
|
15
15
|
Requires-Dist: black; extra == "dev"
|
|
16
|
+
Requires-Dist: isort; extra == "dev"
|
|
16
17
|
Requires-Dist: pylint; extra == "dev"
|
|
17
18
|
Requires-Dist: pytest; extra == "dev"
|
|
18
19
|
Requires-Dist: pytest-md; extra == "dev"
|
|
@@ -138,6 +139,21 @@ When using `ser`, if an Item or Inner List doesn't have parameters, they can be
|
|
|
138
139
|
Note that `ser` produces a string, not a bytes-like object.
|
|
139
140
|
|
|
140
141
|
|
|
142
|
+
## Migrating from `http_sfv`
|
|
143
|
+
|
|
144
|
+
If you have code that uses the deprecated [http_sfv](https://pypi.org/project/http-sfv/) package, a drop-in compatibility layer is available in `http_sf.compat`. It provides the same object-oriented `Dictionary`, `List`, `Item`, `InnerList`, `Token`, and `DisplayString` classes built on top of this library's functional API, so existing code typically only needs an import change:
|
|
145
|
+
|
|
146
|
+
~~~ python
|
|
147
|
+
# Before
|
|
148
|
+
from http_sfv import Dictionary, List, Item, Token
|
|
149
|
+
|
|
150
|
+
# After
|
|
151
|
+
from http_sf.compat import Dictionary, List, Item, Token
|
|
152
|
+
~~~
|
|
153
|
+
|
|
154
|
+
The compat layer exposes the same `.parse(bytes)` / `str(...)` workflow, `.value` / `.params` attributes, structural list/dict behaviour, and Item equality semantics (compared by value, ignoring parameters) as `http_sfv`. New code should prefer the functional `parse` / `ser` API documented above.
|
|
155
|
+
|
|
156
|
+
|
|
141
157
|
## Command Line Use
|
|
142
158
|
|
|
143
159
|
You can validate and examine the data model of a field value by calling the library on the command line, using `-d`, `-l` and `-i` to denote dictionaries, lists or items respectively; e.g.,
|
|
@@ -6,6 +6,7 @@ http_sf/__main__.py
|
|
|
6
6
|
http_sf/bare_item.py
|
|
7
7
|
http_sf/boolean.py
|
|
8
8
|
http_sf/byteseq.py
|
|
9
|
+
http_sf/compat.py
|
|
9
10
|
http_sf/date.py
|
|
10
11
|
http_sf/decimal.py
|
|
11
12
|
http_sf/dictionary.py
|
|
@@ -30,5 +31,8 @@ http_sf.egg-info/requires.txt
|
|
|
30
31
|
http_sf.egg-info/top_level.txt
|
|
31
32
|
test/test.py
|
|
32
33
|
test/test_callback.py
|
|
34
|
+
test/test_compat.py
|
|
35
|
+
test/test_compat_full.py
|
|
36
|
+
test/test_error_contract.py
|
|
33
37
|
test/test_perf.py
|
|
34
38
|
test/test_position.py
|
|
@@ -19,7 +19,7 @@ dependencies = [
|
|
|
19
19
|
]
|
|
20
20
|
|
|
21
21
|
[project.optional-dependencies]
|
|
22
|
-
dev = ["mypy", "black", "pylint", "pytest", "pytest-md", "validate-pyproject", "build"]
|
|
22
|
+
dev = ["mypy", "black", "isort", "pylint", "pytest", "pytest-md", "validate-pyproject", "build"]
|
|
23
23
|
|
|
24
24
|
[project.urls]
|
|
25
25
|
homepage = "https://github.com/mnot/http-sf"
|
|
@@ -40,6 +40,7 @@ version = {attr = "http_sf.__version__"}
|
|
|
40
40
|
[tool.setuptools.packages.find]
|
|
41
41
|
where = ["."]
|
|
42
42
|
include = ["http_sf", "http_sf.*"]
|
|
43
|
+
exclude = ["build*", "dist*"]
|
|
43
44
|
namespaces = false
|
|
44
45
|
|
|
45
46
|
[tool.setuptools]
|
|
@@ -48,35 +49,3 @@ include-package-data = true
|
|
|
48
49
|
[tool.setuptools.package-data]
|
|
49
50
|
http_sf = ["py.typed"]
|
|
50
51
|
|
|
51
|
-
[tool.mypy]
|
|
52
|
-
strict = true
|
|
53
|
-
show_error_codes = true
|
|
54
|
-
|
|
55
|
-
[tool.pylint.basic]
|
|
56
|
-
function-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
57
|
-
variable-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
58
|
-
attr-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
59
|
-
argument-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
60
|
-
class-attribute-rgx = "([A-Za-z_][A-Za-z0-9_]{1,30}|(__.*__))$"
|
|
61
|
-
method-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
62
|
-
class-rgx = "[a-zA-Z0-9_]+$"
|
|
63
|
-
|
|
64
|
-
[tool.pylint.messages_control]
|
|
65
|
-
disable = "C0114,C0115,C0116,W0613"
|
|
66
|
-
|
|
67
|
-
[tool.pylint.reports]
|
|
68
|
-
reports = false
|
|
69
|
-
|
|
70
|
-
[tool.pylint.variables]
|
|
71
|
-
dummy-variables-rgx = "_"
|
|
72
|
-
|
|
73
|
-
[tool.pylint.design]
|
|
74
|
-
max-args=10
|
|
75
|
-
max-locals=35
|
|
76
|
-
max-branches=25
|
|
77
|
-
max-statements=100
|
|
78
|
-
max-attributes=40
|
|
79
|
-
min-public-methods=1
|
|
80
|
-
|
|
81
|
-
[tool.pylint.similarities]
|
|
82
|
-
min-similarity-lines = 10
|
|
@@ -54,7 +54,11 @@ def run_suite(suite_name: str, suite: List) -> None:
|
|
|
54
54
|
if ser_success:
|
|
55
55
|
suite_passed += 1
|
|
56
56
|
else:
|
|
57
|
-
|
|
57
|
+
if test.get("can_fail", False):
|
|
58
|
+
print(f"{WARN} * {test['name']}: SERIALISE FAIL (non-critical){ENDC}")
|
|
59
|
+
suite_passed += 1
|
|
60
|
+
else:
|
|
61
|
+
print(f"{FAIL} * {test['name']}: SERIALISE FAIL{ENDC}")
|
|
58
62
|
print(f" - expected: {ser_expected}")
|
|
59
63
|
print(f" - got: ['{serialised}']")
|
|
60
64
|
if ser_fail_reason:
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Tests for the http_sfv-compatible API exposed by http_sf.compat."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from http_sf.compat import (
|
|
6
|
+
Dictionary,
|
|
7
|
+
DisplayString,
|
|
8
|
+
InnerList,
|
|
9
|
+
Item,
|
|
10
|
+
List,
|
|
11
|
+
Token,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestItem(unittest.TestCase):
|
|
16
|
+
def test_parse_value_and_params(self):
|
|
17
|
+
item = Item()
|
|
18
|
+
item.parse(b"foo;a=1")
|
|
19
|
+
self.assertEqual(item.value, Token("foo"))
|
|
20
|
+
self.assertEqual(dict(item.params), {"a": 1})
|
|
21
|
+
|
|
22
|
+
def test_serialise_roundtrip(self):
|
|
23
|
+
item = Item()
|
|
24
|
+
item.parse(b"foo;a=1")
|
|
25
|
+
self.assertEqual(str(item), "foo;a=1")
|
|
26
|
+
|
|
27
|
+
def test_eq_ignores_params(self):
|
|
28
|
+
item_a = Item("foo")
|
|
29
|
+
item_a.params["x"] = 1
|
|
30
|
+
item_b = Item("foo")
|
|
31
|
+
self.assertEqual(item_a, item_b)
|
|
32
|
+
|
|
33
|
+
def test_eq_to_bare_value(self):
|
|
34
|
+
item = Item(Token("foo"))
|
|
35
|
+
item.params["x"] = 1
|
|
36
|
+
self.assertEqual(item, Token("foo"))
|
|
37
|
+
|
|
38
|
+
def test_parse_error_is_valueerror(self):
|
|
39
|
+
with self.assertRaises(ValueError):
|
|
40
|
+
Item().parse(b"!!!")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestList(unittest.TestCase):
|
|
44
|
+
def test_parse(self):
|
|
45
|
+
my_list = List()
|
|
46
|
+
my_list.parse(b"foo; a=1, bar; b=2")
|
|
47
|
+
self.assertEqual(len(my_list), 2)
|
|
48
|
+
self.assertEqual(my_list[0].value, Token("foo"))
|
|
49
|
+
self.assertEqual(my_list[0].params["a"], 1)
|
|
50
|
+
|
|
51
|
+
def test_contains_uses_item_equality(self):
|
|
52
|
+
my_list = List()
|
|
53
|
+
my_list.parse(b"foo;a=1, bar;b=2")
|
|
54
|
+
self.assertIn(Token("foo"), my_list)
|
|
55
|
+
self.assertEqual(my_list.count(Token("foo")), 1)
|
|
56
|
+
|
|
57
|
+
def test_append_bare_value(self):
|
|
58
|
+
my_list = List()
|
|
59
|
+
my_list.parse(b"foo;a=1")
|
|
60
|
+
my_list.append(Token("bar"))
|
|
61
|
+
self.assertEqual(my_list[-1].value, Token("bar"))
|
|
62
|
+
|
|
63
|
+
def test_append_inner_list(self):
|
|
64
|
+
my_list = List()
|
|
65
|
+
my_list.append(["x", "y"])
|
|
66
|
+
self.assertIsInstance(my_list[-1], InnerList)
|
|
67
|
+
self.assertEqual(my_list[-1][0].value, "x")
|
|
68
|
+
|
|
69
|
+
def test_serialise(self):
|
|
70
|
+
my_list = List()
|
|
71
|
+
my_list.parse(b"a, b;q=5, c")
|
|
72
|
+
self.assertEqual(str(my_list), "a, b;q=5, c")
|
|
73
|
+
|
|
74
|
+
def test_inner_list_with_params(self):
|
|
75
|
+
my_list = List()
|
|
76
|
+
my_list.append(["x", "y"])
|
|
77
|
+
my_list[-1][-1].params["a"] = True
|
|
78
|
+
self.assertEqual(str(my_list), '("x" "y";a)')
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestDictionary(unittest.TestCase):
|
|
82
|
+
def test_init_with_dict(self):
|
|
83
|
+
my_dict = Dictionary({"a": "1", "b": 2, "c": Token("foo")})
|
|
84
|
+
self.assertEqual(my_dict["a"].value, "1")
|
|
85
|
+
self.assertEqual(my_dict["b"].value, 2)
|
|
86
|
+
self.assertEqual(my_dict["c"].value, Token("foo"))
|
|
87
|
+
|
|
88
|
+
def test_serialise_after_param_set(self):
|
|
89
|
+
my_dict = Dictionary({"a": "1", "b": 2, "c": Token("foo")})
|
|
90
|
+
my_dict["b"].params["b1"] = 2.0
|
|
91
|
+
self.assertEqual(str(my_dict), 'a="1", b=2;b1=2.0, c=foo')
|
|
92
|
+
|
|
93
|
+
def test_parse_bare_member_is_true(self):
|
|
94
|
+
my_dict = Dictionary()
|
|
95
|
+
my_dict.parse(b"a=1, b, c;x=2")
|
|
96
|
+
self.assertEqual(my_dict["b"].value, True)
|
|
97
|
+
self.assertEqual(str(my_dict), "a=1, b, c;x=2")
|
|
98
|
+
|
|
99
|
+
def test_empty_serialisation_raises(self):
|
|
100
|
+
with self.assertRaises(ValueError):
|
|
101
|
+
str(Dictionary())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestInnerList(unittest.TestCase):
|
|
105
|
+
def test_parse_via_list(self):
|
|
106
|
+
my_list = List()
|
|
107
|
+
my_list.parse(b"(1 2);a, 3")
|
|
108
|
+
self.assertIsInstance(my_list[0], InnerList)
|
|
109
|
+
self.assertEqual(my_list[0][0].value, 1)
|
|
110
|
+
self.assertEqual(my_list[0].params["a"], True)
|
|
111
|
+
|
|
112
|
+
def test_inner_list_str(self):
|
|
113
|
+
inner = InnerList(["x", Token("y")])
|
|
114
|
+
inner.params["z"] = True
|
|
115
|
+
self.assertEqual(str(inner), '("x" y);z')
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestTokenAndDisplayString(unittest.TestCase):
|
|
119
|
+
def test_token_constructor(self):
|
|
120
|
+
token = Token("abc")
|
|
121
|
+
self.assertEqual(str(token), "abc")
|
|
122
|
+
|
|
123
|
+
def test_display_string_roundtrip(self):
|
|
124
|
+
item = Item(DisplayString("hello"))
|
|
125
|
+
self.assertEqual(str(item), '%"hello"')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
unittest.main()
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Run the full JSON test suite through the http_sf.compat object API.
|
|
2
|
+
|
|
3
|
+
Mirrors test/test.py but uses Dictionary/List/Item.parse() and str(...) from
|
|
4
|
+
http_sf.compat, converting between functional and object forms to compare
|
|
5
|
+
against the existing JSON test expectations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, List, Tuple
|
|
11
|
+
|
|
12
|
+
from http_sf import from_json
|
|
13
|
+
from http_sf.compat import (
|
|
14
|
+
Dictionary,
|
|
15
|
+
Item,
|
|
16
|
+
List as CompatList,
|
|
17
|
+
Parameters,
|
|
18
|
+
_from_functional,
|
|
19
|
+
_to_functional,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
FAIL = "\033[91m"
|
|
23
|
+
WARN = "\033[93m"
|
|
24
|
+
ENDC = "\033[0m"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_tests(files: Any = None) -> List[Tuple[Path, List[Any]]]:
|
|
28
|
+
suites = []
|
|
29
|
+
if not files:
|
|
30
|
+
files = Path("test/tests").glob("*.json")
|
|
31
|
+
for filename in files:
|
|
32
|
+
with open(filename, encoding="utf-8") as fh:
|
|
33
|
+
suite = from_json(fh.read())
|
|
34
|
+
suites.append((filename, suite))
|
|
35
|
+
return suites
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _compat_parse(field_value: bytes, header_type: str) -> Any:
|
|
39
|
+
if header_type == "dictionary":
|
|
40
|
+
obj: Any = Dictionary()
|
|
41
|
+
elif header_type == "list":
|
|
42
|
+
obj = CompatList()
|
|
43
|
+
elif header_type == "item":
|
|
44
|
+
obj = Item()
|
|
45
|
+
else:
|
|
46
|
+
raise ValueError(f"Unknown header_type: {header_type}")
|
|
47
|
+
obj.parse(field_value)
|
|
48
|
+
return obj
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _compat_to_functional(obj: Any, header_type: str) -> Any:
|
|
52
|
+
if header_type == "dictionary":
|
|
53
|
+
return {k: _to_functional(v) for k, v in obj.data.items()}
|
|
54
|
+
if header_type == "list":
|
|
55
|
+
return [_to_functional(v) for v in obj.data]
|
|
56
|
+
# item
|
|
57
|
+
return (obj.value, dict(obj.params))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_compat(expected: Any, header_type: str) -> Any:
|
|
61
|
+
if header_type == "dictionary":
|
|
62
|
+
out = Dictionary()
|
|
63
|
+
for key, val in expected.items():
|
|
64
|
+
out.data[key] = _from_functional(val)
|
|
65
|
+
return out
|
|
66
|
+
if header_type == "list":
|
|
67
|
+
out = CompatList()
|
|
68
|
+
for val in expected:
|
|
69
|
+
out.data.append(_from_functional(val))
|
|
70
|
+
return out
|
|
71
|
+
# item
|
|
72
|
+
value, params = expected
|
|
73
|
+
item = Item(value)
|
|
74
|
+
item.params = Parameters(params)
|
|
75
|
+
return item
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_parse(test: dict) -> Tuple[bool, Any, Any]:
|
|
79
|
+
parsed: Any = None
|
|
80
|
+
parse_success = False
|
|
81
|
+
parse_fail_reason = None
|
|
82
|
+
try:
|
|
83
|
+
field_value = b", ".join(v.encode("utf-8") for v in test["raw"])
|
|
84
|
+
obj = _compat_parse(field_value, test["header_type"])
|
|
85
|
+
parsed = _compat_to_functional(obj, test["header_type"])
|
|
86
|
+
parse_success = True
|
|
87
|
+
except ValueError as why:
|
|
88
|
+
parse_fail_reason = why.args
|
|
89
|
+
parsed = {}
|
|
90
|
+
except Exception:
|
|
91
|
+
sys.stderr.write(f"*** TEST ERROR in {test['name']}\n")
|
|
92
|
+
raise
|
|
93
|
+
if test.get("must_fail", False):
|
|
94
|
+
test_success = not parse_success
|
|
95
|
+
else:
|
|
96
|
+
test_success = test["expected"] == parsed
|
|
97
|
+
return test_success, parsed, parse_fail_reason
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_serialise(test: dict) -> Tuple[bool, Any, Any, Any]:
|
|
101
|
+
expected = test.get("canonical", test["raw"])
|
|
102
|
+
output: Any = None
|
|
103
|
+
ser_fail_reason = None
|
|
104
|
+
try:
|
|
105
|
+
obj = _build_compat(test["expected"], test["header_type"])
|
|
106
|
+
output = str(obj)
|
|
107
|
+
except ValueError as why:
|
|
108
|
+
ser_fail_reason = why.args[0]
|
|
109
|
+
except Exception:
|
|
110
|
+
sys.stderr.write(f"*** TEST ERROR in {test['name']}\n")
|
|
111
|
+
raise
|
|
112
|
+
if output is None:
|
|
113
|
+
test_success = expected == []
|
|
114
|
+
else:
|
|
115
|
+
test_success = expected == [output]
|
|
116
|
+
return test_success, output, expected, ser_fail_reason
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run_suite(suite_name: str, suite: List[dict]) -> Tuple[int, int]:
|
|
120
|
+
print(f"## {suite_name}")
|
|
121
|
+
suite_tests = 0
|
|
122
|
+
suite_passed = 0
|
|
123
|
+
for test in suite:
|
|
124
|
+
suite_tests += 1
|
|
125
|
+
parse_ok, parsed, parse_reason = test_parse(test)
|
|
126
|
+
if parse_ok:
|
|
127
|
+
suite_passed += 1
|
|
128
|
+
else:
|
|
129
|
+
if test.get("can_fail", False):
|
|
130
|
+
print(
|
|
131
|
+
f"{WARN} * {test['name']}: PARSE FAIL (non-critical){ENDC}"
|
|
132
|
+
)
|
|
133
|
+
suite_passed += 1
|
|
134
|
+
else:
|
|
135
|
+
print(f"{FAIL} * {test['name']}: PARSE FAIL{ENDC}")
|
|
136
|
+
print(f" - raw: {test['raw']}")
|
|
137
|
+
print(f" - expected: {test.get('expected', 'FAIL')}")
|
|
138
|
+
print(f" - got: {parsed}")
|
|
139
|
+
if parse_reason:
|
|
140
|
+
print(f" - reason: {parse_reason}")
|
|
141
|
+
|
|
142
|
+
if not test.get("must_fail", False):
|
|
143
|
+
suite_tests += 1
|
|
144
|
+
ser_ok, serialised, ser_expected, ser_reason = test_serialise(test)
|
|
145
|
+
if ser_ok:
|
|
146
|
+
suite_passed += 1
|
|
147
|
+
else:
|
|
148
|
+
if test.get("can_fail", False):
|
|
149
|
+
print(
|
|
150
|
+
f"{WARN} * {test['name']}: SERIALISE FAIL (non-critical){ENDC}"
|
|
151
|
+
)
|
|
152
|
+
suite_passed += 1
|
|
153
|
+
else:
|
|
154
|
+
print(f"{FAIL} * {test['name']}: SERIALISE FAIL{ENDC}")
|
|
155
|
+
print(f" - expected: {ser_expected}")
|
|
156
|
+
print(f" - got: ['{serialised}']")
|
|
157
|
+
if ser_reason:
|
|
158
|
+
print(f" - reason: {ser_reason}")
|
|
159
|
+
|
|
160
|
+
print(f"-> {suite_passed} of {suite_tests} passed.")
|
|
161
|
+
print()
|
|
162
|
+
return suite_tests, suite_passed
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
import argparse
|
|
167
|
+
|
|
168
|
+
argparser = argparse.ArgumentParser(
|
|
169
|
+
description="Run tests through http_sf.compat."
|
|
170
|
+
)
|
|
171
|
+
argparser.add_argument(
|
|
172
|
+
"files",
|
|
173
|
+
metavar="filename",
|
|
174
|
+
type=str,
|
|
175
|
+
nargs="*",
|
|
176
|
+
help="a JSON file containing tests",
|
|
177
|
+
)
|
|
178
|
+
args = argparser.parse_args()
|
|
179
|
+
total_tests = 0
|
|
180
|
+
total_passed = 0
|
|
181
|
+
for filename, suite in load_tests(args.files):
|
|
182
|
+
tests, passed = run_suite(filename, suite)
|
|
183
|
+
total_tests += tests
|
|
184
|
+
total_passed += passed
|
|
185
|
+
print(f"TOTAL: {total_passed} of {total_tests} passed.")
|
|
186
|
+
if total_passed < total_tests:
|
|
187
|
+
sys.exit(1)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests that all parse errors raise StructuredFieldError, never bare ValueError
|
|
3
|
+
or OSError, regardless of input. Covers cases where the SF integer range
|
|
4
|
+
permits values that downstream Python APIs reject.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
from http_sf import parse, StructuredFieldError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_date_out_of_range():
|
|
11
|
+
print("Testing out-of-range Date values raise StructuredFieldError...")
|
|
12
|
+
|
|
13
|
+
# Max SF integer — year ~31 million, far outside datetime's year 1-9999 range
|
|
14
|
+
try:
|
|
15
|
+
parse(b"@999999999999999", tltype="item")
|
|
16
|
+
print(" Test 1: FAIL (no exception raised)")
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
except StructuredFieldError:
|
|
19
|
+
print(" Test 1: OK (large positive timestamp)")
|
|
20
|
+
except (ValueError, OSError) as e:
|
|
21
|
+
print(f" Test 1: FAIL (wrong exception type {type(e).__name__}: {e})")
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
# Min SF integer — far before year 1
|
|
25
|
+
try:
|
|
26
|
+
parse(b"@-999999999999999", tltype="item")
|
|
27
|
+
print(" Test 2: FAIL (no exception raised)")
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
except StructuredFieldError:
|
|
30
|
+
print(" Test 2: OK (large negative timestamp)")
|
|
31
|
+
except (ValueError, OSError) as e:
|
|
32
|
+
print(f" Test 2: FAIL (wrong exception type {type(e).__name__}: {e})")
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
print("All Date out-of-range tests passed.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_decimal_too_long():
|
|
39
|
+
print("Testing overlong decimal integer part raises StructuredFieldError...")
|
|
40
|
+
|
|
41
|
+
# 14 digits before decimal point — exceeds the 12-digit SF limit
|
|
42
|
+
try:
|
|
43
|
+
parse(b"12345678901234.1", tltype="item")
|
|
44
|
+
print(" Test 1: FAIL (no exception raised)")
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
except StructuredFieldError:
|
|
47
|
+
print(" Test 1: OK (14-digit integer part)")
|
|
48
|
+
except ValueError as e:
|
|
49
|
+
print(f" Test 1: FAIL (wrong exception type ValueError: {e})")
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
# Exactly at the boundary — 13 digits before decimal (also invalid per spec)
|
|
53
|
+
try:
|
|
54
|
+
parse(b"1234567890123.1", tltype="item")
|
|
55
|
+
print(" Test 2: FAIL (no exception raised)")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
except StructuredFieldError:
|
|
58
|
+
print(" Test 2: OK (13-digit integer part)")
|
|
59
|
+
except ValueError as e:
|
|
60
|
+
print(f" Test 2: FAIL (wrong exception type ValueError: {e})")
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
print("All Decimal too-long tests passed.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
test_date_out_of_range()
|
|
68
|
+
print()
|
|
69
|
+
test_decimal_too_long()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|