sapsf-shared 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.
- sapsf_shared/__init__.py +45 -0
- sapsf_shared/auth.py +357 -0
- sapsf_shared/client.py +344 -0
- sapsf_shared/config.py +153 -0
- sapsf_shared/exceptions.py +37 -0
- sapsf_shared/flask_base.py +186 -0
- sapsf_shared/logging_config.py +98 -0
- sapsf_shared/utils.py +143 -0
- sapsf_shared-0.1.0.dist-info/METADATA +224 -0
- sapsf_shared-0.1.0.dist-info/RECORD +12 -0
- sapsf_shared-0.1.0.dist-info/WHEEL +4 -0
- sapsf_shared-0.1.0.dist-info/licenses/LICENSE +21 -0
sapsf_shared/client.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""OData v2 HTTP client for SAP SuccessFactors.
|
|
2
|
+
|
|
3
|
+
Features:
|
|
4
|
+
- requests.Session with configurable auth (Basic, OAuth, Certificate)
|
|
5
|
+
- 3 retries with exponential back-off on 429 and 5xx
|
|
6
|
+
- Automatic OData __next pagination
|
|
7
|
+
- Configurable per-request timeout
|
|
8
|
+
- Context-manager support for automatic cleanup
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from sapsf_shared.auth import AuthConfig, build_requests_auth
|
|
21
|
+
from sapsf_shared.exceptions import SFClientError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# HTTP status codes that trigger a retry
|
|
26
|
+
RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
27
|
+
MAX_RETRIES = 3
|
|
28
|
+
BACKOFF_SECONDS = (1, 2, 4)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SFClient:
|
|
32
|
+
"""Thin OData v2 client bound to ONE SuccessFactors tenant.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
config = AuthConfig(base_url="https://api.sapsf.com", username="...", password="...")
|
|
36
|
+
with SFClient(config) as client:
|
|
37
|
+
records = client.get_entity_by_code("FODepartment", "IT")
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
auth_config: AuthConfig,
|
|
43
|
+
*,
|
|
44
|
+
default_top: int = 100,
|
|
45
|
+
json_indent: int | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.config = auth_config
|
|
48
|
+
self.base_url = auth_config.base_url.rstrip("/")
|
|
49
|
+
self.default_top = default_top
|
|
50
|
+
self.json_indent = json_indent
|
|
51
|
+
|
|
52
|
+
auth_obj, cert = build_requests_auth(auth_config)
|
|
53
|
+
|
|
54
|
+
self._session = requests.Session()
|
|
55
|
+
self._session.auth = auth_obj # type: ignore[assignment]
|
|
56
|
+
if cert:
|
|
57
|
+
self._session.cert = cert
|
|
58
|
+
self._session.headers.update(
|
|
59
|
+
{
|
|
60
|
+
"Accept": "application/json",
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Internal helpers
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _url(self, entity_set: str) -> str:
|
|
70
|
+
return f"{self.base_url}/{entity_set}"
|
|
71
|
+
|
|
72
|
+
def _request_with_retry(
|
|
73
|
+
self,
|
|
74
|
+
method: str,
|
|
75
|
+
url: str,
|
|
76
|
+
**kwargs: Any,
|
|
77
|
+
) -> requests.Response:
|
|
78
|
+
"""Execute an HTTP request with retry logic on transient errors."""
|
|
79
|
+
last_exc: Exception | None = None
|
|
80
|
+
for attempt in range(MAX_RETRIES):
|
|
81
|
+
try:
|
|
82
|
+
resp = self._session.request(
|
|
83
|
+
method, url, timeout=self.config.timeout_sec, **kwargs
|
|
84
|
+
)
|
|
85
|
+
if resp.status_code not in RETRY_STATUS_CODES:
|
|
86
|
+
return resp
|
|
87
|
+
wait = BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)]
|
|
88
|
+
logger.warning(
|
|
89
|
+
"HTTP %s from %s (attempt %d/%d) - retrying in %ds",
|
|
90
|
+
resp.status_code,
|
|
91
|
+
url,
|
|
92
|
+
attempt + 1,
|
|
93
|
+
MAX_RETRIES,
|
|
94
|
+
wait,
|
|
95
|
+
)
|
|
96
|
+
time.sleep(wait)
|
|
97
|
+
last_exc = None
|
|
98
|
+
except requests.exceptions.RequestException as exc:
|
|
99
|
+
wait = BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)]
|
|
100
|
+
logger.warning(
|
|
101
|
+
"Request error on %s (attempt %d/%d): %s - retrying in %ds",
|
|
102
|
+
url,
|
|
103
|
+
attempt + 1,
|
|
104
|
+
MAX_RETRIES,
|
|
105
|
+
exc,
|
|
106
|
+
wait,
|
|
107
|
+
)
|
|
108
|
+
last_exc = exc
|
|
109
|
+
time.sleep(wait)
|
|
110
|
+
|
|
111
|
+
if last_exc:
|
|
112
|
+
raise SFClientError(
|
|
113
|
+
f"Request failed after {MAX_RETRIES} attempts: {last_exc}",
|
|
114
|
+
url=url,
|
|
115
|
+
)
|
|
116
|
+
# All retries exhausted, return the last (still bad) response
|
|
117
|
+
return resp
|
|
118
|
+
|
|
119
|
+
def _check_response(self, resp: requests.Response, url: str) -> dict[str, Any]:
|
|
120
|
+
"""Parse JSON, handle auth errors, and return the OData payload dict."""
|
|
121
|
+
if resp.status_code == 401:
|
|
122
|
+
raise SFClientError(
|
|
123
|
+
"Authentication failed — check username, password, and company_id",
|
|
124
|
+
status_code=401,
|
|
125
|
+
url=url,
|
|
126
|
+
)
|
|
127
|
+
if resp.status_code == 403:
|
|
128
|
+
raise SFClientError(
|
|
129
|
+
"Access denied — check API user permissions",
|
|
130
|
+
status_code=403,
|
|
131
|
+
url=url,
|
|
132
|
+
)
|
|
133
|
+
if resp.status_code >= 400:
|
|
134
|
+
body = resp.text[:2000]
|
|
135
|
+
raise SFClientError(
|
|
136
|
+
f"HTTP {resp.status_code} from {url}",
|
|
137
|
+
status_code=resp.status_code,
|
|
138
|
+
body=body,
|
|
139
|
+
url=url,
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
return resp.json()
|
|
143
|
+
except json.JSONDecodeError as exc:
|
|
144
|
+
raise SFClientError(
|
|
145
|
+
f"Non-JSON response from {url}: {exc}",
|
|
146
|
+
body=resp.text[:500],
|
|
147
|
+
url=url,
|
|
148
|
+
) from exc
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# Public API
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def get(
|
|
155
|
+
self,
|
|
156
|
+
entity_set: str,
|
|
157
|
+
*,
|
|
158
|
+
top: int | None = None,
|
|
159
|
+
skip: int = 0,
|
|
160
|
+
select: list[str] | None = None,
|
|
161
|
+
expand: list[str] | None = None,
|
|
162
|
+
filter_expr: str | None = None,
|
|
163
|
+
orderby: str | None = None,
|
|
164
|
+
params: dict[str, str] | None = None,
|
|
165
|
+
) -> list[dict[str, Any]]:
|
|
166
|
+
"""Fetch all records for *entity_set* with automatic pagination.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
entity_set: OData entity set name (e.g. "FODepartment")
|
|
170
|
+
top: Max records per page (default: self.default_top)
|
|
171
|
+
skip: Initial $skip value
|
|
172
|
+
select: Fields to $select
|
|
173
|
+
expand: Navigation properties to $expand
|
|
174
|
+
filter_expr: OData $filter expression
|
|
175
|
+
orderby: OData $orderby expression
|
|
176
|
+
params: Any additional query parameters
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Flat list of record dicts from d.results across all pages.
|
|
180
|
+
"""
|
|
181
|
+
url = self._url(entity_set)
|
|
182
|
+
query: dict[str, str] = {
|
|
183
|
+
"$format": "json",
|
|
184
|
+
"$top": str(top or self.default_top),
|
|
185
|
+
"$skip": str(skip),
|
|
186
|
+
}
|
|
187
|
+
if select:
|
|
188
|
+
query["$select"] = ",".join(select)
|
|
189
|
+
if expand:
|
|
190
|
+
query["$expand"] = ",".join(expand)
|
|
191
|
+
if filter_expr:
|
|
192
|
+
query["$filter"] = filter_expr
|
|
193
|
+
if orderby:
|
|
194
|
+
query["$orderby"] = orderby
|
|
195
|
+
if params:
|
|
196
|
+
query.update(params)
|
|
197
|
+
|
|
198
|
+
return self._paginate(url, query)
|
|
199
|
+
|
|
200
|
+
def get_entity_by_code(
|
|
201
|
+
self,
|
|
202
|
+
entity_set: str,
|
|
203
|
+
external_code: str,
|
|
204
|
+
*,
|
|
205
|
+
expand: str | None = None,
|
|
206
|
+
extra_params: dict[str, str] | None = None,
|
|
207
|
+
) -> list[dict[str, Any]]:
|
|
208
|
+
"""Fetch all records where externalCode = *external_code*.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
expand: comma-separated navigation properties to $expand
|
|
212
|
+
extra_params: additional OData query params to merge in
|
|
213
|
+
"""
|
|
214
|
+
url = self._url(entity_set)
|
|
215
|
+
params: dict[str, str] = {
|
|
216
|
+
"$filter": f"externalCode eq '{external_code}'",
|
|
217
|
+
"$format": "json",
|
|
218
|
+
"$top": str(self.default_top),
|
|
219
|
+
}
|
|
220
|
+
if expand:
|
|
221
|
+
params["$expand"] = expand
|
|
222
|
+
if extra_params:
|
|
223
|
+
params.update(extra_params)
|
|
224
|
+
return self._paginate(url, params)
|
|
225
|
+
|
|
226
|
+
def _paginate(
|
|
227
|
+
self,
|
|
228
|
+
url: str,
|
|
229
|
+
params: dict[str, str] | None,
|
|
230
|
+
) -> list[dict[str, Any]]:
|
|
231
|
+
"""Follow OData __next links until exhausted."""
|
|
232
|
+
results: list[dict[str, Any]] = []
|
|
233
|
+
next_url: str | None = url
|
|
234
|
+
first_call = True
|
|
235
|
+
|
|
236
|
+
while next_url:
|
|
237
|
+
resp = self._request_with_retry(
|
|
238
|
+
"GET",
|
|
239
|
+
next_url,
|
|
240
|
+
params=params if first_call else None,
|
|
241
|
+
)
|
|
242
|
+
first_call = False
|
|
243
|
+
payload = self._check_response(resp, next_url or url)
|
|
244
|
+
data = payload.get("d", {})
|
|
245
|
+
batch = data.get("results", [])
|
|
246
|
+
results.extend(batch)
|
|
247
|
+
|
|
248
|
+
next_url = data.get("__next")
|
|
249
|
+
logger.debug(
|
|
250
|
+
"GET %s → %d records (total so far: %d)%s",
|
|
251
|
+
next_url or url,
|
|
252
|
+
len(batch),
|
|
253
|
+
len(results),
|
|
254
|
+
" [has next]" if next_url else "",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return results
|
|
258
|
+
|
|
259
|
+
def post(
|
|
260
|
+
self,
|
|
261
|
+
entity_set: str,
|
|
262
|
+
payload: dict[str, Any],
|
|
263
|
+
) -> tuple[int, dict[str, Any]]:
|
|
264
|
+
"""POST *payload* to *entity_set*.
|
|
265
|
+
|
|
266
|
+
Returns (http_status_code, response_body_dict).
|
|
267
|
+
Raises SFClientError on network / parsing failures only.
|
|
268
|
+
"""
|
|
269
|
+
url = self._url(entity_set)
|
|
270
|
+
logger.debug(
|
|
271
|
+
"POST %s payload=%s",
|
|
272
|
+
url,
|
|
273
|
+
json.dumps(payload, indent=self.json_indent)[:500],
|
|
274
|
+
)
|
|
275
|
+
resp = self._request_with_retry("POST", url, json=payload)
|
|
276
|
+
body = self._check_response(resp, url)
|
|
277
|
+
return resp.status_code, body
|
|
278
|
+
|
|
279
|
+
def patch(
|
|
280
|
+
self,
|
|
281
|
+
entity_set: str,
|
|
282
|
+
payload: dict[str, Any],
|
|
283
|
+
) -> tuple[int, dict[str, Any]]:
|
|
284
|
+
"""PATCH (upsert) *payload* to *entity_set*.
|
|
285
|
+
|
|
286
|
+
Returns (http_status_code, response_body_dict).
|
|
287
|
+
"""
|
|
288
|
+
url = self._url(entity_set)
|
|
289
|
+
logger.debug("PATCH %s payload=%s", url, json.dumps(payload)[:500])
|
|
290
|
+
resp = self._request_with_retry("PATCH", url, json=payload)
|
|
291
|
+
body = self._check_response(resp, url)
|
|
292
|
+
return resp.status_code, body
|
|
293
|
+
|
|
294
|
+
def delete(
|
|
295
|
+
self,
|
|
296
|
+
entity_set: str,
|
|
297
|
+
key: str,
|
|
298
|
+
) -> int:
|
|
299
|
+
"""DELETE a record by key. Returns HTTP status code."""
|
|
300
|
+
url = f"{self._url(entity_set)}('{key}')"
|
|
301
|
+
resp = self._request_with_retry("DELETE", url)
|
|
302
|
+
return resp.status_code
|
|
303
|
+
|
|
304
|
+
def entity_exists(
|
|
305
|
+
self,
|
|
306
|
+
entity_set: str,
|
|
307
|
+
external_code: str,
|
|
308
|
+
) -> tuple[bool, dict[str, Any] | None]:
|
|
309
|
+
"""Check whether an active record exists in *entity_set*.
|
|
310
|
+
|
|
311
|
+
Returns (exists: bool, first_record: dict | None).
|
|
312
|
+
The caller should apply effective-dating logic to select the active record.
|
|
313
|
+
"""
|
|
314
|
+
records = self.get_entity_by_code(entity_set, external_code)
|
|
315
|
+
if not records:
|
|
316
|
+
return False, None
|
|
317
|
+
return True, records[0]
|
|
318
|
+
|
|
319
|
+
def test_connection(self) -> tuple[bool, str]:
|
|
320
|
+
"""Quick connectivity check. Returns (ok, message)."""
|
|
321
|
+
try:
|
|
322
|
+
# Metadata endpoint is a fast, read-only probe
|
|
323
|
+
url = f"{self.base_url}/$metadata"
|
|
324
|
+
resp = self._request_with_retry("GET", url, params={"$format": "json"})
|
|
325
|
+
if resp.status_code == 200:
|
|
326
|
+
return True, "Connected successfully"
|
|
327
|
+
return False, f"HTTP {resp.status_code}"
|
|
328
|
+
except SFClientError as exc:
|
|
329
|
+
return False, str(exc)
|
|
330
|
+
except Exception as exc:
|
|
331
|
+
return False, f"Connection error: {exc}"
|
|
332
|
+
|
|
333
|
+
# ------------------------------------------------------------------
|
|
334
|
+
# Lifecycle
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def close(self) -> None:
|
|
338
|
+
self._session.close()
|
|
339
|
+
|
|
340
|
+
def __enter__(self) -> SFClient:
|
|
341
|
+
return self
|
|
342
|
+
|
|
343
|
+
def __exit__(self, *args: Any) -> None:
|
|
344
|
+
self.close()
|
sapsf_shared/config.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Configuration loader for SAP SuccessFactors tools.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- YAML config files (with env-var substitution ${VAR_NAME})
|
|
5
|
+
- JSON config files
|
|
6
|
+
- Environment variable overrides
|
|
7
|
+
- Typed dataclass for IDE autocomplete
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from sapsf_shared.auth import AuthConfig
|
|
22
|
+
from sapsf_shared.exceptions import SFConfigError
|
|
23
|
+
|
|
24
|
+
_ENV_RE = re.compile(r"\$\{(\w+)\}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_env_vars(value: Any) -> Any:
|
|
28
|
+
"""Recursively replace ${VAR_NAME} with os.environ values."""
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
def _replacer(m: re.Match[str]) -> str:
|
|
31
|
+
var_name = m.group(1)
|
|
32
|
+
env_val = os.environ.get(var_name, "")
|
|
33
|
+
return env_val
|
|
34
|
+
return _ENV_RE.sub(_replacer, value)
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
return {k: _resolve_env_vars(v) for k, v in value.items()}
|
|
37
|
+
if isinstance(value, list):
|
|
38
|
+
return [_resolve_env_vars(item) for item in value]
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_yaml(path: str | Path) -> dict[str, Any]:
|
|
43
|
+
"""Load a YAML file and resolve ${ENV_VAR} placeholders."""
|
|
44
|
+
p = Path(path)
|
|
45
|
+
if not p.exists():
|
|
46
|
+
raise SFConfigError(f"Config file not found: {p}")
|
|
47
|
+
try:
|
|
48
|
+
raw = yaml.safe_load(p.read_text())
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise SFConfigError(f"Failed to parse YAML {p}: {exc}") from exc
|
|
51
|
+
if raw is None:
|
|
52
|
+
return {}
|
|
53
|
+
return _resolve_env_vars(raw)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_json(path: str | Path) -> dict[str, Any]:
|
|
57
|
+
"""Load a JSON file and resolve ${ENV_VAR} placeholders."""
|
|
58
|
+
p = Path(path)
|
|
59
|
+
if not p.exists():
|
|
60
|
+
raise SFConfigError(f"Config file not found: {p}")
|
|
61
|
+
try:
|
|
62
|
+
raw = json.loads(p.read_text())
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
raise SFConfigError(f"Failed to parse JSON {p}: {exc}") from exc
|
|
65
|
+
return _resolve_env_vars(raw)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_config(path: str | Path) -> dict[str, Any]:
|
|
69
|
+
"""Auto-detect format from extension (.yaml / .yml / .json) and load."""
|
|
70
|
+
p = Path(path)
|
|
71
|
+
suffix = p.suffix.lower()
|
|
72
|
+
if suffix in (".yaml", ".yml"):
|
|
73
|
+
return load_yaml(p)
|
|
74
|
+
if suffix == ".json":
|
|
75
|
+
return load_json(p)
|
|
76
|
+
raise SFConfigError(f"Unsupported config format: {suffix}. Use .yaml or .json")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Typed dataclass for common SF tool config ───────────────────────────
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class SFEnvConfig:
|
|
83
|
+
"""Configuration shaped like the env-var patterns used across your tools.
|
|
84
|
+
|
|
85
|
+
Example .env mapping:
|
|
86
|
+
SF_BASE_URL → base_url
|
|
87
|
+
SF_USERNAME → username
|
|
88
|
+
SF_PASSWORD → password
|
|
89
|
+
SF_COMPANY_ID → company_id
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
base_url: str = ""
|
|
93
|
+
username: str = ""
|
|
94
|
+
password: str = ""
|
|
95
|
+
company_id: str = ""
|
|
96
|
+
client_id: str = ""
|
|
97
|
+
client_secret: str = ""
|
|
98
|
+
token_url: str = ""
|
|
99
|
+
cert_path: str = ""
|
|
100
|
+
key_path: str = ""
|
|
101
|
+
auth_type: str = "basic"
|
|
102
|
+
timeout_sec: int = 30
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_env(cls, prefix: str = "SF") -> SFEnvConfig:
|
|
106
|
+
"""Build from environment variables with optional prefix.
|
|
107
|
+
|
|
108
|
+
Looks for SF_BASE_URL, SF_USERNAME, etc.
|
|
109
|
+
Also checks legacy names: SF_INSTANCE_ID → company_id.
|
|
110
|
+
"""
|
|
111
|
+
return cls(
|
|
112
|
+
base_url=os.environ.get(f"{prefix}_BASE_URL", ""),
|
|
113
|
+
username=os.environ.get(f"{prefix}_USERNAME", ""),
|
|
114
|
+
password=os.environ.get(f"{prefix}_PASSWORD", ""),
|
|
115
|
+
company_id=os.environ.get(
|
|
116
|
+
f"{prefix}_COMPANY_ID",
|
|
117
|
+
os.environ.get(f"{prefix}_INSTANCE_ID", ""),
|
|
118
|
+
),
|
|
119
|
+
client_id=os.environ.get(f"{prefix}_CLIENT_ID", ""),
|
|
120
|
+
client_secret=os.environ.get(f"{prefix}_CLIENT_SECRET", ""),
|
|
121
|
+
token_url=os.environ.get(f"{prefix}_TOKEN_URL", ""),
|
|
122
|
+
cert_path=os.environ.get(f"{prefix}_CERT_PATH", ""),
|
|
123
|
+
key_path=os.environ.get(f"{prefix}_KEY_PATH", ""),
|
|
124
|
+
auth_type=os.environ.get(f"{prefix}_AUTH_TYPE", "basic").lower(),
|
|
125
|
+
timeout_sec=int(os.environ.get(f"{prefix}_TIMEOUT_SEC", "30")),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def validate(self) -> None:
|
|
129
|
+
"""Raise SFConfigError if required fields are missing."""
|
|
130
|
+
if not self.base_url:
|
|
131
|
+
raise SFConfigError("base_url is required (set SF_BASE_URL)")
|
|
132
|
+
if not self.base_url.startswith(("https://", "http://")):
|
|
133
|
+
raise SFConfigError(
|
|
134
|
+
f"base_url must start with https:// or http:// — got: {self.base_url}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def to_auth_config(self) -> AuthConfig:
|
|
138
|
+
"""Convert to an AuthConfig for use with SFClient."""
|
|
139
|
+
from sapsf_shared.auth import AuthConfig
|
|
140
|
+
|
|
141
|
+
return AuthConfig(
|
|
142
|
+
base_url=self.base_url,
|
|
143
|
+
company_id=self.company_id,
|
|
144
|
+
auth_type=self.auth_type,
|
|
145
|
+
username=self.username,
|
|
146
|
+
password=self.password,
|
|
147
|
+
client_id=self.client_id,
|
|
148
|
+
client_secret=self.client_secret,
|
|
149
|
+
token_url=self.token_url,
|
|
150
|
+
cert_path=self.cert_path,
|
|
151
|
+
key_path=self.key_path,
|
|
152
|
+
timeout_sec=self.timeout_sec,
|
|
153
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exception hierarchy for sapsf-shared.
|
|
2
|
+
|
|
3
|
+
All errors inherit from SFError for easy catching across tools.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SFError(Exception):
|
|
8
|
+
"""Base exception for all SAP SF SDK errors."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, *, details: str | None = None) -> None:
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.details = details
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SFConfigError(SFError):
|
|
16
|
+
"""Raised when configuration is missing or invalid."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SFClientError(SFError):
|
|
20
|
+
"""Raised when an OData API call fails unrecoverably."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
*,
|
|
26
|
+
status_code: int | None = None,
|
|
27
|
+
body: str = "",
|
|
28
|
+
url: str | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.body = body
|
|
33
|
+
self.url = url
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthError(SFError):
|
|
37
|
+
"""Raised when authentication cannot be established."""
|