hexconv 0.2.7__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexconv
3
- Version: 0.2.7
3
+ Version: 0.3.1
4
4
  Summary: Pythonic conversion toolkit for bytes, hex, integers, arrays, binary strings, base64, and text.
5
5
  Author: hexconv contributors
6
6
  License-Expression: MIT
@@ -23,7 +23,10 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: build; extra == "dev"
26
+ Requires-Dist: hypothesis; extra == "dev"
27
+ Requires-Dist: mypy; extra == "dev"
26
28
  Requires-Dist: pytest; extra == "dev"
29
+ Requires-Dist: ruff; extra == "dev"
27
30
  Requires-Dist: twine; extra == "dev"
28
31
  Dynamic: license-file
29
32
 
@@ -319,6 +322,23 @@ Available format specs all work in their simplest bare form:
319
322
  - `Bits()`
320
323
  - `Auto()`
321
324
 
325
+ The short names are ergonomic aliases of the explicit long-form specs — pick
326
+ whichever reads better, they resolve to the same format:
327
+
328
+ | Short | Long form |
329
+ | --- | --- |
330
+ | `Hex` | `HexString` |
331
+ | `Binary` | `BinaryString` |
332
+ | `Base64` / `Base32` / `Base85` / `Ascii85` | `Base64String` / `Base32String` / `Base85String` / `Ascii85String` |
333
+ | `Escaped` | `EscapedString` |
334
+ | `Hexdump` | `HexDump` |
335
+ | `Struct` | `StructFormat` |
336
+ | `Bits` | `BitArray` |
337
+ | `DecimalInt` / `DecimalIntArray` | `Int` / `IntArray` |
338
+ | `LargeHexNumber` | `HexInt` |
339
+ | `ASCIIText` / `RawString` | `Text` |
340
+ | `Group` (transform) | `Chunk` |
341
+
322
342
  Add options only when you need control over formatting or parsing:
323
343
 
324
344
  - Display: `Hex(sep=" ", prefix=True, uppercase=True)`, `HexArray(width=2)`.
@@ -363,7 +383,7 @@ hx.from_hex("dead beef").bytes
363
383
  hx.from_text("data").hex
364
384
  # '64617461'
365
385
 
366
- hx.convert("data", from_=hx.Text, to=hx.HexString)
386
+ hx.convert("data", hx.Text, hx.HexString)
367
387
  # '64617461'
368
388
  ```
369
389
 
@@ -290,6 +290,23 @@ Available format specs all work in their simplest bare form:
290
290
  - `Bits()`
291
291
  - `Auto()`
292
292
 
293
+ The short names are ergonomic aliases of the explicit long-form specs — pick
294
+ whichever reads better, they resolve to the same format:
295
+
296
+ | Short | Long form |
297
+ | --- | --- |
298
+ | `Hex` | `HexString` |
299
+ | `Binary` | `BinaryString` |
300
+ | `Base64` / `Base32` / `Base85` / `Ascii85` | `Base64String` / `Base32String` / `Base85String` / `Ascii85String` |
301
+ | `Escaped` | `EscapedString` |
302
+ | `Hexdump` | `HexDump` |
303
+ | `Struct` | `StructFormat` |
304
+ | `Bits` | `BitArray` |
305
+ | `DecimalInt` / `DecimalIntArray` | `Int` / `IntArray` |
306
+ | `LargeHexNumber` | `HexInt` |
307
+ | `ASCIIText` / `RawString` | `Text` |
308
+ | `Group` (transform) | `Chunk` |
309
+
293
310
  Add options only when you need control over formatting or parsing:
294
311
 
295
312
  - Display: `Hex(sep=" ", prefix=True, uppercase=True)`, `HexArray(width=2)`.
@@ -334,7 +351,7 @@ hx.from_hex("dead beef").bytes
334
351
  hx.from_text("data").hex
335
352
  # '64617461'
336
353
 
337
- hx.convert("data", from_=hx.Text, to=hx.HexString)
354
+ hx.convert("data", hx.Text, hx.HexString)
338
355
  # '64617461'
339
356
  ```
340
357
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hexconv"
7
- version = "0.2.7"
7
+ version = "0.3.1"
8
8
  description = "Pythonic conversion toolkit for bytes, hex, integers, arrays, binary strings, base64, and text."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.10"
@@ -31,7 +31,10 @@ classifiers = [
31
31
  [project.optional-dependencies]
32
32
  dev = [
33
33
  "build",
34
+ "hypothesis",
35
+ "mypy",
34
36
  "pytest",
37
+ "ruff",
35
38
  "twine",
36
39
  ]
37
40
 
@@ -44,3 +47,21 @@ hexconv = ["py.typed"]
44
47
  [tool.pytest.ini_options]
45
48
  testpaths = ["tests"]
46
49
  pythonpath = ["src"]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py310"
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "B", "UP"]
57
+
58
+ [tool.ruff.lint.per-file-ignores]
59
+ # Public re-exports are grouped by concept rather than sorted alphabetically.
60
+ "src/hexconv/__init__.py" = ["I001"]
61
+
62
+ [tool.mypy]
63
+ python_version = "3.10"
64
+ no_implicit_optional = true
65
+ warn_redundant_casts = true
66
+ warn_unused_ignores = true
67
+ strict_equality = true
@@ -1,52 +1,60 @@
1
1
  """Conversion toolkit for bytes, hex, integers, arrays, binary, base64, and text."""
2
2
 
