Skip to content

Commit dd49d7d

Browse files
committed
feat(config): introduce profile-based configuration system
1 parent 51161f9 commit dd49d7d

37 files changed

+4864
-2175
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- Added current integral specification classes: `AxisAlignedCurrentIntegralSpec`, `CompositeCurrentIntegralSpec`, and `Custom2DCurrentIntegralSpec`.
2424
- `sort_spec` in `ModeSpec` allows for fine-grained filtering and sorting of modes. This also deprecates `filter_pol`. The equivalent usage for example to `filter_pol="te"` is `sort_spec=ModeSortSpec(filter_key="TE_polarization", filter_reference=0.5)`. `ModeSpec.track_freq` has also been deprecated and moved to `ModeSortSpec.track_freq`.
2525
- Added `custom_source_time` parameter to `ComponentModeler` classes (`ModalComponentModeler` and `TerminalComponentModeler`), allowing specification of custom source time dependence.
26+
- Introduced a profile-based configuration manager with TOML persistence and runtime overrides exposed via `tidy3d.config`.
2627

2728
### Changed
2829
- Improved performance of antenna metrics calculation by utilizing cached wave amplitude calculations instead of recomputing wave amplitudes for each port excitation in the `TerminalComponentModelerData`.

poetry.lock

Lines changed: 1647 additions & 1472 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pydantic = "^2.0"
3737
PyYAML = "*"
3838
dask = "*"
3939
toml = "*"
40+
tomlkit = "^0.13.2"
4041
autograd = ">=1.7.0"
4142
scipy = "*"
4243
### NOT CORE

tests/config/conftest.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Shared fixtures for the configuration test suite."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
7+
import pytest
8+
9+
from tidy3d.config.__init__ import get_manager, reload_config
10+
11+
_ENV_VARS_TO_CLEAR = {
12+
"TIDY3D_PROFILE",
13+
"TIDY3D_CONFIG_PROFILE",
14+
"TIDY3D_ENV",
15+
"SIMCLOUD_APIKEY",
16+
"TIDY3D_AUTH__APIKEY",
17+
"TIDY3D_WEB__APIKEY",
18+
"TIDY3D_BASE_DIR",
19+
}
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def clean_env(monkeypatch):
24+
"""Ensure configuration-related env vars do not leak between tests."""
25+
26+
original: dict[str, str | None] = {var: os.environ.get(var) for var in _ENV_VARS_TO_CLEAR}
27+
for var in _ENV_VARS_TO_CLEAR:
28+
monkeypatch.delenv(var, raising=False)
29+
try:
30+
yield
31+
finally:
32+
for var, value in original.items():
33+
if value is None:
34+
monkeypatch.delenv(var, raising=False)
35+
else:
36+
monkeypatch.setenv(var, value)
37+
38+
39+
@pytest.fixture
40+
def mock_config_dir(tmp_path, monkeypatch):
41+
"""Point the config system at a temporary directory."""
42+
43+
base_dir = tmp_path / "config_home"
44+
monkeypatch.setenv("TIDY3D_BASE_DIR", str(base_dir))
45+
return base_dir / ".tidy3d"
46+
47+
48+
@pytest.fixture
49+
def config_manager(mock_config_dir):
50+
"""Return a freshly initialized configuration manager."""
51+
52+
from tidy3d.config import config as config_wrapper
53+
54+
reload_config(profile="default")
55+
config_wrapper.switch_profile("default")
56+
manager = get_manager()
57+
return manager

tests/config/test_legacy.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
5+
from tidy3d.config.__init__ import get_manager, reload_config
6+
7+
8+
def test_legacy_logging_level(config_manager):
9+
cfg = reload_config(profile=config_manager.profile)
10+
cfg.logging_level = "DEBUG"
11+
manager = get_manager()
12+
assert manager.get_section("logging").level == "DEBUG"
13+
14+
15+
def test_env_switch(config_manager):
16+
config_module = importlib.import_module("tidy3d.config.__init__")
17+
config_module.Env.dev.active()
18+
assert get_manager().profile == "dev"
19+
config_module.Env.set_current(config_module.Env.prod)
20+
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+
lines = text.splitlines()
28+
assert lines
29+
assert lines[0] == "Config(profile='default')"
30+
assert "microwave:" in text
31+
assert '"https://tidy3d-api.simulation.cloud"' in text

