Skip to content

Commit eb15422

Browse files
committed
Add multiple selectable config profiles to get_interface
cottoncandy previously supported a single set of connection settings (keys, endpoint, bucket) split across the [login]/[basic]/[gdrive] config sections. This adds named profiles so users can switch between accounts/endpoints/buckets at instantiation time: cci = cc.get_interface(profile='mylab') - options.py: add get_profile()/list_profiles() that resolve a [profile:NAME] section over the base sections, with `inherits` for profile inheritance (multi-level, with cycle detection). - __init__.py: get_interface()/get_browser() accept profile=; the credential/bucket/endpoint/backend arguments default to None and are filled from the resolved profile, so explicit arguments still win. - defaults.cfg: document profiles and add an explicit default backend key. - tests: unit tests for profile resolution, inheritance, error cases, and get_interface precedence (no network). - README: document profiles and inheritance. Fully backward compatible: omitting profile= reproduces the current behavior and existing configuration files keep working unchanged. https://claude.ai/code/session_01UEkty8EbGpn3mWcwtcSwdw
1 parent b2bb7a3 commit eb15422

5 files changed

Lines changed: 388 additions & 17 deletions

File tree

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,41 @@ By default, cottoncandy sets object and bucket permissions to ``authenticated-re
5757

5858
Advanced (for admins): One can customize the cottoncandy system install by cloning the repo and modifying `defaults.cfg`. For example, one can set the default encryption key across the system for all users (`key = SoMeEncypTionKey`). When a user first uses cottoncandy, this default value will be copied to their personal configuration file. Note however that the user can still overwrite that value.
5959

60+
### Profiles
61+
62+
If you work across multiple accounts, endpoints, or buckets, you can define named
63+
**profiles** in your configuration file and select one when creating an interface:
64+
65+
```python
66+
>>> cci = cc.get_interface(profile='mylab')
67+
```
68+
69+
Each profile is a `[profile:NAME]` section that may set any subset of
70+
`access_key`, `secret_key`, `endpoint_url`, `default_bucket`, `signature_version`,
71+
`force_bucket_creation`, `backend`, and (for `backend = gdrive`) `secrets` and
72+
`credentials`. Any setting a profile omits falls back to the base
73+
`[login]`/`[basic]`/`[gdrive]` sections (the default profile, used when no
74+
`profile` is given). Arguments passed directly to `get_interface` always take
75+
precedence over the profile.
76+
77+
Profiles can **inherit** from one another with `inherits`; a child only needs to
78+
specify the keys it overrides:
79+
80+
```ini
81+
[profile:mylab]
82+
access_key = LABACCESSKEY
83+
secret_key = LABSECRETKEY
84+
endpoint_url = https://s3.example.edu/
85+
default_bucket = lab-shared
86+
87+
[profile:mylab-scratch]
88+
inherits = mylab
89+
default_bucket = lab-scratch
90+
```
91+
92+
Use `cottoncandy.options.list_profiles()` to see the profiles defined in your
93+
configuration.
94+
6095

6196
## Getting started
6297
Setup the connection (endpoint, access and secret keys can be specified in the configuration file instead):

cottoncandy/__init__.py

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
force_bucket_creation = string2bool(force_bucket_creation)
2222

2323

