Skip to content

Commit 1d18bcd

Browse files
committed
feat(_internal[settings]) Add TOML-based user settings
why: Allow users to set a default config_style in settings.toml so they don't need to pass --style on every command invocation. what: - Add settings.toml loader using tomllib (3.11+) with tomli fallback - Add VcspullSettings dataclass with config_style field - Add resolve_style() for CLI-flag-overrides-settings priority chain - Add tomli>=1.0 conditional dependency for Python <3.11 - Add 11 tests covering TOML parsing, fallbacks, and resolution
1 parent ba230f9 commit 1d18bcd

4 files changed

Lines changed: 271 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ homepage = "https://vcspull.git-pull.com"
5555
dependencies = [
5656
"libvcs~=0.39.0",
5757
"colorama>=0.3.9",
58-
"PyYAML>=6.0"
58+
"PyYAML>=6.0",
59+
"tomli>=1.0; python_version < '3.11'"
5960
]
6061

6162
[project-urls]

src/vcspull/_internal/settings.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""User settings for vcspull.
2+
3+
Reads optional ``settings.toml`` from the vcspull config directory
4+
(see :func:`vcspull.util.get_config_dir`) and provides typed access to
5+
user preferences.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import dataclasses
11+
import logging
12+
13+
from vcspull.types import ConfigStyle
14+
from vcspull.util import get_config_dir
15+
16+
try:
17+
import tomllib # type: ignore[import-not-found]
18+
except ModuleNotFoundError:
19+
import tomli as tomllib # type: ignore[import-not-found]
20+
21+
log = logging.getLogger(__name__)
22+
23+
SETTINGS_FILENAME = "settings.toml"
24+
25+
26+
@dataclasses.dataclass
27+
class VcspullSettings:
28+
"""Parsed vcspull user settings.
29+
30+
Examples
31+
--------
32+
>>> VcspullSettings()
33+
VcspullSettings(config_style=<ConfigStyle.STANDARD: 'standard'>)
34+
"""
35+
36+
config_style: ConfigStyle = ConfigStyle.STANDARD
37+
38+
39+
def load_settings() -> VcspullSettings:
40+
"""Load settings from the vcspull config directory.
41+
42+
Returns the default settings when no ``settings.toml`` exists or
43+
when parsing fails.
44+
45+
Returns
46+
-------
47+
VcspullSettings
48+
Parsed settings (or defaults on error).
49+
50+
Examples
51+
--------
52+
>>> settings = load_settings()
53+
>>> isinstance(settings, VcspullSettings)
54+
True
55+
"""
56+
config_dir = get_config_dir()
57+
settings_path = config_dir / SETTINGS_FILENAME
58+
59+
if not settings_path.is_file():
60+
return VcspullSettings()
61+
62+
try:
63+
with settings_path.open("rb") as f:
64+
data = tomllib.load(f)
65+
except Exception:
66+
log.warning(
67+
"Failed to parse %s; using default settings",
68+
settings_path,
69+
exc_info=True,
70+
)
71+
return VcspullSettings()
72+
73+
style_value = data.get("config_style")
74+
if style_value is not None:
75+
try:
76+
style = ConfigStyle(style_value)
77+
except ValueError:
78+
log.warning(
79+
"Unknown config_style '%s' in %s; using default 'standard'",
80+
style_value,
81+
settings_path,
82+
)
83+
style = ConfigStyle.STANDARD
84+
else:
85+
style = ConfigStyle.STANDARD
86+
87+
return VcspullSettings(config_style=style)
88+
89+
90+
def resolve_style(
91+
cli_style: str | None,
92+
settings: VcspullSettings | None = None,
93+
) -> ConfigStyle:
94+
"""Resolve the effective config style from CLI flag and user settings.
95+
96+
The CLI ``--style`` flag overrides the ``settings.toml`` value, which
97+
itself overrides the built-in default (``standard``).
98+
99+
Parameters
100+
----------
101+
cli_style : str | None
102+
Value from ``--style`` CLI flag, or ``None`` if not specified.
103+
settings : VcspullSettings | None
104+
Pre-loaded settings; loaded lazily when ``None``.
105+
106+
Returns
107+
-------
108+
ConfigStyle
109+
The resolved config style.
110+
111+
Examples
112+
--------
113+
>>> from vcspull.types import ConfigStyle
114+
>>> resolve_style("concise")
115+
<ConfigStyle.CONCISE: 'concise'>
116+
>>> resolve_style(None, VcspullSettings(config_style=ConfigStyle.VERBOSE))
117+
<ConfigStyle.VERBOSE: 'verbose'>
118+
>>> resolve_style(None) # doctest: +ELLIPSIS
119+
<ConfigStyle.STANDARD: 'standard'>
120+
"""
121+
if cli_style is not None:
122+
try:
123+
return ConfigStyle(cli_style)
124+
except ValueError:
125+
log.warning("Unknown --style '%s'; falling back to 'standard'", cli_style)
126+
return ConfigStyle.STANDARD
127+
128+
if settings is None:
129+
settings = load_settings()
130+
131+
return settings.config_style

