Skip to content

Commit a4de778

Browse files
add readable config printing
1 parent e68bea1 commit a4de778

File tree

4 files changed

+187
-0
lines changed

4 files changed

+187
-0
lines changed

tests/config/test_legacy.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,11 @@ def test_env_switch(config_manager):
1818
assert get_manager().profile == "dev"
1919
config_module.Env.set_current(config_module.Env.prod)
2020
assert get_manager().profile == "prod"
21+
22+
23+
def test_legacy_wrapper_str(config_manager):
24+
from tidy3d.config import config
25+
26+
text = str(config)
27+
assert text.startswith("Config(profile='default')")
28+
assert '"https://tidy3d-api.simulation.cloud"' in text

tests/config/test_manager.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,24 @@ def test_autograd_update_section(config_manager):
9797

9898
assert autograd.gradient_dtype_float is np.float64
9999
assert autograd.gradient_dtype_complex is np.complex128
100+
101+
102+
def test_config_str_formatting(config_manager):
103+
text = str(config_manager)
104+
lines = text.splitlines()
105+
assert lines[0] == "Config(profile='default')"
106+
assert lines[1] == ""
107+
assert lines[2] == "web:"
108+
assert ' api_endpoint: "https://tidy3d-api.simulation.cloud"' in lines
109+
assert ' s3_region: "us-gov-west-1"' in lines
110+
111+
112+
def test_section_accessor_str_formatting(config_manager):
113+
text = str(config_manager.autograd)
114+
lines = text.splitlines()
115+
assert lines[0] == "autograd:"
116+
assert ' gradient_precision: "single"' in lines
117+
idx = lines.index(" monitor_interval_poly:")
118+
assert lines[idx + 1] == " - 1"
119+
assert lines[idx + 2] == " - 1"
120+
assert lines[idx + 3] == " - 1"

tidy3d/config/legacy.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ def __setattr__(self, name: str, value: Any) -> None:
9696
else:
9797
setattr(self._manager, name, value)
9898

99+
def __str__(self) -> str: # pragma: no cover - presentation helper
100+
return self._manager.format()
101+
99102

100103
class LegacyEnvironmentConfig:
101104
"""Backward compatible environment config wrapper."""

tidy3d/config/manager.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from collections import defaultdict
88
from collections.abc import Iterable
99
from copy import deepcopy
10+
from enum import Enum
1011
from pathlib import Path
1112
from typing import Any, Optional, get_args, get_origin
1213

@@ -63,6 +64,9 @@ def dict(self, *args, **kwargs): # type: ignore[override]
6364
return {}
6465
return model.model_dump(*args, **kwargs)
6566

67+
def __str__(self) -> str: # pragma: no cover - presentation helper
68+
return self._manager.format_section(self._path)
69+
6670

6771
class PluginsAccessor:
6872
"""Provides access to registered plugin configurations."""
@@ -236,6 +240,21 @@ def as_dict(self, include_env: bool = True) -> dict[str, Any]:
236240
return deepcopy(self._effective_tree)
237241
return self._compose_without_env()
238242

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+
239258
# ------------------------------------------------------------------
240259
# Registry callbacks
241260
# ------------------------------------------------------------------
@@ -387,6 +406,9 @@ def __setattr__(self, name: str, value: Any) -> None:
387406
return
388407
object.__setattr__(self, name, value)
389408

409+
def __str__(self) -> str: # pragma: no cover - presentation helper
410+
return self.format()
411+
390412

391413
# ----------------------------------------------------------------------
392414
# Helpers
@@ -429,6 +451,139 @@ def _serialize_value(value: Any) -> Any:
429451
return value
430452

431453

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+
432587
def _model_dict(model: BaseModel) -> dict[str, Any]:
433588
data = model.model_dump(exclude_unset=False)
434589
for key, value in list(data.items()):

0 commit comments

Comments
 (0)