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.
Files changed (41) hide show
  1. sap_adt/__init__.py +1 -0
  2. sap_adt/__main__.py +5 -0
  3. sap_adt/adt/__init__.py +0 -0
  4. sap_adt/adt/client.py +185 -0
  5. sap_adt/adt/coverage.py +163 -0
  6. sap_adt/adt/handlers.py +956 -0
  7. sap_adt/adt/parsers/__init__.py +47 -0
  8. sap_adt/adt/parsers/capabilities.py +29 -0
  9. sap_adt/adt/parsers/common.py +65 -0
  10. sap_adt/adt/parsers/fields.py +106 -0
  11. sap_adt/adt/parsers/findings.py +276 -0
  12. sap_adt/adt/parsers/objects.py +128 -0
  13. sap_adt/adt/parsers/records.py +148 -0
  14. sap_adt/adt/parsers/rows.py +41 -0
  15. sap_adt/adt/parsers/scalar.py +89 -0
  16. sap_adt/adt/parsers/source.py +14 -0
  17. sap_adt/cli.py +1157 -0
  18. sap_adt/config.py +804 -0
  19. sap_adt/credentials.py +213 -0
  20. sap_adt/credentials_reports.py +197 -0
  21. sap_adt/errors.py +269 -0
  22. sap_adt/keystore/__init__.py +87 -0
  23. sap_adt/keystore/base.py +54 -0
  24. sap_adt/keystore/dpapi_store.py +157 -0
  25. sap_adt/keystore/env_store.py +57 -0
  26. sap_adt/keystore/file_store.py +164 -0
  27. sap_adt/keystore/keyring_store.py +87 -0
  28. sap_adt/keystore/pass_store.py +86 -0
  29. sap_adt/log_redaction.py +89 -0
  30. sap_adt/output.py +244 -0
  31. sap_adt/transport/__init__.py +1 -0
  32. sap_adt/transport/collector.py +100 -0
  33. sap_adt/transport/handlers.py +289 -0
  34. sap_adt/transport/legacy_config.py +57 -0
  35. sap_adt/version.py +24 -0
  36. sap_adt_cli-1.5.0.dist-info/METADATA +280 -0
  37. sap_adt_cli-1.5.0.dist-info/RECORD +41 -0
  38. sap_adt_cli-1.5.0.dist-info/WHEEL +5 -0
  39. sap_adt_cli-1.5.0.dist-info/entry_points.txt +2 -0
  40. sap_adt_cli-1.5.0.dist-info/licenses/LICENSE +21 -0
  41. 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
@@ -0,0 +1,5 @@
1
+ """`python -m sap_adt` entry — equivalent to the `sap-adt` console script."""
2
+ from sap_adt.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main(prog_name="sap-adt")
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
@@ -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)