Skip to content

Commit 8862812

Browse files
refactor: extract shared create_session utility
Move HTTP session creation logic into a shared create_session() function in cloudsmith_cli/core/credentials/session.py. This provides a single place for configuring proxy, SSL verification, user-agent, headers, and optional Bearer auth on requests sessions. Add ConfigResolver/ClientConfig (boto3-inspired ConfigChain pattern) for centralised configuration resolution from CLI flags, environment variables, and config.ini profiles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent aab4997 commit 8862812

File tree

6 files changed

+444
-28
lines changed

6 files changed

+444
-28
lines changed

cloudsmith_cli/cli/decorators.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from cloudsmith_cli.cli import validators
88

99
from ..core.api.init import initialise_api as _initialise_api
10+
from ..core.config_resolver import ConfigResolver
1011
from ..core.credentials import CredentialContext, CredentialProviderChain
1112
from ..core.mcp import server
1213
from . import config, utils
@@ -315,16 +316,22 @@ def _set_boolean(name, invert=False):
315316
opts.error_retry_codes = kwargs.pop("error_retry_codes")
316317
opts.error_retry_cb = report_retry
317318

319+
client_config = ConfigResolver().resolve(
320+
api_host=opts.api_host,
321+
proxy=opts.api_proxy,
322+
ssl_verify=opts.api_ssl_verify,
323+
user_agent=opts.api_user_agent,
324+
headers=opts.api_headers,
325+
debug=opts.debug,
326+
profile=ctx.meta.get("profile"),
327+
)
328+
318329
credential_context = CredentialContext(
319-
api_host=opts.api_host or "https://api.cloudsmith.io",
330+
config=client_config,
320331
creds_file_path=ctx.meta.get("creds_file"),
321332
profile=ctx.meta.get("profile"),
322333
debug=opts.debug,
323334
cli_api_key=opts.api_key,
324-
proxy=opts.api_proxy,
325-
ssl_verify=opts.api_ssl_verify if opts.api_ssl_verify is not None else True,
326-
user_agent=opts.api_user_agent,
327-
headers=opts.api_headers,
328335
)
329336

