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.
- hexicon-0.1.0/.gitignore +23 -0
- hexicon-0.1.0/LICENSE +21 -0
- hexicon-0.1.0/PKG-INFO +147 -0
- hexicon-0.1.0/README.md +119 -0
- hexicon-0.1.0/pyproject.toml +39 -0
- hexicon-0.1.0/src/hexicon/__init__.py +43 -0
- hexicon-0.1.0/src/hexicon/__main__.py +5 -0
- hexicon-0.1.0/src/hexicon/_core.py +304 -0
- hexicon-0.1.0/src/hexicon/cli.py +145 -0
- hexicon-0.1.0/tests/test_hexicon.py +313 -0
hexicon-0.1.0/.gitignore
ADDED
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
|
hexicon-0.1.0/README.md
ADDED
|
@@ -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,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()
|