Skip to content

Commit 10ea58f

Browse files
Initial implementation of HTTP connector
1 parent 662266a commit 10ea58f

File tree

9 files changed

+318
-22
lines changed

9 files changed

+318
-22
lines changed

singer_sdk/authenticators.py

+50
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import requests
1414
from cryptography.hazmat.backends import default_backend
1515
from cryptography.hazmat.primitives import serialization
16+
from requests.auth import AuthBase
1617

1718
from singer_sdk.helpers._util import utc_now
1819

@@ -589,3 +590,52 @@ def oauth_request_payload(self) -> dict:
589590
"RS256",
590591
),
591592
}
593+
594+
595+
class NoopAuth(AuthBase):
596+
"""No-op authenticator."""
597+
598+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
599+
"""Do nothing.
600+
601+
Args:
602+
r: The prepared request.
603+
604+
Returns:
605+
The unmodified prepared request.
606+
"""
607+
return r
608+
609+
610+
class HeaderAuth(AuthBase):
611+
"""Header-based authenticator."""
612+
613+
def __init__(
614+
self,
615+
keyword: str,
616+
value: str,
617+
header: str = "Authorization",
618+
) -> None:
619+
"""Initialize the authenticator.
620+
621+
Args:
622+
keyword: The keyword to use in the header, e.g. "Bearer".
623+
value: The value to use in the header, e.g. "my-token".
624+
header: The header to add the keyword and value to, defaults to
625+
``"Authorization"``.
626+
"""
627+
self.keyword = keyword
628+
self.value = value
629+
self.header = header
630+
631+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
632+
"""Add the header to the request.
633+
634+
Args:
635+
r: The prepared request.
636+
637+
Returns:
638+
The prepared request with the header added.
639+
"""
640+
r.headers[self.header] = f"{self.keyword} {self.value}"
641+
return r

singer_sdk/connectors/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from ._http import HTTPConnector
56
from .sql import SQLConnector
67

7-
__all__ = ["SQLConnector"]
8+
__all__ = ["HTTPConnector", "SQLConnector"]

singer_sdk/connectors/_http.py

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""HTTP-based tap class for Singer SDK."""
2+
3+
from __future__ import annotations
4+
5+
import typing as t
6+
7+
import requests
8+
9+
from singer_sdk.authenticators import NoopAuth
10+
from singer_sdk.connectors.base import BaseConnector
11+
12+
if t.TYPE_CHECKING:
13+
import sys
14+
15+
from requests.adapters import BaseAdapter
16+
17+
if sys.version_info >= (3, 10):
18+
from typing import TypeAlias # noqa: ICN003
19+
else:
20+
from typing_extensions import TypeAlias
21+
22+
_Auth: TypeAlias = t.Callable[[requests.PreparedRequest], requests.PreparedRequest]
23+
24+
25+
class HTTPConnector(BaseConnector[requests.Session]):
26+
"""Base class for all HTTP-based connectors."""
27+
28+
def __init__(self, config: t.Mapping[str, t.Any] | None) -> None:
29+
"""Initialize the HTTP connector.
30+
31+
Args:
32+
config: Connector configuration parameters.
33+
"""
34+
super().__init__(config)
35+
self._session = self.get_session()
36+
self.refresh_auth()
37+
38+
def get_connection(self, *, authenticate: bool = True) -> requests.Session:
39+
"""Return a new HTTP session object.
40+
41+
Adds adapters and optionally authenticates the session.
42+
43+
Args:
44+
authenticate: Whether to authenticate the request.
45+
46+
Returns:
47+
A new HTTP session object.
48+
"""
49+
for prefix, adapter in self.adapters.items():
50+
self._session.mount(prefix, adapter)
51+
52+
self._session.auth = self._auth if authenticate else None
53+
54+
return self._session
55+
56+
def get_session(self) -> requests.Session:
57+
"""Return a new HTTP session object.
58+
59+
Returns:
60+
A new HTTP session object.
61+
"""
62+
return requests.Session()
63+
64+
def get_authenticator(self) -> _Auth:
65+
"""Authenticate the HTTP session.
66+
67+
Returns:
68+
An auth callable.
69+
"""
70+
return NoopAuth()
71+
72+
def refresh_auth(self) -> None:
73+
"""Refresh the HTTP session authentication."""
74+
self._auth = self.get_authenticator()
75+
76+
@property
77+
def adapters(self) -> dict[str, BaseAdapter]:
78+
"""Return a mapping of URL prefixes to adapter objects.
79+
80+
Returns:
81+
A mapping of URL prefixes to adapter objects.
82+
"""
83+
return {}
84+
85+
@property
86+
def default_request_kwargs(self) -> dict[str, t.Any]:
87+
"""Return default kwargs for HTTP requests.
88+
89+
Returns:
90+
A mapping of default kwargs for HTTP requests.
91+
"""
92+
return {}
93+
94+
def request(
95+
self,
96+
*args: t.Any,
97+
authenticate: bool = True,
98+
**kwargs: t.Any,
99+
) -> requests.Response:
100+
"""Make an HTTP request.
101+
102+
Args:
103+
*args: Positional arguments to pass to the request method.
104+
authenticate: Whether to authenticate the request.
105+
**kwargs: Keyword arguments to pass to the request method.
106+
107+
Returns:
108+
The HTTP response object.
109+
"""
110+
with self._connect(authenticate=authenticate) as session:
111+
kwargs = {**self.default_request_kwargs, **kwargs}
112+
return session.request(*args, **kwargs)

singer_sdk/connectors/base.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Base class for all connectors."""
2+
3+
from __future__ import annotations
4+
5+
import abc
6+
import typing as t
7+
from contextlib import contextmanager
8+
9+
from singer_sdk.helpers._compat import Protocol
10+
11+
_T = t.TypeVar("_T", covariant=True)
12+
13+
14+
class ContextManagerProtocol(Protocol[_T]):
15+
"""Protocol for context manager enter/exit."""
16+
17+
def __enter__(self) -> _T: # noqa: D105
18+
...
19+
20+
def __exit__(self, *args: t.Any) -> None: # noqa: D105
21+
...
22+
23+
24+
_C = t.TypeVar("_C", bound=ContextManagerProtocol)
25+
26+
27+
class BaseConnector(abc.ABC, t.Generic[_C]):
28+
"""Base class for all connectors."""
29+
30+
def __init__(self, config: t.Mapping[str, t.Any] | None) -> None:
31+
"""Initialize the connector.
32+
33+
Args:
34+
config: Plugin configuration parameters.
35+
"""
36+
self._config = config or {}
37+
38+
@property
39+
def config(self) -> t.Mapping:
40+
"""Return the connector configuration.
41+
42+
Returns:
43+
A mapping of configuration parameters.
44+
"""
45+
return self._config
46+
47+
@contextmanager
48+
def _connect(self, *args: t.Any, **kwargs: t.Any) -> t.Generator[_C, None, None]:
49+
"""Connect to the destination.
50+
51+
Args:
52+
args: Positional arguments to pass to the connection method.
53+
kwargs: Keyword arguments to pass to the connection method.
54+
55+
Yields:
56+
A connection object.
57+
"""
58+
with self.get_connection(*args, **kwargs) as connection:
59+
yield connection
60+
61+
@abc.abstractmethod
62+
def get_connection(self, *args: t.Any, **kwargs: t.Any) -> _C:
63+
"""Connect to the destination.
64+
65+
Args:
66+
args: Positional arguments to pass to the connection method.
67+
kwargs: Keyword arguments to pass to the connection method.
68+
"""
69+
...

