hardguard25 1.3.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.
- hardguard25-1.3.0/LICENSE +21 -0
- hardguard25-1.3.0/PKG-INFO +33 -0
- hardguard25-1.3.0/README.md +9 -0
- hardguard25-1.3.0/hardguard25/__init__.py +196 -0
- hardguard25-1.3.0/hardguard25/py.typed +0 -0
- hardguard25-1.3.0/hardguard25.egg-info/PKG-INFO +33 -0
- hardguard25-1.3.0/hardguard25.egg-info/SOURCES.txt +10 -0
- hardguard25-1.3.0/hardguard25.egg-info/dependency_links.txt +1 -0
- hardguard25-1.3.0/hardguard25.egg-info/top_level.txt +1 -0
- hardguard25-1.3.0/pyproject.toml +39 -0
- hardguard25-1.3.0/setup.cfg +4 -0
- hardguard25-1.3.0/tests/test_hardguard25.py +238 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Snap Synapse LLC
|
|
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.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hardguard25
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: HardGuard25: A human-friendly unique ID alphabet. 25 unambiguous characters.
|
|
5
|
+
Author-email: Snap Synapse <info@snapsynapse.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/snapsynapse/hardguard25
|
|
8
|
+
Project-URL: Documentation, https://github.com/snapsynapse/hardguard25/blob/main/SPEC.md
|
|
9
|
+
Project-URL: Repository, https://github.com/snapsynapse/hardguard25
|
|
10
|
+
Keywords: id,uid,unique-id,identifier,human-readable,unambiguous
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# hardguard25
|
|
26
|
+
|
|
27
|
+
A human-friendly unique ID alphabet. 25 unambiguous characters, zero confusion.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
0 1 2 3 4 5 6 7 8 9 A C D F G H J K M N P R U W Y
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
See the full specification and documentation at [github.com/snapsynapse/hardguard25](https://github.com/snapsynapse/hardguard25).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# hardguard25
|
|
2
|
+
|
|
3
|
+
A human-friendly unique ID alphabet. 25 unambiguous characters, zero confusion.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
0 1 2 3 4 5 6 7 8 9 A C D F G H J K M N P R U W Y
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
See the full specification and documentation at [github.com/snapsynapse/hardguard25](https://github.com/snapsynapse/hardguard25).
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HardGuard25: A 25-character alphabet for human-friendly unique IDs.
|
|
3
|
+
|
|
4
|
+
This module provides functions for generating, validating, and managing
|
|
5
|
+
HardGuard25 identifiers. The alphabet excludes visually ambiguous characters
|
|
6
|
+
(B, E, I, L, O, Q, S, T, V, X, Z) to prevent confusion.
|
|
7
|
+
|
|
8
|
+
Character set: 0 1 2 3 4 5 6 7 8 9 A C D F G H J K M N P R U W Y
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
from typing import Dict
|
|
14
|
+
|
|
15
|
+
__version__ = "1.3.0"
|
|
16
|
+
|
|
17
|
+
# The 25-character HardGuard25 alphabet
|
|
18
|
+
ALPHABET = "0123456789ACDFGHJKMNPRUWY"
|
|
19
|
+
|
|
20
|
+
# Frozenset for O(1) membership testing
|
|
21
|
+
ALPHABET_SET = frozenset(ALPHABET)
|
|
22
|
+
|
|
23
|
+
# Dictionary mapping each character to its 0-24 index
|
|
24
|
+
_CHAR_TO_INDEX: Dict[str, int] = {char: idx for idx, char in enumerate(ALPHABET)}
|
|
25
|
+
|
|
26
|
+
# Compiled regex pattern for validation
|
|
27
|
+
_REGEX = re.compile(r"^[0-9ACDFGHJKMNPRUWY]+$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate(length: int, *, check_digit: bool = False) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Generate a random HardGuard25 identifier.
|
|
33
|
+
|
|
34
|
+
Uses cryptographically secure random number generation with rejection sampling
|
|
35
|
+
to ensure uniform distribution across the 25-character alphabet.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
length: The desired length of the identifier (must be > 0).
|
|
39
|
+
check_digit: If True, append a check digit (result will be length+1 chars).
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
A random HardGuard25 string of the specified length (plus 1 if check_digit=True).
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If length <= 0.
|
|
46
|
+
"""
|
|
47
|
+
if length <= 0:
|
|
48
|
+
raise ValueError("length must be greater than 0")
|
|
49
|
+
|
|
50
|
+
result = []
|
|
51
|
+
alphabet_len = 25
|
|
52
|
+
|
|
53
|
+
# Rejection sampling: only accept byte values < 225 (25*9)
|
|
54
|
+
# This ensures uniform distribution
|
|
55
|
+
while len(result) < length:
|
|
56
|
+
random_bytes = secrets.token_bytes(1)
|
|
57
|
+
byte_val = random_bytes[0]
|
|
58
|
+
|
|
59
|
+
# Reject values >= 225 to ensure uniform distribution
|
|
60
|
+
if byte_val < 225:
|
|
61
|
+
index = byte_val % alphabet_len
|
|
62
|
+
result.append(ALPHABET[index])
|
|
63
|
+
|
|
64
|
+
code = "".join(result)
|
|
65
|
+
|
|
66
|
+
if check_digit:
|
|
67
|
+
code += check_digit_func(code)
|
|
68
|
+
|
|
69
|
+
return code
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate(input_str: str) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Validate a HardGuard25 identifier.
|
|
75
|
+
|
|
76
|
+
Normalizes the input and checks if it matches the HardGuard25 pattern.
|
|
77
|
+
Never raises an exception; returns False for invalid input.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
input_str: The string to validate.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if the input is a valid HardGuard25 identifier, False otherwise.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
normalized = normalize(input_str)
|
|
87
|
+
return bool(_REGEX.match(normalized))
|
|
88
|
+
except ValueError:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def normalize(input_str: str) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Normalize a HardGuard25 identifier.
|
|
95
|
+
|
|
96
|
+
Trims whitespace, collapses separators (hyphens, spaces, underscores, dots),
|
|
97
|
+
converts to uppercase, and validates the result.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
input_str: The string to normalize.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The normalized HardGuard25 string.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: If the input contains invalid characters (after separator removal).
|
|
107
|
+
"""
|
|
108
|
+
if not isinstance(input_str, str):
|
|
109
|
+
raise ValueError("input must be a string")
|
|
110
|
+
|
|
111
|
+
# Trim whitespace
|
|
112
|
+
normalized = input_str.strip()
|
|
113
|
+
|
|
114
|
+
# Remove common separators
|
|
115
|
+
normalized = normalized.replace("-", "").replace(" ", "").replace("_", "").replace(".", "")
|
|
116
|
+
|
|
117
|
+
# Convert to uppercase
|
|
118
|
+
normalized = normalized.upper()
|
|
119
|
+
|
|
120
|
+
# Check for invalid characters
|
|
121
|
+
if not _REGEX.match(normalized):
|
|
122
|
+
raise ValueError(f"invalid characters in input: {input_str}")
|
|
123
|
+
|
|
124
|
+
return normalized
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_digit(code: str) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Compute the check digit for a HardGuard25 code.
|
|
130
|
+
|
|
131
|
+
Uses a mod-25 weighted checksum where each character is weighted by its
|
|
132
|
+
position (1-indexed).
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
code: The code to compute the check digit for.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
A single character representing the check digit.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If the code contains invalid characters.
|
|
142
|
+
"""
|
|
143
|
+
if not code:
|
|
144
|
+
raise ValueError("code must not be empty")
|
|
145
|
+
|
|
146
|
+
upper_code = code.upper()
|
|
147
|
+
try:
|
|
148
|
+
weighted_sum = sum(
|
|
149
|
+
_CHAR_TO_INDEX[char] * (i + 1)
|
|
150
|
+
for i, char in enumerate(upper_code)
|
|
151
|
+
)
|
|
152
|
+
except KeyError as e:
|
|
153
|
+
raise ValueError(f"invalid character in code: {e}")
|
|
154
|
+
|
|
155
|
+
return ALPHABET[weighted_sum % 25]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def verify_check_digit(code_with_check: str) -> bool:
|
|
159
|
+
"""
|
|
160
|
+
Verify the check digit of a HardGuard25 code.
|
|
161
|
+
|
|
162
|
+
Strips the last character, recomputes the check digit, and compares.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
code_with_check: The code including the check digit.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
True if the check digit is valid, False otherwise.
|
|
169
|
+
"""
|
|
170
|
+
if not isinstance(code_with_check, str) or len(code_with_check) < 2:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
upper = code_with_check.upper()
|
|
175
|
+
code = upper[:-1]
|
|
176
|
+
provided_check = upper[-1]
|
|
177
|
+
computed_check = check_digit(code)
|
|
178
|
+
return provided_check == computed_check
|
|
179
|
+
except (ValueError, AttributeError):
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Backward-compatible alias for earlier pre-release naming.
|
|
184
|
+
check_digit_func = check_digit
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = [
|
|
188
|
+
"ALPHABET",
|
|
189
|
+
"ALPHABET_SET",
|
|
190
|
+
"generate",
|
|
191
|
+
"validate",
|
|
192
|
+
"normalize",
|
|
193
|
+
"check_digit",
|
|
194
|
+
"check_digit_func",
|
|
195
|
+
"verify_check_digit",
|
|
196
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hardguard25
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: HardGuard25: A human-friendly unique ID alphabet. 25 unambiguous characters.
|
|
5
|
+
Author-email: Snap Synapse <info@snapsynapse.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/snapsynapse/hardguard25
|
|
8
|
+
Project-URL: Documentation, https://github.com/snapsynapse/hardguard25/blob/main/SPEC.md
|
|
9
|
+
Project-URL: Repository, https://github.com/snapsynapse/hardguard25
|
|
10
|
+
Keywords: id,uid,unique-id,identifier,human-readable,unambiguous
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# hardguard25
|
|
26
|
+
|
|
27
|
+
A human-friendly unique ID alphabet. 25 unambiguous characters, zero confusion.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
0 1 2 3 4 5 6 7 8 9 A C D F G H J K M N P R U W Y
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
See the full specification and documentation at [github.com/snapsynapse/hardguard25](https://github.com/snapsynapse/hardguard25).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
hardguard25/__init__.py
|
|
5
|
+
hardguard25/py.typed
|
|
6
|
+
hardguard25.egg-info/PKG-INFO
|
|
7
|
+
hardguard25.egg-info/SOURCES.txt
|
|
8
|
+
hardguard25.egg-info/dependency_links.txt
|
|
9
|
+
hardguard25.egg-info/top_level.txt
|
|
10
|
+
tests/test_hardguard25.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hardguard25
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hardguard25"
|
|
7
|
+
version = "1.3.0"
|
|
8
|
+
description = "HardGuard25: A human-friendly unique ID alphabet. 25 unambiguous characters."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "Snap Synapse", email = "info@snapsynapse.com"}
|
|
15
|
+
]
|
|
16
|
+
keywords = ["id", "uid", "unique-id", "identifier", "human-readable", "unambiguous"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
include = ["hardguard25*"]
|
|
31
|
+
exclude = ["tests*"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/snapsynapse/hardguard25"
|
|
35
|
+
Documentation = "https://github.com/snapsynapse/hardguard25/blob/main/SPEC.md"
|
|
36
|
+
Repository = "https://github.com/snapsynapse/hardguard25"
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive test suite for HardGuard25.
|
|
3
|
+
|
|
4
|
+
Tests cover alphabet validation, ID generation, validation, normalization,
|
|
5
|
+
check digits, and distribution characteristics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from collections import Counter
|
|
10
|
+
|
|
11
|
+
import hardguard25
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestAlphabet:
|
|
15
|
+
|
|
16
|
+
def test_alphabet_length(self):
|
|
17
|
+
assert len(hardguard25.ALPHABET) == 25
|
|
18
|
+
|
|
19
|
+
def test_alphabet_set_matches_alphabet(self):
|
|
20
|
+
assert hardguard25.ALPHABET_SET == frozenset(hardguard25.ALPHABET)
|
|
21
|
+
|
|
22
|
+
def test_no_duplicates_in_alphabet(self):
|
|
23
|
+
assert len(set(hardguard25.ALPHABET)) == 25
|
|
24
|
+
|
|
25
|
+
def test_no_excluded_chars(self):
|
|
26
|
+
excluded = set("BEILOQSTVXZ")
|
|
27
|
+
alphabet_set = set(hardguard25.ALPHABET)
|
|
28
|
+
assert alphabet_set.isdisjoint(excluded)
|
|
29
|
+
|
|
30
|
+
def test_only_base10_and_uppercase_letters(self):
|
|
31
|
+
for char in hardguard25.ALPHABET:
|
|
32
|
+
assert char.isdigit() or char.isupper()
|
|
33
|
+
|
|
34
|
+
def test_char_to_index_mapping(self):
|
|
35
|
+
for idx, char in enumerate(hardguard25.ALPHABET):
|
|
36
|
+
assert hardguard25._CHAR_TO_INDEX[char] == idx
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestGeneration:
|
|
40
|
+
|
|
41
|
+
def test_generate_correct_length(self):
|
|
42
|
+
for length in [1, 5, 10, 32, 64, 128]:
|
|
43
|
+
result = hardguard25.generate(length)
|
|
44
|
+
assert len(result) == length
|
|
45
|
+
|
|
46
|
+
def test_generate_invalid_length(self):
|
|
47
|
+
with pytest.raises(ValueError):
|
|
48
|
+
hardguard25.generate(0)
|
|
49
|
+
with pytest.raises(ValueError):
|
|
50
|
+
hardguard25.generate(-1)
|
|
51
|
+
|
|
52
|
+
def test_generate_only_valid_chars(self):
|
|
53
|
+
for _ in range(1000):
|
|
54
|
+
result = hardguard25.generate(20)
|
|
55
|
+
for char in result:
|
|
56
|
+
assert char in hardguard25.ALPHABET_SET
|
|
57
|
+
|
|
58
|
+
def test_generate_with_check_digit(self):
|
|
59
|
+
for length in [5, 10, 32]:
|
|
60
|
+
result = hardguard25.generate(length, check_digit=True)
|
|
61
|
+
assert len(result) == length + 1
|
|
62
|
+
|
|
63
|
+
def test_generate_randomness(self):
|
|
64
|
+
results = [hardguard25.generate(20) for _ in range(10)]
|
|
65
|
+
assert len(set(results)) == 10
|
|
66
|
+
|
|
67
|
+
def test_generate_distribution(self):
|
|
68
|
+
chars = []
|
|
69
|
+
for _ in range(400):
|
|
70
|
+
chars.extend(hardguard25.generate(25))
|
|
71
|
+
counter = Counter(chars)
|
|
72
|
+
assert len(counter) == 25
|
|
73
|
+
for char in hardguard25.ALPHABET:
|
|
74
|
+
assert counter.get(char, 0) > 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestValidation:
|
|
78
|
+
|
|
79
|
+
def test_validate_valid_ids(self):
|
|
80
|
+
valid_ids = [
|
|
81
|
+
"ACD123",
|
|
82
|
+
"0123456789",
|
|
83
|
+
"ACDFGHJKMNPRUWY",
|
|
84
|
+
"acd123",
|
|
85
|
+
"A-C-D-1-2-3",
|
|
86
|
+
"A C D 1 2 3",
|
|
87
|
+
"A_C_D_1_2_3",
|
|
88
|
+
"A.C.D.1.2.3",
|
|
89
|
+
]
|
|
90
|
+
for test_id in valid_ids:
|
|
91
|
+
assert hardguard25.validate(test_id), f"Failed to validate: {test_id}"
|
|
92
|
+
|
|
93
|
+
def test_validate_invalid_ids(self):
|
|
94
|
+
invalid_ids = [
|
|
95
|
+
"BEILOQSTVXZ",
|
|
96
|
+
"!@#$%^&*()",
|
|
97
|
+
"ACD123XBZ",
|
|
98
|
+
"",
|
|
99
|
+
]
|
|
100
|
+
for test_id in invalid_ids:
|
|
101
|
+
assert not hardguard25.validate(test_id), f"Should not validate: {test_id}"
|
|
102
|
+
|
|
103
|
+
def test_validate_never_raises(self):
|
|
104
|
+
test_inputs = [None, 123, [], {}, "ACD\x00123"]
|
|
105
|
+
for test_input in test_inputs:
|
|
106
|
+
try:
|
|
107
|
+
result = hardguard25.validate(test_input)
|
|
108
|
+
assert isinstance(result, bool)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
pytest.fail(f"validate() raised {type(e).__name__}: {e}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestNormalization:
|
|
114
|
+
|
|
115
|
+
def test_normalize_uppercase(self):
|
|
116
|
+
assert hardguard25.normalize("acd") == "ACD"
|
|
117
|
+
assert hardguard25.normalize("aCd") == "ACD"
|
|
118
|
+
|
|
119
|
+
def test_normalize_trim_whitespace(self):
|
|
120
|
+
assert hardguard25.normalize(" ACD123 ") == "ACD123"
|
|
121
|
+
|
|
122
|
+
def test_normalize_remove_separators(self):
|
|
123
|
+
assert hardguard25.normalize("A-C-D-1-2-3") == "ACD123"
|
|
124
|
+
assert hardguard25.normalize("A C D 1 2 3") == "ACD123"
|
|
125
|
+
assert hardguard25.normalize("A_C_D_1_2_3") == "ACD123"
|
|
126
|
+
assert hardguard25.normalize("A.C.D.1.2.3") == "ACD123"
|
|
127
|
+
assert hardguard25.normalize("A-C D_1.2 3") == "ACD123"
|
|
128
|
+
|
|
129
|
+
def test_normalize_idempotent(self):
|
|
130
|
+
test_ids = ["acd-123", "A C D 1 2 3", "A_C_D_1_2_3", "A.C.D.1.2.3"]
|
|
131
|
+
for test_id in test_ids:
|
|
132
|
+
once = hardguard25.normalize(test_id)
|
|
133
|
+
twice = hardguard25.normalize(once)
|
|
134
|
+
assert once == twice
|
|
135
|
+
|
|
136
|
+
def test_normalize_invalid_input_raises(self):
|
|
137
|
+
invalid_inputs = ["BEILOQSTVXZ", "ACD!@#"]
|
|
138
|
+
for test_input in invalid_inputs:
|
|
139
|
+
with pytest.raises(ValueError):
|
|
140
|
+
hardguard25.normalize(test_input)
|
|
141
|
+
|
|
142
|
+
def test_normalize_non_string_input_raises(self):
|
|
143
|
+
with pytest.raises(ValueError):
|
|
144
|
+
hardguard25.normalize(123)
|
|
145
|
+
with pytest.raises(ValueError):
|
|
146
|
+
hardguard25.normalize(None)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TestCheckDigit:
|
|
150
|
+
|
|
151
|
+
def test_check_digit_returns_single_char(self):
|
|
152
|
+
for length in [1, 5, 10, 32]:
|
|
153
|
+
code = hardguard25.generate(length)
|
|
154
|
+
digit = hardguard25.check_digit(code)
|
|
155
|
+
assert len(digit) == 1
|
|
156
|
+
assert digit in hardguard25.ALPHABET_SET
|
|
157
|
+
|
|
158
|
+
def test_check_digit_deterministic(self):
|
|
159
|
+
code = "ACD123"
|
|
160
|
+
digit1 = hardguard25.check_digit(code)
|
|
161
|
+
digit2 = hardguard25.check_digit(code)
|
|
162
|
+
assert digit1 == digit2
|
|
163
|
+
|
|
164
|
+
def test_check_digit_different_for_different_codes(self):
|
|
165
|
+
digits = set()
|
|
166
|
+
for length in [1, 2, 3, 4, 5]:
|
|
167
|
+
code = hardguard25.generate(length)
|
|
168
|
+
digit = hardguard25.check_digit(code)
|
|
169
|
+
digits.add(digit)
|
|
170
|
+
assert len(digits) > 1
|
|
171
|
+
|
|
172
|
+
def test_check_digit_invalid_input_raises(self):
|
|
173
|
+
with pytest.raises(ValueError):
|
|
174
|
+
hardguard25.check_digit("")
|
|
175
|
+
with pytest.raises(ValueError):
|
|
176
|
+
hardguard25.check_digit("ACD!@#")
|
|
177
|
+
|
|
178
|
+
def test_check_digit_backward_compatible_alias(self):
|
|
179
|
+
code = "ACD123"
|
|
180
|
+
assert hardguard25.check_digit(code) == hardguard25.check_digit_func(code)
|
|
181
|
+
|
|
182
|
+
def test_verify_check_digit_valid(self):
|
|
183
|
+
code = hardguard25.generate(10)
|
|
184
|
+
digit = hardguard25.check_digit(code)
|
|
185
|
+
full_code = code + digit
|
|
186
|
+
assert hardguard25.verify_check_digit(full_code)
|
|
187
|
+
|
|
188
|
+
def test_verify_check_digit_invalid(self):
|
|
189
|
+
code = hardguard25.generate(10)
|
|
190
|
+
digit = hardguard25.check_digit(code)
|
|
191
|
+
wrong_chars = [c for c in hardguard25.ALPHABET if c != digit]
|
|
192
|
+
wrong_code = code + wrong_chars[0]
|
|
193
|
+
assert not hardguard25.verify_check_digit(wrong_code)
|
|
194
|
+
|
|
195
|
+
def test_verify_check_digit_short_input(self):
|
|
196
|
+
assert not hardguard25.verify_check_digit("")
|
|
197
|
+
assert not hardguard25.verify_check_digit("A")
|
|
198
|
+
|
|
199
|
+
def test_verify_check_digit_never_raises(self):
|
|
200
|
+
test_inputs = ["ACD!@#", None, 123, []]
|
|
201
|
+
for test_input in test_inputs:
|
|
202
|
+
try:
|
|
203
|
+
result = hardguard25.verify_check_digit(test_input)
|
|
204
|
+
assert isinstance(result, bool)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
pytest.fail(f"verify_check_digit() raised {type(e).__name__}: {e}")
|
|
207
|
+
|
|
208
|
+
def test_check_digit_workflow(self):
|
|
209
|
+
for _ in range(100):
|
|
210
|
+
code = hardguard25.generate(15)
|
|
211
|
+
digit = hardguard25.check_digit(code)
|
|
212
|
+
full = code + digit
|
|
213
|
+
assert hardguard25.verify_check_digit(full)
|
|
214
|
+
wrong_chars = [c for c in hardguard25.ALPHABET if c != digit]
|
|
215
|
+
corrupted = code + wrong_chars[0]
|
|
216
|
+
assert not hardguard25.verify_check_digit(corrupted)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TestDistribution:
|
|
220
|
+
|
|
221
|
+
def test_distribution_all_chars_appear(self):
|
|
222
|
+
chars = []
|
|
223
|
+
for _ in range(400):
|
|
224
|
+
chars.extend(hardguard25.generate(25))
|
|
225
|
+
counter = Counter(chars)
|
|
226
|
+
assert len(counter) == 25
|
|
227
|
+
|
|
228
|
+
def test_distribution_roughly_uniform(self):
|
|
229
|
+
chars = []
|
|
230
|
+
for _ in range(400):
|
|
231
|
+
chars.extend(hardguard25.generate(25))
|
|
232
|
+
counter = Counter(chars)
|
|
233
|
+
total = len(chars)
|
|
234
|
+
expected_per_char = total / 25
|
|
235
|
+
for char in hardguard25.ALPHABET:
|
|
236
|
+
count = counter.get(char, 0)
|
|
237
|
+
ratio = count / expected_per_char
|
|
238
|
+
assert 0.5 < ratio < 1.5
|