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.
@@ -0,0 +1,4 @@
1
+ try:
2
+ from ._version import __version__
3
+ except ImportError:
4
+ __version__ = "0.0.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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."""