tests/config/test_loader.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from click.testing import CliRunner
6+
from pydantic import Field
7+
8+
from tidy3d.config import get_manager, reload_config
9+
from tidy3d.config import registry as config_registry
10+
from tidy3d.config.sections import ConfigSection
11+
from tidy3d.web.cli.app import tidy3d_cli
12+
13+
14+
def _config_path(config_dir: Path) -> Path:
15+
return config_dir / "config.toml"
16+
17+
18+
def test_save_includes_descriptions(config_manager, mock_config_dir):
19+
manager = config_manager
20+
manager.save(include_defaults=True)
21+
22+
content = _config_path(mock_config_dir).read_text(encoding="utf-8")
23+
assert "# Web/HTTP configuration." in content
24+
25+
26+
def test_preserves_user_comments(config_manager, mock_config_dir):
27+
manager = config_manager
28+
manager.save(include_defaults=True)
29+
30+
config_path = _config_path(mock_config_dir)
31+
text = config_path.read_text(encoding="utf-8")
32+
text = text.replace(
33+
"Web/HTTP configuration.",
34+
"user-modified comment",
35+
)
36+
config_path.write_text(text, encoding="utf-8")
37+
38+
reload_config(profile="default")
39+
manager = get_manager()
40+
manager.save(include_defaults=True)
41+
42+
updated = config_path.read_text(encoding="utf-8")
43+
assert "user-modified comment" in updated
44+
assert "Web/HTTP configuration." not in updated
45+
46+
47+
def test_profile_preserves_comments(config_manager, mock_config_dir):
48+
@config_registry.register_plugin("profile_comment")
49+
class ProfileComment(ConfigSection):
50+
"""Profile comment plugin."""
51+
52+
knob: int = Field(
53+
1,
54+
description="Profile knob description.",
55+
json_schema_extra={"persist": True},
56+
)
57+
58+
try:
59+
manager = config_manager
60+
manager.switch_profile("custom")
61+
manager.update_section("plugins.profile_comment", knob=5)
62+
manager.save()
63+
64+
profile_path = mock_config_dir / "profiles" / "custom.toml"
65+
text = profile_path.read_text(encoding="utf-8")
66+
assert "Profile knob description." in text
67+
text = text.replace("Profile knob description.", "user comment")
68+
profile_path.write_text(text, encoding="utf-8")
69+
70+
manager.update_section("plugins.profile_comment", knob=7)
71+
manager.save()
72+
73+
updated = profile_path.read_text(encoding="utf-8")
74+
assert "user comment" in updated
75+
assert "Profile knob description." not in updated
76+
finally:
77+
config_registry._SECTIONS.pop("plugins.profile_comment", None)
78+
reload_config(profile="default")
79+
80+
81+
def test_cli_reset_config(mock_config_dir):
82+
@config_registry.register_plugin("cli_comment")
83+
class CLIPlugin(ConfigSection):
84+
"""CLI plugin configuration."""
85+
86+
knob: int = Field(
87+
3,
88+
description="CLI knob description.",
89+
json_schema_extra={"persist": True},
90+
)
91+
92+
try:
93+
reload_config(profile="default")
94+
manager = get_manager()
95+
manager.update_section("web", apikey="secret")
96+
manager.save(include_defaults=True)
97+
manager.switch_profile("custom")
98+
manager.update_section("plugins.cli_comment", knob=42)
99+
manager.save()
100+
101+
profiles_dir = mock_config_dir / "profiles"
102+
assert profiles_dir.exists()
103+
104+
runner = CliRunner()
105+
result = runner.invoke(tidy3d_cli, ["config", "reset", "--yes"])
106+
assert result.exit_code == 0, result.output
107+
108+
config_text = _config_path(mock_config_dir).read_text(encoding="utf-8")
109+
assert "Web/HTTP configuration." in config_text
110+
assert "[web]" in config_text
111+
assert "secret" not in config_text
112+
assert not profiles_dir.exists()
113+
finally:
114+
config_registry._SECTIONS.pop("plugins.cli_comment", None)
115+
reload_config(profile="default")
116+
117+
118+
def test_plugin_descriptions(mock_config_dir):
119+
@config_registry.register_plugin("comment_test")
120+
class CommentPlugin(ConfigSection):
121+
"""Comment plugin configuration."""
122+
123+
knob: int = Field(
124+
3,
125+
description="Plugin knob description.",
126+
json_schema_extra={"persist": True},
127+
)
128+
129+
try:
130+
reload_config(profile="default")
131+
manager = get_manager()
132+
manager.save(include_defaults=True)
133+
content = _config_path(mock_config_dir).read_text(encoding="utf-8")
134+
assert "Comment plugin configuration." in content
135+
assert "Plugin knob description." in content
136+
finally:
137+
config_registry._SECTIONS.pop("plugins.comment_test", None)
138+
reload_config(profile="default")

