Skip to content
Open
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
3000d4c
draft postponed import pattern for cohere generator
leondz May 2, 2025
757e0f3
move extra dependency requirements into classdefs, mediate requiremen…
leondz May 5, 2025
9310d0a
actually do the plugin dep load
leondz May 5, 2025
dac569e
migrate generators to 'extra dependencies' pattern
leondz May 5, 2025
35e93fc
prune dupe lazyload
leondz May 7, 2025
bf7f36b
extra_dependency_names in all plugins
leondz May 7, 2025
6a39b0c
active must be False for Probes using extra modules
leondz May 7, 2025
56c6182
make PIL optional in generators.huggingface.LLaVA
leondz May 7, 2025
3657e04
move optional load fail to ModuleNotFoundError
leondz May 7, 2025
865d604
add _load/_clear_deps() into base generator and _load/_clear client
leondz May 7, 2025
d61957d
put the MNFE where it belongs
leondz May 7, 2025
8a7051e
backoff exception placeholder must inherit base exception
leondz May 7, 2025
60775f6
test for reqs presence in pyproject.toml, requirements.txt
leondz May 7, 2025
31e98d4
handle hyphen in pypi pkg names
leondz May 7, 2025
75babb7
rm optional plugin deps
leondz May 7, 2025
83f551a
skip generator tests if optional deps absent
leondz May 8, 2025
dd51196
support sub-package deps
leondz May 8, 2025
b33a46c
scope optimum to nvidia
leondz May 8, 2025
de5b3f1
move import function to _load_deps
leondz May 8, 2025
19c31fe
rm import handling in langchain
leondz May 8, 2025
54fabc5
amend optimum to be nvidia flavour
leondz May 8, 2025
ffac714
dry - use garak._plugins.PLUGIN_TYPES as canonical def of 1st class p…
leondz May 8, 2025
97c8160
unify backoff exception pattern mediated via garak GeneratorBackoffEx…
leondz May 9, 2025
1d4e69c
skip instantiation when modules not present
leondz May 9, 2025
6164bc5
catch straggling backoff exception wrappings
leondz May 9, 2025
85fb7c3
Merge branch 'main' into update/optional_imports
leondz May 9, 2025
0402116
use isinstance for exception matching
leondz May 9, 2025
e287fe9
don't backoff on 404
leondz May 9, 2025
6339648
merge in our good pal main
leondz May 16, 2025
76b1774
switch to pyproject; get tests deps if testing
leondz May 16, 2025
ca133e4
add [dev] target
leondz May 16, 2025
8e8a5b9
add required jsonschema that was previously implicit from now-optiona…
leondz May 16, 2025
aa7500a
specify versions; move to secure versions cf. #1207
leondz May 16, 2025
4f2e5ef
skip internal config mappings for req consistency testing
leondz May 16, 2025
69cfef2
skip test option for non-test workflow
leondz May 16, 2025
a1da5ed
skip ollama tests if no module
leondz May 16, 2025
3a8605d
rm spurious dep check
leondz May 16, 2025
d2d17ad
straggling spurious check
leondz May 16, 2025
13974b8
Merge branch 'main' into update/optional_imports
leondz May 28, 2025
dc83929
Merge branch 'main' into update/optional_imports
leondz Jun 8, 2025
d527650
merge in octo removal
leondz Jun 8, 2025
8c46730
add all_plugins option; handle pkg name != import nonsense in pillow
leondz Jun 11, 2025
06180b6
rm unused import
leondz Jun 11, 2025
7c22dea
cache maint workflow gets deps for all plugins
leondz Jun 11, 2025
ce23d70
merge main
leondz Jun 30, 2025
8ab94bd
Merge branch 'main' into update/optional_imports
leondz Jul 3, 2025
bb67a3e
Merge branch 'main' into update/optional_imports
leondz Jul 3, 2025
b45ba35
use correct backoff exception name
leondz Jul 3, 2025
de86505
merg main / turn & conv
leondz Aug 22, 2025
38e6a15
cohere v2 validation: update backoff errors, remove double unpacking …
leondz Sep 25, 2025
64399a5
merge main
leondz Sep 25, 2025
65ac9fe
rm unconditional top level ollama import in test
leondz Sep 25, 2025
ea71cea
wrap llava test global imports in try/except
leondz Sep 25, 2025
14b958d
Update tests/test_reqs.py to use global plugins def
leondz Sep 25, 2025
d0c90ea
move plugin-general tests to tests/plugins
leondz Sep 25, 2025
de28c75
force cache update to include new plugin param
leondz Sep 25, 2025
1742f60
gate dep-requiring tests
leondz Sep 25, 2025
c877225
migrate to deferred loading
leondz Sep 25, 2025
6eede53
cohere generator partial fixes
leondz Sep 26, 2025
2bcca2c
revent to main for cohere
leondz Sep 26, 2025
9d57b5c
add deferred loading to cohere; migrate to new library exception names
leondz Sep 26, 2025
e195148
skip cohere tests if module not present
leondz Sep 26, 2025
f8e8635
skip tomllib-using tests if lib not present
leondz Sep 26, 2025
246b7dc
rm audio,dra hard deps
leondz Sep 26, 2025
b9e3b73
bring pyproject up to standard, add tests
leondz Sep 26, 2025
40b6bcc
summon librosa in probes.audio
leondz Sep 26, 2025
ae5ad5d
deselect audio achilles heel by default
leondz Sep 26, 2025
1a57c8e
force update cache sorry
leondz Sep 26, 2025
738e8e8
skip tests that fail on import (maybe a custom exception is better)
leondz Sep 26, 2025
46e1794
skip tests where deps not present
leondz Sep 29, 2025
dfbe9ac
scan and report all missing modules in _plugins.load_plugin
leondz Sep 29, 2025
35fa3d6
generalise dep loading & clearing to _plugins; activate in probes also
leondz Sep 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install .[tests]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think linter will want to have all possible dependencies.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

