Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ optional-dependencies = { dev = [
"pytest",
"ruff",
"cogapp",
], xml = [
"xmltodict"
] }
readme = "README.md"
license = "MIT"
Expand All @@ -63,6 +65,9 @@ classifiers = [

[tool.pyright]
exclude = ["**/_vendor/**"]
# TODO 2026-10-31 @ py3.10 EOL: remove this as it silences the tomli error on higher py versions
# for whatever reason the line/file ignores don't work with pyright
# reportMissingImports = false

[tool.ds.scripts] # run dev scripts <https://github.com/metaist/ds>
# Lint
Expand Down
8 changes: 7 additions & 1 deletion src/attrbox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@

# pkg
from . import env
from . import xml
from ._vendor.docopt import docopt
from .attrdict import AttrDict

# TODO 2026-10-31 @ py3.10 EOL: remove conditional
# TODO 2026-10-31 @ py3.10 EOL: remove conditional and pyright
# pyright: reportMissingImports=false
if sys.version_info >= (3, 11): # pragma: no cover
import tomllib as toml
else: # pragma: no cover
# the line-level comment does not work, so we have to use file-level
# which is gross
import tomli as toml

PYTHON_KEYWORDS: Sequence[str] = """
Expand Down Expand Up @@ -57,6 +61,8 @@ def set_loader(suffix: str, loader: LoaderFunc) -> None:
set_loader(".json", json.loads)
set_loader(".toml", toml.loads)
set_loader(".env", env.loads)
if xml.parser_available(): # pragma: no cover
set_loader(".xml", xml.loads)
# loaders registered


Expand Down
97 changes: 97 additions & 0 deletions src/attrbox/xml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""A loader for XML based configuration files.

This is essentially a thin wrapper for
[xmltodict](https://github.com/martinblech/xmltodict). Note that at this time,
streaming XML is not supported.

Requires the [xml] extra to be installed, otherwise a ModuleNotFound error will
be thrown.
"""

# native
import importlib.util
from typing import Any
from typing import Dict

# pkg
from .env import SupportsRead


def parser_available() -> bool:
"""Returns True if the [xml] extra is installed.
This checks for the existence of the xmltodict package.

Returns:
bool: True if the parser is available
"""
return importlib.util.find_spec("xmltodict") is not None


def _guard_parser_available() -> None:
"""Guard for the parser availability.
This effectively just throws the appropriate ModuleNotFoundError when parser_available() is not installed.

Raises:
ModuleNotFoundError: raised if the [xml] extra is not installed.
"""
if not parser_available(): # pragma: no cover
raise ModuleNotFoundError(
"Please install the [xml] extra to use the XML importer"
)


def load(file: SupportsRead, /) -> Dict[str, Any]:
"""Load an XML file and translate it to a Dict-like format.

Args:
file (SupportsRead): file-like object (has `.read()`)

Returns:
Dict[str, Any]: Dict-formatted XML document.

Raises:
ModuleNotFoundError: raised if the [xml] extra is not installed.

Examples:
>>> from pathlib import Path
>>> root = Path(__file__).parent.parent.parent
>>> load((root / "test/config_4.xml").open())
{'section': {'key': 'value4', 'xml': 'loaded'}}
"""
_guard_parser_available()
return loads(file.read())


def loads(text: str, /) -> Dict[str, Any]:
"""Parse an XML string and translate it to a Dict-like format.

Args:
text (str): text to parse.

Returns:
Dict[str, Any]: Dict-formatted XML document.

Raises:
ModuleNotFoundError: raised if the [xml] extra is not installed.

Examples:
Elements get turned into keys/values:
>>> xml_str = "<section><key>value</key></section>"
>>> loads(xml_str)
{'section': {'key': 'value'}}

Repeated keys make an array:
>>> xml_str = "<section><keys>value1</keys><keys>value2</keys></section>"
>>> loads(xml_str)
{'section': {'keys': ['value1', 'value2']}}

Attributes split up an element:
>>> xml_str = '<el name="value">inner text</el>'
>>> loads(xml_str)
{'el': {'@name': 'value', '#text': 'inner text'}}
"""
_guard_parser_available()

import xmltodict

return xmltodict.parse(text)
4 changes: 4 additions & 0 deletions test/config_4.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<section>
<key>value4</key>
<xml>loaded</xml>
</section>
3 changes: 3 additions & 0 deletions test/test_attrdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
# no cover: start
# Coverage disabled to cover all python versions.
# TODO 2026-10-31 @ py3.10 EOL: remove conditional
# pyright: reportMissingImports=false
if sys.version_info >= (3, 11):
import tomllib as toml
else:
# the line-level comment does not work, so we have to use file-level
# which is gross
import tomli as toml
# no cover: stop

Expand Down
Loading