3
3
  from ._core import (
4
- ASCIIText,
5
- Auto,
4
+ # Core types and errors
6
5
  AutoResult,
7
- Ascii85,
6
+ Converter,
7
+ HexConvError,
8
+ Pipeline,
9
+ Transform,
10
+ Value,
11
+ # Format specifications (explicit long-form names)
8
12
  Ascii85String,
9
- Base64String,
10
- Base64,
11
- Base32,
13
+ Auto,
12
14
  Base32String,
13
- Base85,
15
+ Base64String,
14
16
  Base85String,
15
17
  BinaryString,
16
- Binary,
17
18
  BitArray,
18
- Bits,
19
19
  Bytes,
20
20
  BytesArray,
21
21
  BytesString,
22
- Chunk,
23
- Converter,
24
- DecimalInt,
25
- DecimalIntArray,
26
- Escaped,
27
22
  EscapedString,
28
- Group,
29
- Hex,
30
23
  HexArray,
31
- HexConvError,
32
24
  HexDump,
33
- Hexdump,
34
25
  HexInt,
35
26
  HexNumbersArray,
36
27
  HexString,
37
28
  Int,
38
29
  IntArray,
30
+ StructFormat,
31
+ Text,
32
+ # Short, ergonomic aliases for the format specifications above
33
+ ASCIIText,
34
+ Ascii85,
35
+ Base32,
36
+ Base64,
37
+ Base85,
38
+ Binary,
39
+ Bits,
40
+ DecimalInt,
41
+ DecimalIntArray,
42
+ Escaped,
43
+ Hex,
44
+ Hexdump,
39
45
  LargeHexNumber,
40
- Pad,
41
- Pipeline,
42
46
  RawString,
43
- Reverse,
44
47
  Struct,
45
- StructFormat,
46
- Text,
47
- Transform,
48
- Value,
48
+ # Pipeline transforms
49
+ Chunk,
50
+ Group,
51
+ Pad,
52
+ Reverse,
53
+ # One-shot conversion and pipeline builders
49
54
  convert,
55
+ infer,
56
+ pipeline,
57
+ # Source helpers
50
58
  from_ascii85,
51
59
  from_auto,
52
60
  from_base32,
@@ -66,57 +74,63 @@ from ._core import (
66
74
  from_int_array,
67
75
  from_struct,
68
76
  from_text,
69
- infer,
70
- pipeline,
71
77
  )
72
78
 
73
79
  __all__ = [
74
- "ASCIIText",
75
- "Auto",
80
+ # Core types and errors
76
81
  "AutoResult",
77
- "Ascii85",
82
+ "Converter",
83
+ "HexConvError",
84
+ "Pipeline",
85
+ "Transform",
86
+ "Value",
87
+ # Format specifications (explicit long-form names)
78
88
  "Ascii85String",
79
- "Base32",
89
+ "Auto",
80
90
  "Base32String",
81
91
  "Base64String",
82
- "Base64",
83
- "Base85",
84
92
  "Base85String",
85
- "Binary",
86
93
  "BinaryString",
87
94
  "BitArray",
88
- "Bits",
89
95
  "Bytes",
90
96
  "BytesArray",
91
97
  "BytesString",
92
- "Chunk",
93
- "Converter",
94
- "DecimalInt",
95
- "DecimalIntArray",
96
- "Escaped",
97
98
  "EscapedString",
98
- "Group",
99
- "Hex",
100
99
  "HexArray",
101
- "HexConvError",
102
100
  "HexDump",
103
- "Hexdump",
104
101
  "HexInt",
105
102
  "HexNumbersArray",
106
103
  "HexString",
107
104
  "Int",
108
105
  "IntArray",
106
+ "StructFormat",
107
+ "Text",
108
+ # Short, ergonomic aliases for the format specifications above
109
+ "ASCIIText",
110
+ "Ascii85",
111
+ "Base32",
112
+ "Base64",
113
+ "Base85",
114
+ "Binary",
115
+ "Bits",
116
+ "DecimalInt",
117
+ "DecimalIntArray",
118
+ "Escaped",
119
+ "Hex",
120
+ "Hexdump",
109
121
  "LargeHexNumber",
110
- "Pad",
111
- "Pipeline",
112
122
  "RawString",
113
- "Reverse",
114
123
  "Struct",
115
- "StructFormat",
116
- "Text",
117
- "Transform",
118
- "Value",
124
+ # Pipeline transforms
125
+ "Chunk",
126
+ "Group",
127
+ "Pad",
128
+ "Reverse",
129
+ # One-shot conversion and pipeline builders
119
130
  "convert",
131
+ "infer",
132
+ "pipeline",
133
+ # Source helpers
120
134
  "from_ascii85",
121
135
  "from_auto",
122
136
  "from_base32",
@@ -136,6 +150,4 @@ __all__ = [
136
150
  "from_int_array",
137
151
  "from_struct",
138
152
  "from_text",
139
- "infer",
140
- "pipeline",
141
153
  ]
@@ -3,12 +3,13 @@ from __future__ import annotations
3
3
  import ast
4
4
  import base64
5
5
  import binascii
6
+ import builtins
6
7
  import codecs
7
8
  import re
8
9
  import struct
9
- from collections.abc import Iterable
10
+ from collections.abc import Callable, Iterable
10
11
  from dataclasses import dataclass, field
11
- from typing import Any, Callable, TypeAlias
12
+ from typing import Any, Literal, TypeAlias, overload
12
13
 
13
14
 
14
15
  class HexConvError(ValueError):
@@ -18,12 +19,12 @@ class HexConvError(ValueError):
18
19
  class _Format:
19
20
  """Base class for public format marker classes."""
20
21
 
21
- def __call__(self, value: Any) -> "Value":
22
+ def __call__(self, value: Any) -> Value:
22
23
  """Parse a value using this configured format."""
23
24
 
24
25
  return _parse(value, self)
25
26
 
26
- def __rshift__(self, other: Any) -> "Pipeline":
27
+ def __rshift__(self, other: Any) -> Pipeline:
27
28
  """Compose this format with a transform, target format, or pipeline."""
28
29
 
29
30
  return Pipeline(source=self) >> other
@@ -87,7 +88,7 @@ class HexArray(_Format):
87
88
  width: int | None = None,
88
89
  prefix: bool = False,
89
90
  uppercase: bool = False,
90
- endian: "Endian" = "big",
91
+ endian: Endian = "big",
91
92
  pad_odd: bool = True,
92
93
  ) -> None:
93
94
  self.width = width
@@ -118,7 +119,7 @@ class HexNumbersArray(_Format):
118
119
  self,
119
120
  *,
120
121
  width: int | None = 1,
121
- endian: "Endian" = "big",
122
+ endian: Endian = "big",
122
123
  signed: bool = False,
123
124
  ) -> None:
124
125
  self.width = width
