|
7 | 7 | from collections import defaultdict |
8 | 8 | from collections.abc import Iterable |
9 | 9 | from copy import deepcopy |
| 10 | +from enum import Enum |
10 | 11 | from pathlib import Path |
11 | 12 | from typing import Any, Optional, get_args, get_origin |
12 | 13 |
|
@@ -63,6 +64,9 @@ def dict(self, *args, **kwargs): # type: ignore[override] |
63 | 64 | return {} |
64 | 65 | return model.model_dump(*args, **kwargs) |
65 | 66 |
|
| 67 | + def __str__(self) -> str: # pragma: no cover - presentation helper |
| 68 | + return self._manager.format_section(self._path) |
| 69 | + |
66 | 70 |
|
67 | 71 | class PluginsAccessor: |
68 | 72 | """Provides access to registered plugin configurations.""" |
@@ -236,6 +240,21 @@ def as_dict(self, include_env: bool = True) -> dict[str, Any]: |
236 | 240 | return deepcopy(self._effective_tree) |
237 | 241 | return self._compose_without_env() |
238 | 242 |
|
| 243 | + def format(self, *, include_env: bool = True) -> str: |
| 244 | + """Return a human-friendly representation of the full configuration.""" |
| 245 | + |
| 246 | + tree = self.as_dict(include_env=include_env) |
| 247 | + return _render_config_tree(tree, profile=self._profile) |
| 248 | + |
| 249 | + def format_section(self, name: str) -> str: |
| 250 | + """Return a string representation for an individual section.""" |
| 251 | + |
| 252 | + model = self._get_model(name) |
| 253 | + if model is None: |
| 254 | + raise AttributeError(f"Section '{name}' is not available") |
| 255 | + data = model.model_dump(exclude_unset=False) |
| 256 | + return _render_config_tree(data, root=name) |
| 257 | + |
239 | 258 | # ------------------------------------------------------------------ |
240 | 259 | # Registry callbacks |
241 | 260 | # ------------------------------------------------------------------ |
@@ -387,6 +406,9 @@ def __setattr__(self, name: str, value: Any) -> None: |
387 | 406 | return |
388 | 407 | object.__setattr__(self, name, value) |
389 | 408 |
|
| 409 | + def __str__(self) -> str: # pragma: no cover - presentation helper |
| 410 | + return self.format() |
| 411 | + |
390 | 412 |
|
391 | 413 | # ---------------------------------------------------------------------- |
392 | 414 | # Helpers |
@@ -429,6 +451,139 @@ def _serialize_value(value: Any) -> Any: |
429 | 451 | return value |
430 | 452 |
|
431 | 453 |
|
| 454 | +def _render_config_tree( |
| 455 | + data: Any, |
| 456 | + *, |
| 457 | + profile: Optional[str] = None, |
| 458 | + root: Optional[str] = None, |
| 459 | +) -> str: |
| 460 | + processed = _prepare_for_display(data) |
| 461 | + lines: list[str] = [] |
| 462 | + |
| 463 | + if profile is not None and root is None: |
| 464 | + lines.append(f"Config(profile={profile!r})") |
| 465 | + if processed: |
| 466 | + lines.append("") |
| 467 | + |
| 468 | + if root is not None: |
| 469 | + header = f"{root}:" |
| 470 | + if isinstance(processed, dict) and processed: |
| 471 | + lines.append(header) |
| 472 | + lines.extend(_format_mapping(processed, indent=2)) |
| 473 | + elif isinstance(processed, dict): |
| 474 | + lines.append(f"{header} {{}}") |
| 475 | + else: |
| 476 | + inline = _format_inline(processed) |
| 477 | + lines.append(f"{header} {inline}") |
| 478 | + return "\n".join(lines) |
| 479 | + |
| 480 | + if isinstance(processed, dict) and processed: |
| 481 | + lines.extend(_format_mapping(processed, indent=0)) |
| 482 | + elif isinstance(processed, dict): |
| 483 | + lines.append("<empty>") |
| 484 | + else: |
| 485 | + lines.extend(_format_value(processed, indent=0)) |
| 486 | + return "\n".join(lines) |
| 487 | + |
| 488 | + |
| 489 | +def _prepare_for_display(value: Any) -> Any: |
| 490 | + if isinstance(value, BaseModel): |
| 491 | + return { |
| 492 | + k: _prepare_for_display(v) for k, v in value.model_dump(exclude_unset=False).items() |
| 493 | + } |
| 494 | + if isinstance(value, dict): |
| 495 | + return {str(k): _prepare_for_display(v) for k, v in value.items()} |
| 496 | + if isinstance(value, (list, tuple, set)): |
| 497 | + return [_prepare_for_display(v) for v in value] |
| 498 | + if isinstance(value, Path): |
| 499 | + return str(value) |
| 500 | + if isinstance(value, Enum): |
| 501 | + return value.value |
| 502 | + if hasattr(value, "get_secret_value"): |
| 503 | + displayed = getattr(value, "display", None) |
| 504 | + if callable(displayed): |
| 505 | + return displayed() |
| 506 | + return str(value) |
| 507 | + return value |
| 508 | + |
| 509 | + |
| 510 | +def _format_mapping(data: dict[str, Any], *, indent: int) -> list[str]: |
| 511 | + lines: list[str] = [] |
| 512 | + for key in sorted(data.keys()): |
| 513 | + value = data[key] |
| 514 | + prefix = " " * indent + f"{key}:" |
| 515 | + if isinstance(value, dict): |
| 516 | + if value: |
| 517 | + lines.append(prefix) |
| 518 | + lines.extend(_format_mapping(value, indent=indent + 2)) |
| 519 | + else: |
| 520 | + lines.append(f"{prefix} {{}}") |
| 521 | + continue |
| 522 | + if isinstance(value, list): |
| 523 | + if value: |
| 524 | + lines.append(prefix) |
| 525 | + lines.extend(_format_sequence(value, indent=indent + 2)) |
| 526 | + else: |
| 527 | + lines.append(f"{prefix} []") |
| 528 | + continue |
| 529 | + inline = _format_inline(value) |
| 530 | + lines.append(f"{prefix} {inline}") |
| 531 | + return lines |
| 532 | + |
| 533 | + |
| 534 | +def _format_sequence(items: list[Any], *, indent: int) -> list[str]: |
| 535 | + lines: list[str] = [] |
| 536 | + if not items: |
| 537 | + lines.append(" " * indent + "[]") |
| 538 | + return lines |
| 539 | + for item in items: |
| 540 | + bullet = " " * indent + "-" |
| 541 | + if isinstance(item, dict): |
| 542 | + if item: |
| 543 | + lines.append(bullet) |
| 544 | + lines.extend(_format_mapping(item, indent=indent + 2)) |
| 545 | + else: |
| 546 | + lines.append(f"{bullet} {{}}") |
| 547 | + continue |
| 548 | + if isinstance(item, list): |
| 549 | + lines.append(bullet) |
| 550 | + lines.extend(_format_sequence(item, indent=indent + 2)) |
| 551 | + continue |
| 552 | + inline = _format_inline(item) |
| 553 | + lines.append(f"{bullet} {inline}") |
| 554 | + return lines |
| 555 | + |
| 556 | + |
| 557 | +def _format_value(value: Any, indent: int) -> list[str]: |
| 558 | + if isinstance(value, dict): |
| 559 | + return _format_mapping(value, indent=indent) |
| 560 | + if isinstance(value, list): |
| 561 | + return _format_sequence(value, indent=indent) |
| 562 | + return [" " * indent + _format_inline(value)] |
| 563 | + |
| 564 | + |
| 565 | +def _format_inline(value: Any) -> str: |
| 566 | + if isinstance(value, str): |
| 567 | + return _quote_string(value) |
| 568 | + if isinstance(value, bool): |
| 569 | + return "true" if value else "false" |
| 570 | + if value is None: |
| 571 | + return "null" |
| 572 | + if isinstance(value, float): |
| 573 | + return repr(value) |
| 574 | + return str(value) |
| 575 | + |
| 576 | + |
| 577 | +def _quote_string(value: str) -> str: |
| 578 | + if value == "": |
| 579 | + return '""' |
| 580 | + if any(ch in value for ch in '\n\r\t"'): |
| 581 | + import json |
| 582 | + |
| 583 | + return json.dumps(value) |
| 584 | + return f'"{value}"' |
| 585 | + |
| 586 | + |
432 | 587 | def _model_dict(model: BaseModel) -> dict[str, Any]: |
433 | 588 | data = model.model_dump(exclude_unset=False) |
434 | 589 | for key, value in list(data.items()): |
|
0 commit comments