singer_sdk/connectors/sql.py

+19-18
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import logging
66
import typing as t
77
import warnings
8-
from contextlib import contextmanager
98
from datetime import datetime
109
from functools import lru_cache
1110

@@ -14,13 +13,14 @@
1413

1514
from singer_sdk import typing as th
1615
from singer_sdk._singerlib import CatalogEntry, MetadataMapping, Schema
16+
from singer_sdk.connectors.base import BaseConnector
1717
from singer_sdk.exceptions import ConfigValidationError
1818

1919
if t.TYPE_CHECKING:
2020
from sqlalchemy.engine.reflection import Inspector
2121

2222

23-
class SQLConnector:
23+
class SQLConnector(BaseConnector):
2424
"""Base class for SQLAlchemy-based connectors.
2525
2626
The connector class serves as a wrapper around the SQL connection.
@@ -42,7 +42,7 @@ class SQLConnector:
4242

4343
def __init__(
4444
self,
45-
config: dict | None = None,
45+
config: t.Mapping[str, t.Any] | None = None,
4646
sqlalchemy_url: str | None = None,
4747
) -> None:
4848
"""Initialize the SQL connector.
@@ -51,18 +51,9 @@ def __init__(
5151
config: The parent tap or target object's config.
5252
sqlalchemy_url: Optional URL for the connection.
5353
"""
54-
self._config: dict[str, t.Any] = config or {}
54+
super().__init__(config=config)
5555
self._sqlalchemy_url: str | None = sqlalchemy_url or None
5656

57-
@property
58-
def config(self) -> dict:
59-
"""If set, provides access to the tap or target config.
60-
61-
Returns:
62-
The settings as a dict.
63-
"""
64-
return self._config
65-
6657
@property
6758
def logger(self) -> logging.Logger:
6859
"""Get logger.
@@ -72,10 +63,20 @@ def logger(self) -> logging.Logger:
7263
"""
7364
return logging.getLogger("sqlconnector")
7465

75-
@contextmanager
76-
def _connect(self) -> t.Iterator[sqlalchemy.engine.Connection]:
77-
with self._engine.connect().execution_options(stream_results=True) as conn:
78-
yield conn
66+
def get_connection(
67+
self,
68+
*,
69+
stream_results: bool = True,
70+
) -> sqlalchemy.engine.Connection:
71+
"""Return a new SQLAlchemy connection using the provided config.
72+
73+
Args:
74+
stream_results: Whether to stream results from the database.
75+
76+
Returns:
77+
A newly created SQLAlchemy connection object.
78+
"""
79+
return self._engine.connect().execution_options(stream_results=stream_results)
7980

8081
def create_sqlalchemy_connection(self) -> sqlalchemy.engine.Connection:
8182
"""(DEPRECATED) Return a new SQLAlchemy connection using the provided config.
@@ -155,7 +156,7 @@ def sqlalchemy_url(self) -> str:
155156

156157
return self._sqlalchemy_url
157158

158-
def get_sqlalchemy_url(self, config: dict[str, t.Any]) -> str:
159+
def get_sqlalchemy_url(self, config: t.Mapping[str, t.Any]) -> str:
159160
"""Return the SQLAlchemy URL string.
160161
161162
Developers can generally override just one of the following:

singer_sdk/helpers/_compat.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
if sys.version_info < (3, 8):
88
import importlib_metadata as metadata
9-
from typing_extensions import final
9+
from typing_extensions import Protocol, final
1010
else:
1111
from importlib import metadata
12-
from typing import final # noqa: ICN003
12+
from typing import Protocol, final # noqa: ICN003
1313

14-
__all__ = ["metadata", "final"]
14+
__all__ = ["metadata", "final", "Protocol"]

tests/core/connectors/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)