tests/config/test_manager.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
import pytest
5+
6+
from tidy3d.config import Env, get_manager, reload_config
7+
8+
9+
def test_default_web_settings(config_manager):
10+
web = config_manager.get_section("web")
11+
assert str(web.api_endpoint) == "https://tidy3d-api.simulation.cloud"
12+
assert str(web.website_endpoint) == "https://tidy3d.simulation.cloud"
13+
assert web.ssl_verify is True
14+
15+
16+
def test_update_section_runtime_overlay(config_manager):
17+
config_manager.update_section("logging", level="DEBUG", suppression=False)
18+
logging_section = config_manager.get_section("logging")
19+
assert logging_section.level == "DEBUG"
20+
assert logging_section.suppression is False
21+
22+
23+
def test_runtime_isolated_per_profile(config_manager):
24+
config_manager.update_section("web", timeout=45)
25+
config_manager.switch_profile("customer")
26+
assert config_manager.get_section("web").timeout == 120
27+
config_manager.switch_profile("default")
28+
assert config_manager.get_section("web").timeout == 45
29+
30+
31+
def test_environment_variable_precedence(monkeypatch, config_manager):
32+
monkeypatch.setenv("TIDY3D_LOGGING__LEVEL", "WARNING")
33+
config_manager.switch_profile(config_manager.profile)
34+
config_manager.update_section("logging", level="DEBUG")
35+
logging_section = config_manager.get_section("logging")
36+
# env var should still take precedence
37+
assert logging_section.level == "WARNING"
38+
39+
40+
@pytest.mark.parametrize("profile", ["dev", "uat"])
41+
def test_builtin_profiles(profile, config_manager):
42+
config_manager.switch_profile(profile)
43+
web = config_manager.get_section("web")
44+
assert web.s3_region is not None
45+
46+
47+
def test_uppercase_profile_normalization(monkeypatch):
48+
monkeypatch.setenv("TIDY3D_ENV", "DEV")
49+
try:
50+
reload_config()
51+
manager = get_manager()
52+
assert manager.profile == "dev"
53+
web = manager.get_section("web")
54+
assert str(web.api_endpoint) == "https://tidy3d-api.dev-simulation.cloud"
55+
assert Env.current.name == "dev"
56+
finally:
57+
reload_config(profile="default")
58+
59+
60+
def test_adjoint_defaults(config_manager):
61+
adjoint = config_manager.get_section("adjoint")
62+
assert adjoint.min_wvl_fraction == pytest.approx(5e-2)
63+
assert adjoint.points_per_wavelength == 10
64+
assert adjoint.monitor_interval_poly == (1, 1, 1)
65+
assert adjoint.quadrature_sample_fraction == pytest.approx(0.4)
66+
assert adjoint.gauss_quadrature_order == 7
67+
assert adjoint.edge_clip_tolerance == pytest.approx(1e-9)
68+
assert adjoint.minimum_spacing_fraction == pytest.approx(1e-2)
69+
assert adjoint.gradient_precision == "single"
70+
assert adjoint.max_traced_structures == 500
71+
assert adjoint.max_adjoint_per_fwd == 10
72+
73+
74+
def test_adjoint_update_section(config_manager):
75+
config_manager.update_section(
76+
"adjoint",
77+
min_wvl_fraction=0.08,
78+
points_per_wavelength=12,
79+
solver_freq_chunk_size=3,
80+
gradient_precision="double",
81+
minimum_spacing_fraction=0.02,
82+
gauss_quadrature_order=5,
83+
edge_clip_tolerance=2e-9,
84+
max_traced_structures=600,
85+
max_adjoint_per_fwd=7,
86+
)
87+
adjoint = config_manager.get_section("adjoint")
88+
assert adjoint.min_wvl_fraction == pytest.approx(0.08)
89+
assert adjoint.points_per_wavelength == 12
90+
assert adjoint.solver_freq_chunk_size == 3
91+
assert adjoint.gauss_quadrature_order == 5
92+
assert adjoint.edge_clip_tolerance == pytest.approx(2e-9)
93+
assert adjoint.minimum_spacing_fraction == pytest.approx(0.02)
94+
assert adjoint.gradient_precision == "double"
95+
assert adjoint.max_traced_structures == 600
96+
assert adjoint.max_adjoint_per_fwd == 7
97+
98+
assert adjoint.gradient_dtype_float is np.float64
99+
assert adjoint.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.adjoint)
114+
lines = text.splitlines()
115+
assert lines[0] == "adjoint:"
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"

0 commit comments

Comments
 (0)