11"""Main CLI tool for document operations."""
22
3+ from collections .abc import Sequence
34import logging
45from pathlib import Path
56import sys
67from typing import Any , cast
78
89import click
9- from pydantic import ValidationError
10+ from pydantic import BaseModel , ValidationError
1011import rich .console
1112from rich .traceback import install as install_traceback
1213
2324from ..logging import setup_logging
2425from ..models .config .app import AppConfig
2526from ..models .config .env import EnvConfig
27+ from ..utils .errors import format_pydantic_error
2628from ..utils .pidlock import LockAcquisitionError , PidFileLock
2729from .cmd_build import build
2830from .cmd_c14n import c14n
@@ -46,6 +48,41 @@ def _setup_console() -> None:
4648 install_traceback (console = CONSOLE , show_locals = True )
4749
4850
51+ def handle_validation_error (
52+ e : Exception ,
53+ model_class : type [BaseModel ],
54+ config_files : Sequence [Path ] | None ,
55+ verbose : int ,
56+ ctx : click .Context ,
57+ ) -> None :
58+ """Format validation errors and exit the CLI.
59+
60+ Outsourced logic to avoid code duplication between App and Env config phases.
61+ Using Sequence[Path] ensures compatibility with both lists and tuples.
62+ :param e: The exception that was raised during validation.
63+ :param model_class: The Pydantic model class that was being validated
64+ (AppConfig or EnvConfig).
65+ :param config_files: The list of config files that were attempted to be loaded, used for error context.
66+ :param verbose: The verbosity level from the CLI options, which can be
67+ used to control the level of detail in the error output.
68+ :param ctx: The Click context, used to exit the CLI with an appropriate
69+ status code after handling the error.
70+ """
71+ config_label = "Application" if model_class == AppConfig else "Environment"
72+
73+ if isinstance (e , ValidationError ):
74+ # Safely extract the first config file name for the error header
75+ config_file = str ((config_files or ["unknown" ])[0 ])
76+ format_pydantic_error (
77+ e , model_class , config_file , verbose , console = CONSOLE
78+ )
79+ else :
80+ log .error ("%s configuration failed validation:" , config_label )
81+ log .error ("Error in config file(s): %s" , config_files )
82+ log .error (e )
83+ ctx .exit (1 )
84+
85+
4986@click .group (
5087 name = APP_NAME ,
5188 context_settings = {"show_default" : True , "help_option_names" : ["-h" , "--help" ]},
@@ -113,26 +150,22 @@ def cli(
113150 :param kwargs: Additional keyword arguments.
114151 """
115152 if ctx .invoked_subcommand is None :
116- # If no subcommand is invoked, show the help message
117153 click .echo (10 * "-" )
118154 click .echo (ctx .get_help ())
119155 ctx .exit (0 )
120156
121157 if ctx .obj is None :
122158 ctx .ensure_object (DocBuildContext )
123159
124- # Build the context object
125160 context = ctx .obj
126161 context .verbose = verbose
127162 context .dry_run = dry_run
128163 context .debug = debug
129164
130- # --- PHASE 1: Load and Validate Application Config (and setup logging) ---
131- #
132- # 1. Load the raw application config dictionary
165+ # --- PHASE 1: Load and Validate Application Config ---
133166 (
134167 context .appconfigfiles ,
135- raw_appconfig , # Store config as raw dictionary
168+ raw_appconfig ,
136169 context .appconfig_from_defaults ,
137170 ) = handle_config (
138171 app_config ,
@@ -142,32 +175,23 @@ def cli(
142175 DEFAULT_APP_CONFIG ,
143176 )
144177
145- # Explicitly cast the raw_appconfig type to silence Pylance
146178 raw_appconfig = cast (dict [str , Any ], raw_appconfig )
147179
148- # 2. Validate the raw config dictionary using Pydantic
149180 try :
150- # Pydantic validation also handles placeholder replacement via @model_validator
151181 context .appconfig = AppConfig .from_dict (raw_appconfig )
152182 except (ValueError , ValidationError ) as e :
153- log .error ("Application configuration failed validation:" )
154- log .error ("Error in config file(s): %s" , context .appconfigfiles )
155- log .error (e )
156- ctx .exit (1 )
183+ handle_validation_error (e , AppConfig , context .appconfigfiles , verbose , ctx )
157184
158185 # 3. Setup logging using the validated config object
159- # Use model_dump(by_alias=True) to ensure the 'class' alias is used.
160186 logging_config = context .appconfig .logging .model_dump (
161187 by_alias = True , exclude_none = True
162188 )
163189 setup_logging (cliverbosity = verbose , user_config = {"logging" : logging_config })
164190
165191 # --- PHASE 2: Load Environment Config, Validate, and Acquire Lock ---
166- #
167- # 1. Load raw Environment Config
168192 (
169193 context .envconfigfiles ,
170- raw_envconfig , # Renaming context.envconfig to raw_envconfig locally
194+ raw_envconfig ,
171195 context .envconfig_from_defaults ,
172196 ) = handle_config (
173197 env_config ,
@@ -177,52 +201,32 @@ def cli(
177201 DEFAULT_ENV_CONFIG ,
178202 )
179203
180- # Explicitly cast the raw_envconfig type to silence Pylance
181204 raw_envconfig = cast (dict [str , Any ], raw_envconfig )
182205
183- # 2. VALIDATE the raw environment config dictionary using Pydantic
184206 try :
185- # Pydantic validation handles placeholder replacement via @model_validator
186- # The result is the validated Pydantic object, stored in context.envconfig
187207 context .envconfig = EnvConfig .from_dict (raw_envconfig )
188208 except (ValueError , ValidationError ) as e :
189- log .error (
190- "Environment configuration failed validation: "
191- "Error in config file(s): %s %s" ,
192- context .envconfigfiles ,
193- e ,
194- )
195- ctx .exit (1 )
209+ handle_validation_error (e , EnvConfig , context .envconfigfiles , verbose , ctx )
196210
197- env_config_path = context .envconfigfiles [ 0 ] if context . envconfigfiles else None
211+ env_config_path = ( context .envconfigfiles or [ None ])[ 0 ]
198212
199- # --- CONCURRENCY CONTROL: Use explicit __enter__ and cleanup registration ---
213+ # --- CONCURRENCY CONTROL ---
200214 if env_config_path :
201- # 1. Instantiate the lock object
202- ctx .obj .env_lock = PidFileLock (resource_path = env_config_path )
203-
215+ ctx .obj .env_lock = PidFileLock (resource_path = cast (Path , env_config_path ))
204216 try :
205- # 2. Acquire the lock by explicitly calling the __enter__ method.
206217 ctx .obj .env_lock .__enter__ ()
207218 log .info ("Acquired lock for environment config: %r" , env_config_path .name )
208-
209219 except LockAcquisitionError as e :
210- # Handles lock contention
211220 log .error (str (e ))
212221 ctx .exit (1 )
213222 except Exception as e :
214- # Handles critical errors
215223 log .error ("Failed to set up environment lock: %s" , e )
216224 ctx .exit (1 )
217225
218- # 3. Register the lock's __exit__ method to be called when the context
219- # terminates.
220- # We use a lambda to supply the three mandatory positional arguments (None)
221- # expected by __exit__, satisfying the click.call_on_close requirement.
222226 ctx .call_on_close (lambda : ctx .obj .env_lock .__exit__ (None , None , None ))
223227
224228
225- # Add subcommand
229+ # Add subcommands
226230cli .add_command (build )
227231cli .add_command (c14n )
228232cli .add_command (config )
0 commit comments