Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-oslo.config for openSUSE:Factory checked in at 2026-08-09 21:34:46 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-oslo.config (Old) and /work/SRC/openSUSE:Factory/.python-oslo.config.new.16738 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-oslo.config" Sun Aug 9 21:34:46 2026 rev:52 rq:1369779 version:10.6.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-oslo.config/python-oslo.config.changes 2026-06-15 19:55:04.160668481 +0200 +++ /work/SRC/openSUSE:Factory/.python-oslo.config.new.16738/python-oslo.config.changes 2026-08-09 21:37:04.885801164 +0200 @@ -1,0 +2,14 @@ +Wed Aug 5 14:25:20 UTC 2026 - Dirk Müller <[email protected]> + +- update to 10.6.0: + * Name ConfigOpts serialized state version + * ruff: Enable log-related checks + * Support ConfigOpts state snapshots for spawn workers + * zuul: Use openstack-python3-next-jobs template + * pre-commit: Bump versions + * Run doc8 + * Do not install code to build release notes + * Loose validation for schemes + * Better support Sphinx doc generation with custom option types + +------------------------------------------------------------------- Old: ---- oslo_config-10.5.0.tar.gz New: ---- oslo_config-10.6.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-oslo.config.spec ++++++ --- /var/tmp/diff_new_pack.6viotX/_old 2026-08-09 21:37:05.341816691 +0200 +++ /var/tmp/diff_new_pack.6viotX/_new 2026-08-09 21:37:05.341816691 +0200 @@ -17,7 +17,7 @@ Name: python-oslo.config -Version: 10.5.0 +Version: 10.6.0 Release: 0 Summary: OpenStack common configuration library License: Apache-2.0 ++++++ oslo_config-10.5.0.tar.gz -> oslo_config-10.6.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/.pre-commit-config.yaml new/oslo_config-10.6.0/.pre-commit-config.yaml --- old/oslo_config-10.5.0/.pre-commit-config.yaml 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/.pre-commit-config.yaml 2026-07-16 13:15:12.000000000 +0200 @@ -13,13 +13,18 @@ - id: check-yaml files: .*\.(yaml|yml)$ - repo: https://opendev.org/openstack/hacking - rev: 8.0.0 + rev: 8.1.0 hooks: - id: hacking additional_dependencies: [] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.5 + rev: v0.15.19 hooks: - id: ruff-check args: ['--fix', '--unsafe-fixes'] - id: ruff-format + - repo: https://github.com/PyCQA/doc8 + rev: v2.0.0 + hooks: + - id: doc8 + args: ['--ignore', 'D001'] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/.zuul.yaml new/oslo_config-10.6.0/.zuul.yaml --- old/oslo_config-10.5.0/.zuul.yaml 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/.zuul.yaml 2026-07-16 13:15:12.000000000 +0200 @@ -1,8 +1,11 @@ +--- + - project: templates: - check-requirements - lib-forward-testing-python3 - openstack-python3-jobs + - openstack-python3-next-jobs - periodic-stable-jobs - publish-openstack-docs-pti - release-notes-jobs-python3 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/AUTHORS new/oslo_config-10.6.0/AUTHORS --- old/oslo_config-10.5.0/AUTHORS 2026-06-11 15:23:46.000000000 +0200 +++ new/oslo_config-10.6.0/AUTHORS 2026-07-16 13:15:43.000000000 +0200 @@ -31,6 +31,7 @@ Chuck Short <[email protected]> Chuck Short <[email protected]> Chung Chih, Hung <[email protected]> +Clif Houck <[email protected]> Corey Bryant <[email protected]> Cyril Roelandt <[email protected]> Cyril Roelandt <[email protected]> @@ -120,6 +121,7 @@ Michael Still <[email protected]> Michał Górny <[email protected]> Miguel Grinberg <[email protected]> +Mike Bayer <[email protected]> Mike Fedosin <[email protected]> Moises Guimaraes de Medeiros <[email protected]> Moisés Guimarães de Medeiros <[email protected]> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/ChangeLog new/oslo_config-10.6.0/ChangeLog --- old/oslo_config-10.5.0/ChangeLog 2026-06-11 15:23:46.000000000 +0200 +++ new/oslo_config-10.6.0/ChangeLog 2026-07-16 13:15:43.000000000 +0200 @@ -1,9 +1,23 @@ CHANGES ======= +10.6.0 +------ + +* Name ConfigOpts serialized state version +* ruff: Enable log-related checks +* Support ConfigOpts state snapshots for spawn workers +* zuul: Use openstack-python3-next-jobs template +* pre-commit: Bump versions +* Run doc8 +* Do not install code to build release notes +* Loose validation for schemes +* Better support Sphinx doc generation with custom option types + 10.5.0 ------ +* typing: Accept Sequence to ConfigOpts.\_\_call\_\_ * typing: Accept tuples to exception * Bump python version in mypy check * fix: setuptools.package.find is a glob diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/PKG-INFO new/oslo_config-10.6.0/PKG-INFO --- old/oslo_config-10.5.0/PKG-INFO 2026-06-11 15:23:46.495775200 +0200 +++ new/oslo_config-10.6.0/PKG-INFO 2026-07-16 13:15:43.727089000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: oslo.config -Version: 10.5.0 +Version: 10.6.0 Summary: Oslo Configuration API Author-email: OpenStack <[email protected]> License-Expression: Apache-2.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/doc/requirements.txt new/oslo_config-10.6.0/doc/requirements.txt --- old/oslo_config-10.5.0/doc/requirements.txt 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/doc/requirements.txt 2026-07-16 13:15:12.000000000 +0200 @@ -3,4 +3,3 @@ openstackdocstheme>=1.20.0 # Apache-2.0 reno>=2.5.0 # Apache-2.0 fixtures>=3.0.0 # Apache-2.0/BSD -doc8>=0.6.0 # Apache-2.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo.config.egg-info/PKG-INFO new/oslo_config-10.6.0/oslo.config.egg-info/PKG-INFO --- old/oslo_config-10.5.0/oslo.config.egg-info/PKG-INFO 2026-06-11 15:23:46.000000000 +0200 +++ new/oslo_config-10.6.0/oslo.config.egg-info/PKG-INFO 2026-07-16 13:15:43.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: oslo.config -Version: 10.5.0 +Version: 10.6.0 Summary: Oslo Configuration API Author-email: OpenStack <[email protected]> License-Expression: Apache-2.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo.config.egg-info/SOURCES.txt new/oslo_config-10.6.0/oslo.config.egg-info/SOURCES.txt --- old/oslo_config-10.5.0/oslo.config.egg-info/SOURCES.txt 2026-06-11 15:23:46.000000000 +0200 +++ new/oslo_config-10.6.0/oslo.config.egg-info/SOURCES.txt 2026-07-16 13:15:43.000000000 +0200 @@ -102,6 +102,7 @@ releasenotes/notes/add_option_for_shell_completion-47b1b47d41a490e8.yaml releasenotes/notes/config-from-environment-3feba7b4cc747d2b.yaml releasenotes/notes/config-validator-256817f2183994fd.yaml +releasenotes/notes/configopts-serialization-spawn-9f6d3a1c2b4e5f70.yaml releasenotes/notes/deprecate-version-module-10817e0cac220063.yaml releasenotes/notes/dict-key-value-separator-127ce36ba4d9245f.yaml releasenotes/notes/drop-python27-support-87f1b4089d4cc78b.yaml diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo.config.egg-info/pbr.json new/oslo_config-10.6.0/oslo.config.egg-info/pbr.json --- old/oslo_config-10.5.0/oslo.config.egg-info/pbr.json 2026-06-11 15:23:46.000000000 +0200 +++ new/oslo_config-10.6.0/oslo.config.egg-info/pbr.json 2026-07-16 13:15:43.000000000 +0200 @@ -1 +1 @@ -{"git_version": "183a7ab", "is_release": true} \ No newline at end of file +{"git_version": "e148266", "is_release": true} \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo_config/cfg.py new/oslo_config-10.6.0/oslo_config/cfg.py --- old/oslo_config-10.5.0/oslo_config/cfg.py 2026-06-11 15:22:56.000000000 +0200 +++ new/oslo_config-10.6.0/oslo_config/cfg.py 2026-07-16 13:15:12.000000000 +0200 @@ -26,6 +26,7 @@ import itertools import logging import os +import pickle import string import sys from typing import IO, Any, Protocol, TypedDict, cast @@ -49,6 +50,21 @@ LOG = logging.getLogger(__name__) +_CONFIG_OPTS_STATE_VERSION = 1 + + +def _identity(value: Any) -> Any: + return value + + +def _import_config_opts_state(state: Mapping[str, Any]) -> 'ConfigOpts': + return ConfigOpts.import_state(state) + + +def _import_global_config_opts_state(state: Mapping[str, Any]) -> 'ConfigOpts': + CONF.__setstate__(state) + return CONF + class _SupportsLog(Protocol): """Protocol for objects that support the log() interface.""" @@ -210,6 +226,10 @@ return f'Failed to parse {self.config_file}: {self.msg}' +class ConfigOptsSerializationError(Error): + """Raised if a ConfigOpts instance cannot be serialized safely.""" + + class ConfigSourceValueError(Error, ValueError): """Raised if a config source value does not match its opt type.""" @@ -1373,7 +1393,7 @@ :param name: the option's name :param max_length: If positive integer, the value must be less than or equal to this parameter. - :param schemes: list of valid URI schemes, e.g. 'https', 'ftp', 'git' + :param schemes: iterable of valid URI schemes, e.g. 'https', 'ftp', 'git' :param \*\*kwargs: arbitrary keyword arguments passed to :class:`Opt` .. versionadded:: 3.12 @@ -1388,7 +1408,7 @@ self, name: str, max_length: int | None = None, - schemes: list[str] | None = None, + schemes: Iterable[str] | None = None, **kwargs: Any, ) -> None: type = types.URI(max_length=max_length, schemes=schemes) @@ -1564,7 +1584,7 @@ ConfigParser._parse_file(values, namespace) def __init__(self, name: str, **kwargs: Any) -> None: - super().__init__(name, cast(types.ConfigType, lambda x: x), **kwargs) + super().__init__(name, cast(types.ConfigType, _identity), **kwargs) def _get_argparse_kwargs( self, group: 'OptGroup | None', **kwargs: Any @@ -1653,6 +1673,17 @@ default: Any +class _OptGroupState(TypedDict): + """Serialized form for an OptGroup.""" + + name: str + title: str + help: str | None + dynamic_group_owner: str + driver_option: str + opts: dict[str, _OptInfo] + + class _OptGroupGeneratorData(TypedDict): """Data returned by OptGroup._get_generator_data().""" @@ -1786,6 +1817,75 @@ group: OptGroup | None +def _copy_opt_info(info: _OptInfo) -> _OptInfo: + copied: _OptInfo = { + 'opt': info['opt'], + 'cli': info['cli'], + } + if 'location' in info: + copied['location'] = info['location'] + if 'override' in info: + copied['override'] = info['override'] + if 'default' in info: + copied['default'] = info['default'] + return copied + + +def _export_group(group: OptGroup) -> _OptGroupState: + if group._driver_opts: + raise ConfigOptsSerializationError( + 'cannot serialize ConfigOpts state because group ' + f'{group.name!r} contains driver options. Driver option metadata ' + 'is not part of the supported runtime configuration snapshot; ' + 'avoid passing ConfigOpts instances with driver option metadata ' + 'to spawn workers or rebuild that metadata in the child process.' + ) + return { + 'name': group.name, + 'title': group.title, + 'help': group.help, + 'dynamic_group_owner': group.dynamic_group_owner, + 'driver_option': group.driver_option, + 'opts': { + opt_name: _copy_opt_info(info) + for opt_name, info in group._opts.items() + }, + } + + +def _import_group(state: _OptGroupState) -> OptGroup: + group = OptGroup( + state['name'], + title=state['title'], + help=state['help'], + dynamic_group_owner=state['dynamic_group_owner'], + driver_option=state['driver_option'], + ) + group._opts = state['opts'] + return group + + +def _export_namespace(namespace: '_Namespace | None') -> dict[str, Any] | None: + if namespace is None: + return None + return { + key: value + for key, value in namespace.__dict__.items() + if key != '_conf' + } + + +def _import_namespace( + conf: 'ConfigOpts', state: Mapping[str, Any] | None +) -> '_Namespace | None': + if state is None: + return None + namespace = _Namespace(conf) + namespace.__dict__.update(state) + namespace._conf = conf + return namespace + + class ParseError(iniparser.ParseError): def __init__( self, msg: str, lineno: int, line: str | None, filename: str @@ -2291,6 +2391,14 @@ choices=supported_shell_completion, help='Display a shell completion script', ) + _serialized_setup_attrs = ( + 'project', + 'prog', + 'version', + 'usage', + 'default_config_files', + 'default_config_dirs', + ) def __init__(self) -> None: """Construct a ConfigOpts object.""" @@ -2320,6 +2428,190 @@ self.register_opt(self._config_source_opt) self.register_cli_opt(self._shell_completion_opt) + def _export_deprecated_opts( + self, + ) -> dict[str, dict[str, dict[str, Any]]]: + """Return a serializable copy of ``_deprecated_opts``. + + The live mapping stores raw :class:`OptGroup` references, which may + hold process-local argparse objects after CLI parsing. Replace those + references with the group name so the snapshot remains picklable; the + name is re-resolved to the live group on import. + """ + exported: dict[str, dict[str, dict[str, Any]]] = {} + for group_name, entries in self._deprecated_opts.items(): + exported[group_name] = { + dest: { + 'opt': info['opt'], + 'group_name': ( + info['group'].name + if info['group'] is not None + else None + ), + } + for dest, info in entries.items() + } + return exported + + def _import_deprecated_opts( + self, + state: Mapping[str, Mapping[str, Mapping[str, Any]]], + ) -> dict[str, dict[str, _DeprecatedOptInfo]]: + """Rebuild ``_deprecated_opts`` from an exported snapshot. + + ``OptGroup`` references are re-resolved against the groups restored by + :meth:`__setstate__`. A valid snapshot produced by ``export_state`` + always includes every group a deprecated entry references, because + ``register_opt`` creates the group before tracking it and + ``export_state`` serializes every group. A referenced group that is + absent from the restored ``_groups`` therefore indicates a corrupt or + hand-built state, which is rejected rather than silently repaired. + """ + imported: dict[str, dict[str, _DeprecatedOptInfo]] = {} + for group_name, entries in state.items(): + imported_entries: dict[str, _DeprecatedOptInfo] = {} + for dest, info in entries.items(): + ref_group_name: str | None = info['group_name'] + if ref_group_name is None: + ref_group: OptGroup | None = None + else: + ref_group = self._groups.get(ref_group_name) + if ref_group is None: + raise ConfigOptsSerializationError( + 'cannot import ConfigOpts state because the ' + f'deprecated option {dest!r} references group ' + f'{ref_group_name!r}, which is not present in ' + 'the serialized groups. The state is invalid.' + ) + imported_entries[dest] = { + 'opt': info['opt'], + 'group': ref_group, + } + imported[group_name] = imported_entries + return imported + + def export_state(self) -> dict[str, Any]: + """Return a serializable snapshot of this configuration state. + + The snapshot preserves registered opts and groups, parsed CLI and + config-file values, defaults, overrides, and setup metadata. It + intentionally excludes process-local parser objects, caches, mutation + hooks, extension managers, and environment drivers. + + Alternative configuration source objects are not serialized because + they may hold process-local resources. If any are loaded, exporting + fails instead of silently dropping meaningful configuration state. + """ + if self._sources: + raise ConfigOptsSerializationError( + 'cannot serialize ConfigOpts state because it contains loaded ' + 'configuration sources. Source objects may hold process-local ' + 'or non-picklable state; avoid passing this ConfigOpts ' + 'instance to spawn workers or use serializable configuration ' + 'inputs before spawning.' + ) + + state: dict[str, Any] = { + 'version': _CONFIG_OPTS_STATE_VERSION, + 'opts': { + opt_name: _copy_opt_info(info) + for opt_name, info in self._opts.items() + }, + 'groups': { + group_name: _export_group(group) + for group_name, group in self._groups.items() + }, + 'deprecated_opts': self._export_deprecated_opts(), + 'args': list(self._args) if self._args is not None else None, + 'namespace': _export_namespace(self._namespace), + 'mutable_ns': _export_namespace(self._mutable_ns), + 'config_opts': list(self._config_opts), + 'cli_opts': [ + ( + item['opt'], + item['group'].name if item['group'] is not None else None, + ) + for item in self._cli_opts + ], + 'validate_default_values': self._validate_default_values, + 'use_env': self._use_env, + 'setup_attrs': { + name: getattr(self, name) + for name in self._serialized_setup_attrs + if hasattr(self, name) + }, + } + self._assert_serializable(state) + return state + + @classmethod + def import_state(cls, state: Mapping[str, Any]) -> 'ConfigOpts': + """Create a ConfigOpts instance from an exported state snapshot.""" + conf = cls() + conf.__setstate__(state) + return conf + + @staticmethod + def _assert_serializable(state: Mapping[str, Any]) -> None: + try: + pickle.dumps(state) + except Exception as exc: + raise ConfigOptsSerializationError( + 'cannot serialize ConfigOpts state because it contains a ' + 'non-picklable option, option type, or source-related value. ' + 'Avoid passing such state to spawn workers or use ' + f'serializable option types. Original error: {exc}' + ) from exc + + def __getstate__(self) -> dict[str, Any]: + return self.export_state() + + def __setstate__(self, state: Mapping[str, Any]) -> None: + if state.get('version') != _CONFIG_OPTS_STATE_VERSION: + raise ConfigOptsSerializationError( + 'unsupported ConfigOpts serialized state version ' + f'{state.get("version")!r}' + ) + + self._opts = state['opts'] + self._groups = { + group_name: _import_group(group_state) + for group_name, group_state in state['groups'].items() + } + self._deprecated_opts = self._import_deprecated_opts( + state['deprecated_opts'] + ) + self._args = state['args'] + self._oparser = None + self._namespace = _import_namespace(self, state['namespace']) + self._mutable_ns = _import_namespace(self, state['mutable_ns']) + self._mutate_hooks = set() + self.__cache = {} + self.__drivers_cache = {} + self._config_opts = state['config_opts'] + self._cli_opts = collections.deque( + { + 'opt': opt, + 'group': self._groups[group_name] + if group_name is not None + else None, + } + for opt, group_name in state['cli_opts'] + ) + self._validate_default_values = state['validate_default_values'] + self._sources = [] + self._ext_mgr = None + self._use_env = state['use_env'] + self._env_driver = _environment.EnvironmentConfigurationSource() + + for name, value in state['setup_attrs'].items(): + setattr(self, name, value) + + def __reduce__(self) -> tuple[Any, tuple[dict[str, Any]]]: + if self is CONF: + return (_import_global_config_opts_state, (self.export_state(),)) + return (_import_config_opts_state, (self.export_state(),)) + def _pre_setup( self, project: str | None, @@ -2449,7 +2741,7 @@ def __call__( self, - args: list[str] | None = None, + args: Sequence[str] | None = None, project: str | None = None, prog: str | None = None, version: str | None = None, @@ -3618,7 +3910,7 @@ if self._get(opt.dest, group, namespace) is None: raise RequiredOptError(opt.name, group) - def _parse_cli_opts(self, args: list[str]) -> '_Namespace': + def _parse_cli_opts(self, args: Sequence[str]) -> '_Namespace': """Parse command line options. Initializes the command line option parser and parses the supplied @@ -3630,7 +3922,7 @@ ConfigFileParseError, ConfigFileValueError """ - self._args = args + self._args = list(args) assert self._oparser is not None for opt, group in self._all_cli_opts(): opt._add_to_cli(self._oparser, group) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo_config/sphinxconfiggen.py new/oslo_config-10.6.0/oslo_config/sphinxconfiggen.py --- old/oslo_config-10.5.0/oslo_config/sphinxconfiggen.py 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/oslo_config/sphinxconfiggen.py 2026-07-16 13:15:12.000000000 +0200 @@ -62,7 +62,7 @@ app: Sphinx, config_file: str, base_name: str | None ) -> None: def info(msg: str) -> None: - LOG.info(f'[{__name__}] {msg}') + LOG.info('[%s] {%s}', __name__, msg) # If we are given a file that isn't an absolute path, look for it # in the source directory if it doesn't exist. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo_config/sphinxext.py new/oslo_config-10.6.0/oslo_config/sphinxext.py --- old/oslo_config-10.5.0/oslo_config/sphinxext.py 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/oslo_config/sphinxext.py 2026-07-16 13:15:12.000000000 +0200 @@ -93,6 +93,9 @@ } +_UNKNOWN_TYPE = 'unknown type' + + def _get_choice_text(choice: Any) -> str: if choice is None: return '<None>' @@ -102,7 +105,13 @@ def _format_opt(opt: Any, group_name: str) -> Generator[str, None, None]: - opt_type = _TYPE_DESCRIPTIONS.get(type(opt), 'unknown type') + opt_type = _TYPE_DESCRIPTIONS.get(type(opt), _UNKNOWN_TYPE) + + # NOTE(clif): If we don't have a type description handy, fall back to + # trying to retrieve the type from opt.type if available. + if opt_type == _UNKNOWN_TYPE and hasattr(opt, 'type'): + opt_type = getattr(opt.type, 'type_name', _UNKNOWN_TYPE) + yield f'.. oslo.config:option:: {opt.dest}' yield '' yield _indent(f':Type: {opt_type}') diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo_config/tests/test_cfg.py new/oslo_config-10.6.0/oslo_config/tests/test_cfg.py --- old/oslo_config-10.5.0/oslo_config/tests/test_cfg.py 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/oslo_config/tests/test_cfg.py 2026-07-16 13:15:12.000000000 +0200 @@ -17,11 +17,14 @@ import functools import io import logging +import multiprocessing import os +import pickle +import queue import shutil import sys import tempfile -from typing import Any +from typing import Any, cast import unittest from unittest import mock @@ -35,6 +38,46 @@ load_tests = testscenarios.load_tests_apply_scenarios +class _UnserializableType: + def __init__(self): + self._not_picklable = lambda value: value + + def __call__(self, value): + return value + + +def _spawn_config_opts_worker(conf, result_queue): + try: + assert conf.spawn_default == 'changed-default' + assert conf.spawn_override == 'override-value' + assert conf.spawn_cli == 'cli-value' + assert conf.spawn_file == 'file-value' + assert conf.spawn_bool is True + assert conf.spawn_int == 42 + assert conf.spawn_list == ['alpha', 'beta'] + assert conf.spawn_dict == {'one': '1', 'two': '2'} + assert conf.spawn_multi == ['first', 'second'] + assert conf.spawn_group.spawn_group_default == 'group-default' + assert conf.spawn_group.spawn_group_override == 'group-override' + assert conf.spawn_group.spawn_group_file == 'group-file' + except Exception as exc: + result_queue.put(('error', repr(exc))) + else: + result_queue.put(('ok', None)) + + +def _spawn_global_conf_worker(conf, result_queue): + try: + assert conf is cfg.CONF + assert cfg.CONF.ovn.ovsdb_retry_max_interval == 42 + assert cfg.CONF.ovn.ovn_metadata_enabled is True + assert cfg.CONF.agent.extensions == ['metadata'] + except Exception as exc: + result_queue.put(('error', repr(exc))) + else: + result_queue.put(('ok', None)) + + class ExceptionsTestCase(base.BaseTestCase): def test_error(self): msg = str(cfg.Error('foobar')) @@ -5583,6 +5626,448 @@ self.assertRaises(cfg.NotInitializedError, conf.print_help) +class ConfigOptsSerializationTestCase(BaseTestCase): + def _roundtrip_pickle(self, conf): + return pickle.loads(pickle.dumps(conf)) + + def test_basic_export_import_state(self): + conf = cfg.ConfigOpts() + conf([]) + + restored = cfg.ConfigOpts.import_state(conf.export_state()) + + self.assertEqual([], restored.config_file) + self.assertEqual([], restored.config_dir) + + def test_basic_pickle_serialization(self): + conf = cfg.ConfigOpts() + conf([]) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual([], restored.config_file) + self.assertEqual([], restored.config_dir) + + def test_unknown_state_version_fails_clearly(self): + conf = cfg.ConfigOpts() + state = conf.export_state() + state['version'] = 2 + + exc = self.assertRaises( + cfg.ConfigOptsSerializationError, + cfg.ConfigOpts.import_state, + state, + ) + self.assertIn( + 'unsupported ConfigOpts serialized state version', str(exc) + ) + + def test_registered_options_survive_serialization(self): + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('registered_opt', default='registered')) + conf([]) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('registered', restored.registered_opt) + + def test_common_option_types_survive_serialization(self): + conf = cfg.ConfigOpts() + conf.register_cli_opt(cfg.BoolOpt('bool_opt')) + conf.register_cli_opt(cfg.IntOpt('int_opt')) + conf.register_cli_opt(cfg.ListOpt('list_opt')) + conf.register_cli_opt(cfg.DictOpt('dict_opt')) + conf.register_cli_opt(cfg.MultiStrOpt('multi_opt')) + conf( + [ + '--bool_opt', + '--int_opt=42', + '--list_opt=alpha,beta', + '--dict_opt=one:1,two:2', + '--multi_opt=first', + '--multi_opt=second', + ] + ) + + restored_from_state = cfg.ConfigOpts.import_state(conf.export_state()) + restored_from_pickle = self._roundtrip_pickle(conf) + + for restored in (restored_from_state, restored_from_pickle): + self.assertIs(True, restored.bool_opt) + self.assertEqual(42, restored.int_opt) + self.assertEqual(['alpha', 'beta'], restored.list_opt) + self.assertEqual({'one': '1', 'two': '2'}, restored.dict_opt) + self.assertEqual(['first', 'second'], restored.multi_opt) + + def test_set_default_before_parse_survives_serialization(self): + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('default_opt', default='original')) + conf.set_default('default_opt', 'changed') + conf([]) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('changed', restored.default_opt) + + def test_set_default_after_parse_survives_serialization(self): + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('default_opt', default='original')) + conf([]) + conf.set_default('default_opt', 'changed') + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('changed', restored.default_opt) + + def test_set_override_before_parse_survives_serialization(self): + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('override_opt', default='original')) + conf.set_override('override_opt', 'override') + conf([]) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('override', restored.override_opt) + + def test_set_override_after_parse_survives_serialization(self): + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('override_opt', default='original')) + conf([]) + conf.set_override('override_opt', 'override') + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('override', restored.override_opt) + + def test_group_values_survive_serialization(self): + conf = cfg.ConfigOpts() + conf.register_group(cfg.OptGroup('serial_group')) + conf.register_opt( + cfg.StrOpt('group_default_opt', default='original'), + group='serial_group', + ) + conf.register_opt( + cfg.StrOpt('group_override_opt', default='original'), + group='serial_group', + ) + conf.set_default('group_default_opt', 'changed', group='serial_group') + conf.set_override( + 'group_override_opt', 'override', group='serial_group' + ) + conf([]) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('changed', restored.serial_group.group_default_opt) + self.assertEqual('override', restored.serial_group.group_override_opt) + + def test_config_file_values_survive_serialization(self): + paths = self.create_tempfiles( + [('serialization', '[DEFAULT]\nfile_opt = file-value\n')] + ) + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('file_opt')) + conf(args=[], default_config_files=paths, use_env=False) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('file-value', restored.file_opt) + self.assertEqual(paths, restored.config_file) + + def test_multiple_config_files_preserve_precedence(self): + paths = self.create_tempfiles( + [ + ('serialization-1', '[DEFAULT]\nfile_opt = first\n'), + ('serialization-2', '[DEFAULT]\nfile_opt = second\n'), + ] + ) + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('file_opt')) + conf(args=[], default_config_files=paths, use_env=False) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('second', restored.file_opt) + self.assertEqual(paths, restored.config_file) + + def test_group_values_from_multiple_config_files_preserve_precedence(self): + paths = self.create_tempfiles( + [ + ( + 'serialization-group-1', + '[serial_group]\nfile_opt = first\n', + ), + ( + 'serialization-group-2', + '[serial_group]\nfile_opt = second\n', + ), + ] + ) + conf = cfg.ConfigOpts() + conf.register_group(cfg.OptGroup('serial_group')) + conf.register_opt(cfg.StrOpt('file_opt'), group='serial_group') + conf(args=[], default_config_files=paths, use_env=False) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('second', restored.serial_group.file_opt) + + def test_deprecated_opts_survive_serialization(self): + paths = self.create_tempfiles( + [('deprecated-serialization', '[DEFAULT]\nold_opt = from-file\n')] + ) + conf = cfg.ConfigOpts() + conf.register_opt( + cfg.StrOpt( + 'new_opt', + deprecated_opts=[cfg.DeprecatedOpt('old_opt')], + ) + ) + conf(args=[], default_config_files=paths, use_env=False) + + restored = self._roundtrip_pickle(conf) + + self.assertEqual('from-file', restored.new_opt) + + def test_deprecated_opts_in_group_with_cli_opt_survive_serialization(self): + paths = self.create_tempfiles( + [ + ( + 'deprecated-group-cli', + '[serial_group]\nold_group_opt = group-from-file\n', + ) + ] + ) + conf = cfg.ConfigOpts() + conf.register_group(cfg.OptGroup('serial_group')) + # A CLI opt in the named group forces OptGroup._argparse_group to be + # populated with an unpicklable argparse object during parsing. + conf.register_cli_opt( + cfg.StrOpt('serial_group_cli'), group='serial_group' + ) + conf.register_opt( + cfg.StrOpt( + 'new_group_opt', + deprecated_opts=[ + cfg.DeprecatedOpt('old_group_opt', group='serial_group') + ], + ), + group='serial_group', + ) + conf(args=[], default_config_files=paths, use_env=False) + + restored = self._roundtrip_pickle(conf) + + # The deprecated name in the config file still resolves to the new opt. + self.assertEqual( + 'group-from-file', restored.serial_group.new_group_opt + ) + # The deprecated-alias reverse lookup survives and is keyed on the + # group. + self.assertEqual( + ('new_group_opt', 'serial_group'), + restored._find_deprecated_opts('old_group_opt', 'serial_group'), + ) + # The restored _deprecated_opts re-resolves to the live group instance + # rather than carrying a duplicate OptGroup with process-local state. + dep_group = restored._deprecated_opts['serial_group']['old_group_opt'][ + 'group' + ] + self.assertIs(dep_group, restored._groups['serial_group']) + self.assertIsNone(dep_group._argparse_group) + + def test_import_state_rejects_missing_referenced_group(self): + # A valid state produced by export_state always includes every group a + # deprecated entry references. A state where such a group is absent is + # corrupt; import should reject it rather than silently repairing it. + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('new_opt')) + conf([]) + state = conf.export_state() + state['deprecated_opts']['ghost_group'] = { + 'old_x': { + 'opt': conf._opts['new_opt']['opt'], + 'group_name': 'ghost_group', + } + } + + exc = self.assertRaises( + cfg.ConfigOptsSerializationError, + cfg.ConfigOpts.import_state, + state, + ) + self.assertIn( + "references group 'ghost_group', which is not present", + str(exc), + ) + + def test_repeated_pickle_roundtrip(self): + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('roundtrip_opt', default='original')) + conf.set_override('roundtrip_opt', 'override') + conf([]) + + restored = self._roundtrip_pickle(self._roundtrip_pickle(conf)) + + self.assertEqual('override', restored.roundtrip_opt) + + def test_unserializable_opt_fails_clearly(self): + conf = cfg.ConfigOpts() + conf.register_opt( + cfg.Opt( + 'broken_opt', + type=cast(types.ConfigType, _UnserializableType()), + ) + ) + conf([]) + + exc = self.assertRaises( + cfg.ConfigOptsSerializationError, + conf.export_state, + ) + self.assertIn('cannot serialize ConfigOpts state', str(exc)) + + def test_loaded_sources_fail_clearly(self): + conf = cfg.ConfigOpts() + conf._sources.append(mock.Mock()) + + exc = self.assertRaises( + cfg.ConfigOptsSerializationError, + conf.export_state, + ) + self.assertIn('loaded configuration sources', str(exc)) + + def test_driver_opts_fail_clearly(self): + conf = cfg.ConfigOpts() + group = cfg.OptGroup('driver_group') + group._save_driver_opts({'driver': [cfg.StrOpt('driver_opt')]}) + conf.register_group(group) + + exc = self.assertRaises( + cfg.ConfigOptsSerializationError, + conf.export_state, + ) + self.assertIn('contains driver options', str(exc)) + + def test_multiprocessing_spawn_observes_parent_state(self): + paths = self.create_tempfiles( + [ + ( + 'spawn', + '[DEFAULT]\n' + 'spawn_file = file-value\n' + 'spawn_bool = true\n' + 'spawn_int = 42\n' + 'spawn_list = alpha,beta\n' + 'spawn_dict = one:1,two:2\n' + 'spawn_multi = first\n' + 'spawn_multi = second\n' + '[spawn_group]\n' + 'spawn_group_file = group-file\n', + ) + ] + ) + conf = cfg.ConfigOpts() + conf.register_opt(cfg.StrOpt('spawn_default', default='original')) + conf.register_opt(cfg.StrOpt('spawn_override', default='original')) + conf.register_cli_opt(cfg.StrOpt('spawn_cli')) + conf.register_opt(cfg.StrOpt('spawn_file')) + conf.register_opt(cfg.BoolOpt('spawn_bool')) + conf.register_opt(cfg.IntOpt('spawn_int')) + conf.register_opt(cfg.ListOpt('spawn_list')) + conf.register_opt(cfg.DictOpt('spawn_dict')) + conf.register_opt(cfg.MultiStrOpt('spawn_multi')) + conf.register_group(cfg.OptGroup('spawn_group')) + conf.register_opt( + cfg.StrOpt('spawn_group_default', default='group-original'), + group='spawn_group', + ) + conf.register_opt( + cfg.StrOpt('spawn_group_override', default='group-original'), + group='spawn_group', + ) + conf.register_opt( + cfg.StrOpt('spawn_group_file'), + group='spawn_group', + ) + conf( + args=['--spawn_cli=cli-value'], + default_config_files=paths, + use_env=False, + ) + conf.set_default('spawn_default', 'changed-default') + conf.set_override('spawn_override', 'override-value') + conf.set_default( + 'spawn_group_default', 'group-default', group='spawn_group' + ) + conf.set_override( + 'spawn_group_override', 'group-override', group='spawn_group' + ) + + ctx = multiprocessing.get_context('spawn') + result_queue = ctx.Queue() + proc = ctx.Process( + target=_spawn_config_opts_worker, + args=(conf, result_queue), + ) + proc.start() + proc.join(10) + if proc.is_alive(): + proc.terminate() + proc.join() + self.fail('spawn worker did not finish') + + try: + status, details = result_queue.get(timeout=5) + except queue.Empty: + self.fail('spawn worker did not report a result') + + self.assertEqual(0, proc.exitcode) + self.assertEqual(('ok', None), (status, details)) + + def test_multiprocessing_spawn_restores_global_conf_singleton(self): + state = cfg.CONF.export_state() + self.addCleanup(cfg.CONF.__setstate__, state) + cfg.CONF.__setstate__(cfg.ConfigOpts().export_state()) + + cfg.CONF.register_group(cfg.OptGroup('ovn')) + cfg.CONF.register_opt( + cfg.IntOpt('ovsdb_retry_max_interval', default=10), + group='ovn', + ) + cfg.CONF.register_opt( + cfg.BoolOpt('ovn_metadata_enabled', default=False), + group='ovn', + ) + cfg.CONF.register_group(cfg.OptGroup('agent')) + cfg.CONF.register_opt(cfg.ListOpt('extensions'), group='agent') + cfg.CONF([]) + cfg.CONF.set_override('ovsdb_retry_max_interval', 42, group='ovn') + cfg.CONF.set_override('ovn_metadata_enabled', True, group='ovn') + cfg.CONF.set_override('extensions', ['metadata'], group='agent') + + ctx = multiprocessing.get_context('spawn') + result_queue = ctx.Queue() + proc = ctx.Process( + target=_spawn_global_conf_worker, + args=(cfg.CONF, result_queue), + ) + proc.start() + proc.join(10) + if proc.is_alive(): + proc.terminate() + proc.join() + self.fail('spawn worker did not finish') + + try: + status, details = result_queue.get(timeout=5) + except queue.Empty: + self.fail('spawn worker did not report a result') + + self.assertEqual(0, proc.exitcode) + self.assertEqual(('ok', None), (status, details)) + + class OptTestCase(base.BaseTestCase): def test_opt_eq(self): d1 = cfg.ListOpt('oldfoo') diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo_config/tests/test_sphinxext.py new/oslo_config-10.6.0/oslo_config/tests/test_sphinxext.py --- old/oslo_config-10.5.0/oslo_config/tests/test_sphinxext.py 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/oslo_config/tests/test_sphinxext.py 2026-07-16 13:15:12.000000000 +0200 @@ -17,6 +17,7 @@ from oslo_config import cfg from oslo_config import sphinxext +from oslo_config import types class FormatGroupTest(base.BaseTestCase): @@ -81,6 +82,49 @@ ''').lstrip(), results, ) + + def test_with_custom_type(self): + TEST_TYPE_NAME = 'test type' + + class TestType(types.ConfigType): + def __init__(self): + super().__init__(type_name=TEST_TYPE_NAME) + + def __call__(self, value): + return str(value) + + def _formatter(self, value): + return self.quote_trailing_and_leading_space(value) + + results = '\n'.join( + list( + sphinxext._format_group_opts( + namespace=None, + group_name=None, + group_obj=None, + opt_list=[ + cfg.Opt( + 'custom_type_opt_name', + type=TestType(), + help='this is a custom option type', + ), + ], + ) + ) + ) + self.assertEqual( + textwrap.dedent(''' + .. oslo.config:group:: DEFAULT + + .. oslo.config:option:: custom_type_opt_name + + :Type: test type + :Default: ``<None>`` + + this is a custom option type + ''').lstrip(), + results, + ) def test_with_min(self): results = '\n'.join( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo_config/types.py new/oslo_config-10.6.0/oslo_config/types.py --- old/oslo_config-10.5.0/oslo_config/types.py 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/oslo_config/types.py 2026-07-16 13:15:12.000000000 +0200 @@ -21,7 +21,7 @@ """ import collections -from collections.abc import Callable, KeysView +from collections.abc import Callable, Iterable, KeysView import operator import re import warnings @@ -1056,7 +1056,7 @@ def __init__( self, max_length: int | None = None, - schemes: list[str] | None = None, + schemes: Iterable[str] | None = None, type_name: str = 'uri value', ) -> None: super().__init__(type_name=type_name) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/oslo_config/validator.py new/oslo_config-10.6.0/oslo_config/validator.py --- old/oslo_config-10.5.0/oslo_config/validator.py 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/oslo_config/validator.py 2026-07-16 13:15:12.000000000 +0200 @@ -83,6 +83,9 @@ ] +LOG = logging.getLogger(__name__) + + KNOWN_BAD_GROUPS = ['keystone_authtoken'] @@ -127,7 +130,7 @@ if group in conf.exclude_group: continue if group not in sections: - logging.warning( + LOG.warning( 'Group %s from the sample config is not defined in input-file', group, ) @@ -156,7 +159,7 @@ keyname = opt.get('dest') if any(rex.fullmatch(keyname) for rex in exclusion_regexes): - logging.info( + LOG.info( '%s/%s Ignoring option because it is part of the excluded ' 'patterns. This can be changed with the --exclude-options ' 'argument', @@ -166,11 +169,9 @@ continue if len(value) > 1: - logging.info( - '%s/%s defined %s times', group, keyname, len(value) - ) + LOG.info('%s/%s defined %s times', group, keyname, len(value)) if not opt['default']: - logging.warning( + LOG.warning( '%s/%s sample value is empty but input-file has %s', group, keyname, @@ -178,7 +179,7 @@ ) warnings = True elif not frozenset(defaults).intersection(value): - logging.warning( + LOG.warning( '%s/%s sample value %s is not in %s', group, keyname, @@ -224,11 +225,11 @@ continue for option in options: if _validate_deprecated_opt(section, option, opt_data): - logging.warning('Deprecated opt %s/%s found', section, option) + LOG.warning('Deprecated opt %s/%s found', section, option) warnings = True elif not _validate_opt(section, option, opt_data): if section in KNOWN_BAD_GROUPS: - logging.info( + LOG.info( 'Ignoring missing option "%s" from group ' '"%s" because the group is known to have ' 'incomplete sample config data and thus ' @@ -237,7 +238,7 @@ section, ) continue - logging.error( + LOG.error( '%s/%s is not part of the sample config', section, option ) errors = True diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/pyproject.toml new/oslo_config-10.6.0/pyproject.toml --- old/oslo_config-10.5.0/pyproject.toml 2026-06-11 15:22:55.000000000 +0200 +++ new/oslo_config-10.6.0/pyproject.toml 2026-07-16 13:15:12.000000000 +0200 @@ -75,7 +75,7 @@ docstring-code-format = true [tool.ruff.lint] -select = ["E4", "E5", "E7", "E9", "F", "S", "UP", "W"] +select = ["E4", "E5", "E7", "E9", "F", "S", "G", "LOG", "UP", "W"] external = ["H"] ignore = [ "S101", # asserts are only used for type narrowing diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/releasenotes/notes/configopts-serialization-spawn-9f6d3a1c2b4e5f70.yaml new/oslo_config-10.6.0/releasenotes/notes/configopts-serialization-spawn-9f6d3a1c2b4e5f70.yaml --- old/oslo_config-10.5.0/releasenotes/notes/configopts-serialization-spawn-9f6d3a1c2b4e5f70.yaml 1970-01-01 01:00:00.000000000 +0100 +++ new/oslo_config-10.6.0/releasenotes/notes/configopts-serialization-spawn-9f6d3a1c2b4e5f70.yaml 2026-07-16 13:15:12.000000000 +0200 @@ -0,0 +1,14 @@ +--- +features: + - | + ``ConfigOpts`` instances can now export and import a serialized state and + use that state for pickle serialization. This allows configuration state to + be passed to multiprocessing workers that use the ``spawn`` start method. + The serialized state preserves registered options and groups, parsed + command-line and config-file values, defaults, overrides, and setup + metadata. + + Process-local helpers such as argparse parsers, caches, extension managers, + mutation hooks, and environment drivers are intentionally excluded from the + serialized state. Loaded configuration source objects are rejected with + ``ConfigOptsSerializationError`` instead of being silently dropped. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/oslo_config-10.5.0/tox.ini new/oslo_config-10.6.0/tox.ini --- old/oslo_config-10.5.0/tox.ini 2026-06-11 15:22:56.000000000 +0200 +++ new/oslo_config-10.6.0/tox.ini 2026-07-16 13:15:12.000000000 +0200 @@ -13,7 +13,6 @@ {env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} deps = -r{toxinidir}/test-requirements.txt - -r{toxinidir}/requirements.txt -r{toxinidir}/doc/requirements.txt commands = find . -type f -name "*.pyc" -delete @@ -64,6 +63,7 @@ sphinx-build -W --keep-going -b html -j auto doc/source doc/build/html {posargs} [testenv:releasenotes] +skip_install = true allowlist_externals = rm deps = {[testenv:docs]deps}