330337
chain = CredentialProviderChain()
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
"""Unified configuration resolution for the Cloudsmith CLI.
2+
3+
Inspired by boto3/botocore's ConfigChain pattern: each configuration
4+
variable is resolved through an ordered list of sources (explicit
5+
override → environment variable → config file → default). The resolved
6+
values are collected into a frozen :class:`ClientConfig` dataclass that
7+
is passed to HTTP sessions, API clients, and credential providers.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
import os
14+
from dataclasses import dataclass
15+
from typing import Any, Callable
16+
17+
from .credentials.session import create_session
18+
19+
logger = logging.getLogger(__name__)
20+
21+
_SENTINEL = object()
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Source providers
26+
# ---------------------------------------------------------------------------
27+
28+
29+
class InstanceProvider:
30+
"""Return an explicit value supplied at call-site (e.g. a CLI flag)."""
31+
32+
def __init__(self, value: Any):
33+
self._value = value
34+
35+
def __call__(self) -> Any:
36+
return self._value
37+
38+
39+
class EnvironmentProvider:
40+
"""Read a value from an environment variable."""
41+
42+
def __init__(self, env_var: str, cast: Callable | None = None):
43+
self._env_var = env_var
44+
self._cast = cast
45+
46+
def __call__(self) -> Any:
47+
raw = os.environ.get(self._env_var, "").strip()
48+
if not raw:
49+
return None
50+
if self._cast:
51+
return self._cast(raw)
52+
return raw
53+
54+
55+
class ConfigFileProvider:
56+
"""Read a value from the CLI config.ini file.
57+
58+
When *profile* is set, reads from ``[default]`` first, then overlays
59+
the value from ``[profile:<name>]`` (if present). This mirrors the
60+
layering behaviour of :meth:`ConfigReader.load_config`.
61+
"""
62+
63+
def __init__(
64+
self,
65+
key: str,
66+
profile: str | None = None,
67+
cast: Callable | None = None,
68+
):
69+
self._key = key
70+
self._profile = profile
71+
self._cast = cast
72+
73+
def __call__(self) -> Any:
74+
try:
75+
from ..cli.config import ConfigReader
76+
77+
raw_config = ConfigReader.read_config()
78+
79+
value = raw_config.get("default", {}).get(self._key)
80+
81+
if self._profile and self._profile != "default":
82+
profile_value = raw_config.get(f"profile:{self._profile}", {}).get(
83+
self._key
84+
)
85+
if profile_value is not None:
86+
value = profile_value
87+
88+
if value is None:
89+
return None
90+
if isinstance(value, str):
91+
value = value.strip()
92+
if not value:
93+
return None
94+
if self._cast:
95+
return self._cast(value)
96+
return value
97+
except Exception: # pylint: disable=broad-exception-caught
98+
logger.debug("Failed to read %s from config file", self._key, exc_info=True)
99+
return None
100+
101+
102+
# ---------------------------------------------------------------------------
103+
# Chain resolver
104+
# ---------------------------------------------------------------------------
105+
106+
107+
class ChainProvider:
108+
"""Resolve a config value from an ordered list of source providers.
109+
110+
Walks the providers in order and returns the first non-``None`` value.
111+
If all providers return ``None``, returns the given *default*.
112+
"""
113+
114+
def __init__(self, providers: list[Callable], default: Any = None):
115+
self._providers = providers
116+
self._default = default
117+
118+
def resolve(self) -> Any:
119+
for provider in self._providers:
120+
value = provider()
121+
if value is not None:
122+
return value
123+
return self._default
124+
125+
126+
# ---------------------------------------------------------------------------
127+
# Cast helpers
128+
# ---------------------------------------------------------------------------
129+
130+
131+
def _cast_bool_truthy(value: str | bool) -> bool:
132+
"""Cast a string to bool where truthy values are ``1/true/yes``."""
133+
if isinstance(value, bool):
134+
return value
135+
return value.lower() in ("1", "true", "yes")
136+
137+
138+
def _cast_bool_falsy_means_disabled(value: str | bool) -> bool:
139+
"""For ``CLOUDSMITH_WITHOUT_API_SSL_VERIFY``: truthy means *disable* SSL."""
140+
return not _cast_bool_truthy(value)
141+
142+
143+
def _cast_bool_ssl_config(value: str | bool) -> bool:
144+
"""For config.ini ``api_ssl_verify``: ``0/false/no`` means disable."""
145+
if isinstance(value, bool):
146+
return value
147+
return value.lower() not in ("0", "false", "no")
148+
149+
150+
def _cast_headers(value: str | dict | None) -> dict | None:
151+
"""Parse ``key=value,key2=value2`` CSV into a dict."""
152+
if value is None:
153+
return None
154+
if isinstance(value, dict):
155+
return value
156+
headers = {}
157+
for pair in value.split(","):
158+
if "=" in pair:
159+
k, v = pair.split("=", 1)
160+
headers[k.strip()] = v.strip()
161+
return headers or None
162+
163+
164+
# ---------------------------------------------------------------------------
165+
# ClientConfig
166+
# ---------------------------------------------------------------------------
167+
168+
169+
@dataclass(frozen=True)
170+
class ClientConfig: # pylint: disable=too-many-instance-attributes
171+
"""Resolved, immutable configuration for Cloudsmith operations.
172+
173+
Created once by :class:`ConfigResolver` and passed to HTTP sessions,
174+
API clients, credential providers, and OIDC detectors.
175+
"""
176+
177+
api_host: str = "https://api.cloudsmith.io"
178+
proxy: str | None = None
179+
ssl_verify: bool = True
180+
user_agent: str | None = None
181+
headers: dict | None = None
182+
debug: bool = False
183+
184+
# Feature flags
185+
no_keyring: bool = False
186+
187+
def create_session(self, api_key: str | None = None, retry=_SENTINEL):
188+
"""Create an HTTP session with this config applied.
189+
190+
Args:
191+
api_key: Optional API key for Bearer auth.
192+
retry: urllib3 Retry configuration. Defaults to the
193+
:data:`DEFAULT_RETRY` policy defined in
194+
:mod:`~cloudsmith_cli.core.credentials.session`.
195+
Pass ``None`` to disable retries.
196+
"""
197+
from .credentials.session import DEFAULT_RETRY
198+
199+
return create_session(
200+
proxy=self.proxy,
201+
ssl_verify=self.ssl_verify,
202+
user_agent=self.user_agent,
203+
headers=self.headers,
204+
api_key=api_key,
205+
retry=DEFAULT_RETRY if retry is _SENTINEL else retry,
206+
)
207+
208+
209+
# ---------------------------------------------------------------------------
210+
# ConfigResolver
211+
# ---------------------------------------------------------------------------
212+
213+
214+
class ConfigResolver:
215+
"""Build a :class:`ClientConfig` by resolving each variable through its chain.
216+
217+
Resolution order per variable: explicit override → env var → config file → default.
218+
219+
Usage::
220+
221+
# CLI entry-point — pass CLI flag values as overrides
222+
config = ConfigResolver().resolve(
223+
api_host=opts.api_host,
224+
proxy=opts.api_proxy,
225+
)
226+
227+
# Credential-helper entry-point — no overrides, resolve from env/config
228+
config = ConfigResolver().resolve()
229+
"""
230+
231+
def resolve(self, **overrides: Any) -> ClientConfig:
232+
"""Resolve all config variables and return a frozen :class:`ClientConfig`.
233+
234+
Args:
235+
**overrides: Explicit values (e.g. from CLI flags). ``None``
236+
values are silently ignored so callers can pass optional
237+
values without filtering. The special key ``profile``
238+
selects a ``[profile:<name>]`` section in config.ini.
239+
"""
240+
profile = self._resolve_profile(overrides)
241+
return ClientConfig(
242+
api_host=self._resolve_api_host(overrides, profile),
243+
proxy=self._resolve_proxy(overrides, profile),
244+
ssl_verify=self._resolve_ssl_verify(overrides, profile),
245+
user_agent=self._resolve_user_agent(overrides),
246+
headers=self._resolve_headers(overrides, profile),
247+
debug=bool(overrides.get("debug", False)),
248+
no_keyring=self._resolve_no_keyring(overrides),
249+
)
250+
251+
# -- individual resolvers ------------------------------------------------
252+
253+
@staticmethod
254+
def _resolve_profile(overrides: dict) -> str | None:
255+
return ChainProvider(
256+
[
257+
InstanceProvider(overrides.get("profile")),
258+
EnvironmentProvider("CLOUDSMITH_PROFILE"),
259+
],
260+
).resolve()
261+
262+
@staticmethod
263+
def _resolve_api_host(overrides: dict, profile: str | None) -> str:
264+
return ChainProvider(
265+
[
266+
InstanceProvider(overrides.get("api_host")),
267+
EnvironmentProvider("CLOUDSMITH_API_HOST"),
268+
ConfigFileProvider("api_host", profile=profile),
269+
],
270+
default="https://api.cloudsmith.io",
271+
).resolve()
272+
273+
@staticmethod
274+
def _resolve_proxy(overrides: dict, profile: str | None) -> str | None:
275+
return ChainProvider(
276+
[
277+
InstanceProvider(overrides.get("proxy")),
278+
EnvironmentProvider("CLOUDSMITH_API_PROXY"),
279+
ConfigFileProvider("api_proxy", profile=profile),
280+
],
281+
).resolve()
282+
283+
@staticmethod
284+
def _resolve_ssl_verify(overrides: dict, profile: str | None) -> bool:
285+
override = overrides.get("ssl_verify")
286+
if override is not None:
287+
return bool(override)
288+
return ChainProvider(
289+
[
290+
EnvironmentProvider(
291+
"CLOUDSMITH_WITHOUT_API_SSL_VERIFY",
292+
cast=_cast_bool_falsy_means_disabled,
293+
),
294+
ConfigFileProvider(
295+
"api_ssl_verify", profile=profile, cast=_cast_bool_ssl_config
296+
),
297+
],
298+
default=True,
299+
).resolve()
300+
301+
@staticmethod
302+
def _resolve_user_agent(overrides: dict) -> str | None:
303+
return ChainProvider(
304+
[
305+
InstanceProvider(overrides.get("user_agent")),
306+
EnvironmentProvider("CLOUDSMITH_API_USER_AGENT"),
307+
],
308+
).resolve()
309+
310+
@staticmethod
311+
def _resolve_headers(overrides: dict, profile: str | None) -> dict | None:
312+
return ChainProvider(
313+
[
314+
InstanceProvider(overrides.get("headers")),
315+
EnvironmentProvider("CLOUDSMITH_API_HEADERS", cast=_cast_headers),
316+
ConfigFileProvider("api_headers", profile=profile, cast=_cast_headers),
317+
],
318+
).resolve()
319+
320+
@staticmethod
321+
def _resolve_no_keyring(overrides: dict) -> bool:
322+
override = overrides.get("no_keyring")
323+
if override is not None:
324+
return bool(override)
325+
return ChainProvider(
326+
[
327+
EnvironmentProvider("CLOUDSMITH_NO_KEYRING", cast=_cast_bool_truthy),
328+
],
329+
default=False,
330+
).resolve()

0 commit comments

Comments
 (0)