Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ To install: ```pip install config2py```

[Documentation](https://i2mint.github.io/config2py/)

<!-- epythet:agentic-readme:start -->
## For AI agents

`config2py` ships no skills or subagents of its own — it's the thing that reads
*your* agent's config, not the other way around. If you're an agent that needs to
fetch a value from an environment variable, a local file, or a user prompt without
three different codecs and a `configparser` incantation, this is your package.

**The documentation, machine-readable**: [`llms.txt`](https://i2mint.github.io/config2py/llms.txt) indexes every page; [`config2py.md`](https://i2mint.github.io/config2py/config2py.md) is the whole documentation in one file; every page has a `.md` twin; [`objects.inv`](https://i2mint.github.io/config2py/objects.inv) maps symbols to URLs.

If you are a control freak (human or otherwise), the rest of this README is written for you, starting at [The cherry on top: config_getter](#the-cherry-on-top-config_getter).
<!-- epythet:agentic-readme:end -->

# The cherry on top: config_getter

Expand Down
119 changes: 72 additions & 47 deletions config2py/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ def __contains__(self, k: KT) -> bool:


def is_not_none_nor_empty(x):
"""True unless ``x`` is ``None`` or the empty string.

>>> is_not_none_nor_empty(None)
False
>>> is_not_none_nor_empty('')
False
>>> is_not_none_nor_empty('a')
True
>>> is_not_none_nor_empty(0)
True
"""
if isinstance(x, str):
return x != ""
else:
Expand Down Expand Up @@ -188,8 +199,8 @@ def get_config(
Note that a source can be a callable or a ``GettableContainer`` (most of the
time, a ``Mapping`` (e.g. ``dict``)).
Here, you should be compelled to use the resources of ``dol``
(https://pypi.org/project/dol/) which will allow you to make ``Mapping``s for all
sorts of data sources.
(https://pypi.org/project/dol/) which will allow you to make ``Mapping`` objects
for all sorts of data sources.

For more info, see: https://github.com/i2mint/config2py/issues/4

Expand Down Expand Up @@ -435,6 +446,17 @@ def _resolve_saver(save_to: SaveTo) -> Optional[KTSaver]:


def is_not_empty(val) -> bool:
"""True unless ``val`` is ``None`` or the empty string.

>>> is_not_empty(None)
False
>>> is_not_empty('')
False
>>> is_not_empty('a')
True
>>> is_not_empty(0)
True
"""
if isinstance(val, str):
return val != ""
else:
Expand Down Expand Up @@ -519,54 +541,57 @@ def user_gettable(
"""
Create a ``GettableContainer`` that asks the user for a value, optionally saving it.

:param save_to: Where to save the user's response: a ``MutableMapping`` (or
anything with a ``__setitem__``), or a ``(key, value)`` saver function.
If ``None``, the user's response is not saved.
:param prompt_template: A template string to prompt the user with. It should
contain a placeholder for the key, e.g. ``"Enter a value for {}: "``.
:param egress: A function to apply to the user's response before returning it.
This can be used to validate the response, for example.
:param user_asker: A function that asks the user for input. It should take a
prompt string and return the user's response.
:param val_is_valid: A function that takes a value and returns a boolean. If it
returns ``False``, the user will be asked for a new value.
:param config_not_found_exceptions: An iterable of exceptions that should be
considered as "config not found" exceptions. If the user's response raises
one of these exceptions, the user will be asked for a new value.
:return: A ``GettableContainer`` that asks the user for a value, optionally saving
it.
Args:
save_to: Where to save the user's response: a ``MutableMapping`` (or
anything with a ``__setitem__``), or a ``(key, value)`` saver function.
If ``None``, the user's response is not saved.
prompt_template: A template string to prompt the user with. It should
contain a placeholder for the key, e.g. ``"Enter a value for {}: "``.
egress: A function to apply to the user's response before returning it.
This can be used to validate the response, for example.
user_asker: A function that asks the user for input. It should take a
prompt string and return the user's response.
val_is_valid: A function that takes a value and returns a boolean. If it
returns ``False``, the user will be asked for a new value.
config_not_found_exceptions: An iterable of exceptions that should be
considered as "config not found" exceptions. If the user's response
raises one of these exceptions, the user will be asked for a new value.

Returns:
A ``GettableContainer`` that asks the user for a value, optionally saving it.

Example:

>>> s = user_gettable()
>>> v = s['SOME_KEY'] # doctest: +SKIP
'SOME_VAL'

This will trigger a prompt for the user to enter the value of ``SOME_KEY``.
When they do (say they entered 'SOME_VAL') it will return that value.

And if you specify a save_to store (usually a persistent MutableMapping made with
the ``dol`` package) then it will save the value to that store for future use.

>>> d = dict(some='store')
>>> s = user_gettable(save_to=d)
>>> s['SOME_KEY'] # doctest: +SKIP
'SOME_VAL'
>>> d # doctest: +SKIP
{'some': 'store', 'SOME_KEY': 'SOME_VAL'}

When saving isn't a simple write (say you need to encrypt, or write to two
places), ``save_to`` can be a ``(key, value)`` function instead:

>>> saved = []
>>> s = user_gettable(
... save_to=lambda k, v: saved.append((k, v)),
... user_asker=lambda prompt: 'SOME_VAL',
... )
>>> s['SOME_KEY']
'SOME_VAL'
>>> saved
[('SOME_KEY', 'SOME_VAL')]
>>> s = user_gettable()
>>> v = s['SOME_KEY'] # doctest: +SKIP
'SOME_VAL'

This will trigger a prompt for the user to enter the value of ``SOME_KEY``.
When they do (say they entered 'SOME_VAL') it will return that value.

And if you specify a save_to store (usually a persistent MutableMapping made
with the ``dol`` package) then it will save the value to that store for
future use.

>>> d = dict(some='store')
>>> s = user_gettable(save_to=d)
>>> s['SOME_KEY'] # doctest: +SKIP
'SOME_VAL'
>>> d # doctest: +SKIP
{'some': 'store', 'SOME_KEY': 'SOME_VAL'}

When saving isn't a simple write (say you need to encrypt, or write to two
places), ``save_to`` can be a ``(key, value)`` function instead:

>>> saved = []
>>> s = user_gettable(
... save_to=lambda k, v: saved.append((k, v)),
... user_asker=lambda prompt: 'SOME_VAL',
... )
>>> s['SOME_KEY']
'SOME_VAL'
>>> saved
[('SOME_KEY', 'SOME_VAL')]

"""
getter = ask_user_for_key(
Expand Down
10 changes: 10 additions & 0 deletions config2py/codecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
based on their file extensions. It includes codecs for bytes <-> JSON-friendly Python types.

Examples:

>>> # Basic usage
>>> data = {'name': 'config2py', 'version': '1.0'}
>>>
Expand Down Expand Up @@ -75,6 +76,7 @@ def get_extension(key: str) -> str:
Extension without the dot, or empty string if no extension found

Examples:

>>> get_extension('config.json')
'json'
>>> get_extension('/path/to/data.yaml')
Expand Down Expand Up @@ -115,6 +117,7 @@ def decode_by_extension(key: str, data: bytes) -> Any:
ValueError: If no decoder registered for extension

Examples:

>>> data = b'{"key": "value"}'
>>> decode_by_extension('config.json', data)
{'key': 'value'}
Expand Down Expand Up @@ -145,6 +148,7 @@ def encode_by_extension(key: str, obj: Any) -> bytes:
ValueError: If no encoder registered for extension

Examples:

>>> obj = {'key': 'value'}
>>> encoded = encode_by_extension('config.json', obj)
>>> assert b'"key"' in encoded
Expand Down Expand Up @@ -187,6 +191,7 @@ def register_codec(
ValueError: If codec already registered and overwrite=False

Examples:

>>> def my_encoder(obj): return str(obj).encode()
>>> def my_decoder(data): return eval(data.decode())
>>> register_codec('.custom', encoder=my_encoder, decoder=my_decoder, overwrite=True)
Expand Down Expand Up @@ -220,6 +225,7 @@ def register_decoder(extension: str, *, overwrite: bool = False):
Decorator function

Examples:

>>> @register_decoder('.custom', overwrite=True)
... def decode_custom(data: bytes) -> dict:
... return {'data': data.decode()}
Expand Down Expand Up @@ -248,6 +254,7 @@ def register_encoder(extension: str, *, overwrite: bool = False):
Decorator function

Examples:

>>> @register_encoder('.custom', overwrite=True)
... def encode_custom(obj: dict) -> bytes:
... return obj.get('data', '').encode()
Expand Down Expand Up @@ -277,6 +284,7 @@ def list_registered_extensions() -> list[str]:
Sorted list of registered extensions

Examples:

>>> extensions = list_registered_extensions()
>>> '.json' in extensions
True
Expand All @@ -295,6 +303,7 @@ def is_extension_registered(extension: str) -> bool:
True if decoder or encoder is registered

Examples:

>>> is_extension_registered('.json')
True
>>> is_extension_registered('.nonexistent')
Expand All @@ -316,6 +325,7 @@ def get_codec_info(extension: str) -> dict[str, Any]:
Dictionary with codec information

Examples:

>>> info = get_codec_info('.json')
>>> info['has_encoder']
True
Expand Down
34 changes: 30 additions & 4 deletions config2py/s_configparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@


def persist_after_operation(method_func):
"""Wrap a mutating method so it calls ``self.persist()`` after running.

Used to make ``ConfigStore`` methods like ``__setitem__`` and
``__delitem__`` persist their change to the store's target immediately --
which writes to disk only when ``target_kind`` is ``'filepath'``; for
``'string'``, ``'bytes'`` and ``'dict'`` targets, ``persist()`` just returns
the serialized data without touching disk.
"""

@wraps(method_func)
def _method_func(self, *args, **kwargs):
output = method_func(self, *args, **kwargs)
Expand All @@ -68,10 +77,12 @@ def _method_func(self, *args, **kwargs):
def super_and_persist(super_cls, method_name):
"""
To be able to do this:
```
__setitem__ = super_and_persist(ConfigParser, '__setitem__')
__delitem__ = super_and_persist(ConfigParser, '__delitem__')
```

.. code-block:: text

__setitem__ = super_and_persist(ConfigParser, '__setitem__')
__delitem__ = super_and_persist(ConfigParser, '__delitem__')

in your class definition block.

I thought I needed to wrap more method this way, but as it turns out, I might not,
Expand Down Expand Up @@ -151,9 +162,11 @@ class ConfigStore(ConfigParserStore):
{}

You can delete sections

>>> del s['add']

But you'll need to refresh your reader to see the effect.

>>> list(config_reader)
['DEFAULT', 'nothing', 'add']
>>> config_reader = ConfigReader(ini_filepath)
Expand All @@ -162,6 +175,7 @@ class ConfigStore(ConfigParserStore):

You can use `update` to write several sections at the same time.
Note that existing sections will be completely overwritten.

>>> s.update({'nothing': {'like': 'you'}, 'new_section': {'a': 'b', 'c': 'd'}})
>>> ConfigReader(ini_filepath).to_dict()
{'DEFAULT': {}, 'nothing': {'like': 'you'}, 'new_section': {'a': 'b', 'c': 'd'}}
Expand All @@ -173,15 +187,18 @@ class ConfigStore(ConfigParserStore):
will not be persisted.

You'll see the updated section in the store.

>>> s['nothing'].update({'something': 'else'})
>>> dict(s['nothing'])
{'like': 'you', 'something': 'else'}

But it's not automatically persisted

>>> dict(ConfigReader(ini_filepath)['nothing'])
{'like': 'you'}

... unless you ask for it explicitly

>>> s.persist()
>>> dict(ConfigReader(ini_filepath)['nothing'])
{'like': 'you', 'something': 'else'}
Expand Down Expand Up @@ -218,6 +235,9 @@ def __init__(
target_kind=None,
**more_config_parser_kwargs,
):
"""See the class docstring: ``source`` may be a filepath, a config string,
bytes, a dict, or a readable stream; ``defaults``, ``dict_type`` and
``allow_no_value`` are passed on to ``ConfigParser``."""
super().__init__(
defaults, dict_type, allow_no_value, **more_config_parser_kwargs
)
Expand Down Expand Up @@ -248,6 +268,7 @@ def __init__(
self.target_kind = target_kind or source_kind

def to_dict(self):
"""Return the whole config as a ``{section: {key: value}}`` dict."""
return {
section: dict(section_contents)
for section, section_contents in self.items()
Expand Down Expand Up @@ -310,6 +331,7 @@ def __delitem__(self, k):

class ConfigReader(ConfigStore):
r"""A KvReader to read config files

>>> from config2py.s_configparser import ConfigReader
>>>
>>> # from a (pretend) file
Expand Down Expand Up @@ -361,12 +383,15 @@ class ConfigReader(ConfigStore):
"""

def persist(self):
"""Disabled: ``ConfigReader`` is read-only."""
raise NotImplementedError("persist disabled for ConfigReader")

def __setitem__(self, k, v):
"""``ConfigReader`` is read-only."""
raise NotImplementedError("__setitem__ disabled for ConfigReader")

def __delitem__(self, k):
"""``ConfigReader`` is read-only."""
raise NotImplementedError("__delitem__ disabled for ConfigReader")


Expand Down Expand Up @@ -419,6 +444,7 @@ def postprocess_ini_section_items(items: Mapping | Iterable) -> Generator:
# TODO: Find out if configparse has an option to do this processing alreadys
def preprocess_ini_section_items(items: Mapping | Iterable) -> Generator:
"""Transform list values into newline-separated strings, in view of writing the value to a ini formatted section

>>> section = {
... 'name': 'aspyre',
... 'keywords': ['documentation', 'packaging', 'publishing']
Expand Down
Loading
Loading