Skip to content

Commit 2051c26

Browse files
committed
refactor(service): validate Settings with pydantic
- convert Settings from a frozen dataclass to a frozen pydantic model - add validators for domain, sparql_endpoint, state_dir, cred_filepath - constrain max_results/max_chars to positive integers via Field(gt=0) - drop _int_env in favour of one ValidationError -> RuntimeError site that still names the exact alias that was set Closes #143
1 parent be75e47 commit 2051c26

2 files changed

Lines changed: 178 additions & 25 deletions

File tree

‎src/osw/service/config.py‎

Lines changed: 106 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212

1313
import os
1414
import sys
15-
from dataclasses import dataclass, field
1615
from pathlib import Path
1716
from typing import Optional
1817
from urllib.parse import urlparse
1918

2019
import yaml
20+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
2121

2222
from 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-
100162
def _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

‎tests/test_service_config.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
import pytest
66
import yaml
7+
from pydantic import ValidationError
78

89
from osw.service import config
10+
from osw.service.config import Settings
911

1012
_ALL_VARS = [
1113
"OSW_DOMAIN",
@@ -531,3 +533,73 @@ def test_log_config_sources_omits_cred_file_when_unconfigured(monkeypatch, capsy
531533
captured = capsys.readouterr()
532534
assert "env file" in captured.err
533535
assert "credential file" not in captured.err
536+
537+
538+
# -- Settings validation (pydantic) ------------------------------------------
539+
540+
541+
def test_blank_max_results_falls_back_to_default(monkeypatch):
542+
monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
543+
monkeypatch.setenv("OSW_USERNAME", "alice")
544+
monkeypatch.setenv("OSW_PASSWORD", "secret")
545+
monkeypatch.setenv("OSW_MAX_RESULTS", " ")
546+
settings = config.load()
547+
assert settings.max_results == 100
548+
549+
550+
def test_zero_max_results_raises(monkeypatch):
551+
monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
552+
monkeypatch.setenv("OSW_USERNAME", "alice")
553+
monkeypatch.setenv("OSW_PASSWORD", "secret")
554+
monkeypatch.setenv("OSW_MAX_RESULTS", "0")
555+
with pytest.raises(RuntimeError) as exc:
556+
config.load()
557+
assert "OSW_MAX_RESULTS" in str(exc.value)
558+
559+
560+
def test_malformed_sparql_endpoint_raises(monkeypatch):
561+
monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
562+
monkeypatch.setenv("OSW_USERNAME", "alice")
563+
monkeypatch.setenv("OSW_PASSWORD", "secret")
564+
monkeypatch.setenv("OSW_SPARQL_ENDPOINT", "not a url")
565+
with pytest.raises(RuntimeError) as exc:
566+
config.load()
567+
assert "OSW_SPARQL_ENDPOINT" in str(exc.value)
568+
569+
570+
def test_valid_sparql_endpoint_loads(monkeypatch):
571+
monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
572+
monkeypatch.setenv("OSW_USERNAME", "alice")
573+
monkeypatch.setenv("OSW_PASSWORD", "secret")
574+
monkeypatch.setenv("OSW_SPARQL_ENDPOINT", "https://wiki.example.org/sparql")
575+
settings = config.load()
576+
assert settings.sparql_endpoint == "https://wiki.example.org/sparql"
577+
578+
579+
def test_error_names_the_alias_that_was_set(monkeypatch):
580+
# Only the alias is set (not the canonical name), so the error must name
581+
# the alias, not the canonical variable, for the operator to find it.
582+
monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
583+
monkeypatch.setenv("OSW_USERNAME", "alice")
584+
monkeypatch.setenv("OSW_PASSWORD", "secret")
585+
monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "notanumber")
586+
with pytest.raises(RuntimeError) as exc:
587+
config.load()
588+
assert "OSW_MCP_MAX_RESULTS" in str(exc.value)
589+
590+
591+
def test_domain_with_whitespace_rejected():
592+
with pytest.raises(ValidationError):
593+
Settings(domain="wiki.example.org has a space")
594+
595+
596+
def test_domain_as_full_url_accepted():
597+
# get_active_domain() relies on a full URL being a legal domain value.
598+
settings = Settings(domain="https://wiki.example.org/w/")
599+
assert settings.domain == "https://wiki.example.org/w/"
600+
601+
602+
def test_settings_is_frozen():
603+
settings = Settings(domain="wiki.example.org")
604+
with pytest.raises(ValidationError):
605+
settings.domain = "other.example.org"

0 commit comments

Comments
 (0)