diff --git a/.gitignore b/.gitignore index 0d20b64..7559382 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ +*.egg-info +__pycache__ *.pyc + +uv.lock +.venv 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/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/README b/README deleted file mode 100644 index fc4b9e6..0000000 --- a/README +++ /dev/null @@ -1,8 +0,0 @@ -A simple module for reading Python configuration files - -Python configuration files themselves are actual Python files. The module 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. - -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..7f9c787 --- /dev/null +++ b/README.rst @@ -0,0 +1,88 @@ +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. + +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`` Expand the current value full-screen (scroll with ``j``/``n`` and ``k``/``p``) +``a`` Toggle assignment source view (top-level variables only) +``q`` Quit +========== ====================================================== + +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 new file mode 100644 index 0000000..8033be7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[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.rst" + +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" + +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", + "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/python-config.spec b/python-config.spec index eef389a..9dcd425 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 @@ -13,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: ROCKIT1%{?dist} Summary: A simple module for reading Python configuration files Group: Development/Libraries @@ -34,7 +33,17 @@ 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 Conflicts: python36-config @@ -59,10 +68,11 @@ make PYTHON=%{__python3} check %files -n python%{python3_pkgversion}-config %defattr(-,root,root,-) -%{python3_sitelib}/python_config.py -%{python3_sitelib}/__pycache__/python_config.*.py* +%{_bindir}/python-config-view +%{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 @@ -70,6 +80,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 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..cce4025 --- /dev/null +++ b/python_config/__init__.py @@ -0,0 +1,25 @@ +"""Python configuration file parser.""" + +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/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 new file mode 100644 index 0000000..811c88f --- /dev/null +++ b/python_config/exceptions.py @@ -0,0 +1,44 @@ +class Error(Exception): + """The base class for all exceptions that the module raises.""" + + def __init__(self, error, *args, **kwargs): + 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().__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 + + +class ParsingError(Error): + """Error while parsing a configuration file.""" + + def __init__(self, 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().__init__(f"Error while validating '{path}' configuration file: {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().__init__(*args, **kwargs) + self.option_name = option_name 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..ea6ec40 --- /dev/null +++ b/python_config/models.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from .exceptions import ValidationError, _ValidationError +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. + + 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 the :class:`Assignment` for a top-level option.""" + + for item in self.items: + if item.name == name: + return item + + raise KeyError(name) + + def setvar(self, name, value=_UNSET, *, docstring=_UNSET): + """Create or update a top-level assignment.""" + + 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: + item = self.getvar(name) + except KeyError: + if value is _UNSET: + raise TypeError("setvar() value is required when creating a new assignment") + + 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 + + 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): + 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 new file mode 100644 index 0000000..92b86f6 --- /dev/null +++ b/python_config/validation.py @@ -0,0 +1,90 @@ +from .exceptions import ValidationError, _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): + """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) + 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 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..5889673 --- /dev/null +++ b/python_config/viewer/app.py @@ -0,0 +1,407 @@ +"""Interactive application loop for the config viewer.""" + +import click +from rich.live import Live + +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 + 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: + 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 _resolve_detail(self, page_items): + if not page_items and self.path: + detail_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 = 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, + 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, + live=live, + ) + 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() + 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, 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}" + 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 = "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.""" + + 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.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() + 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, live=live) + + 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(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"): + 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..48ec6d4 --- /dev/null +++ b/python_config/viewer/nav.py @@ -0,0 +1,301 @@ +"""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 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*.""" + + 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..fce9fdb --- /dev/null +++ b/python_config/viewer/render.py @@ -0,0 +1,247 @@ +"""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 a 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, + ) + ) + lines.append("") + lines.append(Text("Press s to expand value", style="dim italic")) + if assignment is not None: + lines.append(Text("Press a to view assignment 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 [s] expand" + if show_source_available: + 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, + path, + page_items, + page, + total_pages, + selected_index, + detail_label, + search_mode=False, + search_results=None, + message=None, + show_source=False, +): + """Build the full viewer screen as a single renderable.""" + + 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: + parts.append(Text(message, style="yellow")) + + parts.append( + render_children_table( + page_items, + page, + total_pages, + selected_index, + search_mode, + search_results, + ) + ) + + show_source_available = False + if detail_label and not search_mode: + _value, assignment = nav.resolve(doc, path) + parts.append(render_detail_panel(doc, path, detail_label, show_source=show_source)) + show_source_available = assignment is not None + + 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): + """Return indices of selectable rows on the current page.""" + + return list(range(len(page_items))) diff --git a/setup.py b/setup.py index d7aafe3..d2f1be2 100644 --- a/setup.py +++ b/setup.py @@ -1,51 +1,47 @@ """python-config installation script.""" -from __future__ import unicode_literals +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__": - 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 :: 2", - "Programming Language :: Python :: 3", - "Topic :: Software Development :: Libraries :: Python Modules", + readme = Path("README.rst").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", "python_config.viewer" ], + + entry_points = { + "console_scripts": [ + "python-config-view = python_config.viewer.cli:main", ], - platforms = [ "unix", "linux", "osx" ], - - py_modules = [ "python_config" ], - - cmdclass = { "test": PyTest }, - tests_require = [ "pytest" ], - ) + }, + ) 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..0ae9461 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,68 @@ +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) + + +# tmpdir_factory instead of tmp_path_factory: el8 pytest 3.4 without it +@pytest.fixture(scope="session") +def huge_conf_path(tmpdir_factory): + """Return path to a generated huge config file.""" + + source = generate_huge_config() + assert source.count("\n") >= 15000 + + path = Path(str(tmpdir_factory.mktemp("huge"))) / "huge.conf" + path.write_text(source) + + return path 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_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..7058228 --- /dev/null +++ b/tests/test_document.py @@ -0,0 +1,143 @@ +"""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") + + +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") diff --git a/tests/test_file_reading.py b/tests/test_file_reading.py deleted file mode 100644 index 46f694b..0000000 --- a/tests/test_file_reading.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Test configuration file reading.""" - -from __future__ import unicode_literals - -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 1c7e29c..0000000 --- a/tests/test_parsing.py +++ /dev/null @@ -1,108 +0,0 @@ -"""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 - -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" - -TUPLE_VALUE = ( "a", 1 ) - -LIST_VALUE = ( "b", 2 ) - -SET_VALUE = set(( "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 - - if PY2: - assert type(config["long_value"]) is long - - 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" ], - }, - }, - } - - 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" - - -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" - - -def test_invalid_syntax(): - with pytest.raises(python_config.ParsingError): - python_config.load("test", contents="a=") 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..a9a27a8 --- /dev/null +++ b/tests/test_simple.py @@ -0,0 +1,139 @@ +"""Tests for python_config.simple.""" + +import errno +import io +import os +import sys +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 + + +@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.""" + + 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") diff --git a/tests/test_viewer.py b/tests/test_viewer.py new file mode 100644 index 0000000..1bf0afe --- /dev/null +++ b/tests/test_viewer.py @@ -0,0 +1,265 @@ +"""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_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(["a", "a", "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 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) + 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 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): + 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" 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