@@ -140,7 +141,7 @@ class HexInt(_Format):
140
141
  *,
141
142
  min_width: int = 1,
142
143
  width: int | None = None,
143
- endian: "Endian" = "big",
144
+ endian: Endian = "big",
144
145
  signed: bool = False,
145
146
  pad_odd: bool = True,
146
147
  ) -> None:
@@ -174,7 +175,7 @@ class Int(_Format):
174
175
  *,
175
176
  min_width: int = 1,
176
177
  width: int | None = None,
177
- endian: "Endian" = "big",
178
+ endian: Endian = "big",
178
179
  signed: bool = False,
179
180
  ) -> None:
180
181
  self.min_width = min_width
@@ -205,7 +206,7 @@ class IntArray(_Format):
205
206
  self,
206
207
  *,
207
208
  width: int | None = None,
208
- endian: "Endian" = "big",
209
+ endian: Endian = "big",
209
210
  signed: bool = False,
210
211
  ) -> None:
211
212
  self.width = width
@@ -258,7 +259,7 @@ class BinaryString(_Format):
258
259
  sep: str = "",
259
260
  prefix: bool = False,
260
261
  pad_odd: bool = True,
261
- bit_order: "BitOrder" = "msb",
262
+ bit_order: BitOrder = "msb",
262
263
  ) -> None:
263
264
  self.sep = sep
264
265
  self.prefix = prefix
@@ -414,9 +415,9 @@ class BitArray(_Format):
414
415
  def __init__(
415
416
  self,
416
417
  *,
417
- bit_order: "BitOrder" = "msb",
418
+ bit_order: BitOrder = "msb",
418
419
  pad: bool = True,
419
- pad_side: "PadSide" = "left",
420
+ pad_side: PadSide = "left",
420
421
  ) -> None:
421
422
  self.bit_order = bit_order
422
423
  self.pad = pad
@@ -435,9 +436,9 @@ class BitArray(_Format):
435
436
 
436
437
  Format: TypeAlias = type[_Format]
437
438
  FormatLike: TypeAlias = type[_Format] | _Format
438
- Endian: TypeAlias = str
439
- BitOrder: TypeAlias = str
440
- PadSide: TypeAlias = str
439
+ Endian: TypeAlias = Literal["big", "little"]
440
+ BitOrder: TypeAlias = Literal["msb", "lsb"]
441
+ PadSide: TypeAlias = Literal["left", "right"]
441
442
 
442
443
 
443
444
  Hex = HexString
@@ -468,12 +469,12 @@ class Value:
468
469
  source: str | None = None
469
470
  meta: dict[str, Any] = field(default_factory=dict)
470
471
 
471
- def with_data(self, data: bytes, *, source: str | None = None) -> "Value":
472
+ def with_data(self, data: bytes, *, source: str | None = None) -> Value:
472
473
  """Return a copy with different bytes and preserved metadata."""
473
474
 
474
475
  return Value(bytes(data), source=source or self.source, meta=dict(self.meta))
475
476
 
476
- def with_meta(self, **meta: Any) -> "Value":
477
+ def with_meta(self, **meta: Any) -> Value:
477
478
  """Return a copy with additional metadata used by composable transforms."""
478
479
 
479
480
  merged = dict(self.meta)
@@ -484,6 +485,12 @@ class Value:
484
485
  """Emit this value using a public format marker such as HexString."""
485
486
 
486
487
  format_type = _format_type(format_)
488
+ if "chunk_width" in self.meta and format_type not in _CHUNK_AWARE_OUTPUTS:
489
+ raise HexConvError(
490
+ f"chunking has no effect on {format_type.__name__} output; emit to a "
491
+ "chunk-aware format such as HexArray, HexNumbersArray, IntArray, or "
492
+ "DecimalIntArray, or drop the Chunk/Group step"
493
+ )
487
494
  output_options = _format_options(format_, "output")
488
495
  output_options.update(options)
489
496
  try:
@@ -602,18 +609,6 @@ class Value:
602
609
  _validate_endian(endian)
603
610
  return int.from_bytes(self._data, endian, signed=signed)
604
611
 
605
- @property
606
- def int(self) -> int:
607
- """Shortcut for to_int()."""
608
-
609
- return self.to_int()
610
-
611
- @property
612
- def integer(self) -> int:
613
- """Shortcut for to_int()."""
614
-
615
- return self.to_int()
616
-
617
612
  def to_int_array(
618
613
  self,
619
614
  *,
@@ -779,6 +774,22 @@ class Value:
779
774
  raise HexConvError("chunk width cannot be None")
780
775
  return chunk_width
781
776
 
777
+ # ``int``/``integer`` are convenience shortcuts whose names shadow the builtin
778
+ # ``int``. They are declared after every method above so those methods keep
779
+ # annotating with the builtin, and they spell their own return type as
780
+ # ``builtins.int`` so their order here does not matter.
781
+ @property
782
+ def int(self) -> builtins.int:
783
+ """Shortcut for to_int()."""
784
+
785
+ return self.to_int()
786
+
787
+ @property
788
+ def integer(self) -> builtins.int:
789
+ """Shortcut for to_int()."""
790
+
791
+ return self.to_int()
792
+
782
793
 
783
794
  @dataclass(frozen=True)
784
795
  class AutoResult:
@@ -796,7 +807,7 @@ class Transform:
796
807
  def __call__(self, value: Any) -> Value:
797
808
  return self.apply(_ensure_value(value))
798
809
 
799
- def __rshift__(self, other: Any) -> "Pipeline":
810
+ def __rshift__(self, other: Any) -> Pipeline:
800
811
  return Pipeline(transforms=[self]) >> other
801
812
 
802
813
  def apply(self, value: Value) -> Value:
@@ -852,7 +863,7 @@ class Pad(Transform):
852
863
  width: int | None = None,
853
864
  block_size: int | None = None,
854
865
  byte: int = 0,
855
- ) -> "Pad":
866
+ ) -> Pad:
856
867
  return cls(width=width, block_size=block_size, byte=byte, side="left")
857
868
 
858
869
  @classmethod
@@ -862,7 +873,7 @@ class Pad(Transform):
862
873
  width: int | None = None,
