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.
- SapApiLibrary/SapApiLibrary.py +380 -0
- SapApiLibrary/__init__.py +4 -0
- SapApiLibrary/py.typed +0 -0
- SapEccLibrary/SapEccLibrary.py +310 -0
- SapEccLibrary/__init__.py +9 -0
- SapEccLibrary/_vendor/__init__.py +4 -0
- SapEccLibrary/_vendor/sapgui_base.py +791 -0
- SapEccLibrary/keywords/__init__.py +16 -0
- SapEccLibrary/keywords/_connection.py +214 -0
- SapEccLibrary/keywords/_diagnostics.py +347 -0
- SapEccLibrary/keywords/_embedded_browser.py +188 -0
- SapEccLibrary/keywords/_grid.py +204 -0
- SapEccLibrary/keywords/_healing.py +106 -0
- SapEccLibrary/keywords/_perception.py +940 -0
- SapEccLibrary/keywords/_pointer.py +117 -0
- SapEccLibrary/keywords/_semantic.py +206 -0
- SapEccLibrary/keywords/_sessions.py +321 -0
- SapEccLibrary/keywords/_waits.py +189 -0
- SapEccLibrary/py.typed +0 -0
- SapFioriLibrary/SapFioriLibrary.py +1308 -0
- SapFioriLibrary/__init__.py +5 -0
- SapFioriLibrary/_ui5_js.py +2340 -0
- SapFioriLibrary/_ui5_runtime.py +295 -0
- SapFioriLibrary/py.typed +0 -0
- SapFioriLibrary/regen_recorder.py +33 -0
- robotframework_sapfx-0.6.4.dist-info/METADATA +263 -0
- robotframework_sapfx-0.6.4.dist-info/RECORD +50 -0
- robotframework_sapfx-0.6.4.dist-info/WHEEL +5 -0
- robotframework_sapfx-0.6.4.dist-info/licenses/LICENSE +201 -0
- robotframework_sapfx-0.6.4.dist-info/licenses/NOTICE +92 -0
- robotframework_sapfx-0.6.4.dist-info/top_level.txt +4 -0
- sapfx_common/__init__.py +7 -0
- sapfx_common/abap_list.py +48 -0
- sapfx_common/auth_flows.py +77 -0
- sapfx_common/client_security.py +111 -0
- sapfx_common/com_safety.py +35 -0
- sapfx_common/fiori_diagnostics.py +215 -0
- sapfx_common/healing.py +119 -0
- sapfx_common/healing_telemetry.py +68 -0
- sapfx_common/object_tree.py +159 -0
- sapfx_common/perception_diff.py +144 -0
- sapfx_common/polling.py +77 -0
- sapfx_common/py.typed +0 -0
- sapfx_common/screen_watch.py +160 -0
- sapfx_common/secrets.py +10 -0
- sapfx_common/semantic.py +600 -0
- sapfx_common/session_context.py +22 -0
- sapfx_common/visual_baseline.py +124 -0
- sapfx_common/visual_hash.py +214 -0
- 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))
|
SapApiLibrary/py.typed
ADDED
|
File without changes
|