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/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."""