863
874
  block_size: int | None = None,
864
875
  byte: int = 0,
865
- ) -> "Pad":
876
+ ) -> Pad:
866
877
  return cls(width=width, block_size=block_size, byte=byte, side="right")
867
878
 
868
879
  def apply(self, value: Value) -> Value:
@@ -899,23 +910,23 @@ class Pipeline:
899
910
  self.transforms = list(transforms or [])
900
911
  self.target = target
901
912
 
902
- def from_(self, source: FormatLike) -> "Pipeline":
913
+ def from_(self, source: FormatLike) -> Pipeline:
903
914
  return Pipeline(source=source, transforms=self.transforms, target=self.target)
904
915
 
905
- def to(self, target: FormatLike) -> "Pipeline":
916
+ def to(self, target: FormatLike) -> Pipeline:
906
917
  return Pipeline(source=self.source, transforms=self.transforms, target=target)
907
918
 
908
- def then(self, transform: Transform) -> "Pipeline":
919
+ def then(self, transform: Transform) -> Pipeline:
909
920
  return Pipeline(
910
921
  source=self.source,
911
922
  transforms=[*self.transforms, transform],
912
923
  target=self.target,
913
924
  )
914
925
 
915
- def chunk(self, width: int, *, strict: bool = True) -> "Pipeline":
926
+ def chunk(self, width: int, *, strict: bool = True) -> Pipeline:
916
927
  return self.then(Chunk(width, strict=strict))
917
928
 
918
- def group(self, width: int, *, strict: bool = True) -> "Pipeline":
929
+ def group(self, width: int, *, strict: bool = True) -> Pipeline:
919
930
  return self.then(Group(width, strict=strict))
920
931
 
921
932
  def pad(
@@ -925,13 +936,13 @@ class Pipeline:
925
936
  block_size: int | None = None,
926
937
  byte: int = 0,
927
938
  side: PadSide = "left",
928
- ) -> "Pipeline":
939
+ ) -> Pipeline:
929
940
  return self.then(Pad(width=width, block_size=block_size, byte=byte, side=side))
930
941
 
931
- def reverse(self) -> "Pipeline":
942
+ def reverse(self) -> Pipeline:
932
943
  return self.then(Reverse())
933
944
 
934
- def __rshift__(self, other: Any) -> "Pipeline":
945
+ def __rshift__(self, other: Any) -> Pipeline:
935
946
  if isinstance(other, Pipeline):
936
947
  source = self.source if self.source is not None else other.source
