glitchlings 0.4.4__cp313-cp313-win_amd64.whl
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.
Potentially problematic release.
This version of glitchlings might be problematic. Click here for more details.
- glitchlings/__init__.py +67 -0
- glitchlings/__main__.py +8 -0
- glitchlings/_zoo_rust.cp313-win_amd64.pyd +0 -0
- glitchlings/compat.py +284 -0
- glitchlings/config.py +388 -0
- glitchlings/config.toml +3 -0
- glitchlings/dlc/__init__.py +7 -0
- glitchlings/dlc/_shared.py +153 -0
- glitchlings/dlc/huggingface.py +81 -0
- glitchlings/dlc/prime.py +254 -0
- glitchlings/dlc/pytorch.py +166 -0
- glitchlings/dlc/pytorch_lightning.py +215 -0
- glitchlings/lexicon/__init__.py +192 -0
- glitchlings/lexicon/_cache.py +110 -0
- glitchlings/lexicon/data/default_vector_cache.json +82 -0
- glitchlings/lexicon/metrics.py +162 -0
- glitchlings/lexicon/vector.py +651 -0
- glitchlings/lexicon/wordnet.py +232 -0
- glitchlings/main.py +364 -0
- glitchlings/util/__init__.py +195 -0
- glitchlings/util/adapters.py +27 -0
- glitchlings/zoo/__init__.py +168 -0
- glitchlings/zoo/_ocr_confusions.py +32 -0
- glitchlings/zoo/_rate.py +131 -0
- glitchlings/zoo/_rust_extensions.py +143 -0
- glitchlings/zoo/_sampling.py +54 -0
- glitchlings/zoo/_text_utils.py +100 -0
- glitchlings/zoo/adjax.py +128 -0
- glitchlings/zoo/apostrofae.py +127 -0
- glitchlings/zoo/assets/__init__.py +0 -0
- glitchlings/zoo/assets/apostrofae_pairs.json +32 -0
- glitchlings/zoo/core.py +582 -0
- glitchlings/zoo/jargoyle.py +335 -0
- glitchlings/zoo/mim1c.py +109 -0
- glitchlings/zoo/ocr_confusions.tsv +30 -0
- glitchlings/zoo/redactyl.py +193 -0
- glitchlings/zoo/reduple.py +148 -0
- glitchlings/zoo/rushmore.py +153 -0
- glitchlings/zoo/scannequin.py +171 -0
- glitchlings/zoo/typogre.py +231 -0
- glitchlings/zoo/zeedub.py +185 -0
- glitchlings-0.4.4.dist-info/METADATA +627 -0
- glitchlings-0.4.4.dist-info/RECORD +47 -0
- glitchlings-0.4.4.dist-info/WHEEL +5 -0
- glitchlings-0.4.4.dist-info/entry_points.txt +2 -0
- glitchlings-0.4.4.dist-info/licenses/LICENSE +201 -0
- glitchlings-0.4.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""WordNet-backed lexicon implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any, Callable, Protocol, Sequence, cast
|
|
9
|
+
|
|
10
|
+
from ..compat import nltk as _nltk_dependency
|
|
11
|
+
from . import LexiconBackend
|
|
12
|
+
from ._cache import CacheSnapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _LemmaProtocol(Protocol):
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _SynsetProtocol(Protocol):
|
|
21
|
+
def lemmas(self) -> Sequence[_LemmaProtocol]:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _WordNetResource(Protocol):
|
|
26
|
+
def synsets(self, word: str, pos: str | None = None) -> Sequence[_SynsetProtocol]:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def ensure_loaded(self) -> None:
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
WordNetCorpusReaderFactory = Callable[[Any, Any], _WordNetResource]
|
|
34
|
+
|
|
35
|
+
nltk: ModuleType | None = _nltk_dependency.get()
|
|
36
|
+
_NLTK_IMPORT_ERROR: ModuleNotFoundError | None = _nltk_dependency.error
|
|
37
|
+
|
|
38
|
+
WordNetCorpusReader: WordNetCorpusReaderFactory | None = None
|
|
39
|
+
find: Callable[[str], Any] | None = None
|
|
40
|
+
_WORDNET_MODULE: _WordNetResource | None = None
|
|
41
|
+
|
|
42
|
+
if nltk is not None: # pragma: no cover - guarded by import success
|
|
43
|
+
try:
|
|
44
|
+
corpus_reader_module = import_module("nltk.corpus.reader")
|
|
45
|
+
except ModuleNotFoundError as exc: # pragma: no cover - triggered when corpus missing
|
|
46
|
+
if _NLTK_IMPORT_ERROR is None:
|
|
47
|
+
_NLTK_IMPORT_ERROR = exc
|
|
48
|
+
else:
|
|
49
|
+
reader_candidate = getattr(corpus_reader_module, "WordNetCorpusReader", None)
|
|
50
|
+
if reader_candidate is not None:
|
|
51
|
+
WordNetCorpusReader = cast(WordNetCorpusReaderFactory, reader_candidate)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
data_module = import_module("nltk.data")
|
|
55
|
+
except ModuleNotFoundError as exc: # pragma: no cover - triggered when data missing
|
|
56
|
+
if _NLTK_IMPORT_ERROR is None:
|
|
57
|
+
_NLTK_IMPORT_ERROR = exc
|
|
58
|
+
else:
|
|
59
|
+
locator = getattr(data_module, "find", None)
|
|
60
|
+
if callable(locator):
|
|
61
|
+
find = cast(Callable[[str], Any], locator)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
module_candidate = import_module("nltk.corpus.wordnet")
|
|
65
|
+
except ModuleNotFoundError: # pragma: no cover - only hit on namespace packages
|
|
66
|
+
_WORDNET_MODULE = None
|
|
67
|
+
else:
|
|
68
|
+
_WORDNET_MODULE = cast(_WordNetResource, module_candidate)
|
|
69
|
+
else:
|
|
70
|
+
nltk = None
|
|
71
|
+
find = None
|
|
72
|
+
_WORDNET_MODULE = None
|
|
73
|
+
|
|
74
|
+
_WORDNET_HANDLE: _WordNetResource | None = _WORDNET_MODULE
|
|
75
|
+
_wordnet_ready = False
|
|
76
|
+
|
|
77
|
+
_VALID_POS: tuple[str, ...] = ("n", "v", "a", "r")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _require_nltk() -> None:
|
|
81
|
+
"""Ensure the NLTK dependency is present before continuing."""
|
|
82
|
+
if nltk is None or find is None:
|
|
83
|
+
message = (
|
|
84
|
+
"The NLTK package is required for WordNet-backed lexicons; install "
|
|
85
|
+
"`nltk` and its WordNet corpus manually to enable this backend."
|
|
86
|
+
)
|
|
87
|
+
if "_NLTK_IMPORT_ERROR" in globals() and _NLTK_IMPORT_ERROR is not None:
|
|
88
|
+
raise RuntimeError(message) from _NLTK_IMPORT_ERROR
|
|
89
|
+
raise RuntimeError(message)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def dependencies_available() -> bool:
|
|
93
|
+
"""Return ``True`` when the runtime NLTK dependency is present."""
|
|
94
|
+
return nltk is not None and find is not None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_wordnet_reader() -> _WordNetResource:
|
|
98
|
+
"""Return a WordNet corpus reader from the downloaded corpus files."""
|
|
99
|
+
_require_nltk()
|
|
100
|
+
|
|
101
|
+
if WordNetCorpusReader is None:
|
|
102
|
+
raise RuntimeError("The NLTK WordNet corpus reader is unavailable.")
|
|
103
|
+
|
|
104
|
+
locator = find
|
|
105
|
+
if locator is None:
|
|
106
|
+
raise RuntimeError("The NLTK data locator is unavailable.")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
root = locator("corpora/wordnet")
|
|
110
|
+
except LookupError:
|
|
111
|
+
try:
|
|
112
|
+
zip_root = locator("corpora/wordnet.zip")
|
|
113
|
+
except LookupError as exc:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
"The NLTK WordNet corpus is not installed; run `nltk.download('wordnet')`."
|
|
116
|
+
) from exc
|
|
117
|
+
root = zip_root.join("wordnet/")
|
|
118
|
+
|
|
119
|
+
return WordNetCorpusReader(root, None)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _wordnet(force_refresh: bool = False) -> _WordNetResource:
|
|
123
|
+
"""Retrieve the active WordNet handle, rebuilding it on demand."""
|
|
124
|
+
global _WORDNET_HANDLE
|
|
125
|
+
|
|
126
|
+
if force_refresh:
|
|
127
|
+
_WORDNET_HANDLE = _WORDNET_MODULE
|
|
128
|
+
|
|
129
|
+
cached = _WORDNET_HANDLE
|
|
130
|
+
if cached is not None:
|
|
131
|
+
return cached
|
|
132
|
+
|
|
133
|
+
resource = _load_wordnet_reader()
|
|
134
|
+
_WORDNET_HANDLE = resource
|
|
135
|
+
return resource
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ensure_wordnet() -> None:
|
|
139
|
+
"""Ensure the WordNet corpus is available before use."""
|
|
140
|
+
global _wordnet_ready
|
|
141
|
+
if _wordnet_ready:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
_require_nltk()
|
|
145
|
+
|
|
146
|
+
resource = _wordnet()
|
|
147
|
+
nltk_module = nltk
|
|
148
|
+
if nltk_module is None:
|
|
149
|
+
raise RuntimeError("The NLTK dependency is unexpectedly unavailable.")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
resource.ensure_loaded()
|
|
153
|
+
except LookupError:
|
|
154
|
+
nltk_module.download("wordnet", quiet=True)
|
|
155
|
+
try:
|
|
156
|
+
resource = _wordnet(force_refresh=True)
|
|
157
|
+
resource.ensure_loaded()
|
|
158
|
+
except LookupError as exc: # pragma: no cover - only triggered when download fails
|
|
159
|
+
raise RuntimeError("Unable to load NLTK WordNet corpus for synonym lookups.") from exc
|
|
160
|
+
|
|
161
|
+
_wordnet_ready = True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _collect_synonyms(word: str, parts_of_speech: tuple[str, ...]) -> list[str]:
|
|
165
|
+
"""Gather deterministic synonym candidates for the supplied word."""
|
|
166
|
+
normalized_word = word.lower()
|
|
167
|
+
wordnet = _wordnet()
|
|
168
|
+
synonyms: set[str] = set()
|
|
169
|
+
for pos_tag in parts_of_speech:
|
|
170
|
+
synsets = wordnet.synsets(word, pos=pos_tag)
|
|
171
|
+
if not synsets:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
for synset in synsets:
|
|
175
|
+
lemmas_list = [lemma.name() for lemma in synset.lemmas()]
|
|
176
|
+
if not lemmas_list:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
filtered = []
|
|
180
|
+
for lemma_str in lemmas_list:
|
|
181
|
+
cleaned = lemma_str.replace("_", " ")
|
|
182
|
+
if cleaned.lower() != normalized_word:
|
|
183
|
+
filtered.append(cleaned)
|
|
184
|
+
|
|
185
|
+
if filtered:
|
|
186
|
+
synonyms.update(filtered)
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
if synonyms:
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
return sorted(synonyms)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class WordNetLexicon(LexiconBackend):
|
|
196
|
+
"""Lexicon that retrieves synonyms from the NLTK WordNet corpus."""
|
|
197
|
+
|
|
198
|
+
def get_synonyms(self, word: str, pos: str | None = None, n: int = 5) -> list[str]:
|
|
199
|
+
"""Return up to ``n`` WordNet lemmas for ``word`` filtered by ``pos`` if provided."""
|
|
200
|
+
ensure_wordnet()
|
|
201
|
+
|
|
202
|
+
if pos is None:
|
|
203
|
+
parts: tuple[str, ...] = _VALID_POS
|
|
204
|
+
else:
|
|
205
|
+
normalized_pos = pos.lower()
|
|
206
|
+
if normalized_pos not in _VALID_POS:
|
|
207
|
+
return []
|
|
208
|
+
parts = (normalized_pos,)
|
|
209
|
+
|
|
210
|
+
synonyms = _collect_synonyms(word, parts)
|
|
211
|
+
return self._deterministic_sample(synonyms, limit=n, word=word, pos=pos)
|
|
212
|
+
|
|
213
|
+
def supports_pos(self, pos: str | None) -> bool:
|
|
214
|
+
"""Return ``True`` when ``pos`` is unset or recognised by the WordNet corpus."""
|
|
215
|
+
if pos is None:
|
|
216
|
+
return True
|
|
217
|
+
return pos.lower() in _VALID_POS
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def load_cache(cls, path: str | Path) -> CacheSnapshot:
|
|
221
|
+
"""WordNet lexicons do not persist caches; raising keeps the contract explicit."""
|
|
222
|
+
raise RuntimeError("WordNetLexicon does not persist or load caches.")
|
|
223
|
+
|
|
224
|
+
def save_cache(self, path: str | Path | None = None) -> Path | None:
|
|
225
|
+
"""WordNet lexicons do not persist caches; raising keeps the contract explicit."""
|
|
226
|
+
raise RuntimeError("WordNetLexicon does not persist or load caches.")
|
|
227
|
+
|
|
228
|
+
def __repr__(self) -> str: # pragma: no cover - trivial representation
|
|
229
|
+
return f"WordNetLexicon(seed={self.seed!r})"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = ["WordNetLexicon", "dependencies_available", "ensure_wordnet"]
|
glitchlings/main.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Command line interface for summoning and running glitchlings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from . import SAMPLE_TEXT
|
|
13
|
+
from .config import DEFAULT_ATTACK_SEED, build_gaggle, load_attack_config
|
|
14
|
+
from .zoo import (
|
|
15
|
+
BUILTIN_GLITCHLINGS,
|
|
16
|
+
DEFAULT_GLITCHLING_NAMES,
|
|
17
|
+
Gaggle,
|
|
18
|
+
Glitchling,
|
|
19
|
+
parse_glitchling_spec,
|
|
20
|
+
summon,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
MAX_NAME_WIDTH = max(len(glitchling.name) for glitchling in BUILTIN_GLITCHLINGS.values())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
"""Create and configure the CLI argument parser.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
argparse.ArgumentParser: The configured argument parser instance.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
description=(
|
|
35
|
+
"Summon glitchlings to corrupt text. Provide input text as an argument, "
|
|
36
|
+
"via --file, or pipe it on stdin."
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"text",
|
|
41
|
+
nargs="?",
|
|
42
|
+
help="Text to corrupt. If omitted, stdin is used or --sample provides fallback text.",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"-g",
|
|
46
|
+
"--glitchling",
|
|
47
|
+
dest="glitchlings",
|
|
48
|
+
action="append",
|
|
49
|
+
metavar="SPEC",
|
|
50
|
+
help=(
|
|
51
|
+
"Glitchling to apply, optionally with parameters like "
|
|
52
|
+
"Typogre(rate=0.05). Repeat for multiples; defaults to all built-ins."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"-s",
|
|
57
|
+
"--seed",
|
|
58
|
+
type=int,
|
|
59
|
+
default=None,
|
|
60
|
+
help="Seed controlling deterministic corruption order (default: 151).",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"-f",
|
|
64
|
+
"--file",
|
|
65
|
+
type=Path,
|
|
66
|
+
help="Read input text from a file instead of the command line argument.",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--sample",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Use the included SAMPLE_TEXT when no other input is provided.",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--diff",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Show a unified diff between the original and corrupted text.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--list",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="List available glitchlings and exit.",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"-c",
|
|
85
|
+
"--config",
|
|
86
|
+
type=Path,
|
|
87
|
+
help="Load glitchlings from a YAML configuration file.",
|
|
88
|
+
)
|
|
89
|
+
return parser
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_lexicon_parser() -> argparse.ArgumentParser:
|
|
93
|
+
"""Create the ``build-lexicon`` subcommand parser with vector cache options."""
|
|
94
|
+
builder = argparse.ArgumentParser(
|
|
95
|
+
prog="glitchlings build-lexicon",
|
|
96
|
+
description=(
|
|
97
|
+
"Generate deterministic synonym caches using vector embeddings so "
|
|
98
|
+
"they can be distributed without bundling large models."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
builder.add_argument(
|
|
102
|
+
"--source",
|
|
103
|
+
required=True,
|
|
104
|
+
help=(
|
|
105
|
+
"Vector source specification. Use 'spacy:<model>' for spaCy pipelines "
|
|
106
|
+
"or provide a path to a gensim KeyedVectors/word2vec file."
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
builder.add_argument(
|
|
110
|
+
"--output",
|
|
111
|
+
required=True,
|
|
112
|
+
type=Path,
|
|
113
|
+
help="Path to the JSON file that will receive the synonym cache.",
|
|
114
|
+
)
|
|
115
|
+
builder.add_argument(
|
|
116
|
+
"--tokens",
|
|
117
|
+
type=Path,
|
|
118
|
+
help="Optional newline-delimited vocabulary file to restrict generation.",
|
|
119
|
+
)
|
|
120
|
+
builder.add_argument(
|
|
121
|
+
"--max-neighbors",
|
|
122
|
+
type=int,
|
|
123
|
+
default=50,
|
|
124
|
+
help="Number of nearest neighbours to cache per token (default: 50).",
|
|
125
|
+
)
|
|
126
|
+
builder.add_argument(
|
|
127
|
+
"--min-similarity",
|
|
128
|
+
type=float,
|
|
129
|
+
default=0.0,
|
|
130
|
+
help="Minimum cosine similarity required to keep a synonym (default: 0.0).",
|
|
131
|
+
)
|
|
132
|
+
builder.add_argument(
|
|
133
|
+
"--seed",
|
|
134
|
+
type=int,
|
|
135
|
+
help="Optional deterministic seed to bake into the resulting cache.",
|
|
136
|
+
)
|
|
137
|
+
builder.add_argument(
|
|
138
|
+
"--case-sensitive",
|
|
139
|
+
action="store_true",
|
|
140
|
+
help="Preserve original casing instead of lower-casing cache keys.",
|
|
141
|
+
)
|
|
142
|
+
builder.add_argument(
|
|
143
|
+
"--normalizer",
|
|
144
|
+
choices=["lower", "identity"],
|
|
145
|
+
default="lower",
|
|
146
|
+
help="Token normalization strategy for cache keys (default: lower).",
|
|
147
|
+
)
|
|
148
|
+
builder.add_argument(
|
|
149
|
+
"--limit",
|
|
150
|
+
type=int,
|
|
151
|
+
help="Optional maximum number of tokens to process.",
|
|
152
|
+
)
|
|
153
|
+
builder.add_argument(
|
|
154
|
+
"--overwrite",
|
|
155
|
+
action="store_true",
|
|
156
|
+
help="Allow overwriting an existing cache file.",
|
|
157
|
+
)
|
|
158
|
+
return builder
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def list_glitchlings() -> None:
|
|
162
|
+
"""Print information about the available built-in glitchlings."""
|
|
163
|
+
for key in DEFAULT_GLITCHLING_NAMES:
|
|
164
|
+
glitchling = BUILTIN_GLITCHLINGS[key]
|
|
165
|
+
display_name = glitchling.name
|
|
166
|
+
scope = glitchling.level.name.title()
|
|
167
|
+
order = glitchling.order.name.lower()
|
|
168
|
+
print(f"{display_name:>{MAX_NAME_WIDTH}} — scope: {scope}, order: {order}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def read_text(args: argparse.Namespace, parser: argparse.ArgumentParser) -> str:
|
|
172
|
+
"""Resolve the input text based on CLI arguments.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
args: Parsed arguments from the CLI.
|
|
176
|
+
parser: The argument parser used for emitting user-facing errors.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
str: The text to corrupt.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
SystemExit: Raised indirectly via ``parser.error`` on failure.
|
|
183
|
+
|
|
184
|
+
"""
|
|
185
|
+
file_path = cast(Path | None, getattr(args, "file", None))
|
|
186
|
+
if file_path is not None:
|
|
187
|
+
try:
|
|
188
|
+
return file_path.read_text(encoding="utf-8")
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
filename = getattr(exc, "filename", None) or file_path
|
|
191
|
+
reason = exc.strerror or str(exc)
|
|
192
|
+
parser.error(f"Failed to read file {filename}: {reason}")
|
|
193
|
+
|
|
194
|
+
text_argument = cast(str | None, getattr(args, "text", None))
|
|
195
|
+
if text_argument:
|
|
196
|
+
return text_argument
|
|
197
|
+
|
|
198
|
+
if not sys.stdin.isatty():
|
|
199
|
+
return sys.stdin.read()
|
|
200
|
+
|
|
201
|
+
if bool(getattr(args, "sample", False)):
|
|
202
|
+
return SAMPLE_TEXT
|
|
203
|
+
|
|
204
|
+
parser.error(
|
|
205
|
+
"No input text provided. Supply text as an argument, use --file, pipe input, or "
|
|
206
|
+
"pass --sample."
|
|
207
|
+
)
|
|
208
|
+
raise AssertionError("parser.error should exit")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def summon_glitchlings(
|
|
212
|
+
names: list[str] | None,
|
|
213
|
+
parser: argparse.ArgumentParser,
|
|
214
|
+
seed: int | None,
|
|
215
|
+
*,
|
|
216
|
+
config_path: Path | None = None,
|
|
217
|
+
) -> Gaggle:
|
|
218
|
+
"""Instantiate the requested glitchlings and bundle them in a ``Gaggle``."""
|
|
219
|
+
if config_path is not None:
|
|
220
|
+
if names:
|
|
221
|
+
parser.error("Cannot combine --config with --glitchling.")
|
|
222
|
+
raise AssertionError("parser.error should exit")
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
config = load_attack_config(config_path)
|
|
226
|
+
except (TypeError, ValueError) as exc:
|
|
227
|
+
parser.error(str(exc))
|
|
228
|
+
raise AssertionError("parser.error should exit")
|
|
229
|
+
|
|
230
|
+
return build_gaggle(config, seed_override=seed)
|
|
231
|
+
|
|
232
|
+
normalized: Sequence[str | Glitchling]
|
|
233
|
+
if names:
|
|
234
|
+
parsed: list[str | Glitchling] = []
|
|
235
|
+
for specification in names:
|
|
236
|
+
try:
|
|
237
|
+
parsed.append(parse_glitchling_spec(specification))
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
parser.error(str(exc))
|
|
240
|
+
raise AssertionError("parser.error should exit")
|
|
241
|
+
normalized = parsed
|
|
242
|
+
else:
|
|
243
|
+
normalized = list(DEFAULT_GLITCHLING_NAMES)
|
|
244
|
+
|
|
245
|
+
effective_seed = seed if seed is not None else DEFAULT_ATTACK_SEED
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
return summon(list(normalized), seed=effective_seed)
|
|
249
|
+
except ValueError as exc:
|
|
250
|
+
parser.error(str(exc))
|
|
251
|
+
raise AssertionError("parser.error should exit")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def show_diff(original: str, corrupted: str) -> None:
|
|
255
|
+
"""Display a unified diff between the original and corrupted text."""
|
|
256
|
+
diff_lines = list(
|
|
257
|
+
difflib.unified_diff(
|
|
258
|
+
original.splitlines(keepends=True),
|
|
259
|
+
corrupted.splitlines(keepends=True),
|
|
260
|
+
fromfile="original",
|
|
261
|
+
tofile="corrupted",
|
|
262
|
+
lineterm="",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
if diff_lines:
|
|
266
|
+
for line in diff_lines:
|
|
267
|
+
print(line)
|
|
268
|
+
else:
|
|
269
|
+
print("No changes detected.")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
273
|
+
"""Execute the CLI workflow using the provided arguments.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
args: Parsed CLI arguments.
|
|
277
|
+
parser: Argument parser used for error reporting.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
int: Exit code for the process (``0`` on success).
|
|
281
|
+
|
|
282
|
+
"""
|
|
283
|
+
if args.list:
|
|
284
|
+
list_glitchlings()
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
text = read_text(args, parser)
|
|
288
|
+
gaggle = summon_glitchlings(
|
|
289
|
+
args.glitchlings,
|
|
290
|
+
parser,
|
|
291
|
+
args.seed,
|
|
292
|
+
config_path=args.config,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
corrupted = gaggle.corrupt(text)
|
|
296
|
+
if not isinstance(corrupted, str):
|
|
297
|
+
message = "Gaggle returned non-string output for string input"
|
|
298
|
+
raise TypeError(message)
|
|
299
|
+
|
|
300
|
+
if args.diff:
|
|
301
|
+
show_diff(text, corrupted)
|
|
302
|
+
else:
|
|
303
|
+
print(corrupted)
|
|
304
|
+
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def run_build_lexicon(args: argparse.Namespace) -> int:
|
|
309
|
+
"""Delegate to the vector lexicon cache builder using CLI arguments."""
|
|
310
|
+
from glitchlings.lexicon.vector import main as vector_main
|
|
311
|
+
|
|
312
|
+
vector_args = [
|
|
313
|
+
"--source",
|
|
314
|
+
args.source,
|
|
315
|
+
"--output",
|
|
316
|
+
str(args.output),
|
|
317
|
+
"--max-neighbors",
|
|
318
|
+
str(args.max_neighbors),
|
|
319
|
+
"--min-similarity",
|
|
320
|
+
str(args.min_similarity),
|
|
321
|
+
"--normalizer",
|
|
322
|
+
args.normalizer,
|
|
323
|
+
]
|
|
324
|
+
if args.tokens is not None:
|
|
325
|
+
vector_args.extend(["--tokens", str(args.tokens)])
|
|
326
|
+
if args.seed is not None:
|
|
327
|
+
vector_args.extend(["--seed", str(args.seed)])
|
|
328
|
+
if args.case_sensitive:
|
|
329
|
+
vector_args.append("--case-sensitive")
|
|
330
|
+
if args.limit is not None:
|
|
331
|
+
vector_args.extend(["--limit", str(args.limit)])
|
|
332
|
+
if args.overwrite:
|
|
333
|
+
vector_args.append("--overwrite")
|
|
334
|
+
|
|
335
|
+
return vector_main(vector_args)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main(argv: list[str] | None = None) -> int:
|
|
339
|
+
"""Entry point for the ``glitchlings`` command line interface.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
argv: Optional list of command line arguments. Defaults to ``sys.argv``.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
int: Exit code suitable for use with ``sys.exit``.
|
|
346
|
+
|
|
347
|
+
"""
|
|
348
|
+
if argv is None:
|
|
349
|
+
raw_args = sys.argv[1:]
|
|
350
|
+
else:
|
|
351
|
+
raw_args = list(argv)
|
|
352
|
+
|
|
353
|
+
if raw_args and raw_args[0] == "build-lexicon":
|
|
354
|
+
builder = build_lexicon_parser()
|
|
355
|
+
args = builder.parse_args(raw_args[1:])
|
|
356
|
+
return run_build_lexicon(args)
|
|
357
|
+
|
|
358
|
+
parser = build_parser()
|
|
359
|
+
args = parser.parse_args(raw_args)
|
|
360
|
+
return run_cli(args, parser)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
if __name__ == "__main__":
|
|
364
|
+
sys.exit(main())
|