Skip to content

Commit 5c2b23d

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. Refactor KeyringProvider to use the shared function instead of inline session configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent aab4997 commit 5c2b23d

File tree

5 files changed

+411
-28
lines changed

5 files changed

+411
-28
lines changed

cloudsmith_cli/cli/decorators.py

Lines changed: 11 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,21 @@ 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+
)
327+
318328
credential_context = CredentialContext(
319-
api_host=opts.api_host or "https://api.cloudsmith.io",
329+
config=client_config,
320330
creds_file_path=ctx.meta.get("creds_file"),
321331
profile=ctx.meta.get("profile"),
322332
debug=opts.debug,
323333
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,
328334
)
329335

330336
chain = CredentialProviderChain()
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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+
def __init__(
59+
self, key: str, section: str = "default", cast: Callable | None = None
60+
):
61+
self._key = key
62+
self._section = section
63+
self._cast = cast
64+
65+
def __call__(self) -> Any:
66+
try:
67+
from ..cli.config import ConfigReader
68+
69+
raw_config = ConfigReader.read_config()
70+
value = raw_config.get(self._section, {}).get(self._key)
71+
if value is None:
72+
return None
73+
if isinstance(value, str):
74+
value = value.strip()
75+
if not value:
76+
return None
77+
if self._cast:
78+
return self._cast(value)
79+
return value
80+
except Exception: # pylint: disable=broad-exception-caught
81+
logger.debug("Failed to read %s from config file", self._key, exc_info=True)
82+
return None
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Chain resolver
87+
# ---------------------------------------------------------------------------
88+
89+
90+
class ChainProvider:
91+
"""Resolve a config value from an ordered list of source providers.
92+
93+
Walks the providers in order and returns the first non-``None`` value.
94+
If all providers return ``None``, returns the given *default*.
95+
"""
96+
97+
def __init__(self, providers: list[Callable], default: Any = None):
98+
self._providers = providers
99+
self._default = default
100+
101+
def resolve(self) -> Any:
102+
for provider in self._providers:
103+
value = provider()
104+
if value is not None:
105+
return value
106+
return self._default
107+
108+
109+
# ---------------------------------------------------------------------------
110+
# Cast helpers
111+
# ---------------------------------------------------------------------------
112+
113+
114+
def _cast_bool_truthy(value: str | bool) -> bool:
115+
"""Cast a string to bool where truthy values are ``1/true/yes``."""
116+
if isinstance(value, bool):
117+
return value
118+
return value.lower() in ("1", "true", "yes")
119+
120+
121+
def _cast_bool_falsy_means_disabled(value: str | bool) -> bool:
122+
"""For ``CLOUDSMITH_WITHOUT_API_SSL_VERIFY``: truthy means *disable* SSL."""
123+
return not _cast_bool_truthy(value)
124+
125+
126+
def _cast_bool_ssl_config(value: str | bool) -> bool:
127+
"""For config.ini ``api_ssl_verify``: ``0/false/no`` means disable."""
128+
if isinstance(value, bool):
129+
return value
130+
return value.lower() not in ("0", "false", "no")
131+
132+
133+
def _cast_headers(value: str | dict | None) -> dict | None:
134+
"""Parse ``key=value,key2=value2`` CSV into a dict."""
135+
if value is None:
136+
return None
137+
if isinstance(value, dict):
138+
return value
139+
headers = {}
140+
for pair in value.split(","):
141+
if "=" in pair:
142+
k, v = pair.split("=", 1)
143+
headers[k.strip()] = v.strip()
144+
return headers or None
145+
146+
147+
# ---------------------------------------------------------------------------
148+
# ClientConfig
149+
# ---------------------------------------------------------------------------
150+
151+
152+
@dataclass(frozen=True)
153+
class ClientConfig: # pylint: disable=too-many-instance-attributes
154+
"""Resolved, immutable configuration for Cloudsmith operations.
155+
156+
Created once by :class:`ConfigResolver` and passed to HTTP sessions,
157+
API clients, credential providers, and OIDC detectors.
158+
"""
159+
160+
api_host: str = "https://api.cloudsmith.io"
161+
proxy: str | None = None
162+
ssl_verify: bool = True
163+
user_agent: str | None = None
164+
headers: dict | None = None
165+
debug: bool = False
166+
167+
# Feature flags
168+
no_keyring: bool = False
169+
170+
def create_session(self, api_key: str | None = None, retry=_SENTINEL):
171+
"""Create an HTTP session with this config applied.
172+
173+
Args:
174+
api_key: Optional API key for Bearer auth.
175+
retry: urllib3 Retry configuration. Defaults to the
176+
:data:`DEFAULT_RETRY` policy defined in
177+
:mod:`~cloudsmith_cli.core.credentials.session`.
178+
Pass ``None`` to disable retries.
179+
"""
180+
from .credentials.session import DEFAULT_RETRY
181+
182+
return create_session(
183+
proxy=self.proxy,
184+
ssl_verify=self.ssl_verify,
185+
user_agent=self.user_agent,
186+
headers=self.headers,
187+
api_key=api_key,
188+
retry=DEFAULT_RETRY if retry is _SENTINEL else retry,
189+
)
190+
191+
192+
# ---------------------------------------------------------------------------
193+
# ConfigResolver
194+
# ---------------------------------------------------------------------------
195+
196+
197+
class ConfigResolver:
198+
"""Build a :class:`ClientConfig` by resolving each variable through its chain.
199+
200+
Resolution order per variable: explicit override → env var → config file → default.
201+
202+
Usage::
203+
204+
# CLI entry-point — pass CLI flag values as overrides
205+
config = ConfigResolver().resolve(
206+
api_host=opts.api_host,
207+
proxy=opts.api_proxy,
208+
)
209+
210+
# Credential-helper entry-point — no overrides, resolve from env/config
211+
config = ConfigResolver().resolve()
212+
"""
213+
214+
def resolve(self, **overrides: Any) -> ClientConfig:
215+
"""Resolve all config variables and return a frozen :class:`ClientConfig`.
216+
217+
Args:
218+
**overrides: Explicit values (e.g. from CLI flags). ``None``
219+
values are silently ignored so callers can pass optional
220+
values without filtering.
221+
"""
222+
return ClientConfig(
223+
api_host=self._resolve_api_host(overrides),
224+
proxy=self._resolve_proxy(overrides),
225+
ssl_verify=self._resolve_ssl_verify(overrides),
226+
user_agent=self._resolve_user_agent(overrides),
227+
headers=self._resolve_headers(overrides),
228+
debug=bool(overrides.get("debug", False)),
229+
no_keyring=self._resolve_no_keyring(overrides),
230+
)
231+
232+
# -- individual resolvers ------------------------------------------------
233+
234+
@staticmethod
235+
def _resolve_api_host(overrides: dict) -> str:
236+
return ChainProvider(
237+
[
238+
InstanceProvider(overrides.get("api_host")),
239+
EnvironmentProvider("CLOUDSMITH_API_HOST"),
240+
ConfigFileProvider("api_host"),
241+
],
242+
default="https://api.cloudsmith.io",
243+
).resolve()
244+
245+
@staticmethod
246+
def _resolve_proxy(overrides: dict) -> str | None:
247+
return ChainProvider(
248+
[
249+
InstanceProvider(overrides.get("proxy")),
250+
EnvironmentProvider("CLOUDSMITH_API_PROXY"),
251+
ConfigFileProvider("api_proxy"),
252+
],
253+
).resolve()
254+
255+
@staticmethod
256+
def _resolve_ssl_verify(overrides: dict) -> bool:
257+
override = overrides.get("ssl_verify")
258+
if override is not None:
259+
return bool(override)
260+
return ChainProvider(
261+
[
262+
EnvironmentProvider(
263+
"CLOUDSMITH_WITHOUT_API_SSL_VERIFY",
264+
cast=_cast_bool_falsy_means_disabled,
265+
),
266+
ConfigFileProvider("api_ssl_verify", cast=_cast_bool_ssl_config),
267+
],
268+
default=True,
269+
).resolve()
270+
271+
@staticmethod
272+
def _resolve_user_agent(overrides: dict) -> str | None:
273+
return ChainProvider(
274+
[
275+
InstanceProvider(overrides.get("user_agent")),
276+
EnvironmentProvider("CLOUDSMITH_API_USER_AGENT"),
277+
],
278+
).resolve()
279+
280+
@staticmethod
281+
def _resolve_headers(overrides: dict) -> dict | None:
282+
return ChainProvider(
283+
[
284+
InstanceProvider(overrides.get("headers")),
285+
EnvironmentProvider("CLOUDSMITH_API_HEADERS", cast=_cast_headers),
286+
ConfigFileProvider("api_headers", cast=_cast_headers),
287+
],
288+
).resolve()
289+
290+
@staticmethod
291+
def _resolve_no_keyring(overrides: dict) -> bool:
292+
override = overrides.get("no_keyring")
293+
if override is not None:
294+
return bool(override)
295+
return ChainProvider(
296+
[
297+
EnvironmentProvider("CLOUDSMITH_NO_KEYRING", cast=_cast_bool_truthy),
298+
],
299+
default=False,
300+
).resolve()

