1212
1313import os
1414import sys
15- from dataclasses import dataclass , field
1615from pathlib import Path
1716from typing import Optional
1817from urllib .parse import urlparse
1918
2019import yaml
20+ from pydantic import BaseModel , ConfigDict , Field , ValidationError , field_validator
2121
2222from osw .auth import CredentialManager
2323
@@ -51,10 +51,41 @@ def _first_env(names: tuple[str, ...]) -> Optional[str]:
5151 return None
5252
5353
54- @dataclass (frozen = True )
55- class Settings :
54+ # Which env variable tuple feeds each Settings field, used to name the offending
55+ # variable when pydantic rejects a value.
56+ _ENV_BY_FIELD : dict [str , tuple [str , ...]] = {
57+ "domain" : ENV_DOMAIN ,
58+ "username" : ENV_USERNAME ,
59+ "password" : ENV_PASSWORD ,
60+ "cred_filepath" : ENV_CRED_FILEPATH ,
61+ "sparql_endpoint" : ENV_SPARQL_ENDPOINT ,
62+ "read_only" : ENV_READ_ONLY ,
63+ "state_dir" : ENV_STATE_DIR ,
64+ "max_results" : ENV_MAX_RESULTS ,
65+ "max_chars" : ENV_MAX_CHARS ,
66+ }
67+
68+
69+ def _env_name_for (field_name : str ) -> str :
70+ """Name the env variable that actually supplied ``field_name``.
71+
72+ Falls back to the canonical name so the operator always gets something
73+ actionable to fix.
74+ """
75+ names = _ENV_BY_FIELD .get (field_name , ())
76+ if not names :
77+ return field_name
78+ for name in names :
79+ if os .getenv (name ):
80+ return name
81+ return names [0 ]
82+
83+
84+ class Settings (BaseModel ):
5685 """Resolved, validated server settings."""
5786
87+ model_config = ConfigDict (frozen = True )
88+
5889 # domain is optional: with a usable credential file, no domain need be
5990 # configured via the environment; the active instance is then chosen from
6091 # the credential file (auto-selected, or picked with the CLI's --instance).
@@ -63,13 +94,59 @@ class Settings:
6394 # alternative source of credentials (see ENV_CRED_FILEPATH).
6495 username : Optional [str ] = None
6596 # kept only to build the SPARQL client; never returned by any tool
66- password : Optional [str ] = field (default = None , repr = False )
97+ password : Optional [str ] = Field (default = None , repr = False )
6798 cred_filepath : Optional [str ] = None
6899 sparql_endpoint : Optional [str ] = None
69100 read_only : bool = False
70101 state_dir : Optional [str ] = None
71- max_results : int = 100
72- max_chars : int = 100_000
102+ max_results : int = Field (default = 100 , gt = 0 )
103+ max_chars : int = Field (default = 100_000 , gt = 0 )
104+
105+ @field_validator ("domain" )
106+ @classmethod
107+ def _validate_domain (cls , value : Optional [str ]) -> Optional [str ]:
108+ if value is None :
109+ return value
110+ if not value .strip ():
111+ raise ValueError ("must not be empty or whitespace-only" )
112+ if any (char .isspace () for char in value ):
113+ raise ValueError ("must not contain whitespace" )
114+ if any (ord (char ) < 32 for char in value ):
115+ raise ValueError ("must not contain control characters" )
116+ return value
117+
118+ @field_validator ("sparql_endpoint" )
119+ @classmethod
120+ def _validate_sparql_endpoint (cls , value : Optional [str ]) -> Optional [str ]:
121+ if value is None :
122+ return value
123+ parsed = urlparse (value )
124+ if parsed .scheme not in ("http" , "https" ) or not parsed .netloc :
125+ raise ValueError (
126+ "must be a valid http(s) URL (e.g. 'https://wiki.example.org/sparql')"
127+ )
128+ return value
129+
130+ @field_validator ("state_dir" )
131+ @classmethod
132+ def _validate_state_dir (cls , value : Optional [str ]) -> Optional [str ]:
133+ if value is None :
134+ return value
135+ if not value .strip ():
136+ raise ValueError ("must not be empty or whitespace-only" )
137+ return value
138+
139+ @field_validator ("cred_filepath" )
140+ @classmethod
141+ def _validate_cred_filepath (cls , value : Optional [str ]) -> Optional [str ]:
142+ # Control characters are deliberately not rejected here: load() already
143+ # produces a much better, hint-carrying error for that case via
144+ # _escape_hint(), and that check runs before Settings is constructed.
145+ if value is None :
146+ return value
147+ if not value .strip ():
148+ raise ValueError ("must not be empty or whitespace-only" )
149+ return value
73150
74151 def redacted (self ) -> dict :
75152 """A dict view safe for logging / the status tool (no password)."""
@@ -82,21 +159,6 @@ def redacted(self) -> dict:
82159 }
83160
84161
85- def _int_env (names : tuple [str , ...], default : int ) -> int :
86- raw = _first_env (names )
87- if raw is None or raw .strip () == "" :
88- return default
89- try :
90- return int (raw )
91- except ValueError :
92- # Name the variable that was actually set (not necessarily the
93- # canonical one), so the operator can find what to fix.
94- name = next ((n for n in names if os .getenv (n ) == raw ), names [0 ])
95- raise RuntimeError (
96- f"Environment variable { name } ={ raw !r} is not a valid integer."
97- )
98-
99-
100162def _escape_hint (value : str ) -> str :
101163 """Extra error text when ``value`` holds a control character, else "".
102164
@@ -292,7 +354,9 @@ def load(strict: bool = True) -> Settings:
292354 report "not configured" rather than crash. Every other error still
293355 raises regardless of ``strict``: a configured credential file that
294356 does not exist, a configured credential file with no entry matching a
295- configured domain, an unparseable integer environment variable, and a
357+ configured domain, an environment variable holding a value the
358+ settings model rejects (an unparseable or non-positive integer, a
359+ malformed SPARQL endpoint URL, a domain containing whitespace), and a
296360 missing ``python-dotenv`` for an explicitly configured env file.
297361
298362 Raises
@@ -347,17 +411,34 @@ def load(strict: bool = True) -> Settings:
347411 if cred_file_usable and domain :
348412 _verify_cred_file_has_domain (cred_filepath , domain )
349413
350- return Settings (
414+ kwargs : dict = dict (
351415 domain = domain ,
352416 username = username ,
353417 password = password ,
354418 cred_filepath = cred_filepath ,
355419 sparql_endpoint = _first_env (ENV_SPARQL_ENDPOINT ),
356420 read_only = (_first_env (ENV_READ_ONLY ) or "" ).lower () in _TRUTHY ,
357421 state_dir = _first_env (ENV_STATE_DIR ),
358- max_results = _int_env (ENV_MAX_RESULTS , 100 ),
359- max_chars = _int_env (ENV_MAX_CHARS , 100_000 ),
360422 )
423+ # An unset or blank/whitespace-only integer variable falls back to the
424+ # model default; pass the raw string only when there is one to validate.
425+ max_results_raw = _first_env (ENV_MAX_RESULTS )
426+ if max_results_raw is not None and max_results_raw .strip ():
427+ kwargs ["max_results" ] = max_results_raw
428+ max_chars_raw = _first_env (ENV_MAX_CHARS )
429+ if max_chars_raw is not None and max_chars_raw .strip ():
430+ kwargs ["max_chars" ] = max_chars_raw
431+
432+ try :
433+ return Settings (** kwargs )
434+ except ValidationError as exc :
435+ details = []
436+ for err in exc .errors ():
437+ field_name = str (err ["loc" ][0 ]) if err ["loc" ] else "<config>"
438+ details .append (
439+ f"{ _env_name_for (field_name )} ={ err .get ('input' )!r} : { err ['msg' ]} "
440+ )
441+ raise RuntimeError ("Invalid OSW configuration: " + "; " .join (details )) from exc
361442
362443
363444_settings : Optional [Settings ] = None
0 commit comments