- name: Pylint
run: |
pylint -v garak
2 changes: 1 addition & 1 deletion .github/workflows/maintain_cache.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install .
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should install all dependencies as the cache file needs to include all plugins.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

- name: Build a local cache
run: |
export TZ=UTC
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/remote_package_install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
- name: pip install from repo
run: |
python -m pip install --upgrade pip
python -m pip install -U git+https://github.com/${GITHUB_REPOSITORY}.git@${GITHUB_SHA}
python -m pip install -U "git+https://github.com/${GITHUB_REPOSITORY}.git@${GITHUB_SHA}"
- name: Sanity Test
run: |
python -m garak --model_type test.Blank --probes test.Test
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test_linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install --no-cache-dir -r requirements.txt
pip install --no-cache-dir .[tests]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either tests needs to install all optional dependencies or we need to have more tasks that run tests for each dependency group.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

python -m pip cache purge

- name: Restore test cache artifacts
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test_macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
brew install libmagic
cd garak
python -m pip install --upgrade pip
pip install --no-cache-dir -r requirements.txt
pip install --no-cache-dir .[tests]
python -m pip cache purge

- name: Restore test cache artifacts
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test_windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
run: |
python -m pip install --upgrade pip
cd garak
pip install --no-cache-dir -r requirements.txt
pip install --no-cache-dir .[tests]
python -m pip cache purge

- name: Restore test cache artifacts
Expand Down
35 changes: 35 additions & 0 deletions garak/_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,23 @@ def load_plugin(path, break_on_fail=True, config_root=_config) -> object:
) from ve
else:
return False

full_plugin_name = ".".join((category, module_name, plugin_class_name))

