glitchlings 0.4.2__cp310-cp310-macosx_11_0_universal2.whl → 0.4.4__cp310-cp310-macosx_11_0_universal2.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.

Files changed (38) hide show
  1. glitchlings/__init__.py +4 -0
  2. glitchlings/_zoo_rust.cpython-310-darwin.so +0 -0
  3. glitchlings/compat.py +80 -11
  4. glitchlings/config.py +32 -19
  5. glitchlings/config.toml +1 -1
  6. glitchlings/dlc/__init__.py +3 -1
  7. glitchlings/dlc/_shared.py +86 -1
  8. glitchlings/dlc/pytorch.py +166 -0
  9. glitchlings/dlc/pytorch_lightning.py +215 -0
  10. glitchlings/lexicon/__init__.py +10 -16
  11. glitchlings/lexicon/_cache.py +21 -15
  12. glitchlings/lexicon/data/default_vector_cache.json +80 -14
  13. glitchlings/lexicon/vector.py +94 -15
  14. glitchlings/lexicon/wordnet.py +66 -25
  15. glitchlings/main.py +21 -11
  16. glitchlings/zoo/__init__.py +5 -1
  17. glitchlings/zoo/_rate.py +114 -1
  18. glitchlings/zoo/_rust_extensions.py +143 -0
  19. glitchlings/zoo/adjax.py +5 -6
  20. glitchlings/zoo/apostrofae.py +127 -0
  21. glitchlings/zoo/assets/__init__.py +0 -0
  22. glitchlings/zoo/assets/apostrofae_pairs.json +32 -0
  23. glitchlings/zoo/core.py +61 -23
  24. glitchlings/zoo/jargoyle.py +50 -36
  25. glitchlings/zoo/redactyl.py +15 -13
  26. glitchlings/zoo/reduple.py +5 -6
  27. glitchlings/zoo/rushmore.py +5 -6
  28. glitchlings/zoo/scannequin.py +5 -6
  29. glitchlings/zoo/typogre.py +8 -6
  30. glitchlings/zoo/zeedub.py +8 -6
  31. {glitchlings-0.4.2.dist-info → glitchlings-0.4.4.dist-info}/METADATA +40 -4
  32. glitchlings-0.4.4.dist-info/RECORD +47 -0
  33. glitchlings/lexicon/graph.py +0 -282
  34. glitchlings-0.4.2.dist-info/RECORD +0 -42
  35. {glitchlings-0.4.2.dist-info → glitchlings-0.4.4.dist-info}/WHEEL +0 -0
  36. {glitchlings-0.4.2.dist-info → glitchlings-0.4.4.dist-info}/entry_points.txt +0 -0
  37. {glitchlings-0.4.2.dist-info → glitchlings-0.4.4.dist-info}/licenses/LICENSE +0 -0
  38. {glitchlings-0.4.2.dist-info → glitchlings-0.4.4.dist-info}/top_level.txt +0 -0
@@ -4,49 +4,74 @@ from __future__ import annotations
4
4
 
5
5
  from importlib import import_module
6
6
  from pathlib import Path
7
- from typing import TYPE_CHECKING, Any
7
+ from types import ModuleType
8
+ from typing import Any, Callable, Protocol, Sequence, cast
8
9
 
9
10
  from ..compat import nltk as _nltk_dependency
10
11
  from . import LexiconBackend
11
12
  from ._cache import CacheSnapshot
12
13
 
13
- nltk = _nltk_dependency.get() # type: ignore[assignment]
14
- _NLTK_IMPORT_ERROR = _nltk_dependency.error
15
14
 
16
- if TYPE_CHECKING: # pragma: no cover - typing aid only
17
- from nltk.corpus.reader import WordNetCorpusReader # type: ignore[import]
18
- else: # pragma: no cover - runtime fallback to avoid hard dependency
19
- WordNetCorpusReader = Any
15
+ class _LemmaProtocol(Protocol):
16
+ def name(self) -> str:
17
+ ...
20
18
 
21
- find: Any | None = None
22
- _WORDNET_MODULE: Any | None = None
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
23
41
 
24
42
  if nltk is not None: # pragma: no cover - guarded by import success
25
43
  try:
26
44
  corpus_reader_module = import_module("nltk.corpus.reader")
27
- WordNetCorpusReader = corpus_reader_module.WordNetCorpusReader # type: ignore[assignment]
28
45
  except ModuleNotFoundError as exc: # pragma: no cover - triggered when corpus missing
