diff --git a/pyproject.toml b/pyproject.toml index 9ec0f3219d..0cdc6cf6bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,9 +82,7 @@ dev = [ "jupyter-book>=2.0.0", "jupytext>=1.17.1", "matplotlib>=3.10.0", - # ty 0.0.70 adds unsound-return-statement under `all = "error"`; adopting it - # requires a repo-wide typing cleanup. - "ty>=0.0.32,<0.0.70", + "ty>=0.0.32", "pandas>=2.2.0", "pre-commit>=4.2.0", "pytest>=9.0.3", diff --git a/pyrit/auth/copilot_authenticator.py b/pyrit/auth/copilot_authenticator.py index d6894b868e..f6de84cd3a 100644 --- a/pyrit/auth/copilot_authenticator.py +++ b/pyrit/auth/copilot_authenticator.py @@ -359,7 +359,7 @@ async def _run_playwright_browser_automation_async(self) -> str | None: """ from playwright.async_api import async_playwright # type: ignore[ty:unresolved-import] - bearer_token = None + bearer_token: str | None = None token_expires_in = None async with async_playwright() as playwright: @@ -393,7 +393,10 @@ async def response_handler_async(response: Any) -> None: try: data = json.loads(text) if "access_token" in data: - bearer_token = data["access_token"] + token = data["access_token"] + if not isinstance(token, str): + raise TypeError("OAuth access_token must be a string") + bearer_token = token token_expires_in = data.get("expires_in") logger.info("Captured bearer token from JSON response.") diff --git a/pyrit/backend/services/converter_service.py b/pyrit/backend/services/converter_service.py index 7ec6aec799..3ed38e0400 100644 --- a/pyrit/backend/services/converter_service.py +++ b/pyrit/backend/services/converter_service.py @@ -17,7 +17,7 @@ import uuid from functools import lru_cache from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, urlparse from pyrit.backend.mappers.converter_mappers import converter_object_to_instance @@ -37,6 +37,9 @@ from pyrit.models import PromptDataType from pyrit.registry.components import ConverterRegistry +if TYPE_CHECKING: + from pyrit.converter import ConverterResult + class ConverterService: """ @@ -336,13 +339,13 @@ async def _apply_converters_async( Returns: Tuple of (steps, final_value, final_type). """ - current_value = initial_value - current_type = initial_type + current_value: str = initial_value + current_type: PromptDataType = initial_type steps: list[PreviewStep] = [] for conv_id, conv_type, conv_obj in converters: input_value, input_type = current_value, current_type - result = await conv_obj.convert_async(prompt=current_value, input_type=current_type) + result: ConverterResult = await conv_obj.convert_async(prompt=current_value, input_type=current_type) current_value, current_type = result.output_text, result.output_type steps.append( diff --git a/pyrit/cli/_cli_args.py b/pyrit/cli/_cli_args.py index 3c7980ad06..af4435a716 100644 --- a/pyrit/cli/_cli_args.py +++ b/pyrit/cli/_cli_args.py @@ -285,12 +285,14 @@ def parse_memory_labels(json_string: str) -> dict[str, str]: if not isinstance(labels, dict): raise ValueError("Memory labels must be a JSON object (dictionary)") - # Validate all keys and values are strings + # Validate all keys and values are strings and build a precisely typed result + validated_labels: dict[str, str] = {} for key, value in labels.items(): if not isinstance(key, str) or not isinstance(value, str): raise ValueError(f"All label keys and values must be strings. Got: {key}={value}") + validated_labels[key] = value - return labels + return validated_labels def parse_dataset_filter(arg: str) -> tuple[str, str]: diff --git a/pyrit/cli/api_client.py b/pyrit/cli/api_client.py index 47d587aa4a..4f43c57e1c 100644 --- a/pyrit/cli/api_client.py +++ b/pyrit/cli/api_client.py @@ -95,7 +95,7 @@ async def health_check_async(self) -> bool: if resp.status_code != 200: return False payload = resp.json() - return payload.get("status") == "healthy" and payload.get("service") == "pyrit-backend" + return bool(payload.get("status") == "healthy" and payload.get("service") == "pyrit-backend") except httpx.ConnectError: return False except Exception: @@ -405,9 +405,9 @@ def _response_detail(resp: Any) -> str | None: if isinstance(text, bytes): text = text.decode(errors="replace") if isinstance(text, str): - text = text.strip() - if text: - return text + stripped_text: str = text.strip() + if stripped_text: + return stripped_text return None @staticmethod diff --git a/pyrit/cli/pyrit_scan.py b/pyrit/cli/pyrit_scan.py index 0b8b383149..ee89d0bf7b 100644 --- a/pyrit/cli/pyrit_scan.py +++ b/pyrit/cli/pyrit_scan.py @@ -394,7 +394,7 @@ def _discover_verbs() -> frozenset[str]: for action in parser._actions: if isinstance(action, argparse._SubParsersAction): return frozenset(action.choices) - return frozenset() + return frozenset[str]() #: Every valid subcommand verb (used by the legacy-argv shim to detect new-style calls). @@ -634,12 +634,17 @@ async def _resolve_server_url_async(*, parsed_args: Namespace) -> str | None: Returns: str | None: The server base URL, or ``None`` if unreachable. + + Raises: + TypeError: If the configured server URL is not a string. """ from pyrit.cli._config_reader import DEFAULT_SERVER_URL, read_server_settings from pyrit.cli._server_launcher import ServerLauncher, parse_local_server_address server_settings = read_server_settings(config_file=parsed_args.config_file) base_url = parsed_args.server_url or server_settings.url or DEFAULT_SERVER_URL + if not isinstance(base_url, str): + raise TypeError(f"Configured server URL must be a string, got {type(base_url).__name__}") startup_timeout = getattr(parsed_args, "startup_timeout", None) or server_settings.startup_timeout # Probe existing server @@ -679,10 +684,16 @@ def _resolve_configured_server_url(*, parsed_args: Namespace) -> str: Returns: str: The configured server URL, falling back to the built-in default. + + Raises: + TypeError: If the configured server URL is not a string. """ from pyrit.cli._config_reader import DEFAULT_SERVER_URL, read_server_url - return parsed_args.server_url or read_server_url(config_file=parsed_args.config_file) or DEFAULT_SERVER_URL + server_url = parsed_args.server_url or read_server_url(config_file=parsed_args.config_file) or DEFAULT_SERVER_URL + if not isinstance(server_url, str): + raise TypeError(f"Configured server URL must be a string, got {type(server_url).__name__}") + return server_url async def _handle_stop_server_async(*, parsed_args: Namespace) -> int: @@ -918,7 +929,7 @@ async def _poll_until_terminal_async( seen_retry_attack_ids: set[str] = set() while True: - run = await client.get_scenario_run_async(scenario_result_id=scenario_result_id) + run: ScenarioRunSummary = await client.get_scenario_run_async(scenario_result_id=scenario_result_id) _output.print_scenario_retry_warnings(run=run, seen_attack_ids=seen_retry_attack_ids) _output.print_scenario_run_progress(run=run, total_techniques=total_techniques) if run.status in terminal_states: @@ -1034,9 +1045,17 @@ async def _dispatch_with_client_async(*, client: Any, parsed_args: Namespace) -> Returns: int: Exit code from the dispatched command. + + Raises: + TypeError: If the dispatched handler returns a non-int exit code. """ handler = _CLIENT_HANDLERS[parsed_args.command] - return await handler(client=client, parsed_args=parsed_args) + result = await handler(client=client, parsed_args=parsed_args) + if not isinstance(result, int): + raise TypeError( + f"Handler for '{parsed_args.command}' must return an int exit code, got {type(result).__name__}" + ) + return result async def _run_async(*, parsed_args: Namespace) -> int: diff --git a/pyrit/common/yaml_loadable.py b/pyrit/common/yaml_loadable.py index 2fb4422c73..0c32e169fc 100644 --- a/pyrit/common/yaml_loadable.py +++ b/pyrit/common/yaml_loadable.py @@ -44,5 +44,6 @@ def from_yaml_file(cls: type[T], file: Path | str) -> T: # If this class provides a from_dict factory, use it; # otherwise, just instantiate directly with **yaml_data if hasattr(cls, "from_dict") and callable(getattr(cls, "from_dict")): # noqa: B009 - return cls.from_dict(yaml_data) # type: ignore[ty:call-non-callable] + result: T = cls.from_dict(yaml_data) # type: ignore[ty:call-non-callable] + return result return cls(**yaml_data) diff --git a/pyrit/converter/decomposition_converter.py b/pyrit/converter/decomposition_converter.py index 936e3f8ad6..be8e8f807b 100644 --- a/pyrit/converter/decomposition_converter.py +++ b/pyrit/converter/decomposition_converter.py @@ -73,7 +73,8 @@ def _tokens(text: str) -> list[str]: Returns: list[str]: The lowercase word tokens. """ - return re.findall(r"\w+", text.lower(), flags=re.UNICODE) + tokens: list[str] = re.findall(r"\w+", text.lower(), flags=re.UNICODE) + return tokens def _token_recall(source: list[str], got: list[str]) -> float: @@ -291,6 +292,7 @@ def _parse_and_validate(self, *, objective: str, raw: str) -> tuple[list[str], l if not isinstance(words, list) or not isinstance(types, list) or not words or len(words) != len(types): raise InvalidJsonException(message="response must contain equal-length non-empty 'words' and 'types' lists") words = [str(w) for w in words] + types = [str(t) for t in types] if any(not w.strip() for w in words): raise InvalidJsonException(message="every phrase must be non-empty") if any(t not in _VALID_TAGS for t in types): diff --git a/pyrit/datasets/seed_datasets/local/local_dataset_loader.py b/pyrit/datasets/seed_datasets/local/local_dataset_loader.py index 92f85ccd12..fc26646c05 100644 --- a/pyrit/datasets/seed_datasets/local/local_dataset_loader.py +++ b/pyrit/datasets/seed_datasets/local/local_dataset_loader.py @@ -42,7 +42,7 @@ def __init__(self, *, file_path: Path) -> None: try: dataset = SeedDataset.from_yaml_file(file_path) # Use the dataset_name from the YAML if available, otherwise use filename - self._dataset_name = ( + self._dataset_name: str = ( getattr(dataset, "dataset_name", None) or getattr(dataset, "name", None) or file_path.stem ) except Exception as e: diff --git a/pyrit/datasets/seed_datasets/remote/vlguard_dataset.py b/pyrit/datasets/seed_datasets/remote/vlguard_dataset.py index 3d8671f7b0..316b67f8db 100644 --- a/pyrit/datasets/seed_datasets/remote/vlguard_dataset.py +++ b/pyrit/datasets/seed_datasets/remote/vlguard_dataset.py @@ -326,7 +326,7 @@ async def _download_dataset_files_async(self, *, cache: bool = True) -> tuple[li if cache and json_path.exists() and image_dir.exists() and any(image_dir.iterdir()): logger.info("Using cached VLGuard dataset") with open(json_path, encoding="utf-8") as f: - metadata = json.load(f) + metadata: list[dict[str, str]] = json.load(f) return metadata, image_dir logger.info("Downloading VLGuard dataset from HuggingFace...") @@ -357,6 +357,6 @@ def _download_sync() -> tuple[str, str]: await asyncio.to_thread(safe_extract_zip, source=zip_path, dest_dir=cache_dir) with open(json_path, encoding="utf-8") as f: - metadata = json.load(f) + metadata: list[dict[str, str]] = json.load(f) return metadata, image_dir diff --git a/pyrit/executor/attack/multi_turn/tree_of_attacks.py b/pyrit/executor/attack/multi_turn/tree_of_attacks.py index 8e2f12aeb5..a7eaf72dbd 100644 --- a/pyrit/executor/attack/multi_turn/tree_of_attacks.py +++ b/pyrit/executor/attack/multi_turn/tree_of_attacks.py @@ -154,8 +154,14 @@ def threshold(self) -> float: Returns: float: The threshold value from the FloatScaleThresholdScorer. + + Raises: + TypeError: If the configured objective scorer has an unexpected type. """ - return self.objective_scorer.threshold # type: ignore[ty:unresolved-attribute] + objective_scorer = self.objective_scorer + if not isinstance(objective_scorer, FloatScaleThresholdScorer): + raise TypeError("TAP objective scorer must be a FloatScaleThresholdScorer") + return objective_scorer.threshold @dataclass(frozen=True, slots=True) @@ -233,7 +239,8 @@ class TAPAttackResult(AttackResult): @property def tree_visualization(self) -> Tree | None: """The tree visualization from metadata.""" - return self.metadata.get("tree_visualization", None) + tree: Tree | None = self.metadata.get("tree_visualization") + return tree @tree_visualization.setter def tree_visualization(self, value: Tree) -> None: diff --git a/pyrit/executor/promptgen/gcg/attack/base/attack_manager.py b/pyrit/executor/promptgen/gcg/attack/base/attack_manager.py index 1d9cc7a69f..6a00c8b3c6 100644 --- a/pyrit/executor/promptgen/gcg/attack/base/attack_manager.py +++ b/pyrit/executor/promptgen/gcg/attack/base/attack_manager.py @@ -307,14 +307,14 @@ def _update_ids(self) -> None: # token. Both are necessary for the slice arithmetic to remain valid # across tokenizers/templates. def end_tok(char_pos: int) -> int: - tok = encoding.char_to_token(char_pos) + tok: int | None = encoding.char_to_token(char_pos) return len(toks) if tok is None else tok def start_tok(char_pos: int) -> int: limit = len(prompt) cur = char_pos while cur < limit: - tok = encoding.char_to_token(cur) + tok: int | None = encoding.char_to_token(cur) if tok is not None: return tok cur += 1 @@ -355,11 +355,11 @@ def generate(self, model: Any, gen_config: Any = None) -> torch.Tensor: logger.warning("max_new_tokens > 32 may cause testing to slow down.") input_ids = self.input_ids[: self._assistant_role_slice.stop].to(model.device).unsqueeze(0) attn_masks = torch.ones_like(input_ids).to(model.device) - output_ids = model.generate( + output_ids: torch.Tensor = model.generate( input_ids, attention_mask=attn_masks, generation_config=gen_config, pad_token_id=self.tokenizer.pad_token_id )[0] - return output_ids[self._assistant_role_slice.stop :] # type: ignore[no-any-return, unused-ignore] + return output_ids[self._assistant_role_slice.stop :] def generate_str(self, model: Any, gen_config: Any = None) -> Any: """ @@ -564,11 +564,8 @@ def input_str(self) -> Any: @property def eval_str(self) -> str: """The decoded input used for evaluation.""" - return ( # type: ignore[no-any-return, unused-ignore] - self.tokenizer.decode(self.input_ids[: self._assistant_role_slice.stop]) - .replace("", "") - .replace("", "") - ) + decoded: str = self.tokenizer.decode(self.input_ids[: self._assistant_role_slice.stop]) + return decoded.replace("", "").replace("", "") class PromptManager: @@ -754,7 +751,8 @@ def control_toks(self, input_control_toks: torch.Tensor) -> None: @property def control_str(self) -> str: """The shared decoded control suffix.""" - return self._prompts[0].control_str # type: ignore[no-any-return, unused-ignore] + control: str = self._prompts[0].control_str + return control @control_str.setter def control_str(self, control: str) -> None: @@ -837,9 +835,10 @@ def __init__( self.managers = managers @property - def control_str(self) -> Any: + def control_str(self) -> str: """The shared decoded control suffix.""" - return self.prompts[0].control_str + control: str = self.prompts[0].control_str + return control @control_str.setter def control_str(self, control: str) -> None: @@ -871,7 +870,8 @@ def get_filtered_cands( Returns: list[str]: Decoded candidate controls. """ - cands, count = [], 0 + cands: list[str] = [] + count = 0 worker = self.workers[worker_index] logger.info("Masking out of range token_id.") @@ -1019,8 +1019,8 @@ def test( for j, worker in enumerate(workers): worker(prompts[j], ModelWorkerOperation.TEST) model_tests = np.array([worker.results.get() for worker in workers]) - model_tests_jb = model_tests[..., 0].tolist() - model_tests_mb = model_tests[..., 1].tolist() + model_tests_jb: list[list[bool]] = model_tests[..., 0].tolist() + model_tests_mb: list[list[int]] = model_tests[..., 1].tolist() model_tests_loss: list[list[float]] = [] if include_loss: for j, worker in enumerate(workers): @@ -1334,7 +1334,7 @@ def run( ) if num_goals == len(self.goals) and num_workers == len(self.workers): stop_inner_on_success = False - control, loss, inner_steps = attack.run( + inner_result: tuple[str, float, int] = attack.run( n_steps=n_steps - step, batch_size=batch_size, topk=topk, @@ -1350,6 +1350,7 @@ def run( filter_cand=filter_cand, verbose=verbose, ) + control, loss, inner_steps = inner_result step += inner_steps self.control = control @@ -1683,8 +1684,12 @@ def run( _update_attack_log_params(logfile=self.logfile, params={"num_tests": len(controls)}) - total_jb, total_em, total_outputs = [], [], [] - test_total_jb, test_total_em, test_total_outputs = [], [], [] + total_jb: list[list[bool]] = [] + total_em: list[list[bool]] = [] + total_outputs: list[list[str]] = [] + test_total_jb: list[list[bool]] = [] + test_total_em: list[list[bool]] = [] + test_total_outputs: list[list[str]] = [] curr_jb: list[bool] = [] curr_em: list[bool] = [] all_outputs: list[str] = [] @@ -1959,6 +1964,24 @@ def get_workers(params: Any, evaluation: bool = False) -> tuple[list[ModelWorker return workers[:num_train_models], workers[num_train_models:] +def _read_string_column(*, data: Any, column: str, start: int = 0, end: int | None = None) -> list[str]: + """ + Read a slice of a dataframe column as strings. + + Returns: + list[str]: The requested column values. + + Raises: + ValueError: If the requested column contains a non-string value. + """ + values: list[str] = [] + for value in data[column].tolist()[start:end]: + if not isinstance(value, str): + raise ValueError(f"Column '{column}' must contain only strings, got {type(value).__name__}") + values.append(value) + return values + + def get_goals_and_targets(params: Any) -> tuple[list[str], list[str], list[str], list[str]]: """ Load training and held-out goals and targets from parameters or CSV files. @@ -1970,10 +1993,10 @@ def get_goals_and_targets(params: Any) -> tuple[list[str], list[str], list[str], Raises: ValueError: If a goal list and its corresponding target list differ in length. """ - train_goals = getattr(params, "goals", []) - train_targets = getattr(params, "targets", []) - test_goals = getattr(params, "test_goals", []) - test_targets = getattr(params, "test_targets", []) + train_goals: list[str] = getattr(params, "goals", []) + train_targets: list[str] = getattr(params, "targets", []) + test_goals: list[str] = getattr(params, "test_goals", []) + test_targets: list[str] = getattr(params, "test_targets", []) if params.train_data: train_data = pd.read_csv(params.train_data) @@ -1981,22 +2004,32 @@ def get_goals_and_targets(params: Any) -> tuple[list[str], list[str], list[str], # this line shuffles the rows of train data randomly with a random seed train_data = train_data.sample(frac=1, random_state=params.random_seed).reset_index(drop=True) - train_targets = train_data["target"].tolist()[: params.n_train_data] + train_targets = _read_string_column(data=train_data, column="target", end=params.n_train_data) if "goal" in train_data.columns: - train_goals = train_data["goal"].tolist()[: params.n_train_data] + train_goals = _read_string_column(data=train_data, column="goal", end=params.n_train_data) else: train_goals = [""] * len(train_targets) if params.test_data and params.n_test_data > 0: test_data = pd.read_csv(params.test_data) - test_targets = test_data["target"].tolist()[: params.n_test_data] + test_targets = _read_string_column(data=test_data, column="target", end=params.n_test_data) if "goal" in test_data.columns: - test_goals = test_data["goal"].tolist()[: params.n_test_data] + test_goals = _read_string_column(data=test_data, column="goal", end=params.n_test_data) else: test_goals = [""] * len(test_targets) elif params.n_test_data > 0: - test_targets = train_data["target"].tolist()[params.n_train_data : params.n_train_data + params.n_test_data] + test_targets = _read_string_column( + data=train_data, + column="target", + start=params.n_train_data, + end=params.n_train_data + params.n_test_data, + ) if "goal" in train_data.columns: - test_goals = train_data["goal"].tolist()[params.n_train_data : params.n_train_data + params.n_test_data] + test_goals = _read_string_column( + data=train_data, + column="goal", + start=params.n_train_data, + end=params.n_train_data + params.n_test_data, + ) else: test_goals = [""] * len(test_targets) diff --git a/pyrit/executor/promptgen/gcg/attack/gcg/gcg_attack.py b/pyrit/executor/promptgen/gcg/attack/gcg/gcg_attack.py index 916260e267..d9287b69cd 100644 --- a/pyrit/executor/promptgen/gcg/attack/gcg/gcg_attack.py +++ b/pyrit/executor/promptgen/gcg/attack/gcg/gcg_attack.py @@ -170,7 +170,7 @@ def __init__( self._candidate_filter = candidate_filter def _resolve_sampling(self) -> SamplingStrategy: - sampling = getattr(self, "_sampling", None) + sampling: SamplingStrategy | None = getattr(self, "_sampling", None) if sampling is not None: return sampling return StandardGCGSampling() diff --git a/pyrit/executor/promptgen/gcg/default_implementations.py b/pyrit/executor/promptgen/gcg/default_implementations.py index ee70a912df..ae22eb85f9 100644 --- a/pyrit/executor/promptgen/gcg/default_implementations.py +++ b/pyrit/executor/promptgen/gcg/default_implementations.py @@ -208,7 +208,8 @@ def compute_loss( "CrossEntropyLoss.compute_loss produced no terms; " "this indicates a corrupted instance with both weights at 0." ) - return total + result: torch.Tensor = total + return result class LengthPreservingFilter: diff --git a/pyrit/memory/azure_sql_memory.py b/pyrit/memory/azure_sql_memory.py index 102e8d3c2b..551d111c32 100644 --- a/pyrit/memory/azure_sql_memory.py +++ b/pyrit/memory/azure_sql_memory.py @@ -147,7 +147,12 @@ def _resolve_sas_token(env_var_name: str, passed_value: str | None = None) -> st str | None: Resolved SAS token or None if not provided. """ try: - return default_values.get_required_value(env_var_name=env_var_name, passed_value=passed_value) + value = default_values.get_required_value(env_var_name=env_var_name, passed_value=passed_value) + # get_required_value() is typed to return Any because it can also preserve callables + # (e.g. token providers). This call site only ever passes a str | None, so the + # runtime value is guaranteed to be a str. + resolved_value: str = value + return resolved_value except ValueError: return None diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py index 4bbef89456..9a76171b72 100644 --- a/pyrit/memory/memory_interface.py +++ b/pyrit/memory/memory_interface.py @@ -854,7 +854,8 @@ def _get_identifiers( filters: dict[str, Any], ) -> Sequence[IdentifierModel]: if identifier_hashes is not None and not identifier_hashes: - return [] + empty_identifiers: list[IdentifierModel] = [] + return empty_identifiers list_filters = {name: value for name, value in filters.items() if isinstance(value, list)} conditions = [ @@ -1405,11 +1406,15 @@ def _query_with_list_params( batch_size=effective_batch_size, ) + if not other_large_params: + return results + + filtered_results: list[Model] = list(results) for _col, vals, attr_name in other_large_params: vals_set = set(vals) - results = [e for e in results if getattr(e, attr_name, None) in vals_set] + filtered_results = [e for e in filtered_results if getattr(e, attr_name, None) in vals_set] - return results + return filtered_results def _insert_entry(self, entry: Base) -> None: """ @@ -1666,7 +1671,8 @@ def get_scores( Sequence[Score]: A list of Score objects that match the specified filters. """ if score_ids is not None and len(score_ids) == 0: - return [] + empty_scores: list[Score] = [] + return empty_scores conditions: list[Any] = [] @@ -1699,7 +1705,8 @@ def get_scores( # No score_ids specified - use regular query if not conditions: - return [] + no_condition_scores: list[Score] = [] + return no_condition_scores score_entries: Sequence[ScoreEntry] = self._query_entries(ScoreEntry, conditions=and_(*conditions)) return [entry.get_score() for entry in score_entries] @@ -1762,7 +1769,8 @@ def get_prompt_scores( # with their originals. original_ids = {piece.original_prompt_id for piece in message_pieces if piece.original_prompt_id is not None} if not original_ids: - return [] + no_original_ids_scores: list[Score] = [] + return no_original_ids_scores score_entries = self._execute_batched_query( ScoreEntry, @@ -1929,7 +1937,8 @@ def get_message_pieces( an exception is logged and an empty list is returned. """ if prompt_ids is not None and len(prompt_ids) == 0: - return [] + empty_message_pieces: list[MessagePiece] = [] + return empty_message_pieces try: conditions: list[Any] = [] @@ -2464,7 +2473,7 @@ def get_seed_dataset_names(self) -> Sequence[str]: distinct=True, ) # Extract unique dataset names from the entries - dataset_names = set() + dataset_names: set[str] = set() for entry in entries: if entry.dataset_name: dataset_names.add(entry.dataset_name) @@ -2895,7 +2904,8 @@ def _query_attack_results(self, *, query: _AttackResultQuery) -> Sequence[Attack with an ID-batched lookup. """ if self._attack_result_query_has_empty_lookup(query=query): - return [] + empty_attack_results: list[AttackResult] = [] + return empty_attack_results conditions = self._build_attack_result_conditions(query=query) paginating = query.limit is not None or query.after is not None @@ -3400,7 +3410,8 @@ def get_scenario_results( ordered by completion_time descending. """ if scenario_result_ids is not None and len(scenario_result_ids) == 0: - return [] + empty_scenario_results: list[ScenarioResult] = [] + return empty_scenario_results conditions = self._build_scenario_result_query_conditions( scenario_name=scenario_name, diff --git a/pyrit/memory/memory_models.py b/pyrit/memory/memory_models.py index 653abf8b48..2cc9ef0dc7 100644 --- a/pyrit/memory/memory_models.py +++ b/pyrit/memory/memory_models.py @@ -1396,7 +1396,8 @@ def _pack_seed_metadata(entry: Seed) -> dict[str, str | int] | None: schema = getattr(entry, "response_json_schema", None) if not raw and schema is None: - return raw + no_schema_metadata: dict[str, str | int] | None = None if raw is None else {} + return no_schema_metadata packed: dict[str, str | int] = dict(raw) if raw else {} # Defensive strip — the reserved key is owned by this class. diff --git a/pyrit/models/identifiers/component_identifier.py b/pyrit/models/identifiers/component_identifier.py index 45753b771f..ae8dce3bce 100644 --- a/pyrit/models/identifiers/component_identifier.py +++ b/pyrit/models/identifiers/component_identifier.py @@ -323,7 +323,8 @@ def _promoted_fields(cls) -> tuple[str, ...]: ``ComponentIdentifier``, in field-definition order. """ base_fields = set(ComponentIdentifier.model_fields) - return tuple(name for name in cls.model_fields if name not in base_fields) + promoted_fields: tuple[str, ...] = tuple(name for name in cls.model_fields if name not in base_fields) + return promoted_fields @classmethod def _promoted_param_fields(cls) -> tuple[str, ...]: diff --git a/pyrit/models/parameter.py b/pyrit/models/parameter.py index e7e692d178..ae2da9cd6b 100644 --- a/pyrit/models/parameter.py +++ b/pyrit/models/parameter.py @@ -535,7 +535,8 @@ def _render_type_name(param_type: Any) -> str: param_type = _unwrap_optional(param_type) if get_origin(param_type) is Literal: args = get_args(param_type) - return type(args[0]).__name__ if args else "str" + literal_type_name: str = type(args[0]).__name__ if args else "str" + return literal_type_name if get_origin(param_type) is list: type_args = get_args(param_type) element_type = _unwrap_optional(type_args[0]) if type_args else str diff --git a/pyrit/prompt_normalizer/json_retry.py b/pyrit/prompt_normalizer/json_retry.py index e63fd662e4..2f1d71dce4 100644 --- a/pyrit/prompt_normalizer/json_retry.py +++ b/pyrit/prompt_normalizer/json_retry.py @@ -75,4 +75,5 @@ async def _attempt_async() -> T: raise ValueError(f"No response received for conversation ID: {conversation_id}") return parse(response) - return await _attempt_async() + result: T = await _attempt_async() + return result diff --git a/pyrit/prompt_normalizer/prompt_normalizer.py b/pyrit/prompt_normalizer/prompt_normalizer.py index cf7b458f94..412c71fc71 100644 --- a/pyrit/prompt_normalizer/prompt_normalizer.py +++ b/pyrit/prompt_normalizer/prompt_normalizer.py @@ -216,7 +216,7 @@ async def send_prompt_batch_to_target_async( "conversation_id", ] - return await batch_task_async( + results: list[Message] = await batch_task_async( prompt_target=target, batch_size=batch_size, items_to_batch=batch_items, @@ -224,6 +224,7 @@ async def send_prompt_batch_to_target_async( task_arguments=batch_item_keys, target=target, ) + return results async def convert_values_async( self, diff --git a/pyrit/prompt_target/common/chat_completions_response_parser.py b/pyrit/prompt_target/common/chat_completions_response_parser.py index b1a28a0f18..a65acc5db8 100644 --- a/pyrit/prompt_target/common/chat_completions_response_parser.py +++ b/pyrit/prompt_target/common/chat_completions_response_parser.py @@ -413,7 +413,7 @@ def is_content_filter_response(response: Any) -> bool: bool: True if ``finish_reason == "content_filter"``, False otherwise. """ try: - return bool(response.choices) and response.choices[0].finish_reason == "content_filter" + return bool(response.choices) and bool(response.choices[0].finish_reason == "content_filter") except (AttributeError, IndexError): return False @@ -429,9 +429,9 @@ def extract_partial_content(response: Any) -> str | None: str | None: The partial text, or None if none was generated. """ try: - choice = response.choices[0] - if choice.message and choice.message.content: - return choice.message.content + content = response.choices[0].message.content + if isinstance(content, str) and content: + return content except (AttributeError, IndexError): pass return None diff --git a/pyrit/prompt_target/common/discover_target_capabilities.py b/pyrit/prompt_target/common/discover_target_capabilities.py index 882db920b8..ff9ad42f98 100644 --- a/pyrit/prompt_target/common/discover_target_capabilities.py +++ b/pyrit/prompt_target/common/discover_target_capabilities.py @@ -285,7 +285,8 @@ async def _send_and_check_async( def _retry_backoff_seconds(*, attempt: int) -> float: """Return the exponential backoff delay for a retry attempt.""" - return min(DEFAULT_PROBE_RETRY_BACKOFF_SECONDS * (2**attempt), MAX_PROBE_RETRY_BACKOFF_SECONDS) + backoff_seconds: float = DEFAULT_PROBE_RETRY_BACKOFF_SECONDS * (2**attempt) + return min(backoff_seconds, MAX_PROBE_RETRY_BACKOFF_SECONDS) async def _sleep_before_retry_async(*, attempt: int) -> None: diff --git a/pyrit/prompt_target/openai/_response_adapter.py b/pyrit/prompt_target/openai/_response_adapter.py index 8b8d629fc8..aa3d914011 100644 --- a/pyrit/prompt_target/openai/_response_adapter.py +++ b/pyrit/prompt_target/openai/_response_adapter.py @@ -119,7 +119,7 @@ def is_content_filtered(self, *, response: Response) -> bool: if getattr(response, "status", None) != "incomplete": return False incomplete_details = getattr(response, "incomplete_details", None) - return incomplete_details is not None and incomplete_details.reason == "content_filter" + return incomplete_details is not None and bool(incomplete_details.reason == "content_filter") def extract_partial_content(self, *, response: Response) -> str | None: """ @@ -132,13 +132,17 @@ def extract_partial_content(self, *, response: Response) -> str | None: str | None: Partial text, if present. """ try: - parts = [ - content_item.text - for section in response.output or [] - if getattr(section, "type", None) == "message" and getattr(section, "status", None) == "completed" - for content_item in getattr(section, "content", None) or [] - if isinstance(content_item, ResponseOutputText) and content_item.text - ] + parts: list[str] = [] + for section in response.output or []: + if getattr(section, "type", None) != "message" or getattr(section, "status", None) != "completed": + continue + parts.extend( + content_item.text + for content_item in getattr(section, "content", None) or [] + if isinstance(content_item, ResponseOutputText) + and isinstance(content_item.text, str) + and content_item.text + ) except (AttributeError, IndexError, TypeError): return None return "\n".join(parts) if parts else None diff --git a/pyrit/prompt_target/openai/openai_error_handling.py b/pyrit/prompt_target/openai/openai_error_handling.py index 5c67f2402f..851cb7937e 100644 --- a/pyrit/prompt_target/openai/openai_error_handling.py +++ b/pyrit/prompt_target/openai/openai_error_handling.py @@ -136,7 +136,8 @@ def _extract_error_payload(exc: Exception) -> tuple[dict[str, object] | str, boo data = resp.json() # Validate that we got actual data, not a mock if isinstance(data, dict): - return data, _is_content_filter_error(data) + json_payload: dict[str, object] = data + return json_payload, _is_content_filter_error(json_payload) except Exception: pass # Try text fallback from response @@ -151,13 +152,17 @@ def _extract_error_payload(exc: Exception) -> tuple[dict[str, object] | str, boo body = getattr(exc, "body", None) if body is not None: if isinstance(body, dict): - return body, _is_content_filter_error(body) + body_payload: dict[str, object] = body + return body_payload, _is_content_filter_error(body_payload) if isinstance(body, str): try: data = json.loads(body) - return data, _is_content_filter_error(data) except json.JSONDecodeError: return body, _is_content_filter_error(body) + if isinstance(data, dict): + parsed_payload: dict[str, object] = data + return parsed_payload, _is_content_filter_error(parsed_payload) + return body, _is_content_filter_error(body) # Strategy 3: Fall back to str(e) text = str(exc) diff --git a/pyrit/prompt_target/openai/openai_target.py b/pyrit/prompt_target/openai/openai_target.py index dc3f57e4b0..4e9f6391b2 100644 --- a/pyrit/prompt_target/openai/openai_target.py +++ b/pyrit/prompt_target/openai/openai_target.py @@ -180,8 +180,9 @@ def _extract_deployment_from_azure_url(self, url: str) -> str: match = re.search(r"/deployments/([^/]+)/", url) if match: deployment = match.group(1) - logger.info(f"Extracted deployment name from URL: {deployment}") - return deployment + if isinstance(deployment, str): + logger.info(f"Extracted deployment name from URL: {deployment}") + return deployment return "" diff --git a/pyrit/prompt_target/playwright_copilot_target.py b/pyrit/prompt_target/playwright_copilot_target.py index fe5d0e3e62..a662e7f1a1 100644 --- a/pyrit/prompt_target/playwright_copilot_target.py +++ b/pyrit/prompt_target/playwright_copilot_target.py @@ -377,7 +377,7 @@ async def _extract_text_from_message_groups_async( Returns: List of extracted text strings (may include placeholders) """ - all_text_parts = [] + all_text_parts: list[str] = [] for group_idx, msg_group in enumerate(ai_message_groups): text_elements = await msg_group.query_selector_all(text_selector) @@ -385,7 +385,7 @@ async def _extract_text_from_message_groups_async( for text_elem in text_elements: text = await text_elem.text_content() - if text: + if isinstance(text, str) and text: all_text_parts.append(text.strip()) return all_text_parts @@ -712,10 +712,10 @@ async def _extract_fallback_text_async(self, *, ai_message_groups: list[Any]) -> Returns: Combined text content from all groups """ - fallback_parts = [] + fallback_parts: list[str] = [] for msg_group in ai_message_groups: fallback_text = await msg_group.text_content() - if fallback_text: + if isinstance(fallback_text, str) and fallback_text: fallback_parts.append(fallback_text.strip()) fallback_result = "\n".join(fallback_parts).strip() logger.debug(f"Using fallback text: '{fallback_result}'") diff --git a/pyrit/registry/components/target_registry.py b/pyrit/registry/components/target_registry.py index eff8338121..4cd941f5f6 100644 --- a/pyrit/registry/components/target_registry.py +++ b/pyrit/registry/components/target_registry.py @@ -53,7 +53,8 @@ class TargetMetadata(RegistryMetadata): @property def supported_auth_modes(self) -> tuple[str, ...]: """Auth modes this target type accepts (e.g. ``"api_key"``, ``"identity"``).""" - return tuple(self.class_attributes.get("supported_auth_modes") or ()) + auth_modes: tuple[str, ...] = tuple(self.class_attributes.get("supported_auth_modes") or ()) + return auth_modes class TargetRegistry(Registry["PromptTarget", TargetMetadata]): diff --git a/pyrit/scenario/core/atomic_attack.py b/pyrit/scenario/core/atomic_attack.py index 427b14384e..0ad434542a 100644 --- a/pyrit/scenario/core/atomic_attack.py +++ b/pyrit/scenario/core/atomic_attack.py @@ -19,13 +19,12 @@ from typing import TYPE_CHECKING, Any from pyrit.common.utils import to_sha256 -from pyrit.executor.attack import AttackExecutor +from pyrit.executor.attack import AttackExecutor, AttackExecutorResult from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution from pyrit.memory import CentralMemory from pyrit.models import AtomicAttackEvaluationIdentifier, AtomicAttackIdentifier, AttackResult, AttackSeedGroup if TYPE_CHECKING: - from pyrit.executor.attack.core.attack_executor import AttackExecutorResult from pyrit.prompt_target import PromptTarget from pyrit.scenario.core.attack_technique import AttackTechnique from pyrit.score import TrueFalseScorer @@ -333,7 +332,7 @@ async def run_async( parent_eval_hash=self.technique_eval_hash, ) - results = await executor.execute_attack_from_seed_groups_async( + untyped_results = await executor.execute_attack_from_seed_groups_async( attack=technique.attack, seed_groups=execution_seed_groups, adversarial_chat=self._adversarial_chat, @@ -343,6 +342,16 @@ async def run_async( attribution=attribution, **self._attack_execute_params, ) + completed_results: list[AttackResult] = [] + for result in untyped_results.completed_results: + if not isinstance(result, AttackResult): + raise ValueError(f"Attack returned unsupported result type: {type(result).__name__}") + completed_results.append(result) + results = AttackExecutorResult[AttackResult]( + completed_results=completed_results, + incomplete_objectives=untyped_results.incomplete_objectives, + input_indices=untyped_results.input_indices, + ) # Enrich atomic_attack_identifier with seed identifiers self._enrich_atomic_attack_identifiers(results=results) diff --git a/pyrit/scenario/core/attack_technique_factory.py b/pyrit/scenario/core/attack_technique_factory.py index bf415e4e22..990957386c 100644 --- a/pyrit/scenario/core/attack_technique_factory.py +++ b/pyrit/scenario/core/attack_technique_factory.py @@ -749,6 +749,8 @@ def _get_scoring_config_type(self) -> type | None: if inner is None or inner is AttackScoringConfig: # Base type or unresolvable — any config is accepted return None + if not issubclass(inner, AttackScoringConfig): + return None return inner @staticmethod @@ -764,13 +766,15 @@ def _unwrap_optional(annotation: Any) -> type | None: if origin is Union or (hasattr(annotation, "__args__") and origin is None and hasattr(annotation, "__or__")): args = typing.get_args(annotation) non_none = [a for a in args if a is not type(None)] - return non_none[0] if len(non_none) == 1 else None + candidate = non_none[0] if len(non_none) == 1 else None + return candidate if isinstance(candidate, type) else None # types.UnionType from PEP 604 at runtime (3.10+) if hasattr(annotation, "__args__") and type(annotation).__name__ == "UnionType": args = annotation.__args__ non_none = [a for a in args if a is not type(None)] - return non_none[0] if len(non_none) == 1 else None + candidate = non_none[0] if len(non_none) == 1 else None + return candidate if isinstance(candidate, type) else None # Plain type (not Optional) if isinstance(annotation, type): diff --git a/pyrit/scenario/core/scenario.py b/pyrit/scenario/core/scenario.py index 5ca3c2247c..9385c8c97b 100644 --- a/pyrit/scenario/core/scenario.py +++ b/pyrit/scenario/core/scenario.py @@ -145,7 +145,7 @@ def _get_additional_scoring_questions(cls) -> Sequence[Path]: Returns: Sequence[Path]: Paths to true/false question prompts, or an empty sequence to use the default scorer. """ - return [] + return () def __init__( self, @@ -484,20 +484,28 @@ def _resolve_objective_target(self, *, value: Any) -> PromptTarget | None: nor available as a global default. Raises: + TypeError: If a configured default or resolved target is not a ``PromptTarget``. ValueError: If a target name is supplied that is not registered in ``TargetRegistry``. """ if value is None: found, default = get_global_default_values().get_default_value( class_type=type(self), parameter_name="objective_target" ) - return default if found else None + if not found or default is None: + return None + if not isinstance(default, PromptTarget): + raise TypeError(f"Default objective_target must be a PromptTarget, got {type(default).__name__}") + return default - return resolve_reference_value( + resolved = resolve_reference_value( component_type=ComponentType.TARGET, value=value, owner=type(self).__name__, name="objective_target", ) + if not isinstance(resolved, PromptTarget): + raise TypeError(f"Resolved objective_target must be a PromptTarget, got {type(resolved).__name__}") + return resolved def _resolve_scenario_techniques(self, *, scenario_techniques: Any) -> list[ScenarioTechnique]: """ diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index d1289df6cf..fec03778d2 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -309,14 +309,20 @@ def _build_scoring_config_for_factory(self, *, factory: AttackTechniqueFactory) AttackScoringConfig | None: The most specific config that could be built, or ``None`` if the technique is incompatible with the scenario scorer. + + Raises: + TypeError: If a factory returns a non-``AttackScoringConfig`` instance. """ required = factory.scoring_config_type if required is None or required is AttackScoringConfig: return AttackScoringConfig(objective_scorer=self._objective_scorer) try: - return required(objective_scorer=self._objective_scorer) + config = required(objective_scorer=self._objective_scorer) except (TypeError, ValueError): return None + if not isinstance(config, AttackScoringConfig): + raise TypeError(f"Scoring config factory returned unsupported type: {type(config).__name__}") + return config async def _build_atomics_for_dataset_async( self, diff --git a/pyrit/scenario/scenarios/foundry/red_team_agent.py b/pyrit/scenario/scenarios/foundry/red_team_agent.py index 874ca6d752..5d0cc4f235 100644 --- a/pyrit/scenario/scenarios/foundry/red_team_agent.py +++ b/pyrit/scenario/scenarios/foundry/red_team_agent.py @@ -108,7 +108,7 @@ def __post_init__(self) -> None: def name(self) -> str: """A human-readable name for this composite.""" if not self.converters: - return self.attack.value if self.attack else "baseline" + return str(self.attack.value) if self.attack else "baseline" if self.attack is None and len(self.converters) == 1: return str(self.converters[0].value) attack_name = self.attack.value if self.attack else "baseline" diff --git a/pyrit/score/conversation_scorer.py b/pyrit/score/conversation_scorer.py index 5ddf784947..bf48e04004 100644 --- a/pyrit/score/conversation_scorer.py +++ b/pyrit/score/conversation_scorer.py @@ -212,6 +212,7 @@ def create_conversation_scorer( Scorer: A ConversationScorer instance that is also an instance of the wrapped scorer's type. Raises: + TypeError: If the dynamic scorer does not inherit from ``Scorer``. ValueError: If the scorer is not an instance of FloatScaleScorer or TrueFalseScorer. Example: @@ -243,11 +244,22 @@ class DynamicConversationScorer(ConversationScorer, scorer_base_class): # type: def __init__(self) -> None: # Initialize with the validator and wrapped scorer MessageScorer.__init__(self, validator=validator or ConversationScorer._DEFAULT_VALIDATOR) - self._wrapped_scorer = wrapped_scorer + self._wrapped_scorer: MessageScorer = wrapped_scorer def _get_wrapped_scorer(self) -> MessageScorer: - """Return the wrapped scorer.""" - return self._wrapped_scorer + """ + Return the wrapped scorer. + + Returns: + MessageScorer: The scorer used for conversation-level evaluation. + + Raises: + TypeError: If the stored wrapped scorer is not a ``MessageScorer``. + """ + wrapped_scorer = self._wrapped_scorer + if not isinstance(wrapped_scorer, MessageScorer): + raise TypeError("Wrapped conversation scorer must inherit from MessageScorer") + return wrapped_scorer def _build_identifier(self) -> ComponentIdentifier: """ @@ -255,9 +267,18 @@ def _build_identifier(self) -> ComponentIdentifier: Returns: ComponentIdentifier: The identifier for this scorer. + + Raises: + TypeError: If identifier construction returns an unexpected type. """ - return self._create_identifier( + identifier = self._create_identifier( sub_scorers=[self._wrapped_scorer.get_identifier()], ) - - return DynamicConversationScorer() + if not isinstance(identifier, ComponentIdentifier): + raise TypeError("Conversation scorer identifier must be a ComponentIdentifier") + return identifier + + conversation_scorer = DynamicConversationScorer() + if not isinstance(conversation_scorer, Scorer): + raise TypeError("Dynamic conversation scorer must inherit from Scorer") + return conversation_scorer diff --git a/pyrit/score/float_scale/video_float_scale_scorer.py b/pyrit/score/float_scale/video_float_scale_scorer.py index 7ae07fe118..84b7345bb2 100644 --- a/pyrit/score/float_scale/video_float_scale_scorer.py +++ b/pyrit/score/float_scale/video_float_scale_scorer.py @@ -124,7 +124,10 @@ def matched_conditions(self) -> frozenset[type[Condition]]: scorers = [self._video_helper.image_scorer] if self.audio_scorer: scorers.append(self.audio_scorer) - return frozenset().union(*(scorer.matched_conditions() for scorer in scorers)) + conditions: set[type[Condition]] = set() + for scorer in scorers: + conditions.update(scorer.matched_conditions()) + return frozenset(conditions) def required_conditions(self) -> frozenset[type[Condition]]: """ @@ -136,7 +139,10 @@ def required_conditions(self) -> frozenset[type[Condition]]: scorers = [self._video_helper.image_scorer] if self.audio_scorer: scorers.append(self.audio_scorer) - return frozenset().union(*(scorer.required_conditions() for scorer in scorers)) + conditions: set[type[Condition]] = set() + for scorer in scorers: + conditions.update(scorer.required_conditions()) + return frozenset(conditions) async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: """ diff --git a/pyrit/score/score_utils.py b/pyrit/score/score_utils.py index 4429b34e67..93baea6e87 100644 --- a/pyrit/score/score_utils.py +++ b/pyrit/score/score_utils.py @@ -2,7 +2,6 @@ # Licensed under the MIT license. -from pyrit.common.utils import combine_dict from pyrit.models import Score # Key used by FloatScaleThresholdScorer to store the original float value @@ -24,8 +23,9 @@ def combine_metadata_and_categories(scores: list[Score]) -> tuple[dict[str, str category_set: set[str] = set() for s in scores: - metadata = combine_dict(metadata, getattr(s, "score_metadata", None)) - score_categories = getattr(s, "score_category", None) or [] + if s.score_metadata: + metadata.update(s.score_metadata) + score_categories = s.score_category or [] category_set.update([c for c in score_categories if c]) category = sorted(category_set) diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index 086d4a019c..fc1749b2ca 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -66,7 +66,8 @@ async def _legacy_score_scorable_async( resolver = getattr(self, "_message_resolver", None) or MessageScorableResolver() message = resolver.resolve(scorable=scorable, memory=self._memory) legacy_score_async = self._score_async # type: ignore[ty:unresolved-attribute] - return await legacy_score_async(message, objective=expectation.objective if expectation else None) + scores: list[Score] = await legacy_score_async(message, objective=expectation.objective if expectation else None) + return scores def _adapt_legacy_message_scorer(cls: type) -> None: @@ -187,7 +188,8 @@ def get_chat_target(self) -> PromptTarget | None: Returns: PromptTarget | None: The chat target, or None if not applicable. """ - return getattr(self, "_prompt_target", None) + prompt_target: PromptTarget | None = getattr(self, "_prompt_target", None) + return prompt_target def get_identifier(self) -> ComponentIdentifier: """ diff --git a/pyrit/score/true_false/true_false_composite_scorer.py b/pyrit/score/true_false/true_false_composite_scorer.py index fd3cf2a2e1..679afb3210 100644 --- a/pyrit/score/true_false/true_false_composite_scorer.py +++ b/pyrit/score/true_false/true_false_composite_scorer.py @@ -89,7 +89,10 @@ def matched_conditions(self) -> frozenset[type[Condition]]: Returns: frozenset[type[Condition]]: The condition types this composite routes. """ - return frozenset().union(*(scorer.matched_conditions() for scorer in self._scorers)) + conditions: set[type[Condition]] = set() + for scorer in self._scorers: + conditions.update(scorer.matched_conditions()) + return frozenset(conditions) def required_conditions(self) -> frozenset[type[Condition]]: """ @@ -98,7 +101,10 @@ def required_conditions(self) -> frozenset[type[Condition]]: Returns: frozenset[type[Condition]]: The required condition types. """ - return frozenset().union(*(scorer.required_conditions() for scorer in self._scorers)) + conditions: set[type[Condition]] = set() + for scorer in self._scorers: + conditions.update(scorer.required_conditions()) + return frozenset(conditions) async def _score_prepared_message_async( self, diff --git a/pyrit/score/true_false/video_true_false_scorer.py b/pyrit/score/true_false/video_true_false_scorer.py index 2370de1841..9e390e9fdb 100644 --- a/pyrit/score/true_false/video_true_false_scorer.py +++ b/pyrit/score/true_false/video_true_false_scorer.py @@ -101,7 +101,10 @@ def matched_conditions(self) -> frozenset[type[Condition]]: scorers = [self._video_helper.image_scorer] if self.audio_scorer: scorers.append(self.audio_scorer) - return frozenset().union(*(scorer.matched_conditions() for scorer in scorers)) + conditions: set[type[Condition]] = set() + for scorer in scorers: + conditions.update(scorer.matched_conditions()) + return frozenset(conditions) def required_conditions(self) -> frozenset[type[Condition]]: """ @@ -113,7 +116,10 @@ def required_conditions(self) -> frozenset[type[Condition]]: scorers = [self._video_helper.image_scorer] if self.audio_scorer: scorers.append(self.audio_scorer) - return frozenset().union(*(scorer.required_conditions() for scorer in scorers)) + conditions: set[type[Condition]] = set() + for scorer in scorers: + conditions.update(scorer.required_conditions()) + return frozenset(conditions) async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: """ diff --git a/pyrit/setup/configuration_loader.py b/pyrit/setup/configuration_loader.py index 26a29c45b9..86f6b402e2 100644 --- a/pyrit/setup/configuration_loader.py +++ b/pyrit/setup/configuration_loader.py @@ -509,11 +509,11 @@ def resolve_initializers(self) -> Sequence["PyRITInitializer"]: from pyrit.registry import InitializerRegistry configs = self._initializer_configs + resolved: list[PyRITInitializer] = [] if not configs: - return [] + return resolved registry = InitializerRegistry() - resolved: list[PyRITInitializer] = [] logging.getLogger(__name__).info("Running %d initializer(s)...", len(configs)) @@ -544,7 +544,7 @@ def resolve_initialization_scripts(self) -> Sequence[pathlib.Path] | None: # Empty list means "load nothing" - return empty list if len(self.initialization_scripts) == 0: - return [] + return list[pathlib.Path]() resolved: list[pathlib.Path] = [] for script_str in self.initialization_scripts: @@ -569,7 +569,7 @@ def resolve_env_files(self) -> Sequence[pathlib.Path] | None: # Empty list means "load nothing" - return empty list if len(self.env_files) == 0: - return [] + return list[pathlib.Path]() resolved: list[pathlib.Path] = [] for env_str in self.env_files: diff --git a/tests/unit/cli/test_pyrit_scan.py b/tests/unit/cli/test_pyrit_scan.py index 0d52bc9ef4..db6fb98da7 100644 --- a/tests/unit/cli/test_pyrit_scan.py +++ b/tests/unit/cli/test_pyrit_scan.py @@ -1024,6 +1024,43 @@ async def test_resolution_order_cli_beats_config_beats_default(self): ): assert await pyrit_scan._resolve_server_url_async(parsed_args=parsed) == DEFAULT_SERVER_URL + async def test_raises_type_error_when_configured_url_is_not_a_string(self): + """A non-string configured URL (e.g. malformed config) raises TypeError.""" + parsed = Namespace(server_url=None, start_server=False, config_file=None) + with patch( + "pyrit.cli._config_reader.read_server_settings", + return_value=pyrit_scan_config_reader.ServerSettings(url=12345), # type: ignore[arg-type] + ): + with pytest.raises(TypeError, match="Configured server URL must be a string"): + await pyrit_scan._resolve_server_url_async(parsed_args=parsed) + + +class TestResolveConfiguredServerUrl: + """Tests for _resolve_configured_server_url.""" + + def test_uses_cli_flag_when_provided(self): + parsed = Namespace(server_url="http://cli:1111", config_file=None) + assert pyrit_scan._resolve_configured_server_url(parsed_args=parsed) == "http://cli:1111" + + def test_falls_back_to_config_file(self): + parsed = Namespace(server_url=None, config_file=None) + with patch("pyrit.cli._config_reader.read_server_url", return_value="http://cfg:2222"): + assert pyrit_scan._resolve_configured_server_url(parsed_args=parsed) == "http://cfg:2222" + + def test_falls_back_to_default(self): + from pyrit.cli._config_reader import DEFAULT_SERVER_URL + + parsed = Namespace(server_url=None, config_file=None) + with patch("pyrit.cli._config_reader.read_server_url", return_value=None): + assert pyrit_scan._resolve_configured_server_url(parsed_args=parsed) == DEFAULT_SERVER_URL + + def test_raises_type_error_when_configured_url_is_not_a_string(self): + """A non-string configured URL (e.g. malformed config) raises TypeError.""" + parsed = Namespace(server_url=None, config_file=None) + with patch("pyrit.cli._config_reader.read_server_url", return_value=12345): + with pytest.raises(TypeError, match="Configured server URL must be a string"): + pyrit_scan._resolve_configured_server_url(parsed_args=parsed) + class TestScenarioParamCoercion: """Regression tests for client-side coercion of typed scenario-declared params.""" diff --git a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py index 6e9431ce19..d984134357 100644 --- a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py +++ b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py @@ -2472,6 +2472,22 @@ def test_tap_init_raises_when_objective_scorer_is_none(): ) +def test_tap_scoring_config_threshold_raises_for_reassigned_invalid_scorer(): + """threshold re-validates objective_scorer at access time, not just at construction. + + __init__ already rejects a non-FloatScaleThresholdScorer objective_scorer, so this + exercises the defensive re-check by mutating the attribute after construction. + """ + mock_threshold_scorer = MagicMock(spec=FloatScaleThresholdScorer) + mock_threshold_scorer.threshold = 0.8 + scoring_config = TAPAttackScoringConfig(objective_scorer=mock_threshold_scorer) + + scoring_config.objective_scorer = MagicMock(spec=Scorer) + + with pytest.raises(TypeError, match="TAP objective scorer must be a FloatScaleThresholdScorer"): + _ = scoring_config.threshold + + def test_tap_attack_result_tree_visualization_getter_returns_value(): """Test that TAPAttackResult.tree_visualization returns the stored tree.""" tree = Tree() diff --git a/tests/unit/prompt_target/target/test_openai_error_handling.py b/tests/unit/prompt_target/target/test_openai_error_handling.py index cfbea439c0..d91df315f6 100644 --- a/tests/unit/prompt_target/target/test_openai_error_handling.py +++ b/tests/unit/prompt_target/target/test_openai_error_handling.py @@ -6,9 +6,19 @@ from pyrit.exceptions import CONTENT_FILTER_MARKERS from pyrit.prompt_target.openai.openai_error_handling import ( SAFETY_MESSAGE_MARKERS, + _extract_error_payload, _is_content_filter_error, ) + +class _FakeBodyError(Exception): + """A minimal exception stand-in exposing a ``body`` attribute like the OpenAI SDK's errors.""" + + def __init__(self, body: object) -> None: + super().__init__("fake error") + self.body = body + + # Tests for _is_content_filter_error helper @@ -109,3 +119,47 @@ def test_is_content_filter_error_no_filter(): def test_is_content_filter_error_string_no_filter(): """String input without any marker returns False.""" assert _is_content_filter_error("connection timed out") is False + + +# Tests for _extract_error_payload helper + + +def test_extract_error_payload_body_dict(): + """When exc.body is a dict, it's returned as-is with content-filter detection applied.""" + exc = _FakeBodyError({"error": {"code": "content_filter"}}) + payload, is_filter = _extract_error_payload(exc) + assert payload == {"error": {"code": "content_filter"}} + assert is_filter is True + + +def test_extract_error_payload_body_json_string_decodes_to_dict(): + """When exc.body is a JSON string that decodes to a dict, the parsed dict is returned.""" + exc = _FakeBodyError('{"error": {"code": "content_filter"}}') + payload, is_filter = _extract_error_payload(exc) + assert payload == {"error": {"code": "content_filter"}} + assert is_filter is True + + +def test_extract_error_payload_body_json_string_decodes_to_non_dict(): + """When exc.body is a JSON string that decodes to a non-dict (e.g. a list), the original + string body is returned rather than the parsed value.""" + exc = _FakeBodyError('["not", "a", "dict"]') + payload, is_filter = _extract_error_payload(exc) + assert payload == '["not", "a", "dict"]' + assert is_filter is False + + +def test_extract_error_payload_body_non_json_string(): + """When exc.body is a non-JSON string, the original string is returned unparsed.""" + exc = _FakeBodyError("not json at all") + payload, is_filter = _extract_error_payload(exc) + assert payload == "not json at all" + assert is_filter is False + + +def test_extract_error_payload_falls_back_to_str_of_exception(): + """When exc has neither a response nor a body, the payload falls back to str(exc).""" + exc = Exception("plain failure") + payload, is_filter = _extract_error_payload(exc) + assert payload == "plain failure" + assert is_filter is False diff --git a/tests/unit/scenario/core/test_atomic_attack.py b/tests/unit/scenario/core/test_atomic_attack.py index 28fd9d8004..8a04f20457 100644 --- a/tests/unit/scenario/core/test_atomic_attack.py +++ b/tests/unit/scenario/core/test_atomic_attack.py @@ -314,6 +314,20 @@ async def test_run_async_passes_seed_groups(self, mock_attack, sample_seed_group assert "seed_groups" in call_kwargs assert call_kwargs["seed_groups"] == sample_seed_groups + async def test_run_async_raises_when_executor_returns_non_attack_result(self, mock_attack, sample_seed_groups): + """Test that a non-AttackResult item from the executor raises ValueError.""" + atomic_attack = AtomicAttack( + attack_technique=AttackTechnique(attack=mock_attack), + seed_groups=sample_seed_groups, + atomic_attack_name="Test Attack Run", + ) + + with patch.object(AttackExecutor, "execute_attack_from_seed_groups_async", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = wrap_results(["not-an-attack-result"]) + + with pytest.raises(ValueError, match="unsupported result type"): + await atomic_attack.run_async() + async def test_run_async_passes_attack_execute_params(self, mock_attack, sample_seed_groups, sample_attack_results): """Test that attack execute parameters are passed to the executor.""" atomic_attack = AtomicAttack( diff --git a/tests/unit/scenario/core/test_attack_technique_factory.py b/tests/unit/scenario/core/test_attack_technique_factory.py index 1124cd4df2..5ea41cd64b 100644 --- a/tests/unit/scenario/core/test_attack_technique_factory.py +++ b/tests/unit/scenario/core/test_attack_technique_factory.py @@ -3,6 +3,7 @@ """Tests for the AttackTechniqueFactory class.""" +import typing from unittest.mock import MagicMock, patch import pytest @@ -974,3 +975,39 @@ def test_unwrap_non_type_annotation_returns_none(self): """A non-type annotation (e.g., string forward ref) returns None.""" result = AttackTechniqueFactory._unwrap_optional("SomeForwardRef") assert result is None + + def test_unwrap_typing_optional_with_none(self): + """typing.Optional[X] (legacy typing.Union syntax) should unwrap to X.""" + # Intentionally uses the legacy typing.Optional/Union construct (rather than `X | None`) + # to exercise _unwrap_optional's typing.Union-origin branch specifically. + result = AttackTechniqueFactory._unwrap_optional(typing.Optional[AttackScoringConfig]) # noqa: UP045 + assert result is AttackScoringConfig + + def test_unwrap_typing_union_multi_returns_none(self): + """typing.Union of more than one non-None type returns None (ambiguous).""" + # Intentionally uses typing.Union (rather than `X | Y`) to exercise _unwrap_optional's + # typing.Union-origin branch specifically. + result = AttackTechniqueFactory._unwrap_optional(typing.Union[int, str, None]) # noqa: UP007 + assert result is None + + +class TestGetScoringConfigType: + """Tests for AttackTechniqueFactory._get_scoring_config_type.""" + + def test_returns_none_when_annotation_is_not_attack_scoring_config_subclass(self): + """A resolved, narrowed annotation that isn't an AttackScoringConfig subclass yields None.""" + + class _WrongAnnotationAttack: + def __init__(self, *, objective_target, attack_scoring_config: int | None = None): + pass + + def get_identifier(self): + return ComponentIdentifier(class_name="_WrongAnnotationAttack", class_module="test") + + factory = AttackTechniqueFactory( + name="test", + attack_class=_WrongAnnotationAttack, + scorer_override_policy=ScorerOverridePolicy.SKIP, + ) + + assert factory._get_scoring_config_type() is None diff --git a/tests/unit/scenario/core/test_scenario.py b/tests/unit/scenario/core/test_scenario.py index 73dbc5a048..27c8cae746 100644 --- a/tests/unit/scenario/core/test_scenario.py +++ b/tests/unit/scenario/core/test_scenario.py @@ -17,6 +17,7 @@ from pyrit.executor.attack.core import AttackExecutorResult from pyrit.memory import CentralMemory from pyrit.models import AttackOutcome, AttackResult, ComponentIdentifier, ScenarioRunState +from pyrit.prompt_target import PromptTarget from pyrit.scenario import ( DatasetAttackConfiguration, DatasetConfiguration, @@ -116,7 +117,7 @@ def mock_atomic_attacks(): @pytest.fixture def mock_objective_target(): """Create a mock objective target for testing.""" - target = MagicMock() + target = MagicMock(spec=PromptTarget) target.get_identifier.return_value = ComponentIdentifier( class_name="MockTarget", class_module="test", diff --git a/tests/unit/scenario/core/test_scenario_parameters.py b/tests/unit/scenario/core/test_scenario_parameters.py index 98fe9e28cb..9dffbdd92a 100644 --- a/tests/unit/scenario/core/test_scenario_parameters.py +++ b/tests/unit/scenario/core/test_scenario_parameters.py @@ -9,6 +9,7 @@ import pytest from pyrit.models import ComponentIdentifier, Parameter +from pyrit.prompt_target import PromptTarget from pyrit.scenario import DatasetConfiguration from pyrit.scenario.core import BaselineAttackPolicy, Scenario, ScenarioTechnique from pyrit.score import Scorer @@ -558,7 +559,7 @@ class TestParamPersistenceJsonSafety: @staticmethod def _mock_target() -> MagicMock: - target = MagicMock() + target = MagicMock(spec=PromptTarget) target.get_identifier.return_value = ComponentIdentifier(class_name="MockTarget", class_module="test") return target @@ -592,7 +593,7 @@ class _NotJsonable: def _mock_objective_target() -> MagicMock: - target = MagicMock() + target = MagicMock(spec=PromptTarget) target.get_identifier.return_value = ComponentIdentifier(class_name="MockTarget", class_module="test") return target @@ -667,6 +668,40 @@ async def test_unregistered_name_raises(self) -> None: finally: TargetRegistry.reset_registry_singleton() + async def test_resolved_non_target_value_raises_type_error(self) -> None: + """A supplied value that isn't a string name and isn't a PromptTarget raises TypeError.""" + scenario = _make_scenario(declared_params=[], include_common_params=True) + scenario.set_params_from_args(args={"objective_target": object()}) + with pytest.raises(TypeError, match="Resolved objective_target must be a PromptTarget"): + await scenario.initialize_async() + + async def test_default_non_target_value_raises_type_error(self) -> None: + """A registered default that isn't a PromptTarget raises TypeError.""" + from pyrit.common import reset_default_values, set_default_value + + scenario = _make_scenario(declared_params=[], include_common_params=True) + set_default_value(class_type=type(scenario), parameter_name="objective_target", value=object()) + try: + scenario.set_params_from_args(args={}) + with pytest.raises(TypeError, match="Default objective_target must be a PromptTarget"): + await scenario.initialize_async() + finally: + reset_default_values() + + async def test_valid_default_target_is_used_when_no_value_supplied(self) -> None: + """A registered default that is a valid PromptTarget is used when no value is supplied.""" + from pyrit.common import reset_default_values, set_default_value + + default_target = _mock_objective_target() + scenario = _make_scenario(declared_params=[], include_common_params=True) + set_default_value(class_type=type(scenario), parameter_name="objective_target", value=default_target) + try: + scenario.set_params_from_args(args={}) + await scenario.initialize_async() + assert scenario._objective_target is default_target + finally: + reset_default_values() + @pytest.mark.usefixtures("patch_central_database") class TestOpaquePassthrough: diff --git a/tests/unit/scenario/core/test_scenario_partial_results.py b/tests/unit/scenario/core/test_scenario_partial_results.py index dd8fa97b06..d6f58e902b 100644 --- a/tests/unit/scenario/core/test_scenario_partial_results.py +++ b/tests/unit/scenario/core/test_scenario_partial_results.py @@ -18,6 +18,7 @@ from pyrit.executor.attack.core import AttackExecutorResult from pyrit.memory import CentralMemory from pyrit.models import AttackOutcome, AttackResult, ComponentIdentifier, ScenarioRunState +from pyrit.prompt_target import PromptTarget from pyrit.scenario import DatasetConfiguration, ScenarioResult from pyrit.scenario.core import AtomicAttack, BaselineAttackPolicy, Scenario, ScenarioTechnique @@ -33,7 +34,7 @@ def _mock_scorer_id(name: str = "MockScorer") -> ComponentIdentifier: @pytest.fixture def mock_objective_target(): """Create a mock objective target for testing.""" - target = MagicMock() + target = MagicMock(spec=PromptTarget) target.get_identifier.return_value = ComponentIdentifier( class_name="MockTarget", class_module="test", diff --git a/tests/unit/scenario/core/test_scenario_retry.py b/tests/unit/scenario/core/test_scenario_retry.py index f1ed5a1572..af4a222d0b 100644 --- a/tests/unit/scenario/core/test_scenario_retry.py +++ b/tests/unit/scenario/core/test_scenario_retry.py @@ -240,7 +240,7 @@ def mock_atomic_attacks(): @pytest.fixture def mock_objective_target(): """Create a mock objective target for testing.""" - target = MagicMock() + target = MagicMock(spec=PromptTarget) target.get_identifier.return_value = ComponentIdentifier( class_name="MockTarget", class_module=TEST_MODULE, diff --git a/tests/unit/score/test_conversation_history_scorer.py b/tests/unit/score/test_conversation_history_scorer.py index d9f10ec488..ff893ee984 100644 --- a/tests/unit/score/test_conversation_history_scorer.py +++ b/tests/unit/score/test_conversation_history_scorer.py @@ -2,7 +2,7 @@ # Licensed under the MIT license. import uuid -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from unit.mocks import store_message @@ -434,6 +434,25 @@ def test_factory_creates_unique_instances(): assert isinstance(conv_scorer2, ConversationScorer) +def test_get_wrapped_scorer_raises_when_wrapped_scorer_reassigned_to_non_scorer(): + """_get_wrapped_scorer re-validates the stored scorer, not just at construction time.""" + conv_scorer = create_conversation_scorer(scorer=MockFloatScaleScorer()) + + conv_scorer._wrapped_scorer = "not-a-scorer" # type: ignore[assignment] + + with pytest.raises(TypeError, match="Wrapped conversation scorer must inherit from MessageScorer"): + conv_scorer._get_wrapped_scorer() + + +def test_build_identifier_raises_when_create_identifier_returns_non_component_identifier(): + """_build_identifier re-validates the identifier produced by _create_identifier.""" + conv_scorer = create_conversation_scorer(scorer=MockFloatScaleScorer()) + + with patch.object(type(conv_scorer), "_create_identifier", return_value="not-an-identifier"): + with pytest.raises(TypeError, match="Conversation scorer identifier must be a ComponentIdentifier"): + conv_scorer._build_identifier() + + def test_conversation_scorer_validates_float_scale_scores(): """Test that ConversationScorer delegates float scale score validation to wrapped scorer.""" scorer = MockFloatScaleScorer() diff --git a/uv.lock b/uv.lock index 8fa68fe46c..26f78f6208 100644 --- a/uv.lock +++ b/uv.lock @@ -1095,7 +1095,7 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -1173,7 +1173,7 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } @@ -1417,7 +1417,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, @@ -1452,43 +1452,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] [[package]] @@ -1699,7 +1699,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2420,17 +2420,17 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } wheels = [ @@ -2453,17 +2453,17 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/dd/fb08d22ec0c27e73c8bc8f71810709870d51cadaf27b7ddd3f011236c100/ipython-9.9.0.tar.gz", hash = "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", size = 4425043, upload-time = "2026-01-05T12:36:46.233Z" } wheels = [ @@ -2475,7 +2475,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -4019,7 +4019,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -4058,7 +4058,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -4070,7 +4070,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -4100,9 +4100,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -4114,7 +4114,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -5487,7 +5487,7 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "respx", specifier = ">=0.22.0" }, { name = "ruff", specifier = ">=0.14.4" }, - { name = "ty", specifier = ">=0.0.32,<0.0.70" }, + { name = "ty", specifier = ">=0.0.32" }, { name = "types-aiofiles", specifier = ">=24.1.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250516" }, { name = "types-requests", specifier = ">=2.31.0.20250515" }, @@ -5794,9 +5794,9 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions" }, + { name = "attrs", marker = "python_full_version < '3.12'" }, + { name = "rpds-py", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } wheels = [ @@ -5816,9 +5816,9 @@ resolution-markers = [ "(python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "attrs", marker = "python_full_version >= '3.12'" }, + { name = "rpds-py", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -6212,7 +6212,7 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -6279,7 +6279,7 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload-time = "2025-10-28T17:38:54.068Z" } @@ -7128,27 +7128,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.69" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/5b/7a618632dfe9373b7df572ecd7a08c8f799d772fbc317da82dd3aa363207/ty-0.0.69.tar.gz", hash = "sha256:b65106e9ff24fa76e25e1142fb09c85244e815c40450e3021d2bf652c231bb43", size = 6565094, upload-time = "2026-08-06T10:04:25.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/60/6534092f4d2c15e2491807edd609c2e50d527c1fed957acf40b9f110b64a/ty-0.0.69-py3-none-linux_armv6l.whl", hash = "sha256:98bfd383b273540829af673e7f98b9c1c4bcc8547d12a1a3806cd0bec7f0e087", size = 12364185, upload-time = "2026-08-06T10:03:47.137Z" }, - { url = "https://files.pythonhosted.org/packages/34/2b/5c29689bd4f74c2e3394d983d85e4011b629f2ce3730c9442553b8554bf8/ty-0.0.69-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:964621ddd05771660017c51b4e74078d861d9fc863c21ef2a500db1ab62c9ccf", size = 12042510, upload-time = "2026-08-06T10:03:49.481Z" }, - { url = "https://files.pythonhosted.org/packages/09/46/fa085bde4d23516d7ef14b24736fc5dd7dc498f60f52b3d077e59ffdea20/ty-0.0.69-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3ffea4048dd0da4c9c97393b4be0901098a9065b06fa81be2477cbde65d8a151", size = 11549397, upload-time = "2026-08-06T10:03:51.747Z" }, - { url = "https://files.pythonhosted.org/packages/25/cc/97b9efb2061dcab6fef1e94a4ad99df0bb45bd2cc15d4f5794c787ee0552/ty-0.0.69-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8684d4a70aadd1eab0f41bdba835e3288ef49db8402a8e6ca81bab52ed5d610", size = 12115567, upload-time = "2026-08-06T10:03:53.79Z" }, - { url = "https://files.pythonhosted.org/packages/e0/c1/a5e0404965093835f3e62544e661784ec0aa8ef0b006ed50af50b19c107e/ty-0.0.69-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:afaaba240ab4122e2069a796836d10be81b4ddb053ae268b3dff962a0b4ca5c7", size = 12149770, upload-time = "2026-08-06T10:03:55.993Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8cad6b205a4abe8a044ca0c84aea71e8ccda29b07a75a5f090e310605580/ty-0.0.69-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ea63ef07d4e33aeb1a775cf5f2c736b3ed22fa6f8b1b608591612c36795044", size = 12941278, upload-time = "2026-08-06T10:03:58.324Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/8766d96b732c2a060d70dc8ccafcc4d6a54109a2a95f1deb0705de88892b/ty-0.0.69-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb3730b1268e92a2907d7aea3afe8dd1b360ae65862f0557080cf479d481b424", size = 13426509, upload-time = "2026-08-06T10:04:00.621Z" }, - { url = "https://files.pythonhosted.org/packages/02/1f/e991b2cde953ea5b94d6a9a4c45c87937bd916bc09235f764407bf471c0a/ty-0.0.69-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a544ff57a752ef186ed40b5a2f44c17402af4cdefeb74a311ca02ebd57c4fca0", size = 13106582, upload-time = "2026-08-06T10:04:02.818Z" }, - { url = "https://files.pythonhosted.org/packages/ea/bb/73538f1b99e3558fd9db87b98698426f0f60fc8666da0b1efd0e70e275eb/ty-0.0.69-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87ed2cbca20caddfdf8e3e14d213ce91b67e75feed78900f4aaf3ef884954028", size = 12708931, upload-time = "2026-08-06T10:04:05.233Z" }, - { url = "https://files.pythonhosted.org/packages/87/cd/484a5208d74c4ad1155933906295ccdce9aa81a257d8df2ab9e41bd60133/ty-0.0.69-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2684efcbce5b6fe45045faf610b377b50781b6d2aa7e61ea23ecf5b3d2bce421", size = 12985322, upload-time = "2026-08-06T10:04:07.587Z" }, - { url = "https://files.pythonhosted.org/packages/6e/81/b75003f0d4da9ab3bc8fd4f4802f836cb9921ff7e70f460604f7b769a0b5/ty-0.0.69-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da9aeb26fdac1d2214937542b59e0d4d1ba94ec7a3f45444f33c846de1eb1d63", size = 12063910, upload-time = "2026-08-06T10:04:09.835Z" }, - { url = "https://files.pythonhosted.org/packages/8a/76/088469f547ef63dceefc4a75826aedee5014f9371dc5171cde931896a82c/ty-0.0.69-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:00e7677cd14ede381f705f71104ea7b8ea0ce217a8634e19a89781953de0e9ad", size = 12166823, upload-time = "2026-08-06T10:04:12.114Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c9/ce88a0bec0d46d8ae180b99c6ec014866fecc4cba1727b5feec8877b2765/ty-0.0.69-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d91965eb799649833d0d6042db09cd03d15289125245337cc46a2606effb7bda", size = 12483136, upload-time = "2026-08-06T10:04:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/63/9e/6fae0ff225a0012642cf72c077e20f8f448c0a80771bc3360e8178fe2f32/ty-0.0.69-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f03359cd8e5c412aa0c181118fa9b9061a4dddaedbb61bac0a424fb0814d402", size = 12799025, upload-time = "2026-08-06T10:04:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/e4/43/78a658d18b2a4ccf35b053392f2213bf12e3c63b2abea512d3b6751d1f4c/ty-0.0.69-py3-none-win32.whl", hash = "sha256:ec460e01586b1eb91894c4a8403bee3e045a47e7a4ada943cc27ce8e348e88cf", size = 11787774, upload-time = "2026-08-06T10:04:18.622Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/88db1f674403f2b81316a853a44a81ed220621fa96f8f7ae586fb6ca7513/ty-0.0.69-py3-none-win_amd64.whl", hash = "sha256:18976ca26a4e28fc3249477f79a695d5502e670803f2e080d89ac905baef3c6e", size = 12864038, upload-time = "2026-08-06T10:04:20.748Z" }, - { url = "https://files.pythonhosted.org/packages/4d/7b/6fc6efd00c69103d70f2bdbe824343089cd70b17b3079170057d3e5a3ac0/ty-0.0.69-py3-none-win_arm64.whl", hash = "sha256:7d4ca3bb74d91cb9947ba3f3b4cb131ad6a2b3ecc76d34040c4ec6092d2e411d", size = 12196693, upload-time = "2026-08-06T10:04:22.902Z" }, +version = "0.0.73" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/90/c4e1bb4cead3b644c3e258a27f9b05c7dc5eb0ec96a4f5282194edae9e0d/ty-0.0.73.tar.gz", hash = "sha256:823d4ce0d237bfc7eb6bcee70842f2c0706113813a16951077840743712f4b74", size = 6712739, upload-time = "2026-08-19T03:12:43.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/0f/f5e1801e55cc631f2db193276675b30561b963a2403da832bffb5d100267/ty-0.0.73-py3-none-linux_armv6l.whl", hash = "sha256:90a946082bf9bc446b5e72973d9f4ff1222a240b2ca4c9e6eed61eb913e30810", size = 12715452, upload-time = "2026-08-19T03:12:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/32/515dd05074c213b433524ab97eb003b0132ae7e358e0d75633ba7a314ed8/ty-0.0.73-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b7d6b5c6a6db7ea95fbbc16af514ef44a27a29a2fe1dc798900790364d170209", size = 12301870, upload-time = "2026-08-19T03:12:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/50/4d/085b4889f0d4bbe4af8b96242d4a1cb209fff95967cfa239ea141983719b/ty-0.0.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6f657f463e01372d8688f235be164750c8db722c97da27fa4903aa8d40b203", size = 12111741, upload-time = "2026-08-19T03:12:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/95/f6/d6ec277cadfecf03ad4c18551b67c4c6eb7807a0560d801db14be99d7a89/ty-0.0.73-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2de468e33fd44c9ff1c43473a7316f4289480f5cba8995a67b6d22aee39ca9", size = 12196124, upload-time = "2026-08-19T03:12:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/75/b7/ce78d8707563af9cae9bbd25328bfbc4931035085bd20089adf0c418f70e/ty-0.0.73-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2942fa0ef795a66034cdc8d75a72f453442f3b58ff2f69b4da05b7b954765b55", size = 12488557, upload-time = "2026-08-19T03:12:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e8/329b9851b23502758c5c98e8cc875ea2a1b4c9674b4ca3a86da56a5063d3/ty-0.0.73-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e0f1ef14f642e18ac4e7a616a2796dcf7a5d82e28cd17f9796494acc7c4aabb", size = 13215606, upload-time = "2026-08-19T03:12:17.225Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/67fedfd2cb77516ef0066b1642f487dba0eb3006493cf3475b15f5b8b228/ty-0.0.73-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16981e15fdceedb37d0aff76c5ac25914595dfee2675af95335550064251ad22", size = 13665497, upload-time = "2026-08-19T03:12:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b3/154f4dd48ec5eebc186ab4b822c6e62f982fc5ddfd262d6e3903c2acba44/ty-0.0.73-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644b2bec8a2e2e4957a942ae81d6cff5571c489bb5a8675e4d3886de537a694d", size = 13351231, upload-time = "2026-08-19T03:12:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/35/5f/d462496903fbe453fb76363f8478be929c8e6ff21e6928c57dcd7e5fa21f/ty-0.0.73-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338d565be3186f50ff8e9d10483685549c2d23f0754485d5ede3b54f4319188a", size = 12782586, upload-time = "2026-08-19T03:12:23.667Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/ec6d24b74abe3ec324204c1c71e6d0c6c76a17ffc15fd51d603b0a302abe/ty-0.0.73-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:11c7b6d839309d2c102cb3a4c03d817176bbfab5b2fccc95a75ec5c9597421c9", size = 13247134, upload-time = "2026-08-19T03:12:25.956Z" }, + { url = "https://files.pythonhosted.org/packages/26/20/cc74650fec56a54786c6d7c89e09576fcad3092be34cf21715d39a406a9b/ty-0.0.73-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:488572db7ff97fb50ea36a76250f2d617c9727d143da6c7bf0623276eb0fc507", size = 12309344, upload-time = "2026-08-19T03:12:28.122Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/4b0a9087f4315d7fbadf77a3ce44c816cc9ffabed1ced06cc5be81fbc414/ty-0.0.73-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1b958ebceefbbf594e59eb8d3d55bbd033ce634026fcba3e4bc3179e78e45bb7", size = 12502319, upload-time = "2026-08-19T03:12:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/11/80/0a925074911fe111912ea29d9eed309bcc183f43d2fb3eef07db056a0beb/ty-0.0.73-py3-none-musllinux_1_2_i686.whl", hash = "sha256:91a32993b3c34e42c3f323ad6c0399cb596bd1c27e9b7f20db7cd64c1067b68e", size = 12753688, upload-time = "2026-08-19T03:12:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/6b/aeccaf89efbc2e112bd415340a22e2669ec998aa397242503e747b712ca4/ty-0.0.73-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bab8a19fbf51f479bddb2a12c5fabfe52f918a5590362321ed5d89b44eb62c15", size = 13069050, upload-time = "2026-08-19T03:12:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3e/eae485fd86c1585943fd4e1746b0757b2da01e2c43136ebe8c686fe1c7f1/ty-0.0.73-py3-none-win32.whl", hash = "sha256:03347a612f0fa020b19bfd8dbd521db6ecc75d377a3e4d4f6e6c2e62871da4cc", size = 12053187, upload-time = "2026-08-19T03:12:37.565Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/9b8b983786e3ce34924e372e8b76b92b508273ab65c589fc7e88cc03ee17/ty-0.0.73-py3-none-win_amd64.whl", hash = "sha256:cedd05122ded0b5dcc55431a370e974b747f99c41c290a3d2ab8c1867f197519", size = 12693838, upload-time = "2026-08-19T03:12:39.483Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" }, ] [[package]]