Skip to content

Commit 1625248

Browse files
committed
Add ConfigOption for flexible configuration.
Introduce `ConfigOption` and related utilities in `bumpversion.click_config` to handle configuration file paths or URLs. Includes tests for processing options, resolving paths/URLs, and handling errors in `resolve_conf_location` and `download_url`.
1 parent 450154e commit 1625248

File tree

2 files changed

+266
-0
lines changed

2 files changed

+266
-0
lines changed

bumpversion/click_config.py

+164
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""A configuration option for click."""
2+
3+
from pathlib import Path
4+
from tempfile import NamedTemporaryFile
5+
from typing import Any, Callable, Optional, Sequence, Union
6+
from urllib.parse import urlparse
7+
8+
import httpx
9+
from click import Context, Option
10+
from click.decorators import FC, _param_memo # noqa: PLC2701
11+
12+
from bumpversion.exceptions import BadInputError
13+
from bumpversion.ui import get_indented_logger
14+
15+
logger = get_indented_logger(__name__)
16+
17+
BoolOrStr = Union[bool, str]
18+
StrSequence = Sequence[str]
19+
20+
21+
class ConfigOption(Option):
22+
"""A configuration option for click."""
23+
24+
def __init__(
25+
self,
26+
param_decls: Optional[StrSequence] = None,
27+
show_default: Optional[BoolOrStr] = None,
28+
allow_from_autoenv: bool = True,
29+
help: Optional[str] = None,
30+
show_envvar: bool = False,
31+
**attrs,
32+
):
33+
param_decls = param_decls or ("--config", "-C")
34+
multiple = False
35+
count = False
36+
hidden = False
37+
show_choices = True
38+
prompt = False
39+
confirmation_prompt = False
40+
is_flag = None
41+
flag_value = None
42+
prompt_required = True
43+
hide_input = False
44+
type_ = str
45+
meta_var = "PATH_OR_URL"
46+
47+
super().__init__(
48+
param_decls=param_decls,
49+
show_default=show_default,
50+
prompt=prompt,
51+
confirmation_prompt=confirmation_prompt,
52+
prompt_required=prompt_required,
53+
hide_input=hide_input,
54+
is_flag=is_flag,
55+
flag_value=flag_value,
56+
metavar=meta_var,
57+
multiple=multiple,
58+
count=count,
59+
allow_from_autoenv=allow_from_autoenv,
60+
type=type_,
61+
help=help,
62+
hidden=hidden,
63+
show_choices=show_choices,
64+
show_envvar=show_envvar,
65+
**attrs,
66+
)
67+
68+
def process_value(self, ctx: Context, value: Any) -> Path:
69+
"""Process the value of the option."""
70+
value = super().process_value(ctx, value)
71+
return resolve_conf_location(value)
72+
73+
74+
def config_option(*param_decls: str, cls: Optional[type[ConfigOption]] = None, **attrs: Any) -> Callable[[FC], FC]:
75+
"""
76+
Attaches a ConfigOption to the command.
77+
78+
All positional arguments are passed as parameter declarations to `ConfigOption`.
79+
80+
All keyword arguments are forwarded unchanged (except ``cls``). This is equivalent to creating a
81+
`ConfigOption` instance manually and attaching it to the `Command.params` list.
82+
83+
For the default option class, refer to `ConfigOption` and `Parameter` for descriptions of parameters.
84+
85+
Args:
86+
*param_decls: Passed as positional arguments to the constructor of `cls`.
87+
cls: the option class to instantiate. This defaults to `ConfigOption`.
88+
**attrs: Passed as keyword arguments to the constructor of `cls`.
89+
90+
Returns:
91+
A decorated function.
92+
"""
93+
if cls is None: # pragma: no-coverage
94+
cls = ConfigOption
95+
96+
def decorator(f: FC) -> FC:
97+
_param_memo(f, cls(param_decls, **attrs))
98+
return f
99+
100+
return decorator
101+
102+
103+
def resolve_conf_location(url_or_path: str) -> Path:
104+
"""Resolve a URL or path.
105+
106+
The path is considered a URL if it is parseable as such and starts with ``http://`` or ``https://``.
107+
108+
Args:
109+
url_or_path: The URL or path to resolve.
110+
111+
Returns:
112+
The contents of the location.
113+
"""
114+
parsed_url = urlparse(url_or_path)
115+
116+
if parsed_url.scheme in ("http", "https"):
117+
return download_url(url_or_path)
118+
119+
return Path(url_or_path)
120+
121+
122+
def download_url(url: str) -> Path:
123+
"""
124+
Download the contents of a URL.
125+
126+
Args:
127+
url: The URL to download
128+
129+
Returns:
130+
The Path to the downloaded file.
131+
132+
Raises:
133+
BadInputError: if there is a problem downloading the URL
134+
"""
135+
logger.debug(f"Downloading configuration from URL: {url}")
136+
filename = get_file_name_from_url(url)
137+
suffix = Path(filename).suffix
138+
139+
try:
140+
resp = httpx.get(url, follow_redirects=True, timeout=1)
141+
resp.raise_for_status()
142+
with NamedTemporaryFile(mode="w", delete=False, encoding="utf-8", suffix=suffix) as tmp:
143+
tmp.write(resp.text)
144+
return Path(tmp.name)
145+
except httpx.RequestError as e:
146+
raise BadInputError(f"Unable to download configuration from URL: {url}") from e
147+
except httpx.HTTPStatusError as e:
148+
msg = f"Error response {e.response.status_code} while requesting {url}."
149+
raise BadInputError(msg) from e
150+
151+
152+
def get_file_name_from_url(url: str) -> str:
153+
"""
154+
Extracts the file name from a URL.
155+
156+
Args:
157+
url: The URL to extract the file name from.
158+
159+
Returns:
160+
The file name from the URL, or an empty string if there is no file name.
161+
"""
162+
parsed_url = urlparse(url)
163+
164+
return parsed_url.path.split("/")[-1]

tests/test_click_config.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Tests for the bumpversion.click_config module."""
2+
3+
from pathlib import Path
4+
5+
import click
6+
import httpx
7+
import pytest
8+
9+
from bumpversion.click_config import config_option, resolve_conf_location, download_url
10+
from bumpversion.exceptions import BadInputError
11+
12+
13+
@click.command()
14+
@config_option()
15+
def hello(config: Path):
16+
"""Say hello."""
17+
click.echo(f"Hello {config}!")
18+
click.echo(f"path exists: {config.exists()}")
19+
20+
21+
class TestConfigOption:
22+
"""Tests to for config_option."""
23+
24+
def test_config_option(self, runner):
25+
"""Passing an invalid option should raise an error."""
26+
result = runner.invoke(hello, ["--config", "idont/exist.txt"])
27+
28+
assert result.exit_code == 0
29+
assert "path exists: False" in result.output
30+
31+
32+
class TestResolveConfLocation:
33+
"""Tests for resolve_conf_location."""
34+
35+
def test_resolves_file_path(self, fixtures_path: Path):
36+
"""Should return a Path object for a valid local file path."""
37+
dummy_path = fixtures_path / "basic_cfg.toml"
38+
result = resolve_conf_location(str(dummy_path))
39+
assert isinstance(result, Path)
40+
assert result == dummy_path
41+
42+
def test_resolves_valid_url(self, mocker):
43+
"""Should download the file if given a valid URL."""
44+
# Arrange
45+
mocked_download = mocker.patch(
46+
"bumpversion.click_config.download_url", return_value=Path("/tmp/downloaded_config.txt")
47+
)
48+
url = "http://example.com/config.txt"
49+
50+
# Act
51+
result = resolve_conf_location(url)
52+
53+
# Assert
54+
mocked_download.assert_called_once_with(url)
55+
assert isinstance(result, Path)
56+
assert str(result) == "/tmp/downloaded_config.txt"
57+
58+
59+
class TestDownloadUrl:
60+
"""Tests for download_url."""
61+
62+
def test_download_url_success(self, mocker, tmp_path: Path):
63+
"""Should return a Path for a valid request."""
64+
# Arrange
65+
mock_response = mocker.Mock(spec=httpx.Response)
66+
mock_response.status_code = 200
67+
mock_response.text = "content"
68+
mocker.patch("httpx.get", return_value=mock_response)
69+
tmp_file = tmp_path / "tempfile.txt"
70+
mock_tempfile = mocker.patch("bumpversion.click_config.NamedTemporaryFile", autospec=True)
71+
temp_file_instance = mock_tempfile.return_value.__enter__.return_value
72+
temp_file_instance.name = str(tmp_file)
73+
74+
# Act
75+
result = download_url("http://example.com/config.txt")
76+
77+
# Assert
78+
assert isinstance(result, Path)
79+
assert result == tmp_file
80+
mock_tempfile.assert_called_once()
81+
82+
def test_download_url_request_error(self, mocker):
83+
"""Should raise BadInputError for a RequestError."""
84+
# Arrange
85+
mocker.patch("httpx.get", side_effect=httpx.RequestError("Request failed"))
86+
87+
# Act & Assert
88+
with pytest.raises(BadInputError, match="Unable to download configuration from URL"):
89+
download_url("http://example.com/config.txt")
90+
91+
def test_download_url_http_status_error(self, mocker):
92+
"""Should raise BadInputError for an HTTPStatusError."""
93+
mock_response = mocker.Mock(spec=httpx.Response)
94+
mock_response.status_code = 404
95+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
96+
"Error", request=mocker.Mock(), response=mock_response
97+
)
98+
mocker.patch("httpx.get", return_value=mock_response)
99+
100+
# Act & Assert
101+
with pytest.raises(BadInputError, match="Error response 404 while requesting"):
102+
download_url("http://example.com/config.txt")

0 commit comments

Comments
 (0)