toml-topl


Nametoml-topl JSON
Version 1.0.5 PyPI version JSON
download
home_pageNone
SummaryTOML extended with placeholders - two-phase placeholder resolution
upload_time2025-07-24 12:08:33
maintainerNone
docs_urlNone
authorNone
requires_python>=3.11
licenseMIT
keywords configuration placeholders templates toml
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            # topl

TOML extended with placeholders

---

#!/usr/bin/env -S uv run -s
# /// script
# dependencies = ["python-box", "rich", "fire"]
# ///
# this_file: resolve_toml.py
"""
resolve_toml.py
===============

Resolve double‑curly‑brace placeholders in a TOML file **in two phases**:

1. **Internal phase** – placeholders that reference keys *inside* the same
   TOML structure are substituted first (e.g. ``{{dict2.key2}}``).
2. **External phase** – any *remaining* placeholders are substituted with
   user‑supplied parameters (e.g. ``external1="foo"``).
3. **Warning phase** – unresolved placeholders are left intact **and** a
   warning is emitted.

The script purposefully performs *minimal* work: it does **not** try to
re‑order keys, merge files, or perform type conversions beyond ``str``;
it only “does what it says on the tin”.

---------------------------------------------------------------------------
Usage (CLI)
-----------

./resolve_toml.py path/to/file.toml --external external1="bar" external2="baz"

The CLI is provided by fire; every keyword argument after the filename is
treated as an external parameter.

⸻

Why Box?

Box gives intuitive dotted access (cfg.dict2.key2) while still behaving
like a plain dict for serialization.

“””

from future import annotations

import logging
import re
import sys
from pathlib import Path
from types import MappingProxyType
from typing import Any, Mapping

import tomllib  # Python 3.11+
from box import Box
import fire
from rich.console import Console
from rich.logging import RichHandler

—————————————————————————

Constants & regexes

_PLACEHOLDER_RE = re.compile(r”{{([^{}]+)}}”)
_MAX_INTERNAL_PASSES = 10  # avoid infinite loops on circular refs

—————————————————————————

Logging setup – colourised & optionally verbose

def _configure_logging(verbose: bool = False) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format=”%(message)s”,
handlers=[RichHandler(rich_tracebacks=True, console=Console(stderr=True))],
)

logger = logging.getLogger(name)

—————————————————————————

Low‑level helpers

def _get_by_path(box: Box, dotted_path: str) -> Any:
“””
Return value at dotted_path or None if the path is invalid.

``dotted_path`` follows Box semantics: ``"foo.bar.baz"``.
"""
current = box
for part in dotted_path.split("."):
    if not isinstance(current, Mapping) or part not in current:
        return None
    current = current[part]
return current

def _resolve_internal_once(s: str, root: Box) -> str:
“””
Replace one pass of internal placeholders in s.

A placeholder is internal if the path exists in *root*.
"""
def repl(match: re.Match[str]) -> str:
    path = match.group(1).strip()
    value = _get_by_path(root, path)
    return str(value) if value is not None else match.group(0)

return _PLACEHOLDER_RE.sub(repl, s)

def _resolve_external(s: str, params: Mapping[str, str]) -> str:
“””
Replace external placeholders using str.format_map.

We temporarily convert ``{{name}}`` → ``{name}`` then format.
Missing keys are left untouched.
"""

class _SafeDict(dict):  # noqa: D401
    """dict that leaves unknown placeholders unchanged."""

    def __missing__(self, key: str) -> str:  # noqa: D401
        return f"{{{{{key}}}}}"

if not params:
    return s

# Convert `{{name}}` → `{name}`
tmp = _PLACEHOLDER_RE.sub(lambda m: "{" + m.group(1).strip() + "}", s)
return tmp.format_map(_SafeDict(params))

def _iter_box_strings(box: Box) -> tuple[tuple[str, Box], …]:
“””
Yield (key, parent_box) pairs for every string leaf in box.

We return both key *and* the parent so we can assign new values in‑place.
"""
results: list[tuple[str, Box]] = []
for key, val in box.items():
    if isinstance(val, str):
        results.append((key, box))
    elif isinstance(val, Mapping):
        results.extend(_iter_box_strings(val))  # type: ignore[arg-type]
return tuple(results)

—————————————————————————

Public API

def resolve_placeholders(data: Mapping[str, Any], **params: str) -> Box:
“””
Resolve placeholders inside data in‑place and return a new Box.

Parameters
----------
data:
    Mapping returned by ``tomllib.load``.
**params:
    External parameters used during the *external* phase.

Returns
-------
Box
    The resolved configuration object.
"""
cfg = Box(data, default_box=True, default_box_attr=None)

