robotframework-sapfx 0.6.4__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.
Files changed (50) hide show
  1. SapApiLibrary/SapApiLibrary.py +380 -0
  2. SapApiLibrary/__init__.py +4 -0
  3. SapApiLibrary/py.typed +0 -0
  4. SapEccLibrary/SapEccLibrary.py +310 -0
  5. SapEccLibrary/__init__.py +9 -0
  6. SapEccLibrary/_vendor/__init__.py +4 -0
  7. SapEccLibrary/_vendor/sapgui_base.py +791 -0
  8. SapEccLibrary/keywords/__init__.py +16 -0
  9. SapEccLibrary/keywords/_connection.py +214 -0
  10. SapEccLibrary/keywords/_diagnostics.py +347 -0
  11. SapEccLibrary/keywords/_embedded_browser.py +188 -0
  12. SapEccLibrary/keywords/_grid.py +204 -0
  13. SapEccLibrary/keywords/_healing.py +106 -0
  14. SapEccLibrary/keywords/_perception.py +940 -0
  15. SapEccLibrary/keywords/_pointer.py +117 -0
  16. SapEccLibrary/keywords/_semantic.py +206 -0
  17. SapEccLibrary/keywords/_sessions.py +321 -0
  18. SapEccLibrary/keywords/_waits.py +189 -0
  19. SapEccLibrary/py.typed +0 -0
  20. SapFioriLibrary/SapFioriLibrary.py +1308 -0
  21. SapFioriLibrary/__init__.py +5 -0
  22. SapFioriLibrary/_ui5_js.py +2340 -0
  23. SapFioriLibrary/_ui5_runtime.py +295 -0
  24. SapFioriLibrary/py.typed +0 -0
  25. SapFioriLibrary/regen_recorder.py +33 -0
  26. robotframework_sapfx-0.6.4.dist-info/METADATA +263 -0
  27. robotframework_sapfx-0.6.4.dist-info/RECORD +50 -0
  28. robotframework_sapfx-0.6.4.dist-info/WHEEL +5 -0
  29. robotframework_sapfx-0.6.4.dist-info/licenses/LICENSE +201 -0
  30. robotframework_sapfx-0.6.4.dist-info/licenses/NOTICE +92 -0
  31. robotframework_sapfx-0.6.4.dist-info/top_level.txt +4 -0
  32. sapfx_common/__init__.py +7 -0
  33. sapfx_common/abap_list.py +48 -0
  34. sapfx_common/auth_flows.py +77 -0
  35. sapfx_common/client_security.py +111 -0
  36. sapfx_common/com_safety.py +35 -0
  37. sapfx_common/fiori_diagnostics.py +215 -0
  38. sapfx_common/healing.py +119 -0
  39. sapfx_common/healing_telemetry.py +68 -0
  40. sapfx_common/object_tree.py +159 -0
  41. sapfx_common/perception_diff.py +144 -0
  42. sapfx_common/polling.py +77 -0
  43. sapfx_common/py.typed +0 -0
  44. sapfx_common/screen_watch.py +160 -0
  45. sapfx_common/secrets.py +10 -0
  46. sapfx_common/semantic.py +600 -0
  47. sapfx_common/session_context.py +22 -0
  48. sapfx_common/visual_baseline.py +124 -0
  49. sapfx_common/visual_hash.py +214 -0
  50. sapfx_common/vocabulary.py +227 -0