cloudsmith_cli/core/credentials/__init__.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88

99
import logging
1010
from dataclasses import dataclass, field
11-
from typing import Optional
11+
from typing import TYPE_CHECKING, Optional
12+
13+
if TYPE_CHECKING:
14+
from ..config_resolver import ClientConfig
1215

1316
logger = logging.getLogger(__name__)
1417

@@ -17,20 +20,40 @@
1720
class CredentialContext: # pylint: disable=too-many-instance-attributes
1821
"""Context passed to credential providers during resolution."""
1922

20-
api_host: str = "https://api.cloudsmith.io"
21-
config_file_path: str | None = None
23+
config: ClientConfig | None = None
24+
25+
# Credential-specific inputs (not part of ClientConfig)
26+
cli_api_key: str | None = None
2227
creds_file_path: str | None = None
28+
config_file_path: str | None = None
2329
profile: str | None = None
2430
debug: bool = False
25-
# Pre-resolved values from CLI flags (highest priority)
26-
cli_api_key: str | None = None
27-
# API networking configuration
28-
proxy: str | None = None
29-
ssl_verify: bool = True
30-
user_agent: str | None = None
31-
headers: dict | None = None
3231
keyring_refresh_failed: bool = False
3332

33+
# -- Convenience properties delegating to config -----------------------
34+
35+
@property
36+
def api_host(self) -> str:
37+
if self.config:
38+
return self.config.api_host
39+
return "https://api.cloudsmith.io"
40+
41+
@property
42+
def proxy(self) -> str | None:
43+
return self.config.proxy if self.config else None
44+
45+
@property
46+
def ssl_verify(self) -> bool:
47+
return self.config.ssl_verify if self.config else True
48+
49+
@property
50+
def user_agent(self) -> str | None:
51+
return self.config.user_agent if self.config else None
52+
53+
@property
54+
def headers(self) -> dict | None:
55+
return self.config.headers if self.config else None
56+
3457

3558
@dataclass
3659
class CredentialResult:

0 commit comments

Comments
 (0)