dvm-eahelper 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.
- dvm_eahelper/__init__.py +4 -0
- dvm_eahelper/__main__.py +4 -0
- dvm_eahelper/_version.py +24 -0
- dvm_eahelper/cli.py +500 -0
- dvm_eahelper/graph/__init__.py +0 -0
- dvm_eahelper/graph/base.py +81 -0
- dvm_eahelper/graph/cli.py +193 -0
- dvm_eahelper/graph/discover.py +247 -0
- dvm_eahelper/graph/fetch.py +147 -0
- dvm_eahelper/graph/kuzu_backend.py +263 -0
- dvm_eahelper/graph/loader.py +293 -0
- dvm_eahelper/graph/neo4j_backend.py +150 -0
- dvm_eahelper/graph/seed.py +128 -0
- dvm_eahelper/graph/select.py +51 -0
- dvm_eahelper/leanix/__init__.py +0 -0
- dvm_eahelper/leanix/_library_help.py +256 -0
- dvm_eahelper/leanix/download.py +949 -0
- dvm_eahelper/metamodel-mapping.yaml +197 -0
- dvm_eahelper/proxy/__init__.py +0 -0
- dvm_eahelper/proxy/diagnose.py +320 -0
- dvm_eahelper/proxy/graphiql.py +58 -0
- dvm_eahelper/proxy/persistence.py +59 -0
- dvm_eahelper/proxy/server.py +317 -0
- dvm_eahelper/proxy/token.py +240 -0
- dvm_eahelper-0.1.0.dist-info/METADATA +128 -0
- dvm_eahelper-0.1.0.dist-info/RECORD +29 -0
- dvm_eahelper-0.1.0.dist-info/WHEEL +4 -0
- dvm_eahelper-0.1.0.dist-info/entry_points.txt +3 -0
- dvm_eahelper-0.1.0.dist-info/licenses/LICENSE +21 -0
dvm_eahelper/__init__.py
ADDED
dvm_eahelper/__main__.py
ADDED
dvm_eahelper/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
dvm_eahelper/cli.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""
|
|
2
|
+
eahelper – LeanIX enterprise-architecture helper CLI
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
-----
|
|
6
|
+
eahelper <command> [OPTIONS]
|
|
7
|
+
|
|
8
|
+
Commands
|
|
9
|
+
--------
|
|
10
|
+
proxy Start the GraphQL proxy server
|
|
11
|
+
diagnose Test SSL/TLS connectivity to LeanIX and recommend fixes
|
|
12
|
+
download Download all FactSheets of a type from LeanIX via the proxy
|
|
13
|
+
load Load downloaded data into the graph database
|
|
14
|
+
seed Seed the graph database
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import os
|
|
21
|
+
import ssl
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
DEFAULT_URL = "https://eu-10.leanix.net/YourInstance"
|
|
26
|
+
DEFAULT_PORT = 8765
|
|
27
|
+
DEFAULT_CDP = "http://localhost:9222"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _prompt(prompt_text: str, default: str) -> str:
|
|
31
|
+
"""Prompt the user for a value, showing the default."""
|
|
32
|
+
try:
|
|
33
|
+
value = input(f"{prompt_text} [{default}]: ").strip()
|
|
34
|
+
except (EOFError, KeyboardInterrupt):
|
|
35
|
+
print()
|
|
36
|
+
return default
|
|
37
|
+
return value if value else default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_shared(p: argparse.ArgumentParser) -> None:
|
|
41
|
+
p.add_argument(
|
|
42
|
+
"--url",
|
|
43
|
+
metavar="URL",
|
|
44
|
+
default=None,
|
|
45
|
+
help=f"LeanIX workspace base URL (default: {DEFAULT_URL})",
|
|
46
|
+
)
|
|
47
|
+
ssl_group = p.add_mutually_exclusive_group()
|
|
48
|
+
ssl_group.add_argument(
|
|
49
|
+
"--ca-bundle",
|
|
50
|
+
metavar="PATH",
|
|
51
|
+
default=None,
|
|
52
|
+
help=(
|
|
53
|
+
"Path to a PEM CA bundle for SSL verification. "
|
|
54
|
+
"Use when behind a corporate SSL inspection proxy. "
|
|
55
|
+
"Run 'eahelper diagnose' to auto-detect and export the right bundle."
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
ssl_group.add_argument(
|
|
59
|
+
"--no-verify-ssl",
|
|
60
|
+
action="store_true",
|
|
61
|
+
default=False,
|
|
62
|
+
help="Disable SSL certificate verification entirely (insecure).",
|
|
63
|
+
)
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"--legacy-ssl",
|
|
66
|
+
action="store_true",
|
|
67
|
+
default=True,
|
|
68
|
+
help=(
|
|
69
|
+
"Relax Python 3.13+ strict X.509 certificate validation. "
|
|
70
|
+
"Fixes 'Missing Authority Key Identifier' errors from corporate SSL proxies. "
|
|
71
|
+
"(default: enabled)"
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
p.add_argument(
|
|
75
|
+
"--no-legacy-ssl",
|
|
76
|
+
action="store_false",
|
|
77
|
+
dest="legacy_ssl",
|
|
78
|
+
help="Disable legacy SSL mode and use strict X.509 validation.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _add_proxy_subcommand(subparsers: argparse._SubParsersAction) -> None:
|
|
83
|
+
proxy = subparsers.add_parser(
|
|
84
|
+
"proxy",
|
|
85
|
+
help="Start the GraphQL proxy server",
|
|
86
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
87
|
+
)
|
|
88
|
+
_add_shared(proxy)
|
|
89
|
+
proxy.add_argument(
|
|
90
|
+
"--port",
|
|
91
|
+
type=int,
|
|
92
|
+
default=DEFAULT_PORT,
|
|
93
|
+
help=f"Port for the proxy server (default: {DEFAULT_PORT})",
|
|
94
|
+
)
|
|
95
|
+
proxy.add_argument(
|
|
96
|
+
"--connect",
|
|
97
|
+
metavar="CDP_URL",
|
|
98
|
+
default=DEFAULT_CDP,
|
|
99
|
+
dest="cdp_url",
|
|
100
|
+
help=(
|
|
101
|
+
"Connect to an existing browser via Chrome DevTools Protocol. "
|
|
102
|
+
f"(default: {DEFAULT_CDP})"
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
proxy.add_argument(
|
|
106
|
+
"--token",
|
|
107
|
+
metavar="TOKEN",
|
|
108
|
+
default=None,
|
|
109
|
+
help="Use this Bearer token directly (skips browser extraction)",
|
|
110
|
+
)
|
|
111
|
+
proxy.add_argument(
|
|
112
|
+
"--api-token",
|
|
113
|
+
metavar="API_KEY",
|
|
114
|
+
default=None,
|
|
115
|
+
dest="api_key",
|
|
116
|
+
help=(
|
|
117
|
+
"LeanIX Technical User API key. Exchanges the key for a Bearer token "
|
|
118
|
+
"via OAuth2 (no browser needed). Also reads from env var LEANIX_API_TOKEN."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
proxy.add_argument(
|
|
122
|
+
"--no-save",
|
|
123
|
+
action="store_true",
|
|
124
|
+
default=False,
|
|
125
|
+
help="Do not save the token to ~/.eahelper/tokens.json",
|
|
126
|
+
)
|
|
127
|
+
proxy.set_defaults(func=_run_proxy)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _add_diagnose_subcommand(subparsers: argparse._SubParsersAction) -> None:
|
|
131
|
+
diag = subparsers.add_parser(
|
|
132
|
+
"diagnose",
|
|
133
|
+
help="Test SSL/TLS connectivity to LeanIX and recommend fixes",
|
|
134
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
135
|
+
)
|
|
136
|
+
_add_shared(diag)
|
|
137
|
+
diag.set_defaults(func=_run_diagnose)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _add_download_subcommand(subparsers: argparse._SubParsersAction) -> None:
|
|
141
|
+
dl = subparsers.add_parser(
|
|
142
|
+
"download",
|
|
143
|
+
help="Download all FactSheets of a type from LeanIX via the proxy",
|
|
144
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
145
|
+
description=(
|
|
146
|
+
"Introspects the GraphQL schema, builds a query with all scalar fields\n"
|
|
147
|
+
"for the requested FactSheet type, paginates through all results, and\n"
|
|
148
|
+
"writes them as JSON (default) or CSV.\n\n"
|
|
149
|
+
"Docs: https://help.sap.com/docs/leanix/ea/graphql-api"
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
_add_shared(dl)
|
|
153
|
+
dl.add_argument(
|
|
154
|
+
"--type", "-t",
|
|
155
|
+
metavar="TYPE",
|
|
156
|
+
default=None,
|
|
157
|
+
dest="fs_type",
|
|
158
|
+
help="FactSheet type to download (e.g. Application, ITComponent). "
|
|
159
|
+
"Use --list-types to see all available types.",
|
|
160
|
+
)
|
|
161
|
+
dl.add_argument(
|
|
162
|
+
"--subtype", "-s",
|
|
163
|
+
metavar="SUBTYPE",
|
|
164
|
+
nargs="+",
|
|
165
|
+
default=[],
|
|
166
|
+
dest="subtypes",
|
|
167
|
+
help="Filter by category (subtype). Case-insensitive. "
|
|
168
|
+
"Use --list-subtypes to see available values for a type.",
|
|
169
|
+
)
|
|
170
|
+
dl.add_argument(
|
|
171
|
+
"--proxy",
|
|
172
|
+
metavar="URL",
|
|
173
|
+
default="http://localhost:8765/graphql",
|
|
174
|
+
help="GraphQL proxy URL (default: http://localhost:8765/graphql)",
|
|
175
|
+
)
|
|
176
|
+
dl.add_argument(
|
|
177
|
+
"--output", "-o",
|
|
178
|
+
metavar="FILE",
|
|
179
|
+
default=None,
|
|
180
|
+
help="Write output to FILE (default: {Type}.json)",
|
|
181
|
+
)
|
|
182
|
+
dl.add_argument(
|
|
183
|
+
"--format", "-f",
|
|
184
|
+
choices=["json", "csv"],
|
|
185
|
+
default="json",
|
|
186
|
+
dest="fmt",
|
|
187
|
+
help="Output format: json (default) or csv",
|
|
188
|
+
)
|
|
189
|
+
dl.add_argument(
|
|
190
|
+
"--list-types",
|
|
191
|
+
action="store_true",
|
|
192
|
+
default=False,
|
|
193
|
+
help="List all available FactSheet types from the schema and exit",
|
|
194
|
+
)
|
|
195
|
+
dl.add_argument(
|
|
196
|
+
"--list-subtypes",
|
|
197
|
+
action="store_true",
|
|
198
|
+
default=False,
|
|
199
|
+
help="List all distinct category (subtype) values for --type and exit",
|
|
200
|
+
)
|
|
201
|
+
dl.add_argument(
|
|
202
|
+
"--relations",
|
|
203
|
+
action="store_true",
|
|
204
|
+
default=False,
|
|
205
|
+
help=(
|
|
206
|
+
"Download relationships between FactSheets instead of field data. "
|
|
207
|
+
"If --type is omitted, an interactive type selector is shown. "
|
|
208
|
+
"Output is CSV with columns: source_id, source_displayName, relation, "
|
|
209
|
+
"target_id, target_displayName. Default file: {Type}_relations.csv"
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
dl.add_argument(
|
|
213
|
+
"--list-relations",
|
|
214
|
+
action="store_true",
|
|
215
|
+
default=False,
|
|
216
|
+
help="List all available relationship fields for --type and exit",
|
|
217
|
+
)
|
|
218
|
+
dl.add_argument(
|
|
219
|
+
"--limit", "-n",
|
|
220
|
+
type=int,
|
|
221
|
+
metavar="N",
|
|
222
|
+
default=None,
|
|
223
|
+
help="Stop after downloading N records (useful for testing)",
|
|
224
|
+
)
|
|
225
|
+
dl.set_defaults(func=_run_download)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _stub_graph_subcommands(subparsers: argparse._SubParsersAction) -> None:
|
|
229
|
+
def _missing(_args: argparse.Namespace) -> None:
|
|
230
|
+
print(
|
|
231
|
+
"Error: the 'load'/'seed' commands are not available yet — "
|
|
232
|
+
"the dvm_eahelper.graph package has not been implemented.",
|
|
233
|
+
file=sys.stderr,
|
|
234
|
+
)
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
for name, help_text in (
|
|
238
|
+
("load", "Load downloaded data into the graph database (unavailable)"),
|
|
239
|
+
("seed", "Seed the graph database (unavailable)"),
|
|
240
|
+
):
|
|
241
|
+
p = subparsers.add_parser(name, help=help_text)
|
|
242
|
+
p.set_defaults(func=_missing)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
246
|
+
parser = argparse.ArgumentParser(
|
|
247
|
+
prog="eahelper",
|
|
248
|
+
description="LeanIX enterprise-architecture helper: GraphQL proxy, factsheet download, and graph loading",
|
|
249
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
250
|
+
epilog=__doc__,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
from importlib.metadata import PackageNotFoundError
|
|
254
|
+
from importlib.metadata import version as _pkg_version
|
|
255
|
+
try:
|
|
256
|
+
_version = _pkg_version("dvm-eahelper")
|
|
257
|
+
except PackageNotFoundError:
|
|
258
|
+
_version = "dev"
|
|
259
|
+
|
|
260
|
+
parser.add_argument(
|
|
261
|
+
"--version", "-V",
|
|
262
|
+
action="version",
|
|
263
|
+
version=f"%(prog)s {_version}",
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument(
|
|
266
|
+
"--help-library",
|
|
267
|
+
action="store_true",
|
|
268
|
+
default=False,
|
|
269
|
+
help="Print Markdown documentation for using dvm_eahelper.leanix as a Python library and exit",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
273
|
+
|
|
274
|
+
_add_proxy_subcommand(subparsers)
|
|
275
|
+
_add_diagnose_subcommand(subparsers)
|
|
276
|
+
_add_download_subcommand(subparsers)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
from dvm_eahelper.graph.cli import register_subcommands
|
|
280
|
+
register_subcommands(subparsers)
|
|
281
|
+
except ImportError:
|
|
282
|
+
_stub_graph_subcommands(subparsers)
|
|
283
|
+
|
|
284
|
+
return parser
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
288
|
+
return build_parser().parse_args(argv)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _resolve_ssl(args: argparse.Namespace) -> bool | str | ssl.SSLContext:
|
|
292
|
+
"""
|
|
293
|
+
Determine the ssl_verify value to pass to build_app.
|
|
294
|
+
|
|
295
|
+
Priority:
|
|
296
|
+
1. --no-verify-ssl → False (skip all verification)
|
|
297
|
+
2. --legacy-ssl → SSLContext (relaxed X.509 strict mode)
|
|
298
|
+
3. --ca-bundle PATH → str (custom CA file, optionally + legacy)
|
|
299
|
+
4. default → True or SSLContext (system/certifi bundle)
|
|
300
|
+
"""
|
|
301
|
+
import ssl as _ssl
|
|
302
|
+
|
|
303
|
+
if args.no_verify_ssl:
|
|
304
|
+
print(" SSL verify : DISABLED (--no-verify-ssl)")
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
legacy = getattr(args, "legacy_ssl", False)
|
|
308
|
+
|
|
309
|
+
def _make_ctx(ca_file: str | None = None) -> _ssl.SSLContext:
|
|
310
|
+
ctx = _ssl.create_default_context()
|
|
311
|
+
if ca_file:
|
|
312
|
+
ctx.load_verify_locations(cafile=ca_file)
|
|
313
|
+
try:
|
|
314
|
+
ctx.verify_flags &= ~_ssl.VERIFY_X509_STRICT
|
|
315
|
+
except AttributeError:
|
|
316
|
+
pass
|
|
317
|
+
return ctx
|
|
318
|
+
|
|
319
|
+
if args.ca_bundle:
|
|
320
|
+
path = Path(args.ca_bundle)
|
|
321
|
+
if not path.is_file():
|
|
322
|
+
print(f"Error: --ca-bundle path not found: {path}", file=sys.stderr)
|
|
323
|
+
sys.exit(1)
|
|
324
|
+
if legacy:
|
|
325
|
+
print(f" SSL verify : custom CA bundle + legacy mode ({path})")
|
|
326
|
+
return _make_ctx(str(path))
|
|
327
|
+
print(f" SSL verify : custom CA bundle ({path})")
|
|
328
|
+
return str(path)
|
|
329
|
+
|
|
330
|
+
if legacy:
|
|
331
|
+
print(" SSL verify : legacy mode (relaxed X.509 strict checking)")
|
|
332
|
+
return _make_ctx()
|
|
333
|
+
|
|
334
|
+
# Check env var as a convenience (mirrors requests/httpx convention)
|
|
335
|
+
env_bundle = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE")
|
|
336
|
+
if env_bundle and Path(env_bundle).is_file():
|
|
337
|
+
print(f" SSL verify : CA bundle from env ({env_bundle})")
|
|
338
|
+
return env_bundle
|
|
339
|
+
|
|
340
|
+
print(" SSL verify : system CA bundle")
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _extract_from_browser(leanix_url: str, cdp_url: str) -> str:
|
|
345
|
+
"""Extract Bearer token from browser; print guidance and exit on failure."""
|
|
346
|
+
print(f" CDP endpoint : {cdp_url}")
|
|
347
|
+
print(
|
|
348
|
+
"\nConnecting to browser to extract Bearer token…\n"
|
|
349
|
+
" Make sure Chrome/Edge is running with:\n"
|
|
350
|
+
" chrome.exe --remote-debugging-port=9222 --user-data-dir=C:\\Temp\\chrome-debug\n"
|
|
351
|
+
" and that you are already logged in to LeanIX.\n"
|
|
352
|
+
)
|
|
353
|
+
from dvm_eahelper.proxy.token import get_token_sync
|
|
354
|
+
try:
|
|
355
|
+
return get_token_sync(leanix_url, cdp_url)
|
|
356
|
+
except RuntimeError as exc:
|
|
357
|
+
print(f"\nError: {exc}", file=sys.stderr)
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _run_diagnose(args: argparse.Namespace) -> None:
|
|
362
|
+
url = args.url or DEFAULT_URL
|
|
363
|
+
from dvm_eahelper.proxy.diagnose import run_diagnostics
|
|
364
|
+
run_diagnostics(url.rstrip("/"), ca_bundle=args.ca_bundle)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _run_download(args: argparse.Namespace) -> None:
|
|
368
|
+
ssl_verify = _resolve_ssl(args)
|
|
369
|
+
limit = getattr(args, "limit", None)
|
|
370
|
+
if getattr(args, "relations", False) or getattr(args, "list_relations", False):
|
|
371
|
+
from dvm_eahelper.leanix.download import run_download_relations
|
|
372
|
+
run_download_relations(
|
|
373
|
+
proxy_url=args.proxy,
|
|
374
|
+
type_name=args.fs_type,
|
|
375
|
+
output_path=args.output,
|
|
376
|
+
list_relations=getattr(args, "list_relations", False),
|
|
377
|
+
ssl_verify=ssl_verify,
|
|
378
|
+
limit=limit,
|
|
379
|
+
)
|
|
380
|
+
else:
|
|
381
|
+
from dvm_eahelper.leanix.download import run_download
|
|
382
|
+
run_download(
|
|
383
|
+
proxy_url=args.proxy,
|
|
384
|
+
type_name=args.fs_type,
|
|
385
|
+
subtypes=args.subtypes,
|
|
386
|
+
output_path=args.output,
|
|
387
|
+
fmt=args.fmt,
|
|
388
|
+
list_subtypes=args.list_subtypes,
|
|
389
|
+
list_types=args.list_types,
|
|
390
|
+
ssl_verify=ssl_verify,
|
|
391
|
+
limit=limit,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _run_proxy(args: argparse.Namespace) -> None:
|
|
396
|
+
import uvicorn
|
|
397
|
+
|
|
398
|
+
# ------------------------------------------------------------------ #
|
|
399
|
+
# Resolve LeanIX URL #
|
|
400
|
+
# ------------------------------------------------------------------ #
|
|
401
|
+
leanix_url = args.url
|
|
402
|
+
if not leanix_url:
|
|
403
|
+
leanix_url = _prompt("LeanIX workspace URL", DEFAULT_URL)
|
|
404
|
+
leanix_url = leanix_url.rstrip("/")
|
|
405
|
+
|
|
406
|
+
print(f"\n LeanIX workspace : {leanix_url}")
|
|
407
|
+
|
|
408
|
+
# ------------------------------------------------------------------ #
|
|
409
|
+
# Resolve SSL verification #
|
|
410
|
+
# ------------------------------------------------------------------ #
|
|
411
|
+
ssl_verify = _resolve_ssl(args)
|
|
412
|
+
|
|
413
|
+
# ------------------------------------------------------------------ #
|
|
414
|
+
# Obtain Bearer token #
|
|
415
|
+
# ------------------------------------------------------------------ #
|
|
416
|
+
from dvm_eahelper.proxy.persistence import load_token, save_token
|
|
417
|
+
|
|
418
|
+
token: str | None = None
|
|
419
|
+
|
|
420
|
+
# Resolve API key: CLI flag takes priority, then env var
|
|
421
|
+
api_key: str | None = getattr(args, "api_key", None) or os.environ.get("LEANIX_API_TOKEN")
|
|
422
|
+
|
|
423
|
+
if args.token:
|
|
424
|
+
# Explicit Bearer token provided via CLI — use it directly
|
|
425
|
+
token = args.token
|
|
426
|
+
print(" Token : provided via --token flag")
|
|
427
|
+
|
|
428
|
+
elif api_key:
|
|
429
|
+
# Technical User API key — exchange for a Bearer token via OAuth2
|
|
430
|
+
print(" Token source : Technical User API key (OAuth2)")
|
|
431
|
+
from dvm_eahelper.proxy.token import get_token_from_api_key
|
|
432
|
+
try:
|
|
433
|
+
ssl_for_oauth: bool | str = (
|
|
434
|
+
False if ssl_verify is False
|
|
435
|
+
else ssl_verify if isinstance(ssl_verify, str)
|
|
436
|
+
else True
|
|
437
|
+
)
|
|
438
|
+
token = get_token_from_api_key(api_key, leanix_url, ssl_for_oauth)
|
|
439
|
+
print(" Token : obtained via OAuth2 client-credentials")
|
|
440
|
+
except RuntimeError as exc:
|
|
441
|
+
print(f"\nError: {exc}", file=sys.stderr)
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
|
|
444
|
+
else:
|
|
445
|
+
# Try loading a previously saved token
|
|
446
|
+
saved = load_token(leanix_url)
|
|
447
|
+
if saved:
|
|
448
|
+
print(" Token : loaded from ~/.eahelper/tokens.json")
|
|
449
|
+
token = saved
|
|
450
|
+
else:
|
|
451
|
+
# No saved token — extract from browser
|
|
452
|
+
token = _extract_from_browser(leanix_url, args.cdp_url)
|
|
453
|
+
|
|
454
|
+
# Persist (unless --no-save or --token was used, where we still save)
|
|
455
|
+
if not args.no_save:
|
|
456
|
+
save_token(leanix_url, token)
|
|
457
|
+
|
|
458
|
+
# ------------------------------------------------------------------ #
|
|
459
|
+
# Build and start server #
|
|
460
|
+
# ------------------------------------------------------------------ #
|
|
461
|
+
from dvm_eahelper.proxy.server import build_app
|
|
462
|
+
|
|
463
|
+
app = build_app(leanix_url, token, cdp_url=args.cdp_url, ssl_verify=ssl_verify, api_key=api_key)
|
|
464
|
+
|
|
465
|
+
host = "127.0.0.1"
|
|
466
|
+
refresh_note = (
|
|
467
|
+
f" Token refresh → POST http://{host}:{args.port}/token/refresh\n"
|
|
468
|
+
)
|
|
469
|
+
print(
|
|
470
|
+
f"\n ✓ Starting LeanIX GraphQL proxy on http://{host}:{args.port}\n"
|
|
471
|
+
f" GraphiQL UI → http://{host}:{args.port}/graphql\n"
|
|
472
|
+
f" API endpoint → POST http://{host}:{args.port}/graphql\n"
|
|
473
|
+
f" Upstream → {leanix_url}/services/pathfinder/v1/graphql\n"
|
|
474
|
+
+ refresh_note +
|
|
475
|
+
"\n LeanIX GraphQL API docs:\n"
|
|
476
|
+
" https://help.sap.com/docs/leanix/ea/graphql-api\n"
|
|
477
|
+
"\nPress Ctrl+C to stop.\n"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
uvicorn.run(app, host=host, port=args.port, log_level="warning")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def main(argv: list[str] | None = None) -> None:
|
|
484
|
+
parser = build_parser()
|
|
485
|
+
args = parser.parse_args(argv)
|
|
486
|
+
|
|
487
|
+
if getattr(args, "help_library", False):
|
|
488
|
+
from dvm_eahelper.leanix._library_help import LIBRARY_HELP
|
|
489
|
+
print(LIBRARY_HELP)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
if getattr(args, "func", None):
|
|
493
|
+
args.func(args)
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
parser.print_help()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
if __name__ == "__main__":
|
|
500
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GraphBackend — abstract interface between the LeanIX loader and a graph database.
|
|
3
|
+
|
|
4
|
+
Both the Neo4j backend (dvm_eagraph.load_leanix, ported from an existing schemaless
|
|
5
|
+
Cypher loader) and the KuzuDB backend (embedded, statically-typed schema) implement
|
|
6
|
+
this interface so that loader.py and seed.py can drive either database identically.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GraphBackend(ABC):
|
|
16
|
+
"""Common operations the LeanIX loader needs from a graph database."""
|
|
17
|
+
|
|
18
|
+
def __enter__(self) -> GraphBackend:
|
|
19
|
+
self.connect()
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def __exit__(
|
|
23
|
+
self,
|
|
24
|
+
exc_type: type[BaseException] | None,
|
|
25
|
+
exc: BaseException | None,
|
|
26
|
+
tb: TracebackType | None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.close()
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def connect(self) -> None:
|
|
32
|
+
"""Open the connection/database. Must be called before any other method."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def close(self) -> None:
|
|
36
|
+
"""Close the connection/database and release resources."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def ensure_schema(
|
|
40
|
+
self,
|
|
41
|
+
node_labels: list[str],
|
|
42
|
+
relationship_types: list[tuple[str, str, str]] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Ensure node/relationship schema exists for *node_labels*.
|
|
45
|
+
|
|
46
|
+
*relationship_types* is an optional list of (rel_type, from_label, to_label)
|
|
47
|
+
triples used by backends (e.g. Kuzu) that require rel tables to be declared
|
|
48
|
+
with explicit endpoint labels before data can be inserted. Backends that do
|
|
49
|
+
not need explicit relationship schema (e.g. Neo4j) may ignore it.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def upsert_nodes(self, label: str, records: list[dict]) -> int:
|
|
54
|
+
"""Batch-upsert *records* as nodes with the given *label*, keyed on ``id``.
|
|
55
|
+
|
|
56
|
+
Records without a truthy ``id`` key are skipped. Returns the number of
|
|
57
|
+
records upserted.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def upsert_relationships(self, rows: list[dict], rel_map: dict[str, str]) -> int:
|
|
62
|
+
"""Batch-upsert relationships described by *rows*.
|
|
63
|
+
|
|
64
|
+
Each row has ``source_id``, ``relation`` (a LeanIX relation field name),
|
|
65
|
+
and ``target_id`` keys. *rel_map* maps LeanIX relation field names to
|
|
66
|
+
graph relationship type names; unmapped relation names fall back to the
|
|
67
|
+
caller's naming convention. Returns the total number of relationships
|
|
68
|
+
upserted.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def query_stats(self) -> dict[str, int]:
|
|
73
|
+
"""Return {label: node_count, ..., '_relationships': relationship_count}."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def run_query(self, query: str, parameters: dict | None = None) -> list[dict]:
|
|
77
|
+
"""Run a backend-native query and return a list of row dicts."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def clear(self) -> None:
|
|
81
|
+
"""Delete all nodes and relationships in the database."""
|