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.
Files changed (40) hide show
  1. {http_sf-1.2.0 → http_sf-1.3.0}/PKG-INFO +17 -1
  2. {http_sf-1.2.0 → http_sf-1.3.0}/README.md +15 -0
  3. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/__init__.py +11 -12
  4. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/__main__.py +0 -1
  5. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/bare_item.py +6 -6
  6. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/byteseq.py +2 -1
  7. http_sf-1.3.0/http_sf/compat.py +203 -0
  8. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/date.py +9 -3
  9. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/dictionary.py +2 -3
  10. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/innerlist.py +3 -4
  11. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/integer.py +3 -1
  12. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/item.py +2 -2
  13. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/list.py +0 -1
  14. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/parameters.py +1 -1
  15. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/token.py +2 -1
  16. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/types.py +2 -1
  17. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/util.py +8 -5
  18. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/PKG-INFO +17 -1
  19. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/SOURCES.txt +4 -0
  20. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/requires.txt +1 -0
  21. {http_sf-1.2.0 → http_sf-1.3.0}/pyproject.toml +2 -33
  22. {http_sf-1.2.0 → http_sf-1.3.0}/test/test.py +5 -1
  23. http_sf-1.3.0/test/test_compat.py +129 -0
  24. http_sf-1.3.0/test/test_compat_full.py +187 -0
  25. http_sf-1.3.0/test/test_error_contract.py +69 -0
  26. {http_sf-1.2.0 → http_sf-1.3.0}/MANIFEST.in +0 -0
  27. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/boolean.py +1 -1
  28. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/decimal.py +0 -0
  29. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/display_string.py +1 -1
  30. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/errors.py +0 -0
  31. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/py.typed +0 -0
  32. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/retrofit.py +0 -0
  33. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/state.py +0 -0
  34. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf/string.py +1 -1
  35. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/dependency_links.txt +0 -0
  36. {http_sf-1.2.0 → http_sf-1.3.0}/http_sf.egg-info/top_level.txt +0 -0
  37. {http_sf-1.2.0 → http_sf-1.3.0}/setup.cfg +0 -0
  38. {http_sf-1.2.0 → http_sf-1.3.0}/test/test_callback.py +0 -0
  39. {http_sf-1.2.0 → http_sf-1.3.0}/test/test_perf.py +0 -0
  40. {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.2.0
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.2.0"
28
+ __version__ = "1.3.0"
29
29
 
30
- from typing import Tuple, List, Dict, Optional, Callable
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
- ListType,
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, to_json, from_json
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",
@@ -5,7 +5,6 @@ import sys
5
5
 
6
6
  from . import parse, to_json
7
7
 
8
-
9
8
  parser = argparse.ArgumentParser(
10
9
  description="Validate and show data model of a Structured Field Value."
11
10
  )
@@ -1,16 +1,16 @@
1
1
  from datetime import datetime
2
2
  from decimal import Decimal
3
- from typing import Dict, Callable, Any, Type
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, BYTE_DELIMIT
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, NUMBER_START_CHARS
11
- from http_sf.string import parse_string, ser_string, DQUOTE
12
- from http_sf.token import parse_token, ser_token, TOKEN_START_CHARS
13
- from http_sf.types import BareItemType, Token, DisplayString
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
- from .state import ParserState
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
- from .state import ParserState
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
- return datetime.fromtimestamp(value)
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 cast, Optional
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, ser_key, parse_key
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, cast, Union, Optional
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 ValueError("Decimal too long.")
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 Tuple, Optional
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, ParamsType, OnDuplicateKeyType
6
+ from .types import BareItemType, ItemType, OnDuplicateKeyType, ParamsType
7
7
 
8
8
  PAREN_OPEN = ord(b"(")
9
9
  SEMICOLON = ord(b";")
@@ -6,7 +6,6 @@ from .state import ParserState
6
6
  from .types import ListType, OnDuplicateKeyType
7
7
  from .util import discard_http_ows
8
8
 
9
-
10
9
  COMMA = ord(b",")
11
10
 
12
11
 
@@ -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, ParamsType, OnDuplicateKeyType
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
- from http_sf.types import Token
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 Union, Dict, List, Tuple, Callable, Any
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
- return datetime.fromtimestamp(inobj["value"])
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.2.0
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
@@ -3,6 +3,7 @@ typing_extensions
3
3
  [dev]
4
4
  mypy
5
5
  black
6
+ isort
6
7
  pylint
7
8
  pytest
8
9
  pytest-md
@@ -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
- print(f"{FAIL} * {test['name']}: SERIALISE FAIL{ENDC}")
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
@@ -1,5 +1,5 @@
1
- from .state import ParserState
2
1
  from .errors import StructuredFieldError
2
+ from .state import ParserState
3
3
 
4
4
  QUESTION = ord(b"?")
5
5
  ONE = ord(b"1")
File without changes
@@ -1,5 +1,5 @@
1
- from .state import ParserState
2
1
  from .errors import StructuredFieldError
2
+ from .state import ParserState
3
3
  from .types import DisplayString
4
4
 
5
5
  PERCENT = ord("%")
File without changes
File without changes
File without changes
File without changes
@@ -1,5 +1,5 @@
1
- from .state import ParserState
2
1
  from .errors import StructuredFieldError
2
+ from .state import ParserState
3
3
 
4
4
  DQUOTE = ord('"')
5
5
  BACKSLASH = ord("\\")
File without changes
File without changes
File without changes
File without changes