Skip to content

Commit 6b94baf

Browse files
committed
Merge branch 'release/2.7.3'
2 parents 91a1583 + 18f06ea commit 6b94baf

File tree

7 files changed

+135
-57
lines changed

7 files changed

+135
-57
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
.. _20231012_2_7_3_release:
2+
3+
Release Notes (v2.7.3)
4+
======================
5+
6+
`October 12, 2023`
7+
8+
The v2.7.3 release fixes a bug in how ``_env`` and ``_eval`` get expanded
9+
(`Issue 21 <https://github.com/glentner/CmdKit/issues/21>`_)
10+
11+
-----
12+
13+
14+
The expected behavior is something like the following:
15+
16+
.. code-block:: python
17+
18+
cfg = Configuration(default={'database': {'user': 'bob', 'password_eval': 'echo secret'}})
19+
assert cfg.database.password == 'secret'
20+
21+
The expansion does happen for top-level parameters, but not ones nested within sections (or sub-sections).
22+
You instead get an ``AttributeError`` as you might expect from a normal dictionary.
23+
24+
With the 2.7.x release of cmdkit the ``Namespace`` (and underlying ``NSCoreMixin`` along with ``Environ``) were
25+
migrated to their own separate ``namespace`` module to allow for it to be used in the new ``platform`` module
26+
without created a circular dependency with the original ``config`` module.
27+
28+
The implementation of this expansion functionality was pulled out of ``NSCoreMixin`` and implemented
29+
instead directly in the ``Configuration`` class. This might make sense conceptional, as it's the
30+
configuration that needs this behavior. However, implementing it there destroys the recursive
31+
nature of the structure. As soon as you access a section of the ``Configuration`` instead you get
32+
a ``Namespace`` view, not another ``Configuration``.
33+
34+
The expansion behavior was reverted back to its original home under ``NSCoreMixin``.
35+
All ``Namespace`` instances allow this sort of expansion, as in prior releases.

docs/blog/index.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ A place for announcements, release notes, thoughts and ideas for the project.
77

88
-----
99

10+
:ref:`Release Notes (v2.7.3) <20231012_2_7_3_release>`
11+
------------------------------------------------------
12+
13+
`October 12, 2023`
14+
15+
The v2.7.3 release fixes a bug in how ``_env`` and ``_eval`` get expanded
16+
(`Issue 21 <https://github.com/glentner/CmdKit/issues/21>`_)
17+
18+
-----
19+
1020
:ref:`Release Notes (v2.7.1) <20230927_2_7_1_release>`
1121
------------------------------------------------------
1222

@@ -26,5 +36,6 @@ The v2.7.1 release includes major new features along with numerous fixes and imp
2636
:hidden:
2737
:maxdepth: 1
2838

39+
20231012_2_7_3_release
2940
20230927_2_7_1_release
3041

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "cmdkit"
3-
version = "2.7.2"
3+
version = "2.7.3"
44
description = "A command-line utility toolkit for Python."
55
readme = "README.rst"
66
license = "Apache-2.0"

src/cmdkit/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
# standard libs
88
from logging import NullHandler
9+
from importlib.metadata import version as get_version
910

1011
# internal libs
1112
from cmdkit.cli import Interface, ArgumentError
@@ -26,7 +27,7 @@
2627
]
2728

2829
# package metadata
29-
__version__ = '2.7.2'
30+
__version__ = get_version('cmdkit')
3031

3132
# null-handler for library interface
3233
Logger.with_name(__name__).addHandler(NullHandler())

src/cmdkit/config.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@
1313
from typing import Tuple, List, Dict, TypeVar, Callable, Union, Any
1414

1515
# standard libs
16-
import os
17-
import functools
18-
import subprocess
1916
from collections import Counter
2017

2118
# internal libs
@@ -239,43 +236,6 @@ def __setattr__(self, name: str, value: Any) -> None:
239236
else:
240237
super().__setattr__(name, value)
241238

