Coverage for haystack/components/routers/file_type_router.py: 92%
72 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import mimetypes
6import re
7from collections import defaultdict
8from pathlib import Path
9from typing import Any
11from haystack import component, default_from_dict, default_to_dict, logging
12from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
13from haystack.dataclasses import ByteStream
15from haystack.utils.misc import _guess_mime_type # ruff: isort: skip
17# We import CUSTOM_MIMETYPES here to prevent breaking change from moving to haystack.utils.misc
18from haystack.utils.misc import CUSTOM_MIMETYPES # noqa: F401
20logger = logging.getLogger(__name__)
23@component
24class FileTypeRouter:
25 """
26 Categorizes files or byte streams by their MIME types, helping in context-based routing.
28 FileTypeRouter supports both exact MIME type matching and regex patterns.
30 For file paths, MIME types come from extensions; byte streams use metadata.
31 Each entry in `mime_types` is matched against a source's MIME type by exact equality first,
32 falling back to regex `fullmatch` if equality misses. So `"image/svg+xml"` routes
33 `image/svg+xml` streams correctly via the equality check (without `+` being interpreted as a
34 regex quantifier), and patterns like `"audio/.*"` keep matching every audio subtype.
36 ### Usage example
38 ```python
39 from haystack.components.routers import FileTypeRouter
40 from pathlib import Path
42 # Exact MIME matching — `+`-containing IANA types like image/svg+xml work correctly
43 router = FileTypeRouter(mime_types=["text/plain", "application/pdf", "image/svg+xml"])
45 # Regex matching — catch every audio subtype
46 router_with_regex = FileTypeRouter(mime_types=[r"audio/.*", r"text/plain"])
48 sources = [Path("file.txt"), Path("document.pdf"), Path("song.mp3")]
49 print(router.run(sources=sources))
50 print(router_with_regex.run(sources=sources))
52 # Expected output:
53 # {'text/plain': [
54 # PosixPath('file.txt')], 'application/pdf': [PosixPath('document.pdf')], 'unclassified': [PosixPath('song.mp3')
55 # ]}
56 # {'audio/.*': [
57 # PosixPath('song.mp3')], 'text/plain': [PosixPath('file.txt')], 'unclassified': [PosixPath('document.pdf')
58 # ]}
59 ```
60 """
62 def __init__(
63 self, mime_types: list[str], additional_mimetypes: dict[str, str] | None = None, raise_on_failure: bool = False
64 ) -> None:
65 """
66 Initialize the FileTypeRouter component.
68 :param mime_types:
69 A list of MIME types or regex patterns to classify the input files or byte streams.
70 (for example: `["text/plain", "audio/x-wav", "image/jpeg"]`).
72 :param additional_mimetypes:
73 A dictionary containing the MIME type to add to the mimetypes package to prevent unsupported or non-native
74 packages from being unclassified.
75 (for example: `{"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx"}`).
77 :param raise_on_failure:
78 If True, raises FileNotFoundError when a file path doesn't exist.
79 If False (default), only emits a warning when a file path doesn't exist.
80 """
81 if not mime_types:
82 raise ValueError("The list of mime types cannot be empty.")
84 if additional_mimetypes:
85 for mime, ext in additional_mimetypes.items():
86 mimetypes.add_type(mime, ext)
88 self.mime_type_patterns = []
89 for mime_type in mime_types:
90 try:
91 pattern = re.compile(mime_type)
92 except re.error as e:
93 raise ValueError(f"Invalid MIME type or regex pattern '{mime_type}'.") from e
94 self.mime_type_patterns.append(pattern)
96 # the actual output type is list[Union[Path, ByteStream]],
97 # but this would cause PipelineConnectError with Converters
98 component.set_output_types(
99 self,
100 unclassified=list[str | Path | ByteStream],
101 failed=list[str | Path | ByteStream],
102 **dict.fromkeys(mime_types, list[str | Path | ByteStream]),
103 )
104 self.mime_types = mime_types
105 self._additional_mimetypes = additional_mimetypes
106 self._raise_on_failure = raise_on_failure
108 def to_dict(self) -> dict[str, Any]:
109 """
110 Serializes the component to a dictionary.
112 :returns:
113 Dictionary with serialized data.
114 """
115 return default_to_dict(
116 self,
117 mime_types=self.mime_types,
118 additional_mimetypes=self._additional_mimetypes,
119 raise_on_failure=self._raise_on_failure,
120 )
122 @classmethod
123 def from_dict(cls, data: dict[str, Any]) -> "FileTypeRouter":
124 """
125 Deserializes the component from a dictionary.
127 :param data:
128 The dictionary to deserialize from.
129 :returns:
130 The deserialized component.
131 """
132 return default_from_dict(cls, data)
134 def run(
135 self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
136 ) -> dict[str, list[ByteStream | Path]]:
137 """
138 Categorize files or byte streams according to their MIME types.
140 :param sources:
141 A list of file paths or byte streams to categorize.
143 :param meta:
144 Optional metadata to attach to the sources.
145 When provided, the sources are internally converted to ByteStream objects and the metadata is added.
146 This value can be a list of dictionaries or a single dictionary.
147 If it's a single dictionary, its content is added to the metadata of all ByteStream objects.
148 If it's a list, its length must match the number of sources, as they are zipped together.
150 :returns: A dictionary where the keys are MIME types and the values are lists of data sources.
151 Two extra keys may be returned: `"unclassified"` when a source's MIME type doesn't match any pattern
152 and `"failed"` when a source cannot be processed (for example, a file path that doesn't exist).
153 :raises TypeError: If a source is not a Path, str, or ByteStream.
154 """
156 mime_types: defaultdict[str, list[Path | ByteStream]] = defaultdict(list)
157 meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
159 for source, meta_dict in zip(sources, meta_list, strict=True):
160 if isinstance(source, str):
161 source = Path(source)
163 if isinstance(source, Path):
164 if not source.exists():
165 if self._raise_on_failure:
166 raise FileNotFoundError(f"File not found: {source}")
167 logger.warning("File not found: {source}. Skipping it.", source=source)
168 mime_types["failed"].append(source)
169 continue
171 mime_type = _guess_mime_type(source)
173 elif isinstance(source, ByteStream):
174 mime_type = source.mime_type
175 else:
176 raise TypeError(f"Unsupported data source type: {type(source).__name__}")
178 # If we have metadata, we convert the source to ByteStream and add the metadata
179 if meta_dict:
180 try:
181 source = get_bytestream_from_source(source)
182 except Exception as e:
183 if self._raise_on_failure:
184 raise e
185 logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
186 mime_types["failed"].append(source)
187 continue
189 source.meta.update(meta_dict)
191 matched = False
192 if mime_type:
193 # Try exact equality first so MIMEs containing regex metacharacters (e.g. the `+` in
194 # `image/svg+xml`) match themselves before the regex fallback gets a chance to misread them.
195 for bucket_key, pattern in zip(self.mime_types, self.mime_type_patterns, strict=True):
196 if mime_type == bucket_key or pattern.fullmatch(mime_type):
197 mime_types[bucket_key].append(source)
198 matched = True
199 break
200 if not matched:
201 mime_types["unclassified"].append(source)
203 return dict(mime_types)