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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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