242-
def __getattr__(self, item: str) -> Any:
243-
"""
244-
Alias for index notation.
245-
Transparently expand `_env` and `_eval` variants.
246-
"""
247-
variants = [f'{item}_env', f'{item}_eval']
248-
if item in self:
249-
return self[item]
250-
for variant in variants:
251-
if variant in self:
252-
return self.__expand_attr(item)
253-
else:
254-
raise AttributeError(f'missing \'{item}\'')
255-
256-
def __expand_attr(self, item: str) -> str:
257-
"""Interpolate values if `_env` or `_eval` present."""
258-
259-
getters = {f'{item}': (lambda: self[item]),
260-
f'{item}_env': functools.partial(self.__expand_attr_env, item),
261-
f'{item}_eval': functools.partial(self.__expand_attr_eval, item)}
262-
263-
items = [key for key in self if key in getters]
264-
if len(items) == 0:
265-
raise ConfigurationError(f'\'{item}\' not found')
266-
elif len(items) == 1:
267-
return getters[items[0]]()
268-
else:
269-
raise ConfigurationError(f'\'{item}\' has more than one variant')
270-
271-
def __expand_attr_env(self, item: str) -> str:
272-
"""Expand `item` as an environment variable."""
273-
return os.getenv(str(self[f'{item}_env']), None)
274-
275-
def __expand_attr_eval(self, item: str) -> str:
276-
"""Expand `item` as a shell expression."""
277-
return subprocess.check_output(str(self[f'{item}_eval']), shell=True).decode().strip()
278-
279239
def update(self, *args, **kwargs) -> None:
280240
"""
281241
Update current namespace directly.

src/cmdkit/namespace.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
# SPDX-FileCopyrightText: 2022 CmdKit Developers
22
# SPDX-License-Identifier: Apache-2.0
33

4-
"""
5-
Namespace implementation.
6-
"""
4+
"""Namespace implementation."""
5+
76

87
# type annotations
98
from __future__ import annotations
109
from typing import Union, Mapping, Iterable, Any, Dict, Optional, IO, List, Tuple, Callable, TypeVar, NamedTuple
1110

1211
# standard libs
1312
import os
13+
import functools
14+
import subprocess
1415
from collections import Counter
1516
from functools import reduce
1617

@@ -76,11 +77,29 @@ def update(self, *args, **kwargs) -> None:
7677
self.__depth_first_update(self, dict(*args, **kwargs))
7778

7879
def __getattr__(self, item: str) -> Any:
79-
"""Alias for index notation."""
80-
if item in self:
81-
return self[item]
80+
"""
81+
Alias for index notation.
82+
Transparently expand `_env` and `_eval` variants.
83+
"""
84+
getters = {f'{item}': (lambda: self[item]),
85+
f'{item}_env': functools.partial(self.__expand_attr_env, item),
86+
f'{item}_eval': functools.partial(self.__expand_attr_eval, item)}
87+
88+
items = [key for key in getters if key in self]
89+
if len(items) == 0:
90+
raise AttributeError(f'\'{item}\' not found')
91+
elif len(items) == 1:
92+
return getters[items[0]]()
8293
else:
83-
raise AttributeError(f'missing \'{item}\'')
94+
raise AttributeError(f'\'{item}\' has more than one variant')
95+
96+
def __expand_attr_env(self, item: str) -> str:
97+
"""Expand `item` as an environment variable."""
98+
return os.getenv(str(self[f'{item}_env']), None)
99+
100+
def __expand_attr_eval(self, item: str) -> str:
101+
"""Expand `item` as a shell expression."""
102+
return subprocess.check_output(str(self[f'{item}_eval']), shell=True).decode().strip()
84103

85104
def __repr__(self) -> str:
86105
"""Convert to string representation."""

tests/test_config.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,37 @@ def test_attribute(self) -> None:
264264
assert 'foo' == ns['b'] == ns.b
265265
assert 3.14 == ns['c']['x'] == ns.c.x
266266

267+
def test_attribute_missing(self) -> None:
268+
"""Test raises AttributeError if item is missing."""
269+
ns = Namespace({'a': 1, 'b': 'foo', 'c': {'x': 3.14}})
270+
with pytest.raises(AttributeError) as exc_info:
271+
assert ns.d == 42
272+
exc_info.match('\'d\' not found')
273+
274+
def test_attribute_expand_env(self) -> None:
275+
"""Test transparent environment variable expansion."""
276+
os.environ['CMDKIT_TEST_A'] = 'foo-bar'
277+
ns = Namespace({'test_env': 'CMDKIT_TEST_A'})
278+
assert ns.get('test') is None
279+
assert ns.get('test_env') == 'CMDKIT_TEST_A'
280+
assert ns.test_env == 'CMDKIT_TEST_A'
281+
assert ns.test == 'foo-bar'
282+
283+
def test_attribute_expand_eval(self) -> None:
284+
"""Test transparent shell expression expansion."""
285+
ns = Namespace({'test_eval': 'echo foo-bar'})
286+
assert ns.get('test') is None
287+
assert ns.get('test_eval') == 'echo foo-bar'
288+
assert ns.test_eval == 'echo foo-bar'
289+
assert ns.test == 'foo-bar'
290+
291+
def test_attribute_expand_multiple_variants(self) -> None:
292+
"""Test failure to expand because item has multiple variants."""
293+
ns = Namespace({'a': 1, 'test': 'foo', 'test_eval': 'echo bar'})
294+
with pytest.raises(AttributeError) as exc_info:
295+
assert ns.test == 'foo'
296+
assert exc_info.match('\'test\' has more than one variant')
297+
267298
def test_duplicates(self) -> None:
268299
"""Namespace can find duplicate leaves in the tree."""
269300
ns = Namespace({'a': {'x': 1, 'y': 2}, 'b': {'x': 3, 'z': 4}})
@@ -463,20 +494,41 @@ def test_blending(self) -> None:
463494
assert cfg['a']['z'] == 4
464495
assert cfg['b']['z'] == 3
465496

497+
def test_attribute_missing(self) -> None:
498+
"""Test raises AttributeError if item is missing"""
499+
cfg = Configuration(a=Namespace({'a': 1, 'b': 'foo', 'c': {'x': 3.14}}))
500+
with pytest.raises(AttributeError) as exc_info:
501+
assert cfg.d == 42
502+
exc_info.match('\'d\' not found')
503+
466504
def test_attribute_expand_env(self) -> None:
467505
"""Test transparent environment variable expansion."""
468506
os.environ['CMDKIT_TEST_A'] = 'foo-bar'
469-
ns = Configuration(a=Namespace({'test_env': 'CMDKIT_TEST_A'}))
470-
assert ns.get('test') is None
471-
assert ns.get('test_env') == 'CMDKIT_TEST_A'
472-
assert ns.test == 'foo-bar'
507+
cfg = Configuration(a=Namespace({'nested': {'test_env': 'CMDKIT_TEST_A'}}))
508+
assert cfg.nested.get('test') is None
509+
assert cfg.nested.get('test_env') == 'CMDKIT_TEST_A'
510+
assert cfg.nested.test_env == 'CMDKIT_TEST_A'
511+
assert cfg.nested.test == 'foo-bar'
473512

474513
def test_attribute_expand_eval(self) -> None:
475514
"""Test transparent shell expression expansion."""
476-
ns = Configuration(a=Namespace({'test_eval': 'echo foo-bar'}))
477-
assert ns.get('test') is None
478-
assert ns.get('test_eval') == 'echo foo-bar'
479-
assert ns.test == 'foo-bar'
515+
cfg = Configuration(a=Namespace({'nested': {'test_eval': 'echo foo-bar'}}))
516+
assert cfg.nested.get('test') is None
517+
assert cfg.nested.get('test_eval') == 'echo foo-bar'
518+
assert cfg.nested.test_eval == 'echo foo-bar'
519+
assert cfg.nested.test == 'foo-bar'
520+
521+
def test_attribute_expand_multiple_variants(self) -> None:
522+
"""Test failure to expand because multiple variants found."""
523+
cfg = Configuration(a=Namespace({'nested': {'test': 'foo', 'a': 1}}),
524+
b=Namespace({'nested': {'test_eval': 'echo bar', 'b': 2}}),
525+
c=Namespace({'other': {'secret_eval': 'echo baz'}}))
526+
with pytest.raises(AttributeError) as exc_info:
527+
assert cfg.nested.a == 1
528+
assert cfg.nested.b == 2
529+
assert cfg.other.secret == 'baz'
530+
assert cfg.nested.test == 'foo'
531+
assert exc_info.match('\'test\' has more than one variant')
480532

481533
def test_from_local(self) -> None:
482534
"""Test Configuration.from_local factory method."""

0 commit comments

Comments
 (0)