24-
def get_interface(bucket_name=default_bucket,
25-
ACCESS_KEY=ACCESS_KEY,
26-
SECRET_KEY=SECRET_KEY,
27-
endpoint_url=ENDPOINT_URL,
28-
force_bucket_creation=force_bucket_creation,
24+
def get_interface(bucket_name=None,
25+
ACCESS_KEY=None,
26+
SECRET_KEY=None,
27+
endpoint_url=None,
28+
force_bucket_creation=None,
2929
verbose=True,
30-
backend='s3',
30+
backend=None,
31+
profile=None,
3132
**kwargs):
3233
"""Return an interface to the cloud.
3334
@@ -40,6 +41,13 @@ def get_interface(bucket_name=default_bucket,
4041
The URL for the S3 gateway
4142
backend : 's3'|'gdrive'
4243
What backend to hook on to
44+
profile : str, optional
45+
Name of a ``[profile:NAME]`` section in the configuration file from
46+
which to read the connection settings (access/secret keys, endpoint,
47+
bucket, signature version, backend, gdrive credentials). Any setting a
48+
profile omits falls back to the base ``[login]``/``[basic]``/``[gdrive]``
49+
sections. Explicitly-passed arguments always take precedence over the
50+
profile. See ``cottoncandy.options.list_profiles``.
4351
kwargs :
4452
S3 only. kwargs passed to botocore. For example,
4553
>>> from botocore.client import Config
@@ -52,40 +60,60 @@ def get_interface(bucket_name=default_bucket,
5260
"""
5361
from cottoncandy.interfaces import DefaultInterface
5462

63+
# Resolve the profile (falls back to the base config sections). Any
64+
# argument left as ``None`` is filled in from the resolved settings, so
65+
# explicitly-passed arguments always win over the profile/config.
66+
settings = options.get_profile(profile)
67+
68+
if bucket_name is None:
69+
bucket_name = settings['default_bucket']
70+
if ACCESS_KEY is None:
71+
ACCESS_KEY = settings['access_key']
72+
if SECRET_KEY is None:
73+
SECRET_KEY = settings['secret_key']
74+
if endpoint_url is None:
75+
endpoint_url = settings['endpoint_url']
76+
if backend is None:
77+
backend = settings['backend'] or 's3'
78+
if force_bucket_creation is None:
79+
force_bucket_creation = string2bool(settings['force_bucket_creation'])
80+
signature_version = settings['signature_version']
81+
5582
if backend == 's3':
5683
if ACCESS_KEY in [False, "False"] or SECRET_KEY in [False, "False"]:
5784
ACCESS_KEY, SECRET_KEY = get_keys()
5885
elif backend == 'gdrive':
59-
ACCESS_KEY = os.path.join(options.userdir, options.config.get('gdrive', 'secrets'))
60-
SECRET_KEY = os.path.join(options.userdir, options.config.get('gdrive', 'credentials'))
86+
ACCESS_KEY = os.path.join(options.userdir, settings['secrets'])
87+
SECRET_KEY = os.path.join(options.userdir, settings['credentials'])
6188
else:
6289
pass
6390

6491
if 'config' in kwargs:
6592
# user provided config
6693
if not kwargs['config'].signature_version:
6794
# config does not specify signature
68-
kwargs['config'].signature_version = DEFAULT_SIGNATURE_VERSION
69-
elif DEFAULT_SIGNATURE_VERSION:
95+
kwargs['config'].signature_version = signature_version
96+
elif signature_version:
7097
# no config but default signature exists
7198
from botocore.client import Config
72-
kwargs['config'] = Config(signature_version=DEFAULT_SIGNATURE_VERSION)
99+
kwargs['config'] = Config(signature_version=signature_version)
73100

74101
interface = DefaultInterface(bucket_name,
75102
ACCESS_KEY,
76103
SECRET_KEY,
77104
endpoint_url,
78105
force_bucket_creation,
79106
verbose=verbose,
80-
backend = backend,
107+
backend=backend,
81108
**kwargs)
82109
return interface
83110

84111

85-
def get_browser(bucket_name=default_bucket,
86-
ACCESS_KEY=ACCESS_KEY,
87-
SECRET_KEY=SECRET_KEY,
88-
endpoint_url=ENDPOINT_URL):
112+
def get_browser(bucket_name=None,
113+
ACCESS_KEY=None,
114+
SECRET_KEY=None,
115+
endpoint_url=None,
116+
profile=None):
89117
"""Browser object that allows you to tab-complete your
90118
way through your objects
91119
@@ -96,6 +124,10 @@ def get_browser(bucket_name=default_bucket,
96124
SECRET_KEY : str
97125
endpoint_url : str
98126
The URL for the S3 gateway
127+
profile : str, optional
128+
Name of a ``[profile:NAME]`` section in the configuration file from
129+
which to read the connection settings. Explicitly-passed arguments take
130+
precedence over the profile. See ``cottoncandy.options.list_profiles``.
99131
100132
Returns
101133
-------
@@ -118,7 +150,19 @@ def get_browser(bucket_name=default_bucket,
118150
from cottoncandy.browser import S3Directory
119151
from cottoncandy.interfaces import DefaultInterface
120152

121-
if (ACCESS_KEY is False) and (SECRET_KEY is False):
153+
# Resolve the profile (falls back to the base config sections); explicitly
154+
# passed arguments win over the profile.
155+
settings = options.get_profile(profile)
156+
if bucket_name is None:
157+
bucket_name = settings['default_bucket']
158+
if ACCESS_KEY is None:
159+
ACCESS_KEY = settings['access_key']
160+
if SECRET_KEY is None:
161+
SECRET_KEY = settings['secret_key']
162+
if endpoint_url is None:
163+
endpoint_url = settings['endpoint_url']
164+
165+
if ACCESS_KEY in [False, "False"] and SECRET_KEY in [False, "False"]:
122166
from .utils import get_keys
123167
ACCESS_KEY, SECRET_KEY = get_keys()
124168

cottoncandy/defaults.cfg

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ force_bucket_creation = False
1313
path_separator = /
1414
signature_version =
1515
threads = 4
16+
# default backend: 's3', 'gdrive', or 'local'
17+
backend = s3
1618

1719
[upload_settings]
1820
# in MB, except max_mpu_size_TB, and max_mpu_parts
@@ -43,3 +45,29 @@ do_compression = True
4345
small_array = gzip
4446
# >= 2 GB arrays
4547
large_array = Zstd
48+
49+
# Profiles (optional)
50+
# -------------------
51+
# Define named profiles to switch between accounts/endpoints/buckets:
52+
#
53+
# cci = cottoncandy.get_interface(profile='mylab')
54+
#
55+
# A profile section is named [profile:NAME] and may set any subset of:
56+
# access_key, secret_key, endpoint_url, default_bucket,
57+
# signature_version, force_bucket_creation, backend,
58+
# secrets, credentials (the last two are for backend = gdrive)
59+
# Anything a profile omits falls back to the [login]/[basic]/[gdrive] sections
60+
# above (the default profile).
61+
#
62+
# A profile can inherit from another profile with `inherits`; a child only needs
63+
# to specify the keys it overrides:
64+
#
65+
# [profile:mylab]
66+
# access_key = LABACCESSKEY
67+
# secret_key = LABSECRETKEY
68+
# endpoint_url = https://s3.example.edu/
69+
# default_bucket = lab-shared
70+
#
71+
# [profile:mylab-scratch]
72+
# inherits = mylab
73+
# default_bucket = lab-scratch

cottoncandy/options.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,103 @@ def get_config():
8484
if needs_update:
8585
with open(usercfg, 'w') as configfile:
8686
config.write(configfile)
87+
88+
89+
# Profiles
90+
# --------
91+
# A profile bundles the connection-identity settings so users can switch
92+
# between accounts/endpoints/buckets via ``get_interface(profile='NAME')``.
93+
# Each profile lives in its own ``[profile:NAME]`` section and may set any
94+
# subset of the keys below. Anything a profile omits falls back to the base
95+
# ``[login]``/``[basic]``/``[gdrive]`` sections (the default profile). A profile
96+
# can inherit from another with ``inherits = PARENT`` and only specify the keys
97+
# it overrides.
98+
PROFILE_PREFIX = 'profile:'
99+
INHERITS_KEY = 'inherits'
100+
101+
# logical name -> (base section, key) used as the default-profile fallback
102+
PROFILE_KEYS = {
103+
'access_key': ('login', 'access_key'),
104+
'secret_key': ('login', 'secret_key'),
105+
'endpoint_url': ('login', 'endpoint_url'),
106+
'default_bucket': ('basic', 'default_bucket'),
107+
'signature_version': ('basic', 'signature_version'),
108+
'force_bucket_creation': ('basic', 'force_bucket_creation'),
109+
'backend': ('basic', 'backend'),
110+
'secrets': ('gdrive', 'secrets'),
111+
'credentials': ('gdrive', 'credentials'),
112+
}
113+
114+
115+
def list_profiles(cfg=None):
116+
'''List the names of the profiles defined in the configuration.
117+
118+
Parameters
119+
----------
120+
cfg : configparser.ConfigParser, optional
121+
Defaults to the global cottoncandy config.
122+
123+
Returns
124+
-------
125+
profiles : list of str
126+
'''
127+
cfg = config if cfg is None else cfg
128+
return [section[len(PROFILE_PREFIX):] for section in cfg.sections()
129+
if section.startswith(PROFILE_PREFIX)]
130+
131+
132+
def _profile_chain(name, cfg, _seen=None):
133+
'''Return the inheritance chain for a profile, root parent first.
134+
135+
Raises
136+
------
137+
ValueError
138+
If the profile (or a parent) does not exist, or if the ``inherits``
139+
relationships form a cycle.
140+
'''
141+
_seen = [] if _seen is None else _seen
142+
section = PROFILE_PREFIX + name
143+
if not cfg.has_section(section):
144+
raise ValueError('Unknown cottoncandy profile %r. Available: %s'
145+
% (name, list_profiles(cfg)))
146+
if name in _seen:
147+
raise ValueError('Circular profile inheritance: %s'
148+
% ' -> '.join(_seen + [name]))
149+
_seen = _seen + [name]
150+
if cfg.has_option(section, INHERITS_KEY):
151+
parent = cfg.get(section, INHERITS_KEY).strip()
152+
if parent:
153+
return _profile_chain(parent, cfg, _seen) + [name]
154+
return [name]
155+
156+
157+
def get_profile(name=None, cfg=None):
158+
'''Resolve a profile into a dict of connection settings.
159+
160+
Values are resolved most-specific-last: the base ``[login]``/``[basic]``/
161+
``[gdrive]`` sections provide the defaults, then each profile in the
162+
inheritance chain (root parent first) overlays the keys it sets.
163+
164+
Parameters
165+
----------
166+
name : str or None
167+
Profile name. ``None`` returns the base/default settings.
168+
cfg : configparser.ConfigParser, optional
169+
Defaults to the global cottoncandy config.
170+
171+
Returns
172+
-------
173+
settings : dict
174+
Keys are those of ``PROFILE_KEYS``; missing values are ``None``.
175+
'''
176+
cfg = config if cfg is None else cfg
177+
settings = {key: (cfg.get(section, option)
178+
if cfg.has_option(section, option) else None)
179+
for key, (section, option) in PROFILE_KEYS.items()}
180+
if name:
181+
for profile_name in _profile_chain(name, cfg):
182+
section = PROFILE_PREFIX + profile_name
183+
for key in PROFILE_KEYS:
184+
if cfg.has_option(section, key):
185+
settings[key] = cfg.get(section, key)
186+
return settings

0 commit comments

Comments
 (0)