external-systems 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of external-systems might be problematic. Click here for more details.

@@ -0,0 +1,15 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from ._version import __version__ as __version__
@@ -0,0 +1,19 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ # The version is set during the publishing step (since we can't know the version in advance)
17
+ # using the autorelease bot
18
+
19
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,38 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from ._api import (
16
+ AwsCredentials,
17
+ ClientCertificate,
18
+ ClientCertificateFilePaths,
19
+ HttpsConnectionParameters,
20
+ SourceCredentials,
21
+ SourceParameters,
22
+ )
23
+ from ._connections import HttpsConnection
24
+ from ._refreshable import Refreshable, RefreshHandler
25
+ from ._sources import Source
26
+
27
+ __all__ = [
28
+ "ClientCertificate",
29
+ "ClientCertificateFilePaths",
30
+ "HttpsConnection",
31
+ "HttpsConnectionParameters",
32
+ "Source",
33
+ "SourceParameters",
34
+ "RefreshHandler",
35
+ "Refreshable",
36
+ "AwsCredentials",
37
+ "SourceCredentials",
38
+ ]
@@ -0,0 +1,91 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+ from typing import Dict, Optional, Union
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ClientCertificate:
22
+ """
23
+ A class representing a client certificate presentable to an external system for authentication
24
+ Attributes:
25
+ pem_certificate (str): PEM certificate contents
26
+ pem_private_key (str): PEM private key contents
27
+ """
28
+
29
+ pem_certificate: str
30
+ pem_private_key: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ClientCertificateFilePaths:
35
+ """
36
+ A class holding the paths of the Source's client certificate
37
+ Attributes:
38
+ certificate_file_path (str): Path to the certificate file
39
+ private_key_file_path (str): Path to the private key file
40
+ """
41
+
42
+ certificate_file_path: str
43
+ private_key_file_path: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class HttpsConnectionParameters:
48
+ """
49
+ A class representing HTTPS connection parameters for an external system.
50
+ Attributes:
51
+ url (str): The base URL of the external system.
52
+ headers (Dict[str, str]): A dictionary containing the auth headers for the connection.
53
+ query_params (Dict[str, str]): A dictionary containing the auth query parameters for the connection.
54
+ """
55
+
56
+ url: str
57
+ headers: dict[str, str]
58
+ query_params: dict[str, str]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class AwsCredentials:
63
+ access_key_id: str
64
+ secret_access_key: str
65
+ session_token: Optional[str] = None
66
+ expiration: Optional[datetime] = None
67
+
68
+
69
+ # Each new credential type must include an expiration field
70
+ SourceCredentials = Union[AwsCredentials]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class SourceParameters:
75
+ """
76
+ A class representing source parameters for an external system.
77
+ Attributes:
78
+ secrets (Dict[str, str]): A dictionary containing the secrets contained on the source.
79
+ proxy_token (Optional[str]): The token for authenticating with the on-prem-proxy service.
80
+ https_connections (Dict[str, HttpsConnection]): A dictionary containing the https connections on the Source.
81
+ server_certificates (Dict[str, str]): A dictionary containing the server certificates for the source.
82
+ client_certificate Optional[ClientCertificate]: The client certificate configured on the source.
83
+ resolved_source_credentials (Optional[SourceCredentials]): If session credentials are used, this field will contain the resolved credentials.
84
+ """
85
+
86
+ secrets: Dict[str, str]
87
+ proxy_token: Optional[str]
88
+ https_connections: Dict[str, HttpsConnectionParameters]
89
+ server_certificates: Dict[str, str]
90
+ client_certificate: Optional[ClientCertificate]
91
+ resolved_source_credentials: Optional[SourceCredentials]
@@ -0,0 +1,71 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Mapping, Optional, Tuple
16
+
17
+ from frozendict import frozendict
18
+ from requests import Session
19
+
20
+ from ._api import HttpsConnectionParameters
21
+ from ._proxies import create_session
22
+
23
+
24
+ class HttpsConnection:
25
+ """
26
+ A class representing an HTTPS connection for an external system.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ connection_parameters: HttpsConnectionParameters,
32
+ client_certificate: Optional[Tuple[str, str]] = None,
33
+ proxy_uri_with_auth: Optional[str] = None,
34
+ ca_bundle_path: Optional[str] = None,
35
+ ):
36
+ self._client_certificate = client_certificate
37
+ self._headers = frozendict(connection_parameters.headers)
38
+ self._url = connection_parameters.url
39
+ self._query_params = frozendict(connection_parameters.query_params)
40
+ self._proxies = frozendict({"https": proxy_uri_with_auth}) if proxy_uri_with_auth is not None else None
41
+ self._ca_bundle_path = ca_bundle_path
42
+
43
+ def get_client(self, timeout: int = 30) -> Session:
44
+ """Get the HTTP client for this connection.
45
+ This client must be used in order to reach the external system.
46
+ Args:
47
+ timeout (int, optional): The request timeout in seconds. Defaults to 30.
48
+ Returns:
49
+ requests.Session: A configured requests.Session object for communicating with the external system.
50
+ """
51
+
52
+ return create_session(
53
+ cert=self._client_certificate,
54
+ ca_bundle_path=self._ca_bundle_path,
55
+ headers=self._headers,
56
+ user_agent="external-systems",
57
+ proxies=self._proxies,
58
+ timeout=timeout,
59
+ )
60
+
61
+ @property
62
+ def headers(self) -> Mapping[str, str]:
63
+ return self._headers
64
+
65
+ @property
66
+ def query_params(self) -> Mapping[str, str]:
67
+ return self._query_params
68
+
69
+ @property
70
+ def url(self) -> str:
71
+ return self._url
@@ -0,0 +1,134 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from functools import cache
16
+ from typing import Any, Mapping, Optional, Union
17
+
18
+ from requests import PreparedRequest, Response, Session
19
+ from requests.adapters import HTTPAdapter
20
+ from urllib3.util.retry import Retry
21
+
22
+
23
+ class RetryingTimeoutHttpAdapter(HTTPAdapter):
24
+ def __init__(
25
+ self,
26
+ *args: Any,
27
+ timeout: Optional[Union[float, tuple[float, float], tuple[float, None]]] = None,
28
+ retries: Optional[Union[Retry, int]] = None,
29
+ **kwargs: Any,
30
+ ):
31
+ self._timeout = timeout
32
+ retries = retries or Retry(total=10, backoff_factor=0.1, status_forcelist=[503])
33
+ super().__init__(*args, max_retries=retries, **kwargs) # type: ignore[misc]
34
+
35
+ def send(
36
+ self,
37
+ request: PreparedRequest,
38
+ stream: bool = False,
39
+ timeout: Optional[Union[float, tuple[float, float], tuple[float, None]]] = None,
40
+ verify: Union[bool, str] = True,
41
+ cert: Optional[Union[bytes, str, tuple[Union[bytes, str], Union[bytes, str]]]] = None,
42
+ proxies: Optional[Mapping[str, str]] = None,
43
+ ) -> Response:
44
+ if timeout is None:
45
+ timeout = self._timeout
46
+ return super().send(request, stream=stream, timeout=timeout, verify=verify, cert=cert, proxies=proxies)
47
+
48
+ def __getstate__(self) -> Any:
49
+ state: dict[str, Any] = super().__getstate__() # type: ignore
50
+ # The HTTPAdapter superclass overrides the __getstate__ method to serialize only selective fields.
51
+ # Any fields specific to this class must be added here so that these fields will be preserved after being
52
+ # pickled/unpickled.
53
+ state["_timeout"] = self._timeout
54
+ return state
55
+
56
+
57
+ class ProxyAdapter(HTTPAdapter):
58
+ def __init__(self, *args: Any, proxy_auth: dict[str, str], **kwargs: Any):
59
+ super().__init__(*args, **kwargs)
60
+ self._proxy_auth = proxy_auth
61
+
62
+ def proxy_headers(self, proxy: str) -> dict[str, str]:
63
+ return {"Proxy-Authorization": self._proxy_auth[proxy]}
64
+
65
+ def __getstate__(self) -> Any:
66
+ state: dict[str, Any] = super().__getstate__() # type: ignore
67
+ # The HTTPAdapter superclass overrides the __getstate__ method to serialize only selective fields.
68
+ # Any fields specific to this class must be added here so that these fields will be preserved after being
69
+ # pickled/unpickled.
70
+ state["_proxy_auth"] = self._proxy_auth
71
+ return state
72
+
73
+
74
+ @cache
75
+ def create_proxy_session(proxy_url: str, proxy_token: str) -> Session:
76
+ """
77
+ Create a session with proxy authentication.
78
+ Args:
79
+ proxy_url (str): The URL of the proxy server.
80
+ proxy_token (str): The token for authenticating with the proxy server.
81
+ Returns:
82
+ Session: A requests session with proxy authentication.
83
+ """
84
+
85
+ adapter = ProxyAdapter(proxy_auth={proxy_url: f"Bearer {proxy_token}"})
86
+ session = Session()
87
+ session.mount("http://", adapter)
88
+ session.mount("https://", adapter)
89
+ session.proxies = {"all": proxy_url}
90
+ return session
91
+
92
+
93
+ @cache
94
+ def create_session(
95
+ cert: Optional[Union[str, tuple[str, str]]] = None,
96
+ ca_bundle_path: Optional[str] = None,
97
+ headers: Optional[dict[str, str]] = None,
98
+ user_agent: Optional[str] = None,
99
+ proxies: Optional[dict[str, str]] = None,
100
+ timeout: Optional[Union[float, tuple[float, float], tuple[float, None]]] = None,
101
+ ) -> Session:
102
+ """
103
+ Create a session with optional client certificate and CA bundle path.
104
+ Args:
105
+ cert (Optional[Union[str, tuple[str, str]]]): The client certificate to use for authentication.
106
+ ca_bundle_path (Optional[str]): The path to the CA bundle file.
107
+ headers (Optional[dict[str, str]]): Additional headers to include in the request.
108
+ user_agent (Optional[str]): The User-Agent header value.
109
+ proxies (Optional[dict[str, str]]): Proxies to use for the session.
110
+ timeout (Optional[Union[float, tuple[float, float], tuple[float, None]]]): Timeout settings for the session.
111
+ """
112
+
113
+ session = Session()
114
+
115
+ if cert:
116
+ session.cert = cert
117
+
118
+ if ca_bundle_path:
119
+ session.verify = ca_bundle_path
120
+
121
+ adapter = RetryingTimeoutHttpAdapter(timeout=timeout)
122
+ session.mount("http://", adapter)
123
+ session.mount("https://", adapter)
124
+
125
+ if headers:
126
+ session.headers.update(headers)
127
+
128
+ if user_agent:
129
+ session.headers["User-Agent"] = user_agent
130
+
131
+ if proxies:
132
+ session.proxies = proxies
133
+
134
+ return session
@@ -0,0 +1,130 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import logging
16
+ from abc import ABC, abstractmethod
17
+ from datetime import datetime, timedelta
18
+ from typing import Generic, Optional, TypeVar
19
+
20
+ from external_systems.sources._api import SourceCredentials
21
+ from external_systems.sources._utils import has_expiration_property
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ T = TypeVar("T")
26
+
27
+ # Time before expiration to refresh the credentials
28
+ REFRESH_TIME_OFFSET_SECONDS = 5
29
+
30
+
31
+ class RefreshHandler(ABC, Generic[T]):
32
+ """
33
+ This manages how and where to retrieve the new refreshed resource from
34
+ """
35
+
36
+ @abstractmethod
37
+ def refresh(self) -> Optional[T]:
38
+ """
39
+ Returns:
40
+ T: The refreshed resource.
41
+ """
42
+ raise NotImplementedError("refresh() method not implemented")
43
+
44
+
45
+ class Refreshable(ABC, Generic[T]):
46
+ """
47
+ This manages the lifecycle of a resource that can be refreshed.
48
+ """
49
+
50
+ @abstractmethod
51
+ def get(self) -> T:
52
+ """
53
+ Retrieve the current value of the resource.
54
+
55
+ Returns:
56
+ T: The current value of the resource.
57
+ """
58
+ raise NotImplementedError("get() method not implemented")
59
+
60
+
61
+ class DefaultSessionCredentialsManager(Refreshable[SourceCredentials]):
62
+ """
63
+ Client for refreshing resolved source credentials.
64
+ Will return credentials if they are not expired.
65
+ Otherwise will attempt to lazily refresh the credentials on `get()` calls.
66
+
67
+ :param source_credentials: SourceCredentials object returned by API representing the source credentials.
68
+ :param refresh_handler: A provider responsible for performing the refresh.
69
+ :param refresh_offset_seconds: Represents how many seconds before the credentials expire should we refresh them.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ resolved_source_credentials: SourceCredentials,
75
+ refresh_handler: Optional[RefreshHandler[SourceCredentials]] = None,
76
+ ) -> None:
77
+ self._credentials: SourceCredentials = resolved_source_credentials
78
+ self._refresh_handler = refresh_handler
79
+
80
+ def get(self) -> SourceCredentials:
81
+ """
82
+ Returns the source's credentials.
83
+
84
+ This method automatically refreshes the credentials if they are expired.
85
+
86
+ Returns
87
+ SourceCredentials: The current source credentials.
88
+
89
+ Raises
90
+ ValueError: If the credentials have not been set.
91
+ """
92
+ self._maybe_refresh_credentials()
93
+ if self._credentials is None:
94
+ raise ValueError("Credentials have not been set")
95
+ return self._credentials
96
+
97
+ def _maybe_refresh_credentials(self) -> None:
98
+ """
99
+ Checks if the credentials are expired and delegates the refresh to `_refresh_credentials` if needed.
100
+ """
101
+
102
+ # Don't refresh if expiration time is not set
103
+ if (
104
+ self._credentials is None
105
+ or not has_expiration_property(self._credentials)
106
+ or self._credentials.expiration is None
107
+ ):
108
+ return
109
+
110
+ # If the credentials are valid for more than specified seconds, don't refresh
111
+ time_until_creds_expire = self._credentials.expiration - datetime.now()
112
+ time_until_creds_expire_with_offset = time_until_creds_expire - timedelta(seconds=REFRESH_TIME_OFFSET_SECONDS)
113
+ needs_refresh = time_until_creds_expire_with_offset.total_seconds() <= 0
114
+
115
+ if not needs_refresh:
116
+ return
117
+
118
+ self._refresh_credentials()
119
+
120
+ def _refresh_credentials(self) -> None:
121
+ if self._refresh_handler is None:
122
+ # Refresh handler is not set, nothing to refresh
123
+ return
124
+
125
+ maybe_resolved_credentials = self._refresh_handler.refresh()
126
+
127
+ if maybe_resolved_credentials is None:
128
+ raise ValueError("Refresh failed for source")
129
+
130
+ self._credentials = maybe_resolved_credentials
@@ -0,0 +1,104 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import base64
16
+ import logging
17
+ import os
18
+ import re
19
+ import socket
20
+ import ssl
21
+ import time
22
+
23
+ import urllib3
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ STATUS_LINE_PATTERN = re.compile(r"^HTTP/\d\.\d (\d{3}) .*$")
29
+ NUM_RETRIES = 10
30
+ RETRYABLE_RESPONSE_CODES = {503, 429}
31
+
32
+
33
+ def create_socket(https_proxy_uri: str, target_host: str, target_port: int) -> socket.socket:
34
+ """
35
+ Establishes a socket connection through an HTTPS proxy to a target host and port.
36
+ Parameters:
37
+ https_proxy_uri (str): The URI of the HTTPS proxy, must include auth if required.
38
+ target_host (str): The hostname of the target server to connect to.
39
+ target_port (int): The port number of the target server to connect to.
40
+ Returns:
41
+ socket.socket: A connected SSL socket to the target host and port through the proxy.
42
+ Raises:
43
+ ValueError: If the proxy URI does not specify a hostname or port, or if the connection fails after retrying, with an invalid response code.
44
+ RuntimeError: If there is an exception during the socket creation process.
45
+ """
46
+
47
+ parsed_proxy_uri = urllib3.util.parse_url(https_proxy_uri)
48
+
49
+ if parsed_proxy_uri.hostname is None:
50
+ raise ValueError("proxy uri has no hostname specified")
51
+
52
+ if parsed_proxy_uri.port is None:
53
+ raise ValueError("proxy uri has no port specified")
54
+
55
+ last_response_code = -1
56
+ for _ in range(NUM_RETRIES):
57
+ try:
58
+ proxy_socket = _create_ssl_socket(parsed_proxy_uri.hostname, parsed_proxy_uri.port)
59
+ proxy_socket.sendall(f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n".encode())
60
+ proxy_socket.sendall(f"Host: {target_host}:{target_port}\r\n".encode())
61
+
62
+ if parsed_proxy_uri.auth is not None:
63
+ basic_auth_payload = base64.b64encode(parsed_proxy_uri.auth.encode()).decode()
64
+ proxy_socket.sendall(f"Proxy-Authorization: Basic {basic_auth_payload}\r\n".encode())
65
+
66
+ proxy_socket.sendall(b"\r\n")
67
+ response_line = proxy_socket.recv(4096).decode().split("\r\n")[0]
68
+
69
+ match = STATUS_LINE_PATTERN.match(response_line)
70
+
71
+ if match is None:
72
+ raise ValueError("status line pattern did not match")
73
+
74
+ response_code = int(match.group(1))
75
+
76
+ if response_code == 200:
77
+ return proxy_socket
78
+
79
+ last_response_code = response_code
80
+ if response_code in RETRYABLE_RESPONSE_CODES:
81
+ log.info("Received retryable response code from proxy, retrying after 0.5 seconds: %s", response_code)
82
+ time.sleep(0.5)
83
+ else:
84
+ break
85
+ except Exception as e:
86
+ raise RuntimeError("Failed to create socket") from e
87
+
88
+ raise ValueError(f"Failed to establish tunnel, invalid response code: {last_response_code}")
89
+
90
+
91
+ def _create_ssl_socket(proxy_host: str, proxy_port: int) -> socket.socket:
92
+ ca_bundle_path = os.environ.get("REQUESTS_CA_BUNDLE")
93
+ if not ca_bundle_path or not os.path.isfile(ca_bundle_path):
94
+ log.warning("The REQUESTS_CA_BUNDLE environment variable does not exist or is not a file.")
95
+ raise ValueError("REQUESTS_CA_BUNDLE does not exist")
96
+ if not os.access(ca_bundle_path, os.R_OK):
97
+ log.warning("The REQUESTS_CA_BUNDLE file is not readable.")
98
+ raise ValueError("REQUESTS_CA_BUNDLE is not readable")
99
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
100
+ context.load_verify_locations(ca_bundle_path)
101
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
102
+ wrapped_sock = context.wrap_socket(sock, server_hostname=proxy_host)
103
+ wrapped_sock.connect((proxy_host, proxy_port))
104
+ return wrapped_sock
@@ -0,0 +1,235 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import logging
16
+ import os
17
+ import random
18
+ import socket
19
+ from functools import cached_property
20
+ from tempfile import NamedTemporaryFile
21
+ from typing import Any, Mapping, Optional, Tuple
22
+
23
+ import urllib3.util
24
+ from frozendict import frozendict
25
+ from requests import Session
26
+
27
+ from ._api import AwsCredentials, ClientCertificateFilePaths, SourceCredentials, SourceParameters
28
+ from ._connections import HttpsConnection
29
+ from ._proxies import create_proxy_session
30
+ from ._refreshable import DefaultSessionCredentialsManager, Refreshable, RefreshHandler
31
+ from ._sockets import create_socket
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ class Source:
37
+ """
38
+ A class representing a Source for an external systems.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ source_parameters: SourceParameters,
44
+ on_prem_proxy_service_uris: list[str],
45
+ egress_proxy_service_uris: list[str],
46
+ egress_proxy_token: Optional[str],
47
+ source_configuration: Optional[Any],
48
+ credentials_refresh_handler: Optional[RefreshHandler[SourceCredentials]] = None,
49
+ ):
50
+ self._source_parameters = source_parameters
51
+ self._on_prem_proxy_service_uris = on_prem_proxy_service_uris
52
+ self._egress_proxy_service_uris = egress_proxy_service_uris
53
+ self._source_configuration = source_configuration
54
+ self._egress_proxy_token = egress_proxy_token
55
+ self._credentials_refresh_handler = credentials_refresh_handler
56
+
57
+ @cached_property
58
+ def secrets(self) -> Mapping[str, str]:
59
+ """A dictionary containing plaintext secrets on the Source keyed by secret API name."""
60
+ return frozendict(self._source_parameters.secrets)
61
+
62
+ @property
63
+ def client_certificate(self) -> Optional[ClientCertificateFilePaths]:
64
+ """
65
+ The client certificate file name and private key file name if present on the Source.
66
+ Returns:
67
+ Optional[ClientCertificateFilePaths]: If a client certificate is present on the source, otherwise None.
68
+ """
69
+ return ClientCertificateFilePaths(*self._client_certificate) if self._client_certificate is not None else None
70
+
71
+ @cached_property
72
+ def _https_connections(self) -> Mapping[str, HttpsConnection]:
73
+ return frozendict(
74
+ {
75
+ key: HttpsConnection(params, self._client_certificate, self._https_proxy_url, self._ca_bundle_path)
76
+ for key, params in self._source_parameters.https_connections.items()
77
+ }
78
+ )
79
+
80
+ @cached_property
81
+ def _ca_bundle_path(self) -> Optional[str]:
82
+ if self._source_parameters.server_certificates is None:
83
+ return None
84
+
85
+ new_ca_contents = []
86
+
87
+ # If requests env var is set, add all existing CAs
88
+ requests_ca_bundle_path = os.environ.get("REQUESTS_CA_BUNDLE")
89
+ if requests_ca_bundle_path is not None:
90
+ with open(requests_ca_bundle_path) as requests_ca_bundle_file:
91
+ new_ca_contents.append(requests_ca_bundle_file.read())
92
+
93
+ # Add all CAs for the source
94
+ for required_ca in self._source_parameters.server_certificates.values():
95
+ new_ca_contents.append(required_ca)
96
+
97
+ with NamedTemporaryFile(delete=False, mode="w") as ca_bundle_file:
98
+ ca_bundle_file.write(os.linesep.join(new_ca_contents) + os.linesep)
99
+ return ca_bundle_file.name
100
+
101
+ @cached_property
102
+ def _client_certificate(self) -> Optional[Tuple[str, str]]:
103
+ if self._source_parameters.client_certificate is None:
104
+ return None
105
+
106
+ cert_file = NamedTemporaryFile(delete=False, mode="w") # pylint: disable=consider-using-with
107
+ cert_file.write(self._source_parameters.client_certificate.pem_certificate)
108
+
109
+ private_key_file = NamedTemporaryFile(delete=False, mode="w") # pylint: disable=consider-using-with
110
+ private_key_file.write(self._source_parameters.client_certificate.pem_private_key)
111
+
112
+ return cert_file.name, private_key_file.name
113
+
114
+ @cached_property
115
+ def _proxy_session(self) -> Session:
116
+ egress_proxy_configured = self._egress_proxy_token is not None
117
+ on_prem_proxy_configured = self._source_parameters.proxy_token is not None
118
+
119
+ if on_prem_proxy_configured:
120
+ if egress_proxy_configured:
121
+ log.warning(
122
+ "both egress proxy and on-prem proxy are configured for this source. preferring on-prem proxy"
123
+ )
124
+ if len(self._on_prem_proxy_service_uris) == 0:
125
+ raise ValueError(
126
+ "on-prem proxy was configured for this source, but on-prem proxy URIs were not present"
127
+ )
128
+ return create_proxy_session(
129
+ random.choice(self._on_prem_proxy_service_uris), self._source_parameters.proxy_token
130
+ )
131
+ elif egress_proxy_configured:
132
+ # we checked this earlier, but assert again here to make mypy happy
133
+ assert (
134
+ self._egress_proxy_token is not None
135
+ ), "no egress proxy parameters found while configuring egress proxy session"
136
+ if len(self._egress_proxy_service_uris) == 0:
137
+ raise ValueError("egress proxy was configured for this source, but egress proxy URIs were not present")
138
+ return create_proxy_session(self._https_proxy_url, self._egress_proxy_token)
139
+ else:
140
+ raise ValueError(
141
+ "neither egress proxy nor on-prem proxy were configured. unable to construct a proxy session"
142
+ )
143
+
144
+ @cached_property
145
+ def _https_proxy_url(self) -> Optional[str]:
146
+ if self._source_parameters.proxy_token is not None:
147
+ parsed = urllib3.util.parse_url(random.choice(self._on_prem_proxy_service_uris))
148
+ parsed = parsed._replace(auth=f"user:{self._source_parameters.proxy_token}")
149
+ return parsed.url
150
+
151
+ if self._egress_proxy_token is not None:
152
+ parsed = urllib3.util.parse_url(random.choice(self._egress_proxy_service_uris))
153
+ parsed = parsed._replace(auth=f"user:{self._egress_proxy_token}")
154
+ return parsed.url
155
+
156
+ return None
157
+
158
+ @cached_property
159
+ def _maybe_refreshable_resolved_source_credentials(self) -> Optional[Refreshable[SourceCredentials]]:
160
+ if self._source_parameters.resolved_source_credentials is None:
161
+ return None
162
+
163
+ return DefaultSessionCredentialsManager(
164
+ self._source_parameters.resolved_source_credentials, self._credentials_refresh_handler
165
+ )
166
+
167
+ @cached_property
168
+ def source_configuration(self) -> Any:
169
+ """
170
+ Full configuration of the Source.
171
+ Throws if source configuration is not supported by public API.
172
+ """
173
+ if self._source_configuration is None:
174
+ raise ValueError("Source configuration is not available for this Source.")
175
+ return self._source_configuration
176
+
177
+ def get_aws_credentials(self) -> Refreshable[AwsCredentials]:
178
+ """
179
+ Get the AWS credentials from the Source.
180
+
181
+ Supported Sources:
182
+ - S3
183
+ """
184
+ if self._maybe_refreshable_resolved_source_credentials is None:
185
+ raise ValueError("Resolved source credentials are not present on the Source.")
186
+ if not isinstance(self._maybe_refreshable_resolved_source_credentials.get(), AwsCredentials):
187
+ raise ValueError("Resolved source credentials are not of type AwsCredentials.")
188
+ return self._maybe_refreshable_resolved_source_credentials
189
+
190
+ def get_secret(self, key: str) -> str:
191
+ """Get the plaintext value for the provided secret key, as configured in the Source secrets."""
192
+ secret = self.secrets.get(key)
193
+ if not secret:
194
+ raise ValueError(f"Secret with name {key} not found on the Source.")
195
+ return secret
196
+
197
+ def get_https_connection(self) -> HttpsConnection:
198
+ """Get the HTTPS Connection from the Source.
199
+ Raises:
200
+ ValueError: If there are multiple connections on the source.
201
+ Returns:
202
+ HttpsConnection: The requested HttpsConnection object.
203
+ """
204
+
205
+ if len(self._https_connections) != 1:
206
+ raise ValueError("Only single connection sources are supported.")
207
+ return next(iter(self._https_connections.values()))
208
+
209
+ def get_https_proxy_uri(self) -> Optional[str]:
210
+ """Get the HTTPS proxy URI that must be used to communicate with the external system
211
+ when using an Agent Proxy source.
212
+ The format of the proxy URI will be "https://user:password@proxy-server.com:443".
213
+ If a proxy is not required, the function will return None.
214
+ This is useful if you can't use the provided requests client and need to use a different HTTP client or an SDK.
215
+ """
216
+ return self._https_proxy_url
217
+
218
+ def create_socket(self, target_host: str, target_port: int) -> socket.socket: # pylint: disable=too-many-locals
219
+ """
220
+ Create a socket connection to the host and port in the HTTPS connection through the Agent Proxy.
221
+ The socket returned by this method MUST be closed by the caller to avoid hanging connections in the proxy.
222
+ It is recommended to use the `contextlib.closing` context manager to ensure the socket is closed:
223
+ from contextlib import closing
224
+ with closing(source.create_socket(target_host, target_port)) as sock:
225
+ # Use the socket here
226
+ Returns:
227
+ socket.socket: A socket object connected to the target host and port through the HTTPS proxy.
228
+ Raises:
229
+ ValueError: If the configured proxy is not an HTTPS proxy or if the tunnel cannot be established.
230
+ RuntimeError: If there is a failure during socket creation.
231
+ """
232
+ if not self._https_proxy_url:
233
+ raise ValueError("Only usable with Agent Proxy Sources")
234
+
235
+ return create_socket(self._https_proxy_url, target_host, target_port)
@@ -0,0 +1,25 @@
1
+ # Copyright 2025 Palantir Technologies, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from ._api import SourceCredentials
16
+
17
+ JAVA_OFFSET_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
18
+
19
+
20
+ def has_expiration_property(source_credentials: SourceCredentials) -> bool:
21
+ """
22
+ Checks if the source credentials provided have an expiration property.
23
+ """
24
+
25
+ return hasattr(source_credentials, "expiration")
@@ -0,0 +1,13 @@
1
+ Copyright {YEAR} Palantir Technologies, Inc.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.3
2
+ Name: external-systems
3
+ Version: 0.1.0
4
+ Summary: A Python library for interacting with Foundry Sources
5
+ License: Apache-2.0
6
+ Keywords: Palantir,Foundry,Sources,Compute Modules,Python Functions,Transforms
7
+ Author: Palantir Technologies, Inc.
8
+ Requires-Python: >=3.9,<4.0
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: frozendict (>=2.4.6,<3.0.0)
17
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
18
+ Project-URL: Repository, https://github.com/palantir/external-systems
19
+ Description-Content-Type: text/markdown
20
+
21
+ # External Systems
22
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/external-systems)
23
+ [![PyPI](https://img.shields.io/pypi/v/external-systems)](https://pypi.org/project/external-systems/)
24
+ [![License](https://img.shields.io/badge/License-Apache%202.0-lightgrey.svg)](https://opensource.org/licenses/Apache-2.0)
25
+
26
+
27
+ > [!WARNING]
28
+ > This SDK is incubating and subject to change.
29
+
30
+
31
+ ## About Foundry Sources
32
+
33
+ The External Systems library is Python SDK built as an interface to reference [Foundry Sources](https://www.palantir.com/docs/foundry/data-connection/set-up-source) from code.
34
+
35
+ <a id="installation"></a>
36
+ ## Installation
37
+ You can install the Python package using `pip`:
38
+
39
+ ```sh
40
+ pip install external-systems
41
+ ```
42
+
43
+ <a id="basic-usage"></a>
44
+ ## Basic Source Usage
45
+
46
+ ### Credentials
47
+
48
+ Long lived credentials can be referenced using `get_secret()` on the source.
49
+
50
+ ```python
51
+ my_source: Source = ...
52
+
53
+ some_secret = my_source.get_secret("SECRET_NAME")
54
+ ```
55
+
56
+ For sources using session credentials we support credentials generation and refresh management. Currently on an S3 source you can access session credentials using `get_aws_credentials()`.
57
+
58
+ ```python
59
+ s3_source: Source = ...
60
+
61
+ refreshable_credentials: Refreshable[AwsCredentials] = s3_source.get_aws_credentials()
62
+
63
+ session_credentials: AwsCredentials = refreshable_credentials.get()
64
+ ```
65
+
66
+ ### HTTP Client
67
+ For REST based sources, a preconfigured HTTP client is provided built on top of the Python requests library.
68
+
69
+ ```python
70
+ source_url = my_source.get_https_connection().url
71
+ http_client = my_source.get_https_connection().get_client()
72
+
73
+ response = http_client.get(source_url + "/api/v1/example/", timeout=10)
74
+ ```
75
+
@@ -0,0 +1,15 @@
1
+ external_systems/__init__.py,sha256=xXDUDD6_qRO-nHuXZx-fXp0R0vc3N_OOsB1F5mF_kpU,651
2
+ external_systems/_version.py,sha256=edsEuFM8fnpvqgnm1a9kDAaQcgfl58B_iUMYwpkVdRw,747
3
+ external_systems/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ external_systems/sources/__init__.py,sha256=aqXbMIy_pnyIC1uPRzQfApLCbhYB4N8iRFnpOX4RdAk,1156
5
+ external_systems/sources/_api.py,sha256=NV7oNIgzSWz4ROFW8uPpUJjDN5vfFzdTs3yKC37S39k,3262
6
+ external_systems/sources/_connections.py,sha256=h82npEks29NALAAfMHrXcSnB7TY_OLeXgL3QN9Cssus,2509
7
+ external_systems/sources/_proxies.py,sha256=igWRL-kpQ_IlZyJwI_s66rDe0oQ68ltGeFNydgoiR4w,5071
8
+ external_systems/sources/_refreshable.py,sha256=0pa5XW0_2gTMW-ZymKhlhh3Kka_bWTWffThKvrTTifc,4421
9
+ external_systems/sources/_sockets.py,sha256=oCCUEpLvHLhYXljRiBc11YAWJCZOd-wpUFjl8CaueX0,4285
10
+ external_systems/sources/_sources.py,sha256=fYEkVOVCuEHpJytRWw-TCXzHmefoAhAny5853fONajU,10529
11
+ external_systems/sources/_utils.py,sha256=_GDyJOzPHQudEbrPsRhXMs8u6AaHpgJQIq8H9xSYJZk,913
12
+ external_systems-0.1.0.dist-info/LICENSE.txt,sha256=NAk6Uc9K_N_J5V75k9qECpzUnO-ujT-mKK_jk_mboUE,569
13
+ external_systems-0.1.0.dist-info/METADATA,sha256=isfctYmHoIoGFyNpoFu1W0GqOyW-mTK79UG1nvsVVMI,2513
14
+ external_systems-0.1.0.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
15
+ external_systems-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any