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.
@@ -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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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