Skip to content

Commit 008752a

Browse files
authored
Merge pull request #295 from callowayproject/allow-remote-config
Added ability to use URLs as a configuration file location
2 parents 074aa12 + 279838a commit 008752a

File tree

9 files changed

+391
-370
lines changed

9 files changed

+391
-370
lines changed

bumpversion/cli.py

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from bumpversion import __version__
1212
from bumpversion.bump import do_bump
13+
from bumpversion.click_config import config_option
1314
from bumpversion.config import get_configuration
1415
from bumpversion.config.create import create_configuration
1516
from bumpversion.config.files import find_config_file
@@ -76,12 +77,9 @@ def cli(ctx: Context) -> None:
7677

7778
@cli.command(context_settings={"ignore_unknown_options": True})
7879
@click.argument("args", nargs=-1, type=str)
79-
@click.option(
80+
@config_option(
8081
"--config-file",
81-
metavar="FILE",
82-
required=False,
8382
envvar="BUMPVERSION_CONFIG_FILE",
84-
type=click.Path(exists=True),
8583
help="Config file to read most of the variables from.",
8684
)
8785
@click.option(
@@ -311,12 +309,9 @@ def bump(
311309

312310
@cli.command()
313311
@click.argument("args", nargs=-1, type=str)
314-
@click.option(
312+
@config_option(
315313
"--config-file",
316-
metavar="FILE",
317-
required=False,
318314
envvar="BUMPVERSION_CONFIG_FILE",
319-
type=click.Path(exists=True),
320315
help="Config file to read most of the variables from.",
321316
)
322317
@click.option(
@@ -371,12 +366,9 @@ def show(
371366

372367
@cli.command()
373368
@click.argument("files", nargs=-1, type=str)
374-
@click.option(
369+
@config_option(
375370
"--config-file",
376-
metavar="FILE",
377-
required=False,
378371
envvar="BUMPVERSION_CONFIG_FILE",
379-
type=click.Path(exists=True),
380372
help="Config file to read most of the variables from.",
381373
)
382374
@click.option(
@@ -575,12 +567,9 @@ def sample_config(prompt: bool, destination: str) -> None:
575567

576568
@cli.command()
577569
@click.argument("version", nargs=1, type=str, required=False, default="")
578-
@click.option(
570+
@config_option(
579571
"--config-file",
580-
metavar="FILE",
581-
required=False,
582572
envvar="BUMPVERSION_CONFIG_FILE",
583-
type=click.Path(exists=True),
584573
help="Config file to read most of the variables from.",
585574
)
586575
@click.option("--ascii", is_flag=True, help="Use ASCII characters only.")

bumpversion/click_config.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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, BumpVersionError
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) -> Optional[Path]:
69+
"""Process the value of the option."""
70+
value = super().process_value(ctx, value)
71+
return resolve_conf_location(value) if value else None
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+
Raises:
112+
BumpVersionError: if the file does not exist.
113+
114+
Returns:
115+
The contents of the location.
116+
"""
117+
parsed_url = urlparse(url_or_path)
118+
119+
if parsed_url.scheme in ("http", "https"):
120+
return download_url(url_or_path)
121+
122+
path = Path(url_or_path)
123+
if not path.exists():
124+
raise BumpVersionError(f"'{path}' does not exist.")
125+
return path
126+
127+
128+
def download_url(url: str) -> Path:
129+
"""
130+
Download the contents of a URL.
131+
132+
Args:
133+
url: The URL to download
134+
135+
Returns:
136+
The Path to the downloaded file.
137+
138+
Raises:
139+
BadInputError: if there is a problem downloading the URL
140+
"""
141+
logger.debug(f"Downloading configuration from URL: {url}")
142+
filename = get_file_name_from_url(url)
143+
suffix = Path(filename).suffix
144+
145+
try:
146+
resp = httpx.get(url, follow_redirects=True, timeout=1)
147+
resp.raise_for_status()
148+
with NamedTemporaryFile(mode="w", delete=False, encoding="utf-8", suffix=suffix) as tmp:
149+
tmp.write(resp.text)
150+
return Path(tmp.name)
151+
except httpx.RequestError as e:
152+
raise BadInputError(f"Unable to download configuration from URL: {url}") from e
153+
except httpx.HTTPStatusError as e:
154+
msg = f"Error response {e.response.status_code} while requesting {url}."
155+
raise BadInputError(msg) from e
156+
157+
158+
def get_file_name_from_url(url: str) -> str:
159+
"""
160+
Extracts the file name from a URL.
161+
162+
Args:
163+
url: The URL to extract the file name from.
164+
165+
Returns:
166+
The file name from the URL, or an empty string if there is no file name.
167+
"""
168+
parsed_url = urlparse(url)
169+
170+
return parsed_url.path.split("/")[-1]

overrides/mkdocstrings/python/material/docstring/attributes.html

Lines changed: 0 additions & 90 deletions
This file was deleted.

0 commit comments

Comments
 (0)