diff --git a/docs/AGENTS_TEMPLATE.md b/docs/AGENTS_TEMPLATE.md index 116315526..accb60070 100644 --- a/docs/AGENTS_TEMPLATE.md +++ b/docs/AGENTS_TEMPLATE.md @@ -15,6 +15,38 @@ Use `mellea` for LLM interactions. No direct OpenAI/Anthropic calls or LangChain **Prerequisites**: `pip install mellea` · [Docs](https://mellea.ai) · [Repo](https://github.com/generative-computing/mellea) +**Imports** — prefer the top-level prelude. Most names are re-exported from +`mellea` itself, so a program rarely needs more than one or two import lines: + +```python +from mellea import ChatContext, Instruction, Message, Requirement, req, start_session +``` + +The prelude covers sessions (`start_session`, `MelleaSession`, `start_backend`, +`generative`, `mfuncs`), components (`Message`, `Instruction`, `Document`, +`Intrinsic`, `SimpleComponent`, `mify`, `CBlock`, `Component`, `ModelOutputThunk`, +`TemplateRepresentation`), contexts (`ChatContext`, `SimpleContext`, `Context`), +requirements (`Requirement`, `ValidationResult`, `req`, `check`, `simple_validate`), +sampling (`RejectionSamplingStrategy`, `SamplingResult`), and backend config +(`Backend`, `ModelOption`, `model_ids`). + +Many symbols are reachable from several modules. Use the canonical path so +imports stay consistent across a codebase: + +- Anything listed above → `mellea`, not a sub-package. +- Other protocols and data types → `mellea.core`, not `mellea.core.base` / + `.backend` / `.requirement`. +- Other components, contexts, requirements, strategies → + `mellea.stdlib.`, not leaf modules like `.chat` or `.simple`. +- A concrete backend → `mellea.backends.`. + +Concrete backends are intentionally **not** in the prelude — import them explicitly +so a missing optional dependency gives a targeted install hint: + +```python +from mellea.backends.ollama import OllamaModelBackend +``` + #### 1. The `@generative` Pattern **Don't** write prompt templates or regex parsers: ```python diff --git a/mellea/__init__.py b/mellea/__init__.py index 2f49eaf40..740f47bdb 100644 --- a/mellea/__init__.py +++ b/mellea/__init__.py @@ -3,11 +3,41 @@ """Mellea.""" +# Concrete backends are deliberately not re-exported here. They have optional +# dependencies and are imported explicitly from `mellea.backends.` so +# that a missing extra produces a targeted install hint rather than an error on +# `import mellea`. + from importlib.metadata import PackageNotFoundError, version from . import serve from .backends import model_ids +from .backends.model_options import ModelOption +from .core import ( + Backend, + CBlock, + Component, + Context, + MelleaLogger, + ModelOutputThunk, + Requirement, + SamplingResult, + TemplateRepresentation, + ValidationResult, +) +from .stdlib import functional as mfuncs +from .stdlib.components import ( + Document, + Instruction, + Intrinsic, + Message, + SimpleComponent, + mify, +) from .stdlib.components.genstub import generative +from .stdlib.context import ChatContext, SimpleContext +from .stdlib.requirements import check, req, simple_validate +from .stdlib.sampling import RejectionSamplingStrategy from .stdlib.session import MelleaSession, start_session from .stdlib.start_backend import start_backend @@ -19,11 +49,35 @@ __version__ = "unknown" __all__ = [ + "Backend", + "CBlock", + "ChatContext", + "Component", + "Context", + "Document", + "Instruction", + "Intrinsic", + "MelleaLogger", "MelleaSession", + "Message", + "ModelOption", + "ModelOutputThunk", + "RejectionSamplingStrategy", + "Requirement", + "SamplingResult", + "SimpleComponent", + "SimpleContext", + "TemplateRepresentation", + "ValidationResult", "__version__", + "check", "generative", + "mfuncs", + "mify", "model_ids", + "req", "serve", + "simple_validate", "start_backend", "start_session", ] diff --git a/mellea/stdlib/sampling/budget_forcing.py b/mellea/stdlib/sampling/budget_forcing.py index a6f54dd63..2df57f40f 100644 --- a/mellea/stdlib/sampling/budget_forcing.py +++ b/mellea/stdlib/sampling/budget_forcing.py @@ -7,7 +7,6 @@ import tqdm -from ...backends.ollama import OllamaModelBackend from ...core import ( Backend, BaseModelSubclass, @@ -174,6 +173,10 @@ async def sample( assert tool_calls is False, ( "tool_calls is not supported with budget forcing" ) + # Imported here rather than at module scope so that the `ollama` + # client is not pulled into every `import mellea`. + from ...backends.ollama import OllamaModelBackend + # TODO assert isinstance(backend, OllamaModelBackend), ( "Only ollama backend supported with budget forcing" diff --git a/mellea/stdlib/sampling/majority_voting.py b/mellea/stdlib/sampling/majority_voting.py index 61e746e17..91fa2b6ca 100644 --- a/mellea/stdlib/sampling/majority_voting.py +++ b/mellea/stdlib/sampling/majority_voting.py @@ -3,12 +3,13 @@ """Sampling Strategies for Minimum Bayes Risk Decoding (MBRD).""" +from __future__ import annotations + import abc import asyncio +from typing import TYPE_CHECKING import numpy as np -from math_verify import ExprExtractionConfig, LatexExtractionConfig, parse, verify -from rouge_score.rouge_scorer import RougeScorer # codespell:ignore from ...core import ( Backend, @@ -23,6 +24,40 @@ ) from .base import RejectionSamplingStrategy +if TYPE_CHECKING: + # `math_verify` and `rouge_score` are imported in the constructors of the + # strategies that need them rather than at module scope. Both are required + # dependencies, but `rouge_score` pulls in nltk (and transitively + # scipy/scikit-learn/pandas), which dominated `import mellea` even for programs + # that never perform majority voting. + from math_verify import ExprExtractionConfig, LatexExtractionConfig, parse, verify + from rouge_score.rouge_scorer import RougeScorer # codespell:ignore + + +def _build_extraction_targets( + match_types: tuple[str, ...], +) -> list[LatexExtractionConfig | ExprExtractionConfig]: + """Build `math_verify` extraction targets for the given match types. + + Only `"latex"` and `"expr"` are recognized; anything else is ignored, so an + empty list is a possible result. + + Args: + match_types: Match-type names, in the order the targets should be tried. + + Returns: + One `math_verify` extraction-target config per recognized match type. + """ + from math_verify import ExprExtractionConfig, LatexExtractionConfig + + targets: list[LatexExtractionConfig | ExprExtractionConfig] = [] + for match_type in match_types: + if match_type == "latex": + targets.append(LatexExtractionConfig(boxed_match_priority=0)) + elif match_type == "expr": + targets.append(ExprExtractionConfig()) + return targets + class BaseMBRDSampling(RejectionSamplingStrategy): """Abstract Minimum Bayes Risk Decoding (MBRD) Sampling Strategy. @@ -209,7 +244,8 @@ class MajorityVotingStrategyForMath(BaseMBRDSampling): Attributes: match_types (list[str]): Extraction target types used for parsing math - expressions; always `["latex", "axpr"]`, computed at init. + expressions; defaults to `["latex", "expr"]`. Changing it rebuilds the + extraction targets on the next comparison. symmetric (bool): Inherited from `BaseMBRDSampling`; always `True` for this strategy (set explicitly at init). """ @@ -219,6 +255,8 @@ class MajorityVotingStrategyForMath(BaseMBRDSampling): float_rounding: int strict: bool allow_set_relation_comp: bool + _extraction_targets: list[LatexExtractionConfig | ExprExtractionConfig] + _extraction_targets_key: tuple[str, ...] def __init__( self, @@ -242,16 +280,32 @@ def __init__( loop_budget=loop_budget, requirements=requirements, ) + + from math_verify import parse, verify + + # Use `_parse` and `_verify` so the functions can be referenced in `compare_strings`. + self._parse = parse + self._verify = verify + self.number_of_samples = number_of_samples # match_type: type of match latex, expr (match only so far) # - For math use "latex" or "expr" or both # - For general text similarity use "rougel" - MATCH_TYPES = ["latex", "axpr"] + MATCH_TYPES = ["latex", "expr"] self.match_types = MATCH_TYPES self.float_rounding = float_rounding self.strict = strict self.allow_set_relation_comp = allow_set_relation_comp + # Seeded here so the first `compare_strings` call does not pay for it; + # `compare_strings` runs O(n^2) times per `sample` call. `match_types` is + # public, so the cache is keyed on its contents and rebuilt if a caller + # changes it. + self._extraction_targets_key = tuple(self.match_types) + self._extraction_targets = _build_extraction_targets( + self._extraction_targets_key + ) + # Note: symmetry is not implied for certain expressions, see: https://github.com/huggingface/Math-Verify/blob/5d148cfaaf99214c2e4ffb4bc497ab042c592a7a/README.md?plain=1#L183 self.symmetric = True @@ -259,9 +313,10 @@ def __init__( def compare_strings(self, ref: str, pred: str) -> float: """Compare two strings using math-aware extraction and verification. - Parses both strings into mathematical expressions using the configured - `match_types` (latex and/or expr), then verifies equivalence via - `math_verify.verify`. + Parses both strings into mathematical expressions using extraction + targets derived from `match_types`, then verifies equivalence via + `math_verify.verify`. The targets are cached and rebuilt only when + `match_types` changes. Args: ref (str): The reference (gold) string containing a math expression. @@ -271,19 +326,20 @@ def compare_strings(self, ref: str, pred: str) -> float: float: `1.0` if the expressions are considered equivalent, `0.0` otherwise. """ - # Convert string match_types to ExtractionTarget objects - extraction_targets = [] - for match_type in self.match_types: - if match_type == "latex": - extraction_targets.append(LatexExtractionConfig(boxed_match_priority=0)) - elif match_type == "expr": - extraction_targets.append(ExprExtractionConfig()) + # Rebuild the cached targets if a caller changed the public `match_types`. + # A property / setter approach that updates on match_types changes would not work + # due to slice-assigns: `s.match_types[:] = ["latex"]`. + match_types = tuple(self.match_types) + if match_types != self._extraction_targets_key: + self._extraction_targets = _build_extraction_targets(match_types) + self._extraction_targets_key = match_types + targets = self._extraction_targets # NOTE: Math-Verify parse and verify functions don't support threaded environment due to usage of signal.alarm() in timeout mechanism. If you need to run in multithreaded environment it's recommended to set the parsing_timeout=None - gold_parsed = parse(ref, extraction_targets, parsing_timeout=None) # type: ignore - pred_parsed = parse(pred, extraction_targets, parsing_timeout=None) # type: ignore + gold_parsed = self._parse(ref, targets, parsing_timeout=None) # type: ignore + pred_parsed = self._parse(pred, targets, parsing_timeout=None) # type: ignore return float( - verify( + self._verify( gold_parsed, pred_parsed, float_rounding=self.float_rounding, @@ -335,6 +391,8 @@ def __init__( loop_budget=loop_budget, requirements=requirements, ) + from rouge_score.rouge_scorer import RougeScorer # codespell:ignore + self.match_types = ["rougeL"] self.symmetric = True self.scorer = RougeScorer(self.match_types, use_stemmer=True) diff --git a/mellea/stdlib/sampling/sampling_algos/budget_forcing_alg.py b/mellea/stdlib/sampling/sampling_algos/budget_forcing_alg.py index e7e34bc29..cae5672d1 100644 --- a/mellea/stdlib/sampling/sampling_algos/budget_forcing_alg.py +++ b/mellea/stdlib/sampling/sampling_algos/budget_forcing_alg.py @@ -11,8 +11,11 @@ `OllamaModelBackend`. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + from ....backends import ModelOption -from ....backends.ollama import OllamaModelBackend from ....core import ( BaseModelSubclass, CBlock, @@ -22,6 +25,11 @@ Span, ) +if TYPE_CHECKING: + # Annotation-only: importing the Ollama backend eagerly would pull the + # `ollama` client (and httpx) into every `import mellea`. + from ....backends.ollama import OllamaModelBackend + async def think_budget_forcing( backend: OllamaModelBackend, diff --git a/mellea/telemetry/pricing.py b/mellea/telemetry/pricing.py index f6043cd37..90905be59 100644 --- a/mellea/telemetry/pricing.py +++ b/mellea/telemetry/pricing.py @@ -8,17 +8,20 @@ `MELLEA_PRICING_ENABLED` environment variable. `MELLEA_PRICING_ENABLED` tri-state: - - `"true"` + litellm installed → enabled + - `"true"` + litellm importable → enabled - `"true"` + litellm absent → warning, disabled - `"false"` (any) → disabled (silent) - - unset + litellm installed → enabled (auto) - - unset + litellm absent → disabled (silent) + - unset + litellm importable → enabled (auto) + - unset + litellm absent → disabled (silent) + +If litellm is discoverable but fails to import (a skewed install), pricing is +disabled on first use with a logged warning; it never raises into caller code. Pricing is only active when `MELLEA_METRICS_ENABLED` is also set. Custom pricing: Set `MELLEA_PRICING_FILE` to a JSON file using litellm's native per-token - schema. Minimal entries with only cost fields are supported:: + schema. Minimal entries with only cost fields are supported: { "my-model": { @@ -26,7 +29,6 @@ "output_cost_per_token": 0.000015 } } - Optional cache fields: `cache_read_input_token_cost`, `cache_creation_input_token_cost`. @@ -35,21 +37,23 @@ - MELLEA_PRICING_FILE: Path to a JSON file with custom model pricing. """ +import importlib.util import json import logging import os import warnings from pathlib import Path +from types import ModuleType logger = logging.getLogger(__name__) -try: - import litellm # type: ignore[import-not-found] - - _LITELLM_AVAILABLE = True -except ImportError: - litellm = None # type: ignore - _LITELLM_AVAILABLE = False +# Availability is probed without importing: `import litellm` costs ~1s and +# transitively pulls in openai and pandas, which every `import mellea` would pay +# even when pricing is disabled. `find_spec` only proves a loader exists, so the +# real import happens at the point of use via `_import_litellm`, which downgrades +# to "pricing disabled" if the module fails to execute. Either way the cost lands +# on the first priced request instead of on `import mellea`. +_LITELLM_AVAILABLE = importlib.util.find_spec("litellm") is not None def _resolve_pricing_enabled() -> bool: @@ -60,8 +64,9 @@ def _resolve_pricing_enabled() -> bool: if _LITELLM_AVAILABLE: return True warnings.warn( - "MELLEA_PRICING_ENABLED=true but litellm is not installed — " - "pricing metrics disabled. Install with: pip install 'mellea[litellm]'", + "MELLEA_PRICING_ENABLED=true but litellm is not installed or could " + "not be imported — pricing metrics disabled. " + "Install with: pip install 'mellea[litellm]'", stacklevel=2, ) return False @@ -73,6 +78,38 @@ def _resolve_pricing_enabled() -> bool: _warned_models: set[str] = set() +def _import_litellm() -> ModuleType | None: + """Import litellm, disabling pricing if the import fails. + + `_LITELLM_AVAILABLE` is probed with `find_spec`, which proves only that a + loader exists — not that the module executes. A skewed install (mismatched + pydantic, half-installed openai, wrong native ABI) can raise almost anything + from litellm's module body, so every failure is treated as "no pricing" + rather than propagated: this is a best-effort telemetry path and + `compute_cost` is called from a fire-and-forget metrics hook. + + Both `_LITELLM_AVAILABLE` and `_PRICING_ENABLED` are cleared on failure, so + the warning is emitted at most once per process. + + Returns: + The imported `litellm` module, or `None` if it could not be imported. + """ + global _LITELLM_AVAILABLE, _PRICING_ENABLED + try: + import litellm # type: ignore[import-not-found] + + return litellm + except Exception as exc: + logger.warning( + "litellm was discoverable but failed to import (%s: %s) — " + "pricing metrics disabled.", + type(exc).__name__, + exc, + ) + _LITELLM_AVAILABLE = _PRICING_ENABLED = False + return None + + def _register_custom_pricing(path: str | Path) -> None: """Load MELLEA_PRICING_FILE and register entries with litellm.""" try: @@ -89,6 +126,10 @@ def _register_custom_pricing(path: str | Path) -> None: "Custom pricing file %r must be a JSON object — skipping.", str(path) ) return + litellm = _import_litellm() + if litellm is None: + return + try: litellm.register_model(data) except Exception as exc: @@ -140,6 +181,11 @@ def compute_cost( """ if not _PRICING_ENABLED: return None + + litellm = _import_litellm() + if litellm is None: + return None + try: prompt_cost, completion_cost = litellm.cost_per_token( model=model, diff --git a/pyproject.toml b/pyproject.toml index 5c43347eb..7ba9c7ee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,7 +110,8 @@ switch = [ backends = ["mellea[watsonx,hf,litellm]"] hooks = [ - "cpex>=0.1.0", + # cpex >= 0.2 will change the api and silently break plugins. + "cpex>=0.1.0,<0.2", "grpcio>=1.78.0", ] @@ -323,7 +324,8 @@ disable_error_code = [ ] [[tool.mypy.overrides]] -# cpex is a private optional dependency not available in all environments +# cpex is an optional dependency (the `hooks` extra), so it is absent from +# environments that install mellea without it. module = "cpex.*" ignore_missing_imports = true diff --git a/test/stdlib/sampling/test_majority_voting_unit.py b/test/stdlib/sampling/test_majority_voting_unit.py index eee4fb87e..84fa35309 100644 --- a/test/stdlib/sampling/test_majority_voting_unit.py +++ b/test/stdlib/sampling/test_majority_voting_unit.py @@ -5,6 +5,7 @@ import pytest +from mellea.stdlib.sampling import majority_voting from mellea.stdlib.sampling.majority_voting import ( MajorityVotingStrategyForMath, MBRDRougeLStrategy, @@ -26,11 +27,16 @@ def test_math_compare_identical_latex(math_strategy): assert math_strategy.compare_strings(r"\boxed{4}", r"\boxed{4}") == 1.0 -def test_math_compare_unboxed_integers_return_zero(math_strategy): - # Plain integers without boxed notation are not extracted — returns 0.0 +def test_math_compare_different_unboxed_integers_return_zero(math_strategy): assert math_strategy.compare_strings("2", "3") == 0.0 +def test_math_compare_equal_unboxed_integers(math_strategy): + # Bare expressions are extracted via the "expr" match type, so two samples + # that both answered a plain `2` agree. + assert math_strategy.compare_strings("2", "2") == 1.0 + + def test_math_compare_different_boxed(math_strategy): assert math_strategy.compare_strings(r"\boxed{2}", r"\boxed{3}") == 0.0 @@ -40,6 +46,63 @@ def test_math_compare_returns_float(math_strategy): assert isinstance(result, float) +# --- MajorityVotingStrategyForMath extraction-target cache --- + + +def test_math_match_types_default(math_strategy): + assert math_strategy.match_types == ["latex", "expr"] + + +def test_math_compare_respects_mutated_match_types(math_strategy): + # The cached extraction targets must follow the public `match_types`. + math_strategy.match_types[:] = ["latex"] + assert math_strategy.compare_strings("1+1", "2") == 0.0 + + +def test_math_compare_respects_reassigned_match_types(math_strategy): + math_strategy.match_types = ["latex"] + assert math_strategy.compare_strings("1+1", "2") == 0.0 + + +def test_math_compare_reverts_when_match_types_restored(math_strategy): + math_strategy.match_types[:] = ["latex"] + math_strategy.compare_strings("1+1", "2") + math_strategy.match_types[:] = ["latex", "expr"] + assert math_strategy.compare_strings("1+1", "2") == 1.0 + + +def test_math_compare_does_not_rebuild_targets_when_unchanged( + math_strategy, monkeypatch +): + calls: list[tuple[str, ...]] = [] + monkeypatch.setattr( + majority_voting, + "_build_extraction_targets", + lambda match_types: calls.append(match_types) or [], + ) + math_strategy.compare_strings(r"\boxed{2}", r"\boxed{2}") + math_strategy.compare_strings(r"\boxed{3}", r"\boxed{3}") + + assert calls == [] + + +def test_math_compare_rebuilds_targets_when_match_types_change( + math_strategy, monkeypatch +): + calls: list[tuple[str, ...]] = [] + real = majority_voting._build_extraction_targets + + def counted(match_types): + calls.append(match_types) + return real(match_types) + + monkeypatch.setattr(majority_voting, "_build_extraction_targets", counted) + math_strategy.match_types[:] = ["latex"] + math_strategy.compare_strings("1+1", "2") + + assert calls == [("latex",)] + + # --- MBRDRougeLStrategy.compare_strings --- diff --git a/test/telemetry/test_pricing.py b/test/telemetry/test_pricing.py index 6b7101a1b..8529cd4fc 100644 --- a/test/telemetry/test_pricing.py +++ b/test/telemetry/test_pricing.py @@ -3,7 +3,10 @@ """Unit tests for the litellm-backed pricing module.""" +import builtins import json +import logging +import sys from unittest.mock import MagicMock, patch import pytest @@ -28,12 +31,32 @@ def mock_litellm_pricing(): mock.cost_per_token.return_value = (0.001, 0.002) with ( patch("mellea.telemetry.pricing._PRICING_ENABLED", True), - patch("mellea.telemetry.pricing.litellm", mock), + patch.dict(sys.modules, {"litellm": mock}), patch("mellea.telemetry.pricing._warned_models", set()), ): yield mock +@pytest.fixture() +def unimportable_litellm(monkeypatch): + """Pricing enabled, but `import litellm` fails. + + A `None` entry in `sys.modules` is CPython's "blocked module" sentinel: + `import litellm` raises ImportError while the distribution stays on disk, + which is exactly the skewed-install case `find_spec` cannot detect. + """ + monkeypatch.setattr(pricing, "_LITELLM_AVAILABLE", True) + monkeypatch.setattr(pricing, "_PRICING_ENABLED", True) + monkeypatch.setattr(pricing, "_warned_models", set()) + monkeypatch.setitem(sys.modules, "litellm", None) + yield + # `_import_litellm` clears the module globals. Undo the patches first, then + # re-run setup, so teardown ordering against `restore_pricing` (which also + # depends on monkeypatch, and so is torn down last) is deterministic. + monkeypatch.undo() + reset_pricing_state() + + # --------------------------------------------------------------------------- # Tri-state flag — module init logic # --------------------------------------------------------------------------- @@ -97,7 +120,7 @@ def test_register_custom_pricing_calls_register_model(tmp_path): pricing_file.write_text(json.dumps(data)) mock_litellm = MagicMock() - with patch("mellea.telemetry.pricing.litellm", mock_litellm): + with patch.dict(sys.modules, {"litellm": mock_litellm}): pricing._register_custom_pricing(str(pricing_file)) mock_litellm.register_model.assert_called_once_with(data) @@ -192,7 +215,7 @@ def test_compute_cost_disabled_returns_none(): mock_litellm = MagicMock() with ( patch("mellea.telemetry.pricing._PRICING_ENABLED", False), - patch("mellea.telemetry.pricing.litellm", mock_litellm), + patch.dict(sys.modules, {"litellm": mock_litellm}), ): cost = pricing.compute_cost("gpt-5.4", None, 100, 50) @@ -202,8 +225,6 @@ def test_compute_cost_disabled_returns_none(): def test_compute_cost_unknown_model_warns_once(mock_litellm_pricing, caplog): """Exception from litellm → None + log warning on first failure only.""" - import logging - mock_litellm_pricing.cost_per_token.side_effect = ValueError("No model data") with caplog.at_level(logging.WARNING, logger="mellea.telemetry.pricing"): cost = pricing.compute_cost("unknown-model-xyz", None, 100, 50) @@ -213,3 +234,73 @@ def test_compute_cost_unknown_model_warns_once(mock_litellm_pricing, caplog): pricing.compute_cost("unknown-model-xyz", None, 100, 50) assert not any("unknown-model-xyz" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# litellm discoverable via find_spec but not importable +# --------------------------------------------------------------------------- + + +def test_compute_cost_unimportable_litellm_returns_none(unimportable_litellm): + """A litellm that fails to import yields None instead of propagating.""" + assert pricing.compute_cost("gpt-5.4", "openai", 100, 50) is None + + +def test_compute_cost_unimportable_litellm_disables_pricing(unimportable_litellm): + """A failed litellm import latches pricing off for the rest of the process.""" + pricing.compute_cost("gpt-5.4", "openai", 100, 50) + + assert pricing.is_pricing_enabled() is False + + +def test_compute_cost_unimportable_litellm_warns_once(unimportable_litellm, caplog): + """The import failure is logged on the first call only.""" + with caplog.at_level(logging.WARNING, logger="mellea.telemetry.pricing"): + pricing.compute_cost("gpt-5.4", "openai", 100, 50) + assert any("failed to import" in r.message for r in caplog.records) + caplog.clear() + pricing.compute_cost("gpt-5.4", "openai", 100, 50) + + assert not any("failed to import" in r.message for r in caplog.records) + + +def test_compute_cost_non_import_error_from_litellm_returns_none(monkeypatch): + """A skewed install that raises AttributeError on import is absorbed too.""" + real_import = builtins.__import__ + + def failing_import(name, *args, **kwargs): + if name == "litellm": + raise AttributeError("partially-installed dependency") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(pricing, "_LITELLM_AVAILABLE", True) + monkeypatch.setattr(pricing, "_PRICING_ENABLED", True) + monkeypatch.setattr(builtins, "__import__", failing_import) + + assert pricing.compute_cost("gpt-5.4", "openai", 100, 50) is None + + +def test_register_custom_pricing_unimportable_litellm_does_not_raise( + tmp_path, unimportable_litellm +): + """A valid pricing file plus an unimportable litellm is a no-op, not an error.""" + pricing_file = tmp_path / "pricing.json" + pricing_file.write_text(json.dumps({"m": {"input_cost_per_token": 1e-6}})) + + pricing._register_custom_pricing(str(pricing_file)) # must not raise + + assert pricing.is_pricing_enabled() is False + + +def test_setup_pricing_unimportable_litellm_does_not_raise( + tmp_path, monkeypatch, unimportable_litellm +): + """`import mellea` survives MELLEA_PRICING_FILE plus an unimportable litellm.""" + pricing_file = tmp_path / "pricing.json" + pricing_file.write_text(json.dumps({"m": {"input_cost_per_token": 1e-6}})) + monkeypatch.setenv("MELLEA_PRICING_ENABLED", "true") + monkeypatch.setenv("MELLEA_PRICING_FILE", str(pricing_file)) + + pricing._setup_pricing() # must not raise + + assert pricing.is_pricing_enabled() is False diff --git a/test/test_import_hygiene.py b/test/test_import_hygiene.py new file mode 100644 index 000000000..63dae95e3 --- /dev/null +++ b/test/test_import_hygiene.py @@ -0,0 +1,95 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Guards that `import mellea` stays cheap. + +Several heavy third-party packages are reachable from mellea but are only needed +by narrow features (majority-voting sampling, pricing metrics, individual +backends). They are imported lazily at first use. These tests fail if any of them +is pulled back to module scope, which would silently re-add seconds to every +`import mellea`. +""" + +import importlib.util +import json +import subprocess +import sys + +import pytest + +# Packages that must not be loaded by a bare `import mellea`, and the feature +# that legitimately needs each one. +FORBIDDEN_ON_IMPORT = { + "nltk": "granite citation parsing / rouge_score", + "scipy": "transitive via nltk", + "sklearn": "transitive via nltk", + "pandas": "transitive via litellm", + "litellm": "pricing metrics + LiteLLM backend", + "openai": "transitive via litellm; OpenAI backend", + "rouge_score": "MBRDRougeLStrategy", + "math_verify": "MajorityVotingStrategyForMath", + "torch": "HuggingFace backend", + "transformers": "HuggingFace backend", + # Provider SDKs. Concrete backends are imported explicitly by user code from + # `mellea.backends.`, so none of their clients should load here — + # `import mellea` must not commit the caller to a provider. + "ollama": "Ollama backend", + "ibm_watsonx_ai": "Watsonx backend", + "boto3": "Bedrock backend", + "docling": "RichDocument", + "matplotlib": "plotting requirements", +} + + +def _modules_loaded_by(statement: str) -> set[str]: + """Return the set of top-level modules in sys.modules after running `statement`. + + Runs in a subprocess so the parent test session's already-imported modules + do not pollute the result. + + Args: + statement: Python source executed before sys.modules is sampled. + + Returns: + Top-level module names (text before the first dot) present in + `sys.modules` after the statement runs. + """ + code = ( + f"{statement}\n" + "import sys, json\n" + "print(json.dumps(sorted({m.split('.')[0] for m in sys.modules})))\n" + ) + proc = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, check=True + ) + return set(json.loads(proc.stdout.strip().splitlines()[-1])) + + +@pytest.fixture(scope="module") +def modules_after_import_mellea() -> set[str]: + """Top-level modules loaded by a bare `import mellea`.""" + return _modules_loaded_by("import mellea") + + +@pytest.mark.parametrize( + ("package", "reason"), sorted((k, v) for k, v in FORBIDDEN_ON_IMPORT.items()) +) +def test_heavy_package_not_imported_by_mellea( + package: str, reason: str, modules_after_import_mellea: set[str] +) -> None: + """Heavy optional-feature packages stay unimported after `import mellea`.""" + if importlib.util.find_spec(package) is None: + pytest.skip(f"{package} is not installed in this environment") + assert package not in modules_after_import_mellea, ( + f"`import mellea` pulled in {package!r} (needed only for: {reason}). " + "Move the import inside the function or method that uses it, or guard " + "it with TYPE_CHECKING if it is only needed for annotations." + ) + + +def test_majority_voting_strategies_still_constructible() -> None: + """The lazily-imported sampling strategies work when actually used.""" + from mellea.stdlib.sampling import MajorityVotingStrategyForMath, MBRDRougeLStrategy + + assert MBRDRougeLStrategy().compare_strings("a cat sat", "a cat sat") == 1.0 + assert MajorityVotingStrategyForMath().compare_strings("$1+1$", "$2$") == 1.0 diff --git a/uv.lock b/uv.lock index 034827ecf..88e37d867 100644 --- a/uv.lock +++ b/uv.lock @@ -1619,9 +1619,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, - { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, @@ -1629,9 +1627,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, @@ -1639,9 +1635,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, - { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, @@ -1649,9 +1643,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, - { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, @@ -1659,9 +1651,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, @@ -3428,7 +3418,7 @@ typecheck = [ requires-dist = [ { name = "accelerate", marker = "extra == 'hf'", specifier = ">=1.9.0" }, { name = "boto3", marker = "extra == 'litellm'" }, - { name = "cpex", marker = "extra == 'hooks'", specifier = ">=0.1.0" }, + { name = "cpex", marker = "extra == 'hooks'", specifier = ">=0.1.0,<0.2" }, { name = "datasets", marker = "extra == 'hf'", specifier = ">=4.0.0" }, { name = "docling", marker = "extra == 'docling'", specifier = ">=2.45.0" }, { name = "elasticsearch", marker = "extra == 'granite-retriever'", specifier = ">=8.0.0,<9.0.0" }, @@ -3450,7 +3440,7 @@ requires-dist = [ { name = "mellea", extras = ["hooks"], marker = "extra == 'telemetry'", editable = "." }, { name = "mellea", extras = ["watsonx", "hf", "litellm"], marker = "extra == 'backends'", editable = "." }, { name = "mistletoe", specifier = ">=1.4.0" }, - { name = "nltk", specifier = ">=3.9" }, + { name = "nltk", specifier = ">=3.9,!=3.10.1" }, { name = "ollama", specifier = ">=0.5.1" }, { name = "omegaconf", marker = "extra == 'docling'", specifier = ">=2.1" }, { name = "openai" },