From a2690d6b123757e6776755e3fc907b79a868398a Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Mon, 18 May 2026 13:23:15 +0300 Subject: [PATCH 01/17] Split single-module library --- python-config.spec | 4 +- python_config.py | 171 ------------------------------------ python_config/__init__.py | 15 ++++ python_config/_compat.py | 20 +++++ python_config/exceptions.py | 42 +++++++++ python_config/file.py | 36 ++++++++ python_config/validation.py | 79 +++++++++++++++++ setup.py | 2 +- 8 files changed, 195 insertions(+), 174 deletions(-) delete mode 100644 python_config.py create mode 100644 python_config/__init__.py create mode 100644 python_config/_compat.py create mode 100644 python_config/exceptions.py create mode 100644 python_config/file.py create mode 100644 python_config/validation.py diff --git a/python-config.spec b/python-config.spec index eef389a..4ca89cc 100644 --- a/python-config.spec +++ b/python-config.spec @@ -59,8 +59,8 @@ make PYTHON=%{__python3} check %files -n python%{python3_pkgversion}-config %defattr(-,root,root,-) -%{python3_sitelib}/python_config.py -%{python3_sitelib}/__pycache__/python_config.*.py* +%{python3_sitelib}/python_config/ +%{python3_sitelib}/python_config/__pycache__/ %{python3_sitelib}/python_config-%{version}-*.egg-info %doc ChangeLog INSTALL README diff --git a/python_config.py b/python_config.py deleted file mode 100644 index 18e8938..0000000 --- a/python_config.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Python configuration file parser.""" - -from __future__ import unicode_literals - -import imp -import sys - -_PY2 = sys.version_info < (3,) -if _PY2: - str = unicode - - -_BASIC_TYPES = (bool, int, float, bytes, str) -"""Python basic types.""" - -if _PY2: - _BASIC_TYPES += (long,) - -_COMPLEX_TYPES = (tuple, list, set, dict) -"""Python complex types.""" - -_VALID_TYPES = _BASIC_TYPES + _COMPLEX_TYPES -"""Option value must be one of these types.""" - - - -class Error(Exception): - """The base class for all exceptions that the module raises.""" - - def __init__(self, error, *args, **kwargs): - super(Error, self).__init__(error.format(*args, **kwargs) if args or kwargs else error) - - -class FileReadingError(Error): - """Error while reading a configuration file.""" - - def __init__(self, path, error): - super(FileReadingError, self).__init__( - "Error while reading '{0}' configuration file: {1}.", path, error.strerror) - self.errno = error.errno - - -class ParsingError(Error): - """Error while parsing a configuration file.""" - - def __init__(self, path, error): - super(ParsingError, self).__init__( - "Error while parsing '{0}' configuration file: {1}.", path, error) - - -class ValidationError(Error): - """Error during validation of a configuration file.""" - - def __init__(self, path, error): - super(ValidationError, self).__init__( - "Error while parsing '{0}' configuration file: {1}.", path, error) - self.option_name = error.option_name - - -class _ValidationError(Error): - """Same as ValidationError, but for internal usage.""" - - def __init__(self, option_name, *args, **kwargs): - super(_ValidationError, self).__init__(*args, **kwargs) - self.option_name = option_name - - - -def load(path, contents=None): - """Loads a configuration file.""" - - config_module = imp.new_module("config") - config_module.__file__ = path - - if contents is None: - try: - with open(path) as config_file: - contents = config_file.read() - except EnvironmentError as e: - raise FileReadingError(path, e) - - try: - exec(compile(contents, path, "exec"), config_module.__dict__) - except Exception as e: - raise ParsingError(path, e) - - config = {} - - for option, value in config_module.__dict__.items(): - if not option.startswith("_") and option.isupper(): - try: - config[option.lower()] = _validate_value(option, value) - except _ValidationError as e: - raise ValidationError(path, e) - - return config - - -def _validate_value(option, value, valid_types=_VALID_TYPES): - """Validates an option value.""" - - value_type = type(value) - - if value_type not in valid_types: - raise _ValidationError(option, - "{option} has an invalid value type ({type}). Allowed types: {valid_types}.", - option=option, type=value_type.__name__, - valid_types=", ".join(t.__name__ for t in valid_types)) - - if value_type is dict: - value = _validate_dict(option, value) - elif value_type is list: - value = _validate_list(option, value) - elif value_type is tuple: - value = _validate_tuple(option, value) - elif value_type is set: - value = _validate_set(option, value) - elif value_type is bytes: - try: - value = value.decode() - except UnicodeDecodeError as e: - raise _ValidationError(option, "{0} has an invalid value: {1}.", option, e) - - return value - - -def _validate_dict(option, dictionary): - """Validates a dictionary.""" - - for key, value in tuple(dictionary.items()): - valid_key = _validate_value("A {0}'s key".format(option), - key, valid_types=_BASIC_TYPES) - - valid_value = _validate_value("{0}[{1}]".format(option, repr(key)), value) - - if valid_key is not key: - del dictionary[key] - dictionary[valid_key] = valid_value - elif valid_value is not value: - dictionary[valid_key] = valid_value - - return dictionary - - -def _validate_list(option, sequence): - """Validates a list.""" - - for index, value in enumerate(sequence): - valid_value = _validate_value("{0}[{1}]".format(option, index), value) - if valid_value is not value: - sequence[index] = valid_value - - return sequence - - -def _validate_tuple(option, sequence): - """Validates a tuple.""" - - return [ - _validate_value("{0}[{1}]".format(option, index), value) - for index, value in enumerate(sequence) - ] - - -def _validate_set(option, sequence): - """Validates a set.""" - - return [ - _validate_value("A {0}'s key".format(option), value) - for value in sequence - ] diff --git a/python_config/__init__.py b/python_config/__init__.py new file mode 100644 index 0000000..7534b6a --- /dev/null +++ b/python_config/__init__.py @@ -0,0 +1,15 @@ +"""Python configuration file parser.""" + +from __future__ import unicode_literals + +from .exceptions import Error, FileReadingError, ParsingError, ValidationError +from .file import load + + +__all__ = [ + "Error", + "FileReadingError", + "ParsingError", + "ValidationError", + "load", +] diff --git a/python_config/_compat.py b/python_config/_compat.py new file mode 100644 index 0000000..ae8f317 --- /dev/null +++ b/python_config/_compat.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals + +import sys + +_PY2 = sys.version_info < (3,) +if _PY2: + str = unicode + + +_BASIC_TYPES = (bool, int, float, bytes, str) +"""Python basic types.""" + +if _PY2: + _BASIC_TYPES += (long,) + +_COMPLEX_TYPES = (tuple, list, set, dict) +"""Python complex types.""" + +_VALID_TYPES = _BASIC_TYPES + _COMPLEX_TYPES +"""Option value must be one of these types.""" diff --git a/python_config/exceptions.py b/python_config/exceptions.py new file mode 100644 index 0000000..54f97c4 --- /dev/null +++ b/python_config/exceptions.py @@ -0,0 +1,42 @@ +from __future__ import unicode_literals + + +class Error(Exception): + """The base class for all exceptions that the module raises.""" + + def __init__(self, error, *args, **kwargs): + super(Error, self).__init__(error.format(*args, **kwargs) if args or kwargs else error) + + +class FileReadingError(Error): + """Error while reading a configuration file.""" + + def __init__(self, path, error): + super(FileReadingError, self).__init__( + "Error while reading '{0}' configuration file: {1}.", path, error.strerror) + self.errno = error.errno + + +class ParsingError(Error): + """Error while parsing a configuration file.""" + + def __init__(self, path, error): + super(ParsingError, self).__init__( + "Error while parsing '{0}' configuration file: {1}.", path, error) + + +class ValidationError(Error): + """Error during validation of a configuration file.""" + + def __init__(self, path, error): + super(ValidationError, self).__init__( + "Error while parsing '{0}' configuration file: {1}.", path, error) + self.option_name = error.option_name + + +class _ValidationError(Error): + """Same as ValidationError, but for internal usage.""" + + def __init__(self, option_name, *args, **kwargs): + super(_ValidationError, self).__init__(*args, **kwargs) + self.option_name = option_name diff --git a/python_config/file.py b/python_config/file.py new file mode 100644 index 0000000..3c388bf --- /dev/null +++ b/python_config/file.py @@ -0,0 +1,36 @@ +from __future__ import unicode_literals + +import imp + +from .exceptions import FileReadingError, ParsingError, ValidationError, _ValidationError +from .validation import _validate_value + + +def load(path, contents=None): + """Loads a configuration file.""" + + config_module = imp.new_module("config") + config_module.__file__ = path + + if contents is None: + try: + with open(path) as config_file: + contents = config_file.read() + except EnvironmentError as e: + raise FileReadingError(path, e) + + try: + exec(compile(contents, path, "exec"), config_module.__dict__) + except Exception as e: + raise ParsingError(path, e) + + config = {} + + for option, value in config_module.__dict__.items(): + if not option.startswith("_") and option.isupper(): + try: + config[option.lower()] = _validate_value(option, value) + except _ValidationError as e: + raise ValidationError(path, e) + + return config diff --git a/python_config/validation.py b/python_config/validation.py new file mode 100644 index 0000000..14f2a66 --- /dev/null +++ b/python_config/validation.py @@ -0,0 +1,79 @@ +from __future__ import unicode_literals + +from ._compat import _BASIC_TYPES, _VALID_TYPES +from .exceptions import _ValidationError + + +def _validate_value(option, value, valid_types=_VALID_TYPES): + """Validates an option value.""" + + value_type = type(value) + + if value_type not in valid_types: + raise _ValidationError(option, + "{option} has an invalid value type ({type}). Allowed types: {valid_types}.", + option=option, type=value_type.__name__, + valid_types=", ".join(t.__name__ for t in valid_types)) + + if value_type is dict: + value = _validate_dict(option, value) + elif value_type is list: + value = _validate_list(option, value) + elif value_type is tuple: + value = _validate_tuple(option, value) + elif value_type is set: + value = _validate_set(option, value) + elif value_type is bytes: + try: + value = value.decode() + except UnicodeDecodeError as e: + raise _ValidationError(option, "{0} has an invalid value: {1}.", option, e) + + return value + + +def _validate_dict(option, dictionary): + """Validates a dictionary.""" + + for key, value in tuple(dictionary.items()): + valid_key = _validate_value("A {0}'s key".format(option), + key, valid_types=_BASIC_TYPES) + + valid_value = _validate_value("{0}[{1}]".format(option, repr(key)), value) + + if valid_key is not key: + del dictionary[key] + dictionary[valid_key] = valid_value + elif valid_value is not value: + dictionary[valid_key] = valid_value + + return dictionary + + +def _validate_list(option, sequence): + """Validates a list.""" + + for index, value in enumerate(sequence): + valid_value = _validate_value("{0}[{1}]".format(option, index), value) + if valid_value is not value: + sequence[index] = valid_value + + return sequence + + +def _validate_tuple(option, sequence): + """Validates a tuple.""" + + return [ + _validate_value("{0}[{1}]".format(option, index), value) + for index, value in enumerate(sequence) + ] + + +def _validate_set(option, sequence): + """Validates a set.""" + + return [ + _validate_value("A {0}'s key".format(option), value) + for value in sequence + ] diff --git a/setup.py b/setup.py index d7aafe3..d5c437e 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ def run_tests(self): ], platforms = [ "unix", "linux", "osx" ], - py_modules = [ "python_config" ], + packages = [ "python_config" ], cmdclass = { "test": PyTest }, tests_require = [ "pytest" ], From ec678cdb999b99b6b65014a7f807e96e44b6c4c8 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Mon, 18 May 2026 13:25:59 +0300 Subject: [PATCH 02/17] Update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0d20b64..d0a2148 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ +*.egg-info +__pycache__ *.pyc From 6c5e0fc052013bce14583bbd4c9056574c992db1 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Mon, 18 May 2026 13:52:11 +0300 Subject: [PATCH 03/17] Replace loading with parsing AST, implement saving Notable changes - Drop python2 support due to usage of dataclasses. - Drop tuple, set, byte string support. - Split load(path, contents) into load(path) and loads(contents) mimicking python json, yaml and toml libraries. - New functions dump(out, obj) and dumps(obj). - New Document API, with load(s) and dump(s) support. - Config is stored in a dataclass, keeping preamble and docstings together to minimize configuration diffs on save. - NOTE!!! This commit breaks ability to import and evaluate top-level code. --- python_config/__init__.py | 18 +++- python_config/_compat.py | 20 ---- python_config/ast_validate.py | 123 +++++++++++++++++++++++++ python_config/common.py | 70 ++++++++++++++ python_config/document.py | 77 ++++++++++++++++ python_config/exceptions.py | 24 ++--- python_config/file.py | 36 -------- python_config/literals.py | 142 ++++++++++++++++++++++++++++ python_config/models.py | 137 +++++++++++++++++++++++++++ python_config/parse.py | 92 ++++++++++++++++++ python_config/serialize.py | 169 ++++++++++++++++++++++++++++++++++ python_config/simple.py | 91 ++++++++++++++++++ python_config/validation.py | 83 +++++++++-------- setup.py | 4 +- tests/example.conf | 17 ++++ tests/test_ast_roundtrip.py | 60 ++++++++++++ tests/test_dump.py | 31 +++++++ tests/test_file_reading.py | 21 +++-- tests/test_parsing.py | 85 +++++++---------- 19 files changed, 1129 insertions(+), 171 deletions(-) delete mode 100644 python_config/_compat.py create mode 100644 python_config/ast_validate.py create mode 100644 python_config/common.py create mode 100644 python_config/document.py delete mode 100644 python_config/file.py create mode 100644 python_config/literals.py create mode 100644 python_config/models.py create mode 100644 python_config/parse.py create mode 100644 python_config/serialize.py create mode 100644 python_config/simple.py create mode 100644 tests/example.conf create mode 100644 tests/test_ast_roundtrip.py create mode 100644 tests/test_dump.py diff --git a/python_config/__init__.py b/python_config/__init__.py index 7534b6a..cce4025 100644 --- a/python_config/__init__.py +++ b/python_config/__init__.py @@ -1,15 +1,25 @@ """Python configuration file parser.""" -from __future__ import unicode_literals - -from .exceptions import Error, FileReadingError, ParsingError, ValidationError -from .file import load +from . import document +from .exceptions import ( + Error, + FileReadingError, + FileWritingError, + ParsingError, + ValidationError, +) +from .simple import dump, dumps, load, loads __all__ = [ "Error", "FileReadingError", + "FileWritingError", "ParsingError", "ValidationError", + "document", + "dump", + "dumps", "load", + "loads", ] diff --git a/python_config/_compat.py b/python_config/_compat.py deleted file mode 100644 index ae8f317..0000000 --- a/python_config/_compat.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import unicode_literals - -import sys - -_PY2 = sys.version_info < (3,) -if _PY2: - str = unicode - - -_BASIC_TYPES = (bool, int, float, bytes, str) -"""Python basic types.""" - -if _PY2: - _BASIC_TYPES += (long,) - -_COMPLEX_TYPES = (tuple, list, set, dict) -"""Python complex types.""" - -_VALID_TYPES = _BASIC_TYPES + _COMPLEX_TYPES -"""Option value must be one of these types.""" diff --git a/python_config/ast_validate.py b/python_config/ast_validate.py new file mode 100644 index 0000000..b69d91f --- /dev/null +++ b/python_config/ast_validate.py @@ -0,0 +1,123 @@ +import ast + +from .common import constant_value, is_constant_node + + +_ALLOWED_CONSTANT_TYPES = (bool, int, float, str, type(None)) + + +class AstValidationError(Exception): + """Error raised on AST validation step.""" + + +def _validate_formatted_value(node): + """Validate an f-string field. + + Only plain interpolation is allowed: no format spec (``:.2f``) and no + conversion flag (``!r``). The expression inside the braces may reference + option names defined earlier in the same file. + """ + + if node.format_spec is not None: + raise AstValidationError("f-string format specs are not supported") + + # conversion: -1 means none (default str) + if node.conversion not in (-1, ord("s"), ""): + raise AstValidationError("f-string conversion flags are not supported") + + validate_config_expr(node.value, allow_names=True) + + +def validate_config_expr(node, allow_names=False): + """Check that an AST expression is allowed in a config value. + + Recursively accepts constants, unary ``-``, binary ``+`` ``-`` ``*`` ``/``, + containers of those expressions, and f-strings (``ast.JoinedStr``). + + :param node: AST node for the right-hand side of an assignment or a nested part of it. + :param allow_names: If ``False`` (default), bare names (``ast.Name``) are rejected. Used for + top-level assignment values and dict keys, so ``OTHER + 1`` at the + root of ``X = ...`` is invalid. + + If ``True``, bare names are allowed. This is enabled only inside + f-string braces (e.g. ``f"{VAR1}:{VAR2}"``), where ``VAR1`` must refer + to another option assigned earlier in the file. Names are not + evaluated here; :func:`python_config.literals.literal_from_ast` resolves + them using a per-file namespace during parsing. + + :raises AstValidationError: If the expression uses a disallowed construct. + """ + + if allow_names and isinstance(node, ast.Name): + return + + if is_constant_node(node): + if not isinstance(constant_value(node), _ALLOWED_CONSTANT_TYPES): + raise AstValidationError( + "unsupported constant type: {0}".format(type(constant_value(node)).__name__) + ) + return + + if isinstance(node, ast.JoinedStr): + for part in node.values: + if isinstance(part, ast.Constant) and isinstance(part.value, str): + continue + + if isinstance(part, ast.FormattedValue): + _validate_formatted_value(part) + continue + + if isinstance(part, ast.Str): + continue + + raise AstValidationError("disallowed f-string part: {0}".format(type(part).__name__)) + return + + if isinstance(node, ast.UnaryOp): + if not isinstance(node.op, ast.USub): + raise AstValidationError("only unary minus is allowed on literals") + if not is_constant_node(node.operand) or not isinstance( + constant_value(node.operand), (int, float) + ): + raise AstValidationError("unary minus may only be applied to numeric literals") + return + + if isinstance(node, ast.BinOp): + if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): + raise AstValidationError("only +, -, *, / are allowed") + validate_config_expr(node.left, allow_names=allow_names) + validate_config_expr(node.right, allow_names=allow_names) + return + + if isinstance(node, ast.List): + for element in node.elts: + validate_config_expr(element, allow_names=allow_names) + return + + if isinstance(node, ast.Dict): + for key, value in zip(node.keys, node.values): + if key is not None: + validate_config_expr(key, allow_names=False) + validate_config_expr(value, allow_names=allow_names) + return + + raise AstValidationError("disallowed expression: {0}".format(type(node).__name__)) + + +def validate_assignment(stmt): + """Validate a single-target assignment and return the option name. + + The right-hand side must be a config expression without bare names at the + top level (names may still appear inside f-string braces). + """ + + if len(stmt.targets) != 1: + raise AstValidationError("only single-target assignments are allowed") + + target = stmt.targets[0] + if not isinstance(target, ast.Name): + raise AstValidationError("assignment target must be a simple name") + + validate_config_expr(stmt.value, allow_names=False) + + return target.id diff --git a/python_config/common.py b/python_config/common.py new file mode 100644 index 0000000..bb57409 --- /dev/null +++ b/python_config/common.py @@ -0,0 +1,70 @@ +import ast +import sys + + +AST_CONSTANT = sys.version_info >= (3, 8) +"""Starting from python 3.8 ast.Constant is used for all constants.""" + + +def constant_value(node): + """Return the Python value of a constant AST node.""" + + if AST_CONSTANT and isinstance(node, ast.Constant): + return node.value + + if isinstance(node, ast.Num): + return node.n + + if isinstance(node, ast.Str): + return node.s + + if isinstance(node, ast.Bytes): + return node.s + + if isinstance(node, ast.NameConstant): + return node.value + + raise TypeError("Not a constant node: {0}".format(type(node).__name__)) + + +def is_constant_node(node): + if AST_CONSTANT and isinstance(node, ast.Constant): + return True + + return isinstance(node, (ast.Num, ast.Str, ast.Bytes, ast.NameConstant)) + + +def is_docstring_expr(stmt): + if not isinstance(stmt, ast.Expr): + return False + + value = stmt.value + if AST_CONSTANT and isinstance(value, ast.Constant): + return isinstance(value.value, str) + + return isinstance(value, ast.Str) + + +def docstring_value(stmt): + value = stmt.value + if AST_CONSTANT and isinstance(value, ast.Constant): + return value.value + return value.s + + +def constant_node(value): + """Build an AST literal node for a scalar constant.""" + + if AST_CONSTANT: + return ast.Constant(value=value) + + if value in (None, True, False): + return ast.NameConstant(value=value) + + if isinstance(value, (int, float)): + return ast.Num(n=value) + + if isinstance(value, str): + return ast.Str(s=value) + + raise TypeError("unsupported constant type: {0}".format(type(value).__name__)) diff --git a/python_config/document.py b/python_config/document.py new file mode 100644 index 0000000..c748a7c --- /dev/null +++ b/python_config/document.py @@ -0,0 +1,77 @@ +import pathlib + +from . import parse +from .exceptions import ( + FileReadingError, + FileWritingError, + ParsingError, +) +from .models import Assignment, ConfigDocument +from .serialize import serialize_config +from .validation import _validate_document_items + + +def load(path): + """Load a configuration file into a :class:`ConfigDocument`.""" + + try: + with open(path) as config_file: + contents = config_file.read() + except OSError as e: + raise FileReadingError(path, e) + + return loads(contents, path=path) + + +def loads(contents, path=None): + """Load configuration source into a :class:`ConfigDocument`.""" + + try: + document = parse.parse_config(contents) + except Exception as e: + raise ParsingError(path or "", e) + _validate_document_items(document, path or "") + return document + + +def dumps(document): + """Serialize a :class:`ConfigDocument` to a configuration source string.""" + + _validate_document_items(document, "") + return serialize_config(document) + + +def dump(out, document): + """Serialize a :class:`ConfigDocument` and write it to a file-like object.""" + + # String to Path + if isinstance(out, str): + out = pathlib.Path(out) + + # Path processing ends here + if isinstance(out, pathlib.PurePath): + try: + out.write_text(dumps(document)) + return + except EnvironmentError as e: + raise FileWritingError(out, e) + + # Opened file is the last option + if not hasattr(out, "write"): + raise FileWritingError(out, IOError("out must be string, pathlib object or opened file")) + + try: + out.write(dumps(document)) + except EnvironmentError as e: + path = getattr(out, "name", "") + raise FileWritingError(path, e) + + +__all__ = [ + "Assignment", + "ConfigDocument", + "dump", + "dumps", + "load", + "loads", +] diff --git a/python_config/exceptions.py b/python_config/exceptions.py index 54f97c4..811c88f 100644 --- a/python_config/exceptions.py +++ b/python_config/exceptions.py @@ -1,19 +1,23 @@ -from __future__ import unicode_literals - - class Error(Exception): """The base class for all exceptions that the module raises.""" def __init__(self, error, *args, **kwargs): - super(Error, self).__init__(error.format(*args, **kwargs) if args or kwargs else error) + super().__init__(error.format(*args, **kwargs) if args or kwargs else error) class FileReadingError(Error): """Error while reading a configuration file.""" def __init__(self, path, error): - super(FileReadingError, self).__init__( - "Error while reading '{0}' configuration file: {1}.", path, error.strerror) + super().__init__(f"Error while reading '{path}' configuration file: {error.strerror}.") + self.errno = error.errno + + +class FileWritingError(Error): + """Error while writing a configuration file.""" + + def __init__(self, path, error): + super().__init__(f"Error while writing '{path}' configuration file: {error.strerror}.") self.errno = error.errno @@ -21,16 +25,14 @@ class ParsingError(Error): """Error while parsing a configuration file.""" def __init__(self, path, error): - super(ParsingError, self).__init__( - "Error while parsing '{0}' configuration file: {1}.", path, error) + super().__init__(f"Error while parsing '{path}' configuration file: {error}.") class ValidationError(Error): """Error during validation of a configuration file.""" def __init__(self, path, error): - super(ValidationError, self).__init__( - "Error while parsing '{0}' configuration file: {1}.", path, error) + super().__init__(f"Error while validating '{path}' configuration file: {error}.") self.option_name = error.option_name @@ -38,5 +40,5 @@ class _ValidationError(Error): """Same as ValidationError, but for internal usage.""" def __init__(self, option_name, *args, **kwargs): - super(_ValidationError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.option_name = option_name diff --git a/python_config/file.py b/python_config/file.py deleted file mode 100644 index 3c388bf..0000000 --- a/python_config/file.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import unicode_literals - -import imp - -from .exceptions import FileReadingError, ParsingError, ValidationError, _ValidationError -from .validation import _validate_value - - -def load(path, contents=None): - """Loads a configuration file.""" - - config_module = imp.new_module("config") - config_module.__file__ = path - - if contents is None: - try: - with open(path) as config_file: - contents = config_file.read() - except EnvironmentError as e: - raise FileReadingError(path, e) - - try: - exec(compile(contents, path, "exec"), config_module.__dict__) - except Exception as e: - raise ParsingError(path, e) - - config = {} - - for option, value in config_module.__dict__.items(): - if not option.startswith("_") and option.isupper(): - try: - config[option.lower()] = _validate_value(option, value) - except _ValidationError as e: - raise ValidationError(path, e) - - return config diff --git a/python_config/literals.py b/python_config/literals.py new file mode 100644 index 0000000..2992ff0 --- /dev/null +++ b/python_config/literals.py @@ -0,0 +1,142 @@ +import ast + +from .common import constant_node, constant_value, is_constant_node + + +class LiteralError(Exception): + pass + + +def _arithmetic_operand(value): + if type(value) not in (int, float): + raise LiteralError( + "arithmetic operands must be int or float, got {0}".format(type(value).__name__) + ) + return value + + +def _default_formatted_value(node, value): + """Convert an interpolated value to text for an f-string field. + + Applies default ``str()`` conversion only; rejects format specs and + ``!r`` / ``!s`` conversion flags on *node*. + """ + + if node.format_spec is not None: + raise LiteralError("f-string format specs are not supported") + + if node.conversion not in (-1, ord("s"), ""): + raise LiteralError("f-string conversion flags are not supported") + + return str(value) + + +def literal_from_ast(node, namespace=None): + """Evaluate an AST literal expression to a Python value. + + .. NOTE:: Bare ``ast.Name`` nodes are only present in the tree after validation with + ``allow_names=True`` (inside f-string braces). Top-level assignments do not pass names + through validation. + + :param node: AST node (typically the right-hand side of an assignment). + :param namespace: Mapping of option names (``VAR1``, ``HOST``, …) to values already + assigned earlier in the same config file. Used to resolve names inside + f-strings: ``f"{VAR1}"`` looks up ``namespace["VAR1"]``. If a name is + missing, raises :class:`LiteralError` (undefined forward reference). + Defaults to an empty dict. + + :raises LiteralError: If the node cannot be evaluated (unknown name, division by zero, etc.). + + :returns: The evaluated value (scalars, containers, folded arithmetic, evaluated f-strings). + """ + + if namespace is None: + namespace = {} + + if isinstance(node, ast.Name): + if node.id not in namespace: + raise LiteralError(f"undefined name: {node.id}") + return namespace[node.id] + + if is_constant_node(node): + return constant_value(node) + + if isinstance(node, ast.JoinedStr): + parts = [] + for part in node.values: + if isinstance(part, ast.Constant) and isinstance(part.value, str): + parts.append(part.value) + elif isinstance(part, ast.FormattedValue): + inner = literal_from_ast(part.value, namespace) + parts.append(_default_formatted_value(part, inner)) + elif isinstance(part, ast.Str): + parts.append(part.s) + else: + raise LiteralError(f"cannot convert f-string part: {type(part).__name__}") + return "".join(parts) + + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + value = literal_from_ast(node.operand, namespace) + if not isinstance(value, (int, float)): + raise LiteralError("unary minus requires a numeric operand") + return -value + + if isinstance(node, ast.BinOp): + left = _arithmetic_operand(literal_from_ast(node.left, namespace)) + right = _arithmetic_operand(literal_from_ast(node.right, namespace)) + + if isinstance(node.op, ast.Add): + return left + right + + if isinstance(node.op, ast.Sub): + return left - right + + if isinstance(node.op, ast.Mult): + return left * right + + if isinstance(node.op, ast.Div): + try: + return left / right + except ZeroDivisionError: + raise LiteralError("division by zero") + + raise LiteralError(f"unsupported binary operator: {type(node.op).__name__}") + + if isinstance(node, ast.List): + return [literal_from_ast(element, namespace) for element in node.elts] + + if isinstance(node, ast.Dict): + return { + (literal_from_ast(key, namespace) if key is not None else None): literal_from_ast( + value, namespace + ) + for key, value in zip(node.keys, node.values) + } + + raise LiteralError(f"cannot convert expression to value: {type(node).__name__}") + + +def literal_to_ast(value): + """Build an AST literal expression from a Python value.""" + + if value is None or isinstance(value, (bool, int, float, str)): + return constant_node(value) + + if isinstance(value, list): + return ast.List( + elts=[literal_to_ast(element) for element in value], + ctx=ast.Load(), + ) + + if isinstance(value, dict): + keys = [] + values = [] + for key, item in value.items(): + if key is None: + keys.append(None) + else: + keys.append(literal_to_ast(key)) + values.append(literal_to_ast(item)) + return ast.Dict(keys=keys, values=values) + + raise LiteralError(f"unsupported value type: {type(value).__name__}") diff --git a/python_config/models.py b/python_config/models.py new file mode 100644 index 0000000..d15152f --- /dev/null +++ b/python_config/models.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from .exceptions import ValidationError, _ValidationError +from .validation import _validate_value + + +@dataclass(eq=False) +class Assignment: + """Assignment that may be documented. + + Examples: + VAR = 67 + + VAR_WITH_DOCSTRING = 42 + '''Number of somethings to handle the job.''' + """ + + name: str + value: object + docstring: Optional[str] = None + + def __eq__(self, other): + if not isinstance(other, Assignment): + return NotImplemented + + return ( + self.name == other.name + and self.value == other.value + and self.docstring == other.docstring + ) + + def __ne__(self, other): + result = self.__eq__(other) + + if result is NotImplemented: + return NotImplemented + + return not result + + +@dataclass(eq=False) +class ConfigDocument: + """Full config file meaningful content is represented by this model.""" + + preamble: str = "" + """Will be inserted as header.""" + + module_docstring: Optional[str] = None + """Module-level docstring after the preamble and before assignments.""" + + items: List[Assignment] = field(default_factory=list) + + def __eq__(self, other): + if not isinstance(other, ConfigDocument): + return NotImplemented + + return ( + self.preamble == other.preamble + and self.module_docstring == other.module_docstring + and self.items == other.items + ) + + def __ne__(self, other): + result = self.__eq__(other) + + if result is NotImplemented: + return NotImplemented + + return not result + + @classmethod + def from_dict(cls, obj): + """Create ConfigDocument from dict.""" + + items = [Assignment(name=key.upper(), value=value) for key, value in obj.items()] + return cls(items=items) + + def to_dict(self): + """Dump to dict (for all uppercase key names).""" + + result = {} + + for item in self.items: + if not item.name.startswith("_") and item.name.isupper(): + result[item.name.lower()] = item.value + + return result + + def names(self): + """List names.""" + + return [item.name for item in self.items] + + def getvar(self, name): + """Return unwrapped Assignment object for access to its metadata. + + Use it cautiously! + """ + + for item in self.items: + if item.name == name: + return item + + raise KeyError(name) + + def __getitem__(self, name): + return self.getvar(name).value + + def __setitem__(self, name, value): + try: + value = _validate_value(name, value) + except _ValidationError as e: + raise ValidationError("", e) + + for item in self.items: + if item.name == name: + item.value = value + return + + self.items.append(Assignment(name=name, value=value)) + + def __delitem__(self, name): + for index, item in enumerate(self.items): + if item.name == name: + del self.items[index] + return + raise KeyError(name) + + def __contains__(self, item): + return item in self.names() + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default diff --git a/python_config/parse.py b/python_config/parse.py new file mode 100644 index 0000000..ec23abc --- /dev/null +++ b/python_config/parse.py @@ -0,0 +1,92 @@ +import ast + +from .ast_validate import AstValidationError, validate_assignment +from .common import docstring_value, is_docstring_expr +from .literals import LiteralError, literal_from_ast +from .models import Assignment, ConfigDocument + + +class ParseError(Exception): + pass + + +def _preamble(source, tree): + """Return source text before the first top-level statement in *tree*.""" + + if not tree.body: + return source + + lines = source.splitlines(keepends=True) + + return "".join(lines[: tree.body[0].lineno - 1]) + + +def parse_config(source): + """Parse config source into a :class:`~python_config.models.ConfigDocument`. + + Assignments are processed in source order. Each right-hand side is validated, + then evaluated with a namespace of prior option names so f-strings can + reference earlier options (e.g. ``f"{HOST}:{PORT}"`` after ``HOST`` and + ``PORT`` are assigned). Arithmetic and f-string expressions are folded to + literal values at load time; dumps always emits literals. + """ + + try: + tree = ast.parse(source, mode="exec") + except SyntaxError as exc: + raise ParseError("syntax error: {0}".format(exc)) + + if not isinstance(tree, ast.Module): + raise ParseError("expected a module") + + body = tree.body + module_docstring = None + if body and is_docstring_expr(body[0]): + module_docstring = docstring_value(body[0]) + body = body[1:] + + items = [] + namespace = {} + for stmt in body: + if isinstance(stmt, ast.Assign): + try: + name = validate_assignment(stmt) + except AstValidationError as exc: + raise ParseError(str(exc)) + + try: + value = literal_from_ast(stmt.value, namespace) + except LiteralError as exc: + raise ParseError(str(exc)) + + namespace[name] = value + items.append( + Assignment( + name=name, + value=value, + docstring=None, + ) + ) + continue + + if is_docstring_expr(stmt): + if not items or items[-1].docstring is not None: + raise ParseError("docstring must immediately follow an assignment") + items[-1].docstring = docstring_value(stmt) + continue + + raise ParseError("unsupported top-level statement: {0}".format(type(stmt).__name__)) + + preamble = _preamble(source, tree) + if module_docstring is not None: + # Canonicalize preamble trailing newlines when a module docstring + # follows. Serializer may emit an empty line separator for readability, + # but we keep the stored preamble ending stable for round-trips. + if preamble: + preamble = preamble.rstrip("\n") + "\n" + + return ConfigDocument( + preamble=preamble, + module_docstring=module_docstring, + items=items, + ) diff --git a/python_config/serialize.py b/python_config/serialize.py new file mode 100644 index 0000000..5e55a03 --- /dev/null +++ b/python_config/serialize.py @@ -0,0 +1,169 @@ +import ast + +from .common import constant_value, is_constant_node +from .literals import literal_to_ast + + +def _assignment_stmt(name, value_ast): + return ast.Assign( + targets=[ast.Name(id=name, ctx=ast.Store())], + value=value_ast, + ) + + +def _format_docstring(text): + quote = "'''" if '"""' in text else '"""' + return "{0}{1}{0}".format(quote, text) + + +def _serialize_assignment_block(item): + value_ast = literal_to_ast(item.value) + stmt = _assignment_stmt(item.name, value_ast) + + if hasattr(ast, "fix_missing_locations"): + ast.fix_missing_locations(stmt) + + lines = [] + lines.append(unparse(stmt)) + + if item.docstring is not None: + lines.append(_format_docstring(item.docstring)) + + return "\n".join(lines) + + +def serialize_config(document): + """Serialize whole configuration object into string.""" + + source = "\n\n".join(_serialize_assignment_block(item) for item in document.items) + + prefix = "" + + if document.preamble: + # Ensure exactly one newline at the end of the preamble content, + # then add an empty line before the module docstring (if present). + prefix = document.preamble + if not prefix.endswith("\n"): + prefix = prefix + "\n" + + if document.module_docstring is not None: + prefix = prefix + "\n" + + if document.module_docstring is not None: + # Empty line between module docstring and the first assignment. + prefix = prefix + _format_docstring(document.module_docstring) + "\n\n" + + if prefix: + source = prefix + source + + if not source.endswith("\n"): + source = source + "\n" + + return source + + +def _format_str_literal(value): + quote = _choose_str_quote(value) + return "{0}{1}{0}".format(quote, _escape_quoted_string(value, quote)) + + +def _format_constant(value): + if isinstance(value, str): + return _format_str_literal(value) + return repr(value) + + +def _indent(level): + return " " * level + + +def _choose_str_quote(value): + if "\n" in value or "\r" in value: + if '"""' not in value: + return '"""' + if "'''" not in value: + return "'''" + if '"' not in value: + return '"' + if "'" not in value: + return "'" + if '"""' not in value: + return '"""' + return "'''" + + +def _escape_quoted_string(value, quote): + delimiter = quote[0] + escaped = ( + value.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + ) + return escaped.replace(delimiter, "\\" + delimiter) + + +def _unparse_list(node, level): + if not node.elts: + return "[]" + + inner = _indent(level + 1) + lines = ["["] + for element in node.elts: + lines.append(inner + _unparse_expr(element, level + 1) + ",") + lines.append(_indent(level) + "]") + return "\n".join(lines) + + +def _unparse_dict(node, level): + if not node.keys: + return "{}" + + inner = _indent(level + 1) + lines = ["{"] + for key, value in zip(node.keys, node.values): + line = _unparse_expr(key, 0) + ": " + _unparse_expr(value, level + 1) + lines.append(inner + line + ",") + lines.append(_indent(level) + "}") + return "\n".join(lines) + + +def _unparse_expr(node, level=0): + """Unparse a literal expression AST node to config source text.""" + + if is_constant_node(node): + return _format_constant(constant_value(node)) + + if isinstance(node, ast.List): + return _unparse_list(node, level) + + if isinstance(node, ast.Dict): + return _unparse_dict(node, level) + + raise TypeError("cannot unparse node: {0}".format(type(node).__name__)) + + +def _unparse_stmt(node): + """Unparse a single assignment statement to config source text.""" + + if isinstance(node, ast.Assign): + if len(node.targets) != 1: + raise TypeError("only single-target assignments supported") + + target = node.targets[0] + if not isinstance(target, ast.Name): + raise TypeError("only simple name targets supported") + + return "{0} = {1}".format(target.id, _unparse_expr(node.value)) + + raise TypeError("cannot unparse statement: {0}".format(type(node).__name__)) + + +def unparse(node): + """Unparse an AST node to config source (assignment statement or expression). + + Uses project formatting (double-quoted strings, multiline containers, etc.), + not stdlib ``ast.unparse``. + """ + + if isinstance(node, ast.stmt): + return _unparse_stmt(node) + + return _unparse_expr(node) diff --git a/python_config/simple.py b/python_config/simple.py new file mode 100644 index 0000000..5b56e7d --- /dev/null +++ b/python_config/simple.py @@ -0,0 +1,91 @@ +"""Load-wise backward compatible implementation. + +NOTE: except of changed `load(path, contents=None)` -> `load(path)` signature. +""" + +import pathlib + +from . import parse +from .exceptions import ( + FileReadingError, + FileWritingError, + ParsingError, + ValidationError, + _ValidationError, +) +from .models import ConfigDocument +from .serialize import serialize_config +from .validation import _validate_public_items, _validate_value + + +def load(path): + """Loads a configuration file.""" + + try: + with open(path) as config_file: + contents = config_file.read() + except OSError as e: + raise FileReadingError(path, e) + + return _loads(contents, path=path) + + +def loads(contents): + """Load a configuration from string.""" + + return _loads(contents) + + +def _loads(contents, path=None): + """Common loading function that handles path.""" + + try: + document = parse.parse_config(contents) + except Exception as e: + raise ParsingError(path or "", e) + + return _validate_public_items(document, path or "") + + +def dumps(obj): + """Serialize a configuration dict to a string. + + Validation on write is necessary to ensure it can be loaded in the future. + """ + + validated = {} + for key, value in obj.items(): + name = key.upper() + try: + validated[key] = _validate_value(name, value) + except _ValidationError as e: + raise ValidationError("", e) + + document = ConfigDocument.from_dict(validated) + return serialize_config(document) + + +def dump(out, obj): + """Serialize a configuration dict and write it to a file-like object.""" + + # String to Path + if isinstance(out, str): + out = pathlib.Path(out) + + # Path processing ends here + if isinstance(out, pathlib.PurePath): + try: + out.write_text(dumps(obj)) + return + except EnvironmentError as e: + raise FileWritingError(out, e) + + # Opened file is the last option + if not hasattr(out, "write"): + raise FileWritingError(out, IOError("out must be string, pathlib object or opened file")) + + try: + out.write(dumps(obj)) + except EnvironmentError as e: + path = getattr(out, "name", "") + raise FileWritingError(path, e) diff --git a/python_config/validation.py b/python_config/validation.py index 14f2a66..92b86f6 100644 --- a/python_config/validation.py +++ b/python_config/validation.py @@ -1,7 +1,40 @@ -from __future__ import unicode_literals +from .exceptions import ValidationError, _ValidationError -from ._compat import _BASIC_TYPES, _VALID_TYPES -from .exceptions import _ValidationError + +_BASIC_TYPES = (bool, int, float, str) +"""Python basic types.""" + +_COMPLEX_TYPES = (list, dict) +"""Python complex types.""" + +_VALID_TYPES = _BASIC_TYPES + _COMPLEX_TYPES +"""Option value must be one of these types.""" + + +def _validate_public_items(document, path): + """Validate public uppercase assignments and return a lowercase-key dict.""" + + config = {} + + for item in document.items: + if not item.name.startswith("_") and item.name.isupper(): + try: + config[item.name.lower()] = _validate_value(item.name, item.value) + except _ValidationError as e: + raise ValidationError(path, e) + + return config + + +def _validate_document_items(document, path): + """Validate public uppercase assignments in a document (in place).""" + + for item in document.items: + if not item.name.startswith("_") and item.name.isupper(): + try: + _validate_value(item.name, item.value) + except _ValidationError as e: + raise ValidationError(path, e) def _validate_value(option, value, valid_types=_VALID_TYPES): @@ -10,25 +43,18 @@ def _validate_value(option, value, valid_types=_VALID_TYPES): value_type = type(value) if value_type not in valid_types: - raise _ValidationError(option, + raise _ValidationError( + option, "{option} has an invalid value type ({type}). Allowed types: {valid_types}.", - option=option, type=value_type.__name__, - valid_types=", ".join(t.__name__ for t in valid_types)) + option=option, + type=value_type.__name__, + valid_types=", ".join(t.__name__ for t in valid_types), + ) if value_type is dict: value = _validate_dict(option, value) elif value_type is list: value = _validate_list(option, value) - elif value_type is tuple: - value = _validate_tuple(option, value) - elif value_type is set: - value = _validate_set(option, value) - elif value_type is bytes: - try: - value = value.decode() - except UnicodeDecodeError as e: - raise _ValidationError(option, "{0} has an invalid value: {1}.", option, e) - return value @@ -36,8 +62,11 @@ def _validate_dict(option, dictionary): """Validates a dictionary.""" for key, value in tuple(dictionary.items()): - valid_key = _validate_value("A {0}'s key".format(option), - key, valid_types=_BASIC_TYPES) + valid_key = _validate_value( + "A {0}'s key".format(option), + key, + valid_types=_BASIC_TYPES, + ) valid_value = _validate_value("{0}[{1}]".format(option, repr(key)), value) @@ -59,21 +88,3 @@ def _validate_list(option, sequence): sequence[index] = valid_value return sequence - - -def _validate_tuple(option, sequence): - """Validates a tuple.""" - - return [ - _validate_value("{0}[{1}]".format(option, index), value) - for index, value in enumerate(sequence) - ] - - -def _validate_set(option, sequence): - """Validates a set.""" - - return [ - _validate_value("A {0}'s key".format(option), value) - for value in sequence - ] diff --git a/setup.py b/setup.py index d5c437e..7a5f470 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,5 @@ """python-config installation script.""" -from __future__ import unicode_literals - from setuptools import setup from setuptools.command.test import test as Test @@ -38,8 +36,8 @@ def run_tests(self): "Operating System :: MacOS :: MacOS X", "Operating System :: POSIX", "Operating System :: Unix", - "Programming Language :: Python :: 2", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", "Topic :: Software Development :: Libraries :: Python Modules", ], platforms = [ "unix", "linux", "osx" ], diff --git a/tests/example.conf b/tests/example.conf new file mode 100644 index 0000000..cdf1c36 --- /dev/null +++ b/tests/example.conf @@ -0,0 +1,17 @@ +# vim: set tf=python: + +VAR1 = "string-value" + +VAR2 = 10.0 +"""This variable has a docstring.""" + +SOMEDATA = { + "key1": 20, + "key2": {"subkey": 30}, +} + +SOMETHINGS = [ + "thing1", + "thing2", +] +"""Another docstring.""" diff --git a/tests/test_ast_roundtrip.py b/tests/test_ast_roundtrip.py new file mode 100644 index 0000000..ae6a1a2 --- /dev/null +++ b/tests/test_ast_roundtrip.py @@ -0,0 +1,60 @@ +"""Test internal AST parse/serialize round-trip.""" + +import os + +import pytest + +from python_config.parse import ParseError, parse_config +from python_config.serialize import serialize_config + +EXAMPLE = os.path.join(os.path.dirname(__file__), "example.conf") + + +def test_load_example_values(): + doc = parse_config(open(EXAMPLE).read()) + assert doc["VAR1"] == "string-value" + assert doc["VAR2"] == 10.0 + assert doc["SOMEDATA"] == {"key1": 20, "key2": {"subkey": 30}} + assert doc["SOMETHINGS"] == ["thing1", "thing2"] + + +def test_load_example_docstrings(): + doc = parse_config(open(EXAMPLE).read()) + by_name = {item.name: item for item in doc.items} + assert by_name["VAR2"].docstring == "This variable has a docstring." + assert by_name["SOMETHINGS"].docstring == "Another docstring." + assert by_name["VAR1"].docstring is None + + +def test_load_example_preamble(): + doc = parse_config(open(EXAMPLE).read()) + assert doc.preamble.startswith("# vim: set tf=python:") + + +def test_roundtrip_preserves_semantics(): + source = open(EXAMPLE).read() + doc = parse_config(source) + again = parse_config(serialize_config(doc)) + assert again.preamble == doc.preamble + assert again.names() == doc.names() + for name in doc.names(): + assert again[name] == doc[name] + for left, right in zip(doc.items, again.items): + assert left.docstring == right.docstring + + +def test_roundtrip_docstrings_in_output(): + doc = parse_config(open(EXAMPLE).read()) + text = serialize_config(doc) + assert '"""This variable has a docstring."""' in text + assert '"""Another docstring."""' in text + + +def test_rejects_orphan_docstring(): + with pytest.raises(ParseError, match="docstring must immediately follow"): + parse_config('"""orphan"""\n') + + +def test_rejects_function_call(): + with pytest.raises(ParseError, match="disallowed expression"): + parse_config("X = len([1])\n") diff --git a/tests/test_dump.py b/tests/test_dump.py new file mode 100644 index 0000000..abfa061 --- /dev/null +++ b/tests/test_dump.py @@ -0,0 +1,31 @@ +"""Test configuration serialization.""" + +import io + +import pytest + +import python_config + + +def test_dumps_roundtrip(): + obj = {"key": "value", "count": 42, "enabled": True} + text = python_config.dumps(obj) + assert python_config.loads(text) == obj + + +def test_dump_to_file_like(): + obj = {"alpha": 1, "beta": "two"} + buf = io.StringIO() + python_config.dump(buf, obj) + assert python_config.loads(buf.getvalue()) == obj + + +def test_dumps_invalid_type(): + with pytest.raises(python_config.ValidationError): + python_config.dumps({"bad": object()}) + + +def test_dumps_uppercase_keys(): + text = python_config.dumps({"my_opt": "x"}) + assert "MY_OPT" in text + assert "my_opt" not in text.split("=")[0] diff --git a/tests/test_file_reading.py b/tests/test_file_reading.py index 46f694b..a491f77 100644 --- a/tests/test_file_reading.py +++ b/tests/test_file_reading.py @@ -1,7 +1,5 @@ """Test configuration file reading.""" -from __future__ import unicode_literals - import errno import os import tempfile @@ -13,19 +11,24 @@ def test_reading(): - assert python_config.load("tests/test.conf") == { "key": "value" } + assert python_config.load("tests/test.conf") == {"key": "value"} def test_missing_file(): - assert pytest.raises(FileReadingError, lambda: - python_config.load("missing.conf") - ).value.errno == errno.ENOENT + assert ( + pytest.raises( + FileReadingError, + lambda: python_config.load("missing.conf"), + ).value.errno + == errno.ENOENT + ) def test_no_access(): with tempfile.NamedTemporaryFile() as config: os.chmod(config.name, 0) - assert pytest.raises(FileReadingError, lambda: - python_config.load(config.name) - ).value.errno == errno.EACCES + assert ( + pytest.raises(FileReadingError, lambda: python_config.load(config.name)).value.errno + == errno.EACCES + ) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 1c7e29c..285a155 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1,52 +1,36 @@ """Test configuration file parsing.""" -from __future__ import unicode_literals - -import sys - import pytest import python_config -PY2 = sys.version_info < (3,) -if PY2: - str = unicode - def test_parsing(): - config = python_config.load("test", """ -import sys - -some_variable = 0 -_UNDERSCORE_VALUE = 0 - + config = python_config.loads( + """ BOOL_VALUE = False INT_VALUE = 1 FLOAT_VALUE = 3.3 -if sys.version_info < (3,): - BYTES_VALUE = "bytes value" - STRING_VALUE = unicode("string value") - LONG_VALUE = long(0) -else: - BYTES_VALUE = "bytes value".encode("utf-8") - STRING_VALUE = "string value" +BYTES_VALUE = b"bytes value" +STRING_VALUE = "string value" -TUPLE_VALUE = ( "a", 1 ) +TUPLE_VALUE = ("a", 1) -LIST_VALUE = ( "b", 2 ) +LIST_VALUE = ["b", 2] -SET_VALUE = set(( "a", "b", "c" )) +SET_VALUE = {"a", "b", "c"} DICT_VALUE = { - 1: "number", + 1: "number", "s": "string", "d": { - "l": [ "one", 2 ], - "t": [ 1, "two" ], + "l": ["one", 2], + "t": [1, "two"], }, } - """.strip()) + """.strip(), + ) assert type(config["bool_value"]) is bool assert type(config["int_value"]) is int @@ -55,9 +39,6 @@ def test_parsing(): assert type(config["bytes_value"]) is str assert type(config["string_value"]) is str - if PY2: - assert type(config["long_value"]) is long - assert type(config["set_value"]) is list config["set_value"] = sorted(config["set_value"]) @@ -65,44 +46,44 @@ def test_parsing(): "bool_value": False, "int_value": 1, "float_value": 3.3, - "bytes_value": "bytes value", "string_value": "string value", - - "tuple_value": [ "a", 1 ], - - "list_value": [ "b", 2 ], - - "set_value": sorted(( "a", "b", "c" )), - + "tuple_value": ["a", 1], + "list_value": ["b", 2], + "set_value": sorted(["a", "b", "c"]), "dict_value": { - 1: "number", + 1: "number", "s": "string", "d": { - "l": [ "one", 2 ], - "t": [ 1, "two" ], + "l": ["one", 2], + "t": [1, "two"], }, }, } - if PY2: - valid_config["long_value"] = 0 - assert config == valid_config def test_invalid_type(): - assert pytest.raises(python_config.ValidationError, lambda: - python_config.load("test", "OS = object()") - ).value.option_name == "OS" + with pytest.raises(python_config.ParsingError): + python_config.loads("OS = object()") def test_invalid_dict_key(): - assert pytest.raises(python_config.ValidationError, lambda: - python_config.load("test", "A = {}; B = { (0, 1): 2 }") - ).value.option_name == "A B's key" + assert ( + pytest.raises( + python_config.ValidationError, + lambda: python_config.loads("A = {}; B = { (0, 1): 2 }"), + ).value.option_name + == "A B's key" + ) def test_invalid_syntax(): with pytest.raises(python_config.ParsingError): - python_config.load("test", contents="a=") + python_config.loads("a=") + + +def test_rejects_import(): + with pytest.raises(python_config.ParsingError): + python_config.loads("import sys\nX = 1\n") From 98a3b01d8bf7fcae0662be782367642fcd59b887 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Mon, 18 May 2026 18:51:18 +0300 Subject: [PATCH 04/17] Bump version - Read version from file version.txt - Update metadata - Add pyproject.toml for modern tools alongside setup.py --- .gitignore | 3 ++ README | 6 ++-- pyproject.toml | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 69 +++++++++++++++++++++++++-------------------- version.txt | 1 + 5 files changed, 122 insertions(+), 33 deletions(-) create mode 100644 pyproject.toml create mode 100644 version.txt diff --git a/.gitignore b/.gitignore index d0a2148..7559382 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ *.egg-info __pycache__ *.pyc + +uv.lock +.venv diff --git a/README b/README index fc4b9e6..a1c70ed 100644 --- a/README +++ b/README @@ -1,8 +1,10 @@ -A simple module for reading Python configuration files +A library for reading Python configuration files -Python configuration files themselves are actual Python files. The module reads +Python configuration files themselves are actual Python files. The library reads only values in uppercase from them, checks that they contain only basic Python types and returns a dictionary which corresponds to the configuration file. +Configuration file can be saved back using dump and dumps functions. + Note: if you want to validate the configuration values, take a look at https://github.com/KonishchevDmitry/object-validator project. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..38e1c93 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-config" +dynamic = ["version"] +description = "A library for reading Python configuration files" +authors = [ + { name = "Dmitry Konishchev", email = "konishchev@gmail.com" }, +] +license = { text = "GPL-3.0" } +readme = "README" + +keywords = ["python-syntax", "configuration"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX", + "Operating System :: Unix", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: Implementation :: CPython", +] + +requires-python = ">=3.9" + +[project.optional-dependencies] +dev = [ + "mypy>=1.19.1", + "poethepoet>=0.32", + "pytest>=8", + "ruff>=0.9", +] + +[tool.setuptools.packages.find] +include = ["python_config", "python_config.*"] + +[tool.setuptools.dynamic] +version = {file = "version.txt"} + +[tool.poe.tasks.format] +help = "Format all the code." +shell = "ruff check --fix --select I python_config/ tests/ && ruff format python_config/ tests/" + +[tool.poe.tasks.lint] +help = "Lint all the code." +cmd = "ruff check python_config/ tests/" + +[tool.poe.tasks.typecheck] +help = "Typecheck all the code." +cmd = "mypy python_config" + +[tool.poe.tasks.test] +help = "Run unit tests." +cmd = "pytest --strict-markers -vvv tests/" + +[tool.poe.tasks.ci] +help = "Run full CI sequence." +sequence = [ + "format", + "lint", + "typecheck", + "test", +] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint.isort] +lines-after-imports = 2 diff --git a/setup.py b/setup.py index 7a5f470..3191953 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,7 @@ """python-config installation script.""" +from pathlib import Path + from setuptools import setup from setuptools.command.test import test as Test @@ -16,34 +18,39 @@ def run_tests(self): if __name__ == "__main__": - with open("README") as readme: - setup( - name = "python-config", - version = "0.1.2", - - description = readme.readline().strip(), - long_description = readme.read().strip() or None, - url = "https://github.com/KonishchevDmitry/python-config", - - license = "GPL3", - author = "Dmitry Konishchev", - author_email = "konishchev@gmail.com", - - classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Operating System :: MacOS :: MacOS X", - "Operating System :: POSIX", - "Operating System :: Unix", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Topic :: Software Development :: Libraries :: Python Modules", - ], - platforms = [ "unix", "linux", "osx" ], - - packages = [ "python_config" ], - - cmdclass = { "test": PyTest }, - tests_require = [ "pytest" ], - ) + readme = Path("README").read_text() + version = Path("version.txt").read_text().strip() + + setup( + name = "python-config", + version = version, + + description = readme.split("\n", 1)[0], + long_description = readme, + url = "https://github.com/KonishchevDmitry/python-config", + + license = "GPL3", + author = "Dmitry Konishchev", + author_email = "konishchev@gmail.com", + + classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX", + "Operating System :: Unix", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: Implementation :: CPython", + ], + platforms = [ "unix", "linux", "osx" ], + + packages = [ "python_config" ], + + cmdclass = { "test": PyTest }, + tests_require = [ "pytest" ], + ) diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +1.0.0 From 4e7e6840fd37d2f208b35e96ffb419fdd35ddc59 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Tue, 19 May 2026 12:03:48 +0300 Subject: [PATCH 05/17] Convert README to RST format --- MANIFEST.in | 2 +- README | 10 -------- README.rst | 57 ++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- python-config.spec | 2 +- setup.py | 2 +- 6 files changed, 61 insertions(+), 14 deletions(-) delete mode 100644 README create mode 100644 README.rst diff --git a/MANIFEST.in b/MANIFEST.in index f3d3bec..96bfba3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,6 @@ include ChangeLog include INSTALL include Makefile include python-config.spec -include README +include README.rst include tests/*.conf include tests/*.py diff --git a/README b/README deleted file mode 100644 index a1c70ed..0000000 --- a/README +++ /dev/null @@ -1,10 +0,0 @@ -A library for reading Python configuration files - -Python configuration files themselves are actual Python files. The library reads -only values in uppercase from them, checks that they contain only basic Python -types and returns a dictionary which corresponds to the configuration file. - -Configuration file can be saved back using dump and dumps functions. - -Note: if you want to validate the configuration values, take a look at -https://github.com/KonishchevDmitry/object-validator project. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..a7019b3 --- /dev/null +++ b/README.rst @@ -0,0 +1,57 @@ +A library for reading Python configuration files +================================================ + +About +----- + +This library reads configuration files that are represented as Python modules +with restricted syntax. It uses the AST for parsing. +Values can be written back with ``dump`` and ``dumps``. + +Two APIs are available: + +* ``python_config.load(s)`` / ``dump(s)`` — read and write a **dict** (lowercase keys). + +* ``python_config.document.load(s)`` / ``dump(s)`` — read and write a + :class:`~python_config.document.ConfigDocument`. Use this to change option + values from code and save the file back. Top-level options use uppercase names + (e.g. ``doc["LOG_LEVEL"] = 5``). Nested ``list`` and ``dict`` values are + plain Python objects; in-place mutations are reflected on dump. + +Supported value types are: +- ``bool``, +- ``int``, +- ``float``, +- ``str``, +- ``list``, +- ``dict`` (with string keys). + +Only **uppercase** names that do not start with ``_`` are exposed through the dict API. + +Expressions in source files are **evaluated on load**: + +* Arithmetic (e.g. ``COUNT = 1 + 2 * 3``) +* F-strings that reference earlier options (e.g. ``ENDPOINT = f"{HOST}:{PORT}"``) + +``dump`` and ``dumps`` always emit **literal values** with aggressive formatting applied. +Hand-edited spacing and expression forms are not preserved on save. + +Preamble comments, a module-level docstring, and per-assignment docstrings are +preserved through the document API. + +.. NOTE:: + + If you want to validate the configuration values, take a look at + https://github.com/KonishchevDmitry/object-validator project or just use Pydantic. + +Tests +----- + +It's recommended to test against python3.6. Assuming here you have .venv36 for this. + +.. source:: bash + + poe test + + # python 3.6 + .venv36/bin/pytest -s -vvv tests/ diff --git a/pyproject.toml b/pyproject.toml index 38e1c93..f59df0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ { name = "Dmitry Konishchev", email = "konishchev@gmail.com" }, ] license = { text = "GPL-3.0" } -readme = "README" +readme = "README.rst" keywords = ["python-syntax", "configuration"] classifiers = [ diff --git a/python-config.spec b/python-config.spec index 4ca89cc..b1f10e7 100644 --- a/python-config.spec +++ b/python-config.spec @@ -62,7 +62,7 @@ make PYTHON=%{__python3} check %{python3_sitelib}/python_config/ %{python3_sitelib}/python_config/__pycache__/ %{python3_sitelib}/python_config-%{version}-*.egg-info -%doc ChangeLog INSTALL README +%doc ChangeLog INSTALL README.rst %clean diff --git a/setup.py b/setup.py index 3191953..02d0b0c 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ def run_tests(self): if __name__ == "__main__": - readme = Path("README").read_text() + readme = Path("README.rst").read_text() version = Path("version.txt").read_text().strip() setup( From ace7811e6c4e40e4ad445f7804fafc9bd3239ac7 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Fri, 10 Jul 2026 14:36:26 +0300 Subject: [PATCH 06/17] Fix unit tests and cover new code --- tests/__init__.py | 0 tests/conftest.py | 67 ++++++ tests/example.conf | 17 -- tests/pyproject_like.conf | 109 +++++++++ tests/pyproject_like_ext.conf | 108 +++++++++ tests/test.conf | 1 - tests/test_ast_roundtrip.py | 60 ----- tests/test_benchmark.py | 28 +++ tests/test_document.py | 103 +++++++++ tests/test_dump.py | 31 --- tests/test_file_reading.py | 34 --- tests/test_load_modify_dump.py | 394 +++++++++++++++++++++++++++++++++ tests/test_models.py | 191 ++++++++++++++++ tests/test_parse.py | 46 ++++ tests/test_parsing.py | 89 -------- tests/test_serialize.py | 49 ++++ tests/test_simple.py | 133 +++++++++++ 17 files changed, 1228 insertions(+), 232 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py delete mode 100644 tests/example.conf create mode 100644 tests/pyproject_like.conf create mode 100644 tests/pyproject_like_ext.conf delete mode 100644 tests/test.conf delete mode 100644 tests/test_ast_roundtrip.py create mode 100644 tests/test_benchmark.py create mode 100644 tests/test_document.py delete mode 100644 tests/test_dump.py delete mode 100644 tests/test_file_reading.py create mode 100644 tests/test_load_modify_dump.py create mode 100644 tests/test_models.py create mode 100644 tests/test_parse.py delete mode 100644 tests/test_parsing.py create mode 100644 tests/test_serialize.py create mode 100644 tests/test_simple.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5bcb0bd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +from pathlib import Path + +import pytest + +import python_config.document + + +def generate_huge_config(): + """Build an over-15k-line config source string for benchmark fixtures.""" + + lines = [] + for index in range(1, 201): + lines.append('VAR{0} = "val{0}"'.format(index)) + if index % 2 == 1: + lines.append('"""Docstring for VAR{0}."""'.format(index)) + + lines.append("MEGASTRUCT = {") + lines.append(' "sub1": [') + + for index in range(1, 3001): + lines.append( + ' {{"name": "sub1-{0}", "val": "val1-{0}", "index": {0}}},'.format(index) + ) + lines.append(" ],") + lines.append(' "sub2": {') + lines.append(' "sub2-sub1": {') + lines.append(' "somethings": [') + + for index in range(1, 12001): + lines.append( + ' {{"name": "sub2-sub1-{0}", "val": "val1-{0}", "index": {0}}},'.format( + index + ) + ) + lines.append(" ],") + lines.append(" },") + lines.append(" },") + lines.append("}") + + return "\n".join(lines) + "\n" + + +@pytest.fixture(scope="session") +def confpath(): + """Return the path to testing configuration file.""" + + return Path(__file__).parent / "pyproject_like.conf" + + +@pytest.fixture(scope="session") +def confdoc(confpath): + """Return parsed testing configuration file.""" + + return python_config.document.load(confpath) + + +@pytest.fixture(scope="session") +def huge_conf_path(tmp_path_factory): + """Return path to a generated huge config file.""" + + source = generate_huge_config() + assert source.count("\n") >= 15000 + + path = tmp_path_factory.mktemp("huge") / "huge.conf" + path.write_text(source) + + return path diff --git a/tests/example.conf b/tests/example.conf deleted file mode 100644 index cdf1c36..0000000 --- a/tests/example.conf +++ /dev/null @@ -1,17 +0,0 @@ -# vim: set tf=python: - -VAR1 = "string-value" - -VAR2 = 10.0 -"""This variable has a docstring.""" - -SOMEDATA = { - "key1": 20, - "key2": {"subkey": 30}, -} - -SOMETHINGS = [ - "thing1", - "thing2", -] -"""Another docstring.""" diff --git a/tests/pyproject_like.conf b/tests/pyproject_like.conf new file mode 100644 index 0000000..b279334 --- /dev/null +++ b/tests/pyproject_like.conf @@ -0,0 +1,109 @@ +# Example: pyproject.toml-style metadata expressed as python-config assignments. +# Serves as an excessive configuration file to test against. +# NOTE: This commentary until the PROJECT_NAME must be preserved! +# vim: set tf=python: + +"""Module docstrings are supported too.""" + +PROJECT_NAME = "python-config" + +VERSION = "1.0.0" +"""PEP 440 version string.""" + +ENABLED = True + +DISABLED = False +"""Disabled has precedence over enabled due to implementation shenanigans.""" + +LOG_LEVEL = 3 + +CLOG_LEVEL = -1 +"""Negative one.""" + +APPROX_BYTE_BITS = 8.0 + +APPROX_FRIDGE_TEMP_C = -17.0 +"""I hope I remember correctly.""" + +BUILD_NUMBER = 7 + +TWEAKS_CHUNK_SIZE = 4096 +"""Chunk size for IO operations.""" + +PACKAGE_NAME = "python-config-1.0.0" + +PACKAGE_FULLNAME = "python-config-1.0.0.7" +"""Fully qualified package name. + +Multiline docstrings must be supported too. +""" + +CLASSIFIERS = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", +] + +METADATA = [ + { + "name": "default-pr-template", + "value": ".github/templates/pr.md", + }, + { + "name": "default-smoke-hook", + "value": ".github/hooks/smoke-python-config", + }, + { + "name": "max-test-duration-ms", + "value": 120000, + }, +] +"""Little nesting involved.""" + +PROJECT = { + "name": "python-config", + "dynamic": [ + "version", + ], + "description": "A library for reading Python configuration files", + "readme": "README.rst", + "license": "GPL-3.0", + "keywords": [ + "ast", + "config", + "library", + ], + "classifiers": { + "$REF": "CLASSIFIERS", + }, + "requires-python": ">=3.9", + "authors": [ + { + "name": "Dmitry Konishchev", + "email": "konishchev@gmail.com", + }, + { + "name": "Pavel Kulyov", + "email": "kulyov.pavel@gmail.com", + }, + ], + "include-data": True, + "tools": { + "ruff": { + "line-length": 100, + "rules": [ + "E", + "A", + "B", + ], + }, + "sphinx-upload": { + "path": "docs/python-config/1.0.0", + }, + }, +} +""" +Complex assignment. +Multiline string with hanging bracket. +""" diff --git a/tests/pyproject_like_ext.conf b/tests/pyproject_like_ext.conf new file mode 100644 index 0000000..fac89ff --- /dev/null +++ b/tests/pyproject_like_ext.conf @@ -0,0 +1,108 @@ +# Example: pyproject.toml-style metadata expressed as python-config assignments. +# Serves as an excessive configuration file to test against. +# NOTE: This commentary until the PROJECT_NAME must be preserved! +# vim: set tf=python: +"""Module docstrings are supported too.""" + +PROJECT_NAME = "python-config" + +VERSION = "1.0.0" +"""PEP 440 version string.""" + +ENABLED = True + +DISABLED = False +"""Disabled has precedence over enabled due to implementation shenanigans.""" + +LOG_LEVEL = 3 + +CLOG_LEVEL = -1 +"""Negative one.""" + +APPROX_BYTE_BITS = 8.0 + +APPROX_FRIDGE_TEMP_C = -17.0 +"""I hope I remember correctly.""" + +BUILD_NUMBER = 1 + 2 * 3 + +TWEAKS_CHUNK_SIZE = 4 * 1024 +"""Chunk size for IO operations.""" + +PACKAGE_NAME = f"{PROJECT_NAME}-{VERSION}" + +PACKAGE_FULLNAME = f"{PACKAGE_NAME}.{BUILD_NUMBER}" +"""Fully qualified package name. + +Multiline docstrings must be supported too. +""" + +CLASSIFIERS = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", +] + +METADATA = [ + { + "name": f"default-pr-template", + "value": ".github/templates/pr.md", + }, + { + "name": "default-smoke-hook", + "value": f".github/hooks/smoke-{PROJECT_NAME}", + }, + { + "name": "max-test-duration-ms", + "value": 2 * 1000 * 60, + }, +] +"""Little nesting involved.""" + +PROJECT = { + "name": "python-config", + "dynamic": [ + "version", + ], + "description": "A library for reading Python configuration files", + "readme": "README.rst", + "license": "GPL-3.0", + "keywords": [ + "ast", + "config", + "library", + ], + "classifiers": { + "$REF": "CLASSIFIERS", + }, + "requires-python": ">=3.9", + "authors": [ + { + "name": "Dmitry Konishchev", + "email": "konishchev@gmail.com", + }, + { + "name": "Pavel Kulyov", + "email": "kulyov.pavel@gmail.com", + }, + ], + "include-data": True, + "tools": { + "ruff": { + "line-length": 100, + "rules": [ + "E", + "A", + "B", + ], + }, + "sphinx-upload": { + "path": f"docs/{PROJECT_NAME}/{VERSION}", + }, + }, +} +""" +Complex assignment. +Multiline string with hanging bracket. +""" diff --git a/tests/test.conf b/tests/test.conf deleted file mode 100644 index 64adb1c..0000000 --- a/tests/test.conf +++ /dev/null @@ -1 +0,0 @@ -KEY = "value" diff --git a/tests/test_ast_roundtrip.py b/tests/test_ast_roundtrip.py deleted file mode 100644 index ae6a1a2..0000000 --- a/tests/test_ast_roundtrip.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Test internal AST parse/serialize round-trip.""" - -import os - -import pytest - -from python_config.parse import ParseError, parse_config -from python_config.serialize import serialize_config - -EXAMPLE = os.path.join(os.path.dirname(__file__), "example.conf") - - -def test_load_example_values(): - doc = parse_config(open(EXAMPLE).read()) - assert doc["VAR1"] == "string-value" - assert doc["VAR2"] == 10.0 - assert doc["SOMEDATA"] == {"key1": 20, "key2": {"subkey": 30}} - assert doc["SOMETHINGS"] == ["thing1", "thing2"] - - -def test_load_example_docstrings(): - doc = parse_config(open(EXAMPLE).read()) - by_name = {item.name: item for item in doc.items} - assert by_name["VAR2"].docstring == "This variable has a docstring." - assert by_name["SOMETHINGS"].docstring == "Another docstring." - assert by_name["VAR1"].docstring is None - - -def test_load_example_preamble(): - doc = parse_config(open(EXAMPLE).read()) - assert doc.preamble.startswith("# vim: set tf=python:") - - -def test_roundtrip_preserves_semantics(): - source = open(EXAMPLE).read() - doc = parse_config(source) - again = parse_config(serialize_config(doc)) - assert again.preamble == doc.preamble - assert again.names() == doc.names() - for name in doc.names(): - assert again[name] == doc[name] - for left, right in zip(doc.items, again.items): - assert left.docstring == right.docstring - - -def test_roundtrip_docstrings_in_output(): - doc = parse_config(open(EXAMPLE).read()) - text = serialize_config(doc) - assert '"""This variable has a docstring."""' in text - assert '"""Another docstring."""' in text - - -def test_rejects_orphan_docstring(): - with pytest.raises(ParseError, match="docstring must immediately follow"): - parse_config('"""orphan"""\n') - - -def test_rejects_function_call(): - with pytest.raises(ParseError, match="disallowed expression"): - parse_config("X = len([1])\n") diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 0000000..eed1fee --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,28 @@ +import time + +import python_config +from python_config import document + + +def test_benchmark_document_load(huge_conf_path): + start = time.perf_counter() + doc = document.load(huge_conf_path) + elapsed = time.perf_counter() - start + + assert len(doc["MEGASTRUCT"]["sub1"]) == 3000 + assert len(doc["MEGASTRUCT"]["sub2"]["sub2-sub1"]["somethings"]) == 12000 + assert doc["VAR200"] == "val200" + + print("document.load: {:.3f}s".format(elapsed)) + + +def test_benchmark_simple_load(huge_conf_path): + start = time.perf_counter() + config = python_config.load(huge_conf_path) + elapsed = time.perf_counter() - start + + assert len(config["megastruct"]["sub1"]) == 3000 + assert len(config["megastruct"]["sub2"]["sub2-sub1"]["somethings"]) == 12000 + assert config["var200"] == "val200" + + print("python_config.load: {:.3f}s".format(elapsed)) diff --git a/tests/test_document.py b/tests/test_document.py new file mode 100644 index 0000000..a0f8705 --- /dev/null +++ b/tests/test_document.py @@ -0,0 +1,103 @@ +"""Tests for python_config.document.""" + +import io + +import pytest + +from python_config import document +from python_config.exceptions import ParsingError +from python_config.models import ConfigDocument + + +MINIMAL_SOURCE = '''\ +# section +NAME = "app" + +COUNT = 2 + 3 +"""Five.""" +''' + + +def test_load(confpath): + doc = document.load(confpath) + + assert isinstance(doc, ConfigDocument) + assert doc["PROJECT_NAME"] == "python-config" + assert doc["BUILD_NUMBER"] == 7 + assert doc["VERSION"] == "1.0.0" + assert "NOTE: This commentary" in doc.preamble + + assert doc["METADATA"] == [ + { + "name": "default-pr-template", + "value": ".github/templates/pr.md", + }, + { + "name": "default-smoke-hook", + "value": f".github/hooks/smoke-{doc['PROJECT_NAME']}", + }, + { + "name": "max-test-duration-ms", + "value": 2 * 1000 * 60, + }, + ] + + +def test_loads(confpath): + doc = document.loads(MINIMAL_SOURCE) + + assert isinstance(doc, ConfigDocument) + assert doc.preamble.startswith("# section") + assert doc["NAME"] == "app" + assert doc["COUNT"] == 5 + + assert doc.getvar("COUNT").docstring == "Five." + + with pytest.raises(ParsingError): + document.loads("NOT_AN_ASSIGNMENT()\n") + + +def test_dump(): + doc = document.loads(MINIMAL_SOURCE) + buffer = io.StringIO() + + document.dump(buffer, doc) + + again = document.loads(buffer.getvalue()) + assert again["NAME"] == "app" + assert again["COUNT"] == 5 + assert again.preamble.startswith("# section") + + +def test_dumps(confpath): + doc = document.load(confpath) + text = document.dumps(doc) + + with open(confpath) as source: + assert text == source.read() + + +def test_load_dumped(confpath): + doc = document.load(confpath) + assert doc == document.loads(document.dumps(doc)) + + +def test_dump_example_formatted(confpath): + doc = document.load(confpath) + text = document.dumps(doc) + assert "NOTE: This commentary" in doc.preamble + assert '"""Module docstrings are supported too."""' in text + assert "BUILD_NUMBER = 7" in text + assert 'PACKAGE_NAME = "python-config-1.0.0"' in text + assert ".github/hooks/smoke-python-config" in text + assert '"""PEP 440 version string."""' in text + assert "# str without docstring" not in text + assert "1 + 2 * 3" not in text + assert '= f"' not in text + + +def test_section_comment_not_preserved_on_dump(): + source = '# preamble\n"""mod"""\n# section\nX = 1\n' + doc = document.loads(source) + assert "# section" not in document.dumps(doc) + assert doc.preamble.startswith("# preamble") diff --git a/tests/test_dump.py b/tests/test_dump.py deleted file mode 100644 index abfa061..0000000 --- a/tests/test_dump.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Test configuration serialization.""" - -import io - -import pytest - -import python_config - - -def test_dumps_roundtrip(): - obj = {"key": "value", "count": 42, "enabled": True} - text = python_config.dumps(obj) - assert python_config.loads(text) == obj - - -def test_dump_to_file_like(): - obj = {"alpha": 1, "beta": "two"} - buf = io.StringIO() - python_config.dump(buf, obj) - assert python_config.loads(buf.getvalue()) == obj - - -def test_dumps_invalid_type(): - with pytest.raises(python_config.ValidationError): - python_config.dumps({"bad": object()}) - - -def test_dumps_uppercase_keys(): - text = python_config.dumps({"my_opt": "x"}) - assert "MY_OPT" in text - assert "my_opt" not in text.split("=")[0] diff --git a/tests/test_file_reading.py b/tests/test_file_reading.py deleted file mode 100644 index a491f77..0000000 --- a/tests/test_file_reading.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Test configuration file reading.""" - -import errno -import os -import tempfile - -import pytest - -import python_config -from python_config import FileReadingError - - -def test_reading(): - assert python_config.load("tests/test.conf") == {"key": "value"} - - -def test_missing_file(): - assert ( - pytest.raises( - FileReadingError, - lambda: python_config.load("missing.conf"), - ).value.errno - == errno.ENOENT - ) - - -def test_no_access(): - with tempfile.NamedTemporaryFile() as config: - os.chmod(config.name, 0) - - assert ( - pytest.raises(FileReadingError, lambda: python_config.load(config.name)).value.errno - == errno.EACCES - ) diff --git a/tests/test_load_modify_dump.py b/tests/test_load_modify_dump.py new file mode 100644 index 0000000..cdffe1a --- /dev/null +++ b/tests/test_load_modify_dump.py @@ -0,0 +1,394 @@ +"""Test load, modify values in code, and save back via document API.""" + +import shutil + +import pytest + +import python_config +from python_config import ValidationError + + +@pytest.fixture +def example_copy(confpath, tmpdir): + path = tmpdir.join("test.conf") + shutil.copy(confpath, str(path)) + return str(path) + + +def test_modify_scalar_preserves_other_assignments(example_copy): + doc = python_config.document.load(example_copy) + assert doc["LOG_LEVEL"] == 3 + doc["LOG_LEVEL"] = 5 + + text = python_config.document.dumps(doc) + again = python_config.document.loads(text) + assert again["LOG_LEVEL"] == 5 + assert "BUILD_NUMBER = 7" in text + assert "PROJECT_NAME" in text + assert "NOTE: This commentary" in again.preamble + + +def test_modify_scalar_visible_via_simple_load(example_copy): + doc = python_config.document.load(example_copy) + doc["LOG_LEVEL"] = 5 + with open(example_copy, "w") as out: + python_config.document.dump(out, doc) + + assert python_config.load(example_copy)["log_level"] == 5 + + +def test_dumps_ext_conf_matches_canonical(confpath): + ext_path = confpath.parent / "pyproject_like_ext.conf" + doc = python_config.document.load(ext_path) + + with open(confpath) as source: + assert python_config.document.dumps(doc) == source.read() + + +def test_modify_project_name_keeps_evaluated_metadata_literals(example_copy): + doc = python_config.document.load(example_copy) + doc["PROJECT_NAME"] = "renamed" + again = python_config.document.loads(python_config.document.dumps(doc)) + assert again["PROJECT_NAME"] == "renamed" + hook = next( + entry["value"] for entry in again["METADATA"] if entry["name"] == "default-smoke-hook" + ) + # Evaluated at load; changing PROJECT_NAME does not re-evaluate nested literals. + assert hook == ".github/hooks/smoke-python-config" + + +def test_file_roundtrip(example_copy): + doc = python_config.document.load(example_copy) + doc["LOG_LEVEL"] = 99 + with open(example_copy, "w") as out: + python_config.document.dump(out, doc) + + again = python_config.document.load(example_copy) + assert again["LOG_LEVEL"] == 99 + text = python_config.document.dumps(again) + assert "BUILD_NUMBER = 7" in text + + +def test_setitem_rejects_invalid_type(confpath): + doc = python_config.document.loads(confpath.read_text()) + with pytest.raises(ValidationError): + doc["LOG_LEVEL"] = object() + + +def test_dumps_rejects_invalid_document(confpath): + doc = python_config.document.loads(confpath.read_text()) + doc.items[0].value = object() + with pytest.raises(ValidationError): + python_config.document.dumps(doc) + + +def test_modify_top_level(confpath): + doc = python_config.document.load(confpath) + + # Numbers + assert doc["CLOG_LEVEL"] == -1 + assert doc["LOG_LEVEL"] == 3 + doc["CLOG_LEVEL"], doc["LOG_LEVEL"] = doc["LOG_LEVEL"], doc["CLOG_LEVEL"] + + # Consequent reassignment + doc["APPROX_FRIDGE_TEMP_C"] += 5 + doc["APPROX_FRIDGE_TEMP_C"] = 6 + + # Booleans + assert doc["ENABLED"] is True + assert doc["DISABLED"] is False + doc["ENABLED"], doc["DISABLED"] = doc["DISABLED"], doc["ENABLED"] + + # F-strings evaluated at load + assert doc["PACKAGE_NAME"] == "python-config-1.0.0" + assert doc["PACKAGE_FULLNAME"] == "python-config-1.0.0.7" + + # Arithmetic evaluated at load + assert doc["BUILD_NUMBER"] == 7 + doc["BUILD_NUMBER"] = 10 + + doc["PACKAGE_NAME"] = "pkg-python-config-1.0.0" + assert doc["PACKAGE_NAME"] == "pkg-python-config-1.0.0" + assert doc["PACKAGE_FULLNAME"] == "python-config-1.0.0.7" + + # Lists (flat) + assert doc["CLASSIFIERS"] == [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + ] + doc["CLASSIFIERS"][0] = "Development Status :: 5 - Production/Stable" + doc["CLASSIFIERS"].append("Programming Language :: Python :: 3.10") + + text = python_config.document.dumps(doc) + again = python_config.document.loads(text) + + assert again["CLOG_LEVEL"] == 3 + assert again["LOG_LEVEL"] == -1 + + # Consequent reassignment + assert again["APPROX_FRIDGE_TEMP_C"] == 6 + + assert "BUILD_NUMBER = 10" in text + assert again["BUILD_NUMBER"] == 10 + + assert 'PACKAGE_NAME = "pkg-python-config-1.0.0"' in text + assert again["PACKAGE_NAME"] == "pkg-python-config-1.0.0" + assert again["PACKAGE_FULLNAME"] == "python-config-1.0.0.7" + + # Booleans + assert again["ENABLED"] is False + assert again["DISABLED"] is True + + # Lists + assert again["CLASSIFIERS"] == [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + ] + + +def test_modify_nested_structures(confpath): + doc = python_config.document.load(confpath) + + # Lists (of dicts) + assert doc["METADATA"] == [ + { + "name": "default-pr-template", + "value": ".github/templates/pr.md", + }, + { + "name": "default-smoke-hook", + "value": f".github/hooks/smoke-{doc['PROJECT_NAME']}", # evaluted ofc + }, + { + "name": "max-test-duration-ms", + "value": 2 * 1000 * 60, # evaluated ofc + }, + ] + doc["METADATA"][0]["value"] = ".github/templates/pr-v2.md" + + # NOTE: changing order! + doc["METADATA"][1], doc["METADATA"][2] = doc["METADATA"][2], doc["METADATA"][1] + + # NOTE: adding new entry + doc["METADATA"].append({"name": "doc-auto-translation-hook", "value": ".github/hooks/dat.sh"}) + + # Changing str in dict + assert doc["PROJECT"]["readme"] == "README.rst" + doc["PROJECT"]["readme"] = "README.org" + + # Deleting root-level key and adding new + assert doc["PROJECT"]["dynamic"] == ["version"] + assert "version" not in doc["PROJECT"] + del doc["PROJECT"]["dynamic"] + doc["PROJECT"]["version"] = doc["VERSION"] + + # Changing list in dict + assert doc["PROJECT"]["keywords"] == ["ast", "config", "library"] + doc["PROJECT"]["keywords"].insert(0, "python") + doc["PROJECT"]["keywords"][2] = "configuration" + + assert doc["PROJECT"]["tools"]["ruff"]["line-length"] == 100 + doc["PROJECT"]["tools"]["ruff"]["line-length"] = 120 + + text = python_config.document.dumps(doc) + again = python_config.document.loads(text) + + assert again["METADATA"] == [ + { + "name": "default-pr-template", + "value": ".github/templates/pr-v2.md", + }, + { + "name": "max-test-duration-ms", + "value": 2 * 1000 * 60, # evaluated ofc + }, + { + "name": "default-smoke-hook", + "value": f".github/hooks/smoke-{doc['PROJECT_NAME']}", # evaluted ofc + }, + { + "name": "doc-auto-translation-hook", + "value": ".github/hooks/dat.sh", + }, + ] + + # Dicts (deep nested everything) + # Deleting root-level key and adding new + assert "dynamic" not in again["PROJECT"] + assert again["PROJECT"]["version"] == "1.0.0" + + assert again["PROJECT"]["readme"] == "README.org" + assert again["PROJECT"]["keywords"] == ["python", "ast", "configuration", "library"] + + assert again["PROJECT"]["tools"]["ruff"]["line-length"] == 120 + + +def test_modify_nested_values(confpath): + doc = python_config.document.load(confpath) + + assert doc["METADATA"][2]["value"] == 2 * 1000 * 60 + assert doc["METADATA"][1]["value"] == ".github/hooks/smoke-python-config" + assert doc["PROJECT"]["tools"]["sphinx-upload"]["path"] == "docs/python-config/1.0.0" + + doc["METADATA"][2]["value"] = 180000 + assert doc["METADATA"][2]["value"] == 180000 + assert doc["METADATA"][2]["value"] // 60 == 3000 + + doc["METADATA"][1]["value"] = ".github/hooks/smoke-python-config" + doc["PROJECT"]["tools"]["sphinx-upload"]["path"] = "docs/python-config/1.0.0" + + text = python_config.document.dumps(doc) + again = python_config.document.loads(text) + + assert "180000" in text + assert ".github/hooks/smoke-python-config" in text + assert '"path": "docs/python-config/1.0.0"' in text + assert "3 * 60 * 1000" not in text + assert '= f"' not in text + + assert again["METADATA"][2]["value"] == 180000 + assert again["METADATA"][1]["value"] == ".github/hooks/smoke-python-config" + assert again["PROJECT"]["tools"]["sphinx-upload"]["path"] == "docs/python-config/1.0.0" + + +def test_modify_list(confpath): + doc = python_config.document.load(confpath) + + assert doc["METADATA"] == [ + { + "name": "default-pr-template", + "value": ".github/templates/pr.md", + }, + { + "name": "default-smoke-hook", + "value": ".github/hooks/smoke-python-config", + }, + { + "name": "max-test-duration-ms", + "value": 2 * 1000 * 60, + }, + ] + metadata = doc["METADATA"] + metadata[:] = metadata[::-1] + + assert doc["CLASSIFIERS"] == [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + ] + classifiers = doc["CLASSIFIERS"] + classifiers[0:2] = classifiers[1::-1] + + assert doc["PROJECT"]["keywords"] == ["ast", "config", "library"] + keywords = doc["PROJECT"]["keywords"] + keywords[:] = keywords[1:] + keywords[:1] + + rules = doc["PROJECT"]["tools"]["ruff"]["rules"] + assert rules == ["E", "A", "B"] + rules[:] = rules[::-1] + + text = python_config.document.dumps(doc) + again = python_config.document.loads(text) + + assert again["METADATA"] == [ + { + "name": "max-test-duration-ms", + "value": 120000, + }, + { + "name": "default-smoke-hook", + "value": ".github/hooks/smoke-python-config", + }, + { + "name": "default-pr-template", + "value": ".github/templates/pr.md", + }, + ] + assert "smoke-{PROJECT_NAME}" not in text + assert "2 * 1000 * 60" not in text + + assert again["CLASSIFIERS"] == [ + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + ] + + assert again["PROJECT"]["keywords"] == ["config", "library", "ast"] + assert again["PROJECT"]["tools"]["ruff"]["rules"] == ["B", "A", "E"] + + +def test_modify_dict(confpath): + doc = python_config.document.load(confpath) + + assert doc["PROJECT"]["readme"] == "README.rst" + assert doc["PROJECT"]["dynamic"] == ["version"] + assert doc["PROJECT"]["tools"]["sphinx-upload"]["path"] == "docs/python-config/1.0.0" + + project = doc["PROJECT"] + project["readme"] = "README.org" + del project["dynamic"] + project["version"] = doc["VERSION"] + project["tools"]["ruff"]["line-length"] = 120 + + authors = project["authors"] + authors[0], authors[1] = authors[1], authors[0] + + text = python_config.document.dumps(doc) + again = python_config.document.loads(text) + + assert "smoke-{PROJECT_NAME}" not in text + assert "2 * 1000 * 60" not in text + assert 'f"docs/{PROJECT_NAME}/{VERSION}"' not in text + assert '"path": "docs/python-config/1.0.0"' in text + + assert again["PROJECT"]["readme"] == "README.org" + assert "dynamic" not in again["PROJECT"] + assert again["PROJECT"]["version"] == "1.0.0" + assert again["PROJECT"]["tools"]["ruff"]["line-length"] == 120 + assert again["PROJECT"]["authors"] == [ + { + "name": "Pavel Kulyov", + "email": "kulyov.pavel@gmail.com", + }, + { + "name": "Dmitry Konishchev", + "email": "konishchev@gmail.com", + }, + ] + + +def test_add_remove_top_level(confpath): + doc = python_config.document.load(confpath) + assert doc == python_config.document.loads(python_config.document.dumps(doc)) + + # add new + doc["METADATA2"] = [dict(item) for item in doc["METADATA"]] + assert doc["METADATA2"] == doc["METADATA"] + + # remove existing root-level assignment + assert "PACKAGE_FULLNAME" in doc + del doc["PACKAGE_FULLNAME"] + assert "PACKAGE_FULLNAME" not in doc.names() + + # remove nested dict key + assert "readme" in doc["PROJECT"] + del doc["PROJECT"]["readme"] + assert "readme" not in doc["PROJECT"] + + text = python_config.document.dumps(doc) + again = python_config.document.loads(text) + + assert "METADATA2" in again + assert again["METADATA2"] == again["METADATA"] + + assert "PACKAGE_FULLNAME" not in again + assert "readme" not in again["PROJECT"] + + assert python_config.document.loads(python_config.document.dumps(again)) == again diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..7636659 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,191 @@ +"""Tests for python_config.models.""" + +from python_config.document import dumps, load, loads +from python_config.models import Assignment + + +def test_assignment_equality(): + left = Assignment(name="COUNT", value=7) + right = Assignment(name="COUNT", value=7) + assert left is not right + assert left == right + + +def test_assignment_inequality_on_value_or_docstring(): + base = Assignment(name="X", value=1) + assert base != Assignment(name="Y", value=1) + assert base != Assignment(name="X", value=2) + assert base != Assignment(name="X", value=1, docstring="note") + + +def test_list_mutation(confpath): + doc = load(confpath) + classifiers = doc["CLASSIFIERS"] + + assert isinstance(classifiers, list) + assert len(classifiers) == 4 + assert classifiers == [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + ] + assert repr(classifiers) == repr(classifiers[:]) + + assert classifiers[0] == "Development Status :: 4 - Beta" + assert classifiers[1:3] == [ + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + ] + + classifiers.insert(0, "Topic :: Software Development :: Libraries") + classifiers.append("Programming Language :: Python :: 3.11") + classifiers.extend( + [ + "Private :: Do Not Upload", + "Environment :: Console", + ] + ) + classifiers[1] = "Development Status :: 5 - Production/Stable" + classifiers[1], classifiers[2] = classifiers[2], classifiers[1] + classifiers[3] = "Programming Language :: Python :: 3.10" + classifiers.remove("Private :: Do Not Upload") + classifiers.pop() + del classifiers[1] + + assert classifiers == [ + "Topic :: Software Development :: Libraries", + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.11", + ] + + metadata = doc["METADATA"] + assert isinstance(metadata, list) + assert len(metadata) == 3 + assert isinstance(metadata[0], dict) + assert [entry["name"] for entry in metadata] == [ + "default-pr-template", + "default-smoke-hook", + "max-test-duration-ms", + ] + metadata.append( + { + "name": "doc-auto-translation-hook", + "value": ".github/hooks/dat.sh", + } + ) + metadata[1], metadata[2] = metadata[2], metadata[1] + assert metadata[1]["name"] == "max-test-duration-ms" + assert metadata[2]["name"] == "default-smoke-hook" + + doc["ARITH_LIST"] = [100, 200, 300] + arith = doc["ARITH_LIST"] + assert isinstance(arith, list) + + arith[1] = 200 + assert arith[1] == 200 + arith.clear() + assert len(arith) == 0 + arith.extend([199, 200, 201]) + + text = dumps(doc) + again = loads(text) + + assert again["CLASSIFIERS"] == [ + "Topic :: Software Development :: Libraries", + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.11", + ] + assert again["METADATA"] == [ + { + "name": "default-pr-template", + "value": ".github/templates/pr.md", + }, + { + "name": "max-test-duration-ms", + "value": 120000, + }, + { + "name": "default-smoke-hook", + "value": ".github/hooks/smoke-python-config", + }, + { + "name": "doc-auto-translation-hook", + "value": ".github/hooks/dat.sh", + }, + ] + assert again["ARITH_LIST"] == [199, 200, 201] + assert "ARITH_LIST = [" in text + + +def test_dict_mutation(confpath): + doc = load(confpath) + project = doc["PROJECT"] + + assert isinstance(project, dict) + assert len(project) == len(doc.to_dict()["project"]) + + assert project["name"] == "python-config" + assert "name" in project and "name" in project.keys() + assert "missing-key" not in project + assert list(project.keys()) == list(project) == list(doc.to_dict()["project"]) + + assert list(project.values()) == list(doc.to_dict()["project"].values()) + assert ("name", "python-config") in project.items() + + project.update( + { + "description": "Updated description", + "license": "MIT", + } + ) + assert project["description"] == "Updated description" + + assert "build-backend" not in project + assert project.setdefault("build-backend", "setuptools") == "setuptools" + assert project["build-backend"] == "setuptools" + assert project.setdefault("name", "other") == "python-config" + assert project.pop("build-backend") == "setuptools" + assert "build-backend" not in project + + ruff = project["tools"]["ruff"] + assert isinstance(ruff, dict) + popped_key, popped_value = ruff.popitem() + ruff[popped_key] = popped_value + assert ruff["line-length"] == 100 + + keywords = project["keywords"] + assert isinstance(keywords, list) + keywords.clear() + assert project["keywords"] == keywords == [] + keywords.extend(["python", "config", "library"]) + assert project["keywords"] == ["python", "config", "library"] + + del project["license"] + project["readme"] = "README.md" + project["version"] = doc["VERSION"] + + sphinx = project["tools"]["sphinx-upload"] + assert sphinx["path"] == "docs/python-config/1.0.0" + sphinx["path"] = "docs/python-config/latest" + assert sphinx["path"] == "docs/python-config/latest" + + project["tools"]["mypy"] = {"strict": True} + assert project["tools"]["mypy"]["strict"] is True + + text = dumps(doc) + again = loads(text) + + assert again["PROJECT"]["description"] == "Updated description" + assert "license" not in again["PROJECT"] + assert again["PROJECT"]["readme"] == "README.md" + assert again["PROJECT"]["version"] == "1.0.0" + assert again["PROJECT"]["keywords"] == ["python", "config", "library"] + assert again["PROJECT"]["tools"]["ruff"]["line-length"] == 100 + assert again["PROJECT"]["tools"]["mypy"] == {"strict": True} + assert again["PROJECT"]["tools"]["sphinx-upload"]["path"] == "docs/python-config/latest" + assert '"path": "docs/python-config/latest"' in text diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000..7800db9 --- /dev/null +++ b/tests/test_parse.py @@ -0,0 +1,46 @@ +"""Tests for python_config.parse.""" + +import pytest + +from python_config.parse import ParseError, parse_config + + +def test_parse_config(confpath): + # Empty file is ok + doc = parse_config("") + assert doc.preamble == "" + assert doc.module_docstring is None + assert doc.items == [] + + # Only module docstring still ok + doc = parse_config('"""module only"""\n') + assert doc.preamble == "" + assert doc.module_docstring == "module only" + assert doc.items == [] + + # Single var + doc = parse_config("VAR = 'val'") + assert doc.preamble == "" + assert doc.module_docstring is None + assert len(doc.items) == 1 + assert doc["VAR"] == "val" + assert doc.getvar("VAR").docstring is None + + doc = parse_config(confpath.read_text()) + assert "# vim: set tf=python:" in doc.preamble + assert "NOTE: This commentary" in doc.preamble + + assert doc["PROJECT_NAME"] == "python-config" + assert doc["BUILD_NUMBER"] == 7 + assert doc["ENABLED"] is True + + assert doc.getvar("VERSION").docstring == "PEP 440 version string." + assert doc.getvar("TWEAKS_CHUNK_SIZE").docstring == "Chunk size for IO operations." + assert doc.getvar("PROJECT_NAME").docstring is None + assert doc.module_docstring == "Module docstrings are supported too." + + with pytest.raises(ParseError, match="docstring must immediately follow"): + parse_config('"""module"""\n"""orphan"""\n') + + with pytest.raises(ParseError, match="disallowed expression"): + parse_config("X = len([1])\n") diff --git a/tests/test_parsing.py b/tests/test_parsing.py deleted file mode 100644 index 285a155..0000000 --- a/tests/test_parsing.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Test configuration file parsing.""" - -import pytest - -import python_config - - -def test_parsing(): - config = python_config.loads( - """ -BOOL_VALUE = False -INT_VALUE = 1 -FLOAT_VALUE = 3.3 - -BYTES_VALUE = b"bytes value" -STRING_VALUE = "string value" - -TUPLE_VALUE = ("a", 1) - -LIST_VALUE = ["b", 2] - -SET_VALUE = {"a", "b", "c"} - -DICT_VALUE = { - 1: "number", - "s": "string", - "d": { - "l": ["one", 2], - "t": [1, "two"], - }, -} - """.strip(), - ) - - assert type(config["bool_value"]) is bool - assert type(config["int_value"]) is int - assert type(config["float_value"]) is float - - assert type(config["bytes_value"]) is str - assert type(config["string_value"]) is str - - assert type(config["set_value"]) is list - config["set_value"] = sorted(config["set_value"]) - - valid_config = { - "bool_value": False, - "int_value": 1, - "float_value": 3.3, - "bytes_value": "bytes value", - "string_value": "string value", - "tuple_value": ["a", 1], - "list_value": ["b", 2], - "set_value": sorted(["a", "b", "c"]), - "dict_value": { - 1: "number", - "s": "string", - "d": { - "l": ["one", 2], - "t": [1, "two"], - }, - }, - } - - assert config == valid_config - - -def test_invalid_type(): - with pytest.raises(python_config.ParsingError): - python_config.loads("OS = object()") - - -def test_invalid_dict_key(): - assert ( - pytest.raises( - python_config.ValidationError, - lambda: python_config.loads("A = {}; B = { (0, 1): 2 }"), - ).value.option_name - == "A B's key" - ) - - -def test_invalid_syntax(): - with pytest.raises(python_config.ParsingError): - python_config.loads("a=") - - -def test_rejects_import(): - with pytest.raises(python_config.ParsingError): - python_config.loads("import sys\nX = 1\n") diff --git a/tests/test_serialize.py b/tests/test_serialize.py new file mode 100644 index 0000000..bedb3b3 --- /dev/null +++ b/tests/test_serialize.py @@ -0,0 +1,49 @@ +"""Tests for python_config.serialize.""" + +import ast + +from python_config import document +from python_config.parse import parse_config +from python_config.serialize import ( + _unparse_expr, + serialize_config, +) + + +def test_dumps_evaluated_expressions_as_literals(): + source = 'A = 1\nB = 2\nNUM = 1 + 2 * 3\nMSG = f"{A}:{B}"\n' + doc = document.loads(source) + text = document.dumps(doc) + + assert doc["NUM"] == 7 + assert doc["MSG"] == "1:2" + assert "NUM = 7" in text + assert 'MSG = "1:2"' in text + assert "1 + 2 * 3" not in text + assert '= f"' not in text + + +def test_unparse_expr(): + # Multi-line list + node = ast.parse("[1, 2, 3]").body[0].value + result = _unparse_expr(node) + assert ( + result + == """[ + 1, + 2, + 3, +]""" + ) + + # Forced double quotes + node = ast.parse('"python-config"').body[0].value + assert _unparse_expr(node) == '"python-config"' + + node = ast.parse("'python-config'").body[0].value + assert _unparse_expr(node) == '"python-config"' + + +def test_parse_serialized(confpath): + doc = parse_config(confpath.read_text()) + assert doc == parse_config(serialize_config(doc)) diff --git a/tests/test_simple.py b/tests/test_simple.py new file mode 100644 index 0000000..afcac97 --- /dev/null +++ b/tests/test_simple.py @@ -0,0 +1,133 @@ +"""Tests for python_config.simple.""" + +import errno +import io +import os +import tempfile + +import pytest + +from python_config import simple +from python_config.exceptions import FileReadingError, ParsingError, ValidationError +from python_config.validation import _validate_value + + +# NOTE: ignore the imp module deprecation warning, this code will be deleted later. +def reference_load_impl(source): + import imp + + config_module = imp.new_module("config") + config_module.__file__ = "some/path.py" + + exec(compile(source, "some/path.py", "exec"), config_module.__dict__) + + config = {} + + for option, value in config_module.__dict__.items(): + if not option.startswith("_") and option.isupper(): + try: + config[option.lower()] = _validate_value(option, value) + except Exception as e: + raise ValidationError("some/path.py", e) + + return config + + +def test_load(confpath): + """Brief test that config loads correctly.""" + + conf = simple.load(confpath) + assert isinstance(conf, dict) + assert conf["project_name"] == "python-config" + assert conf["build_number"] == 7 + assert len(conf["metadata"]) == 3 + assert conf["project"]["tools"]["sphinx-upload"] == {"path": "docs/python-config/1.0.0"} + + sources = confpath.read_text() + assert conf == simple.loads(sources) == reference_load_impl(sources) + + +def test_load_missing_file(): + assert ( + pytest.raises( + FileReadingError, + lambda: simple.load("missing.conf"), + ).value.errno + == errno.ENOENT + ) + + +def test_no_access(): + with tempfile.NamedTemporaryFile() as config: + os.chmod(config.name, 0) + + assert ( + pytest.raises(FileReadingError, lambda: simple.load(config.name)).value.errno + == errno.EACCES + ) + + +def test_dumps_roundtrip(): + obj = {"key": "value", "count": 42, "enabled": True} + text = simple.dumps(obj) + assert simple.loads(text) == obj + + +def test_dump_to_file_like(): + obj = {"alpha": 1, "beta": "two"} + buf = io.StringIO() + simple.dump(buf, obj) + assert simple.loads(buf.getvalue()) == obj + + +def test_dumps_invalid_type(): + with pytest.raises(ValidationError): + simple.dumps({"bad": object()}) + + +def test_dumps_uppercase_keys(): + text = simple.dumps({"my_opt": "x"}) + assert "MY_OPT" in text + assert "my_opt" not in text.split("=")[0] + + +def test_dumps_nested_dict_formatted(): + text = simple.dumps({"data": {"a": 1, "b": {"c": 2}}}) + assert "\n " in text + assert '"a": 1,' in text or "'a': 1," in text + assert simple.loads(text) == {"data": {"a": 1, "b": {"c": 2}}} + + +def test_loads_rejects_string_arithmetic(): + with pytest.raises(ParsingError): + simple.loads('X = "a" + "b"') + + +def test_loads_rejects_name_in_expression(): + with pytest.raises(ParsingError): + simple.loads("X = OTHER + 1") + + +def test_loads_rejects_fstring_format_spec(): + with pytest.raises(ParsingError): + simple.loads('X = 1\nY = f"{X:.2f}"\n') + + +def test_loads_invalid_type(): + with pytest.raises(ParsingError): + simple.loads("OS = object()") + + +def test_loads_invalid_dict_key(): + with pytest.raises(ParsingError): + simple.loads("A = {}; B = { (0, 1): 2 }") + + +def test_loads_invalid_syntax(): + with pytest.raises(ParsingError): + simple.loads("a=") + + +def test_loads_rejects_import(): + with pytest.raises(ParsingError): + simple.loads("import sys\nX = 1\n") From e243891e9d969e428963e2feb38940b79e65ae97 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Sat, 11 Jul 2026 17:49:09 +0300 Subject: [PATCH 07/17] spec: drop unused and incorrect project name macro --- python-config.spec | 1 - 1 file changed, 1 deletion(-) diff --git a/python-config.spec b/python-config.spec index b1f10e7..f033457 100644 --- a/python-config.spec +++ b/python-config.spec @@ -2,7 +2,6 @@ %bcond_without tests -%global project_name pcore %global project_description %{expand: Python configuration files themselves are actual Python files. The module reads only values in uppercase from them, checks that they contain only basic From 15ee57ccb79ac50c316f5316239968cb44841833 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Mon, 13 Jul 2026 19:19:52 +0300 Subject: [PATCH 08/17] document: add setvar for convenience --- python_config/models.py | 47 ++++++++++++++++++++++++++++------------- tests/test_document.py | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/python_config/models.py b/python_config/models.py index d15152f..ea6ec40 100644 --- a/python_config/models.py +++ b/python_config/models.py @@ -5,6 +5,10 @@ from .validation import _validate_value +_UNSET = object() +"""Guard object to be able to distinguish user-provided None.""" + + @dataclass(eq=False) class Assignment: """Assignment that may be documented. @@ -93,10 +97,7 @@ def names(self): return [item.name for item in self.items] def getvar(self, name): - """Return unwrapped Assignment object for access to its metadata. - - Use it cautiously! - """ + """Return the :class:`Assignment` for a top-level option.""" for item in self.items: if item.name == name: @@ -104,21 +105,37 @@ def getvar(self, name): raise KeyError(name) - def __getitem__(self, name): - return self.getvar(name).value + def setvar(self, name, value=_UNSET, *, docstring=_UNSET): + """Create or update a top-level assignment.""" - def __setitem__(self, name, value): + if value is _UNSET and docstring is _UNSET: + raise TypeError("setvar() requires at least one of value or docstring") + + # Find or create a corresponding Assignment. try: - value = _validate_value(name, value) - except _ValidationError as e: - raise ValidationError("", e) + item = self.getvar(name) + except KeyError: + if value is _UNSET: + raise TypeError("setvar() value is required when creating a new assignment") - for item in self.items: - if item.name == name: - item.value = value - return + item = Assignment(name=name, value=None) + self.items.append(item) + + # Update value and/or docstring when provided. + if value is not _UNSET: + try: + item.value = _validate_value(name, value) + except _ValidationError as e: + raise ValidationError("", e) + + if docstring is not _UNSET: + item.docstring = docstring - self.items.append(Assignment(name=name, value=value)) + def __getitem__(self, name): + return self.getvar(name).value + + def __setitem__(self, name, value): + self.setvar(name, value) def __delitem__(self, name): for index, item in enumerate(self.items): diff --git a/tests/test_document.py b/tests/test_document.py index a0f8705..7058228 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -101,3 +101,43 @@ def test_section_comment_not_preserved_on_dump(): doc = document.loads(source) assert "# section" not in document.dumps(doc) assert doc.preamble.startswith("# preamble") + + +def test_setvar_updates_value_and_docstring(confpath): + doc = document.load(confpath) + doc.setvar("LOG_LEVEL", 99, docstring="High verbosity.") + item = doc.getvar("LOG_LEVEL") + assert item.value == 99 + assert item.docstring == "High verbosity." + + +def test_setvar_docstring_only(confpath): + doc = document.load(confpath) + doc.setvar("VERSION", docstring="Updated note.") + assert doc.getvar("VERSION").value == "1.0.0" + assert doc.getvar("VERSION").docstring == "Updated note." + + +def test_setvar_clears_docstring(confpath): + doc = document.load(confpath) + doc.setvar("VERSION", docstring=None) + assert doc.getvar("VERSION").docstring is None + + +def test_setvar_new_assignment(): + doc = document.loads('X = 1\n"""note."""\n') + doc.setvar("NEW_OPT", 42, docstring="Brand new.") + assert doc["NEW_OPT"] == 42 + assert doc.getvar("NEW_OPT").docstring == "Brand new." + + +def test_setvar_requires_value_for_new_assignment(): + doc = document.loads("X = 1\n") + with pytest.raises(TypeError): + doc.setvar("Y", docstring="only doc") + + +def test_setvar_requires_value_or_docstring(confpath): + doc = document.load(confpath) + with pytest.raises(TypeError): + doc.setvar("LOG_LEVEL") From 947a64406ea441da6c7ffd81ca3020d5c4ad74e0 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Tue, 19 May 2026 13:06:02 +0300 Subject: [PATCH 09/17] FIXME: Bump RPM version --- python-config.spec | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/python-config.spec b/python-config.spec index f033457..ce1b5a6 100644 --- a/python-config.spec +++ b/python-config.spec @@ -12,8 +12,8 @@ Note: if you want to validate the configuration values, take a look at https://github.com/KonishchevDmitry/object-validator project.} Name: python-config -Version: 0.1.2 -Release: 4.ROCKIT3%{?dist} +Version: 1.0.0 +Release: TEST17%{?dist} Summary: A simple module for reading Python configuration files Group: Development/Libraries @@ -33,7 +33,9 @@ BuildRequires: python%{python3_pkgversion}-devel BuildRequires: python%{python3_pkgversion}-setuptools %if 0%{with tests} BuildRequires: python%{python3_pkgversion}-pytest >= 2.2.4 +BuildRequires: python3-dataclasses %endif # with tests +Requires: python3-dataclasses Obsoletes: python36-config Conflicts: python36-config @@ -69,6 +71,10 @@ make PYTHON=%{__python3} check %changelog +* Tue May 19 2026 Pavel Kulyov - 1.0.0-1 +- Version 1.0.0: AST-based config parsing (no exec), add loads/dump/dumps +- Restructure the project splitting single-module, update metadata + * Tue Jan 24 2023 Andrey Kulaev - 0.1.2-4 - Add centos 8.4 support From 1c840ade6f6adea5895ebfe67d2264eca5d79214 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Fri, 19 Jun 2026 16:17:45 +0300 Subject: [PATCH 10/17] Implement initial configuration interactive viewer --- README.rst | 30 +++ pyproject.toml | 8 + python-config.spec | 3 + python_config/viewer/__init__.py | 1 + python_config/viewer/__main__.py | 5 + python_config/viewer/app.py | 305 +++++++++++++++++++++++++++++++ python_config/viewer/cli.py | 42 +++++ python_config/viewer/nav.py | 256 ++++++++++++++++++++++++++ python_config/viewer/render.py | 165 +++++++++++++++++ setup.py | 8 +- tests/test_viewer.py | 199 ++++++++++++++++++++ 11 files changed, 1021 insertions(+), 1 deletion(-) create mode 100644 python_config/viewer/__init__.py create mode 100644 python_config/viewer/__main__.py create mode 100644 python_config/viewer/app.py create mode 100644 python_config/viewer/cli.py create mode 100644 python_config/viewer/nav.py create mode 100644 python_config/viewer/render.py create mode 100644 tests/test_viewer.py diff --git a/README.rst b/README.rst index a7019b3..d833827 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,36 @@ preserved through the document API. If you want to validate the configuration values, take a look at https://github.com/KonishchevDmitry/object-validator project or just use Pydantic. +Interactive viewer +------------------ + +A terminal UI is included for browsing large configuration files without scrolling +through thousands of lines in an editor. It loads configs through the document API +so f-strings, arithmetic, and comments are shown as in the source. + +.. source:: bash + + python-config-view /path/to/config.conf + python -m python_config.viewer /path/to/config.conf + +Use ``--page-size`` to control pagination (default 20) and ``--no-color`` for +plain output. + +Key bindings: + +========== ====================================================== +Key Action +========== ====================================================== +``/`` Search variables or keys at the current level +``n``/``j`` Next item (or next page at end of page) +``p``/``k`` Previous item (or previous page at start of page) +``Enter`` Open a dict/list child or jump to a search result +``1``-``9`` Quick-select an item on the current page +``b`` Go back (or leave search results) +``s`` Toggle source view (top-level variables only) +``q`` Quit +========== ====================================================== + Tests ----- diff --git a/pyproject.toml b/pyproject.toml index f59df0f..8033be7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,14 @@ classifiers = [ requires-python = ">=3.9" +dependencies = [ + "click>=7,<9", + "rich>=10,<13", +] + +[project.scripts] +python-config-view = "python_config.viewer.cli:main" + [project.optional-dependencies] dev = [ "mypy>=1.19.1", diff --git a/python-config.spec b/python-config.spec index ce1b5a6..d505df4 100644 --- a/python-config.spec +++ b/python-config.spec @@ -36,6 +36,8 @@ BuildRequires: python%{python3_pkgversion}-pytest >= 2.2.4 BuildRequires: python3-dataclasses %endif # with tests Requires: python3-dataclasses +Requires: python%{python3_pkgversion}-click +Requires: python%{python3_pkgversion}-rich Obsoletes: python36-config Conflicts: python36-config @@ -60,6 +62,7 @@ make PYTHON=%{__python3} check %files -n python%{python3_pkgversion}-config %defattr(-,root,root,-) +%{_bindir}/python-config-view %{python3_sitelib}/python_config/ %{python3_sitelib}/python_config/__pycache__/ %{python3_sitelib}/python_config-%{version}-*.egg-info diff --git a/python_config/viewer/__init__.py b/python_config/viewer/__init__.py new file mode 100644 index 0000000..835b343 --- /dev/null +++ b/python_config/viewer/__init__.py @@ -0,0 +1 @@ +"""Interactive terminal viewer for python-config files.""" diff --git a/python_config/viewer/__main__.py b/python_config/viewer/__main__.py new file mode 100644 index 0000000..2f05ddc --- /dev/null +++ b/python_config/viewer/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + main() diff --git a/python_config/viewer/app.py b/python_config/viewer/app.py new file mode 100644 index 0000000..5785043 --- /dev/null +++ b/python_config/viewer/app.py @@ -0,0 +1,305 @@ +"""Interactive application loop for the config viewer.""" + +import click + +from . import nav, render + + +class ViewerApp: + """Browse-only interactive viewer for a loaded :class:`ConfigDocument`.""" + + def __init__( + self, + doc, + config_path, + page_size=20, + no_color=False, + input_fn=None, + prompt_fn=None, + console=None, + ): + self.doc = doc + self.config_path = config_path + self.page_size = page_size + self.console = console or render._make_console(no_color=no_color) + self.input_fn = input_fn or click.getchar + self.prompt_fn = prompt_fn or click.prompt + self.path = () + self.page = 0 + self.selected_index = 0 + self.search_mode = False + self.search_results = [] + self.message = None + self.show_source = False + + def _reset_detail_state(self): + self.show_source = False + + def _detail_assignment(self, page_items): + if not page_items and self.path: + detail_path = self.path + else: + detail_path = self._detail_path(page_items) + _value, assignment = nav.resolve(self.doc, detail_path) + return assignment + + def _all_children(self): + return nav.list_children(self.doc, self.path) + + def _current_page_items(self): + children = self._all_children() + return nav.paginate(children, self.page, self.page_size) + + def _selectable_indices(self, page_items): + return render.selectable_rows(page_items) + + def _clamp_selection(self, page_items): + selectable = self._selectable_indices(page_items) + if not selectable: + self.selected_index = 0 + return + if self.selected_index not in selectable: + self.selected_index = selectable[0] + + def _detail_label(self, page_items): + selectable = self._selectable_indices(page_items) + if self.selected_index not in selectable: + return None + label, _type_name, _preview, child_path = page_items[self.selected_index] + if child_path: + return label + return None + + def _detail_path(self, page_items): + selectable = self._selectable_indices(page_items) + if self.selected_index not in selectable: + return self.path + _label, _type_name, _preview, child_path = page_items[self.selected_index] + return child_path or self.path + + def _draw(self, page_items, page, total_pages): + if not page_items and self.path: + detail_path = self.path + if self.path: + last = self.path[-1] + if hasattr(last, "name"): + detail_label = last.name + elif hasattr(last, "key"): + detail_label = str(last.key) + elif hasattr(last, "index"): + detail_label = f"[{last.index}]" + else: + detail_label = nav.format_path(self.path) + else: + detail_label = None + else: + detail_path = self._detail_path(page_items) + detail_label = self._detail_label(page_items) + + render.render_screen( + self.console, + self.config_path, + self.doc, + detail_path, + page_items, + page, + total_pages, + self.selected_index, + detail_label, + search_mode=self.search_mode, + search_results=self.search_results, + message=self.message, + show_source=self.show_source, + ) + self.message = None + + def _read_key(self): + try: + key = self.input_fn() + except (EOFError, KeyboardInterrupt): + return "q" + if not key: + return "" + if key == "\x03": + return "q" + return key + + def _open_selected(self, page_items): + selectable = self._selectable_indices(page_items) + if self.selected_index not in selectable: + return + + _label, type_name, _preview, child_path = page_items[self.selected_index] + if type_name in ("dict", "list"): + self.path = child_path + self.page = 0 + self.selected_index = 0 + self._reset_detail_state() + + def _go_back(self): + if self.search_mode: + self.search_mode = False + self.search_results = [] + return + + if self.path: + self.path = self.path[:-1] + self.page = 0 + self.selected_index = 0 + self._reset_detail_state() + + def _start_search(self): + query = self.prompt_fn("Search", default="") + self.search_results = nav.search(self.doc, self.path, query) + if not self.search_results: + self.message = f"No matches for {query!r}" + self.search_mode = False + return + + self.search_mode = True + self.selected_index = 0 + + def _open_search_result(self): + if not self.search_results: + return + + if self.selected_index < 0 or self.selected_index >= len(self.search_results): + return + + _label, child_path = self.search_results[self.selected_index] + self.path = child_path + self.page = 0 + self.selected_index = 0 + self.search_mode = False + self.search_results = [] + self._reset_detail_state() + + def _move_selection(self, page_items, delta): + selectable = self._selectable_indices(page_items) + if not selectable: + return + + try: + position = selectable.index(self.selected_index) + except ValueError: + position = 0 + + position = max(0, min(len(selectable) - 1, position + delta)) + self.selected_index = selectable[position] + self._reset_detail_state() + + def _handle_number_key(self, page_items, number): + index = number - 1 + selectable = self._selectable_indices(page_items) + if index < len(selectable): + self.selected_index = selectable[index] + self._reset_detail_state() + + def _toggle_source(self, page_items): + if self._detail_assignment(page_items) is None: + self.message = "Source is available only for top-level variables" + return + self.show_source = not self.show_source + + def run(self): + """Run the interactive loop until the user quits.""" + + while True: + if self.search_mode: + self._draw([], 0, 1) + key = self._read_key() + if key in ("q", "Q"): + return + if key in ("b", "B", "\x1b"): + self._go_back() + continue + if key in ("\r", "\n"): + self._open_search_result() + continue + if key in ("n", "N", "j", "J"): + self.selected_index = min( + len(self.search_results) - 1, + self.selected_index + 1, + ) + continue + if key in ("p", "P", "k", "K"): + self.selected_index = max(0, self.selected_index - 1) + continue + if key.isdigit(): + number = int(key) + if 1 <= number <= len(self.search_results): + self.selected_index = number - 1 + continue + continue + + page_items, page, total_pages = self._current_page_items() + self._clamp_selection(page_items) + self._draw(page_items, page, total_pages) + + key = self._read_key() + if key in ("q", "Q"): + return + if key in ("b", "B", "\x1b"): + self._go_back() + continue + if key == "/": + self._start_search() + continue + if key in ("s", "S"): + self._toggle_source(page_items) + continue + if key in ("n", "N", "j", "J"): + selectable = self._selectable_indices(page_items) + if selectable and self.selected_index >= selectable[-1]: + if page + 1 < total_pages: + self.page += 1 + self.selected_index = 0 + self._reset_detail_state() + else: + self._move_selection(page_items, 1) + else: + self._move_selection(page_items, 1) + continue + if key in ("p", "P", "k", "K"): + selectable = self._selectable_indices(page_items) + if selectable and self.selected_index <= selectable[0]: + if page > 0: + self.page -= 1 + self.selected_index = 0 + self._reset_detail_state() + else: + self._move_selection(page_items, -1) + else: + self._move_selection(page_items, -1) + continue + if key in ("\r", "\n"): + self._open_selected(page_items) + continue + if key.isdigit(): + number = int(key) + if 1 <= number <= 9: + self._handle_number_key(page_items, number) + continue + + +def run_viewer( + doc, + config_path, + page_size=20, + no_color=False, + input_fn=None, + prompt_fn=None, + console=None, +): + """Start the viewer for *doc* loaded from *config_path*.""" + + app = ViewerApp( + doc, + config_path, + page_size=page_size, + no_color=no_color, + input_fn=input_fn, + prompt_fn=prompt_fn, + console=console, + ) + app.run() diff --git a/python_config/viewer/cli.py b/python_config/viewer/cli.py new file mode 100644 index 0000000..0079f30 --- /dev/null +++ b/python_config/viewer/cli.py @@ -0,0 +1,42 @@ +"""CLI entry point for the interactive config viewer.""" + +import sys + +import click + +import python_config.document + +from . import app + + +@click.command(context_settings={"help_option_names": ["-h", "--help"]}) +@click.argument("config_path", type=click.Path(exists=True, dir_okay=False, readable=True)) +@click.option( + "--page-size", + default=20, + show_default=True, + type=click.IntRange(1, 500), + help="Rows per page for lists, dicts, and root variables.", +) +@click.option("--no-color", is_flag=True, help="Disable Rich styling.") +def main(config_path, page_size, no_color): + """Browse a python-config file interactively.""" + + try: + doc = python_config.document.load(config_path) + except Exception as exc: + raise click.ClickException(str(exc)) + + try: + app.run_viewer( + doc, + config_path, + page_size=page_size, + no_color=no_color, + ) + except KeyboardInterrupt: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/python_config/viewer/nav.py b/python_config/viewer/nav.py new file mode 100644 index 0000000..bd0d498 --- /dev/null +++ b/python_config/viewer/nav.py @@ -0,0 +1,256 @@ +"""Navigation model for the interactive config viewer.""" + +import textwrap +from typing import Tuple, Union + + +PathSegment = Union["VarKey", "DictKey", "ListIndex"] +ChildRow = Tuple[str, str, str, Tuple[PathSegment, ...]] + + +class VarKey: + """Top-level assignment name.""" + + __slots__ = ("name",) + + def __init__(self, name): + self.name = name + + def __repr__(self): + return f"VarKey({self.name!r})" + + +class DictKey: + """Key within a dict value.""" + + __slots__ = ("key",) + + def __init__(self, key): + self.key = key + + def __repr__(self): + return f"DictKey({self.key!r})" + + +class ListIndex: + """Index within a list value.""" + + __slots__ = ("index",) + + def __init__(self, index): + self.index = index + + def __repr__(self): + return f"ListIndex({self.index})" + + +def format_path(path): + """Return a breadcrumb string for *path*.""" + + if not path: + return "(root)" + + parts = [] + for segment in path: + if isinstance(segment, VarKey): + parts.append(segment.name) + elif isinstance(segment, DictKey): + parts.append(str(segment.key)) + elif isinstance(segment, ListIndex): + parts.append(f"[{segment.index}]") + return " \u203a ".join(parts) + + +def resolve(doc, path): + """Return ``(value, assignment)`` at *path*. + + *assignment* is set only when *path* is a single :class:`VarKey`. + """ + + if not path: + return None, None + + first = path[0] + if not isinstance(first, VarKey): + raise ValueError("path must start with VarKey") + + assignment = doc.getvar(first.name) + value = assignment.value + + for segment in path[1:]: + if isinstance(segment, DictKey): + value = value[segment.key] + elif isinstance(segment, ListIndex): + value = value[segment.index] + else: + raise ValueError(f"unexpected path segment: {segment!r}") + + if len(path) == 1: + return value, assignment + + return value, None + + +def _type_name(value): + if value is None: + return "none" + + return type(value).__name__ + + +def preview_value(value, max_len=60): + """Short one-line preview of *value*.""" + + if isinstance(value, dict): + return f"dict({len(value)} keys)" + + if isinstance(value, list): + return f"list[{len(value)}]" + + return truncate_text(repr(value), max_len) + + +def list_children(doc, path): + """Return navigable child rows at *path*. + + Each row is ``(label, type_name, preview, child_path)`` where *child_path* + is the full path including the new segment. + """ + + if not path: + children = [] + for item in doc.items: + if item.name.startswith("_") or not item.name.isupper(): + continue + child_path = path + (VarKey(item.name),) + children.append( + ( + item.name, + _type_name(item.value), + preview_value(item.value), + child_path, + ) + ) + return children + + value, _assignment = resolve(doc, path) + + if isinstance(value, dict): + return [ + ( + str(key), + _type_name(nested), + preview_value(nested), + path + (DictKey(key),), + ) + for key, nested in value.items() + ] + + if isinstance(value, list): + return [ + ( + f"[{index}]", + _type_name(nested), + preview_value(nested), + path + (ListIndex(index),), + ) + for index, nested in enumerate(value) + ] + + return [] + + +def paginate(items, page, page_size): + """Return ``(page_items, page_index, total_pages)``.""" + + total = len(items) + if total == 0: + return [], 0, 1 + + total_pages = (total + page_size - 1) // page_size + page = max(0, min(page, total_pages - 1)) + start = page * page_size + end = min(start + page_size, total) + return items[start:end], page, total_pages + + +def search(doc, path, query): + """Search navigable children at *path* matching *query* (case-insensitive). + + Returns a list of ``(label, child_path)`` matches. + """ + + needle = query.lower() + if not needle: + return [] + + matches = [] + for label, _type_name, _preview, child_path in list_children(doc, path): + if needle in label.lower(): + matches.append((label, child_path)) + return matches + + +def assignment_source_text(assignment): + """Return serialized assignment text for a top-level *assignment*.""" + + from ..literals import literal_to_ast + from ..serialize import _assignment_stmt, unparse + + stmt = _assignment_stmt(assignment.name, literal_to_ast(assignment.value)) + return unparse(stmt) + + +VALUE_DISPLAY_EXPANDED = {"max_items": 30, "max_depth": 8} +VALUE_DISPLAY_COMPACT = {"max_items": 10, "max_depth": 4} + + +def truncate_for_display(value, max_items=10, max_depth=4): + """Return a truncated copy of *value* suitable for pretty-printing.""" + if max_depth <= 0: + return "..." + + if isinstance(value, dict): + items = list(value.items()) + result = {} + for key, nested in items[:max_items]: + result[key] = truncate_for_display(nested, max_items, max_depth - 1) + remaining = len(items) - max_items + if remaining > 0: + result["..."] = f"{remaining} more keys" + return result + + if isinstance(value, list): + result = [ + truncate_for_display(nested, max_items, max_depth - 1) for nested in value[:max_items] + ] + remaining = len(value) - max_items + if remaining > 0: + result.append(f"... {remaining} more items") + return result + + return value + + +def format_detail_value(value, max_items=5, max_line_len=120): + """Format *value* as plain text for tests and non-Rich callers.""" + + import pprint + + display = truncate_for_display(value, max_items=max_items, max_depth=4) + return pprint.pformat(display, width=max_line_len, compact=False) + + +def truncate_text(text, max_len=2000): + """Truncate long *text* for display.""" + + return textwrap.shorten(text or "", width=max_len) + + +def metadata_lines(assignment): + """Return metadata lines for a top-level *assignment*.""" + + if assignment is None or assignment.docstring is None: + return [] + + return ["Docstring:", assignment.docstring] diff --git a/python_config/viewer/render.py b/python_config/viewer/render.py new file mode 100644 index 0000000..6b47806 --- /dev/null +++ b/python_config/viewer/render.py @@ -0,0 +1,165 @@ +"""Rich rendering helpers for the interactive config viewer.""" + +from rich.console import Console, Group +from rich.panel import Panel +from rich.pretty import Pretty +from rich.table import Table +from rich.text import Text + +from . import nav + + +def _make_console(no_color=False): + return Console(force_terminal=not no_color, no_color=no_color, highlight=False) + + +def render_children_table( + page_items, + page, + total_pages, + selected_index, + search_mode=False, + search_results=None, +): + """Build a Rich table for the current page of children.""" + + if search_mode and search_results is not None: + table = Table(title=f"Search results ({len(search_results)})", expand=True) + table.add_column("#", style="dim", width=4) + table.add_column("Match", style="bold") + for index, (label, _child_path) in enumerate(search_results): + marker = " \u25ba" if index == selected_index else "" + table.add_row(str(index + 1), label + marker) + return table + + table = Table( + title=f"Children (page {page + 1}/{total_pages})", + expand=True, + ) + table.add_column("#", style="dim", width=4) + table.add_column("Name", style="bold") + table.add_column("Type", style="cyan", width=8) + table.add_column("Preview") + + for row_index, (label, type_name, preview, _child_path) in enumerate(page_items): + marker = " \u25ba" if row_index == selected_index else "" + quick = "" + if row_index < 9: + quick = f" [{row_index + 1}]" + table.add_row( + str(row_index + 1) + quick, + label + marker, + type_name, + preview, + ) + + return table + + +def render_detail_panel(doc, path, label, show_source=False): + """Build a Rich panel showing detail for the node at *path*.""" + + value, assignment = nav.resolve(doc, path) + lines = [] + + if label: + lines.append(Text(f"Node: {label}", style="bold")) + + lines.append(Text(f"Path: {nav.format_path(path)}", style="dim")) + lines.append("") + + if show_source and assignment is not None: + lines.append(Text(nav.preview_value(value), style="dim")) + lines.append("") + lines.append(Text("Source:", style="bold")) + lines.append(nav.truncate_text(nav.assignment_source_text(assignment))) + lines.append("") + lines.append(Text("Press s to return to value", style="dim italic")) + else: + lines.append(Text("Value:", style="bold")) + lines.append( + Pretty( + nav.truncate_for_display(value, **nav.VALUE_DISPLAY_EXPANDED), + expand_all=True, + indent_guides=True, + max_string=300, + ) + ) + if assignment is not None: + lines.append("") + lines.append(Text("Press s to view source", style="dim italic")) + + metadata = nav.metadata_lines(assignment) + if metadata: + lines.append("") + lines.append(Text("Metadata:", style="bold")) + lines.extend(metadata) + + return Panel(Group(*lines), title="Detail", border_style="blue") + + +def render_help_bar(show_source_available=False): + """Return help text for key bindings.""" + + help_text = "[/] search [n]ext [p]prev [Enter] open [b] back" + if show_source_available: + help_text += " [s] source" + help_text += " [q] quit" + return Text(help_text, style="dim") + + +def render_screen( + console, + config_path, + doc, + path, + page_items, + page, + total_pages, + selected_index, + detail_label, + search_mode=False, + search_results=None, + message=None, + show_source=False, +): + """Render the full viewer screen to *console*.""" + + console.clear(home=False) + + header = f"python-config-view: {config_path}" + console.print(Panel(Text(header, style="bold"), border_style="green")) + console.print(Text(f"Path: {nav.format_path(path)}", style="cyan")) + + if message: + console.print(Text(message, style="yellow")) + console.print() + + console.print( + render_children_table( + page_items, + page, + total_pages, + selected_index, + search_mode, + search_results, + ), + ) + + if detail_label and not search_mode: + _value, assignment = nav.resolve(doc, path) + console.print( + render_detail_panel(doc, path, detail_label, show_source=show_source) + ) + show_source_available = assignment is not None + else: + show_source_available = False + + console.print() + console.print(render_help_bar(show_source_available=show_source_available)) + + +def selectable_rows(page_items): + """Return indices of selectable rows on the current page.""" + + return list(range(len(page_items))) diff --git a/setup.py b/setup.py index 02d0b0c..a400d96 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,13 @@ def run_tests(self): ], platforms = [ "unix", "linux", "osx" ], - packages = [ "python_config" ], + packages = [ "python_config", "python_config.viewer" ], + + entry_points = { + "console_scripts": [ + "python-config-view = python_config.viewer.cli:main", + ], + }, cmdclass = { "test": PyTest }, tests_require = [ "pytest" ], diff --git a/tests/test_viewer.py b/tests/test_viewer.py new file mode 100644 index 0000000..f51fcc8 --- /dev/null +++ b/tests/test_viewer.py @@ -0,0 +1,199 @@ +"""Integration tests for the interactive config viewer.""" + +from unittest.mock import patch + +from click.testing import CliRunner + +from python_config.viewer import nav +from python_config.viewer.app import run_viewer +from python_config.viewer.cli import main + + +def test_paginate_empty(): + items, page, total_pages = nav.paginate([], 0, 20) + assert items == [] + assert page == 0 + assert total_pages == 1 + + +def test_paginate_multiple_pages(): + items = list(range(45)) + page_items, page, total_pages = nav.paginate(items, 1, 20) + assert page_items == list(range(20, 40)) + assert page == 1 + assert total_pages == 3 + + +def test_preview_value_truncates_long_string(): + text = "x" * 100 + preview = nav.preview_value(text, max_len=20) + assert preview != repr(text) + assert len(preview) <= 20 + + +def test_list_children_root_lists_variables(confdoc): + children = nav.list_children(confdoc, ()) + labels = [row[0] for row in children] + assert "PROJECT_NAME" in labels + assert all(row[1] != "section" for row in children) + + +def test_search_root_matches(confdoc): + matches = nav.search(confdoc, (), "log") + labels = [label for label, _path in matches] + assert "LOG_LEVEL" in labels + + +def test_resolve_nested_dict(confdoc): + path = (nav.VarKey("PROJECT"), nav.DictKey("tools")) + value, assignment = nav.resolve(confdoc, path) + assert assignment is None + assert "ruff" in value + + +def test_assignment_source_shows_literal_value(confdoc): + item = confdoc.getvar("BUILD_NUMBER") + source = nav.assignment_source_text(item) + assert source == "BUILD_NUMBER = 7" + + +def test_assignment_source_unparse(confdoc): + item = confdoc.getvar("PACKAGE_NAME") + source = nav.assignment_source_text(item) + assert source == 'PACKAGE_NAME = "python-config-1.0.0"' + + +def test_format_detail_value_truncates_large_list(): + value = list(range(100)) + text = nav.format_detail_value(value, max_items=3) + assert "more items" in text + + +def test_truncate_text(): + assert nav.truncate_text(None) == "" + text = nav.truncate_text("a" * 3000, max_len=100) + assert len(text) <= 100 + assert text != "a" * 3000 + + +def test_viewer_app_toggle_source(confdoc, confpath): + keys = iter(["s", "s", "q"]) + + run_viewer( + confdoc, + str(confpath), + no_color=True, + input_fn=lambda: next(keys), + prompt_fn=lambda *args, **kwargs: "", + ) + + +def test_render_detail_panel_source_toggle(confdoc): + from io import StringIO + + from rich.console import Console + + from python_config.viewer.render import render_detail_panel + + path = (nav.VarKey("BUILD_NUMBER"),) + console = Console(file=StringIO(), force_terminal=True, width=120, no_color=True) + + value_panel = render_detail_panel(confdoc, path, "BUILD_NUMBER", show_source=False) + console.print(value_panel) + value_output = console.file.getvalue() + assert "Press s to view source" in value_output + assert "1 + 2 * 3" not in value_output + + console = Console(file=StringIO(), force_terminal=True, width=120, no_color=True) + source_panel = render_detail_panel(confdoc, path, "BUILD_NUMBER", show_source=True) + console.print(source_panel) + source_output = console.file.getvalue() + assert "BUILD_NUMBER = 7" in source_output + assert "Press s to return to value" in source_output + + +def test_viewer_app_quits_on_q(confdoc, confpath): + keys = iter(["q"]) + run_viewer( + confdoc, + str(confpath), + no_color=True, + input_fn=lambda: next(keys), + prompt_fn=lambda *args, **kwargs: "", + ) + + +def test_viewer_app_search_and_open(confdoc, confpath): + keys = iter(["/", "\r", "b", "q"]) + + def prompt_fn(*args, **kwargs): + return "LOG" + + run_viewer( + confdoc, + str(confpath), + no_color=True, + input_fn=lambda: next(keys), + prompt_fn=prompt_fn, + ) + + +def test_cli_smoke_quit(confpath): + runner = CliRunner() + + with patch("click.getchar", side_effect=iter(["q"])): + result = runner.invoke(main, [str(confpath), "--no-color"], catch_exceptions=False) + + assert result.exit_code == 0 + + +def test_cli_shows_variable_names(confpath): + runner = CliRunner() + + with patch("click.getchar", side_effect=iter(["q"])): + result = runner.invoke(main, [str(confpath), "--no-color"]) + + assert result.exit_code == 0 + assert "PROJECT_NAME" in result.output + + +def test_cli_missing_file(): + runner = CliRunner() + result = runner.invoke(main, ["/no/such/config.conf"]) + assert result.exit_code != 0 + + +def test_huge_config_output_bounded(huge_conf_path): + runner = CliRunner() + + with patch("click.getchar", side_effect=iter(["q"])): + result = runner.invoke(main, [str(huge_conf_path), "--no-color", "--page-size", "20"]) + + assert result.exit_code == 0 + assert "VAR1" in result.output or "VAR200" in result.output + assert result.output.count("sub2-sub1-") < 50 + + +def test_huge_config_search_var42(huge_conf_path): + keys = iter(["/", "q"]) + + def input_fn(): + return next(keys) + + def prompt_fn(*args, **kwargs): + return "VAR42" + + from python_config import document + + doc = document.load(huge_conf_path) + run_viewer( + doc, + str(huge_conf_path), + no_color=True, + input_fn=input_fn, + prompt_fn=prompt_fn, + ) + + matches = nav.search(doc, (), "VAR42") + assert len(matches) == 1 + assert matches[0][0] == "VAR42" From d37b75809f3244908ce27c5cfa29a745ecc25253 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Wed, 8 Jul 2026 10:47:14 +0300 Subject: [PATCH 11/17] viewer: use rich live rendering to eliminate flickering --- python_config/viewer/app.py | 31 +++++++++++--- python_config/viewer/render.py | 74 +++++++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/python_config/viewer/app.py b/python_config/viewer/app.py index 5785043..f2b4524 100644 --- a/python_config/viewer/app.py +++ b/python_config/viewer/app.py @@ -1,6 +1,7 @@ """Interactive application loop for the config viewer.""" import click +from rich.live import Live from . import nav, render @@ -77,7 +78,7 @@ def _detail_path(self, page_items): _label, _type_name, _preview, child_path = page_items[self.selected_index] return child_path or self.path - def _draw(self, page_items, page, total_pages): + def _draw(self, page_items, page, total_pages, live=None): if not page_items and self.path: detail_path = self.path if self.path: @@ -110,6 +111,7 @@ def _draw(self, page_items, page, total_pages): search_results=self.search_results, message=self.message, show_source=self.show_source, + live=live, ) self.message = None @@ -148,8 +150,15 @@ def _go_back(self): self.selected_index = 0 self._reset_detail_state() - def _start_search(self): - query = self.prompt_fn("Search", default="") + def _start_search(self, live=None): + if live is not None: + live.stop() + try: + query = self.prompt_fn("Search", default="") + finally: + if live is not None: + live.start(refresh=True) + self.search_results = nav.search(self.doc, self.path, query) if not self.search_results: self.message = f"No matches for {query!r}" @@ -204,9 +213,19 @@ def _toggle_source(self, page_items): def run(self): """Run the interactive loop until the user quits.""" + use_screen = self.console.is_terminal + with Live( + "", + console=self.console, + auto_refresh=False, + screen=use_screen, + ) as live: + self._run_loop(live) + + def _run_loop(self, live): while True: if self.search_mode: - self._draw([], 0, 1) + self._draw([], 0, 1, live=live) key = self._read_key() if key in ("q", "Q"): return @@ -234,7 +253,7 @@ def run(self): page_items, page, total_pages = self._current_page_items() self._clamp_selection(page_items) - self._draw(page_items, page, total_pages) + self._draw(page_items, page, total_pages, live=live) key = self._read_key() if key in ("q", "Q"): @@ -243,7 +262,7 @@ def run(self): self._go_back() continue if key == "/": - self._start_search() + self._start_search(live=live) continue if key in ("s", "S"): self._toggle_source(page_items) diff --git a/python_config/viewer/render.py b/python_config/viewer/render.py index 6b47806..4c81bb5 100644 --- a/python_config/viewer/render.py +++ b/python_config/viewer/render.py @@ -108,8 +108,7 @@ def render_help_bar(show_source_available=False): return Text(help_text, style="dim") -def render_screen( - console, +def build_screen( config_path, doc, path, @@ -123,19 +122,17 @@ def render_screen( message=None, show_source=False, ): - """Render the full viewer screen to *console*.""" + """Build the full viewer screen as a single renderable.""" - console.clear(home=False) - - header = f"python-config-view: {config_path}" - console.print(Panel(Text(header, style="bold"), border_style="green")) - console.print(Text(f"Path: {nav.format_path(path)}", style="cyan")) + parts = [ + Panel(Text(f"python-config-view: {config_path}", style="bold"), border_style="green"), + Text(f"Path: {nav.format_path(path)}", style="cyan"), + ] if message: - console.print(Text(message, style="yellow")) - console.print() + parts.append(Text(message, style="yellow")) - console.print( + parts.append( render_children_table( page_items, page, @@ -143,20 +140,59 @@ def render_screen( selected_index, search_mode, search_results, - ), + ) ) + show_source_available = False if detail_label and not search_mode: _value, assignment = nav.resolve(doc, path) - console.print( - render_detail_panel(doc, path, detail_label, show_source=show_source) - ) + parts.append(render_detail_panel(doc, path, detail_label, show_source=show_source)) show_source_available = assignment is not None - else: - show_source_available = False - console.print() - console.print(render_help_bar(show_source_available=show_source_available)) + parts.append(Text("")) + parts.append(render_help_bar(show_source_available=show_source_available)) + + return Group(*parts) + + +def render_screen( + console, + config_path, + doc, + path, + page_items, + page, + total_pages, + selected_index, + detail_label, + search_mode=False, + search_results=None, + message=None, + show_source=False, + live=None, +): + """Render or update the full viewer screen.""" + + screen = build_screen( + config_path, + doc, + path, + page_items, + page, + total_pages, + selected_index, + detail_label, + search_mode=search_mode, + search_results=search_results, + message=message, + show_source=show_source, + ) + + if live is not None: + live.update(screen, refresh=True) + return + + console.print(screen) def selectable_rows(page_items): From a7ac5b98db59d9a9656b67f65184f5775731b816 Mon Sep 17 00:00:00 2001 From: Pavel Kulyov Date: Thu, 9 Jul 2026 19:47:33 +0300 Subject: [PATCH 12/17] Implement pager panel --- README.rst | 3 +- python_config/viewer/app.py | 109 +++++++++++++++++++++++++++++---- python_config/viewer/nav.py | 45 ++++++++++++++ python_config/viewer/render.py | 56 +++++++++++++++-- tests/test_viewer.py | 72 +++++++++++++++++++++- 5 files changed, 263 insertions(+), 22 deletions(-) diff --git a/README.rst b/README.rst index d833827..7f9c787 100644 --- a/README.rst +++ b/README.rst @@ -70,7 +70,8 @@ Key Action ``Enter`` Open a dict/list child or jump to a search result ``1``-``9`` Quick-select an item on the current page ``b`` Go back (or leave search results) -``s`` Toggle source view (top-level variables only) +``s`` Expand the current value full-screen (scroll with ``j``/``n`` and ``k``/``p``) +``a`` Toggle assignment source view (top-level variables only) ``q`` Quit ========== ====================================================== diff --git a/python_config/viewer/app.py b/python_config/viewer/app.py index f2b4524..5889673 100644 --- a/python_config/viewer/app.py +++ b/python_config/viewer/app.py @@ -32,9 +32,19 @@ def __init__( self.search_results = [] self.message = None self.show_source = False + self.pager_mode = False + self.pager_scroll = 0 + self.pager_lines = [] + self.pager_path = () + self.pager_label = "" def _reset_detail_state(self): self.show_source = False + self.pager_mode = False + self.pager_scroll = 0 + self.pager_lines = [] + self.pager_path = () + self.pager_label = "" def _detail_assignment(self, page_items): if not page_items and self.path: @@ -78,25 +88,27 @@ def _detail_path(self, page_items): _label, _type_name, _preview, child_path = page_items[self.selected_index] return child_path or self.path - def _draw(self, page_items, page, total_pages, live=None): + def _resolve_detail(self, page_items): if not page_items and self.path: detail_path = self.path - if self.path: - last = self.path[-1] - if hasattr(last, "name"): - detail_label = last.name - elif hasattr(last, "key"): - detail_label = str(last.key) - elif hasattr(last, "index"): - detail_label = f"[{last.index}]" - else: - detail_label = nav.format_path(self.path) + last = self.path[-1] + if hasattr(last, "name"): + detail_label = last.name + elif hasattr(last, "key"): + detail_label = str(last.key) + elif hasattr(last, "index"): + detail_label = f"[{last.index}]" else: - detail_label = None + detail_label = nav.format_path(self.path) else: detail_path = self._detail_path(page_items) detail_label = self._detail_label(page_items) + return detail_path, detail_label + + def _draw(self, page_items, page, total_pages, live=None): + detail_path, detail_label = self._resolve_detail(page_items) + render.render_screen( self.console, self.config_path, @@ -115,6 +127,62 @@ def _draw(self, page_items, page, total_pages, live=None): ) self.message = None + def _draw_pager(self, live=None): + render.render_pager_screen( + self.console, + self.pager_label, + self.pager_path, + self.pager_lines, + self.pager_scroll, + live=live, + ) + + def _enter_pager(self, page_items): + detail_path, detail_label = self._resolve_detail(page_items) + if not detail_path: + return + + value, _assignment = nav.resolve(self.doc, detail_path) + self.pager_path = detail_path + self.pager_label = detail_label or nav.format_path(detail_path) + self.pager_lines = nav.value_pager_lines(value, width=self.console.width) + self.pager_scroll = 0 + self.pager_mode = True + self.show_source = False + + def _exit_pager(self): + self.pager_mode = False + self.pager_scroll = 0 + self.pager_lines = [] + + def _scroll_pager(self, delta): + if not self.pager_lines: + return + + viewport_height = max(1, self.console.height - 6) + _visible, scroll, _can_up, _can_down = nav.pager_viewport( + self.pager_lines, + self.pager_scroll + delta, + viewport_height, + ) + self.pager_scroll = scroll + + def _handle_pager_key(self, key, live): + if key in ("q", "Q"): + return True + if key in ("s", "S", "b", "B", "\x1b"): + self._exit_pager() + return False + if key in ("j", "J", "n", "N"): + self._scroll_pager(1) + self._draw_pager(live=live) + return False + if key in ("k", "K", "p", "P"): + self._scroll_pager(-1) + self._draw_pager(live=live) + return False + return False + def _read_key(self): try: key = self.input_fn() @@ -206,10 +274,16 @@ def _handle_number_key(self, page_items, number): def _toggle_source(self, page_items): if self._detail_assignment(page_items) is None: - self.message = "Source is available only for top-level variables" + self.message = "Assignment source is available only for top-level variables" return self.show_source = not self.show_source + def _toggle_pager(self, page_items): + if self.pager_mode: + self._exit_pager() + return + self._enter_pager(page_items) + def run(self): """Run the interactive loop until the user quits.""" @@ -224,6 +298,12 @@ def run(self): def _run_loop(self, live): while True: + if self.pager_mode: + self._draw_pager(live=live) + if self._handle_pager_key(self._read_key(), live): + return + continue + if self.search_mode: self._draw([], 0, 1, live=live) key = self._read_key() @@ -265,6 +345,9 @@ def _run_loop(self, live): self._start_search(live=live) continue if key in ("s", "S"): + self._toggle_pager(page_items) + continue + if key in ("a", "A"): self._toggle_source(page_items) continue if key in ("n", "N", "j", "J"): diff --git a/python_config/viewer/nav.py b/python_config/viewer/nav.py index bd0d498..48ec6d4 100644 --- a/python_config/viewer/nav.py +++ b/python_config/viewer/nav.py @@ -247,6 +247,51 @@ def truncate_text(text, max_len=2000): return textwrap.shorten(text or "", width=max_len) +def value_pager_lines(value, width=120): + """Return full pretty-printed lines of *value* for the scrollable pager.""" + + from io import StringIO + + from rich.console import Console + from rich.pretty import Pretty + + buffer = StringIO() + console = Console( + file=buffer, + force_terminal=True, + width=max(40, width), + no_color=True, + highlight=False, + ) + console.print(Pretty(value, expand_all=True, indent_guides=True)) + text = buffer.getvalue() + if text and not text.endswith("\n"): + text += "\n" + return text.splitlines() + + +def pager_viewport(lines, scroll, viewport_height): + """Return ``(visible_lines, scroll, can_scroll_up, can_scroll_down)``.""" + + if viewport_height < 1: + viewport_height = 1 + + total = len(lines) + if total == 0: + return [""] * viewport_height, 0, False, False + + if total <= viewport_height: + pad_top = (viewport_height - total) // 2 + pad_bottom = viewport_height - total - pad_top + visible = [""] * pad_top + lines + [""] * pad_bottom + return visible, 0, False, False + + max_scroll = total - viewport_height + scroll = max(0, min(scroll, max_scroll)) + visible = lines[scroll : scroll + viewport_height] + return visible, scroll, scroll > 0, scroll < max_scroll + + def metadata_lines(assignment): """Return metadata lines for a top-level *assignment*.""" diff --git a/python_config/viewer/render.py b/python_config/viewer/render.py index 4c81bb5..fce9fdb 100644 --- a/python_config/viewer/render.py +++ b/python_config/viewer/render.py @@ -74,7 +74,7 @@ def render_detail_panel(doc, path, label, show_source=False): lines.append(Text("Source:", style="bold")) lines.append(nav.truncate_text(nav.assignment_source_text(assignment))) lines.append("") - lines.append(Text("Press s to return to value", style="dim italic")) + lines.append(Text("Press a to return to value", style="dim italic")) else: lines.append(Text("Value:", style="bold")) lines.append( @@ -85,9 +85,10 @@ def render_detail_panel(doc, path, label, show_source=False): max_string=300, ) ) + lines.append("") + lines.append(Text("Press s to expand value", style="dim italic")) if assignment is not None: - lines.append("") - lines.append(Text("Press s to view source", style="dim italic")) + lines.append(Text("Press a to view assignment source", style="dim italic")) metadata = nav.metadata_lines(assignment) if metadata: @@ -101,13 +102,58 @@ def render_detail_panel(doc, path, label, show_source=False): def render_help_bar(show_source_available=False): """Return help text for key bindings.""" - help_text = "[/] search [n]ext [p]prev [Enter] open [b] back" + help_text = "[/] search [n]ext [p]prev [Enter] open [b] back [s] expand" if show_source_available: - help_text += " [s] source" + help_text += " [a] source" help_text += " [q] quit" return Text(help_text, style="dim") +def build_pager_screen(label, path, lines, scroll, console_height): + """Build a full-screen scrollable view of a value.""" + + header = Panel( + Text(f"{label} — {nav.format_path(path)}", style="bold"), + border_style="green", + ) + + viewport_height = max(1, console_height - 6) + visible, scroll, can_up, can_down = nav.pager_viewport(lines, scroll, viewport_height) + body = Panel( + Group(*[Text(line) for line in visible]), + title="Value", + border_style="blue", + ) + + hints = [] + if can_up: + hints.append("[k/p] up") + if can_down: + hints.append("[j/n] down") + hints.extend(["[s/b] close", "[q] quit"]) + + footer = f"{' '.join(hints)} lines {scroll + 1}-{scroll + len(visible)}/{len(lines)}" + return Group(header, body, Text(footer, style="dim")) + + +def render_pager_screen(console, label, path, lines, scroll, live=None): + """Render or update the pager screen.""" + + screen = build_pager_screen( + label, + path, + lines, + scroll, + console.height, + ) + + if live is not None: + live.update(screen, refresh=True) + return + + console.print(screen) + + def build_screen( config_path, doc, diff --git a/tests/test_viewer.py b/tests/test_viewer.py index f51fcc8..1bf0afe 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -76,8 +76,38 @@ def test_truncate_text(): assert text != "a" * 3000 +def test_pager_viewport_centers_short_content(): + visible, scroll, can_up, can_down = nav.pager_viewport(["a", "b"], 0, 5) + assert visible == ["", "a", "b", "", ""] + assert scroll == 0 + assert not can_up + assert not can_down + + +def test_pager_viewport_scrolls_long_content(): + lines = [str(index) for index in range(10)] + visible, scroll, can_up, can_down = nav.pager_viewport(lines, 0, 3) + assert visible == ["0", "1", "2"] + assert scroll == 0 + assert not can_up + assert can_down + + visible, scroll, can_up, can_down = nav.pager_viewport(lines, 1, 3) + assert visible == ["1", "2", "3"] + assert scroll == 1 + assert can_up + assert can_down + + +def test_value_pager_lines_dict(confdoc): + value, _assignment = nav.resolve(confdoc, (nav.VarKey("PROJECT"),)) + lines = nav.value_pager_lines(value, width=100) + assert len(lines) > 5 + assert any("python-config" in line for line in lines) + + def test_viewer_app_toggle_source(confdoc, confpath): - keys = iter(["s", "s", "q"]) + keys = iter(["a", "a", "q"]) run_viewer( confdoc, @@ -101,7 +131,8 @@ def test_render_detail_panel_source_toggle(confdoc): value_panel = render_detail_panel(confdoc, path, "BUILD_NUMBER", show_source=False) console.print(value_panel) value_output = console.file.getvalue() - assert "Press s to view source" in value_output + assert "Press s to expand value" in value_output + assert "Press a to view assignment source" in value_output assert "1 + 2 * 3" not in value_output console = Console(file=StringIO(), force_terminal=True, width=120, no_color=True) @@ -109,7 +140,42 @@ def test_render_detail_panel_source_toggle(confdoc): console.print(source_panel) source_output = console.file.getvalue() assert "BUILD_NUMBER = 7" in source_output - assert "Press s to return to value" in source_output + assert "Press a to return to value" in source_output + + +def test_viewer_app_pager_toggle(confdoc, confpath): + children = nav.list_children(confdoc, ()) + project_index = next( + index for index, (label, *_rest) in enumerate(children) if label == "PROJECT" + ) + keys = ["n"] * project_index + ["s", "s", "q"] + key_iter = iter(keys) + + run_viewer( + confdoc, + str(confpath), + no_color=True, + input_fn=lambda: next(key_iter), + prompt_fn=lambda *args, **kwargs: "", + ) + + +def test_render_pager_screen(confdoc): + from io import StringIO + + from rich.console import Console + + from python_config.viewer.render import build_pager_screen + + value, _assignment = nav.resolve(confdoc, (nav.VarKey("PROJECT"),)) + lines = nav.value_pager_lines(value, width=100) + screen = build_pager_screen("PROJECT", (nav.VarKey("PROJECT"),), lines, 0, 40) + console = Console(file=StringIO(), force_terminal=True, width=100, height=40, no_color=True) + console.print(screen) + output = console.file.getvalue() + assert "PROJECT" in output + assert "python-config" in output + assert "[j/n] down" in output def test_viewer_app_quits_on_q(confdoc, confpath): From ad8d7fc976936231ce891bb01f4251451186945a Mon Sep 17 00:00:00 2001 From: grixan482 Date: Thu, 30 Jul 2026 13:35:58 +0300 Subject: [PATCH 13/17] tests: xfail test with imp --- tests/test_simple.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_simple.py b/tests/test_simple.py index afcac97..a9a27a8 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -3,6 +3,7 @@ import errno import io import os +import sys import tempfile import pytest @@ -33,6 +34,11 @@ def reference_load_impl(source): return config +@pytest.mark.xfail( + sys.version_info >= (3, 12), + reason="reference_load_impl uses the imp module, removed in Python 3.12 (el10)", + raises=ModuleNotFoundError, +) def test_load(confpath): """Brief test that config loads correctly.""" From 023efcbd24db84e84907289d4236f0f86391ffd3 Mon Sep 17 00:00:00 2001 From: grixan482 Date: Fri, 7 Aug 2026 11:10:12 +0300 Subject: [PATCH 14/17] tests: use tmpdir_factory for el8 compat --- tests/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5bcb0bd..0ae9461 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,14 +54,15 @@ def confdoc(confpath): return python_config.document.load(confpath) +# tmpdir_factory instead of tmp_path_factory: el8 pytest 3.4 without it @pytest.fixture(scope="session") -def huge_conf_path(tmp_path_factory): +def huge_conf_path(tmpdir_factory): """Return path to a generated huge config file.""" source = generate_huge_config() assert source.count("\n") >= 15000 - path = tmp_path_factory.mktemp("huge") / "huge.conf" + path = Path(str(tmpdir_factory.mktemp("huge"))) / "huge.conf" path.write_text(source) return path From 4c1cb02a377adb769a6fdb019b1f05d5ad14af1a Mon Sep 17 00:00:00 2001 From: grixan482 Date: Thu, 30 Jul 2026 13:36:08 +0300 Subject: [PATCH 15/17] spec: bump version --- python-config.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-config.spec b/python-config.spec index d505df4..f4ff2e0 100644 --- a/python-config.spec +++ b/python-config.spec @@ -13,7 +13,7 @@ https://github.com/KonishchevDmitry/object-validator project.} Name: python-config Version: 1.0.0 -Release: TEST17%{?dist} +Release: ROCKIT1%{?dist} Summary: A simple module for reading Python configuration files Group: Development/Libraries From 15b38e9670bf6d25b859bc400e305d4acf3f7ddc Mon Sep 17 00:00:00 2001 From: grixan482 Date: Thu, 30 Jul 2026 13:48:34 +0300 Subject: [PATCH 16/17] spec: add test build deps --- python-config.spec | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/python-config.spec b/python-config.spec index f4ff2e0..9dcd425 100644 --- a/python-config.spec +++ b/python-config.spec @@ -33,9 +33,15 @@ BuildRequires: python%{python3_pkgversion}-devel BuildRequires: python%{python3_pkgversion}-setuptools %if 0%{with tests} BuildRequires: python%{python3_pkgversion}-pytest >= 2.2.4 +BuildRequires: python%{python3_pkgversion}-click +BuildRequires: python%{python3_pkgversion}-rich +%if 0%{?rhel} <= 8 BuildRequires: python3-dataclasses +%endif %endif # with tests +%if 0%{?rhel} <= 8 Requires: python3-dataclasses +%endif Requires: python%{python3_pkgversion}-click Requires: python%{python3_pkgversion}-rich Obsoletes: python36-config From 496b25d6a576d56094caab0bc43f0bd6bd76b701 Mon Sep 17 00:00:00 2001 From: grixan482 Date: Thu, 30 Jul 2026 14:52:38 +0300 Subject: [PATCH 17/17] build: run pytest directly and drop deprecated setup.py test --- Makefile | 2 +- setup.py | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 19ffe04..1aca304 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ build: $(PYTHON) setup.py build check: - $(PYTHON) setup.py test + $(PYTHON) -m pytest tests install: $(PYTHON) setup.py install --skip-build $(INSTALL_FLAGS) diff --git a/setup.py b/setup.py index a400d96..d2f1be2 100644 --- a/setup.py +++ b/setup.py @@ -3,18 +3,6 @@ from pathlib import Path from setuptools import setup -from setuptools.command.test import test as Test - - -class PyTest(Test): - def finalize_options(self): - Test.finalize_options(self) - self.test_args = [ "tests" ] - self.test_suite = True - - def run_tests(self): - import pytest - pytest.main(self.test_args) if __name__ == "__main__": @@ -56,7 +44,4 @@ def run_tests(self): "python-config-view = python_config.viewer.cli:main", ], }, - - cmdclass = { "test": PyTest }, - tests_require = [ "pytest" ], )