Skip to content

Commit 8bdf76e

Browse files
committed
feat(config): introduce profile-based configuration system
1 parent 4552eaf commit 8bdf76e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+5158
-2400
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
2626
- Validation for `run_only` field in component modelers to catch duplicate or invalid matrix indices early with clear error messages.
27+
- Introduced a profile-based configuration manager with TOML persistence and runtime overrides exposed via `tidy3d.config`.
2728

2829
### Changed
2930
- 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: 1699 additions & 1664 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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
assert "Config (profile='default')" in text
28+
assert "├── microwave" in text
29+
assert "'api_endpoint': 'https://tidy3d-api.simulation.cloud'" in text

tests/config/test_loader.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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_loads_legacy_flat_config(mock_config_dir):
19+
legacy_path = mock_config_dir / "config"
20+
legacy_path.parent.mkdir(parents=True, exist_ok=True)
21+
legacy_path.write_text('apikey = "legacy-key"\n', encoding="utf-8")
22+
23+
reload_config(profile="default")
24+
manager = get_manager()
25+
web = manager.get_section("web")
26+
assert web.apikey is not None
27+
assert web.apikey.get_secret_value() == "legacy-key"
28+
29+
30+
def test_save_includes_descriptions(config_manager, mock_config_dir):
31+
manager = config_manager
32+
manager.save(include_defaults=True)
33+
34+
content = _config_path(mock_config_dir).read_text(encoding="utf-8")
35+
assert "# Web/HTTP configuration." in content
36+
37+
38+
def test_preserves_user_comments(config_manager, mock_config_dir):
39+
manager = config_manager
40+
manager.save(include_defaults=True)
41+
42+
config_path = _config_path(mock_config_dir)
43+
text = config_path.read_text(encoding="utf-8")
44+
text = text.replace(
45+
"Web/HTTP configuration.",
46+
"user-modified comment",
47+
)
48+
config_path.write_text(text, encoding="utf-8")
49+
50+
reload_config(profile="default")
51+
manager = get_manager()
52+
manager.save(include_defaults=True)
53+
54+
updated = config_path.read_text(encoding="utf-8")
55+
assert "user-modified comment" in updated
56+
assert "Web/HTTP configuration." not in updated
57+
58+
59+
def test_profile_preserves_comments(config_manager, mock_config_dir):
60+
@config_registry.register_plugin("profile_comment")
61+
class ProfileComment(ConfigSection):
62+
"""Profile comment plugin."""
63+
64+
knob: int = Field(
65+
1,
66+
description="Profile knob description.",
67+
json_schema_extra={"persist": True},
68+
)
69+
70+
try:
71+
manager = config_manager
72+
manager.switch_profile("custom")
73+
manager.update_section("plugins.profile_comment", knob=5)
74+
manager.save()
75+
76+
profile_path = mock_config_dir / "profiles" / "custom.toml"
77+
text = profile_path.read_text(encoding="utf-8")
78+
assert "Profile knob description." in text
79+
text = text.replace("Profile knob description.", "user comment")
80+
profile_path.write_text(text, encoding="utf-8")
81+
82+
manager.update_section("plugins.profile_comment", knob=7)
83+
manager.save()
84+
85+
updated = profile_path.read_text(encoding="utf-8")
86+
assert "user comment" in updated
87+
assert "Profile knob description." not in updated
88+
finally:
89+
config_registry._SECTIONS.pop("plugins.profile_comment", None)
90+
reload_config(profile="default")
91+
92+
93+
def test_cli_reset_config(mock_config_dir):
94+
@config_registry.register_plugin("cli_comment")
95+
class CLIPlugin(ConfigSection):
96+
"""CLI plugin configuration."""
97+
98+
knob: int = Field(
99+
3,
100+
description="CLI knob description.",
101+
json_schema_extra={"persist": True},
102+
)
103+
104+
try:
105+
reload_config(profile="default")
106+
manager = get_manager()
107+
manager.update_section("web", apikey="secret")
108+
manager.save(include_defaults=True)
109+
manager.switch_profile("custom")
110+
manager.update_section("plugins.cli_comment", knob=42)
111+
manager.save()
112+
113+
profiles_dir = mock_config_dir / "profiles"
114+
assert profiles_dir.exists()
115+
116+
runner = CliRunner()
117+
result = runner.invoke(tidy3d_cli, ["config", "reset", "--yes"])
118+
assert result.exit_code == 0, result.output
119+
120+
config_text = _config_path(mock_config_dir).read_text(encoding="utf-8")
121+
assert "Web/HTTP configuration." in config_text
122+
assert "[web]" in config_text
123+
assert "secret" not in config_text
124+
assert not profiles_dir.exists()
125+
finally:
126+
config_registry._SECTIONS.pop("plugins.cli_comment", None)
127+
reload_config(profile="default")
128+
129+
130+
def test_plugin_descriptions(mock_config_dir):
131+
@config_registry.register_plugin("comment_test")
132+
class CommentPlugin(ConfigSection):
133+
"""Comment plugin configuration."""
134+
135+
knob: int = Field(
136+
3,
137+
description="Plugin knob description.",
138+
json_schema_extra={"persist": True},
139+
)
140+
141+
try:
142+
reload_config(profile="default")
143+
manager = get_manager()
144+
manager.save(include_defaults=True)
145+
content = _config_path(mock_config_dir).read_text(encoding="utf-8")
146+
assert "Comment plugin configuration." in content
147+
assert "Plugin knob description." in content
148+
finally:
149+
config_registry._SECTIONS.pop("plugins.comment_test", None)
150+
reload_config(profile="default")

tests/config/test_manager.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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+
assert "Config (profile='default')" in text
105+
assert "├── adjoint" in text
106+
assert "├── logging" in text
107+
assert "└── web" in text
108+
assert "'api_endpoint': 'https://tidy3d-api.simulation.cloud'" in text
109+
assert "'s3_region': 'us-gov-west-1'" in text
110+
111+
112+
def test_section_accessor_str_formatting(config_manager):
113+
text = str(config_manager.adjoint)
114+
assert "adjoint" in text
115+
assert "'gradient_precision': 'single'" in text
116+
assert "'monitor_interval_poly': [" in text
117+
118+
119+
def test_as_dict_includes_defaults(config_manager):
120+
data = config_manager.as_dict()
121+
assert "logging" in data
122+
assert data["logging"]["level"] == "WARNING"
123+
assert "adjoint" in data
124+
assert data["adjoint"]["local_adjoint_dir"] == "adjoint_data"
125+
assert "simulation" in data

0 commit comments

Comments
 (0)