Skip to content

Commit 8b57884

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 8b57884

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

0 commit comments

Comments
 (0)