hexconv 0.1.0__py3-none-any.whl
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.
- hexconv/__init__.py +76 -0
- hexconv/_core.py +832 -0
- hexconv/py.typed +1 -0
- hexconv-0.1.0.dist-info/METADATA +145 -0
- hexconv-0.1.0.dist-info/RECORD +8 -0
- hexconv-0.1.0.dist-info/WHEEL +5 -0
- hexconv-0.1.0.dist-info/licenses/LICENSE +22 -0
- hexconv-0.1.0.dist-info/top_level.txt +1 -0
hexconv/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CTF-friendly conversions between bytes, hex, integers, arrays, and text."""
|
|
2
|
+
|
|
3
|
+
from ._core import (
|
|
4
|
+
ASCIIText,
|
|
5
|
+
Auto,
|
|
6
|
+
Base64String,
|
|
7
|
+
BinaryString,
|
|
8
|
+
Bytes,
|
|
9
|
+
BytesArray,
|
|
10
|
+
BytesString,
|
|
11
|
+
Converter,
|
|
12
|
+
DecimalInt,
|
|
13
|
+
DecimalIntArray,
|
|
14
|
+
HexArray,
|
|
15
|
+
HexConvError,
|
|
16
|
+
HexInt,
|
|
17
|
+
HexNumbersArray,
|
|
18
|
+
HexString,
|
|
19
|
+
Int,
|
|
20
|
+
IntArray,
|
|
21
|
+
LargeHexNumber,
|
|
22
|
+
RawString,
|
|
23
|
+
Text,
|
|
24
|
+
Value,
|
|
25
|
+
convert,
|
|
26
|
+
from_auto,
|
|
27
|
+
from_base64,
|
|
28
|
+
from_binary,
|
|
29
|
+
from_bytes,
|
|
30
|
+
from_bytes_array,
|
|
31
|
+
from_bytes_string,
|
|
32
|
+
from_hex,
|
|
33
|
+
from_hex_array,
|
|
34
|
+
from_hex_int,
|
|
35
|
+
from_int,
|
|
36
|
+
from_int_array,
|
|
37
|
+
from_text,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ASCIIText",
|
|
42
|
+
"Auto",
|
|
43
|
+
"Base64String",
|
|
44
|
+
"BinaryString",
|
|
45
|
+
"Bytes",
|
|
46
|
+
"BytesArray",
|
|
47
|
+
"BytesString",
|
|
48
|
+
"Converter",
|
|
49
|
+
"DecimalInt",
|
|
50
|
+
"DecimalIntArray",
|
|
51
|
+
"HexArray",
|
|
52
|
+
"HexConvError",
|
|
53
|
+
"HexInt",
|
|
54
|
+
"HexNumbersArray",
|
|
55
|
+
"HexString",
|
|
56
|
+
"Int",
|
|
57
|
+
"IntArray",
|
|
58
|
+
"LargeHexNumber",
|
|
59
|
+
"RawString",
|
|
60
|
+
"Text",
|
|
61
|
+
"Value",
|
|
62
|
+
"convert",
|
|
63
|
+
"from_auto",
|
|
64
|
+
"from_base64",
|
|
65
|
+
"from_binary",
|
|
66
|
+
"from_bytes",
|
|
67
|
+
"from_bytes_array",
|
|
68
|
+
"from_bytes_string",
|
|
69
|
+
"from_hex",
|
|
70
|
+
"from_hex_array",
|
|
71
|
+
"from_hex_int",
|
|
72
|
+
"from_int",
|
|
73
|
+
"from_int_array",
|
|
74
|
+
"from_text",
|
|
75
|
+
]
|
|
76
|
+
|
hexconv/_core.py
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import base64
|
|
5
|
+
import binascii
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Callable, TypeAlias
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HexConvError(ValueError):
|
|
13
|
+
"""Raised when a value cannot be parsed or emitted in the requested format."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Format:
|
|
17
|
+
"""Base class for public format marker classes."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Auto(_Format):
|
|
21
|
+
"""Infer the source format using CTF-friendly heuristics."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Bytes(_Format):
|
|
25
|
+
"""A Python bytes-like object."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BytesArray(_Format):
|
|
29
|
+
"""A sequence of byte-sized integers, e.g. [0xde, 0xad]."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BytesString(_Format):
|
|
33
|
+
"""A string containing a Python bytes literal, e.g. "b'\\xde\\xad'"."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HexString(_Format):
|
|
37
|
+
"""A hex string, e.g. 'deadbeef', '0xdeadbeef', or 'de ad be ef'."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HexArray(_Format):
|
|
41
|
+
"""A sequence of hex tokens, e.g. ['de', 'ad'] or ['0xde', '0xad']."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HexNumbersArray(_Format):
|
|
45
|
+
"""A sequence of integers commonly displayed as hex, e.g. [0xde, 0xad]."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HexInt(_Format):
|
|
49
|
+
"""A single integer or string interpreted as a large hex number."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LargeHexNumber(HexInt):
|
|
53
|
+
"""Alias for HexInt."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Int(_Format):
|
|
57
|
+
"""A Python integer."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DecimalInt(Int):
|
|
61
|
+
"""Alias for Int."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class IntArray(_Format):
|
|
65
|
+
"""A sequence of integers packed into bytes."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DecimalIntArray(IntArray):
|
|
69
|
+
"""Alias for IntArray."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Text(_Format):
|
|
73
|
+
"""Raw text encoded to bytes."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ASCIIText(Text):
|
|
77
|
+
"""Alias for Text, with ASCII as the default encoding."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RawString(Text):
|
|
81
|
+
"""Alias for Text."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BinaryString(_Format):
|
|
85
|
+
"""A binary bit string, e.g. '01100110 01101100'."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Base64String(_Format):
|
|
89
|
+
"""A base64-encoded string."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
Format: TypeAlias = type[_Format]
|
|
93
|
+
Endian: TypeAlias = str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_HEX_SEPARATORS = re.compile(r"[\s_:\-,;]+")
|
|
97
|
+
_HEX_PREFIX = re.compile(r"(?i)0x")
|
|
98
|
+
_HEX_CHARS = re.compile(r"^[0-9a-fA-F]*$")
|
|
99
|
+
_BINARY_SEPARATORS = re.compile(r"[\s_:\-,;]+")
|
|
100
|
+
_BINARY_PREFIX = re.compile(r"(?i)0b")
|
|
101
|
+
_BINARY_CHARS = re.compile(r"^[01]*$")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class Value:
|
|
106
|
+
"""Canonical byte-backed value with output methods for common CTF formats."""
|
|
107
|
+
|
|
108
|
+
_data: bytes
|
|
109
|
+
source: str | None = None
|
|
110
|
+
|
|
111
|
+
def to(self, format_: Format, **options: Any) -> Any:
|
|
112
|
+
"""Emit this value using a public format marker such as HexString."""
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
emitter = _OUTPUTS[format_]
|
|
116
|
+
except KeyError as exc:
|
|
117
|
+
raise HexConvError(f"unsupported output format: {format_!r}") from exc
|
|
118
|
+
return emitter(self, **options)
|
|
119
|
+
|
|
120
|
+
def to_bytes(self) -> bytes:
|
|
121
|
+
"""Return a Python bytes object."""
|
|
122
|
+
|
|
123
|
+
return self._data
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def bytes(self) -> bytes:
|
|
127
|
+
"""Shortcut for to_bytes()."""
|
|
128
|
+
|
|
129
|
+
return self.to_bytes()
|
|
130
|
+
|
|
131
|
+
def to_bytearray(self) -> bytearray:
|
|
132
|
+
"""Return a Python bytearray."""
|
|
133
|
+
|
|
134
|
+
return bytearray(self._data)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def bytearray(self) -> bytearray:
|
|
138
|
+
"""Shortcut for to_bytearray()."""
|
|
139
|
+
|
|
140
|
+
return self.to_bytearray()
|
|
141
|
+
|
|
142
|
+
def to_bytes_array(self) -> list[int]:
|
|
143
|
+
"""Return one integer per byte."""
|
|
144
|
+
|
|
145
|
+
return list(self._data)
|
|
146
|
+
|
|
147
|
+
def to_bytes_string(self) -> str:
|
|
148
|
+
"""Return a Python bytes literal string, e.g. "b'\\xde\\xad'"."""
|
|
149
|
+
|
|
150
|
+
return repr(self._data)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def bytes_string(self) -> str:
|
|
154
|
+
"""Shortcut for to_bytes_string()."""
|
|
155
|
+
|
|
156
|
+
return self.to_bytes_string()
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def bytes_array(self) -> list[int]:
|
|
160
|
+
"""Shortcut for to_bytes_array()."""
|
|
161
|
+
|
|
162
|
+
return self.to_bytes_array()
|
|
163
|
+
|
|
164
|
+
def to_hex(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
sep: str = "",
|
|
168
|
+
prefix: bool = False,
|
|
169
|
+
uppercase: bool = False,
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Return a hex string.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
sep: Separator inserted between bytes. Examples: "", " ", ":".
|
|
175
|
+
prefix: Add a single "0x" prefix to the whole string.
|
|
176
|
+
uppercase: Use uppercase A-F.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
if sep == "":
|
|
180
|
+
text = self._data.hex()
|
|
181
|
+
elif len(sep) == 1:
|
|
182
|
+
text = self._data.hex(sep)
|
|
183
|
+
else:
|
|
184
|
+
text = sep.join(f"{byte:02x}" for byte in self._data)
|
|
185
|
+
if uppercase:
|
|
186
|
+
text = text.upper()
|
|
187
|
+
if prefix:
|
|
188
|
+
text = "0x" + text
|
|
189
|
+
return text
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def hex(self) -> str:
|
|
193
|
+
"""Shortcut for to_hex()."""
|
|
194
|
+
|
|
195
|
+
return self.to_hex()
|
|
196
|
+
|
|
197
|
+
def to_hex_array(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
width: int = 1,
|
|
201
|
+
prefix: bool = False,
|
|
202
|
+
uppercase: bool = False,
|
|
203
|
+
) -> list[str]:
|
|
204
|
+
"""Return hex tokens grouped by byte width."""
|
|
205
|
+
|
|
206
|
+
tokens = [chunk.hex() for chunk in _chunks(self._data, width)]
|
|
207
|
+
if uppercase:
|
|
208
|
+
tokens = [token.upper() for token in tokens]
|
|
209
|
+
if prefix:
|
|
210
|
+
tokens = ["0x" + token for token in tokens]
|
|
211
|
+
return tokens
|
|
212
|
+
|
|
213
|
+
def to_hex_numbers(self, *, width: int = 1, endian: Endian = "big") -> list[int]:
|
|
214
|
+
"""Return integers for chunks that are commonly displayed with hex()."""
|
|
215
|
+
|
|
216
|
+
return self.to_int_array(width=width, endian=endian, signed=False)
|
|
217
|
+
|
|
218
|
+
def to_int(self, *, endian: Endian = "big", signed: bool = False) -> int:
|
|
219
|
+
"""Return the whole byte sequence as one integer."""
|
|
220
|
+
|
|
221
|
+
_validate_endian(endian)
|
|
222
|
+
return int.from_bytes(self._data, endian, signed=signed)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def int(self) -> int:
|
|
226
|
+
"""Shortcut for to_int()."""
|
|
227
|
+
|
|
228
|
+
return self.to_int()
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def integer(self) -> int:
|
|
232
|
+
"""Shortcut for to_int()."""
|
|
233
|
+
|
|
234
|
+
return self.to_int()
|
|
235
|
+
|
|
236
|
+
def to_int_array(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
width: int = 1,
|
|
240
|
+
endian: Endian = "big",
|
|
241
|
+
signed: bool = False,
|
|
242
|
+
) -> list[int]:
|
|
243
|
+
"""Return one integer per fixed-width chunk."""
|
|
244
|
+
|
|
245
|
+
_validate_endian(endian)
|
|
246
|
+
return [
|
|
247
|
+
int.from_bytes(chunk, endian, signed=signed)
|
|
248
|
+
for chunk in _chunks(self._data, width)
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
def to_text(self, *, encoding: str = "ascii", errors: str = "strict") -> str:
|
|
252
|
+
"""Decode bytes to text. ASCII is the default for CTF workflows."""
|
|
253
|
+
|
|
254
|
+
return self._data.decode(encoding, errors=errors)
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def text(self) -> str:
|
|
258
|
+
"""Shortcut for to_text()."""
|
|
259
|
+
|
|
260
|
+
return self.to_text()
|
|
261
|
+
|
|
262
|
+
def to_ascii(self, *, errors: str = "strict") -> str:
|
|
263
|
+
"""Decode bytes as ASCII."""
|
|
264
|
+
|
|
265
|
+
return self.to_text(encoding="ascii", errors=errors)
|
|
266
|
+
|
|
267
|
+
def to_binary(self, *, sep: str = "") -> str:
|
|
268
|
+
"""Return an 8-bit binary representation for each byte."""
|
|
269
|
+
|
|
270
|
+
return sep.join(f"{byte:08b}" for byte in self._data)
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def binary(self) -> str:
|
|
274
|
+
"""Shortcut for to_binary()."""
|
|
275
|
+
|
|
276
|
+
return self.to_binary()
|
|
277
|
+
|
|
278
|
+
def to_base64(self) -> str:
|
|
279
|
+
"""Return standard base64 text."""
|
|
280
|
+
|
|
281
|
+
return base64.b64encode(self._data).decode("ascii")
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def base64(self) -> str:
|
|
285
|
+
"""Shortcut for to_base64()."""
|
|
286
|
+
|
|
287
|
+
return self.to_base64()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class Converter:
|
|
291
|
+
"""Reusable converter between two explicit formats.
|
|
292
|
+
|
|
293
|
+
Example:
|
|
294
|
+
Converter(BytesArray, HexArray)([0xde, 0xad]) == ["de", "ad"]
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
from_: Format = Auto,
|
|
300
|
+
to: Format = Bytes,
|
|
301
|
+
*,
|
|
302
|
+
input_options: dict[str, Any] | None = None,
|
|
303
|
+
output_options: dict[str, Any] | None = None,
|
|
304
|
+
) -> None:
|
|
305
|
+
self.from_ = from_
|
|
306
|
+
self.to = to
|
|
307
|
+
self.input_options = dict(input_options or {})
|
|
308
|
+
self.output_options = dict(output_options or {})
|
|
309
|
+
|
|
310
|
+
def __call__(self, value: Any) -> Any:
|
|
311
|
+
parsed = _parse(value, self.from_, **self.input_options)
|
|
312
|
+
return parsed.to(self.to, **self.output_options)
|
|
313
|
+
|
|
314
|
+
def parse(self, value: Any) -> Value:
|
|
315
|
+
"""Parse a value and return the canonical Value instead of emitting."""
|
|
316
|
+
|
|
317
|
+
return _parse(value, self.from_, **self.input_options)
|
|
318
|
+
|
|
319
|
+
def convert(self, value: Any) -> Any:
|
|
320
|
+
"""Alias for __call__."""
|
|
321
|
+
|
|
322
|
+
return self(value)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def convert(
|
|
326
|
+
value: Any,
|
|
327
|
+
*,
|
|
328
|
+
from_: Format = Auto,
|
|
329
|
+
to: Format = Bytes,
|
|
330
|
+
input_options: dict[str, Any] | None = None,
|
|
331
|
+
output_options: dict[str, Any] | None = None,
|
|
332
|
+
) -> Any:
|
|
333
|
+
"""One-shot conversion between explicit formats."""
|
|
334
|
+
|
|
335
|
+
return Converter(
|
|
336
|
+
from_,
|
|
337
|
+
to,
|
|
338
|
+
input_options=input_options,
|
|
339
|
+
output_options=output_options,
|
|
340
|
+
)(value)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def from_auto(value: Any) -> Value:
|
|
344
|
+
"""Parse a value using convenience heuristics.
|
|
345
|
+
|
|
346
|
+
Heuristics are intentionally simple:
|
|
347
|
+
- Value -> unchanged
|
|
348
|
+
- bytes-like -> bytes
|
|
349
|
+
- int -> minimal-width integer bytes
|
|
350
|
+
- bytes literal string like "b'\\xde'" -> bytes
|
|
351
|
+
- 0x-prefixed or hex-looking strings -> hex
|
|
352
|
+
- 0b-prefixed binary strings -> binary
|
|
353
|
+
- text strings -> ASCII text
|
|
354
|
+
- integer sequences -> byte array if every value fits one byte; otherwise int array
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
if isinstance(value, Value):
|
|
358
|
+
return value
|
|
359
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
360
|
+
return from_bytes(value)
|
|
361
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
362
|
+
return from_int(value)
|
|
363
|
+
if isinstance(value, str):
|
|
364
|
+
stripped = value.strip()
|
|
365
|
+
if _looks_like_bytes_literal(stripped):
|
|
366
|
+
return from_bytes_string(stripped)
|
|
367
|
+
if _looks_like_binary(stripped):
|
|
368
|
+
return from_binary(stripped)
|
|
369
|
+
if _looks_like_hex(stripped):
|
|
370
|
+
return from_hex(stripped)
|
|
371
|
+
return from_text(value)
|
|
372
|
+
try:
|
|
373
|
+
values = _coerce_int_sequence(value, name="integer sequence")
|
|
374
|
+
except HexConvError:
|
|
375
|
+
values = None
|
|
376
|
+
if values is not None:
|
|
377
|
+
if all(0 <= item <= 0xFF for item in values):
|
|
378
|
+
return from_bytes_array(values)
|
|
379
|
+
return from_int_array(values, width=None)
|
|
380
|
+
raise HexConvError(f"cannot infer source format for {type(value).__name__}")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def from_bytes(value: bytes | bytearray | memoryview | Iterable[int]) -> Value:
|
|
384
|
+
"""Parse a bytes-like object or a byte-sized integer sequence."""
|
|
385
|
+
|
|
386
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
387
|
+
return Value(bytes(value), source="bytes")
|
|
388
|
+
if isinstance(value, Iterable) and not isinstance(value, (str, bytes, bytearray)):
|
|
389
|
+
return from_bytes_array(value)
|
|
390
|
+
raise HexConvError(f"expected bytes-like object, got {type(value).__name__}")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def from_bytes_array(values: Iterable[int]) -> Value:
|
|
394
|
+
"""Parse a sequence of byte-sized integers."""
|
|
395
|
+
|
|
396
|
+
items = _coerce_int_sequence(values, name="bytes array")
|
|
397
|
+
for index, item in enumerate(items):
|
|
398
|
+
if not 0 <= item <= 0xFF:
|
|
399
|
+
raise HexConvError(
|
|
400
|
+
f"bytes array item at index {index} is outside 0..255: {item!r}"
|
|
401
|
+
)
|
|
402
|
+
return Value(bytes(items), source="bytes_array")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def from_bytes_string(value: str | bytes | bytearray | memoryview) -> Value:
|
|
406
|
+
"""Parse a Python bytes literal string, e.g. "b'\\xde\\xad'"."""
|
|
407
|
+
|
|
408
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
409
|
+
return from_bytes(value)
|
|
410
|
+
if not isinstance(value, str):
|
|
411
|
+
raise HexConvError(f"expected bytes literal string, got {type(value).__name__}")
|
|
412
|
+
try:
|
|
413
|
+
parsed = ast.literal_eval(value.strip())
|
|
414
|
+
except (SyntaxError, ValueError) as exc:
|
|
415
|
+
raise HexConvError(f"invalid bytes literal: {value!r}") from exc
|
|
416
|
+
if not isinstance(parsed, bytes):
|
|
417
|
+
raise HexConvError(f"literal did not evaluate to bytes: {value!r}")
|
|
418
|
+
return Value(parsed, source="bytes_string")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def from_hex(value: str | bytes | bytearray, *, pad_odd: bool = True) -> Value:
|
|
422
|
+
"""Parse a hex string.
|
|
423
|
+
|
|
424
|
+
Common separators are accepted: whitespace, underscores, colons, hyphens,
|
|
425
|
+
commas, and semicolons. A global or per-token 0x prefix is accepted.
|
|
426
|
+
Odd-length hex is left-padded by default, so "abc" becomes "0abc".
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
digits = _parse_hex_digits(value, pad_odd=pad_odd)
|
|
430
|
+
try:
|
|
431
|
+
return Value(bytes.fromhex(digits), source="hex")
|
|
432
|
+
except ValueError as exc:
|
|
433
|
+
raise HexConvError(f"invalid hex string: {value!r}") from exc
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def from_hex_array(
|
|
437
|
+
values: Iterable[str | int],
|
|
438
|
+
*,
|
|
439
|
+
width: int | None = None,
|
|
440
|
+
endian: Endian = "big",
|
|
441
|
+
pad_odd: bool = True,
|
|
442
|
+
) -> Value:
|
|
443
|
+
"""Parse a sequence of hex tokens.
|
|
444
|
+
|
|
445
|
+
String tokens are interpreted as hex bytes. Integer tokens are packed using
|
|
446
|
+
`width` if provided, otherwise their minimal positive byte width.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
_validate_endian(endian)
|
|
450
|
+
if not isinstance(values, Iterable) or isinstance(values, (str, bytes, bytearray)):
|
|
451
|
+
raise HexConvError("expected a sequence of hex tokens")
|
|
452
|
+
|
|
453
|
+
parts: list[bytes] = []
|
|
454
|
+
for item in values:
|
|
455
|
+
if isinstance(item, int) and not isinstance(item, bool):
|
|
456
|
+
parts.append(_int_to_bytes(item, width=width, min_width=1, endian=endian))
|
|
457
|
+
continue
|
|
458
|
+
digits = _parse_hex_digits(item, pad_odd=pad_odd)
|
|
459
|
+
if width is None:
|
|
460
|
+
parts.append(bytes.fromhex(digits))
|
|
461
|
+
else:
|
|
462
|
+
parts.append(
|
|
463
|
+
_int_to_bytes(int(digits or "0", 16), width=width, endian=endian)
|
|
464
|
+
)
|
|
465
|
+
return Value(b"".join(parts), source="hex_array")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def from_hex_int(
|
|
469
|
+
value: str | int,
|
|
470
|
+
*,
|
|
471
|
+
min_width: int = 1,
|
|
472
|
+
width: int | None = None,
|
|
473
|
+
endian: Endian = "big",
|
|
474
|
+
pad_odd: bool = True,
|
|
475
|
+
) -> Value:
|
|
476
|
+
"""Parse a single large hex number.
|
|
477
|
+
|
|
478
|
+
This treats the input as a number, not as a visible byte sequence. Use
|
|
479
|
+
from_hex() when leading zeroes or visible byte order must be preserved.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
483
|
+
return from_int(value, min_width=min_width, width=width, endian=endian)
|
|
484
|
+
if not isinstance(value, str):
|
|
485
|
+
raise HexConvError(f"expected str or int hex number, got {type(value).__name__}")
|
|
486
|
+
digits = _parse_hex_digits(value, pad_odd=pad_odd)
|
|
487
|
+
return from_int(
|
|
488
|
+
int(digits or "0", 16),
|
|
489
|
+
min_width=min_width,
|
|
490
|
+
width=width,
|
|
491
|
+
endian=endian,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def from_int(
|
|
496
|
+
value: int,
|
|
497
|
+
*,
|
|
498
|
+
min_width: int = 1,
|
|
499
|
+
width: int | None = None,
|
|
500
|
+
endian: Endian = "big",
|
|
501
|
+
signed: bool = False,
|
|
502
|
+
) -> Value:
|
|
503
|
+
"""Pack one integer into bytes."""
|
|
504
|
+
|
|
505
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
506
|
+
raise HexConvError(f"expected int, got {type(value).__name__}")
|
|
507
|
+
return Value(
|
|
508
|
+
_int_to_bytes(
|
|
509
|
+
value,
|
|
510
|
+
width=width,
|
|
511
|
+
min_width=min_width,
|
|
512
|
+
endian=endian,
|
|
513
|
+
signed=signed,
|
|
514
|
+
),
|
|
515
|
+
source="int",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def from_int_array(
|
|
520
|
+
values: Iterable[int],
|
|
521
|
+
*,
|
|
522
|
+
width: int | None = 1,
|
|
523
|
+
endian: Endian = "big",
|
|
524
|
+
signed: bool = False,
|
|
525
|
+
) -> Value:
|
|
526
|
+
"""Pack a sequence of integers into bytes.
|
|
527
|
+
|
|
528
|
+
`width=1` matches a byte array. Use `width=None` for minimal-width packing
|
|
529
|
+
per integer, or set a fixed width such as 2, 4, or 8 for word arrays.
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
items = _coerce_int_sequence(values, name="integer array")
|
|
533
|
+
return Value(
|
|
534
|
+
b"".join(
|
|
535
|
+
_int_to_bytes(item, width=width, min_width=1, endian=endian, signed=signed)
|
|
536
|
+
for item in items
|
|
537
|
+
),
|
|
538
|
+
source="int_array",
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def from_text(
|
|
543
|
+
value: str,
|
|
544
|
+
*,
|
|
545
|
+
encoding: str = "ascii",
|
|
546
|
+
errors: str = "strict",
|
|
547
|
+
) -> Value:
|
|
548
|
+
"""Encode raw text to bytes. ASCII is the default for CTF workflows."""
|
|
549
|
+
|
|
550
|
+
if not isinstance(value, str):
|
|
551
|
+
raise HexConvError(f"expected str, got {type(value).__name__}")
|
|
552
|
+
return Value(value.encode(encoding, errors=errors), source="text")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def from_binary(value: str | bytes | bytearray, *, pad_odd: bool = True) -> Value:
|
|
556
|
+
"""Parse a binary bit string into bytes."""
|
|
557
|
+
|
|
558
|
+
bits = _parse_binary_digits(value, pad_odd=pad_odd)
|
|
559
|
+
return Value(
|
|
560
|
+
bytes(int(bits[index : index + 8], 2) for index in range(0, len(bits), 8)),
|
|
561
|
+
source="binary",
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def from_base64(value: str | bytes | bytearray, *, validate: bool = True) -> Value:
|
|
566
|
+
"""Parse standard base64 text."""
|
|
567
|
+
|
|
568
|
+
if isinstance(value, str):
|
|
569
|
+
raw = value.encode("ascii")
|
|
570
|
+
elif isinstance(value, (bytes, bytearray)):
|
|
571
|
+
raw = bytes(value)
|
|
572
|
+
else:
|
|
573
|
+
raise HexConvError(f"expected base64 str or bytes, got {type(value).__name__}")
|
|
574
|
+
try:
|
|
575
|
+
return Value(base64.b64decode(raw, validate=validate), source="base64")
|
|
576
|
+
except (binascii.Error, ValueError) as exc:
|
|
577
|
+
raise HexConvError(f"invalid base64 value: {value!r}") from exc
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _parse(value: Any, format_: Format, **options: Any) -> Value:
|
|
581
|
+
try:
|
|
582
|
+
parser = _INPUTS[format_]
|
|
583
|
+
except KeyError as exc:
|
|
584
|
+
raise HexConvError(f"unsupported input format: {format_!r}") from exc
|
|
585
|
+
return parser(value, **options)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _emit_bytes(value: Value) -> bytes:
|
|
589
|
+
return value.to_bytes()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _emit_bytearray(value: Value) -> bytearray:
|
|
593
|
+
return value.to_bytearray()
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _emit_bytes_string(value: Value) -> str:
|
|
597
|
+
return value.to_bytes_string()
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _emit_bytes_array(value: Value) -> list[int]:
|
|
601
|
+
return value.to_bytes_array()
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _emit_hex_string(value: Value, **options: Any) -> str:
|
|
605
|
+
return value.to_hex(**options)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _emit_hex_array(value: Value, **options: Any) -> list[str]:
|
|
609
|
+
return value.to_hex_array(**options)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _emit_hex_numbers_array(value: Value, **options: Any) -> list[int]:
|
|
613
|
+
return value.to_hex_numbers(**options)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _emit_int(value: Value, **options: Any) -> int:
|
|
617
|
+
return value.to_int(**options)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _emit_int_array(value: Value, **options: Any) -> list[int]:
|
|
621
|
+
return value.to_int_array(**options)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _emit_text(value: Value, **options: Any) -> str:
|
|
625
|
+
return value.to_text(**options)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _emit_binary(value: Value, **options: Any) -> str:
|
|
629
|
+
return value.to_binary(**options)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _emit_base64(value: Value) -> str:
|
|
633
|
+
return value.to_base64()
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
_INPUTS: dict[Format, Callable[..., Value]] = {
|
|
637
|
+
Auto: from_auto,
|
|
638
|
+
Bytes: from_bytes,
|
|
639
|
+
BytesArray: from_bytes_array,
|
|
640
|
+
BytesString: from_bytes_string,
|
|
641
|
+
HexString: from_hex,
|
|
642
|
+
HexArray: from_hex_array,
|
|
643
|
+
HexNumbersArray: from_int_array,
|
|
644
|
+
HexInt: from_hex_int,
|
|
645
|
+
LargeHexNumber: from_hex_int,
|
|
646
|
+
Int: from_int,
|
|
647
|
+
DecimalInt: from_int,
|
|
648
|
+
IntArray: from_int_array,
|
|
649
|
+
DecimalIntArray: from_int_array,
|
|
650
|
+
Text: from_text,
|
|
651
|
+
ASCIIText: from_text,
|
|
652
|
+
RawString: from_text,
|
|
653
|
+
BinaryString: from_binary,
|
|
654
|
+
Base64String: from_base64,
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
_OUTPUTS: dict[Format, Callable[..., Any]] = {
|
|
658
|
+
Bytes: _emit_bytes,
|
|
659
|
+
BytesArray: _emit_bytes_array,
|
|
660
|
+
BytesString: _emit_bytes_string,
|
|
661
|
+
HexString: _emit_hex_string,
|
|
662
|
+
HexArray: _emit_hex_array,
|
|
663
|
+
HexNumbersArray: _emit_hex_numbers_array,
|
|
664
|
+
HexInt: _emit_int,
|
|
665
|
+
LargeHexNumber: _emit_int,
|
|
666
|
+
Int: _emit_int,
|
|
667
|
+
DecimalInt: _emit_int,
|
|
668
|
+
IntArray: _emit_int_array,
|
|
669
|
+
DecimalIntArray: _emit_int_array,
|
|
670
|
+
Text: _emit_text,
|
|
671
|
+
ASCIIText: _emit_text,
|
|
672
|
+
RawString: _emit_text,
|
|
673
|
+
BinaryString: _emit_binary,
|
|
674
|
+
Base64String: _emit_base64,
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _validate_endian(endian: Endian) -> None:
|
|
679
|
+
if endian not in {"big", "little"}:
|
|
680
|
+
raise HexConvError("endian must be 'big' or 'little'")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _validate_width(width: int | None, *, name: str = "width") -> None:
|
|
684
|
+
if width is None:
|
|
685
|
+
return
|
|
686
|
+
if not isinstance(width, int) or isinstance(width, bool) or width <= 0:
|
|
687
|
+
raise HexConvError(f"{name} must be a positive integer or None")
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _chunks(data: bytes, width: int) -> list[bytes]:
|
|
691
|
+
_validate_width(width)
|
|
692
|
+
if width is None: # kept for type-checkers; _validate_width rejects only invalid ints
|
|
693
|
+
raise HexConvError("chunk width cannot be None")
|
|
694
|
+
return [data[index : index + width] for index in range(0, len(data), width)]
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _coerce_int_sequence(values: Any, *, name: str) -> list[int]:
|
|
698
|
+
if isinstance(values, (str, bytes, bytearray, memoryview)):
|
|
699
|
+
raise HexConvError(f"expected {name} as a sequence of integers")
|
|
700
|
+
if not isinstance(values, Iterable):
|
|
701
|
+
raise HexConvError(f"expected {name} as a sequence of integers")
|
|
702
|
+
try:
|
|
703
|
+
items = list(values)
|
|
704
|
+
except TypeError as exc:
|
|
705
|
+
raise HexConvError(f"expected {name} as a sequence of integers") from exc
|
|
706
|
+
if not all(isinstance(item, int) and not isinstance(item, bool) for item in items):
|
|
707
|
+
raise HexConvError(f"expected {name} as a sequence of integers")
|
|
708
|
+
return items
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _parse_hex_digits(value: Any, *, pad_odd: bool) -> str:
|
|
712
|
+
if isinstance(value, (bytes, bytearray)):
|
|
713
|
+
try:
|
|
714
|
+
text = bytes(value).decode("ascii")
|
|
715
|
+
except UnicodeDecodeError as exc:
|
|
716
|
+
raise HexConvError("hex bytes must be ASCII text") from exc
|
|
717
|
+
elif isinstance(value, str):
|
|
718
|
+
text = value
|
|
719
|
+
else:
|
|
720
|
+
raise HexConvError(f"expected hex string, got {type(value).__name__}")
|
|
721
|
+
|
|
722
|
+
text = text.strip()
|
|
723
|
+
digits = _HEX_PREFIX.sub("", text)
|
|
724
|
+
digits = _HEX_SEPARATORS.sub("", digits)
|
|
725
|
+
if not _HEX_CHARS.fullmatch(digits):
|
|
726
|
+
raise HexConvError(f"invalid hex characters in {value!r}")
|
|
727
|
+
if len(digits) % 2:
|
|
728
|
+
if not pad_odd:
|
|
729
|
+
raise HexConvError(f"hex string has odd length: {value!r}")
|
|
730
|
+
digits = "0" + digits
|
|
731
|
+
return digits
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _parse_binary_digits(value: Any, *, pad_odd: bool) -> str:
|
|
735
|
+
if isinstance(value, (bytes, bytearray)):
|
|
736
|
+
try:
|
|
737
|
+
text = bytes(value).decode("ascii")
|
|
738
|
+
except UnicodeDecodeError as exc:
|
|
739
|
+
raise HexConvError("binary bytes must be ASCII text") from exc
|
|
740
|
+
elif isinstance(value, str):
|
|
741
|
+
text = value
|
|
742
|
+
else:
|
|
743
|
+
raise HexConvError(f"expected binary string, got {type(value).__name__}")
|
|
744
|
+
|
|
745
|
+
bits = _BINARY_PREFIX.sub("", text.strip())
|
|
746
|
+
bits = _BINARY_SEPARATORS.sub("", bits)
|
|
747
|
+
if not _BINARY_CHARS.fullmatch(bits):
|
|
748
|
+
raise HexConvError(f"invalid binary characters in {value!r}")
|
|
749
|
+
remainder = len(bits) % 8
|
|
750
|
+
if remainder:
|
|
751
|
+
if not pad_odd:
|
|
752
|
+
raise HexConvError(f"binary string is not byte-aligned: {value!r}")
|
|
753
|
+
bits = ("0" * (8 - remainder)) + bits
|
|
754
|
+
return bits
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _looks_like_bytes_literal(value: str) -> bool:
|
|
758
|
+
return (
|
|
759
|
+
len(value) >= 3
|
|
760
|
+
and value[0] in {"b", "B"}
|
|
761
|
+
and value[1] in {"'", '"'}
|
|
762
|
+
and value[-1] == value[1]
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _looks_like_binary(value: str) -> bool:
|
|
767
|
+
stripped = value.strip()
|
|
768
|
+
if not stripped:
|
|
769
|
+
return False
|
|
770
|
+
return stripped.lower().startswith("0b")
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _looks_like_hex(value: str) -> bool:
|
|
774
|
+
stripped = value.strip()
|
|
775
|
+
if not stripped:
|
|
776
|
+
return False
|
|
777
|
+
if "0x" in stripped.lower():
|
|
778
|
+
return True
|
|
779
|
+
if _HEX_SEPARATORS.search(stripped):
|
|
780
|
+
try:
|
|
781
|
+
_parse_hex_digits(stripped, pad_odd=True)
|
|
782
|
+
except HexConvError:
|
|
783
|
+
return False
|
|
784
|
+
return True
|
|
785
|
+
return bool(_HEX_CHARS.fullmatch(stripped) and len(stripped) >= 2)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _int_to_bytes(
|
|
789
|
+
value: int,
|
|
790
|
+
*,
|
|
791
|
+
width: int | None = None,
|
|
792
|
+
min_width: int = 1,
|
|
793
|
+
endian: Endian = "big",
|
|
794
|
+
signed: bool = False,
|
|
795
|
+
) -> bytes:
|
|
796
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
797
|
+
raise HexConvError(f"expected int, got {type(value).__name__}")
|
|
798
|
+
_validate_width(width)
|
|
799
|
+
_validate_width(min_width, name="min_width")
|
|
800
|
+
_validate_endian(endian)
|
|
801
|
+
if not signed and value < 0:
|
|
802
|
+
raise HexConvError("negative integers require signed=True")
|
|
803
|
+
|
|
804
|
+
if width is not None:
|
|
805
|
+
try:
|
|
806
|
+
return value.to_bytes(width, endian, signed=signed)
|
|
807
|
+
except OverflowError as exc:
|
|
808
|
+
raise HexConvError(f"integer {value!r} does not fit in {width} bytes") from exc
|
|
809
|
+
|
|
810
|
+
if signed:
|
|
811
|
+
return _signed_int_to_minimal_bytes(value, min_width=min_width, endian=endian)
|
|
812
|
+
|
|
813
|
+
needed = max(min_width, 1, (value.bit_length() + 7) // 8)
|
|
814
|
+
try:
|
|
815
|
+
return value.to_bytes(needed, endian, signed=False)
|
|
816
|
+
except OverflowError as exc:
|
|
817
|
+
raise HexConvError(f"could not pack integer {value!r}") from exc
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _signed_int_to_minimal_bytes(value: int, *, min_width: int, endian: Endian) -> bytes:
|
|
821
|
+
candidate = max(min_width, 1, (abs(value).bit_length() + 8) // 8)
|
|
822
|
+
for width in range(candidate, candidate + 2):
|
|
823
|
+
try:
|
|
824
|
+
return value.to_bytes(width, endian, signed=True)
|
|
825
|
+
except OverflowError:
|
|
826
|
+
continue
|
|
827
|
+
width = candidate + 2
|
|
828
|
+
while True:
|
|
829
|
+
try:
|
|
830
|
+
return value.to_bytes(width, endian, signed=True)
|
|
831
|
+
except OverflowError:
|
|
832
|
+
width += 1
|
hexconv/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hexconv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pythonic CTF-friendly conversions between bytes, hex, integers, arrays, and text.
|
|
5
|
+
Author: hexconv contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ctf,hex,bytes,conversion,pwn,crypto
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Requires-Dist: twine; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# hexconv
|
|
31
|
+
|
|
32
|
+
`hexconv` is a small, dependency-free Python library for CTF-style conversions between
|
|
33
|
+
bytes, byte arrays, hex strings, hex arrays, integers, integer arrays, bytes literals,
|
|
34
|
+
binary strings, base64, and raw text.
|
|
35
|
+
|
|
36
|
+
The main idea is: explicitly state what you have, then ask for what you want.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import hexconv as hx
|
|
40
|
+
|
|
41
|
+
hx.from_hex("dead beef").to_bytes()
|
|
42
|
+
# b'\xde\xad\xbe\xef'
|
|
43
|
+
|
|
44
|
+
hx.from_hex("dead beef").bytes
|
|
45
|
+
# b'\xde\xad\xbe\xef'
|
|
46
|
+
|
|
47
|
+
hx.from_bytes_array([0xde, 0xad, 0xbe, 0xef]).to_hex()
|
|
48
|
+
# 'deadbeef'
|
|
49
|
+
|
|
50
|
+
hx.from_bytes_array([0xde, 0xad, 0xbe, 0xef]).hex
|
|
51
|
+
# 'deadbeef'
|
|
52
|
+
|
|
53
|
+
hx.from_text("flag").to_hex_array(prefix=True)
|
|
54
|
+
# ['0x66', '0x6c', '0x61', '0x67']
|
|
55
|
+
|
|
56
|
+
hx.from_int(0xdeadbeef).to_bytes()
|
|
57
|
+
# b'\xde\xad\xbe\xef'
|
|
58
|
+
|
|
59
|
+
hx.from_int_array([0x1234, 0x5678], width=2).to_hex_array(width=2)
|
|
60
|
+
# ['1234', '5678']
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Why explicit source helpers?
|
|
64
|
+
|
|
65
|
+
Some inputs are impossible to infer safely:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
"face"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That could be ASCII text (`66 61 63 65`) or hex bytes (`fa ce`). So `hexconv`
|
|
72
|
+
keeps the safe path explicit:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
hx.from_text("face").to_hex()
|
|
76
|
+
# '66616365'
|
|
77
|
+
|
|
78
|
+
hx.from_hex("face").to_bytes()
|
|
79
|
+
# b'\xfa\xce'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If you do want convenience heuristics, use `from_auto`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
hx.from_auto("0xdeadbeef").to_int()
|
|
86
|
+
# 3735928559
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Converter API
|
|
90
|
+
|
|
91
|
+
If you prefer a reusable converter object, use marker classes:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
conv = hx.Converter(hx.BytesArray, hx.HexArray)
|
|
95
|
+
conv([0xde, 0xad, 0xbe, 0xef])
|
|
96
|
+
# ['de', 'ad', 'be', 'ef']
|
|
97
|
+
|
|
98
|
+
hx.convert("flag", from_=hx.Text, to=hx.HexString)
|
|
99
|
+
# '666c6167'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
For conversions with options, pass input and output option dictionaries:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
conv = hx.Converter(
|
|
106
|
+
hx.DecimalIntArray,
|
|
107
|
+
hx.HexArray,
|
|
108
|
+
input_options={"width": 2},
|
|
109
|
+
output_options={"width": 2, "prefix": True},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
conv([4660, 22136])
|
|
113
|
+
# ['0x1234', '0x5678']
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Supported source helpers
|
|
117
|
+
|
|
118
|
+
- `from_bytes(value)`
|
|
119
|
+
- `from_bytes_array(values)`
|
|
120
|
+
- `from_bytes_string(value)` for strings like `"b'\\xde\\xad'"`
|
|
121
|
+
- `from_hex(value)`
|
|
122
|
+
- `from_hex_array(values)`
|
|
123
|
+
- `from_hex_int(value)`
|
|
124
|
+
- `from_int(value)`
|
|
125
|
+
- `from_int_array(values, width=...)`
|
|
126
|
+
- `from_text(value)`
|
|
127
|
+
- `from_binary(value)`
|
|
128
|
+
- `from_base64(value)`
|
|
129
|
+
- `from_auto(value)`
|
|
130
|
+
|
|
131
|
+
## Common output methods
|
|
132
|
+
|
|
133
|
+
- `to_bytes()`
|
|
134
|
+
- `to_bytearray()`
|
|
135
|
+
- `to_bytes_array()`
|
|
136
|
+
- `to_bytes_string()`
|
|
137
|
+
- `to_hex(sep="", prefix=False, uppercase=False)`
|
|
138
|
+
- `to_hex_array(width=1, prefix=False, uppercase=False)`
|
|
139
|
+
- `to_hex_numbers(width=1)`
|
|
140
|
+
- `to_int(endian="big", signed=False)`
|
|
141
|
+
- `to_int_array(width=1, endian="big", signed=False)`
|
|
142
|
+
- `to_text(encoding="ascii", errors="strict")`
|
|
143
|
+
- `to_binary(sep="")`
|
|
144
|
+
- `to_base64()`
|
|
145
|
+
- `to(format_marker, **options)`
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hexconv/__init__.py,sha256=DQgu_mkaK24jRrxQtAU07eQgLvsp-2k6Mg-bWxVw4WM,1267
|
|
2
|
+
hexconv/_core.py,sha256=YexqAQOTJkcidsakcEohZlt_FzmQwDmOQb17QZ_xOiA,24291
|
|
3
|
+
hexconv/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
|
+
hexconv-0.1.0.dist-info/licenses/LICENSE,sha256=jLvnEroKuznlNa13aVTgp4usZ2rEI8v1Em1npsUYGso,1078
|
|
5
|
+
hexconv-0.1.0.dist-info/METADATA,sha256=SG996wA-DIw7dIh7KlR-qRfRcZk2Hez2M81OkUPejF8,3711
|
|
6
|
+
hexconv-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
7
|
+
hexconv-0.1.0.dist-info/top_level.txt,sha256=d5UALxOrOalfcPc_Y7Mc_4E2gGPvGvV-92009p_acB0,8
|
|
8
|
+
hexconv-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hexconv contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hexconv
|