# -- Phase 1: internal substitutions (multiple passes) ------------------ #
for i in range(_MAX_INTERNAL_PASSES):
    changed = False
    for key, parent in _iter_box_strings(cfg):
        original = parent[key]
        resolved = _resolve_internal_once(original, cfg)
        if original != resolved:
            parent[key] = resolved
            changed = True
    if not changed:
        logger.debug("Internal resolution stabilised after %s passes", i + 1)
        break
else:  # pragma: no cover
    logger.warning(
        "Reached maximum internal passes (%s). "
        "Possible circular placeholder references?",
        _MAX_INTERNAL_PASSES,
    )

# -- Phase 2: external substitutions ----------------------------------- #
for key, parent in _iter_box_strings(cfg):
    parent[key] = _resolve_external(parent[key], MappingProxyType(params))

# -- Phase 3: warn about leftovers ------------------------------------- #
leftovers: list[str] = []
for key, parent in _iter_box_strings(cfg):
    for match in _PLACEHOLDER_RE.finditer(parent[key]):
        leftovers.append(match.group(0))
if leftovers:
    unique = sorted(set(leftovers))
    logger.warning(
        "Could not resolve %s placeholder(s): %s",
        len(unique),
        ", ".join(unique),
    )

return cfg

—————————————————————————

CLI entry‑point

def main(path: str, verbose: bool = False, **params: str) -> None:  # noqa: D401
“””
Read path (TOML), resolve placeholders, and pretty‑print the result.

