Coverage for src/qdrant_loader/connectors/base.py: 95%
38 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
1import os
2import warnings
3from collections.abc import AsyncIterator
4from datetime import datetime
6from qdrant_loader.config.source_config import SourceConfig
7from qdrant_loader.core.document import Document
8from qdrant_loader.core.file_conversion import FileConversionConfig
11def resolve_safe_path(base_dir: str, entity_id: str) -> str | None:
12 """Resolve ``entity_id`` (a ``/``-separated relative path) under ``base_dir``.
14 Returns the resolved absolute path, or ``None`` if it would escape
15 ``base_dir`` (e.g. via ``../`` segments), guarding against path traversal.
16 """
17 candidate = os.path.join(base_dir, *entity_id.split("/"))
18 base_real = os.path.realpath(base_dir)
19 candidate_real = os.path.realpath(candidate)
20 if candidate_real != base_real and not candidate_real.startswith(
21 base_real + os.sep
22 ):
23 return None
24 return candidate
27class ConnectorConfigurationError(Exception):
28 """Raised when a connector's configuration is invalid or access is denied.
30 This is a *fatal* error: the pipeline should stop rather than silently
31 continuing with 0 documents.
32 """
35class BaseConnector:
36 """Base class for all connectors."""
38 def __init__(self, config: SourceConfig):
39 self.config = config
40 self._initialized = False
42 async def __aenter__(self):
43 """Async context manager entry."""
44 self._initialized = True
45 return self
47 async def __aexit__(self, exc_type, exc_val, _exc_tb):
48 """Async context manager exit."""
49 self._initialized = False
51 def set_file_conversion_config(
52 self, file_conversion_config: FileConversionConfig
53 ) -> None:
54 """Set file conversion configuration.
56 This default implementation stores the configuration for potential
57 use by subclasses that choose to honor it.
59 Args:
60 file_conversion_config: Global file conversion configuration
61 """
62 # Store on the instance so connectors that opt-in can access it.
63 self._file_conversion_config = file_conversion_config
65 async def stream_documents(
66 self, since: datetime | None = None
67 ) -> AsyncIterator[Document]:
68 """Stream documents from the source (WS-1 connector contract).
70 Connectors must implement true streaming. This default raises
71 NotImplementedError to prevent silently materializing the full
72 document list via get_documents().
73 """
74 if False: # pragma: no cover - makes this function an async generator
75 yield "" # type: ignore
76 raise NotImplementedError(
77 f"{type(self).__name__} does not implement stream_documents"
78 )
80 async def get_documents(self) -> list[Document]:
81 """Get documents from the source (DEPRECATED - use stream_documents)."""
82 warnings.warn(
83 "BaseConnector.get_documents is deprecated. Implement stream_documents() "
84 "or use connector.stream_documents() to avoid materializing the full "
85 "document list in memory.",
86 DeprecationWarning,
87 stacklevel=2,
88 )
89 documents: list[Document] = []
90 async for document in self.stream_documents():
91 documents.append(document)
92 return documents
94 async def fetch_by_id(self, entity_id: str) -> Document | None:
95 """Fetch a single entity by ID (WS-1 connector contract).
97 Connectors that support single-event ingestion must override this method.
98 """
99 raise NotImplementedError(f"{type(self).__name__} does not support fetch_by_id")
101 async def list_entity_ids(self) -> AsyncIterator[str]:
102 """Stream all entity IDs for reconciliation (WS-1 connector contract).
104 Yields:
105 Entity IDs from the source.
106 """
107 # Make this an async generator so callers can use `async for` and
108 # receive a NotImplementedError during iteration rather than at call-time.
109 if False: # pragma: no cover - makes this function an async generator
110 yield ""
111 raise NotImplementedError(
112 f"{type(self).__name__} does not support list_entity_ids"
113 )