substitutionciphers 1.0.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,203 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Marimo
198
+ marimo/_static/
199
+ marimo/_lsp/
200
+ __marimo__/
201
+
202
+ # Streamlit
203
+ .streamlit/secrets.toml
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: substitutionciphers
3
+ Version: 1.0.0
4
+ Summary: The Griffinere cipher is a custom encryption algorithm in C# designed for reversible, base64-normalized encryption using a repeating key. Inspired by the Vigenère cipher, it adds configurable alphabet support, input validation, and padding-based encryption length enforcement.
5
+ Project-URL: Homepage, https://github.com/RileyG00/Ciphers
6
+ Project-URL: Source, https://github.com/RileyG00/Ciphers/tree/add-python
7
+ Author-email: Riley Griffin <riley.griffin00@outlook.com>
8
+ License: MIT
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Security :: Cryptography
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Griffinere Cipher 🔐 — Python Edition
16
+
17
+ The **Griffinere** cipher is a reversible, Base‑64‑normalised encryption algorithm implemented in pure **Python**.
18
+ Inspired by the classic Vigenère cipher, it adds:
19
+
20
+ * **Configurable alphabets** (use any character set you like)
21
+ * **Input validation** for safer usage
22
+ * **Padding‑based length enforcement** so encrypted strings meet a minimum length
23
+
24
+ ---
25
+
26
+ ## 📦 Installation
27
+
28
+ ```bash
29
+ pip install substitutionciphers
30
+ ```
31
+
32
+ In your code:
33
+
34
+ ```python
35
+ from griffinere import Griffinere
36
+ ```
37
+
38
+ ---
39
+
40
+ ## ✨ Features
41
+
42
+ * 🔐 Encrypts & decrypts alphanumeric or **custom‑alphabet** strings
43
+ * 🧩 Define your **own alphabet** (emoji? Cyrillic? go ahead!)
44
+ * 📏 Optional **minimum‑length** padding for fixed‑width ciphertext
45
+ * ✅ Strong validation of both alphabet and key integrity
46
+ * 🧪 Unit‑tested with **pytest**
47
+
48
+ ---
49
+
50
+ ## 🧰 Usage
51
+
52
+ ### 1 Create a cipher
53
+
54
+ #### 1.1 Default alphabet
55
+
56
+ ```python
57
+ key = "YourSecureKey"
58
+ cipher = Griffinere(key)
59
+ ```
60
+
61
+ The built‑in alphabet is:
62
+
63
+ ```
64
+ A‑Z a‑z 0‑9
65
+ ```
66
+
67
+ #### 1.2 Custom alphabet
68
+
69
+ ```python
70
+ custom_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345"
71
+ key = "YOURKEY"
72
+ cipher = Griffinere(key, custom_alphabet)
73
+ ```
74
+
75
+ ##### Alphabet rules
76
+
77
+ 1. **Must not contain `.`** (dot)
78
+ 2. **All characters must be unique**
79
+ 3. **Every character in the key must appear in the alphabet**
80
+
81
+ ---
82
+
83
+ ### 2 Encrypt & decrypt
84
+
85
+ #### 2.1 Encrypt a string
86
+
87
+ ```python
88
+ plain_text = "Hello World 123"
89
+ encrypted = cipher.encrypt_string(plain_text)
90
+ # e.g. 'LUKsbK8 OK9ybKJ FC3z'
91
+ ```
92
+
93
+ #### 2.2 Encrypt with a minimum length
94
+
95
+ ```python
96
+ encrypted = cipher.encrypt_string(plain_text, minimum_response_length=24)
97
+ # e.g. 'cm9JbAxsIJg.LUKsbK8 OK9ybKJ FC3z.Fw'
98
+ ```
99
+
100
+ #### 3.1 Decrypt
101
+
102
+ ```python
103
+ decrypted = cipher.decrypt_string(encrypted)
104
+ assert decrypted == plain_text
105
+ ```
106
+
107
+ ---
108
+
109
+ ## ⚠️ Exceptions & validation
110
+
111
+ | Condition | Exception |
112
+ | ----------------------------------------------- | ------------ |
113
+ | Alphabet contains `.` | `ValueError` |
114
+ | Duplicate characters in alphabet | `ValueError` |
115
+ | Key contains characters not present in alphabet | `ValueError` |
116
+ | `minimum_response_length` < 1 | `ValueError` |
117
+
118
+ ---
119
+
120
+ ## 📄 License
121
+
122
+ MIT License © 2025 Riley Griffin
@@ -0,0 +1,108 @@
1
+ # Griffinere Cipher 🔐 — Python Edition
2
+
3
+ The **Griffinere** cipher is a reversible, Base‑64‑normalised encryption algorithm implemented in pure **Python**.
4
+ Inspired by the classic Vigenère cipher, it adds:
5
+
6
+ * **Configurable alphabets** (use any character set you like)
7
+ * **Input validation** for safer usage
8
+ * **Padding‑based length enforcement** so encrypted strings meet a minimum length
9
+
10
+ ---
11
+
12
+ ## 📦 Installation
13
+
14
+ ```bash
15
+ pip install substitutionciphers
16
+ ```
17
+
18
+ In your code:
19
+
20
+ ```python
21
+ from griffinere import Griffinere
22
+ ```
23
+
24
+ ---
25
+
26
+ ## ✨ Features
27
+
28
+ * 🔐 Encrypts & decrypts alphanumeric or **custom‑alphabet** strings
29
+ * 🧩 Define your **own alphabet** (emoji? Cyrillic? go ahead!)
30
+ * 📏 Optional **minimum‑length** padding for fixed‑width ciphertext
31
+ * ✅ Strong validation of both alphabet and key integrity
32
+ * 🧪 Unit‑tested with **pytest**
33
+
34
+ ---
35
+
36
+ ## 🧰 Usage
37
+
38
+ ### 1 Create a cipher
39
+
40
+ #### 1.1 Default alphabet
41
+
42
+ ```python
43
+ key = "YourSecureKey"
44
+ cipher = Griffinere(key)
45
+ ```
46
+
47
+ The built‑in alphabet is:
48
+
49
+ ```
50
+ A‑Z a‑z 0‑9
51
+ ```
52
+
53
+ #### 1.2 Custom alphabet
54
+
55
+ ```python
56
+ custom_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345"
57
+ key = "YOURKEY"
58
+ cipher = Griffinere(key, custom_alphabet)
59
+ ```
60
+
61
+ ##### Alphabet rules
62
+
63
+ 1. **Must not contain `.`** (dot)
64
+ 2. **All characters must be unique**
65
+ 3. **Every character in the key must appear in the alphabet**
66
+
67
+ ---
68
+
69
+ ### 2 Encrypt & decrypt
70
+
71
+ #### 2.1 Encrypt a string
72
+
73
+ ```python
74
+ plain_text = "Hello World 123"
75
+ encrypted = cipher.encrypt_string(plain_text)
76
+ # e.g. 'LUKsbK8 OK9ybKJ FC3z'
77
+ ```
78
+
79
+ #### 2.2 Encrypt with a minimum length
80
+
81
+ ```python
82
+ encrypted = cipher.encrypt_string(plain_text, minimum_response_length=24)
83
+ # e.g. 'cm9JbAxsIJg.LUKsbK8 OK9ybKJ FC3z.Fw'
84
+ ```
85
+
86
+ #### 3.1 Decrypt
87
+
88
+ ```python
89
+ decrypted = cipher.decrypt_string(encrypted)
90
+ assert decrypted == plain_text
91
+ ```
92
+
93
+ ---
94
+
95
+ ## ⚠️ Exceptions & validation
96
+
97
+ | Condition | Exception |
98
+ | ----------------------------------------------- | ------------ |
99
+ | Alphabet contains `.` | `ValueError` |
100
+ | Duplicate characters in alphabet | `ValueError` |
101
+ | Key contains characters not present in alphabet | `ValueError` |
102
+ | `minimum_response_length` < 1 | `ValueError` |
103
+
104
+ ---
105
+
106
+ ## 📄 License
107
+
108
+ MIT License © 2025 Riley Griffin
File without changes
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "substitutionciphers"
7
+ version = "1.0.0"
8
+ description = "The Griffinere cipher is a custom encryption algorithm in C# designed for reversible, base64-normalized encryption using a repeating key. Inspired by the Vigenère cipher, it adds configurable alphabet support, input validation, and padding-based encryption length enforcement."
9
+ authors = [{ name = "Riley Griffin", email = "riley.griffin00@outlook.com" }]
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ readme = "README.md"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Topic :: Security :: Cryptography",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/RileyG00/Ciphers"
21
+ Source = "https://github.com/RileyG00/Ciphers/tree/add-python"
@@ -0,0 +1,3 @@
1
+ from .griffinere import Griffinere
2
+
3
+ __all__ = ['Griffinere']
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import math
5
+ from dataclasses import dataclass, field
6
+ from typing import Dict, List
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class Griffinere:
11
+ key: str
12
+ alphabet: str | None = None
13
+
14
+ _alphabet: List[str] = field(init=False, repr=False)
15
+ _alphabet_length: int = field(init=False, repr=False)
16
+ _alphabet_position_map: Dict[str, int] = field(init=False, repr=False)
17
+ _key_chars: List[str] = field(init=False, repr=False)
18
+
19
+ def __post_init__(self) -> None:
20
+ default_alphabet = (
21
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
22
+ "abcdefghijklmnopqrstuvwxyz"
23
+ "0123456789"
24
+ )
25
+ alphabet_str = self.alphabet or default_alphabet
26
+ self._alphabet = self._validate_alphabet(alphabet_str, self.key)
27
+ self._alphabet_length = len(self._alphabet)
28
+ self._alphabet_position_map = {ch: idx for idx, ch in enumerate(self._alphabet)}
29
+ self._key_chars = list(self.key)
30
+
31
+ def encrypt_string(self, plain_text: str, minimum_response_length: int | None = None) -> str:
32
+ if not plain_text or plain_text.isspace():
33
+ return ""
34
+ if minimum_response_length is None:
35
+ return self._encrypt_segments(plain_text)
36
+ if minimum_response_length < 1:
37
+ raise ValueError("minimum_response_length must be greater than zero")
38
+ need_to_add = minimum_response_length - len(plain_text)
39
+ if need_to_add <= 0:
40
+ return self._encrypt_segments(plain_text)
41
+ pull_from_front = math.ceil(need_to_add / 1.25)
42
+ pull_from_back = need_to_add - pull_from_front
43
+ contiguous = plain_text.replace(" ", "") or plain_text
44
+ string_to_front = self._cycle_take(contiguous, pull_from_front, True)
45
+ string_to_back = self._cycle_take(contiguous, pull_from_back, False)
46
+ fragments_front = f"{self._encrypt_segments(string_to_front[::-1])}." if string_to_front else ""
47
+ fragments_back = f".{self._encrypt_segments(string_to_back)}" if string_to_back else ""
48
+ core = self._encrypt_segments(plain_text)
49
+ return f"{fragments_front}{core}{fragments_back}"
50
+
51
+ def decrypt_string(self, cipher_text: str) -> str:
52
+ if not cipher_text or cipher_text.isspace():
53
+ return ""
54
+ if "." in cipher_text:
55
+ parts = cipher_text.split(".")
56
+ if len(parts) > 2:
57
+ cipher_text = parts[1]
58
+ return self._decrypt_segments(cipher_text)
59
+
60
+ @staticmethod
61
+ def _validate_alphabet(alphabet: str, key: str) -> List[str]:
62
+ if "." in alphabet:
63
+ raise ValueError("Alphabet must not contain '.'")
64
+ unique: List[str] = []
65
+ seen = set()
66
+ for ch in alphabet:
67
+ if ch in seen:
68
+ raise ValueError(f"Duplicate character '{ch}' in provided alphabet.")
69
+ seen.add(ch)
70
+ unique.append(ch)
71
+ for ch in key:
72
+ if ch not in seen:
73
+ raise ValueError(f"Alphabet does not contain the character '{ch}' supplied in the key.")
74
+ return unique
75
+
76
+ @staticmethod
77
+ def _cycle_take(source: str, count: int, front: bool) -> str:
78
+ if count <= 0 or not source:
79
+ return ""
80
+ result: List[str] = []
81
+ length = len(source)
82
+ idx = 0
83
+ while len(result) < count:
84
+ result.append(source[idx % length] if front else source[-1 - (idx % length)])
85
+ idx += 1
86
+ return "".join(result)
87
+
88
+ def _encrypt_segments(self, text: str) -> str:
89
+ return " ".join(self._encrypt_word(word) if word else "" for word in text.split(" "))
90
+
91
+ def _decrypt_segments(self, text: str) -> str:
92
+ return " ".join(self._decrypt_word(word) if word else "" for word in text.split(" "))
93
+
94
+ def _encrypt_word(self, word: str) -> str:
95
+ segment_chars = self._to_base64_char_list(word)
96
+ key_chars = self._get_key(segment_chars)
97
+ encrypted = [self._shift_positive(kc, sc) for kc, sc in zip(key_chars, segment_chars)]
98
+ return "".join(encrypted)
99
+
100
+ def _decrypt_word(self, word: str) -> str:
101
+ segment_chars = list(word)
102
+ key_chars = self._get_key(segment_chars)
103
+ decrypted = [self._shift_negative(kc, sc) for kc, sc in zip(key_chars, segment_chars)]
104
+ return self._from_base64_char_list(decrypted)
105
+
106
+ def _shift_positive(self, key_char: str, text_char: str) -> str:
107
+ key_pos = self._alphabet_position_map.get(key_char)
108
+ text_pos = self._alphabet_position_map.get(text_char)
109
+ if key_pos is None or text_pos is None:
110
+ return text_char
111
+ return self._alphabet[(key_pos + text_pos) % self._alphabet_length]
112
+
113
+ def _shift_negative(self, key_char: str, text_char: str) -> str:
114
+ key_pos = self._alphabet_position_map.get(key_char)
115
+ text_pos = self._alphabet_position_map.get(text_char)
116
+ if key_pos is None or text_pos is None:
117
+ return text_char
118
+ return self._alphabet[(text_pos - key_pos + self._alphabet_length) % self._alphabet_length]
119
+
120
+ def _get_key(self, segment: List[str]) -> List[str]:
121
+ if not segment:
122
+ return []
123
+ key = list(self._key_chars)
124
+ while len(key) < len(segment):
125
+ key.extend(self._key_chars)
126
+ return key[: len(segment)]
127
+
128
+ @staticmethod
129
+ def _to_base64_char_list(text: str) -> List[str]:
130
+ if text is None:
131
+ raise ValueError("text cannot be None")
132
+ if text == "":
133
+ return []
134
+ encoded = base64.b64encode(text.encode()).decode().rstrip("=")
135
+ return list(encoded)
136
+
137
+ @staticmethod
138
+ def _from_base64_char_list(chars: List[str]) -> str:
139
+ if not chars:
140
+ return ""
141
+ encoded = "".join(chars)
142
+ padding_needed = (-len(encoded)) % 4
143
+ encoded += "=" * padding_needed
144
+ decoded_bytes = base64.b64decode(encoded)
145
+ return decoded_bytes.decode()
@@ -0,0 +1,160 @@
1
+ import sys
2
+ from pathlib import Path
3
+ import pytest
4
+
5
+ SRC_DIR = Path(__file__).resolve().parents[1] / "src"
6
+ sys.path.insert(0, str(SRC_DIR))
7
+
8
+ from substitutionciphers import Griffinere
9
+
10
+
11
+ # ────────────────────────────────────────────────
12
+ # Basic round‑trip checks
13
+ # ────────────────────────────────────────────────
14
+
15
+ def test_encrypt_and_decrypt_roundtrip():
16
+ key = "N3bhd1u6gh6Uh88H083envHwuUSec72i"
17
+ plaintext = "This is a test of the encryption."
18
+ cipher = Griffinere(key)
19
+
20
+ encrypted = cipher.encrypt_string(plaintext)
21
+ decrypted = cipher.decrypt_string(encrypted)
22
+
23
+ assert decrypted == plaintext
24
+
25
+
26
+ def test_encrypt_and_decrypt_with_custom_alphabet():
27
+ key = "N3bhd1u6gh6Uh88H083envHwuUSec72i"
28
+ alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
29
+ plaintext = "Hello World 123!"
30
+ cipher = Griffinere(key, alphabet) # type: ignore[arg-type]
31
+
32
+ encrypted = cipher.encrypt_string(plaintext)
33
+ decrypted = cipher.decrypt_string(encrypted)
34
+
35
+ assert decrypted == plaintext
36
+
37
+
38
+ # ────────────────────────────────────────────────
39
+ # Length‑padding variant
40
+ # ────────────────────────────────────────────────
41
+
42
+ def test_encrypt_string_with_minimum_length():
43
+ key = "N3bhd1u6gh6Uh88H083envHwuUSec72i"
44
+ plaintext = "Short text"
45
+ cipher = Griffinere(key)
46
+
47
+ encrypted = cipher.encrypt_string(plaintext, 64)
48
+
49
+ assert len(encrypted) >= 64
50
+
51
+
52
+ def test_encrypt_string_with_empty_text():
53
+ cipher = Griffinere("VkiKMyvu7PT3UV08xZr9X1AA5WiZDzDm")
54
+
55
+ assert cipher.encrypt_string("") == ""
56
+
57
+
58
+ # ────────────────────────────────────────────────
59
+ # Stress‑cases: very long keys / plaintexts
60
+ # ────────────────────────────────────────────────
61
+
62
+ def test_encrypt_string_with_really_long_key_and_message():
63
+ long_key = (
64
+ "LEWQcmPaCv9b8HNHJQFuqxDRDCJnQbcXmhQR3wwTuFhSPRUGBSJnj2GrTBSKj3tJTnnSrVC57DHhnik7EUVL8427EQRM6KHxJWenq1Jiy6qzRDchQt5B57izp744yZ0UtK5hngr9cq8kYDJnctwCc3TMk5awiw2HrhwyunyF3hEPk5bfhGmWZE61reeaC7SwH2iRZF9KYdHEwLQ8u1gV72KfPhMLvtca78ff4FcY7W5GeNZbMySUhU4GytTzU4PEHwtkQjRgcAqb7yxjaZT787t0wPZjTiyvdmVCreNm0C7exCFXpR6a4NC7QBQgimCaSWyj1cKZ9xTTML7Wrm6xZD0v5vHSiVKmN79tUpkPPD6TuV73RaTnPcHzqT8YpnujGtJ1jqvGVT6dRdLtbATth1wtLcmnMx5Mc0jLbp6hKicYjVEu7BJyv2mxYcaeyWQvXmj81zPEdnJ3wFz4ngXmT1XiRZwucAt2HMpxq3QaRaNGdA1y759dZqhueFbZn8G4"
65
+ )
66
+
67
+ original_text = (
68
+ "ikdbr10dbLGm7xtMLkgVhBYVjmkrfAmARyNJXLLbUmvVSTnLMyFWw2vk4tZippWWJGJwhUq9dK6aD5FNJHyje4yzCTiMqjJ26wttnxSbgbNpXAuXKFUECNzDwFj5Dcf1JhqjeA9X6bfTBjY975jSYqrNNje1u1tBNTVjwq3qeMtWVFz9Bj2PxZhWuU99K1R8tedU48uRzjJWdvd18ZSVbwyrTMbGn77FPDAXQirbHiKwcwqXemMVq6tyec7Yc986KNVixV93Da4Z2jS3ERN66WHjhVwMm5yyb9KN81eiCNYWfJZdyp6mBAX2dNuNeBLQr4xP5LNdAFVWg2nn42t9aJNGh1Ep0yr1cGLBcYNXgMwPMqBtJnSLFphhi82zM3YhSeTLbSNchLzjJXu0A5ZhHqddPWc5BmnxtDeZ5tw6uTSy76au4MdTTqR3HcXeAVPuE9fxWSDwxEvh7gRCUBC3bkn7rdUtH8fRJFNLdyYNrNN2SM6C66rdHrhg71d6rGuG"
69
+ )
70
+
71
+ cipher = Griffinere(long_key)
72
+
73
+ encrypted = cipher.encrypt_string(original_text)
74
+ decrypted = cipher.decrypt_string(encrypted)
75
+
76
+ assert decrypted == original_text
77
+
78
+
79
+ def test_encrypt_string_with_really_long_key_and_custom_alphabet():
80
+ long_key = (
81
+ "a{D{BhT(e&V{4zzpQ=Mjw(Hv5epZt;#wf,A!nNTbeMbdA2x%?NwD3kJ@@$)]/*-q/5x3)/T=_JTzRY$4(ggH!d45CK9R8Vm+y&i8N_Ki+PZ4DA[Cj[fxZ02w%:MV"
82
+ )
83
+ alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!\"#$%&'()*+,-/:;<=>?@[]^_`{|}~"
84
+ original_text = "Testing encryption."
85
+
86
+ cipher = Griffinere(long_key, alphabet) # type: ignore[arg-type]
87
+
88
+ encrypted = cipher.encrypt_string(original_text)
89
+ decrypted = cipher.decrypt_string(encrypted)
90
+
91
+ assert decrypted == original_text
92
+
93
+
94
+ # ────────────────────────────────────────────────
95
+ # Escaped / whitespace edge‑cases
96
+ # ────────────────────────────────────────────────
97
+
98
+ def test_encrypt_string_with_escaped_characters():
99
+ cipher = Griffinere("EuMchtXtJFKhA5H8fGduYPXQEcZJKEAe")
100
+ original_text = "Testing\nSpecial\tCharacters"
101
+
102
+ encrypted = cipher.encrypt_string(original_text)
103
+ decrypted = cipher.decrypt_string(encrypted)
104
+
105
+ assert decrypted == original_text
106
+
107
+
108
+ def test_encrypt_string_with_double_space_character():
109
+ cipher = Griffinere("dHiNt8C8JY1RhZ26mtYCHByr0WzzfTLm")
110
+ original_text = "Testing Double Triple Space"
111
+
112
+ encrypted = cipher.encrypt_string(original_text)
113
+ decrypted = cipher.decrypt_string(encrypted)
114
+
115
+ assert decrypted == original_text
116
+
117
+
118
+ # ────────────────────────────────────────────────
119
+ # Constructor validation checks
120
+ # ────────────────────────────────────────────────
121
+
122
+ def test_constructor_with_invalid_alphabet_should_throw():
123
+ invalid_alphabet = "abc.defghijklmf" # contains '.'
124
+ key = "A39a3hiirMFAafY1iRBucZxY86AzCeMZ"
125
+
126
+ with pytest.raises(ValueError) as exc:
127
+ Griffinere(key, invalid_alphabet)
128
+
129
+
130
+
131
+
132
+ def test_constructor_with_duplicate_alphabet_chars_should_throw():
133
+ invalid_alphabet = "aabcdefg" # duplicate 'a'
134
+ key = "abcdefg"
135
+
136
+ with pytest.raises(ValueError) as exc:
137
+ Griffinere(key, invalid_alphabet)
138
+
139
+
140
+ # ────────────────────────────────────────────────
141
+ # Decryption padding / minimum‑length error cases
142
+ # ────────────────────────────────────────────────
143
+
144
+ def test_decrypt_string_with_dot_prefix_should_still_return_plaintext():
145
+ key = "dShHPpUQTihcn7ju1wjYTAD1dvbrPKdT"
146
+ plain_text = ".Padding test case."
147
+ cipher = Griffinere(key)
148
+
149
+ encrypted = cipher.encrypt_string(plain_text, 64)
150
+ decrypted = cipher.decrypt_string(encrypted)
151
+
152
+ assert decrypted == plain_text
153
+
154
+ def test_encrypt_string_with_invalid_minimum_length():
155
+ key = "dShHPpUQTihcn7ju1wjYTAD1dvbrPKdT"
156
+
157
+ cipher = Griffinere(key)
158
+
159
+ with pytest.raises(ValueError) as exc:
160
+ encrypted = cipher.encrypt_string("EncryptWithMinLength", 0)