@@ -0,0 +1,380 @@
1
+ """SapApiLibrary — le canal **API** du projet : OData (Gateway/CAP) et RFC.
2
+
3
+ Le troisième canal, à côté du GUI desktop (``SapEccLibrary``) et du web
4
+ (``SapFioriLibrary``). Raison d'être : un test SAP robuste **prépare et
5
+ vérifie ses données par l'API** et ne passe par l'écran que pour ce qu'il
6
+ teste vraiment — le setup/teardown GUI est lent et fragile, l'API est
7
+ rapide et déterministe. Le keyword métier type croise les canaux :
8
+ compter par SE16 ET par OData, puis exiger l'égalité (voir
9
+ ``tests/robot/flagship_cross_paradigm.robot``).
10
+
11
+ Volontairement en **stdlib pure** (``urllib`` + ``http.cookiejar``) : aucune
12
+ dépendance nouvelle à épingler (la leçon pywin32 de la convention 6). Couvre :
13
+
14
+ * OData **v2** (Gateway embarqué ECC/S4 : enveloppe ``{"d": ...}``) et **v4**
15
+ (CAP, S/4 moderne : ``{"value": [...]}``) — mêmes keywords, détection de
16
+ l'enveloppe ;
17
+ * le protocole **CSRF** SAP (``X-CSRF-Token: Fetch`` puis rejeu du token et
18
+ des cookies sur les écritures) ;
19
+ * le **RFC** en option : ``Call Rfc`` s'appuie sur `pyrfc` (SAP NW RFC SDK)
20
+ s'il est installé, sinon échoue avec la marche à suivre — jamais de
21
+ dépendance dure à un SDK propriétaire.
22
+
23
+ Erreurs auto-corrigibles (politique maison) : un échec HTTP nomme le statut,
24
+ l'URL effective et le début du corps de réponse.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import json
30
+ import ssl
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+ from http.cookiejar import CookieJar
35
+ from typing import Any, Optional, Union
36
+
37
+ from robot.api import logger
38
+ from robot.api.types import Secret
39
+ from robot.utils import timestr_to_secs
40
+
41
+ from sapfx_common.secrets import reveal_secret
42
+ from sapfx_common.session_context import current_execution_namespace
43
+
44
+ _TRUTHY = ("1", "true", "yes", "on")
45
+
46
+ # Extrait de corps de réponse joint aux erreurs HTTP : assez pour lire le
47
+ # message Gateway (<message>...</message>), pas de quoi noyer le log.
48
+ _BODY_EXCERPT = 400
49
+
50
+
51
+ def _as_bool(value: Any) -> bool:
52
+ if isinstance(value, bool):
53
+ return value
54
+ return str(value).strip().lower() in _TRUTHY
55
+
56
+
57
+ def _url_origin(url: str) -> tuple[str, str, Optional[int]]:
58
+ parsed = urllib.parse.urlsplit(url)
59
+ default_port = 443 if parsed.scheme.lower() == "https" else 80
60
+ return (parsed.scheme.lower(), (parsed.hostname or "").lower(),
61
+ parsed.port or default_port)
62
+
63
+
64
+ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
65
+ """Refuse qu'une redirection transporte l'authentification vers un autre hôte."""
66
+
67
+ def __init__(self, base_url: str) -> None:
68
+ self._origin = _url_origin(base_url)
69
+
70
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
71
+ if _url_origin(newurl) != self._origin:
72
+ raise urllib.error.URLError(
73
+ "Cross-origin API redirect blocked: %s" % newurl)
74
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
75
+
76
+
77
+ class _ApiSession:
78
+ """État d'une session API : URL de base, auth, cookies, client SAP."""
79
+
80
+ def __init__(self, base_url: str, user: Optional[str],
81
+ password: Optional[Union[str, Secret]], sap_client: Optional[str],
82
+ timeout: float, verify_tls: bool) -> None:
83
+ self.base_url = base_url.rstrip("/")
84
+ self.sap_client = sap_client
85
+ self.timeout = timeout
86
+ self.headers: dict[str, str] = {"Accept": "application/json"}
87
+ if user is not None:
88
+ token = base64.b64encode(
89
+ ("%s:%s" % (user, reveal_secret(password) or "")).encode(
90
+ "utf-8")).decode("ascii")
91
+ self.headers["Authorization"] = "Basic %s" % token
92
+ self.cookies = CookieJar()
93
+ handlers: list[urllib.request.BaseHandler] = [
94
+ urllib.request.HTTPCookieProcessor(self.cookies),
95
+ _SameOriginRedirectHandler(self.base_url)]
96
+ if not verify_tls:
97
+ context = ssl.create_default_context()
98
+ context.check_hostname = False
99
+ context.verify_mode = ssl.CERT_NONE
100
+ handlers.append(urllib.request.HTTPSHandler(context=context))
101
+ self.opener = urllib.request.build_opener(*handlers)
102
+ self.csrf_token: Optional[str] = None
103
+
104
+
105
+ class SapApiLibrary:
106
+ """Bibliothèque Robot Framework pour parler aux APIs SAP (OData v2/v4, RFC).
107
+
108
+ == Sessions ==
109
+ `Open Api Session` mémorise URL de base, authentification Basic, client SAP
110
+ et cookies ; tous les keywords OData s'y réfèrent par ``alias`` (plusieurs
111
+ systèmes simultanés possibles). Exemple::
112
+
113
+ Open Api Session http://vhcala4hci:50000 user=DEVELOPER
114
+ ... password=${SAP_PASSWORD} sap_client=001
115
+ ${n}= Get Odata Count /sap/opu/odata/sap/SEPMRA_SHOP/Products
116
+
117
+ == OData v2 et v4 ==
118
+ `Get Odata Entities` renvoie la liste quelle que soit l'enveloppe
119
+ (``d.results`` v2, ``value`` v4) ; `Get Odata Count` lit ``$count``.
120
+ `Post Odata` applique le protocole CSRF SAP automatiquement.
121
+
122
+ == RFC (optionnel) ==
123
+ `Open Rfc Connection` / `Call Rfc` utilisent `pyrfc` si installé
124
+ (SAP NW RFC SDK requis) — sinon l'erreur donne la marche à suivre.
125
+ """
126
+
127
+ __version__ = "0.6.4"
128
+ ROBOT_LIBRARY_SCOPE = "SUITE"
129
+ ROBOT_LIBRARY_DOC_FORMAT = "ROBOT"
130
+
131
+ def __init__(self, default_timeout: str = "30s") -> None:
132
+ """``default_timeout`` : délai réseau par défaut des sessions ouvertes
133
+ sans ``timeout`` explicite (chaîne de temps Robot : ``30s``, ``500 ms``)."""
134
+ self.default_timeout = timestr_to_secs(default_timeout)
135
+ self._sessions_by_namespace: dict[str, dict[str, _ApiSession]] = {}
136
+ self._rfc_by_namespace: dict[str, dict[str, Any]] = {}
137
+
138
+ def _api_sessions(self) -> dict[str, _ApiSession]:
139
+ namespace = current_execution_namespace()
140
+ return self._sessions_by_namespace.setdefault(namespace, {})
141
+
142
+ def _rfc_connections(self) -> dict[str, Any]:
143
+ namespace = current_execution_namespace()
144
+ return self._rfc_by_namespace.setdefault(namespace, {})
145
+
146
+ # -- sessions --------------------------------------------------------------
147
+
148
+ def open_api_session(self, base_url: str, user: Optional[str] = None,
149
+ password: Optional[Union[str, Secret]] = None,
150
+ sap_client: Optional[str] = None,
151
+ alias: str = "default", timeout: Optional[str] = None,
152
+ verify_tls: bool = True) -> str:
153
+ """Ouvre une session API vers ``base_url`` (mémorise auth Basic,
154
+ ``sap-client`` ajouté à chaque requête, cookies). ``verify_tls=False``
155
+ accepte un certificat auto-signé (systèmes de test). Retourne l'alias."""
156
+ secs = timestr_to_secs(timeout) if timeout else self.default_timeout
157
+ tls_verified = _as_bool(verify_tls)
158
+ if not tls_verified:
159
+ logger.warn("TLS certificate verification is disabled for API session '%s'."
160
+ % alias)
161
+ self._api_sessions()[alias] = _ApiSession(
162
+ base_url, user, password, sap_client, secs, tls_verified)
163
+ return alias
164
+
165
+ def close_api_session(self, alias: str = "default") -> None:
166
+ """Oublie la session ``alias`` (cookies et token CSRF compris)."""
167
+ self._api_sessions().pop(alias, None)
168
+
169
+ def close_all_api_sessions(self) -> None:
170
+ """Oublie toutes les sessions API (teardown de suite)."""
171
+ namespace = current_execution_namespace()
172
+ self._sessions_by_namespace.pop(namespace, None)
173
+
174
+ def list_api_sessions(self) -> dict[str, Any]:
175
+ """Retourne l'état **JSON-safe** du canal API dans le namespace
176
+ courant : ``{"api_sessions": [{alias, base_url, sap_client,
177
+ authenticated, csrf_token_cached}], "rfc_connections": [alias…]}``.
178
+
179
+ JAMAIS de credentials : ``authenticated`` dit seulement si la session
180
+ porte une authentification Basic, ``csrf_token_cached`` si un token
181
+ CSRF a déjà été obtenu. C'est la perception du canal API (il n'a pas
182
+ d'écran) — consommée par le state provider rf-mcp (`SapApiPlugin`)
183
+ et utile en débogage de suite multi-alias."""
184
+ return {
185
+ "api_sessions": [
186
+ {"alias": alias,
187
+ "base_url": session.base_url,
188
+ "sap_client": session.sap_client,
189
+ "authenticated": "Authorization" in session.headers,
190
+ "csrf_token_cached": session.csrf_token is not None}
191
+ for alias, session in sorted(self._api_sessions().items())],
192
+ "rfc_connections": sorted(self._rfc_connections()),
193
+ }
194
+
195
+ # -- OData -----------------------------------------------------------------
196
+
197
+ def get_odata(self, path: str, alias: str = "default",
198
+ **query: str) -> Any:
199
+ """GET OData → JSON décodé tel quel (enveloppe v2/v4 comprise). Les
200
+ arguments nommés deviennent des paramètres de requête (``top=5`` →
201
+ ``$top=5`` : le préfixe ``$`` des options système OData est ajouté aux
202
+ noms connus — top/skip/filter/select/orderby/format/expand/count)."""
203
+ status, _, body = self._request(alias, "GET", path, query)
204
+ return self._decode_json(body, status, path)
205
+
206
+ def get_odata_entities(self, path: str, alias: str = "default",
207
+ **query: str) -> list:
208
+ """GET OData → la **liste d'entités**, quelle que soit la version :
209
+ ``d.results`` (v2), ``value`` (v4), ou l'entité seule dans une liste."""
210
+ payload = self.get_odata(path, alias=alias, **query)
211
+ if isinstance(payload, dict):
212
+ envelope = payload.get("d", payload)
213
+ if isinstance(envelope, dict) and isinstance(
214
+ envelope.get("results"), list):
215
+ return envelope["results"]
216
+ if isinstance(payload.get("value"), list):
217
+ return payload["value"]
218
+ return [envelope]
219
+ if isinstance(payload, list):
220
+ return payload
221
+ raise AssertionError(
222
+ "Réponse OData inattendue pour '%s' : ni enveloppe v2 (d.results) "
223
+ "ni v4 (value) — reçu %r" % (path, type(payload).__name__))
224
+
225
+ def get_odata_count(self, entity_path: str, alias: str = "default",
226
+ **query: str) -> int:
227
+ """``GET <entity_path>/$count`` → entier (v2 et v4). Le chemin est
228
+ celui de l'entity set (``.../Products``) ; les filtres passent en
229
+ arguments nommés (``filter=Price gt 100``)."""
230
+ path = "%s/$count" % entity_path.rstrip("/")
231
+ status, _, body = self._request(alias, "GET", path, query,
232
+ headers={"Accept": "text/plain"})
233
+ text = body.decode("utf-8-sig", errors="replace").strip()
234
+ digits = "".join(ch for ch in text if ch.isdigit())
235
+ if not digits:
236
+ raise AssertionError(
237
+ "$count sur '%s' n'a pas retourné un entier (statut %d) : %r"
238
+ % (entity_path, status, text[:_BODY_EXCERPT]))
239
+ return int(digits)
240
+
241
+ def post_odata(self, path: str, payload: Any, alias: str = "default",
242
+ **query: str) -> Any:
243
+ """POST OData avec le protocole **CSRF** SAP : un GET préalable avec
244
+ ``X-CSRF-Token: Fetch`` obtient le token (mémorisé avec les cookies de
245
+ session), rejoué sur l'écriture. ``payload`` : dict (sérialisé JSON) ou
246
+ chaîne déjà sérialisée. Retourne le JSON de la réponse (``{}`` si 204)."""
247
+ session = self._session(alias)
248
+ if session.csrf_token is None:
249
+ _, headers, _ = self._request(
250
+ alias, "GET", path, None, headers={"X-CSRF-Token": "Fetch"})
251
+ session.csrf_token = headers.get("x-csrf-token") or headers.get(
252
+ "X-CSRF-Token") or ""
253
+ body = payload if isinstance(payload, (str, bytes)) else json.dumps(payload)
254
+ extra = {"Content-Type": "application/json"}
255
+ if session.csrf_token:
256
+ extra["X-CSRF-Token"] = session.csrf_token
257
+ status, _, raw = self._request(alias, "POST", path, query,
258
+ headers=extra, body=body)
259
+ if status == 204 or not raw.strip():
260
+ return {}
261
+ return self._decode_json(raw, status, path)
262
+
263
+ # -- RFC (optionnel, via pyrfc) ---------------------------------------------
264
+
265
+ def open_rfc_connection(self, alias: str = "default", **params: Any) -> str:
266
+ """Ouvre une connexion RFC via `pyrfc` (``ashost=``, ``sysnr=``,
267
+ ``client=``, ``user=``, ``passwd=``...). Échec explicite avec la marche
268
+ à suivre si `pyrfc`/le SDK NW RFC ne sont pas installés — le RFC reste
269
+ **optionnel**, rien d'autre dans la bibliothèque n'en dépend."""
270
+ try:
271
+ import pyrfc
272
+ except ImportError:
273
+ raise RuntimeError(
274
+ "Call Rfc a besoin de pyrfc (et du SAP NW RFC SDK) : "
275
+ "pip install pyrfc — voir https://github.com/SAP/PyRFC. "
276
+ "Les keywords OData, eux, fonctionnent sans.")
277
+ self._rfc_connections()[alias] = pyrfc.Connection(
278
+ **{name: reveal_secret(value) for name, value in params.items()})
279
+ return alias
280
+
281
+ def call_rfc(self, function_name: str, alias: str = "default",
282
+ **params: Any) -> Any:
283
+ """Appelle le module fonction ``function_name`` sur la connexion RFC
284
+ ``alias`` (ouverte par `Open Rfc Connection`) et retourne le résultat
285
+ (dict pyrfc)."""
286
+ connection = self._rfc_connections().get(alias)
287
+ if connection is None:
288
+ raise RuntimeError(
289
+ "Aucune connexion RFC '%s' — appeler Open Rfc Connection "
290
+ "d'abord." % alias)
291
+ return connection.call(function_name, **params)
292
+
293
+ def close_rfc_connection(self, alias: str = "default") -> None:
294
+ """Ferme la connexion RFC ``alias`` (silencieux si absente)."""
295
+ connection = self._rfc_connections().pop(alias, None)
296
+ if connection is not None:
297
+ connection.close()
298
+
299
+ # -- plomberie ---------------------------------------------------------------
300
+
301
+ # Options système OData acceptées sans le préfixe `$` (ergonomie Robot :
302
+ # `top=5` au lieu de `$top=5` impossible en argument nommé).
303
+ _SYSTEM_QUERY = ("top", "skip", "filter", "select", "orderby", "format",
304
+ "expand", "count", "search", "inlinecount")
305
+
306
+ def _session(self, alias: str) -> _ApiSession:
307
+ sessions = self._api_sessions()
308
+ session = sessions.get(alias)
309
+ if session is None:
310
+ raise RuntimeError(
311
+ "Aucune session API '%s' — appeler Open Api Session d'abord "
312
+ "(sessions ouvertes : %s)."
313
+ % (alias, ", ".join(sorted(sessions)) or "aucune"))
314
+ return session
315
+
316
+ def _build_url(self, session: _ApiSession, path: str,
317
+ query: Optional[dict]) -> str:
318
+ if path.startswith(("http://", "https://")):
319
+ if _url_origin(path) != _url_origin(session.base_url):
320
+ raise ValueError(
321
+ "Cross-origin API URL blocked: %s" % path)
322
+ url = path
323
+ else:
324
+ url = session.base_url + "/" + path.lstrip("/")
325
+ params = []
326
+ for key, value in (query or {}).items():
327
+ name = "$" + key if key in self._SYSTEM_QUERY else key
328
+ params.append((name, value))
329
+ if session.sap_client:
330
+ params.append(("sap-client", session.sap_client))
331
+ if params:
332
+ separator = "&" if "?" in url else "?"
333
+ url += separator + urllib.parse.urlencode(params)
334
+ return url
335
+
336
+ def _request(self, alias: str, method: str, path: str,
337
+ query: Optional[dict] = None,
338
+ headers: Optional[dict] = None,
339
+ body: Any = None) -> tuple[int, dict, bytes]:
340
+ """Requête HTTP d'une session : retourne (statut, en-têtes, corps).
341
+ >= 400 → AssertionError auto-corrigible (statut, URL, extrait du corps)."""
342
+ session = self._session(alias)
343
+ url = self._build_url(session, path, query)
344
+ merged = dict(session.headers)
345
+ merged.update(headers or {})
346
+ data = body.encode("utf-8") if isinstance(body, str) else body
347
+ request = urllib.request.Request(url, data=data, headers=merged,
348
+ method=method.upper())
349
+ try:
350
+ response = self._transport(session, request)
351
+ except urllib.error.HTTPError as err:
352
+ excerpt = err.read()[:_BODY_EXCERPT].decode("utf-8", errors="replace")
353
+ raise AssertionError(
354
+ "%s %s -> HTTP %d %s.\nDébut de la réponse : %s"
355
+ % (method.upper(), url, err.code, err.reason, excerpt))
356
+ except urllib.error.URLError as err:
357
+ raise AssertionError(
358
+ "%s %s injoignable : %s (système démarré ? port ouvert ? "
359
+ "vérifier base_url/verify_tls)." % (method.upper(), url, err.reason))
360
+ with response:
361
+ payload = response.read()
362
+ return (response.status, dict(response.headers), payload)
363
+
364
+ @staticmethod
365
+ def _transport(session: _ApiSession, request: urllib.request.Request):
366
+ """Frontière réseau (stubbable en test unitaire) : exécute la requête
367
+ via l'opener de la session (cookies rejoués automatiquement, contexte
368
+ TLS éventuel installé par le HTTPSHandler de l'opener)."""
369
+ return session.opener.open(request, timeout=session.timeout)
370
+
371
+ @staticmethod
372
+ def _decode_json(body: bytes, status: int, path: str) -> Any:
373
+ try:
374
+ return json.loads(body.decode("utf-8-sig"))
375
+ except (UnicodeDecodeError, json.JSONDecodeError) as err:
376
+ excerpt = body[:_BODY_EXCERPT].decode("utf-8", errors="replace")
377
+ raise AssertionError(
378
+ "Réponse de '%s' (statut %d) illisible en JSON (%s) — ajouter "
379
+ "format=json ou vérifier le chemin.\nDébut : %s"
380
+ % (path, status, err, excerpt))
@@ -0,0 +1,4 @@
1
+ """SapApiLibrary — canal API (OData/RFC) du projet, à côté des canaux GUI."""
2
+ from .SapApiLibrary import SapApiLibrary
3
+
4
+ __all__ = ["SapApiLibrary"]
SapApiLibrary/py.typed ADDED
File without changes