29
46
  if _NLTK_IMPORT_ERROR is None:
30
- _NLTK_IMPORT_ERROR = exc # type: ignore[assignment]
47
+ _NLTK_IMPORT_ERROR = exc
31
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
+
32
53
  try:
33
54
  data_module = import_module("nltk.data")
34
55
  except ModuleNotFoundError as exc: # pragma: no cover - triggered when data missing
35
56
  if _NLTK_IMPORT_ERROR is None:
36
- _NLTK_IMPORT_ERROR = exc # type: ignore[assignment]
57
+ _NLTK_IMPORT_ERROR = exc
37
58
  else:
38
- find = getattr(data_module, "find", None)
59
+ locator = getattr(data_module, "find", None)
60
+ if callable(locator):
61
+ find = cast(Callable[[str], Any], locator)
39
62
 
40
63
  try:
41
- _WORDNET_MODULE = import_module("nltk.corpus.wordnet")
64
+ module_candidate = import_module("nltk.corpus.wordnet")
42
65
  except ModuleNotFoundError: # pragma: no cover - only hit on namespace packages
43
66
  _WORDNET_MODULE = None
67
+ else:
68
+ _WORDNET_MODULE = cast(_WordNetResource, module_candidate)
44
69
  else:
45
- nltk = None # type: ignore[assignment]
70
+ nltk = None
46
71
  find = None
47
72
  _WORDNET_MODULE = None
48
73
 
49
- _WORDNET_HANDLE: WordNetCorpusReader | Any | None = _WORDNET_MODULE
74
+ _WORDNET_HANDLE: _WordNetResource | None = _WORDNET_MODULE
50
75
  _wordnet_ready = False
51
76
 
52
77
  _VALID_POS: tuple[str, ...] = ("n", "v", "a", "r")
@@ -69,15 +94,22 @@ def dependencies_available() -> bool:
69
94
  return nltk is not None and find is not None
70
95
 
71
96
 
72
- def _load_wordnet_reader() -> WordNetCorpusReader:
97
+ def _load_wordnet_reader() -> _WordNetResource:
73
98
  """Return a WordNet corpus reader from the downloaded corpus files."""
74
99
  _require_nltk()
75
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
+
76
108
  try:
77
- root = find("corpora/wordnet")
109
+ root = locator("corpora/wordnet")
78
110
  except LookupError:
79
111
  try:
80
- zip_root = find("corpora/wordnet.zip")
112
+ zip_root = locator("corpora/wordnet.zip")
81
113
  except LookupError as exc:
