hexicon 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ *.whl
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # IDE
16
+ .vscode/
17
+ .idea/
18
+ *.swp
19
+ *.swo
20
+
21
+ # OS
22
+ .DS_Store
23
+ Thumbs.db
hexicon-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 buddha
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.
hexicon-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: hexicon
3
+ Version: 0.1.0
4
+ Summary: Visual fingerprints for network addresses — lossless, bit-accurate identicons for IPv6, IPv4, and MAC addresses.
5
+ Project-URL: Homepage, https://github.com/phatbuddha/hexicon
6
+ Project-URL: Repository, https://github.com/phatbuddha/hexicon
7
+ Project-URL: Issues, https://github.com/phatbuddha/hexicon/issues
8
+ Author: phatbuddha
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: identicon,ipv4,ipv6,mac,network,terminal,visualization
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: System :: Networking
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Hexicon
30
+
31
+ **Visual fingerprints for network addresses.**
32
+
33
+ A terminal-first tool to turn IPv6, IPv4, and MAC addresses into bit-accurate, lossless, human-readable visual patterns. Visualize structured binary data in the terminal without losing bit order or structure.
34
+ Or identicons, but for network addresses.
35
+
36
+ ## Why?
37
+
38
+ Network addresses are hard to compare at a glance.
39
+ Hexicon lets you compare addresses in logs visually, get an intuitive feel for address distribution, and simplify debugging.
40
+ Or just create pixel art to represent your addresses.
41
+
42
+ ---
43
+
44
+ ## Features
45
+
46
+ - Supports **IPv6, IPv4, and MAC addresses**
47
+ - Bit-accurate rendering. No hashing, no data loss
48
+ - Semantic structure (network/host, OUI/NIC, octets)
49
+ - Terminal-friendly block rendering with multiple layouts: grid, split, inline, barcode
50
+ - JSON output for programmatic use
51
+ - Zero dependencies. Python stdlib only
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install hexicon
57
+ ```
58
+
59
+ ## How it works
60
+
61
+ Hexicon converts addresses into bit-accurate patterns through a deterministic, lossless pipeline. No hashing.
62
+
63
+ Addresses are normalised to their canonical form, converted to a bitstream, grouped into scanlines of `width` bits, paired as adjacent scanlines (top/bottom), and mapped to half-block characters.
64
+ Each cell (half a block) represents a single bit.
65
+
66
+ ## Usage
67
+
68
+ ### CLI
69
+
70
+ ```bash
71
+ hexicon 2001:db8::1
72
+
73
+ hexicon --random
74
+
75
+ hexicon --random --type ipv6 --layout barcode --show-bits --output text
76
+
77
+ # Read from stdin
78
+ echo "::1" | hexicon -
79
+ ```
80
+
81
+ ### Python API
82
+
83
+ ```python
84
+ from hexicon import render_address, addr_to_nibbles
85
+
86
+ # Render an address to text
87
+ print(render_address("2001:db8::1"))
88
+
89
+ # Get structured data
90
+ schema = addr_to_nibbles("2001:db8::1")
91
+ print(schema.type) # "ipv6"
92
+ print(schema.parts) # [Part(name="net", ...), Part(name="host", ...)]
93
+
94
+ # JSON output
95
+ print(render_address("::1", output="json"))
96
+ ```
97
+
98
+ ## Options
99
+
100
+ | Flag | Values | Default | Description |
101
+ |------|--------|---------|-------------|
102
+ | `--type` | `auto`, `ipv6`, `ipv4`, `mac` | `auto` | Address type |
103
+ | `--layout` | `auto`, `grid`, `split`, `inline`, `barcode` | `auto` | Layout mode |
104
+ | `--width` | `N` or `auto` | `auto` | Bits per scanline row |
105
+ | `--scale` | `N` | `1` | Vertical scaling factor |
106
+ | `--invert` | flag | — | Invert filled/empty pixels |
107
+ | `--output` | `text`, `json` | `text` | Output format |
108
+ | `--random` | flag | — | Generate a random address |
109
+ | `--show-bits` | flag | — | Debug: print bit values |
110
+ | `--no-newline` | flag | — | Suppress trailing newline |
111
+
112
+ ## Layouts
113
+
114
+ ### Split
115
+ Default view. Semantic separation of address parts. For IPv6: network │ host. For MAC: OUI │ NIC.
116
+
117
+ ### Grid
118
+ Vertical stacked version of split view.
119
+
120
+ ### Inline
121
+ Single 1-height continuous strip. Good for logs or embedding.
122
+
123
+ ### Barcode
124
+ Compact 2-height view.
125
+
126
+ ## JSON Output
127
+
128
+ Returns a JSON object with the following fields:
129
+
130
+ ```json
131
+ {
132
+ "type": "ipv6",
133
+ "address": "2001:db8::1",
134
+ "parts": [
135
+ {
136
+ "name": "net",
137
+ "rows": ["▀ ▀▄", "..."],
138
+ "bit_range": [0, 64],
139
+ "label": "network"
140
+ }
141
+ ]
142
+ }
143
+ ```
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,119 @@
1
+ # Hexicon
2
+
3
+ **Visual fingerprints for network addresses.**
4
+
5
+ A terminal-first tool to turn IPv6, IPv4, and MAC addresses into bit-accurate, lossless, human-readable visual patterns. Visualize structured binary data in the terminal without losing bit order or structure.
6
+ Or identicons, but for network addresses.
7
+
8
+ ## Why?
9
+
10
+ Network addresses are hard to compare at a glance.
11
+ Hexicon lets you compare addresses in logs visually, get an intuitive feel for address distribution, and simplify debugging.
12
+ Or just create pixel art to represent your addresses.
13
+
14
+ ---
15
+
16
+ ## Features
17
+
18
+ - Supports **IPv6, IPv4, and MAC addresses**
19
+ - Bit-accurate rendering. No hashing, no data loss
20
+ - Semantic structure (network/host, OUI/NIC, octets)
21
+ - Terminal-friendly block rendering with multiple layouts: grid, split, inline, barcode
22
+ - JSON output for programmatic use
23
+ - Zero dependencies. Python stdlib only
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install hexicon
29
+ ```
30
+
31
+ ## How it works
32
+
33
+ Hexicon converts addresses into bit-accurate patterns through a deterministic, lossless pipeline. No hashing.
34
+
35
+ Addresses are normalised to their canonical form, converted to a bitstream, grouped into scanlines of `width` bits, paired as adjacent scanlines (top/bottom), and mapped to half-block characters.
36
+ Each cell (half a block) represents a single bit.
37
+
38
+ ## Usage
39
+
40
+ ### CLI
41
+
42
+ ```bash
43
+ hexicon 2001:db8::1
44
+
45
+ hexicon --random
46
+
47
+ hexicon --random --type ipv6 --layout barcode --show-bits --output text
48
+
49
+ # Read from stdin
50
+ echo "::1" | hexicon -
51
+ ```
52
+
53
+ ### Python API
54
+
55
+ ```python
56
+ from hexicon import render_address, addr_to_nibbles
57
+
58
+ # Render an address to text
59
+ print(render_address("2001:db8::1"))
60
+
61
+ # Get structured data
62
+ schema = addr_to_nibbles("2001:db8::1")
63
+ print(schema.type) # "ipv6"
64
+ print(schema.parts) # [Part(name="net", ...), Part(name="host", ...)]
65
+
66
+ # JSON output
67
+ print(render_address("::1", output="json"))
68
+ ```
69
+
70
+ ## Options
71
+
72
+ | Flag | Values | Default | Description |
73
+ |------|--------|---------|-------------|
74
+ | `--type` | `auto`, `ipv6`, `ipv4`, `mac` | `auto` | Address type |
75
+ | `--layout` | `auto`, `grid`, `split`, `inline`, `barcode` | `auto` | Layout mode |
76
+ | `--width` | `N` or `auto` | `auto` | Bits per scanline row |
77
+ | `--scale` | `N` | `1` | Vertical scaling factor |
78
+ | `--invert` | flag | — | Invert filled/empty pixels |
79
+ | `--output` | `text`, `json` | `text` | Output format |
80
+ | `--random` | flag | — | Generate a random address |
81
+ | `--show-bits` | flag | — | Debug: print bit values |
82
+ | `--no-newline` | flag | — | Suppress trailing newline |
83
+
84
+ ## Layouts
85
+
86
+ ### Split
87
+ Default view. Semantic separation of address parts. For IPv6: network │ host. For MAC: OUI │ NIC.
88
+
89
+ ### Grid
90
+ Vertical stacked version of split view.
91
+
92
+ ### Inline
93
+ Single 1-height continuous strip. Good for logs or embedding.
94
+
95
+ ### Barcode
96
+ Compact 2-height view.
97
+
98
+ ## JSON Output
99
+
100
+ Returns a JSON object with the following fields:
101
+
102
+ ```json
103
+ {
104
+ "type": "ipv6",
105
+ "address": "2001:db8::1",
106
+ "parts": [
107
+ {
108
+ "name": "net",
109
+ "rows": ["▀ ▀▄", "..."],
110
+ "bit_range": [0, 64],
111
+ "label": "network"
112
+ }
113
+ ]
114
+ }
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hexicon"
7
+ version = "0.1.0"
8
+ description = "Visual fingerprints for network addresses — lossless, bit-accurate identicons for IPv6, IPv4, and MAC addresses."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "phatbuddha" },
14
+ ]
15
+ keywords = ["ipv6", "ipv4", "mac", "identicon", "network", "visualization", "terminal"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: System Administrators",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: System :: Networking",
30
+ "Topic :: Utilities",
31
+ ]
32
+
33
+ [project.scripts]
34
+ hexicon = "hexicon.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/phatbuddha/hexicon"
38
+ Repository = "https://github.com/phatbuddha/hexicon"
39
+ Issues = "https://github.com/phatbuddha/hexicon/issues"
@@ -0,0 +1,43 @@
1
+ """Hexicon – visual fingerprints for network addresses."""
2
+
3
+ from hexicon._core import (
4
+ CHARSET_DEFAULT,
5
+ DEFAULT_WIDTH,
6
+ LAYOUT_CHOICES,
7
+ OUTPUT_CHOICES,
8
+ TYPE_CHOICES,
9
+ AddressSchema,
10
+ Part,
11
+ addr_to_nibbles,
12
+ detect_type,
13
+ format_json,
14
+ format_text,
15
+ int_to_nibbles,
16
+ nibbles_to_bits,
17
+ parse_addr,
18
+ random_addr,
19
+ render_address,
20
+ render_grid,
21
+ )
22
+
23
+ __all__ = [
24
+ "CHARSET_DEFAULT",
25
+ "DEFAULT_WIDTH",
26
+ "LAYOUT_CHOICES",
27
+ "OUTPUT_CHOICES",
28
+ "TYPE_CHOICES",
29
+ "AddressSchema",
30
+ "Part",
31
+ "addr_to_nibbles",
32
+ "detect_type",
33
+ "format_json",
34
+ "format_text",
35
+ "int_to_nibbles",
36
+ "nibbles_to_bits",
37
+ "parse_addr",
38
+ "random_addr",
39
+ "render_address",
40
+ "render_grid",
41
+ ]
42
+
43
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running hexicon as ``python -m hexicon``"""
2
+
3
+ from hexicon.cli import main
4
+
5
+ main()
@@ -0,0 +1,304 @@
1
+ """Core rendering logic for hexicon"""
2
+
3
+ import dataclasses
4
+ import ipaddress
5
+ import json
6
+ import random
7
+ import re
8
+ import sys
9
+
10
+ # ── Charset ───────────────────────────────────────────────────────────────────
11
+
12
+ CHARSET_DEFAULT = " ▄▀█" # index: top*2 + bottom → 0=' ' 1='▄' 2='▀' 3='█'
13
+
14
+ # ── Renderer ──────────────────────────────────────────────────────────────────
15
+
16
+ def nibbles_to_bits(nibbles):
17
+ """Convert a list of nibbles to a flat list of bits, MSB first."""
18
+ bits = []
19
+ for n in nibbles:
20
+ for i in (3, 2, 1, 0):
21
+ bits.append((n >> i) & 1)
22
+ return bits
23
+
24
+
25
+ def int_to_nibbles(n, count):
26
+ """Extract count nibbles from integer n, MSB first"""
27
+ return [
28
+ (n >> (4 * shift)) & 0xF
29
+ for shift in reversed(range(count))
30
+ ]
31
+
32
+
33
+ # ── Schema types ──────────────────────────────────────────────────────────────
34
+
35
+ @dataclasses.dataclass
36
+ class Part:
37
+ name: str
38
+ nibbles: list
39
+ bit_range: list
40
+ label: str
41
+
42
+
43
+ @dataclasses.dataclass
44
+ class AddressSchema:
45
+ type: str
46
+ input: str
47
+ nibbles: list
48
+ parts: list
49
+
50
+
51
+ # ── Address → nibbles (unified schema) ────────────────────────────────────────
52
+
53
+ _MAC_RE = re.compile(
54
+ r'^([0-9a-fA-F]{2})[:\-]'
55
+ r'([0-9a-fA-F]{2})[:\-]'
56
+ r'([0-9a-fA-F]{2})[:\-]'
57
+ r'([0-9a-fA-F]{2})[:\-]'
58
+ r'([0-9a-fA-F]{2})[:\-]'
59
+ r'([0-9a-fA-F]{2})$'
60
+ )
61
+
62
+
63
+ def detect_type(raw):
64
+ """Guess address type from string format"""
65
+ raw = raw.strip()
66
+ if _MAC_RE.match(raw):
67
+ return "mac"
68
+ try:
69
+ ipaddress.IPv4Address(raw)
70
+ return "ipv4"
71
+ except ValueError:
72
+ pass
73
+ try:
74
+ ipaddress.IPv6Address(raw)
75
+ return "ipv6"
76
+ except ValueError:
77
+ pass
78
+ raise ValueError(f"Cannot detect address type for: {raw!r}")
79
+
80
+
81
+ def _parse_ip_nibbles(raw, cls, nibble_count):
82
+ """Helper for IPv4/IPv6: parses, normalises, extracts nibbles"""
83
+ obj = cls(raw)
84
+ return str(obj), int_to_nibbles(int(obj), nibble_count)
85
+
86
+
87
+ def _parse_ipv6(raw):
88
+ normalised, all_nibbles = _parse_ip_nibbles(raw, ipaddress.IPv6Address, 32)
89
+ return AddressSchema(
90
+ type="ipv6",
91
+ input=normalised,
92
+ nibbles=all_nibbles,
93
+ parts=[
94
+ Part(name="net", nibbles=all_nibbles[:16], bit_range=[0, 64], label="network"),
95
+ Part(name="host", nibbles=all_nibbles[16:], bit_range=[64, 128], label="host"),
96
+ ],
97
+ )
98
+
99
+
100
+ def _parse_ipv4(raw):
101
+ normalised, all_nibbles = _parse_ip_nibbles(raw, ipaddress.IPv4Address, 8)
102
+ parts = [
103
+ Part(
104
+ name=f"octet{i}",
105
+ nibbles=all_nibbles[i*2 : i*2+2],
106
+ bit_range=[i * 8, (i + 1) * 8],
107
+ label=f"octet {i}",
108
+ )
109
+ for i in range(4)
110
+ ]
111
+ return AddressSchema(
112
+ type="ipv4",
113
+ input=normalised,
114
+ nibbles=all_nibbles,
115
+ parts=parts,
116
+ )
117
+
118
+
119
+ def _parse_mac(raw):
120
+ m = _MAC_RE.match(raw)
121
+ if not m:
122
+ raise ValueError(f"Invalid MAC address: {raw!r}")
123
+ octets = [int(g, 16) for g in m.groups()]
124
+ n = 0
125
+ for b in octets:
126
+ n = (n << 8) | b
127
+ all_nibbles = int_to_nibbles(n, 12)
128
+ normalised = ":".join(f"{b:02x}" for b in octets)
129
+ return AddressSchema(
130
+ type="mac",
131
+ input=normalised,
132
+ nibbles=all_nibbles,
133
+ parts=[
134
+ Part(name="oui", nibbles=all_nibbles[:6], bit_range=[0, 24], label="OUI"),
135
+ Part(name="nic", nibbles=all_nibbles[6:], bit_range=[24, 48], label="NIC"),
136
+ ],
137
+ )
138
+
139
+
140
+ _PARSERS = {"ipv6": _parse_ipv6, "ipv4": _parse_ipv4, "mac": _parse_mac}
141
+
142
+
143
+ def addr_to_nibbles(addr, addr_type="auto"):
144
+ """Parses an addr string and return an AddressSchema."""
145
+ raw = addr.strip()
146
+
147
+ if addr_type == "auto":
148
+ addr_type = detect_type(raw)
149
+
150
+ if addr_type not in _PARSERS:
151
+ raise ValueError(f"Unknown address type: {addr_type!r}")
152
+ return _PARSERS[addr_type](raw)
153
+
154
+
155
+ # ── Constants ─────────────────────────────────────────────────────────────────
156
+
157
+ DEFAULT_WIDTH = {"ipv6": 8, "ipv4": 4, "mac": 6}
158
+ TYPE_CHOICES = ["auto", "ipv6", "ipv4", "mac"]
159
+ LAYOUT_CHOICES = ["auto", "grid", "split", "inline", "barcode"]
160
+ OUTPUT_CHOICES = ["text", "json"]
161
+
162
+
163
+ def render_grid(nibbles, width, charset, invert=False):
164
+ """Render nibbles as scanline pairs using halfblock characters.
165
+
166
+ Converts nibbles to a flat bitstream, groups into scanlines of
167
+ `width` bits, pairs adjacent scanlines (top-bottom), and maps
168
+ each (top_bit, bottom_bit) pair to a halfblock character.
169
+ """
170
+ bits = nibbles_to_bits(nibbles)
171
+
172
+ # Pad to fill complete scanline pairs
173
+ pair_size = width * 2
174
+ remainder = len(bits) % pair_size
175
+ if remainder:
176
+ bits = bits + [0] * (pair_size - remainder)
177
+
178
+ # Group into scanlines
179
+ scanlines = [bits[i:i+width] for i in range(0, len(bits), width)]
180
+
181
+ # Pad to even number of scanlines
182
+ if len(scanlines) % 2:
183
+ scanlines.append([0] * width)
184
+
185
+ rows = []
186
+ for i in range(0, len(scanlines), 2):
187
+ top = scanlines[i]
188
+ bot = scanlines[i + 1]
189
+ row = ''
190
+ for t, b in zip(top, bot):
191
+ idx = t * 2 + b
192
+ row += charset[3 - idx if invert else idx]
193
+ rows.append(row)
194
+ return rows
195
+
196
+ # ── Formatter ─────────────────────────────────────────────────────────────────
197
+
198
+ def format_text(part_rows, layout, scale):
199
+ """
200
+ part_rows: list of (part_name, [rendered_row_strings])
201
+ layout: "split" → side-by-side for 2-part addresses, else stacked
202
+ """
203
+ lines = []
204
+ if layout == "split" and len(part_rows) == 2:
205
+ # side-by-side
206
+ left = part_rows[0][1]
207
+ right = part_rows[1][1]
208
+ left_w = len(left[0]) if left else 0
209
+ right_w = len(right[0]) if right else 0
210
+ max_len = max(len(left), len(right))
211
+ left = left + [" " * left_w] * (max_len - len(left))
212
+ right = right + [" " * right_w] * (max_len - len(right))
213
+ for l, r in zip(left, right):
214
+ row = l + " " + r
215
+ for _ in range(scale):
216
+ lines.append(row)
217
+ else:
218
+ for _name, rows in part_rows:
219
+ for row in rows:
220
+ for _ in range(scale):
221
+ lines.append(row)
222
+ return '\n'.join(lines)
223
+
224
+
225
+ def format_json(schema, part_rows):
226
+ parts_out = []
227
+ for name, rows in part_rows:
228
+ entry = {"name": name, "rows": rows}
229
+ part = next((p for p in schema.parts if p.name == name), None)
230
+ if part:
231
+ entry["bit_range"] = part.bit_range
232
+ entry["label"] = part.label
233
+ parts_out.append(entry)
234
+ return json.dumps({
235
+ "type": schema.type,
236
+ "address": schema.input,
237
+ "parts": parts_out,
238
+ }, ensure_ascii=False)
239
+
240
+ # ── Input handling ────────────────────────────────────────────────────────────
241
+
242
+ def parse_addr(raw, addr_type="auto"):
243
+ """Validate a raw address string. Returns (normalised_str, resolved_type)"""
244
+ raw = raw.strip()
245
+ if addr_type == "auto":
246
+ addr_type = detect_type(raw)
247
+ # full parse to validate
248
+ addr_to_nibbles(raw, addr_type)
249
+ return raw, addr_type
250
+
251
+
252
+ def random_addr(addr_type="ipv6"):
253
+ """Generate a random address string of the given type"""
254
+ if addr_type == "ipv6":
255
+ return str(ipaddress.IPv6Address(random.getrandbits(128)))
256
+ if addr_type == "ipv4":
257
+ return str(ipaddress.IPv4Address(random.getrandbits(32)))
258
+ if addr_type == "mac":
259
+ octets = [random.randint(0, 255) for _ in range(6)]
260
+ return ":".join(f"{b:02x}" for b in octets)
261
+ raise ValueError(f"Cannot generate random address for type: {addr_type!r}")
262
+
263
+
264
+ # ── Core render pipeline ──────────────────────────────────────────────────────
265
+
266
+ def render_address(addr, addr_type="auto", charset=CHARSET_DEFAULT, invert=False,
267
+ scale=1, layout="auto", width="auto", output="text", show_bits=False):
268
+ schema = addr_to_nibbles(addr, addr_type)
269
+ atype = schema.type
270
+
271
+ # Resolve layout
272
+ if layout == "auto":
273
+ layout = "split" if len(schema.parts) == 2 else "grid"
274
+
275
+ # Resolve width
276
+ if width == "auto":
277
+ if layout == "inline":
278
+ total_bits = len(schema.nibbles) * 4
279
+ width = total_bits // 2
280
+ elif layout == "barcode":
281
+ total_bits = len(schema.nibbles) * 4
282
+ width = max(total_bits // 4, 1)
283
+ else:
284
+ width = DEFAULT_WIDTH[atype]
285
+
286
+ if show_bits:
287
+ for part in schema.parts:
288
+ bits = nibbles_to_bits(part.nibbles)
289
+ print(f"{part.name} bits: {''.join(str(b) for b in bits)}", file=sys.stderr)
290
+
291
+ # inline/barcode, render all nibbles as one block
292
+ if layout in ("inline", "barcode"):
293
+ rows = render_grid(schema.nibbles, width, charset, invert=invert)
294
+ part_rows = [("all", rows)]
295
+ else:
296
+ part_rows = []
297
+ for part in schema.parts:
298
+ rows = render_grid(part.nibbles, width, charset, invert=invert)
299
+ part_rows.append((part.name, rows))
300
+
301
+ if output == "json":
302
+ return format_json(schema, part_rows)
303
+ else:
304
+ return format_text(part_rows, layout, scale)
@@ -0,0 +1,145 @@
1
+ """Command-line interface for hexicon"""
2
+
3
+ import argparse
4
+ import io
5
+ import sys
6
+
7
+ from hexicon._core import (
8
+ LAYOUT_CHOICES,
9
+ OUTPUT_CHOICES,
10
+ TYPE_CHOICES,
11
+ parse_addr,
12
+ random_addr,
13
+ render_address,
14
+ )
15
+
16
+
17
+ def build_parser():
18
+ parser = argparse.ArgumentParser(
19
+ prog="hexicon",
20
+ description="Render a human-readable identicon for an IPv6 address.",
21
+ )
22
+
23
+ src = parser.add_mutually_exclusive_group(required=True)
24
+ src.add_argument(
25
+ "address", nargs="?", metavar="ADDRESS",
26
+ help="Address to render (IPv6, IPv4, or MAC). Use '-' to read from stdin.",
27
+ )
28
+ src.add_argument(
29
+ "--random", action="store_true",
30
+ help="Generate and render a random address.",
31
+ )
32
+
33
+ parser.add_argument(
34
+ "--type", choices=TYPE_CHOICES, default="auto",
35
+ dest="addr_type",
36
+ help="Address type (default: auto-detect).",
37
+ )
38
+
39
+ parser.add_argument(
40
+ "--invert", action="store_true",
41
+ help="Invert filled and empty pixels.",
42
+ )
43
+ parser.add_argument(
44
+ "--scale", type=int, default=1, metavar="N",
45
+ help="Repeat each row N times (default: 1).",
46
+ )
47
+ parser.add_argument(
48
+ "--layout", choices=LAYOUT_CHOICES,
49
+ default="auto",
50
+ help="Layout mode (default: auto). grid=stacked, split=side-by-side, "
51
+ "inline=single row, barcode=wide strip.",
52
+ )
53
+ parser.add_argument(
54
+ "--width", default="auto", metavar="N|auto",
55
+ help="Bits per scanline row (default: auto, uses type-appropriate width).",
56
+ )
57
+ parser.add_argument(
58
+ "--output", choices=OUTPUT_CHOICES, default="text",
59
+ help="Output format (default: text).",
60
+ )
61
+ parser.add_argument(
62
+ "--no-newline", action="store_true",
63
+ help="Suppress trailing newline (useful for piping).",
64
+ )
65
+ parser.add_argument(
66
+ "--show-bits", action="store_true",
67
+ help="Print raw nibble values to stderr before rendering.",
68
+ )
69
+
70
+ return parser
71
+
72
+
73
+ def emit(text, no_newline=False):
74
+ if no_newline:
75
+ print(text, end="")
76
+ else:
77
+ print(text)
78
+
79
+
80
+ def main():
81
+ # ensure UTF8 output for cp1252 terminals
82
+ if isinstance(sys.stdout, io.TextIOWrapper):
83
+ sys.stdout.reconfigure(encoding="utf-8")
84
+ if isinstance(sys.stderr, io.TextIOWrapper):
85
+ sys.stderr.reconfigure(encoding="utf-8")
86
+
87
+ parser = build_parser()
88
+ args = parser.parse_args()
89
+
90
+ if args.width != "auto":
91
+ try:
92
+ width = int(args.width)
93
+ except ValueError:
94
+ parser.error("--width must be a positive integer or 'auto'")
95
+ if width < 1:
96
+ parser.error("--width must be a positive integer or 'auto'")
97
+ else:
98
+ width = "auto"
99
+
100
+ opts = dict(
101
+ addr_type = args.addr_type,
102
+ invert = args.invert,
103
+ scale = args.scale,
104
+ layout = args.layout,
105
+ width = width,
106
+ output = args.output,
107
+ show_bits = args.show_bits,
108
+ )
109
+
110
+ if args.scale < 1:
111
+ parser.error("--scale must be a positive integer")
112
+
113
+ if args.random:
114
+ addr = random_addr(args.addr_type if args.addr_type != "auto" else "ipv6")
115
+ if args.output == "text":
116
+ print(f"# {addr}", file=sys.stderr)
117
+ result = render_address(addr, **opts)
118
+ emit(result, args.no_newline)
119
+ return
120
+
121
+ # Stdin batch mode
122
+ if args.address == "-":
123
+ for line in sys.stdin:
124
+ line = line.strip()
125
+ if not line:
126
+ continue
127
+ try:
128
+ addr, _ = parse_addr(line, args.addr_type)
129
+ except ValueError as e:
130
+ print(f"hexicon: {e}", file=sys.stderr)
131
+ continue
132
+ result = render_address(addr, **opts)
133
+ emit(result, args.no_newline)
134
+ if not args.no_newline:
135
+ print() # blank separator between addresses
136
+ return
137
+
138
+ # normal single address mode
139
+ try:
140
+ addr, _ = parse_addr(args.address, args.addr_type)
141
+ except ValueError as e:
142
+ parser.error(str(e))
143
+
144
+ result = render_address(addr, **opts)
145
+ emit(result, args.no_newline)
@@ -0,0 +1,313 @@
1
+ """Edge-case test battery for hexicon."""
2
+
3
+ import json
4
+ import unittest
5
+
6
+ from hexicon import (
7
+ addr_to_nibbles,
8
+ detect_type,
9
+ int_to_nibbles,
10
+ render_address,
11
+ random_addr,
12
+ render_grid,
13
+ CHARSET_DEFAULT,
14
+ DEFAULT_WIDTH,
15
+ LAYOUT_CHOICES,
16
+ TYPE_CHOICES,
17
+ OUTPUT_CHOICES,
18
+ )
19
+
20
+
21
+ # ── Schema & detection ────────────────────────────────────────────────────────
22
+
23
+ class TestDetectType(unittest.TestCase):
24
+ def test_ipv6_full(self):
25
+ self.assertEqual(detect_type("2001:0db8::1"), "ipv6")
26
+
27
+ def test_ipv6_loopback(self):
28
+ self.assertEqual(detect_type("::1"), "ipv6")
29
+
30
+ def test_ipv6_all_zeros(self):
31
+ self.assertEqual(detect_type("::"), "ipv6")
32
+
33
+ def test_ipv4_basic(self):
34
+ self.assertEqual(detect_type("192.168.0.1"), "ipv4")
35
+
36
+ def test_mac_colon(self):
37
+ self.assertEqual(detect_type("aa:bb:cc:dd:ee:ff"), "mac")
38
+
39
+ def test_mac_hyphen(self):
40
+ self.assertEqual(detect_type("AA-BB-CC-DD-EE-FF"), "mac")
41
+
42
+ def test_ipv4_mapped_ipv6_is_ipv6(self):
43
+ # ::ffff:192.168.0.1 should be detected as IPv6, *not* IPv4
44
+ self.assertEqual(detect_type("::ffff:192.168.0.1"), "ipv6")
45
+
46
+ def test_garbage_raises(self):
47
+ with self.assertRaises(ValueError):
48
+ detect_type("not-an-address")
49
+
50
+
51
+ # ── IPv6 edge cases ──────────────────────────────────────────────────────────
52
+
53
+ class TestIPv6Nibbles(unittest.TestCase):
54
+ def test_compressed_loopback(self):
55
+ schema = addr_to_nibbles("::1")
56
+ self.assertEqual(schema.type, "ipv6")
57
+ self.assertEqual(len(schema.nibbles), 32)
58
+ # Last nibble should be 1, rest 0
59
+ self.assertEqual(schema.nibbles[-1], 1)
60
+ self.assertTrue(all(n == 0 for n in schema.nibbles[:-1]))
61
+
62
+ def test_compressed_prefix(self):
63
+ schema = addr_to_nibbles("fe80::1")
64
+ self.assertEqual(len(schema.nibbles), 32)
65
+ self.assertEqual(schema.nibbles[:4], [0xf, 0xe, 8, 0])
66
+
67
+ def test_all_zeros(self):
68
+ schema = addr_to_nibbles("::")
69
+ self.assertEqual(schema.nibbles, [0] * 32)
70
+
71
+ def test_all_ones(self):
72
+ schema = addr_to_nibbles("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")
73
+ self.assertEqual(schema.nibbles, [0xf] * 32)
74
+
75
+ def test_ipv4_mapped(self):
76
+ """::ffff:192.168.0.1 is a valid IPv6 address with 32 nibbles."""
77
+ schema = addr_to_nibbles("::ffff:192.168.0.1")
78
+ self.assertEqual(schema.type, "ipv6")
79
+ self.assertEqual(len(schema.nibbles), 32)
80
+ # The last 8 nibbles encode 192.168.0.1 = c0.a8.00.01
81
+ self.assertEqual(schema.nibbles[-8:], [0xc, 0x0, 0xa, 0x8, 0x0, 0x0, 0x0, 0x1])
82
+
83
+ def test_parts_metadata(self):
84
+ schema = addr_to_nibbles("2001:db8::1")
85
+ net = schema.parts[0]
86
+ host = schema.parts[1]
87
+ self.assertEqual(net.bit_range, [0, 64])
88
+ self.assertEqual(net.label, "network")
89
+ self.assertEqual(host.bit_range, [64, 128])
90
+ self.assertEqual(host.label, "host")
91
+
92
+
93
+ # ── IPv4 edge cases ──────────────────────────────────────────────────────────
94
+
95
+ class TestIPv4Nibbles(unittest.TestCase):
96
+ def test_basic(self):
97
+ schema = addr_to_nibbles("192.168.0.1")
98
+ self.assertEqual(schema.type, "ipv4")
99
+ self.assertEqual(len(schema.nibbles), 8)
100
+ # 192 = 0xC0, 168 = 0xA8, 0 = 0x00, 1 = 0x01
101
+ self.assertEqual(schema.nibbles, [0xc, 0x0, 0xa, 0x8, 0x0, 0x0, 0x0, 0x1])
102
+
103
+ def test_all_zeros(self):
104
+ schema = addr_to_nibbles("0.0.0.0")
105
+ self.assertEqual(schema.nibbles, [0] * 8)
106
+
107
+ def test_all_max(self):
108
+ schema = addr_to_nibbles("255.255.255.255")
109
+ self.assertEqual(schema.nibbles, [0xf] * 8)
110
+
111
+ def test_four_parts(self):
112
+ schema = addr_to_nibbles("10.20.30.40")
113
+ self.assertEqual(len(schema.parts), 4)
114
+ for i, part in enumerate(schema.parts):
115
+ self.assertEqual(part.name, f"octet{i}")
116
+ self.assertEqual(len(part.nibbles), 2)
117
+ self.assertEqual(part.bit_range, [i * 8, (i + 1) * 8])
118
+ self.assertEqual(part.label, f"octet {i}")
119
+
120
+
121
+ # ── MAC edge cases ───────────────────────────────────────────────────────────
122
+
123
+ class TestMACNibbles(unittest.TestCase):
124
+ def test_lowercase(self):
125
+ schema = addr_to_nibbles("aa:bb:cc:dd:ee:ff")
126
+ self.assertEqual(schema.type, "mac")
127
+ self.assertEqual(len(schema.nibbles), 12)
128
+ self.assertEqual(schema.input, "aa:bb:cc:dd:ee:ff")
129
+
130
+ def test_uppercase(self):
131
+ schema = addr_to_nibbles("AA:BB:CC:DD:EE:FF")
132
+ self.assertEqual(schema.input, "aa:bb:cc:dd:ee:ff") # normalised
133
+
134
+ def test_mixed_case(self):
135
+ schema = addr_to_nibbles("Aa:bB:Cc:dD:Ee:fF")
136
+ self.assertEqual(schema.input, "aa:bb:cc:dd:ee:ff")
137
+
138
+ def test_hyphen_delimiter(self):
139
+ schema = addr_to_nibbles("11-22-33-44-55-66")
140
+ self.assertEqual(schema.type, "mac")
141
+ self.assertEqual(schema.input, "11:22:33:44:55:66")
142
+
143
+ def test_all_zeros(self):
144
+ schema = addr_to_nibbles("00:00:00:00:00:00")
145
+ self.assertEqual(schema.nibbles, [0] * 12)
146
+
147
+ def test_all_max(self):
148
+ schema = addr_to_nibbles("ff:ff:ff:ff:ff:ff")
149
+ self.assertEqual(schema.nibbles, [0xf] * 12)
150
+
151
+ def test_parts_metadata(self):
152
+ schema = addr_to_nibbles("aa:bb:cc:dd:ee:ff")
153
+ oui = schema.parts[0]
154
+ nic = schema.parts[1]
155
+ self.assertEqual(oui.bit_range, [0, 24])
156
+ self.assertEqual(oui.label, "OUI")
157
+ self.assertEqual(nic.bit_range, [24, 48])
158
+ self.assertEqual(nic.label, "NIC")
159
+
160
+
161
+ # ── Width flag ───────────────────────────────────────────────────────────────
162
+
163
+ class TestWidthFlag(unittest.TestCase):
164
+ def test_default_width_ipv6(self):
165
+ result = render_address("::1", width="auto")
166
+ lines = result.strip().split("\n")
167
+ self.assertTrue(len(lines) > 0)
168
+
169
+ def test_explicit_width_1(self):
170
+ addr = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
171
+ result = render_address(addr, width=1, layout="grid")
172
+ lines = result.split("\n")
173
+ self.assertEqual(len(lines), 32)
174
+ for line in lines:
175
+ self.assertEqual(len(line), 2)
176
+
177
+ def test_explicit_width_full_row(self):
178
+ addr = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
179
+ result = render_address(addr, width=16, layout="grid")
180
+ lines = result.split("\n")
181
+ self.assertEqual(len(lines), 2) # net part + host part
182
+ for line in lines:
183
+ self.assertEqual(len(line), 32)
184
+
185
+ def test_width_ipv4(self):
186
+ result = render_address("10.0.0.1", width=2, layout="grid")
187
+ lines = result.strip().split("\n")
188
+ self.assertEqual(len(lines), 4)
189
+
190
+
191
+ # ── Layout modes ─────────────────────────────────────────────────────────────
192
+
193
+ class TestLayoutModes(unittest.TestCase):
194
+ def test_grid_stacks_parts(self):
195
+ result = render_address("2001:db8::1", layout="grid")
196
+ lines = result.strip().split("\n")
197
+ # grid: net 4 rows + host 4 rows = 8 rows
198
+ self.assertEqual(len(lines), 8)
199
+
200
+ def test_split_side_by_side(self):
201
+ result = render_address("2001:db8::1", layout="split")
202
+ lines = result.strip().split("\n")
203
+ # split: 4 rows, each is net_row + " " + host_row
204
+ self.assertEqual(len(lines), 4)
205
+ for line in lines:
206
+ self.assertIn(" ", line)
207
+
208
+ def test_inline_single_row(self):
209
+ addr = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
210
+ result = render_address(addr, layout="inline")
211
+ lines = result.split("\n")
212
+ # inline: all 32 nibbles in one row → 64 chars
213
+ self.assertEqual(len(lines), 1)
214
+ self.assertEqual(len(lines[0]), 64)
215
+
216
+ def test_barcode_two_rows(self):
217
+ addr = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
218
+ result = render_address(addr, layout="barcode")
219
+ lines = result.split("\n")
220
+ # barcode: width = 32//2 = 16 → 2 rows of 32 chars each
221
+ self.assertEqual(len(lines), 2)
222
+ self.assertEqual(len(lines[0]), 32)
223
+
224
+ def test_auto_ipv6_is_split(self):
225
+ r_auto = render_address("::1", layout="auto")
226
+ r_split = render_address("::1", layout="split")
227
+ self.assertEqual(r_auto, r_split)
228
+
229
+ def test_auto_ipv4_is_split(self):
230
+ """IPv4 also has >1 parts, but split only works for exactly 2 parts.
231
+ With 4 parts auto resolves to grid (since len(parts) != 2)."""
232
+ r_auto = render_address("10.0.0.1", layout="auto")
233
+ r_grid = render_address("10.0.0.1", layout="grid")
234
+ # IPv4 has 4 parts so auto → grid (not split)
235
+ self.assertEqual(r_auto, r_grid)
236
+
237
+ def test_auto_mac_is_split(self):
238
+ r_auto = render_address("aa:bb:cc:dd:ee:ff", layout="auto")
239
+ r_split = render_address("aa:bb:cc:dd:ee:ff", layout="split")
240
+ self.assertEqual(r_auto, r_split)
241
+
242
+ def test_split_falls_back_for_4_parts(self):
243
+ """If layout=split but there are 4 parts, format_text stacks them."""
244
+ result = render_address("10.0.0.1", layout="split")
245
+ lines = result.strip().split("\n")
246
+ # 4 parts with width=4: each has 2 nib / 4 = 1 row → 4 rows stacked
247
+ self.assertEqual(len(lines), 4)
248
+
249
+
250
+ # ── JSON output ──────────────────────────────────────────────────────────────
251
+
252
+ class TestJSONOutput(unittest.TestCase):
253
+ def test_ipv6_json_has_metadata(self):
254
+ raw = render_address("2001:db8::1", output="json")
255
+ data = json.loads(raw)
256
+ self.assertEqual(data["type"], "ipv6")
257
+ self.assertIn("parts", data)
258
+ for part in data["parts"]:
259
+ self.assertIn("bit_range", part)
260
+ self.assertIn("label", part)
261
+
262
+ def test_ipv4_json_has_metadata(self):
263
+ raw = render_address("10.0.0.1", output="json")
264
+ data = json.loads(raw)
265
+ self.assertEqual(data["type"], "ipv4")
266
+ self.assertEqual(len(data["parts"]), 4)
267
+ for i, part in enumerate(data["parts"]):
268
+ self.assertEqual(part["bit_range"], [i * 8, (i + 1) * 8])
269
+
270
+ def test_mac_json_has_metadata(self):
271
+ raw = render_address("aa:bb:cc:dd:ee:ff", output="json")
272
+ data = json.loads(raw)
273
+ self.assertEqual(data["type"], "mac")
274
+ self.assertEqual(data["parts"][0]["label"], "OUI")
275
+ self.assertEqual(data["parts"][1]["label"], "NIC")
276
+
277
+
278
+ # ── Scale stress ─────────────────────────────────────────────────────────────
279
+
280
+ class TestScaleStress(unittest.TestCase):
281
+ def test_large_scale(self):
282
+ """--scale 100 should not crash and should multiply rows."""
283
+ result = render_address("::1", scale=100)
284
+ lines = result.split("\n")
285
+ # split layout: 4 visual rows * 100 = 400 lines
286
+ self.assertEqual(len(lines), 400)
287
+
288
+ def test_scale_1(self):
289
+ r1 = render_address("::1", scale=1)
290
+ self.assertEqual(len(r1.split("\n")), 4)
291
+
292
+
293
+ # ── Random address generation ───────────────────────────────────────────────
294
+
295
+ class TestRandomAddr(unittest.TestCase):
296
+ def test_random_ipv6_valid(self):
297
+ addr = random_addr("ipv6")
298
+ schema = addr_to_nibbles(addr, "ipv6")
299
+ self.assertEqual(len(schema.nibbles), 32)
300
+
301
+ def test_random_ipv4_valid(self):
302
+ addr = random_addr("ipv4")
303
+ schema = addr_to_nibbles(addr, "ipv4")
304
+ self.assertEqual(len(schema.nibbles), 8)
305
+
306
+ def test_random_mac_valid(self):
307
+ addr = random_addr("mac")
308
+ schema = addr_to_nibbles(addr, "mac")
309
+ self.assertEqual(len(schema.nibbles), 12)
310
+
311
+
312
+ if __name__ == "__main__":
313
+ unittest.main()