5ec929435b
**Resolved all 65 mypy errors across 14 files and added type annotations to all previously untyped function bodies. Final result: 0 errors with --check-untyped-defs enabled, all 454 tests pass.** `scrapling/core/_types.py` - Removed broken Self = object fallback — now requires typing_extensions for Python < 3.11 `scrapling/core/storage.py` - Fixed str/bytes mismatch in _get_hash() — used separate _identifier_bytes variable instead of reassigning from str to bytes `scrapling/core/custom_types.py` - split() return type: Union[List, "TextHandlers"] → list[Any] (avoids LSP violation with parent list[str]) - format() kwargs: **kwargs: str → **kwargs: object (matches parent str.format signature) - AttributesHandler.__init__: Added mapping: Any = None, **kwargs: Any and -> None - json_string property: Added -> bytes return type `scrapling/core/mixins.py` - Changed self: "Selector" to self: Any on all mixin methods (mypy can't handle forward-reference self types on non-subclass mixins) - Added Dict[str, int] annotation for counter variable - Removed unused TYPE_CHECKING / Selector imports `scrapling/parser.py (~30 errors)` - Added body: str | bytes pre-annotation for dual-type if/elif assignment - Used Dict[str, Any] kwargs dict for HTMLParser(...) to bypass incomplete lxml stubs missing default_doctype - Changed base_url=url or None → base_url=url or "" (avoids str | None vs str | bytes) - bool(adaptive) to guarantee bool type for __adaptive_enabled - Declared __text: Optional[TextHandler], __tag: Optional[str], __attributes: Optional[AttributesHandler] at top of __init__ - cast(List, ...) for all XPath() call results (_find_all_elements, _find_all_elements_with_spaces) - Added Dict[float, List[Any]] for score_table, Dict[str, Any] for attributes - Changed score, checks = 0, 0 → score: float = 0; checks: int = 0 (two locations) - Renamed target → target_element in save() to avoid variable redefinition with different types - Wrapped node_text.clean() / .lower() in TextHandler(...) to preserve type `scrapling/engines/_browsers/_page.py` - Added PageInfo[SyncPage] | PageInfo[AsyncPage] union type annotation to page_info variable `scrapling/engines/_browsers/_validators.py` - Convert method_kwargs (TypedDict) to plain Dict[str, Any] before dynamic key access `scrapling/engines/_browsers/_base.py` - Added _config declaration to BaseSessionMixin - Used cast(StealthConfig, self._config) in __generate_stealth_options to access stealth-only attributes - Added Tuple[str, ...] annotation for flags - Removed redundant narrower StealthConfig type annotation on self._config in StealthySessionMixin.__validate__ - Widened SyncSession and AsyncSession fields (playwright, context, browser) to Any to support both playwright and patchright types - Added -> None to both start() methods `scrapling/engines/_browsers/_stealth.py` - Added Optional, ProxyType imports - Annotated proxy: Optional[ProxyType] in both sync/async fetch loops - Annotated outer_box: Any at first declaration, removed duplicate type annotations in subsequent branches - Added -> None to sync and async start() - Added config: Any parameter type to _initialize_context - Removed redundant self.context: AsyncBrowserContext re-annotations in conditional branches `scrapling/engines/_browsers/_controllers.py` - Added Optional, ProxyType imports - Annotated proxy: Optional[ProxyType] in both sync/async fetch loops - Added -> None to async start() - Removed redundant self.context: AsyncBrowserContext re-annotations `scrapling/spiders/request.py` - Added Optional import, typed _fp: Optional[bytes] = None - Removed redundant body: bytes re-annotation `scrapling/spiders/session.py` - Used separate client variable instead of reassigning session = session._client (avoids type incompatibility and fixes a bug where session._make_request was called instead of client._make_request) - Added -> None to SessionManager.__init__ `scrapling/engines/toolbelt/convertor.py` - Added list[Response] annotation for history in both sync/async methods `scrapling/engines/static.py` - FetcherClient.__init__ and AsyncFetcherClient.__init__: Added **kwargs: Any and -> None `scrapling/core/shell.py` - Wrapped re_sub(...) result in TextHandler(...) to maintain correct type - Added -> None to CurlParser.__init__ - Added full type signature to create_wrapper, replaced wrapper.__signature__ = ... with setattr(wrapper, "__signature__", ...) to satisfy mypy - Added Callable to imports
146 lines
5.2 KiB
Python
146 lines
5.2 KiB
Python
from asyncio import Lock
|
|
|
|
from scrapling.spiders.request import Request
|
|
from scrapling.engines.static import _ASyncSessionLogic
|
|
from scrapling.engines.toolbelt.convertor import Response
|
|
from scrapling.core._types import Set, cast, SUPPORTED_HTTP_METHODS
|
|
from scrapling.fetchers import AsyncDynamicSession, AsyncStealthySession, FetcherSession
|
|
|
|
Session = FetcherSession | AsyncDynamicSession | AsyncStealthySession
|
|
|
|
|
|
class SessionManager:
|
|
"""Manages pre-configured session instances."""
|
|
|
|
def __init__(self) -> None:
|
|
self._sessions: dict[str, Session] = {}
|
|
self._default_session_id: str | None = None
|
|
self._started: bool = False
|
|
self._lazy_sessions: Set[str] = set()
|
|
self._lazy_lock = Lock()
|
|
|
|
def add(self, session_id: str, session: Session, *, default: bool = False, lazy: bool = False) -> "SessionManager":
|
|
"""Register a session instance.
|
|
|
|
:param session_id: Name to reference this session in requests
|
|
:param session: Your pre-configured session instance
|
|
:param default: If True, this becomes the default session
|
|
:param lazy: If True, the session will be started only when a request uses its ID.
|
|
"""
|
|
if session_id in self._sessions:
|
|
raise ValueError(f"Session '{session_id}' already registered")
|
|
|
|
self._sessions[session_id] = session
|
|
|
|
if default or self._default_session_id is None:
|
|
self._default_session_id = session_id
|
|
|
|
if lazy:
|
|
self._lazy_sessions.add(session_id)
|
|
|
|
return self
|
|
|
|
def remove(self, session_id: str) -> None:
|
|
"""Removes a session.
|
|
|
|
:param session_id: ID of session to remove
|
|
"""
|
|
_ = self.pop(session_id)
|
|
|
|
def pop(self, session_id: str) -> Session:
|
|
"""Remove and returns a session.
|
|
|
|
:param session_id: ID of session to remove
|
|
"""
|
|
if session_id not in self._sessions:
|
|
raise KeyError(f"Session '{session_id}' not found")
|
|
|
|
session = self._sessions.pop(session_id)
|
|
if session_id in self._lazy_sessions:
|
|
self._lazy_sessions.remove(session_id)
|
|
|
|
if session and self._default_session_id == session_id:
|
|
self._default_session_id = next(iter(self._sessions), None)
|
|
|
|
return session
|
|
|
|
@property
|
|
def default_session_id(self) -> str:
|
|
if self._default_session_id is None:
|
|
raise RuntimeError("No sessions registered")
|
|
return self._default_session_id
|
|
|
|
@property
|
|
def session_ids(self) -> list[str]:
|
|
return list(self._sessions.keys())
|
|
|
|
def get(self, session_id: str) -> Session:
|
|
if session_id not in self._sessions:
|
|
available = ", ".join(self._sessions.keys())
|
|
raise KeyError(f"Session '{session_id}' not found. Available: {available}")
|
|
return self._sessions[session_id]
|
|
|
|
async def start(self) -> None:
|
|
"""Start all sessions that aren't already alive."""
|
|
if self._started:
|
|
return
|
|
|
|
for sid, session in self._sessions.items():
|
|
if sid not in self._lazy_sessions and not session._is_alive:
|
|
await session.__aenter__()
|
|
|
|
self._started = True
|
|
|
|
async def close(self) -> None:
|
|
"""Close all registered sessions."""
|
|
for session in self._sessions.values():
|
|
_ = await session.__aexit__(None, None, None)
|
|
|
|
self._started = False
|
|
|
|
async def fetch(self, request: Request) -> Response:
|
|
sid = request.sid if request.sid else self.default_session_id
|
|
session = self.get(sid)
|
|
|
|
if session:
|
|
if sid in self._lazy_sessions and not session._is_alive:
|
|
async with self._lazy_lock:
|
|
if not session._is_alive:
|
|
await session.__aenter__()
|
|
|
|
if isinstance(session, FetcherSession):
|
|
client = session._client
|
|
|
|
if isinstance(client, _ASyncSessionLogic):
|
|
response = await client._make_request(
|
|
method=cast(SUPPORTED_HTTP_METHODS, request._session_kwargs.pop("method", "GET")),
|
|
url=request.url,
|
|
**request._session_kwargs,
|
|
)
|
|
else:
|
|
# Sync session or other types - shouldn't happen in async context
|
|
raise TypeError(f"Session type {type(client)} not supported for async fetch")
|
|
else:
|
|
response = await session.fetch(url=request.url, **request._session_kwargs)
|
|
|
|
response.request = request
|
|
# Merge request meta into response meta (response meta takes priority)
|
|
response.meta = {**request.meta, **response.meta}
|
|
return response
|
|
raise RuntimeError("No session found with the request session id")
|
|
|
|
async def __aenter__(self) -> "SessionManager":
|
|
await self.start()
|
|
return self
|
|
|
|
async def __aexit__(self, *exc) -> None:
|
|
await self.close()
|
|
|
|
def __contains__(self, session_id: str) -> bool:
|
|
"""Check if a session ID is registered."""
|
|
return session_id in self._sessions
|
|
|
|
def __len__(self) -> int:
|
|
"""Number of registered sessions."""
|
|
return len(self._sessions)
|