Any ``key=value`` arguments after *path* are considered external params.
"""
_configure_logging(verbose)

toml_path = Path(path).expanduser()
try:
    data = toml_path.read_bytes()
except FileNotFoundError:
    logger.error("TOML file %s not found", toml_path)
    sys.exit(1)

config = resolve_placeholders(tomllib.loads(data.decode()), **params)
Console().print(config.to_dict())

if name == “main”:  # pragma: no cover
fire.Fire(main)

---

### How this fulfils the brief 📝

1. **Two‑phase resolution**:  
   *Internal* references are substituted first; only the unresolved placeholders
   are then offered to external parameters via ``str.format_map``.
2. **Warnings**: Any placeholders still unreplaced are logged **once** –
   exactly as requested.
3. **Box integration**: The Toml structure is returned as a `Box`, so callers
   keep dotted access for further processing.
4. **CLI optionality**: Fire provides a one‑liner interface but is *not*
   mandatory for library use.
5. **Safety**: Circular references are detected via a pass‑count limit and will
   not hang the program.

Feel free to drop the CLI bits if you only need a function – everything is
modular.

            

Raw data

            {
    "_id": null,
    "home_page": null,
    "name": "toml-topl",
    "maintainer": null,
    "docs_url": null,
    "requires_python": ">=3.11",
    "maintainer_email": null,
    "keywords": "configuration, placeholders, templates, toml",
    "author": null,
    "author_email": "Adam Twardoch <adam+github@twardoch.com>",
    "download_url": "https://files.pythonhosted.org/packages/ee/c4/06699a8648986b0a954188f344689d5f97517676957a4f812748b168aab8/toml_topl-1.0.5.tar.gz",
    "platform": null,
    "description": "# topl\n\nTOML extended with placeholders\n\n---\n\n#!/usr/bin/env -S uv run -s\n# /// script\n# dependencies = [\"python-box\", \"rich\", \"fire\"]\n# ///\n# this_file: resolve_toml.py\n\"\"\"\nresolve_toml.py\n===============\n\nResolve double\u2011curly\u2011brace placeholders in a TOML file **in two phases**:\n\n1. **Internal phase** \u2013 placeholders that reference keys *inside* the same\n   TOML structure are substituted first (e.g. ``{{dict2.key2}}``).\n2. **External phase** \u2013 any *remaining* placeholders are substituted with\n   user\u2011supplied parameters (e.g. ``external1=\"foo\"``).\n3. **Warning phase** \u2013 unresolved placeholders are left intact **and** a\n   warning is emitted.\n\nThe script purposefully performs *minimal* work: it does **not** try to\nre\u2011order keys, merge files, or perform type conversions beyond ``str``;\nit only \u201cdoes what it says on the tin\u201d.\n\n---------------------------------------------------------------------------\nUsage (CLI)\n-----------\n\n./resolve_toml.py path/to/file.toml --external external1=\"bar\" external2=\"baz\"\n\nThe CLI is provided by fire; every keyword argument after the filename is\ntreated as an external parameter.\n\n\u2e3b\n\nWhy Box?\n\nBox gives intuitive dotted access (cfg.dict2.key2) while still behaving\nlike a plain dict for serialization.\n\n\u201c\u201d\u201d\n\nfrom future import annotations\n\nimport logging\nimport re\nimport sys\nfrom pathlib import Path\nfrom types import MappingProxyType\nfrom typing import Any, Mapping\n\nimport tomllib  # Python\u00a03.11+\nfrom box import Box\nimport fire\nfrom rich.console import Console\nfrom rich.logging import RichHandler\n\n\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\n\nConstants & regexes\n\n_PLACEHOLDER_RE = re.compile(r\u201d{{([^{}]+)}}\u201d)\n_MAX_INTERNAL_PASSES = 10  # avoid infinite loops on circular refs\n\n\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\n\nLogging setup \u2013\u00a0colourised & optionally verbose\n\ndef _configure_logging(verbose: bool = False) -> None:\nlevel = logging.DEBUG if verbose else logging.INFO\nlogging.basicConfig(\nlevel=level,\nformat=\u201d%(message)s\u201d,\nhandlers=[RichHandler(rich_tracebacks=True, console=Console(stderr=True))],\n)\n\nlogger = logging.getLogger(name)\n\n\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\n\nLow\u2011level helpers\n\ndef _get_by_path(box: Box, dotted_path: str) -> Any:\n\u201c\u201d\u201d\nReturn value at dotted_path or None if the path is invalid.\n\n``dotted_path`` follows Box semantics: ``\"foo.bar.baz\"``.\n\"\"\"\ncurrent = box\nfor part in dotted_path.split(\".\"):\n    if not isinstance(current, Mapping) or part not in current:\n        return None\n    current = current[part]\nreturn current\n\ndef _resolve_internal_once(s: str, root: Box) -> str:\n\u201c\u201d\u201d\nReplace one pass of internal placeholders in s.\n\nA placeholder is internal if the path exists in *root*.\n\"\"\"\ndef repl(match: re.Match[str]) -> str:\n    path = match.group(1).strip()\n    value = _get_by_path(root, path)\n    return str(value) if value is not None else match.group(0)\n\nreturn _PLACEHOLDER_RE.sub(repl, s)\n\ndef _resolve_external(s: str, params: Mapping[str, str]) -> str:\n\u201c\u201d\u201d\nReplace external placeholders using str.format_map.\n\nWe temporarily convert ``{{name}}`` \u2192 ``{name}`` then format.\nMissing keys are left untouched.\n\"\"\"\n\nclass _SafeDict(dict):  # noqa: D401\n    \"\"\"dict that leaves unknown placeholders unchanged.\"\"\"\n\n    def __missing__(self, key: str) -> str:  # noqa: D401\n        return f\"{{{{{key}}}}}\"\n\nif not params:\n    return s\n\n# Convert `{{name}}` \u2192 `{name}`\ntmp = _PLACEHOLDER_RE.sub(lambda m: \"{\" + m.group(1).strip() + \"}\", s)\nreturn tmp.format_map(_SafeDict(params))\n\ndef _iter_box_strings(box: Box) -> tuple[tuple[str, Box], \u2026]:\n\u201c\u201d\u201d\nYield (key, parent_box) pairs for every string leaf in box.\n\nWe return both key *and* the parent so we can assign new values in\u2011place.\n\"\"\"\nresults: list[tuple[str, Box]] = []\nfor key, val in box.items():\n    if isinstance(val, str):\n        results.append((key, box))\n    elif isinstance(val, Mapping):\n        results.extend(_iter_box_strings(val))  # type: ignore[arg-type]\nreturn tuple(results)\n\n\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\n\nPublic API\n\ndef resolve_placeholders(data: Mapping[str, Any], **params: str) -> Box:\n\u201c\u201d\u201d\nResolve placeholders inside data in\u2011place and return a new Box.\n\nParameters\n----------\ndata:\n    Mapping returned by ``tomllib.load``.\n**params:\n    External parameters used during the *external* phase.\n\nReturns\n-------\nBox\n    The resolved configuration object.\n\"\"\"\ncfg = Box(data, default_box=True, default_box_attr=None)\n\n# -- Phase\u00a01: internal substitutions (multiple passes) ------------------ #\nfor i in range(_MAX_INTERNAL_PASSES):\n    changed = False\n    for key, parent in _iter_box_strings(cfg):\n        original = parent[key]\n        resolved = _resolve_internal_once(original, cfg)\n        if original != resolved:\n            parent[key] = resolved\n            changed = True\n    if not changed:\n        logger.debug(\"Internal resolution stabilised after %s passes\", i + 1)\n        break\nelse:  # pragma: no cover\n    logger.warning(\n        \"Reached maximum internal passes (%s). \"\n        \"Possible circular placeholder references?\",\n        _MAX_INTERNAL_PASSES,\n    )\n\n# -- Phase\u00a02: external substitutions ----------------------------------- #\nfor key, parent in _iter_box_strings(cfg):\n    parent[key] = _resolve_external(parent[key], MappingProxyType(params))\n\n# -- Phase\u00a03: warn about leftovers ------------------------------------- #\nleftovers: list[str] = []\nfor key, parent in _iter_box_strings(cfg):\n    for match in _PLACEHOLDER_RE.finditer(parent[key]):\n        leftovers.append(match.group(0))\nif leftovers:\n    unique = sorted(set(leftovers))\n    logger.warning(\n        \"Could not resolve %s placeholder(s): %s\",\n        len(unique),\n        \", \".join(unique),\n    )\n\nreturn cfg\n\n\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\n\nCLI entry\u2011point\n\ndef main(path: str, verbose: bool = False, **params: str) -> None:  # noqa: D401\n\u201c\u201d\u201d\nRead path (TOML), resolve placeholders, and pretty\u2011print the result.\n\nAny ``key=value`` arguments after *path* are considered external params.\n\"\"\"\n_configure_logging(verbose)\n\ntoml_path = Path(path).expanduser()\ntry:\n    data = toml_path.read_bytes()\nexcept FileNotFoundError:\n    logger.error(\"TOML file %s not found\", toml_path)\n    sys.exit(1)\n\nconfig = resolve_placeholders(tomllib.loads(data.decode()), **params)\nConsole().print(config.to_dict())\n\nif name == \u201cmain\u201d:  # pragma: no cover\nfire.Fire(main)\n\n---\n\n### How this fulfils the brief\u202f\ud83d\udcdd\n\n1. **Two\u2011phase resolution**:  \n   *Internal* references are substituted first; only the unresolved placeholders\n   are then offered to external parameters via ``str.format_map``.\n2. **Warnings**: Any placeholders still unreplaced are logged **once** \u2013\n   exactly as requested.\n3. **Box integration**: The Toml structure is returned as a `Box`, so callers\n   keep dotted access for further processing.\n4. **CLI optionality**: Fire provides a one\u2011liner interface but is *not*\n   mandatory for library use.\n5. **Safety**: Circular references are detected via a pass\u2011count limit and will\n   not hang the program.\n\nFeel free to drop the CLI bits if you only need a function \u2013 everything is\nmodular.\n",
    "bugtrack_url": null,
    "license": "MIT",
    "summary": "TOML extended with placeholders - two-phase placeholder resolution",
    "version": "1.0.5",
    "project_urls": {
        "Changelog": "https://github.com/terragonlabs/topl/blob/main/CHANGELOG.md",
        "Documentation": "https://topl.readthedocs.io",
        "Homepage": "https://github.com/terragonlabs/topl",
        "Issues": "https://github.com/terragonlabs/topl/issues",
        "Repository": "https://github.com/terragonlabs/topl"
    },
    "split_keywords": [
        "configuration",
        " placeholders",
        " templates",
        " toml"
    ],
    "urls": [
        {
            "comment_text": null,
            "digests": {
                "blake2b_256": "0f89c031801b51a4a74e54cc9720842d009f850f392cf86362cbf7744d18371d",
                "md5": "2f8cbe798f2a99096976187e1c1ad23c",
                "sha256": "79cdfc3ad4cccdde211b78f6ea5c499e30c18ad7f5316c5a7a73153ce81554f4"
            },
            "downloads": -1,
            "filename": "toml_topl-1.0.5-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "2f8cbe798f2a99096976187e1c1ad23c",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": ">=3.11",
            "size": 13730,
            "upload_time": "2025-07-24T12:08:34",
            "upload_time_iso_8601": "2025-07-24T12:08:34.810675Z",
            "url": "https://files.pythonhosted.org/packages/0f/89/c031801b51a4a74e54cc9720842d009f850f392cf86362cbf7744d18371d/toml_topl-1.0.5-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": null,
            "digests": {
                "blake2b_256": "eec406699a8648986b0a954188f344689d5f97517676957a4f812748b168aab8",
                "md5": "86800975735a10deccbc6f5fbdb0ef6a",
                "sha256": "567c5e15159bfd6ff0750735e7c768de8d96a038f9ce76707a3c602e076dd067"
            },
            "downloads": -1,
            "filename": "toml_topl-1.0.5.tar.gz",
            "has_sig": false,
            "md5_digest": "86800975735a10deccbc6f5fbdb0ef6a",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": ">=3.11",
            "size": 19579,
            "upload_time": "2025-07-24T12:08:33",
            "upload_time_iso_8601": "2025-07-24T12:08:33.185075Z",
            "url": "https://files.pythonhosted.org/packages/ee/c4/06699a8648986b0a954188f344689d5f97517676957a4f812748b168aab8/toml_topl-1.0.5.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2025-07-24 12:08:33",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "terragonlabs",
    "github_project": "topl",
    "github_not_found": true,
    "lcname": "toml-topl"
}
        
Elapsed time: 0.45748s