937
948
  return Pipeline(
@@ -1002,31 +1013,22 @@ def convert(
1002
1013
  from_format: FormatLike | None = None,
1003
1014
  to_format: FormatLike | None = None,
1004
1015
  *,
1005
- from_: FormatLike | None = None,
1006
- to: FormatLike | None = None,
1007
1016
  input_options: dict[str, Any] | None = None,
1008
1017
  output_options: dict[str, Any] | None = None,
1009
1018
  ) -> Any:
1010
1019
  """One-shot conversion between explicit formats.
1011
1020
 
1012
- Preferred:
1021
+ The source and target formats are positional:
1022
+
1013
1023
  convert(value, Hex(), Bytes())
1014
1024
 
1015
- Backward-compatible:
1016
- convert(value, from_=Hex(), to=Bytes())
1025
+ Omit ``from_format`` to infer the source with ``Auto``; omit ``to_format``
1026
+ to emit raw ``Bytes``.
1017
1027
  """
1018
1028
 
1019
- if from_format is not None and from_ is not None:
1020
- raise HexConvError("use either positional input format or from_, not both")
1021
- if to_format is not None and to is not None:
1022
- raise HexConvError("use either positional output format or to, not both")
1023
-
1024
- input_format = from_format if from_format is not None else from_
1025
- output_format = to_format if to_format is not None else to
1026
-
1027
1029
  return Converter(
1028
- Auto if input_format is None else input_format,
1029
- Bytes if output_format is None else output_format,
1030
+ Auto if from_format is None else from_format,
1031
+ Bytes if to_format is None else to_format,
1030
1032
  input_options=input_options,
1031
1033
  output_options=output_options,
1032
1034
  )(value)
@@ -1082,6 +1084,18 @@ def infer(value: Any) -> AutoResult:
1082
1084
  raise HexConvError(f"cannot infer source format for {type(value).__name__}")
1083
1085
 
1084
1086
 
1087
+ @overload
1088
+ def from_auto(value: Any, *, explain: Literal[False] = ...) -> Value: ...
1089
+
1090
+
1091
+ @overload
1092
+ def from_auto(value: Any, *, explain: Literal[True]) -> AutoResult: ...
1093
+
1094
+
1095
+ @overload
1096
+ def from_auto(value: Any, *, explain: bool) -> Value | AutoResult: ...
1097
+
1098
+
1085
1099
  def from_auto(value: Any, *, explain: bool = False) -> Value | AutoResult:
1086
1100
  """Parse a value using convenience heuristics.
1087
1101
 
@@ -1100,6 +1114,12 @@ def from_auto(value: Any, *, explain: bool = False) -> Value | AutoResult:
1100
1114
  return result if explain else result.value
1101
1115
 
1102
1116
 
1117
+ def _from_auto_value(value: Any) -> Value:
1118
+ """Auto-format parser that always returns a Value (never AutoResult)."""
1119
+
1120
+ return from_auto(value)
1121
+
1122
+
1103
1123
  def from_bytes(value: bytes | bytearray | memoryview | Iterable[int]) -> Value:
1104
1124
  """Parse a bytes-like object or a byte-sized integer sequence."""
1105
1125
 
@@ -1549,7 +1569,7 @@ def _emit_bits(value: Value, **options: Any) -> list[int]:
1549
1569
 
1550
1570
 
1551
1571
  _INPUTS: dict[Format, Callable[..., Value]] = {
1552
- Auto: from_auto,
1572
+ Auto: _from_auto_value,
1553
1573
  Bytes: from_bytes,
1554
1574
  BytesArray: from_bytes_array,
1555
1575
  BytesString: from_bytes_string,
@@ -1604,6 +1624,14 @@ _OUTPUTS: dict[Format, Callable[..., Any]] = {
1604
1624
  }
1605
1625
 
1606
1626
 
1627
+ # Output formats whose emitters honor a Chunk/Group width stored on Value.meta.
1628
+ # Every other target silently ignores chunking, so emitting to one after a chunk
1629
+ # is treated as a mistake (see Value.to).
1630
+ _CHUNK_AWARE_OUTPUTS: frozenset[Format] = frozenset(
1631
+ {HexArray, HexNumbersArray, IntArray, DecimalIntArray}
1632
+ )
1633
+
1634
+
1607
1635
  def _is_format_like(value: Any) -> bool:
1608
1636
  return isinstance(value, _Format) or (
1609
1637
  isinstance(value, type) and issubclass(value, _Format)
@@ -1631,10 +1659,7 @@ def _format_options(format_: FormatLike, direction: str) -> dict[str, Any]:
1631
1659
  def _ensure_value(value: Any) -> Value:
1632
1660
  if isinstance(value, Value):
1633
1661
  return value
1634
- parsed = from_auto(value)
1635
- if isinstance(parsed, AutoResult):
1636
- return parsed.value
1637
- return parsed
1662
+ return from_auto(value)
1638
1663
 
1639
1664
 
1640
1665
  def _validate_endian(endian: Endian) -> None:
@@ -1824,6 +1849,8 @@ def _int_to_bytes(
1824
1849
 
1825
1850
 
1826
1851
  def _signed_int_to_minimal_bytes(value: int, *, min_width: int, endian: Endian) -> bytes:
1852
+ # Two's complement needs a bit beyond the magnitude for the sign, so start
1853
+ # one byte wider than the magnitude and widen further until the value fits.
1827
1854
  candidate = max(min_width, 1, (abs(value).bit_length() + 8) // 8)
1828
1855
  for width in range(candidate, candidate + 2):
1829
1856
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexconv
3
- Version: 0.2.7
3
+ Version: 0.3.1
4
4
  Summary: Pythonic conversion toolkit for bytes, hex, integers, arrays, binary strings, base64, and text.
5
5
  Author: hexconv contributors
6
6
  License-Expression: MIT
@@ -23,7 +23,10 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: build; extra == "dev"
26
+ Requires-Dist: hypothesis; extra == "dev"
27
+ Requires-Dist: mypy; extra == "dev"
26
28
  Requires-Dist: pytest; extra == "dev"
29
+ Requires-Dist: ruff; extra == "dev"
27
30
  Requires-Dist: twine; extra == "dev"
28
31
  Dynamic: license-file
29
32
 
@@ -319,6 +322,23 @@ Available format specs all work in their simplest bare form:
319
322
  - `Bits()`
320
323
  - `Auto()`
321
324
 
325
+ The short names are ergonomic aliases of the explicit long-form specs — pick
326
+ whichever reads better, they resolve to the same format:
327
+
328
+ | Short | Long form |
329
+ | --- | --- |
330
+ | `Hex` | `HexString` |
331
+ | `Binary` | `BinaryString` |
332
+ | `Base64` / `Base32` / `Base85` / `Ascii85` | `Base64String` / `Base32String` / `Base85String` / `Ascii85String` |
333
+ | `Escaped` | `EscapedString` |
334
+ | `Hexdump` | `HexDump` |
335
+ | `Struct` | `StructFormat` |
336
+ | `Bits` | `BitArray` |
337
+ | `DecimalInt` / `DecimalIntArray` | `Int` / `IntArray` |
338
+ | `LargeHexNumber` | `HexInt` |
339
+ | `ASCIIText` / `RawString` | `Text` |
340
+ | `Group` (transform) | `Chunk` |
341
+
322
342
  Add options only when you need control over formatting or parsing:
323
343
 
324
344
  - Display: `Hex(sep=" ", prefix=True, uppercase=True)`, `HexArray(width=2)`.
@@ -363,7 +383,7 @@ hx.from_hex("dead beef").bytes
363
383
  hx.from_text("data").hex
364
384
  # '64617461'
365
385
 
366
- hx.convert("data", from_=hx.Text, to=hx.HexString)
386
+ hx.convert("data", hx.Text, hx.HexString)
367
387
  # '64617461'
368
388
  ```
369
389
 
@@ -10,4 +10,5 @@ src/hexconv.egg-info/SOURCES.txt
10
10
  src/hexconv.egg-info/dependency_links.txt
11
11
  src/hexconv.egg-info/requires.txt
12
12
  src/hexconv.egg-info/top_level.txt
13
- tests/test_hexconv.py
13
+ tests/test_hexconv.py
14
+ tests/test_properties.py
@@ -1,5 +1,8 @@
1
1
 
2
2
  [dev]
3
3
  build
4
+ hypothesis
5
+ mypy
4
6
  pytest
7
+ ruff
5
8
  twine
@@ -44,62 +44,150 @@ def test_converter_class_matches_proposed_usage():
44
44
 
45
45
 
46
46
  def test_convert_one_shot():
47
- assert hx.convert("data", from_=hx.Text, to=hx.HexString) == "64617461"
47
+ assert hx.convert("data", hx.Text, hx.HexString) == "64617461"
48
48
  assert hx.convert("de ad be ef", hx.Hex(), hx.Bytes()) == b"\xde\xad\xbe\xef"
49
49
  assert hx.convert(b"\xde\xad\xbe\xef", hx.Bytes(), hx.Hex(sep=" ")) == "de ad be ef"
50
50
  assert hx.convert("data", hx.Text(), hx.Hex(prefix=True)) == "0x64617461"
51
51
 
52
52
 
53
- def test_documented_format_options_quick_tour():
54
- assert hx.from_hex("de ad:be-ef").bytes == b"\xde\xad\xbe\xef"
55
- assert hx.from_bytes(b"\xde\xad\xbe\xef").to(hx.Hex(sep=":", uppercase=True)) == "DE:AD:BE:EF"
56
- assert hx.from_bytes_array([0xDE, 0xAD, 0xBE, 0xEF]).to(
57
- hx.HexArray(width=2, prefix=True, uppercase=True)
58
- ) == ["0xDEAD", "0xBEEF"]
59
- assert hx.from_text("π", encoding="utf-8").to(hx.Hex(prefix=True)) == "0xcf80"
60
- assert hx.from_int(0x12345678, endian="little").hex == "78563412"
61
- assert hx.from_int_array([0x1234, 0x5678], width=2).to(hx.HexArray(width=2)) == [
62
- "1234",
63
- "5678",
64
- ]
65
- assert hx.from_binary("01100100 01100001 01110100 01100001").text == "data"
66
- assert hx.from_base64("-_8", urlsafe=True, padding=False).hex == "fbff"
67
- assert hx.from_base32("MRQXIYI", padding=False).text == "data"
68
- assert hx.from_escaped(r"\xDE\xAD").hex == "dead"
69
- assert hx.from_struct((0x1234, 0x5678), fmt=">HH").hex == "12345678"
70
- assert hx.from_bits("101", pad_side="right").hex == "a0"
71
-
72
- assert hx.convert("de ad be ef", hx.Hex(), hx.Bytes()) == b"\xde\xad\xbe\xef"
73
- assert (
74
- hx.convert(
53
+ _QUICK_TOUR_CASES = [
54
+ (
55
+ "hex_separators",
56
+ lambda: hx.from_hex("de ad:be-ef").bytes,
57
+ b"\xde\xad\xbe\xef",
58
+ ),
59
+ (
60
+ "hex_sep_uppercase",
61
+ lambda: hx.from_bytes(b"\xde\xad\xbe\xef").to(hx.Hex(sep=":", uppercase=True)),
62
+ "DE:AD:BE:EF",
63
+ ),
64
+ (
65
+ "hex_array_width2",
66
+ lambda: hx.from_bytes_array([0xDE, 0xAD, 0xBE, 0xEF]).to(
67
+ hx.HexArray(width=2, prefix=True, uppercase=True)
68
+ ),
69
+ ["0xDEAD", "0xBEEF"],
70
+ ),
71
+ (
72
+ "text_utf8_prefix",
73
+ lambda: hx.from_text("π", encoding="utf-8").to(hx.Hex(prefix=True)),
74
+ "0xcf80",
75
+ ),
76
+ (
77
+ "int_little_endian",
78
+ lambda: hx.from_int(0x12345678, endian="little").hex,
79
+ "78563412",
80
+ ),
81
+ (
82
+ "int_array_width2",
83
+ lambda: hx.from_int_array([0x1234, 0x5678], width=2).to(hx.HexArray(width=2)),
84
+ ["1234", "5678"],
85
+ ),
86
+ (
87
+ "binary_to_text",
88
+ lambda: hx.from_binary("01100100 01100001 01110100 01100001").text,
89
+ "data",
90
+ ),
91
+ (
92
+ "base64_urlsafe_in",
93
+ lambda: hx.from_base64("-_8", urlsafe=True, padding=False).hex,
94
+ "fbff",
95
+ ),
96
+ (
97
+ "base32_no_padding_in",
98
+ lambda: hx.from_base32("MRQXIYI", padding=False).text,
99
+ "data",
100
+ ),
101
+ (
102
+ "escaped_in",
103
+ lambda: hx.from_escaped(r"\xDE\xAD").hex,
104
+ "dead",
105
+ ),
106
+ (
107
+ "struct_pack",
108
+ lambda: hx.from_struct((0x1234, 0x5678), fmt=">HH").hex,
109
+ "12345678",
110
+ ),
111
+ (
112
+ "bits_pad_right",
113
+ lambda: hx.from_bits("101", pad_side="right").hex,
114
+ "a0",
115
+ ),
116
+ (
117
+ "convert_hex_to_bytes",
118
+ lambda: hx.convert("de ad be ef", hx.Hex(), hx.Bytes()),
119
+ b"\xde\xad\xbe\xef",
120
+ ),
121
+ (
122
+ "convert_bytes_to_hex",
123
+ lambda: hx.convert(
75
124
  b"\xde\xad\xbe\xef",
76
125
  hx.Bytes(),
77
126
  hx.Hex(sep=" ", prefix=True, uppercase=True),
78
- )
79
- == "0xDE AD BE EF"
80
- )
81
- assert hx.convert(["0xDEAD", "0xBEEF"], hx.HexArray(width=2), hx.IntArray(width=2)) == [
82
- 57005,
83
- 48879,
84
- ]
85
- assert hx.convert("78563412", hx.Hex(), hx.Int(endian="little")) == 305419896
86
- assert hx.convert("0b10100000", hx.Binary(bit_order="lsb"), hx.Hex()) == "05"
87
- assert hx.from_text("data").to(hx.Base64(urlsafe=True, padding=False)) == "ZGF0YQ"
88
- assert hx.from_text("data").to(hx.Base85()) == "WMOn+"
89
- assert hx.from_text("data").to(hx.Ascii85(adobe=True)) == "<~A79Rg~>"
90
- assert hx.from_hex("deadbeef").to(hx.Hexdump(width=2, offset=False, ascii=False)) == "de ad\nbe ef"
91
- assert hx.from_hex("dead").to(hx.Escaped(uppercase=True)) == r"\xDE\xAD"
92
- assert hx.from_hex("0506").to(hx.Binary(sep=" ", prefix=True, bit_order="lsb")) == (
93
- "0b10100000 0b01100000"
94
- )
95
- assert hx.from_hex("05").to(hx.Bits(bit_order="lsb")) == [1, 0, 1, 0, 0, 0, 0, 0]
96
-
97
-
98
- def test_convert_rejects_mixed_positional_and_keyword_formats():
99
- with pytest.raises(hx.HexConvError, match="from_"):
100
- hx.convert("data", hx.Text(), from_=hx.Text(), to=hx.Hex())
101
- with pytest.raises(hx.HexConvError, match="to"):
102
- hx.convert("data", hx.Text(), hx.Hex(), to=hx.Hex())
127
+ ),
128
+ "0xDE AD BE EF",
129
+ ),
130
+ (
131
+ "convert_hexarray_to_intarray",
132
+ lambda: hx.convert(
133
+ ["0xDEAD", "0xBEEF"], hx.HexArray(width=2), hx.IntArray(width=2)
134
+ ),
135
+ [57005, 48879],
136
+ ),
137
+ (
138
+ "convert_hex_to_int_little",
139
+ lambda: hx.convert("78563412", hx.Hex(), hx.Int(endian="little")),
140
+ 305419896,
141
+ ),
142
+ (
143
+ "convert_binary_to_hex_lsb",
144
+ lambda: hx.convert("0b10100000", hx.Binary(bit_order="lsb"), hx.Hex()),
145
+ "05",
146
+ ),
147
+ (
148
+ "base64_urlsafe_out",
149
+ lambda: hx.from_text("data").to(hx.Base64(urlsafe=True, padding=False)),
150
+ "ZGF0YQ",
151
+ ),
152
+ (
153
+ "base85_out",
154
+ lambda: hx.from_text("data").to(hx.Base85()),
155
+ "WMOn+",
156
+ ),
157
+ (
158
+ "ascii85_adobe_out",
159
+ lambda: hx.from_text("data").to(hx.Ascii85(adobe=True)),
160
+ "<~A79Rg~>",
161
+ ),
162
+ (
163
+ "hexdump_bare_out",
164
+ lambda: hx.from_hex("deadbeef").to(hx.Hexdump(width=2, offset=False, ascii=False)),
165
+ "de ad\nbe ef",
166
+ ),
167
+ (
168
+ "escaped_uppercase_out",
169
+ lambda: hx.from_hex("dead").to(hx.Escaped(uppercase=True)),
170
+ r"\xDE\xAD",
171
+ ),
172
+ (
173
+ "binary_sep_prefix_lsb_out",
174
+ lambda: hx.from_hex("0506").to(hx.Binary(sep=" ", prefix=True, bit_order="lsb")),
175
+ "0b10100000 0b01100000",
176
+ ),
177
+ (
178
+ "bits_lsb_out",
179
+ lambda: hx.from_hex("05").to(hx.Bits(bit_order="lsb")),
180
+ [1, 0, 1, 0, 0, 0, 0, 0],
181
+ ),
182
+ ]
183
+
184
+
185
+ @pytest.mark.parametrize(
186
+ "op, expected",
187
+ [pytest.param(op, expected, id=name) for name, op, expected in _QUICK_TOUR_CASES],
188
+ )
189
+ def test_documented_format_options_quick_tour(op, expected):
190
+ assert op() == expected
103
191
 
104
192
 
105
193
  def test_converter_options():
@@ -115,7 +203,7 @@ def test_converter_options():
115
203
  def test_bytes_string_literal():
116
204
  assert hx.from_bytes_string(r"b'\xde\xad'").to_hex() == "dead"
117
205
  assert hx.from_hex("dead").to_bytes_string() == r"b'\xde\xad'"
118
- assert hx.convert(b"\xde", from_=hx.Bytes, to=hx.BytesString) == r"b'\xde'"
206
+ assert hx.convert(b"\xde", hx.Bytes, hx.BytesString) == r"b'\xde'"
119
207
 
120
208
 
121
209
  def test_odd_hex_left_pads_by_default_and_can_be_strict():
@@ -155,7 +243,7 @@ def test_invalid_byte_array_item_reports_context():
155
243
  def test_constructable_hex_and_text_specs():
156
244
  assert hx.Hex()("de:ad").hex == "dead"
157
245
  assert hx.from_hex("dead").to(hx.Hex(prefix=True, uppercase=True)) == "0xDEAD"
158
- assert hx.convert("π", from_=hx.Text(encoding="utf-8"), to=hx.Hex()) == "cf80"
246
+ assert hx.convert("π", hx.Text(encoding="utf-8"), hx.Hex()) == "cf80"
159
247
  assert hx.from_hex("cf80").to(hx.Text(encoding="utf-8")) == "π"
160
248
 
161
249
 
@@ -174,18 +262,27 @@ def test_pipeline_transforms_pad_reverse_and_group():
174
262
  assert (hx.Hex() >> hx.Group(2) >> hx.IntArray())("12345678") == [0x1234, 0x5678]
175
263
 
176
264
 
265
+ def test_chunk_into_non_chunk_aware_format_is_rejected():
266
+ with pytest.raises(hx.HexConvError, match="chunking has no effect"):
267
+ (hx.Hex() >> hx.Chunk(2) >> hx.Hex())("12345678")
268
+ with pytest.raises(hx.HexConvError, match="chunking has no effect"):
269
+ (hx.Hex() >> hx.Group(2) >> hx.Text())("12345678")
270
+ # Chunk-aware targets still accept a grouping width.
271
+ assert (hx.Hex() >> hx.Chunk(2) >> hx.HexArray())("12345678") == ["1234", "5678"]
272
+
273
+
177
274
  def test_urlsafe_base64_base32_base85_and_ascii85():
178
275
  assert hx.from_bytes(b"\xfb\xff").to(hx.Base64(urlsafe=True, padding=False)) == "-_8"
179
- assert hx.convert("-_8", from_=hx.Base64(urlsafe=True, padding=False), to=hx.Hex()) == "fbff"
276
+ assert hx.convert("-_8", hx.Base64(urlsafe=True, padding=False), hx.Hex()) == "fbff"
180
277
 
181
278
  assert hx.from_text("data").to(hx.Base32(padding=False)) == "MRQXIYI"
182
- assert hx.convert("MRQXIYI", from_=hx.Base32(padding=False), to=hx.Text()) == "data"
279
+ assert hx.convert("MRQXIYI", hx.Base32(padding=False), hx.Text()) == "data"
183
280
 
184
281
  base85 = hx.from_text("data").to(hx.Base85())
185
- assert hx.convert(base85, from_=hx.Base85(), to=hx.Text()) == "data"
282
+ assert hx.convert(base85, hx.Base85(), hx.Text()) == "data"
186
283
 
187
284
  ascii85 = hx.from_text("data").to(hx.Ascii85())
188
- assert hx.convert(ascii85, from_=hx.Ascii85(), to=hx.Text()) == "data"
285
+ assert hx.convert(ascii85, hx.Ascii85(), hx.Text()) == "data"
189
286
 
190
287
 
191
288
  def test_escaped_string_format():
@@ -198,14 +295,14 @@ def test_hexdump_format_round_trip():
198
295
  dump = hx.from_hex("deadbeef").to(hx.Hexdump(width=2))
199
296
  assert "00000000" in dump
200
297
  assert hx.from_hexdump(dump).hex == "deadbeef"
201
- assert hx.convert(dump, from_=hx.Hexdump(), to=hx.Hex()) == "deadbeef"
298
+ assert hx.convert(dump, hx.Hexdump(), hx.Hex()) == "deadbeef"
202
299
 
203
300
 
204
301
  def test_struct_format_pack_and_unpack():
205
302
  packed = hx.from_struct((0x1234, 0x5678), fmt=">HH")
206
303
  assert packed.hex == "12345678"
207
304
  assert packed.to(hx.Struct(">HH")) == (0x1234, 0x5678)
208
- assert hx.convert((0x1234, 0x5678), from_=hx.Struct(">HH"), to=hx.Hex()) == "12345678"
305
+ assert hx.convert((0x1234, 0x5678), hx.Struct(">HH"), hx.Hex()) == "12345678"
209
306
  assert hx.Struct(">HH")((0x1234, 0x5678)).hex == "12345678"
210
307
  assert (hx.Struct(">HH") >> hx.Hex())((0x1234, 0x5678)) == "12345678"
211
308
  assert (hx.Hex() >> hx.Struct(">HH"))("12345678") == (0x1234, 0x5678)
@@ -0,0 +1,99 @@
1
+ """Property-based round-trip invariants.
2
+
3
+ For every lossless format, parsing what we emit must reproduce the original
4
+ bytes (or integers). Hypothesis explores edge cases that the example-based
5
+ tests in ``test_hexconv.py`` do not.
6
+ """
7
+
8
+ from hypothesis import given
9
+ from hypothesis import strategies as st
10
+
11
+ import hexconv as hx
12
+
13
+
14
+ @given(st.binary(), st.sampled_from(["", " ", ":", "-", "_", ","]))
15
+ def test_hex_round_trip(data, sep):
16
+ text = hx.from_bytes(data).to_hex(sep=sep)
17
+ assert hx.from_hex(text).to_bytes() == data
18
+
19
+
20
+ @given(st.binary(), st.booleans())
21
+ def test_hex_prefix_and_uppercase_round_trip(data, uppercase):
22
+ text = hx.from_bytes(data).to_hex(prefix=True, uppercase=uppercase)
23
+ assert hx.from_hex(text).to_bytes() == data
24
+
25
+
26
+ @given(st.binary(), st.booleans(), st.booleans())
27
+ def test_base64_round_trip(data, urlsafe, padding):
28
+ text = hx.from_bytes(data).to_base64(urlsafe=urlsafe, padding=padding)
29
+ assert hx.from_base64(text, urlsafe=urlsafe, padding=padding).to_bytes() == data
30
+
31
+
32
+ @given(st.binary(), st.booleans())
33
+ def test_base32_round_trip(data, padding):
34
+ text = hx.from_bytes(data).to_base32(padding=padding)
35
+ assert hx.from_base32(text, padding=padding).to_bytes() == data
36
+
37
+
38
+ @given(st.binary())
39
+ def test_base85_round_trip(data):
40
+ assert hx.from_base85(hx.from_bytes(data).to_base85()).to_bytes() == data
41
+
42
+
43
+ @given(st.binary(), st.booleans())
44
+ def test_ascii85_round_trip(data, adobe):
45
+ text = hx.from_bytes(data).to_ascii85(adobe=adobe)
46
+ assert hx.from_ascii85(text, adobe=adobe).to_bytes() == data
47
+
48
+
49
+ @given(st.binary(), st.sampled_from(["msb", "lsb"]))
50
+ def test_binary_round_trip(data, bit_order):
51
+ text = hx.from_bytes(data).to_binary(bit_order=bit_order)
52
+ assert hx.from_binary(text, bit_order=bit_order).to_bytes() == data
53
+
54
+
55
+ @given(st.binary(), st.sampled_from(["msb", "lsb"]))
56
+ def test_bits_round_trip(data, bit_order):
57
+ bits = hx.from_bytes(data).to_bits(bit_order=bit_order)
58
+ assert hx.from_bits(bits, bit_order=bit_order).to_bytes() == data
59
+
60
+
61
+ @given(st.binary())
62
+ def test_escaped_round_trip(data):
63
+ assert hx.from_escaped(hx.from_bytes(data).to_escaped()).to_bytes() == data
64
+
65
+
66
+ @given(st.binary())
67
+ def test_bytes_string_round_trip(data):
68
+ text = hx.from_bytes(data).to_bytes_string()
69
+ assert hx.from_bytes_string(text).to_bytes() == data
70
+
71
+
72
+ @given(st.binary())
73
+ def test_bytes_array_round_trip(data):
74
+ assert hx.from_bytes_array(hx.from_bytes(data).to_bytes_array()).to_bytes() == data
75
+
76
+
77
+ @given(st.binary(min_size=1))
78
+ def test_hexdump_round_trip(data):
79
+ dump = hx.from_bytes(data).to_hexdump()
80
+ assert hx.from_hexdump(dump).to_bytes() == data
81
+
82
+
83
+ @given(st.integers(min_value=0))
84
+ def test_unsigned_int_round_trip(n):
85
+ assert hx.from_int(n).to_int() == n
86
+
87
+
88
+ @given(st.integers())
89
+ def test_signed_int_round_trip(n):
90
+ assert hx.from_int(n, signed=True).to_int(signed=True) == n
91
+
92
+
93
+ @given(
94
+ st.lists(st.integers(min_value=0, max_value=0xFFFF)),
95
+ st.sampled_from(["big", "little"]),
96
+ )
97
+ def test_int_array_width2_round_trip(values, endian):
98
+ packed = hx.from_int_array(values, width=2, endian=endian)
99
+ assert packed.to_int_array(width=2, endian=endian) == values
File without changes
File without changes
File without changes
File without changes