Skip to content

Commit ac92088

Browse files
authored
Fix #21: use Pydantic for validation of env config (#101)
The application now features comprehensive validation for the Environment configuration (env.toml) using Pydantic. This ensures all configuration files strictly adhere to the required schema, enforcing correct data types for fields (e.g., paths, URLs, network addresses) and immediately catching errors like missing keys or incorrect values. This change stabilizes the configuration loading process and eliminates runtime errors caused by misconfigured environments. Signed-off-by: sushant-suse <[email protected]>
1 parent c272dff commit ac92088

16 files changed

Lines changed: 1146 additions & 293 deletions

File tree

changelog.d/101.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The application now features comprehensive validation for the Environment Configuration (`env.toml`) using Pydantic. This ensures all configuration files strictly adhere to the required schema, enforcing correct data types for fields (for example, paths, URLs, network addresses) and immediately catching errors like missing keys or incorrect values. This change stabilizes the configuration loading process and eliminates runtime errors caused by misconfigured environments.

src/docbuild/cli/cmd_cli.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from ..config.app import replace_placeholders
1616
from ..config.load import handle_config
1717
from ..models.config_model.app import AppConfig
18+
from ..models.config_model.env import EnvConfig
19+
1820
from ..constants import (
1921
APP_CONFIG_BASENAMES,
2022
APP_NAME,
@@ -159,12 +161,12 @@ def cli(
159161
logging_config = context.appconfig.logging.model_dump(by_alias=True, exclude_none=True)
160162
setup_logging(cliverbosity=verbose, user_config={'logging': logging_config})
161163

162-
# --- PHASE 2: Load Environment Config and Acquire Lock ---
164+
# --- PHASE 2: Load Environment Config, Validate, and Acquire Lock ---
163165

164-
# Load Environment Config (still returns raw dict)
166+
# 1. Load raw Environment Config
165167
(
166168
context.envconfigfiles,
167-
context.envconfig,
169+
raw_envconfig, # Renaming context.envconfig to raw_envconfig locally
168170
context.envconfig_from_defaults,
169171
) = handle_config(
170172
env_config,
@@ -174,10 +176,23 @@ def cli(
174176
DEFAULT_ENV_CONFIG,
175177
)
176178

177-
env_config_path = context.envconfigfiles[0] if context.envconfigfiles else None
179+
# Explicitly cast the raw_envconfig type to silence Pylance
180+
raw_envconfig = cast(dict[str, Any], raw_envconfig)
178181

179-
# Explicitly cast the context.envconfig type to silence Pylance
180-
context.envconfig = cast(dict[str, Any], context.envconfig)
182+
# 2. VALIDATE the raw environment config dictionary using Pydantic
183+
try:
184+
# Pydantic validation handles placeholder replacement via @model_validator
185+
# The result is the validated Pydantic object, stored in context.envconfig
186+
context.envconfig = EnvConfig.from_dict(raw_envconfig)
187+
except (ValueError, ValidationError) as e:
188+
log.error(
189+
"Environment configuration failed validation: "
190+
"Error in config file(s): %s %s",
191+
context.envconfigfiles, e
192+
)
193+
ctx.exit(1)
194+
195+
env_config_path = context.envconfigfiles[0] if context.envconfigfiles else None
181196

182197
# --- CONCURRENCY CONTROL: Use explicit __enter__ and cleanup registration ---
183198
if env_config_path:
@@ -203,11 +218,6 @@ def cli(
203218
# expected by __exit__, satisfying the click.call_on_close requirement.
204219
ctx.call_on_close(lambda: ctx.obj.env_lock.__exit__(None, None, None))
205220

206-
# Final config processing must happen outside the lock acquisition check
207-
context.envconfig = replace_placeholders(
208-
context.envconfig,
209-
)
210-
211221
# Add subcommand
212222
cli.add_command(build)
213223
cli.add_command(c14n)

src/docbuild/cli/cmd_config/environment.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""CLI interface to showsthe configuration of the environment files."""
22

33
import click
4-
from rich import print # noqa: A004
4+
from rich import print
55
from rich.pretty import Pretty
6+
from rich import print_json
67

78

89
@click.command(
@@ -14,6 +15,15 @@ def env(ctx: click.Context) -> None:
1415
1516
:param ctx: The Click context object.
1617
"""
17-
path = ', '.join(str(path) for path in ctx.obj.envconfigfiles)
18+
19+
# Check if envconfigfiles is None (which it is when the default config is used)
20+
if ctx.obj.envconfigfiles is None:
21+
path = '(Default configuration used)'
22+
else:
23+
path = ', '.join(str(path) for path in ctx.obj.envconfigfiles)
24+
1825
click.secho(f"# ENV Config file '{path}'", fg='blue')
19-
print(Pretty(ctx.obj.envconfig, expand_all=True))
26+
27+
serialized_config = ctx.obj.envconfig.model_dump_json()
28+
29+
print_json(serialized_config)

src/docbuild/cli/defaults.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from ..constants import APP_NAME
1111

12+
1213
DEFAULT_APP_CONFIG = {
1314
'debug': False,
1415
'role': 'production',
@@ -24,16 +25,56 @@
2425
}
2526
"""Default configuration for the application."""
2627

28+
# --- FIXED DEFAULT_ENV_CONFIG ---
2729
DEFAULT_ENV_CONFIG = {
28-
'role': 'production',
30+
# 1. ROOT SECTIONS MUST BE PRESENT AND VALIDATED AGAINST EnvConfig
31+
'server': {
32+
'name': 'default-local-env',
33+
'role': 'production',
34+
'host': '127.0.0.1',
35+
'enable_mail': False,
36+
},
37+
'config': {
38+
'default_lang': 'en-us',
39+
'languages': ['en-us'],
40+
'canonical_url_domain': 'http://localhost/',
41+
},
2942
'paths': {
3043
'config_dir': '/etc/docbuild',
44+
'root_config_dir': '/etc/docbuild',
45+
'jinja_dir': '/etc/docbuild/jinja',
46+
'server_rootfiles_dir': '/etc/docbuild/root-files',
3147
'repo_dir': '/data/docserv/repos/permanent-full/',
3248
'temp_repo_dir': '/data/docserv/repos/temporary-branches/',
49+
'base_cache_dir': '/var/cache/docserv',
50+
'base_server_cache_dir': '/var/cache/docserv/default',
51+
'meta_cache_dir': '/var/cache/docserv/default/meta',
52+
'base_tmp_dir': f'/var/tmp/{APP_NAME}',
53+
54+
'tmp': {
55+
'tmp_base_dir': f'/var/tmp/{APP_NAME}',
56+
'tmp_dir': '{tmp_base_dir}/default-local',
57+
'tmp_deliverable_dir': '{tmp_dir}/deliverable',
58+
'tmp_metadata_dir': '{tmp_dir}/metadata',
59+
'tmp_build_dir': '{tmp_dir}/build/default',
60+
'tmp_out_dir': '{tmp_dir}/out',
61+
'log_dir': '{tmp_dir}/log',
62+
'tmp_deliverable_name': 'default_deliverable',
63+
},
64+
'target': {
65+
'target_dir': 'file:///tmp/docbuild/target',
66+
'backup_dir': '/tmp/docbuild/backup',
67+
}
3368
},
34-
'paths.tmp': {
35-
'tmp_base_dir': f'/var/tmp/{APP_NAME}',
36-
'tmp_dir': '{tmp_base_dir}/doc-example-com',
69+
'build': {
70+
'daps': {
71+
'command': 'daps',
72+
'meta': 'daps metadata',
73+
},
74+
'container': {
75+
'container': 'none',
76+
},
3777
},
78+
'xslt-params': {},
3879
}
39-
"""Default configuration for the environment."""
80+
"""Default configuration for the environment."""

src/docbuild/config/load.py

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,32 +10,7 @@
1010
from .merge import deep_merge
1111

1212

13-
def process_envconfig(envconfigfile: str | Path | None) -> tuple[Path, dict[str, Any]]:
14-
"""Process the env config.
15-
16-
Note: This function now returns the raw dictionary. Validation and
17-
placeholder replacement should be done by the caller using a Pydantic model
18-
(e.g., EnvConfig).
19-
20-
:param envconfigfile: Path to the env TOML config file.
21-
:return: Tuple of the env config file path and the config object (raw dict).
22-
:raise ValueError: If neither envconfigfile nor role is provided.
23-
"""
24-
if envconfigfile:
25-
envconfigfile = Path(envconfigfile)
26-
27-
# If we don't have a envconfigfile, we need to find the default one.
28-
# We will look for the default env config file in the current directory.
29-
elif (rfile := Path(DEFAULT_ENV_CONFIG_FILENAME)).exists():
30-
envconfigfile = rfile
31-
32-
else:
33-
raise ValueError(
34-
'Could not find default ENV configuration file.',
35-
)
36-
37-
rawconfig = load_single_config(envconfigfile)
38-
return envconfigfile, rawconfig
13+
# --- REMOVED THE OBSOLETE `process_envconfig` FUNCTION ---
3914

4015

4116
def load_single_config(configfile: str | Path) -> dict[str, Any]:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Config model package for the docbuild application."""
2+
3+
4+
from .env import EnvConfig
5+
from .app import AppConfig

0 commit comments

Comments
 (0)