From a3fe88b5a2f213e138c33d34bf5acb05cfc5ddd3 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:39:54 +0200 Subject: [PATCH 1/3] docs: mechanical epythet repair (blank lines before doctest/fence/list blocks) epythet repair_package fixes for DR003/DR006/DR008/DR011/DR016 rendering artifacts: missing blank lines before doctest blocks, a Markdown fence converted to RST, and __init__ docstrings for SyncStore/FileStore/JsonStore. --- config2py/codecs.py | 10 ++++++++++ config2py/sync_store.py | 5 +++++ config2py/tools.py | 1 + 3 files changed, 16 insertions(+) diff --git a/config2py/codecs.py b/config2py/codecs.py index d5a1af6..3a49029 100644 --- a/config2py/codecs.py +++ b/config2py/codecs.py @@ -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'} >>> @@ -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') @@ -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'} @@ -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 @@ -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) @@ -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()} @@ -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() @@ -277,6 +284,7 @@ def list_registered_extensions() -> list[str]: Sorted list of registered extensions Examples: + >>> extensions = list_registered_extensions() >>> '.json' in extensions True @@ -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') @@ -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 diff --git a/config2py/sync_store.py b/config2py/sync_store.py index d913f60..8876d06 100644 --- a/config2py/sync_store.py +++ b/config2py/sync_store.py @@ -176,6 +176,7 @@ class SyncStore(MutableMapping): dumper: Function that persists the data dict to storage Example: + >>> def my_loader(): ... return {'x': 1} >>> @@ -199,6 +200,7 @@ class SyncStore(MutableMapping): """ def __init__(self, loader: Loader, dumper: Dumper): + """See the class docstring for ``loader`` and ``dumper``.""" self._loader = loader self._dumper = dumper self._data = None @@ -272,6 +274,7 @@ class FileStore(SyncStore): for missing key_path. If None, KeyError is raised for missing key paths. Example: + >>> import tempfile >>> import os >>> @@ -312,6 +315,7 @@ def __init__( create_file_content: Optional[Callable[[], dict]] = None, create_key_path_content: Optional[Callable[[], Any]] = None, ): + """See the class docstring for each parameter.""" self.filepath = Path(filepath).expanduser() self.key_path = _normalize_key_path(key_path) self.mode = mode @@ -421,6 +425,7 @@ def __init__( ensure_ascii: bool = False, **dump_kwargs, ): + """See the class docstring for each parameter.""" dump_kwargs.setdefault("indent", indent) dump_kwargs.setdefault("ensure_ascii", ensure_ascii) diff --git a/config2py/tools.py b/config2py/tools.py index 65a22ba..dfcb627 100644 --- a/config2py/tools.py +++ b/config2py/tools.py @@ -189,6 +189,7 @@ def source_config_params(*config_params): >>> bar = partial(foo, a='a') `a` is set, but you'll be able to call `bar` with different config sources, + >>> bar(b='b', c=3, _config_getter=config.get) (1, 2, 3) >>> other_config = {'a': 11, 'b': 22, 'c': 33} From 8cebd60c0ba331cd0b96cc8ccb68570d6baed832 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:40:07 +0200 Subject: [PATCH 2/3] docs: fix docstring rendering errors and coverage/correctness gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base.py, util.py: indent Google-style section headers (Args/Example) so napoleon renders them as fields instead of prose (DR002); fix an unbalanced ``Mapping``s backtick span (DR010); turn bullet lists that were glued to their intro line into proper RST lists (DR008); split a block of "**kind**: ..." lines into a real bullet list to remove a stray "Unexpected indentation" Sphinx build error. - Add missing docstrings (D102/D103/D107) on previously undocumented public callables and __init__ methods, verified against the class docstrings and tests they already had. - base.py user_gettable: convert its :param:/​:return: fields to Google style so pydoclint can match them against the signature (fixes a DOC101/DOC103 false "missing argument" finding). - util.py is_repl: the docstring documented a nonexistent `repl_conditions` parameter; is_repl takes no arguments -- the set it checks is the module-level `is_repl.repl_conditions` attribute. Rewrote the docstring to describe that correctly (fixes DOC102/DOC103). - README.md: add the "For AI agents" section (epythet ai-readme-check). epythet validate -i tests/ scrap/ examples/ -- config2py --level 2: before 17 Level-0.5 errors, 5 undocumented objects; after 0 errors at levels 0.5 and 1 (Sphinx build), 0 undocumented objects. pytest --doctest-modules: 127 -> 129 passed (2 new doctests added), all green. --- README.md | 11 ++++ config2py/base.py | 119 ++++++++++++++++++++++-------------- config2py/s_configparser.py | 31 ++++++++-- config2py/util.py | 110 +++++++++++++++++++-------------- 4 files changed, 173 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 7004bc6..469b2ff 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,18 @@ To install: ```pip install config2py``` [Documentation](https://i2mint.github.io/config2py/) + +## 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). + # The cherry on top: config_getter diff --git a/config2py/base.py b/config2py/base.py index bbd8551..6d43c4a 100644 --- a/config2py/base.py +++ b/config2py/base.py @@ -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: @@ -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 @@ -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: @@ -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( diff --git a/config2py/s_configparser.py b/config2py/s_configparser.py index 6083e97..a9ff128 100644 --- a/config2py/s_configparser.py +++ b/config2py/s_configparser.py @@ -56,6 +56,12 @@ 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__`` write their change to disk immediately. + """ + @wraps(method_func) def _method_func(self, *args, **kwargs): output = method_func(self, *args, **kwargs) @@ -68,10 +74,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, @@ -151,9 +159,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) @@ -162,6 +172,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'}} @@ -173,15 +184,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'} @@ -218,6 +232,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 ) @@ -248,6 +265,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() @@ -310,6 +328,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 @@ -361,12 +380,15 @@ class ConfigReader(ConfigStore): """ def persist(self): + """``ConfigReader`` is read-only and has nothing to persist.""" 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") @@ -419,6 +441,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'] diff --git a/config2py/util.py b/config2py/util.py index d9bb63d..73c30b2 100644 --- a/config2py/util.py +++ b/config2py/util.py @@ -58,9 +58,11 @@ class EnvironmentVariables(ChainMap): """ def __init__(self): + """Wrap ``os.environ`` as the (only) mapping in the chain.""" super().__init__(os.environ) def __repr__(self): + """Return a fixed, value-free label so secrets aren't printed.""" return "EnvironmentVariables" @@ -205,34 +207,36 @@ def create_directories(dirpath, max_dirs_to_make=None): Create directories up to a specified limit. Parameters: - dirpath (str): The directory path to create. - max_dirs_to_make (int, optional): The maximum number of directories to create. If None, there's no limit. + dirpath (str): The directory path to create. + max_dirs_to_make (int, optional): The maximum number of directories to + create. If None, there's no limit. Returns: - bool: True if the directory was created successfully, False otherwise. + bool: True if the directory was created successfully, False otherwise. Raises: - ValueError: If max_dirs_to_make is negative. + ValueError: If max_dirs_to_make is negative. Examples: - >>> import tempfile, shutil - >>> temp_dir = tempfile.mkdtemp() - >>> target_dir = os.path.join(temp_dir, 'a', 'b', 'c') - >>> create_directories(target_dir, max_dirs_to_make=2) - False - >>> create_directories(target_dir, max_dirs_to_make=3) - True - >>> os.path.isdir(target_dir) - True - >>> shutil.rmtree(temp_dir) # Cleanup - >>> temp_dir = tempfile.mkdtemp() - >>> target_dir = os.path.join(temp_dir, 'a', 'b', 'c', 'd') - >>> create_directories(target_dir) - True - >>> os.path.isdir(target_dir) - True - >>> shutil.rmtree(temp_dir) # Cleanup + >>> import tempfile, shutil + >>> temp_dir = tempfile.mkdtemp() + >>> target_dir = os.path.join(temp_dir, 'a', 'b', 'c') + >>> create_directories(target_dir, max_dirs_to_make=2) + False + >>> create_directories(target_dir, max_dirs_to_make=3) + True + >>> os.path.isdir(target_dir) + True + >>> shutil.rmtree(temp_dir) # Cleanup + + >>> temp_dir = tempfile.mkdtemp() + >>> target_dir = os.path.join(temp_dir, 'a', 'b', 'c', 'd') + >>> create_directories(target_dir) + True + >>> os.path.isdir(target_dir) + True + >>> shutil.rmtree(temp_dir) # Cleanup """ if max_dirs_to_make is not None and max_dirs_to_make < 0: raise ValueError("max_dirs_to_make must be non-negative or None") @@ -385,6 +389,7 @@ def get_app_rootdir( Returns the root directory for a specific folder kind. The folder kind determines which standard directory is returned: + - 'config': Configuration files (XDG_CONFIG_HOME, default ~/.config) - 'data': Application data (XDG_DATA_HOME, default ~/.local/share) - 'cache': Temporary/cache files (XDG_CACHE_HOME, default ~/.cache) @@ -392,6 +397,7 @@ def get_app_rootdir( - 'runtime': Runtime files (XDG_RUNTIME_DIR, default /tmp) On Windows: + - 'config': %APPDATA% - 'data': %LOCALAPPDATA% - 'cache': %LOCALAPPDATA%\\Temp @@ -416,6 +422,7 @@ def get_app_rootdir( Note: The default root folder follows XDG Base Directory standards on Unix/Linux/macOS. You can override this by setting environment variables: + - CONFIG2PY_CONFIG_DIR, CONFIG2PY_DATA_DIR, CONFIG2PY_CACHE_DIR, etc. (highest priority, overrides everything, and works on **every** platform -- see ``config2py_env_var`` for the full list of names) @@ -454,10 +461,10 @@ def _default_folder_setup(directory_path: str) -> None: with a hidden file for identification. Args: - - directory_path (str): Path to the directory to be initialized. + directory_path (str): Path to the directory to be initialized. Note: - This is the default setup callback for directories managed by config2py. + This is the default setup callback for directories managed by config2py. """ if not os.path.isdir(directory_path): os.makedirs(directory_path, exist_ok=True) @@ -477,14 +484,26 @@ def get_app_folder( """ Retrieve or create the app directory specific to the given app name and folder kind. - The folder kind determines where the app's files are stored: - Here are concise explanations for each folder kind: - **config**: User preferences and settings files (e.g., API keys, theme preferences, editor settings). Files users might edit manually or that define how the app behaves. - **data**: Essential user-created content and application state (e.g., databases, saved games, user documents, session files). Data that should be backed up and persists across updates. - **cache**: Temporary, regeneratable files (e.g., downloaded images, compiled assets, web cache). Can be safely deleted to free space without losing user work. - **state**: Application state and logs that persist between sessions but aren't critical user data (e.g., command history, undo history, recently opened files, log files). Unlike cache, shouldn't be auto-deleted. - **runtime**: Temporary runtime files that only exist while the app runs (e.g., PID files, Unix sockets, lock files, named pipes). Typically cleared on logout/reboot. - **TL;DR**: config = settings, data = user files, cache = disposable, state = logs/history, runtime = process files. + The folder kind determines where the app's files are stored. Here are concise + explanations for each folder kind: + + - **config**: User preferences and settings files (e.g., API keys, theme + preferences, editor settings). Files users might edit manually or that + define how the app behaves. + - **data**: Essential user-created content and application state (e.g., + databases, saved games, user documents, session files). Data that should + be backed up and persists across updates. + - **cache**: Temporary, regeneratable files (e.g., downloaded images, + compiled assets, web cache). Can be safely deleted to free space without + losing user work. + - **state**: Application state and logs that persist between sessions but + aren't critical user data (e.g., command history, undo history, recently + opened files, log files). Unlike cache, shouldn't be auto-deleted. + - **runtime**: Temporary runtime files that only exist while the app runs + (e.g., PID files, Unix sockets, lock files, named pipes). Typically + cleared on logout/reboot. + - **TL;DR**: config = settings, data = user files, cache = disposable, + state = logs/history, runtime = process files. Args: app_name: Name of the app for which the directory is needed. @@ -559,12 +578,12 @@ def get_configs_folder_for_app( Retrieve or create the configs directory specific to the given app name. Args: - - app_name (str): Name of the app for which the configs directory is needed. - - configs_name (str): Name of the configs directory. - - app_dir_setup_callback (Callable[[str], None]): A callback function to initialize the app directory. - Default is _default_folder_setup. - - config_dir_setup_callback (Callable[[str], None]): A callback function to initialize the configs directory. - Default is _default_folder_setup. + app_name (str): Name of the app for which the configs directory is needed. + configs_name (str): Name of the configs directory. + app_dir_setup_callback (Callable[[str], None]): A callback function to + initialize the app directory. Default is _default_folder_setup. + config_dir_setup_callback (Callable[[str], None]): A callback function to + initialize the configs directory. Default is _default_folder_setup. """ app_dir = get_app_config_folder(app_name, setup_callback=app_dir_setup_callback) configs_dir = os.path.join(app_dir, configs_name) @@ -612,7 +631,7 @@ def ensure_seeded( Returns: The resolved *target* as a ``Path``. - Example:: + Example: >>> from config2py import ensure_seeded >>> # ensure_seeded("/tmp/myfile.txt", "mypkg", "resources", "myfile.txt") @@ -651,7 +670,7 @@ class AppData: seed_data_dir: Name of the seed-data sub-package inside the Python package (default ``"_seed_data"``). - Example:: + Example: >>> app = AppData("myapp", package_name="myapp") >>> app.app_folder() # doctest: +SKIP @@ -665,6 +684,7 @@ def __init__( package_name: Optional[str] = None, seed_data_dir: str = "_seed_data", ): + """See the class docstring for each parameter.""" self.app_name = app_name self.package_name = package_name or app_name self.seed_data_dir = seed_data_dir @@ -737,17 +757,13 @@ def is_repl(): If you do ``python -i module.py``, or call it from a python console or jupyter notebook, it should return ``True``. - Args: - repl_conditions (list): A list of functions that return True if the interpreter - is running in a REPL, False otherwise. - By default, this is a list of two functions that check if: - - ``get_ipython`` is in globals - - ``__main__`` does not have a ``__file__`` attribute Returns: bool: True if running in a REPL, False otherwise. - is_repl.repl_conditions is a set of functions that return True if the interpreter. - This set can be modified to modify the behavior of ``is_repl``. + ``is_repl`` returns ``True`` if any function in ``is_repl.repl_conditions`` + (a set of no-argument, no-parameter callables) returns ``True``. By default that + set checks whether ``get_ipython`` is in globals, or whether ``__main__`` has no + ``__file__`` attribute. Reassign ``is_repl.repl_conditions`` to change the checks. """ if any(condition() for condition in _repl_conditions): return True From 3b1a33592d9cb7551eb911397e12bfca428b4383 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:42:08 +0200 Subject: [PATCH 3/3] docs: fix behaviour claims found by adversarial review - is_repl: the docstring said to "reassign" is_repl.repl_conditions to change the checks; the function reads the original module-level set object, so rebinding the attribute has no effect -- only mutating the set does. Corrected. - persist_after_operation / ConfigStore: dropped the "write to disk" claim; persist() only touches disk when target_kind == 'filepath', and returns serialized data without writing for 'string'/'bytes'/'dict' targets. - ConfigReader.persist: removed the invented "has nothing to persist" rationale -- it's disabled, not vacuous. --- config2py/s_configparser.py | 7 +++++-- config2py/util.py | 8 +++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/config2py/s_configparser.py b/config2py/s_configparser.py index a9ff128..00e14c0 100644 --- a/config2py/s_configparser.py +++ b/config2py/s_configparser.py @@ -59,7 +59,10 @@ 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__`` write their change to disk immediately. + ``__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) @@ -380,7 +383,7 @@ class ConfigReader(ConfigStore): """ def persist(self): - """``ConfigReader`` is read-only and has nothing to persist.""" + """Disabled: ``ConfigReader`` is read-only.""" raise NotImplementedError("persist disabled for ConfigReader") def __setitem__(self, k, v): diff --git a/config2py/util.py b/config2py/util.py index 73c30b2..af470d2 100644 --- a/config2py/util.py +++ b/config2py/util.py @@ -761,9 +761,11 @@ def is_repl(): bool: True if running in a REPL, False otherwise. ``is_repl`` returns ``True`` if any function in ``is_repl.repl_conditions`` - (a set of no-argument, no-parameter callables) returns ``True``. By default that - set checks whether ``get_ipython`` is in globals, or whether ``__main__`` has no - ``__file__`` attribute. Reassign ``is_repl.repl_conditions`` to change the checks. + (a set of no-argument callables) returns ``True``. By default that set checks + whether ``get_ipython`` is in globals, or whether ``__main__`` has no + ``__file__`` attribute. Mutate ``is_repl.repl_conditions`` in place (e.g. + ``is_repl.repl_conditions.add(fn)``) to change the checks -- rebinding the + attribute to a new set has no effect, since ``is_repl`` reads the original set. """ if any(condition() for condition in _repl_conditions): return True