82
114
  raise RuntimeError(
83
115
  "The NLTK WordNet corpus is not installed; run `nltk.download('wordnet')`."
@@ -87,18 +119,20 @@ def _load_wordnet_reader() -> WordNetCorpusReader:
87
119
  return WordNetCorpusReader(root, None)
88
120
 
89
121
 
90
- def _wordnet(force_refresh: bool = False) -> WordNetCorpusReader | Any:
122
+ def _wordnet(force_refresh: bool = False) -> _WordNetResource:
91
123
  """Retrieve the active WordNet handle, rebuilding it on demand."""
92
124
  global _WORDNET_HANDLE
93
125
 
94
126
  if force_refresh:
95
127
  _WORDNET_HANDLE = _WORDNET_MODULE
96
128
 
97
- if _WORDNET_HANDLE is not None:
98
- return _WORDNET_HANDLE
129
+ cached = _WORDNET_HANDLE
130
+ if cached is not None:
131
+ return cached
99
132
 
100
- _WORDNET_HANDLE = _load_wordnet_reader()
101
- return _WORDNET_HANDLE
133
+ resource = _load_wordnet_reader()
134
+ _WORDNET_HANDLE = resource
135
+ return resource
102
136
 
103
137
 
104
138
  def ensure_wordnet() -> None:
@@ -110,11 +144,14 @@ def ensure_wordnet() -> None:
110
144
  _require_nltk()
111
145
 
112
146
  resource = _wordnet()
147
+ nltk_module = nltk
148
+ if nltk_module is None:
149
+ raise RuntimeError("The NLTK dependency is unexpectedly unavailable.")
113
150
 
114
151
  try:
115
152
  resource.ensure_loaded()
116
153
  except LookupError:
117
- nltk.download("wordnet", quiet=True)
154
+ nltk_module.download("wordnet", quiet=True)
118
155
  try:
119
156
  resource = _wordnet(force_refresh=True)
120
157
  resource.ensure_loaded()
@@ -159,6 +196,7 @@ class WordNetLexicon(LexiconBackend):
159
196
  """Lexicon that retrieves synonyms from the NLTK WordNet corpus."""
160
197
 
161
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."""
162
200
  ensure_wordnet()
163
201
 
164
202
  if pos is None:
@@ -173,15 +211,18 @@ class WordNetLexicon(LexiconBackend):
173
211
  return self._deterministic_sample(synonyms, limit=n, word=word, pos=pos)
174
212
 
175
213
  def supports_pos(self, pos: str | None) -> bool:
214
+ """Return ``True`` when ``pos`` is unset or recognised by the WordNet corpus."""
176
215
  if pos is None:
177
216
  return True
178
217
  return pos.lower() in _VALID_POS
179
218
 
180
219
  @classmethod
181
220
  def load_cache(cls, path: str | Path) -> CacheSnapshot:
221
+ """WordNet lexicons do not persist caches; raising keeps the contract explicit."""
182
222
  raise RuntimeError("WordNetLexicon does not persist or load caches.")
183
223
 
184
224
  def save_cache(self, path: str | Path | None = None) -> Path | None:
225
+ """WordNet lexicons do not persist caches; raising keeps the contract explicit."""
185
226
  raise RuntimeError("WordNetLexicon does not persist or load caches.")
186
227
 
187
228
  def __repr__(self) -> str: # pragma: no cover - trivial representation
glitchlings/main.py CHANGED
@@ -5,7 +5,9 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import difflib
7
7
  import sys
8
+ from collections.abc import Sequence
8
9
  from pathlib import Path
10
+ from typing import cast
9
11
 
10
12
  from . import SAMPLE_TEXT
11
13
  from .config import DEFAULT_ATTACK_SEED, build_gaggle, load_attack_config
@@ -88,6 +90,7 @@ def build_parser() -> argparse.ArgumentParser:
88
90
 
89
91
 
90
92
  def build_lexicon_parser() -> argparse.ArgumentParser:
93
+ """Create the ``build-lexicon`` subcommand parser with vector cache options."""
91
94
  builder = argparse.ArgumentParser(
92
95
  prog="glitchlings build-lexicon",
93
96
  description=(
@@ -179,21 +182,23 @@ def read_text(args: argparse.Namespace, parser: argparse.ArgumentParser) -> str:
179
182
  SystemExit: Raised indirectly via ``parser.error`` on failure.
180
183
 
181
184
  """
182
- if args.file is not None:
185
+ file_path = cast(Path | None, getattr(args, "file", None))
186
+ if file_path is not None:
183
187
  try:
184
- return args.file.read_text(encoding="utf-8")
188
+ return file_path.read_text(encoding="utf-8")
185
189
  except OSError as exc:
186
- filename = getattr(exc, "filename", None) or args.file
190
+ filename = getattr(exc, "filename", None) or file_path
187
191
  reason = exc.strerror or str(exc)
188
192
  parser.error(f"Failed to read file {filename}: {reason}")
189
193
 
190
- if args.text:
191
- return args.text
194
+ text_argument = cast(str | None, getattr(args, "text", None))
195
+ if text_argument:
196
+ return text_argument
192
197
 
193
198
  if not sys.stdin.isatty():
194
199
  return sys.stdin.read()
195
200
 
196
- if args.sample:
201
+ if bool(getattr(args, "sample", False)):
197
202
  return SAMPLE_TEXT
198
203
 
199
204
  parser.error(
@@ -224,21 +229,23 @@ def summon_glitchlings(
224
229
 
225
230
  return build_gaggle(config, seed_override=seed)
226
231
 
232
+ normalized: Sequence[str | Glitchling]
227
233
  if names:
228
- normalized: list[str | Glitchling] = []
234
+ parsed: list[str | Glitchling] = []
229
235
  for specification in names:
230
236
  try:
231
- normalized.append(parse_glitchling_spec(specification))
237
+ parsed.append(parse_glitchling_spec(specification))
232
238
  except ValueError as exc:
233
239
  parser.error(str(exc))
234
240
  raise AssertionError("parser.error should exit")
241
+ normalized = parsed
235
242
  else:
236
- normalized = DEFAULT_GLITCHLING_NAMES
243
+ normalized = list(DEFAULT_GLITCHLING_NAMES)
237
244
 
238
245
  effective_seed = seed if seed is not None else DEFAULT_ATTACK_SEED
239
246
 
240
247
  try:
241
- return summon(normalized, seed=effective_seed)
248
+ return summon(list(normalized), seed=effective_seed)
242
249
  except ValueError as exc:
243
250
  parser.error(str(exc))
244
251
  raise AssertionError("parser.error should exit")
@@ -285,7 +292,10 @@ def run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
285
292
  config_path=args.config,
286
293
  )
287
294
 
288
- corrupted = gaggle(text)
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)
289
299
 
290
300
  if args.diff:
291
301
  show_diff(text, corrupted)
@@ -4,6 +4,7 @@ import ast
4
4
  from typing import Any
5
5
 
6
6
  from .adjax import Adjax, adjax
7
+ from .apostrofae import Apostrofae, apostrofae
7
8
  from .core import (
8
9
  Gaggle,
9
10
  Glitchling,
@@ -30,6 +31,8 @@ __all__ = [
30
31
  "mim1c",
31
32
  "Jargoyle",
32
33
  "jargoyle",
34
+ "Apostrofae",
35
+ "apostrofae",
33
36
  "Adjax",
34
37
  "adjax",
35
38
  "Reduple",
@@ -58,7 +61,7 @@ __all__ = [
58
61
 
59
62
  _HAS_JARGOYLE = _jargoyle_available()
60
63
 
61
- _BUILTIN_GLITCHLING_LIST: list[Glitchling] = [typogre, mim1c]
64
+ _BUILTIN_GLITCHLING_LIST: list[Glitchling] = [typogre, apostrofae, mim1c]
62
65
  if _HAS_JARGOYLE:
63
66
  _BUILTIN_GLITCHLING_LIST.append(jargoyle)
64
67
  _BUILTIN_GLITCHLING_LIST.extend([adjax, reduple, rushmore, redactyl, scannequin, zeedub])
@@ -69,6 +72,7 @@ BUILTIN_GLITCHLINGS: dict[str, Glitchling] = {
69
72
 
70
73
  _BUILTIN_GLITCHLING_TYPES: dict[str, type[Glitchling]] = {
71
74
  typogre.name.lower(): Typogre,
75
+ apostrofae.name.lower(): Apostrofae,
72
76
  mim1c.name.lower(): Mim1c,
73
77
  adjax.name.lower(): Adjax,
74
78
  reduple.name.lower(): Reduple,
glitchlings/zoo/_rate.py CHANGED
@@ -1,5 +1,9 @@
1
+ """Utilities for handling legacy parameter names across glitchling classes."""
2
+
1
3
  from __future__ import annotations
2
4
 
5
+ import warnings
6
+
3
7
 
4
8
  def resolve_rate(
5
9
  *,
@@ -8,11 +12,120 @@ def resolve_rate(
8
12
  default: float,
9
13
  legacy_name: str,
10
14
  ) -> float:
11
- """Return the effective rate while enforcing mutual exclusivity."""
15
+ """Return the effective rate while enforcing mutual exclusivity.
16
+
17
+ This function centralizes the handling of legacy parameter names, allowing
18
+ glitchlings to maintain backwards compatibility while encouraging migration
19
+ to the standardized 'rate' parameter.
20
+
21
+ Parameters
22
+ ----------
23
+ rate : float | None
24
+ The preferred parameter value.
25
+ legacy_value : float | None
26
+ The deprecated legacy parameter value.
27
+ default : float
28
+ Default value if neither parameter is specified.
29
+ legacy_name : str
30
+ Name of the legacy parameter for error/warning messages.
31
+
32
+ Returns
33
+ -------
34
+ float
35
+ The resolved rate value.
36
+
37
+ Raises
38
+ ------
39
+ ValueError
40
+ If both rate and legacy_value are specified simultaneously.
41
+
42
+ Warnings
43
+ --------
44
+ DeprecationWarning
45
+ If the legacy parameter is used, a deprecation warning is issued.
46
+
47
+ Examples
48
+ --------
49
+ >>> resolve_rate(rate=0.5, legacy_value=None, default=0.1, legacy_name="old_rate")
50
+ 0.5
51
+ >>> resolve_rate(rate=None, legacy_value=0.3, default=0.1, legacy_name="old_rate")
52
+ 0.3 # Issues deprecation warning
53
+ >>> resolve_rate(rate=None, legacy_value=None, default=0.1, legacy_name="old_rate")
54
+ 0.1
55
+
56
+ """
12
57
  if rate is not None and legacy_value is not None:
13
58
  raise ValueError(f"Specify either 'rate' or '{legacy_name}', not both.")
59
+
14
60
  if rate is not None:
15
61
  return rate
62
+
63
+ if legacy_value is not None:
64
+ warnings.warn(
65
+ f"The '{legacy_name}' parameter is deprecated and will be removed in version 0.6.0. "
66
+ f"Use 'rate' instead.",
67
+ DeprecationWarning,
68
+ stacklevel=3,
69
+ )
70
+ return legacy_value
71
+
72
+ return default
73
+
74
+
75
+ def resolve_legacy_param(
76
+ *,
77
+ preferred_value: object,
78
+ legacy_value: object,
79
+ default: object,
80
+ preferred_name: str,
81
+ legacy_name: str,
82
+ ) -> object:
83
+ """Resolve a parameter that has both preferred and legacy names.
84
+
85
+ This is a generalized version of resolve_rate() that works with any type.
86
+
87
+ Parameters
88
+ ----------
89
+ preferred_value : object
90
+ The value from the preferred parameter name.
91
+ legacy_value : object
92
+ The value from the legacy parameter name.
93
+ default : object
94
+ Default value if neither parameter is specified.
95
+ preferred_name : str
96
+ Name of the preferred parameter.
97
+ legacy_name : str
98
+ Name of the legacy parameter for warning messages.
99
+
100
+ Returns
101
+ -------
102
+ object
103
+ The resolved parameter value.
104
+
105
+ Raises
106
+ ------
107
+ ValueError
108
+ If both preferred and legacy values are specified simultaneously.
109
+
110
+ Warnings
111
+ --------
112
+ DeprecationWarning
113
+ If the legacy parameter is used.
114
+
115
+ """
116
+ if preferred_value is not None and legacy_value is not None:
117
+ raise ValueError(f"Specify either '{preferred_name}' or '{legacy_name}', not both.")
118
+
119
+ if preferred_value is not None:
120
+ return preferred_value
121
+
16
122
  if legacy_value is not None:
123
+ warnings.warn(
124
+ f"The '{legacy_name}' parameter is deprecated and will be removed in version 0.6.0. "
125
+ f"Use '{preferred_name}' instead.",
126
+ DeprecationWarning,
127
+ stacklevel=3,
128
+ )
17
129
  return legacy_value
130
+
18
131
  return default
@@ -0,0 +1,143 @@
1
+ """Centralized loading and fallback management for optional Rust extensions.
2
+
3
+ This module provides a single source of truth for importing Rust-accelerated
4
+ operations, eliminating duplicated try/except blocks across the codebase.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any, Callable
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ # Cache of loaded Rust operations to avoid repeated import attempts
16
+ _rust_operation_cache: dict[str, Callable[..., Any] | None] = {}
17
+ _rust_module_available: bool | None = None
18
+
19
+
20
+ def is_rust_module_available() -> bool:
21
+ """Check if the Rust extension module can be imported.
22
+
23
+ Returns
24
+ -------
25
+ bool
26
+ True if glitchlings._zoo_rust can be imported successfully.
27
+
28
+ Notes
29
+ -----
30
+ The result is cached after the first check to avoid repeated import attempts.
31
+ """
32
+ global _rust_module_available
33
+
34
+ if _rust_module_available is not None:
35
+ return _rust_module_available
36
+
37
+ try:
38
+ import glitchlings._zoo_rust # noqa: F401
39
+
40
+ _rust_module_available = True
41
+ log.debug("Rust extension module successfully loaded")
42
+ except (ImportError, ModuleNotFoundError):
43
+ _rust_module_available = False
44
+ log.debug("Rust extension module not available; using Python fallbacks")
45
+
46
+ return _rust_module_available
47
+
48
+
49
+ def get_rust_operation(operation_name: str) -> Callable[..., Any] | None:
50
+ """Load a specific Rust operation by name with caching.
51
+
52
+ Parameters
53
+ ----------
54
+ operation_name : str
55
+ The name of the operation to import from glitchlings._zoo_rust.
56
+
57
+ Returns
58
+ -------
59
+ Callable | None
60
+ The Rust operation callable if available, None otherwise.
61
+
62
+ Examples
63
+ --------
64
+ >>> fatfinger = get_rust_operation("fatfinger")
65
+ >>> if fatfinger is not None:
66
+ ... result = fatfinger(text, ...)
67
+ ... else:
68
+ ... result = python_fallback(text, ...)
69
+
70
+ Notes
71
+ -----
72
+ - Results are cached to avoid repeated imports
73
+ - Returns None if the Rust module is unavailable or the operation doesn't exist
74
+ - All import errors are logged at debug level
75
+ """
76
+ # Check cache first
77
+ if operation_name in _rust_operation_cache:
78
+ return _rust_operation_cache[operation_name]
79
+
80
+ # If the module isn't available, don't try to import individual operations
81
+ if not is_rust_module_available():
82
+ _rust_operation_cache[operation_name] = None
83
+ return None
84
+
85
+ try:
86
+ from glitchlings import _zoo_rust
87
+
88
+ operation = getattr(_zoo_rust, operation_name, None)
89
+ _rust_operation_cache[operation_name] = operation
90
+
91
+ if operation is None:
92
+ log.debug(f"Rust operation '{operation_name}' not found in extension module")
93
+ else:
94
+ log.debug(f"Rust operation '{operation_name}' loaded successfully")
95
+
96
+ return operation
97
+
98
+ except (ImportError, ModuleNotFoundError, AttributeError) as exc:
99
+ log.debug(f"Failed to load Rust operation '{operation_name}': {exc}")
100
+ _rust_operation_cache[operation_name] = None
101
+ return None
102
+
103
+
104
+ def clear_cache() -> None:
105
+ """Clear the operation cache, forcing re-import on next access.
106
+
107
+ This is primarily useful for testing scenarios where the Rust module
108
+ availability might change during runtime.
109
+ """
110
+ global _rust_module_available, _rust_operation_cache
111
+
112
+ _rust_module_available = None
113
+ _rust_operation_cache.clear()
114
+ log.debug("Rust extension cache cleared")
115
+
116
+
117
+ def preload_operations(*operation_names: str) -> dict[str, Callable[..., Any] | None]:
118
+ """Eagerly load multiple Rust operations at once.
119
+
120
+ Parameters
121
+ ----------
122
+ *operation_names : str
123
+ Names of operations to preload.
124
+
125
+ Returns
126
+ -------
127
+ dict[str, Callable | None]
128
+ Mapping of operation names to their callables (or None if unavailable).
129
+
130
+ Examples
131
+ --------
132
+ >>> ops = preload_operations("fatfinger", "reduplicate_words", "delete_random_words")
133
+ >>> fatfinger = ops["fatfinger"]
134
+ """
135
+ return {name: get_rust_operation(name) for name in operation_names}
136
+
137
+
138
+ __all__ = [
139
+ "is_rust_module_available",
140
+ "get_rust_operation",
141
+ "clear_cache",
142
+ "preload_operations",
143
+ ]
glitchlings/zoo/adjax.py CHANGED
@@ -1,16 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import random
4
- from typing import Any
4
+ from typing import Any, cast
5
5
 
6
6
  from ._rate import resolve_rate
7
+ from ._rust_extensions import get_rust_operation
7
8
  from ._text_utils import split_preserving_whitespace, split_token_edges
8
9
  from .core import AttackWave, Glitchling
9
10
 
10
- try:
11
- from glitchlings._zoo_rust import swap_adjacent_words as _swap_adjacent_words_rust
12
- except ImportError: # pragma: no cover - optional acceleration
13
- _swap_adjacent_words_rust = None
11
+ # Load Rust-accelerated operation if available
12
+ _swap_adjacent_words_rust = get_rust_operation("swap_adjacent_words")
14
13
 
15
14
 
16
15
  def _python_swap_adjacent_words(
@@ -83,7 +82,7 @@ def swap_adjacent_words(
83
82
  rng = random.Random(seed)
84
83
 
85
84
  if _swap_adjacent_words_rust is not None:
86
- return _swap_adjacent_words_rust(text, clamped_rate, rng)
85
+ return cast(str, _swap_adjacent_words_rust(text, clamped_rate, rng))
87
86
 
88
87
  return _python_swap_adjacent_words(text, rate=clamped_rate, rng=rng)
89
88