Skip to content

Commit 646c50a

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 646c50a

File tree

5 files changed

+463
-28
lines changed

5 files changed

+463
-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: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
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+
# OIDC-specific
168+
oidc_org: str | None = None
169+
oidc_service_slug: str | None = None
170+
oidc_audience: str = "cloudsmith"
171+
172+
# Feature flags
173+
no_keyring: bool = False
174+
oidc_discovery_disabled: bool = False
175+
176+
def create_session(self, api_key: str | None = None, retry=_SENTINEL):
177+
"""Create an HTTP session with this config applied.
178+
179+
Args:
180+
api_key: Optional API key for Bearer auth.
181+
retry: urllib3 Retry configuration. Defaults to the
182+
:data:`DEFAULT_RETRY` policy defined in
183+
:mod:`~cloudsmith_cli.core.credentials.session`.
184+
Pass ``None`` to disable retries.
185+
"""
186+
from .credentials.session import DEFAULT_RETRY
187+
188+
return create_session(
189+
proxy=self.proxy,
190+
ssl_verify=self.ssl_verify,
191+
user_agent=self.user_agent,
192+
headers=self.headers,
193+
api_key=api_key,
194+
retry=DEFAULT_RETRY if retry is _SENTINEL else retry,
195+
)
196+
197+
198+
# ---------------------------------------------------------------------------
199+
# ConfigResolver
200+
# ---------------------------------------------------------------------------
201+
202+
203+
class ConfigResolver:
204+
"""Build a :class:`ClientConfig` by resolving each variable through its chain.
205+
206+
Resolution order per variable: explicit override → env var → config file → default.
207+
208+
Usage::
209+
210+
# CLI entry-point — pass CLI flag values as overrides
211+
config = ConfigResolver().resolve(
212+
api_host=opts.api_host,
213+
proxy=opts.api_proxy,
214+
)
215+
216+
# Credential-helper entry-point — no overrides, resolve from env/config
217+
config = ConfigResolver().resolve()
218+
"""
219+
220+
def resolve(self, **overrides: Any) -> ClientConfig:
221+
"""Resolve all config variables and return a frozen :class:`ClientConfig`.
222+
223+
Args:
224+
**overrides: Explicit values (e.g. from CLI flags). ``None``
225+
values are silently ignored so callers can pass optional
226+
values without filtering.
227+
"""
228+
return ClientConfig(
229+
api_host=self._resolve_api_host(overrides),
230+
proxy=self._resolve_proxy(overrides),
231+
ssl_verify=self._resolve_ssl_verify(overrides),
232+
user_agent=self._resolve_user_agent(overrides),
233+
headers=self._resolve_headers(overrides),
234+
debug=bool(overrides.get("debug", False)),
235+
oidc_org=self._resolve_oidc_org(overrides),
236+
oidc_service_slug=self._resolve_oidc_service_slug(overrides),
237+
oidc_audience=self._resolve_oidc_audience(overrides),
238+
no_keyring=self._resolve_no_keyring(overrides),
239+
oidc_discovery_disabled=self._resolve_oidc_discovery_disabled(overrides),
240+
)
241+
242+
# -- individual resolvers ------------------------------------------------
243+
244+
@staticmethod
245+
def _resolve_api_host(overrides: dict) -> str:
246+
return ChainProvider(
247+
[
248+
InstanceProvider(overrides.get("api_host")),
249+
EnvironmentProvider("CLOUDSMITH_API_HOST"),
250+
ConfigFileProvider("api_host"),
251+
],
252+
default="https://api.cloudsmith.io",
253+
).resolve()
254+
255+
@staticmethod
256+
def _resolve_proxy(overrides: dict) -> str | None:
257+
return ChainProvider(
258+
[
259+
InstanceProvider(overrides.get("proxy")),
260+
EnvironmentProvider("CLOUDSMITH_API_PROXY"),
261+
ConfigFileProvider("api_proxy"),
262+
],
263+
).resolve()
264+
265+
@staticmethod
266+
def _resolve_ssl_verify(overrides: dict) -> bool:
267+
override = overrides.get("ssl_verify")
268+
if override is not None:
269+
return bool(override)
270+
return ChainProvider(
271+
[
272+
EnvironmentProvider(
273+
"CLOUDSMITH_WITHOUT_API_SSL_VERIFY",
274+
cast=_cast_bool_falsy_means_disabled,
275+
),
276+
ConfigFileProvider("api_ssl_verify", cast=_cast_bool_ssl_config),
277+
],
278+
default=True,
279+
).resolve()
280+
281+
@staticmethod
282+
def _resolve_user_agent(overrides: dict) -> str | None:
283+
return ChainProvider(
284+
[
285+
InstanceProvider(overrides.get("user_agent")),
286+
EnvironmentProvider("CLOUDSMITH_API_USER_AGENT"),
287+
],
288+
).resolve()
289+
290+
@staticmethod
291+
def _resolve_headers(overrides: dict) -> dict | None:
292+
return ChainProvider(
293+
[
294+
InstanceProvider(overrides.get("headers")),
295+
EnvironmentProvider("CLOUDSMITH_API_HEADERS", cast=_cast_headers),
296+
ConfigFileProvider("api_headers", cast=_cast_headers),
297+
],
298+
).resolve()
299+
300+
@staticmethod
301+
def _resolve_oidc_org(overrides: dict) -> str | None:
302+
return ChainProvider(
303+
[
304+
InstanceProvider(overrides.get("oidc_org")),
305+
EnvironmentProvider("CLOUDSMITH_ORG"),
306+
],
307+
).resolve()
308+
309+
@staticmethod
310+
def _resolve_oidc_service_slug(overrides: dict) -> str | None:
311+
return ChainProvider(
312+
[
313+
InstanceProvider(overrides.get("oidc_service_slug")),
314+
EnvironmentProvider("CLOUDSMITH_SERVICE_SLUG"),
315+
],
316+
).resolve()
317+
318+
@staticmethod
319+
def _resolve_oidc_audience(overrides: dict) -> str:
320+
return ChainProvider(
321+
[
322+
InstanceProvider(overrides.get("oidc_audience")),
323+
EnvironmentProvider("CLOUDSMITH_OIDC_AUDIENCE"),
324+
],
325+
default="cloudsmith",
326+
).resolve()
327+
328+
@staticmethod
329+
def _resolve_no_keyring(overrides: dict) -> bool:
330+
override = overrides.get("no_keyring")
331+
if override is not None:
332+
return bool(override)
333+
return ChainProvider(
334+
[
335+
EnvironmentProvider("CLOUDSMITH_NO_KEYRING", cast=_cast_bool_truthy),
336+
],
337+
default=False,
338+
).resolve()
339+
340+
@staticmethod
341+
def _resolve_oidc_discovery_disabled(overrides: dict) -> bool:
342+
override = overrides.get("oidc_discovery_disabled")
343+
if override is not None:
344+
return bool(override)
345+
return ChainProvider(
346+
[
347+
EnvironmentProvider(
348+
"CLOUDSMITH_OIDC_DISCOVERY_DISABLED", cast=_cast_bool_truthy
349+
),
350+
],
351+
default=False,
352+
).resolve()

0 commit comments

Comments
 (0)