# check cache for optional imports
if category in PLUGIN_TYPES:
extra_dependency_names = PluginCache.instance()[category][full_plugin_name][
"extra_dependency_names"
]
if len(extra_dependency_names) > 0:
for dependency_module_name in extra_dependency_names:
for dependency_path in [ # support both plain names and also multi-point names e.g. langchain.llms
".".join(dependency_module_name.split(".")[: n + 1])
for n in range(dependency_module_name.count(".") + 1)
]:
if importlib.util.find_spec(dependency_path) is None:
_import_failed(dependency_path, full_plugin_name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really the best way to do this? Perhaps we just enforce lazy loading throughout instead? I'm not sure.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I guess that is what we're doing. This is the hazard of doing code reviews linearly, I suppose.


module_path = f"garak.{category}.{module_name}"
try:
mod = importlib.import_module(module_path)
Expand All @@ -428,6 +445,7 @@ def load_plugin(path, break_on_fail=True, config_root=_config) -> object:
if plugin_instance is None:
plugin_instance = klass(config_root=config_root)
PluginProvider.storeInstance(plugin_instance, config_root)

except Exception as e:
logging.warning(
"Exception instantiating %s.%s: %s",
Expand All @@ -442,3 +460,20 @@ def load_plugin(path, break_on_fail=True, config_root=_config) -> object:
return False

return plugin_instance


def load_optional_module(module_name: str):
try:
m = importlib.import_module(module_name)
except ModuleNotFoundError:
requesting_module = Path(inspect.stack()[1].filename).name.replace(".py", "")
_import_failed(module_name, requesting_module)
return m


def _import_failed(import_module: str, calling_module: str):
msg = f"⛔ Plugin '{calling_module}' requires Python module '{import_module}' but this isn't installed/available."
hint = f"💡 Try 'pip install {import_module}' to get it."
logging.critical(msg)
print(msg + "\n" + hint)
raise ModuleNotFoundError(msg)
2 changes: 2 additions & 0 deletions garak/buffs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class Buff(Configurable):
doc_uri = ""
lang = None # set of languages this buff should be constrained to
active = True
# list of strings naming modules required but not explicitly in garak by default
extra_dependency_names = []

DEFAULT_PARAMS = {}

Expand Down
2 changes: 2 additions & 0 deletions garak/detectors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class Detector(Configurable):
accuracy = None
active = True
tags = [] # list of taxonomy categories per the MISP format
# list of strings naming modules required but not explicitly in garak by default
extra_dependency_names = []

# support mainstream any-to-any large models
# legal element for str list `modality['in']`: 'text', 'image', 'audio', 'video', '3d'
Expand Down
3 changes: 2 additions & 1 deletion garak/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class ModelNameMissingError(GarakException):
"""A generator requires model_name to be set, but it wasn't"""


class GarakBackoffTrigger(GarakException):
class GeneratorBackoffTrigger(GarakException):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why rename this? The original name seems clear enough to me, I there some envisioned case where two layers of backoff would need to differentiate the source?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two reasons:

  • didn't like the redundant garak prefix
  • want granularity to scope to generators (agree this is currently speculative, but a generic BackoffException seems begging for trouble in a few quarters in e.g. adaptive probes, llmaaj)

"""Thrown when backoff should be triggered"""


Expand All @@ -36,3 +36,4 @@ class ConfigFailure(GarakException):

class PayloadFailure(GarakException):
"""Problem instantiating/using payloads"""

1 change: 1 addition & 0 deletions garak/generators/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def _validate_env_var(self):
return super()._validate_env_var()

def _load_client(self):
self._load_deps()
if self.model_name in openai_model_mapping:
self.model_name = openai_model_mapping[self.model_name]

Expand Down
27 changes: 26 additions & 1 deletion garak/generators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class Generator(Configurable):
supports_multiple_generations = (
False # can more than one generation be extracted per request?
)
# list of strings naming modules required but not explicitly in garak by default
extra_dependency_names = []

def __init__(self, name="", config_root=_config):
self._load_config(config_root)
Expand All @@ -63,6 +65,29 @@ def __init__(self, name="", config_root=_config):
f"🦜 loading {Style.BRIGHT}{Fore.LIGHTMAGENTA_EX}generator{Style.RESET_ALL}: {self.generator_family_name}: {self.name}"
)
logging.info("generator init: %s", self)
self._load_deps()

def _load_deps(self):
# load external dependencies. should be invoked at construction and
# in _client_load (if used)
for extra_dependency in self.extra_dependency_names:
extra_dep_name = extra_dependency.replace(".", "_").replace("-", "_")
if (
not hasattr(self, extra_dep_name)
or getattr(self, extra_dep_name) is None
):
setattr(
self,
extra_dep_name,
garak._plugins.load_optional_module(extra_dependency),
)

def _clear_deps(self):
# unload external dependencies from class. should be invoked before
# serialisation, esp. in _clear_client (if used)
for extra_dependency in self.extra_dependency_names:
extra_dep_name = extra_dependency.replace(".", "_")
setattr(self, extra_dep_name, None)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in Configurable instead, since it can/should be used across all base classes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

definitely makes sense to factor it up, thanks

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the generator implementation is special, because generators have client load/unload for probe parallelisation. will slate upfactoring for a second iteration.


def _call_model(
self, prompt: str, generations_this_call: int = 1
Expand Down Expand Up @@ -101,7 +126,7 @@ def _prune_skip_sequences(self, outputs: List[str | None]) -> List[str | None]:
)
rx_missing_final = re.escape(self.skip_seq_start) + ".*?$"
rx_missing_start = ".*?" + re.escape(self.skip_seq_end)

if self.skip_seq_start == "":
complete_seqs_removed = [
(
Expand Down
41 changes: 25 additions & 16 deletions garak/generators/cohere.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
from typing import List, Union

import backoff
import cohere
import tqdm

from garak import _config
from garak.exception import GeneratorBackoffTrigger
from garak.generators.base import Generator


Expand All @@ -38,11 +38,13 @@ class CohereGenerator(Generator):
"presence_penalty": 0.0,
"stop": [],
}
extra_dependency_names = ["cohere"]

supports_multiple_generations = True
generator_family_name = "Cohere"

def __init__(self, name="command", config_root=_config):

self.name = name
self.fullname = f"Cohere {self.name}"

Expand All @@ -51,9 +53,9 @@ def __init__(self, name="command", config_root=_config):
logging.debug(
"Cohere generation request limit capped at %s", COHERE_GENERATION_LIMIT
)
self.generator = cohere.Client(self.api_key)
self.generator = self.cohere.Client(self.api_key)

@backoff.on_exception(backoff.fibo, cohere.error.CohereAPIError, max_value=70)
@backoff.on_exception(backoff.fibo, GeneratorBackoffTrigger, max_value=70)
def _call_cohere_api(self, prompt, request_size=COHERE_GENERATION_LIMIT):
"""as of jun 2 2023, empty prompts raise:
cohere.error.CohereAPIError: invalid request: prompt must be at least 1 token long
Expand All @@ -63,19 +65,26 @@ def _call_cohere_api(self, prompt, request_size=COHERE_GENERATION_LIMIT):
if prompt == "":
return [""] * request_size
else:
response = self.generator.generate(
model=self.name,
prompt=prompt,
temperature=self.temperature,
num_generations=request_size,
max_tokens=self.max_tokens,
preset=self.preset,
k=self.k,
p=self.p,
frequency_penalty=self.frequency_penalty,
presence_penalty=self.presence_penalty,
end_sequences=self.stop,
)
try:
response = self.generator.generate(
model=self.name,
prompt=prompt,
temperature=self.temperature,
num_generations=request_size,
max_tokens=self.max_tokens,
preset=self.preset,
k=self.k,
p=self.p,
frequency_penalty=self.frequency_penalty,
presence_penalty=self.presence_penalty,
end_sequences=self.stop,
)
except Exception as e:
backoff_exception_types = [self.cohere.error.CohereAPIError]
for backoff_exception in backoff_exception_types:
if isinstance(e, backoff_exception):
raise GeneratorBackoffTrigger from e
raise e
return [g.text for g in response]

def _call_model(
Expand Down
1 change: 1 addition & 0 deletions garak/generators/groq.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class GroqChat(OpenAICompatible):
generator_family_name = "Groq"

def _load_client(self):
self._load_deps()
self.client = openai.OpenAI(base_url=self.uri, api_key=self.api_key)
if self.name in ("", None):
raise ValueError(
Expand Down
18 changes: 6 additions & 12 deletions garak/generators/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,21 @@ class NeMoGuardrails(Generator):

supports_multiple_generations = False
generator_family_name = "Guardrails"
extra_dependency_names = ["nemoguardrails"]

def __init__(self, name="", config_root=_config):
# another class that may need to skip testing due to non required dependency
try:
from nemoguardrails import RailsConfig, LLMRails
from nemoguardrails.logging.verbose import set_verbose
except ImportError as e:
raise NameError(
"You must first install NeMo Guardrails using `pip install nemoguardrails`."
) from e

self.name = name
self._load_config(config_root)
self.fullname = f"Guardrails {self.name}"

super().__init__(self.name, config_root=config_root)

set_verbose = self.nemoguardrails.logging.verbose.set_verbose
# Currently, we use the model_name as the path to the config
with redirect_stderr(io.StringIO()) as f: # quieten the tqdm
config = RailsConfig.from_path(self.name)
self.rails = LLMRails(config=config)

super().__init__(self.name, config_root=config_root)
config = self.nemoguardrails.RailsConfig.from_path(self.name)
self.rails = self.nemoguardrails.LLMRails(config=config)

def _call_model(
self, prompt: str, generations_this_call: int = 1
Expand Down
Loading
Loading