sap-adt-cli 1.5.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.
- sap_adt/__init__.py +1 -0
- sap_adt/__main__.py +5 -0
- sap_adt/adt/__init__.py +0 -0
- sap_adt/adt/client.py +185 -0
- sap_adt/adt/coverage.py +163 -0
- sap_adt/adt/handlers.py +956 -0
- sap_adt/adt/parsers/__init__.py +47 -0
- sap_adt/adt/parsers/capabilities.py +29 -0
- sap_adt/adt/parsers/common.py +65 -0
- sap_adt/adt/parsers/fields.py +106 -0
- sap_adt/adt/parsers/findings.py +276 -0
- sap_adt/adt/parsers/objects.py +128 -0
- sap_adt/adt/parsers/records.py +148 -0
- sap_adt/adt/parsers/rows.py +41 -0
- sap_adt/adt/parsers/scalar.py +89 -0
- sap_adt/adt/parsers/source.py +14 -0
- sap_adt/cli.py +1157 -0
- sap_adt/config.py +804 -0
- sap_adt/credentials.py +213 -0
- sap_adt/credentials_reports.py +197 -0
- sap_adt/errors.py +269 -0
- sap_adt/keystore/__init__.py +87 -0
- sap_adt/keystore/base.py +54 -0
- sap_adt/keystore/dpapi_store.py +157 -0
- sap_adt/keystore/env_store.py +57 -0
- sap_adt/keystore/file_store.py +164 -0
- sap_adt/keystore/keyring_store.py +87 -0
- sap_adt/keystore/pass_store.py +86 -0
- sap_adt/log_redaction.py +89 -0
- sap_adt/output.py +244 -0
- sap_adt/transport/__init__.py +1 -0
- sap_adt/transport/collector.py +100 -0
- sap_adt/transport/handlers.py +289 -0
- sap_adt/transport/legacy_config.py +57 -0
- sap_adt/version.py +24 -0
- sap_adt_cli-1.5.0.dist-info/METADATA +280 -0
- sap_adt_cli-1.5.0.dist-info/RECORD +41 -0
- sap_adt_cli-1.5.0.dist-info/WHEEL +5 -0
- sap_adt_cli-1.5.0.dist-info/entry_points.txt +2 -0
- sap_adt_cli-1.5.0.dist-info/licenses/LICENSE +21 -0
- sap_adt_cli-1.5.0.dist-info/top_level.txt +1 -0
sap_adt/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""sap-adt: SAP ADT REST API CLI and transport request collector."""
|
sap_adt/__main__.py
ADDED
sap_adt/adt/__init__.py
ADDED
|
File without changes
|
sap_adt/adt/client.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
import urllib3
|
|
6
|
+
from requests.auth import HTTPBasicAuth
|
|
7
|
+
|
|
8
|
+
from .. import log_redaction
|
|
9
|
+
from ..config import SapConfig, get_config
|
|
10
|
+
|
|
11
|
+
_session: Optional[requests.Session] = None
|
|
12
|
+
_csrf_token: Optional[str] = None
|
|
13
|
+
_session_cookies: Optional[dict] = None
|
|
14
|
+
# Explicit connection override (transport collection, tests). When None the
|
|
15
|
+
# client resolves the active profile on every request as before.
|
|
16
|
+
_active_config: Optional[SapConfig] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def set_config(config: Optional[SapConfig]) -> None:
|
|
20
|
+
"""Use an explicit connection for subsequent requests (None = resolve)."""
|
|
21
|
+
global _active_config
|
|
22
|
+
_active_config = config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_override() -> Optional[SapConfig]:
|
|
26
|
+
"""Return the injected config (or None when none was injected)."""
|
|
27
|
+
return _active_config
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def active_config() -> SapConfig:
|
|
31
|
+
"""Return the explicit override, or resolve the active profile/env."""
|
|
32
|
+
return _active_config if _active_config is not None else get_config()
|
|
33
|
+
|
|
34
|
+
# Query parameters whose values must never appear in error messages.
|
|
35
|
+
_SENSITIVE_QUERY_RE = ("token", "password", "passwd", "secret", "codepage",
|
|
36
|
+
"lockhandle")
|
|
37
|
+
_MAX_BODY_CHARS = 500
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AdtHttpError(Exception):
|
|
41
|
+
"""HTTP failure carrying only sanitized, safe-to-print information."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, status: Optional[int] = None):
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status = status
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_session() -> requests.Session:
|
|
49
|
+
global _session
|
|
50
|
+
if _session is None:
|
|
51
|
+
_session = requests.Session()
|
|
52
|
+
return _session
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_url(url: str) -> str:
|
|
56
|
+
"""Strip sensitive query parameters before including a URL in any message."""
|
|
57
|
+
try:
|
|
58
|
+
parsed = urlparse(url)
|
|
59
|
+
kept = [(k, v) for k, v in parse_qsl(parsed.query)
|
|
60
|
+
if not any(s in k.lower() for s in _SENSITIVE_QUERY_RE)]
|
|
61
|
+
return urlunparse(parsed._replace(query=urlencode(kept)))
|
|
62
|
+
except ValueError:
|
|
63
|
+
return url
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _redact(text, config: SapConfig) -> str:
|
|
67
|
+
return log_redaction.redact(text, secret=config.password, username=config.username)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _wrap_error(exc: Exception, config: SapConfig, method: str, url: str) -> AdtHttpError:
|
|
71
|
+
"""Translate requests exceptions into sanitized AdtHttpError instances."""
|
|
72
|
+
safe_url = _safe_url(url)
|
|
73
|
+
if isinstance(exc, requests.HTTPError) and exc.response is not None:
|
|
74
|
+
response = exc.response
|
|
75
|
+
body = _redact(response.text or "", config)[:_MAX_BODY_CHARS]
|
|
76
|
+
return AdtHttpError(
|
|
77
|
+
f"HTTP {response.status_code} for {method} {safe_url}: {body}".rstrip(": "),
|
|
78
|
+
status=response.status_code,
|
|
79
|
+
)
|
|
80
|
+
message = _redact(str(exc), config)
|
|
81
|
+
return AdtHttpError(f"{type(exc).__name__} for {method} {safe_url}: {message}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _request(config: SapConfig, method: str, url: str, **kwargs) -> requests.Response:
|
|
85
|
+
try:
|
|
86
|
+
return _get_session().request(method=method, url=url, **kwargs)
|
|
87
|
+
except requests.RequestException as e:
|
|
88
|
+
# Chain discarded ('from None'): a requests PreparedRequest repr in
|
|
89
|
+
# the chain carries the Authorization header.
|
|
90
|
+
raise _wrap_error(e, config, method, url) from None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _auth_headers(config: SapConfig) -> dict:
|
|
94
|
+
return {"X-SAP-Client": config.client}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fetch_csrf_token(url: str, config: SapConfig,
|
|
98
|
+
params: Optional[dict] = None) -> str:
|
|
99
|
+
global _session_cookies
|
|
100
|
+
resp = _request(
|
|
101
|
+
config,
|
|
102
|
+
"GET",
|
|
103
|
+
url,
|
|
104
|
+
auth=HTTPBasicAuth(config.username, config.password),
|
|
105
|
+
headers={**_auth_headers(config), "x-csrf-token": "fetch"},
|
|
106
|
+
params=params,
|
|
107
|
+
verify=config.verify_ssl,
|
|
108
|
+
timeout=30,
|
|
109
|
+
)
|
|
110
|
+
token = resp.headers.get("x-csrf-token")
|
|
111
|
+
if not token:
|
|
112
|
+
raise AdtHttpError("No CSRF token received from SAP server")
|
|
113
|
+
if resp.cookies:
|
|
114
|
+
_session_cookies = dict(resp.cookies)
|
|
115
|
+
return token
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def make_adt_request(
|
|
119
|
+
url: str,
|
|
120
|
+
method: str = "GET",
|
|
121
|
+
timeout: int = 30,
|
|
122
|
+
data=None,
|
|
123
|
+
params: Optional[dict] = None,
|
|
124
|
+
extra_headers: Optional[dict] = None,
|
|
125
|
+
) -> requests.Response:
|
|
126
|
+
global _csrf_token, _session_cookies
|
|
127
|
+
|
|
128
|
+
config = active_config()
|
|
129
|
+
# Feed the live secret to the log filter so literal/Base64 leaks are
|
|
130
|
+
# scrubbed even from libraries that log raw objects.
|
|
131
|
+
log_redaction.set_secret(config.password, config.username)
|
|
132
|
+
headers = _auth_headers(config)
|
|
133
|
+
if extra_headers:
|
|
134
|
+
headers.update(extra_headers)
|
|
135
|
+
|
|
136
|
+
# Some gateways select/mandate the SAP client via the sap-client query
|
|
137
|
+
# parameter during Basic Auth login, so send it on every request -- the
|
|
138
|
+
# CSRF pre-flight, the business call, and the CSRF retry included. Copy
|
|
139
|
+
# the caller's dict (never mutate it) and let an explicit sap-client win.
|
|
140
|
+
request_params = dict(params or {})
|
|
141
|
+
request_params.setdefault("sap-client", config.client)
|
|
142
|
+
|
|
143
|
+
if not config.verify_ssl:
|
|
144
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
145
|
+
|
|
146
|
+
if method in ("POST", "PUT") and not _csrf_token:
|
|
147
|
+
_csrf_token = _fetch_csrf_token(url, config, request_params)
|
|
148
|
+
|
|
149
|
+
if method in ("POST", "PUT") and _csrf_token:
|
|
150
|
+
headers["x-csrf-token"] = _csrf_token
|
|
151
|
+
|
|
152
|
+
resp = _request(
|
|
153
|
+
config,
|
|
154
|
+
method,
|
|
155
|
+
url,
|
|
156
|
+
auth=HTTPBasicAuth(config.username, config.password),
|
|
157
|
+
headers=headers,
|
|
158
|
+
verify=config.verify_ssl,
|
|
159
|
+
timeout=timeout,
|
|
160
|
+
data=data,
|
|
161
|
+
params=request_params,
|
|
162
|
+
cookies=_session_cookies or {},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if resp.status_code == 403 and "CSRF" in (resp.text or ""):
|
|
166
|
+
_csrf_token = _fetch_csrf_token(url, config, request_params)
|
|
167
|
+
headers["x-csrf-token"] = _csrf_token
|
|
168
|
+
resp = _request(
|
|
169
|
+
config,
|
|
170
|
+
method,
|
|
171
|
+
url,
|
|
172
|
+
auth=HTTPBasicAuth(config.username, config.password),
|
|
173
|
+
headers=headers,
|
|
174
|
+
verify=config.verify_ssl,
|
|
175
|
+
timeout=timeout,
|
|
176
|
+
data=data,
|
|
177
|
+
params=request_params,
|
|
178
|
+
cookies=_session_cookies or {},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
resp.raise_for_status()
|
|
183
|
+
except requests.HTTPError as e:
|
|
184
|
+
raise _wrap_error(e, config, method, url) from None
|
|
185
|
+
return resp
|
sap_adt/adt/coverage.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Command coverage against the connected system's discovery document.
|
|
2
|
+
|
|
3
|
+
Pure functions: given the parsed discovery ``collections`` and the
|
|
4
|
+
``COMMAND_CAPABILITIES`` map (command -> discovery hrefs it depends on),
|
|
5
|
+
split everything into three buckets. Local commands that do not talk to ADT
|
|
6
|
+
(configure/profile/credentials/status) are intentionally absent from the map
|
|
7
|
+
and counted separately by the CLI.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
# Commands that mutate/read SAP via ADT and the discovery hrefs they need.
|
|
15
|
+
# A command is "covered & available" when ANY listed href exists; a command
|
|
16
|
+
# spans object types (write/activate) when one of its object resources does.
|
|
17
|
+
_OO = ["/sap/bc/adt/oo/classes", "/sap/bc/adt/oo/interfaces"]
|
|
18
|
+
_PROG = ["/sap/bc/adt/programs/programs", "/sap/bc/adt/programs/includes"]
|
|
19
|
+
_FUGR = ["/sap/bc/adt/functions/groups"]
|
|
20
|
+
_DDL = ["/sap/bc/adt/ddic/ddl/sources"]
|
|
21
|
+
|
|
22
|
+
COMMAND_CAPABILITIES: "OrderedDict[str, list[str]]" = OrderedDict([
|
|
23
|
+
("get-program", _PROG),
|
|
24
|
+
("get-class", _OO),
|
|
25
|
+
("get-function-group", _FUGR),
|
|
26
|
+
("get-function", _FUGR),
|
|
27
|
+
("get-include", ["/sap/bc/adt/programs/includes"]),
|
|
28
|
+
("get-interface", ["/sap/bc/adt/oo/interfaces"]),
|
|
29
|
+
("get-cds-view", _DDL),
|
|
30
|
+
("get-type-group", ["/sap/bc/adt/ddic/typegroups"]),
|
|
31
|
+
("get-table", ["/sap/bc/adt/ddic/tables"]),
|
|
32
|
+
("get-structure", ["/sap/bc/adt/ddic/structures"]),
|
|
33
|
+
("get-type-info",
|
|
34
|
+
["/sap/bc/adt/ddic/domains", "/sap/bc/adt/ddic/dataelements"]),
|
|
35
|
+
("get-transaction",
|
|
36
|
+
["/sap/bc/adt/repository/informationsystem/objectproperties/values"]),
|
|
37
|
+
("search-object",
|
|
38
|
+
["/sap/bc/adt/repository/informationsystem/search"]),
|
|
39
|
+
("get-package", ["/sap/bc/adt/repository/nodestructure"]),
|
|
40
|
+
("where-used",
|
|
41
|
+
["/sap/bc/adt/repository/informationsystem/usageReferences"]),
|
|
42
|
+
("syntax-check", ["/sap/bc/adt/checkruns"]),
|
|
43
|
+
("run-unit-test", ["/sap/bc/adt/abapunit/testruns"]),
|
|
44
|
+
("run-atc", ["/sap/bc/adt/atc/runs", "/sap/bc/adt/atc/worklists"]),
|
|
45
|
+
("run-sql", ["/sap/bc/adt/datapreview/freestyle"]),
|
|
46
|
+
("list-transports", ["/sap/bc/adt/cts/transportrequests"]),
|
|
47
|
+
("create-transport", ["/sap/bc/adt/cts/transports"]),
|
|
48
|
+
("release-transport", ["/sap/bc/adt/cts/transports"]),
|
|
49
|
+
("write-source", _OO + _PROG + _FUGR),
|
|
50
|
+
("activate", _OO + _PROG + _FUGR + _DDL +
|
|
51
|
+
["/sap/bc/adt/activation", "/sap/bc/adt/activation/runs"]),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
# Commands not in the map by design (no ADT resource dependency).
|
|
55
|
+
LOCAL_COMMANDS = [
|
|
56
|
+
"status", "configure", "profile list", "profile use", "profile remove",
|
|
57
|
+
"credentials set", "credentials forget", "credentials status",
|
|
58
|
+
"credentials doctor", "discovery",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _available_hrefs(collections: Iterable[dict]) -> set[str]:
|
|
63
|
+
return {c["href"] for c in collections if c.get("href")}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _command_available(prefixes: list[str], hrefs: set[str]) -> bool:
|
|
67
|
+
return any(p in hrefs for p in prefixes)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _group_key(href: str) -> str:
|
|
71
|
+
# Absolute entries are service roots (contain the host) — bucket them
|
|
72
|
+
# without surfacing the hostname in examples.
|
|
73
|
+
if href.startswith("http://") or href.startswith("https://"):
|
|
74
|
+
return "(service root)"
|
|
75
|
+
# /sap/bc/adt/ddic/tables -> /ddic ; /sap/bc/adt/oo/classes -> /oo
|
|
76
|
+
marker = "/sap/bc/adt/"
|
|
77
|
+
if marker in href:
|
|
78
|
+
rest = href.split(marker, 1)[1].strip("/")
|
|
79
|
+
return "/" + (rest.split("/", 1)[0] if rest else "adt")
|
|
80
|
+
return href.split("/", 2)[-1].split("/", 1)[0] or "/"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute_coverage(collections: list[dict]) -> dict:
|
|
84
|
+
hrefs = _available_hrefs(collections)
|
|
85
|
+
by_href = {c["href"]: c for c in collections}
|
|
86
|
+
|
|
87
|
+
available_commands = []
|
|
88
|
+
unavailable_commands = []
|
|
89
|
+
for command, prefixes in COMMAND_CAPABILITIES.items():
|
|
90
|
+
(available_commands if _command_available(prefixes, hrefs)
|
|
91
|
+
else unavailable_commands).append(command)
|
|
92
|
+
|
|
93
|
+
referenced = {p for prefixes in COMMAND_CAPABILITIES.values() for p in prefixes}
|
|
94
|
+
uncovered = [c for c in collections if c.get("href") not in referenced]
|
|
95
|
+
|
|
96
|
+
groups: "OrderedDict[str, dict]" = OrderedDict()
|
|
97
|
+
for c in sorted(uncovered, key=lambda x: x["href"] or ""):
|
|
98
|
+
key = _group_key(c["href"])
|
|
99
|
+
g = groups.setdefault(key, {"count": 0, "examples": []})
|
|
100
|
+
g["count"] += 1
|
|
101
|
+
if len(g["examples"]) < 4:
|
|
102
|
+
# Never surface absolute (host-bearing) URLs in examples.
|
|
103
|
+
g["examples"].append(
|
|
104
|
+
"<system base URL>" if key == "(service root)" else c["href"]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"implemented_available": available_commands,
|
|
109
|
+
"available_not_covered": {
|
|
110
|
+
"total": len(uncovered),
|
|
111
|
+
"groups": [{"prefix": k, **v} for k, v in groups.items()],
|
|
112
|
+
"collections": [
|
|
113
|
+
{"href": c["href"], "title": c.get("title"),
|
|
114
|
+
"content_types": c.get("content_types", [])}
|
|
115
|
+
for c in uncovered
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
"covered_not_available": unavailable_commands,
|
|
119
|
+
"local_commands": len(LOCAL_COMMANDS),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_discovery_markdown(collections: list[dict]) -> str:
|
|
124
|
+
"""Render discovery collections to a generated endpoint table.
|
|
125
|
+
|
|
126
|
+
Generated output only — the curated adt_api.md keeps human annotations
|
|
127
|
+
(verified protocol facts, SICF, authorizations) that discovery lacks.
|
|
128
|
+
"""
|
|
129
|
+
lines = [
|
|
130
|
+
"<!-- GENERATED by `sap-adt discovery --emit-markdown PATH`.",
|
|
131
|
+
" Do not merge over adt_api.md; its protocol notes are curated. -->",
|
|
132
|
+
"",
|
|
133
|
+
"# ADT discovery — exposed resources",
|
|
134
|
+
"",
|
|
135
|
+
"| Href | Title | Accept content types |",
|
|
136
|
+
"|------|-------|----------------------|",
|
|
137
|
+
]
|
|
138
|
+
for c in collections:
|
|
139
|
+
types = ", ".join(c.get("content_types") or []) or "—"
|
|
140
|
+
title = (c.get("title") or "").replace("|", "\\|")
|
|
141
|
+
lines.append(f"| `{c.get('href')}` | {title} | {types} |")
|
|
142
|
+
lines.append("")
|
|
143
|
+
return "\n".join(lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_text(report: dict) -> str:
|
|
147
|
+
"""Text mode: per-segment counts; uncovered shown as path groups only."""
|
|
148
|
+
lines = []
|
|
149
|
+
avail = report["implemented_available"]
|
|
150
|
+
lines.append(f"implemented & available : {len(avail)}")
|
|
151
|
+
lines.append(" " + ", ".join(avail))
|
|
152
|
+
unc = report["available_not_covered"]
|
|
153
|
+
lines.append(f"available but NOT covered : {unc['total']}")
|
|
154
|
+
for g in unc["groups"]:
|
|
155
|
+
lines.append(f" {g['prefix']}/* {g['count']}")
|
|
156
|
+
for ex in g["examples"]:
|
|
157
|
+
lines.append(f" {ex}")
|
|
158
|
+
missing = report["covered_not_available"]
|
|
159
|
+
lines.append(f"covered but NOT available : {len(missing)}")
|
|
160
|
+
for cmd in missing:
|
|
161
|
+
lines.append(f" {cmd}")
|
|
162
|
+
lines.append(f"local commands (no ADT resource) : {report['local_commands']}")
|
|
163
|
+
return "\n".join(lines)
|