Name | toml-topl JSON |
Version |
1.0.5
JSON |
| download |
home_page | None |
Summary | TOML extended with placeholders - two-phase placeholder resolution |
upload_time | 2025-07-24 12:08:33 |
maintainer | None |
docs_url | None |
author | None |
requires_python | >=3.11 |
license | MIT |
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"
}