diff --git a/src/docbuild/cli/defaults.py b/src/docbuild/cli/defaults.py index e37e003f..e849c876 100644 --- a/src/docbuild/cli/defaults.py +++ b/src/docbuild/cli/defaults.py @@ -12,6 +12,7 @@ DEFAULT_APP_CONFIG = { "debug": False, "role": "production", + "max_workers": "half", "paths": { "config_dir": "/etc/docbuild", "repo_dir": "/data/docserv/repos/permanent-full/", diff --git a/src/docbuild/models/config/app.py b/src/docbuild/models/config/app.py index d44f0a96..8025c877 100644 --- a/src/docbuild/models/config/app.py +++ b/src/docbuild/models/config/app.py @@ -2,9 +2,10 @@ from collections.abc import Sequence # Added Sequence for internal use from copy import deepcopy +import os from typing import Any, Literal, Self -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from docbuild.config.app import ( CircularReferenceError, @@ -188,8 +189,46 @@ class AppConfig(BaseModel): description="Configuration for the application's logging system.", ) + # Added max_workers with support for ints and descriptive strings + max_workers: int | str = Field( + default="half", + description="Max concurrent workers. Supports integers or 'all', 'all2'/'half'.", + ) + model_config = ConfigDict(extra="allow") + @field_validator("max_workers") + @classmethod + def _resolve_worker_count(cls, v: int | str) -> int: + """Resolve keywords 'all', 'half', 'all2' into concrete integers.""" + cpu_count = os.cpu_count() or 1 + + keyword_map = { + "all": lambda cpu: cpu, + "half": lambda cpu: max(1, cpu // 2), + "all2": lambda cpu: max(1, cpu // 2), + } + + if isinstance(v, str): + val = v.lower() + if val in keyword_map: + # Return immediately once resolved + return keyword_map[val](cpu_count) + + if val.isdigit(): + v = int(val) + else: + raise ValueError( + f"Invalid max_workers value: '{v}'. " + "Use an integer, 'all', or 'half'/'all2'." + ) + + # At this point, v is guaranteed to be an int + if v < 1: + raise ValueError("max_workers must be at least 1") + + return v + @model_validator(mode="before") @classmethod def _resolve_placeholders(cls, data: dict[str, Any]) -> dict[str, Any] | None: diff --git a/tests/models/test_config_workers.py b/tests/models/test_config_workers.py new file mode 100644 index 00000000..ba7febf3 --- /dev/null +++ b/tests/models/test_config_workers.py @@ -0,0 +1,47 @@ +from unittest.mock import patch + +import pytest + +from docbuild.models.config import app +from docbuild.models.config.app import AppConfig + + +# os.cpu_count() returns None if the count is indeterminate. +@pytest.fixture(params=[1, 2, 8, None]) +def mock_cpu_count(request): + """Mock os.cpu_count in app module to ensure deterministic tests.""" + cpu_count = request.param + with patch.object(app, "os") as mock_os: + mock_os.cpu_count.return_value = cpu_count + yield cpu_count + +# Separate test cases for better clarity +def test_max_workers_resolution_all(mock_cpu_count): + """Verify 'all' keyword resolves to the full CPU count (min 1).""" + expected = mock_cpu_count or 1 + conf = AppConfig(max_workers="all") + assert conf.max_workers == expected + +def test_max_workers_resolution_half(mock_cpu_count): + """Verify 'half' and 'all2' resolve to 50% CPU count (min 1).""" + cpu = mock_cpu_count or 1 + expected_half = max(1, cpu // 2) + + assert AppConfig(max_workers="half").max_workers == expected_half + assert AppConfig(max_workers="all2").max_workers == expected_half + +def test_max_workers_resolution_explicit_values(): + """Verify that specific integers/strings override CPU-based logic. + We don't need the fixture here because these values are + independent of the host hardware. + """ + assert AppConfig(max_workers=4).max_workers == 4 + assert AppConfig(max_workers="8").max_workers == 8 + +def test_max_workers_validation_errors(): + """Verify error handling for invalid inputs.""" + with pytest.raises(ValueError, match="at least 1"): + AppConfig(max_workers=0) + + with pytest.raises(ValueError, match="Invalid max_workers"): + AppConfig(max_workers="infinite")