tests/_internal/test_settings.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Tests for vcspull._internal.settings."""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
import typing as t
7+
8+
import pytest
9+
10+
from vcspull._internal.settings import (
11+
SETTINGS_FILENAME,
12+
VcspullSettings,
13+
load_settings,
14+
resolve_style,
15+
)
16+
from vcspull.types import ConfigStyle
17+
18+
19+
class SettingsFixture(t.NamedTuple):
20+
"""Fixture for settings loading test cases."""
21+
22+
test_id: str
23+
toml_content: str | None # None = no file
24+
expected_style: ConfigStyle
25+
26+
27+
SETTINGS_FIXTURES: list[SettingsFixture] = [
28+
SettingsFixture(
29+
"no-file-uses-default",
30+
None,
31+
ConfigStyle.STANDARD,
32+
),
33+
SettingsFixture(
34+
"explicit-standard",
35+
'config_style = "standard"\n',
36+
ConfigStyle.STANDARD,
37+
),
38+
SettingsFixture(
39+
"explicit-concise",
40+
'config_style = "concise"\n',
41+
ConfigStyle.CONCISE,
42+
),
43+
SettingsFixture(
44+
"explicit-verbose",
45+
'config_style = "verbose"\n',
46+
ConfigStyle.VERBOSE,
47+
),
48+
SettingsFixture(
49+
"invalid-value-falls-back",
50+
'config_style = "invalid"\n',
51+
ConfigStyle.STANDARD,
52+
),
53+
SettingsFixture(
54+
"empty-file-uses-default",
55+
"",
56+
ConfigStyle.STANDARD,
57+
),
58+
SettingsFixture(
59+
"no-style-key-uses-default",
60+
'other_key = "value"\n',
61+
ConfigStyle.STANDARD,
62+
),
63+
]
64+
65+
66+
@pytest.mark.parametrize(
67+
list(SettingsFixture._fields),
68+
SETTINGS_FIXTURES,
69+
ids=[f.test_id for f in SETTINGS_FIXTURES],
70+
)
71+
def test_load_settings(
72+
test_id: str,
73+
toml_content: str | None,
74+
expected_style: ConfigStyle,
75+
tmp_path: pathlib.Path,
76+
monkeypatch: pytest.MonkeyPatch,
77+
) -> None:
78+
"""load_settings should parse TOML correctly or fall back to defaults."""
79+
del test_id
80+
81+
config_dir = tmp_path / "vcspull"
82+
config_dir.mkdir()
83+
84+
if toml_content is not None:
85+
settings_file = config_dir / SETTINGS_FILENAME
86+
settings_file.write_text(toml_content, encoding="utf-8")
87+
88+
monkeypatch.setattr("vcspull._internal.settings.get_config_dir", lambda: config_dir)
89+
90+
settings = load_settings()
91+
assert settings.config_style == expected_style
92+
93+
94+
def test_load_settings_invalid_toml(
95+
tmp_path: pathlib.Path,
96+
monkeypatch: pytest.MonkeyPatch,
97+
) -> None:
98+
"""Malformed TOML should fall back to defaults without crashing."""
99+
config_dir = tmp_path / "vcspull"
100+
config_dir.mkdir()
101+
settings_file = config_dir / SETTINGS_FILENAME
102+
settings_file.write_text("this is not valid toml [[[", encoding="utf-8")
103+
104+
monkeypatch.setattr("vcspull._internal.settings.get_config_dir", lambda: config_dir)
105+
106+
settings = load_settings()
107+
assert settings.config_style == ConfigStyle.STANDARD
108+
109+
110+
def test_resolve_style_cli_overrides_settings() -> None:
111+
"""CLI --style flag should take precedence over settings."""
112+
settings = VcspullSettings(config_style=ConfigStyle.VERBOSE)
113+
assert resolve_style("concise", settings=settings) == ConfigStyle.CONCISE
114+
115+
116+
def test_resolve_style_uses_settings_when_cli_none() -> None:
117+
"""When no CLI flag, settings value should be used."""
118+
settings = VcspullSettings(config_style=ConfigStyle.VERBOSE)
119+
assert resolve_style(None, settings=settings) == ConfigStyle.VERBOSE
120+
121+
122+
def test_resolve_style_default_when_both_none(
123+
tmp_path: pathlib.Path,
124+
monkeypatch: pytest.MonkeyPatch,
125+
) -> None:
126+
"""When no CLI flag and no settings file, default to standard."""
127+
config_dir = tmp_path / "vcspull"
128+
config_dir.mkdir()
129+
monkeypatch.setattr("vcspull._internal.settings.get_config_dir", lambda: config_dir)
130+
131+
assert resolve_style(None) == ConfigStyle.STANDARD
132+
133+
134+
def test_resolve_style_invalid_cli_value() -> None:
135+
"""Invalid CLI value should fall back to standard."""
136+
assert resolve_style("nonexistent") == ConfigStyle.STANDARD

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)