zig-hocon 0.4.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zig_hocon/__init__.py +39 -0
- zig_hocon/__main__.py +36 -0
- zig_hocon/_capi.py +104 -0
- zig_hocon/config_parser.py +108 -0
- zig_hocon/config_tree.py +248 -0
- zig_hocon/exceptions.py +81 -0
- zig_hocon/hocon.dll +0 -0
- zig_hocon/units.py +37 -0
- zig_hocon-0.4.0.dist-info/METADATA +238 -0
- zig_hocon-0.4.0.dist-info/RECORD +12 -0
- zig_hocon-0.4.0.dist-info/WHEEL +4 -0
- zig_hocon-0.4.0.dist-info/licenses/LICENSE +28 -0
zig_hocon/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""HOCON for Python, read by zig-hocon.
|
|
2
|
+
|
|
3
|
+
The parsing, the substitutions and every conversion are the Zig library's
|
|
4
|
+
(libhocon, next to this file); this is a thin ctypes layer over its C API
|
|
5
|
+
(c/hocon.h), shaped like pyhocon so moving over is a change of import::
|
|
6
|
+
|
|
7
|
+
from zig_hocon import ConfigFactory
|
|
8
|
+
|
|
9
|
+
conf = ConfigFactory.parse_file("deploy/prod/app.conf")
|
|
10
|
+
conf.get_int("db.pool")
|
|
11
|
+
conf.as_dict()
|
|
12
|
+
|
|
13
|
+
The modules are pyhocon's: ``config_parser`` (ConfigFactory), ``config_tree``
|
|
14
|
+
(ConfigTree), ``exceptions``; and ``units`` for durations and sizes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .config_parser import KEEP, ConfigFactory
|
|
18
|
+
from .config_tree import ConfigTree
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
ConfigException,
|
|
21
|
+
ConfigMissingException,
|
|
22
|
+
ConfigParseException,
|
|
23
|
+
ConfigSubstitutionException,
|
|
24
|
+
ConfigWrongTypeException,
|
|
25
|
+
)
|
|
26
|
+
from .units import parse_bytes, parse_duration
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"KEEP",
|
|
30
|
+
"ConfigException",
|
|
31
|
+
"ConfigFactory",
|
|
32
|
+
"ConfigMissingException",
|
|
33
|
+
"ConfigParseException",
|
|
34
|
+
"ConfigSubstitutionException",
|
|
35
|
+
"ConfigTree",
|
|
36
|
+
"ConfigWrongTypeException",
|
|
37
|
+
"parse_bytes",
|
|
38
|
+
"parse_duration",
|
|
39
|
+
]
|
zig_hocon/__main__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""``python -m zig_hocon [--hocon | --json] [--env=process|none|keep] <file.conf>``
|
|
2
|
+
|
|
3
|
+
The file resolved, as the ``hocon`` CLI prints it: HOCON by default, or
|
|
4
|
+
compact JSON; an error goes to stderr with exit 1. Through this package and
|
|
5
|
+
libhocon, so the conformance suite can measure them
|
|
6
|
+
(conformance/adapters/zig-hocon-python.sh).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from zig_hocon import KEEP, ConfigException, ConfigFactory
|
|
14
|
+
|
|
15
|
+
parser = argparse.ArgumentParser(prog="python -m zig_hocon")
|
|
16
|
+
output = parser.add_mutually_exclusive_group()
|
|
17
|
+
output.add_argument("--hocon", action="store_true", help="print HOCON (the default)")
|
|
18
|
+
output.add_argument("--json", action="store_true", help="print compact JSON")
|
|
19
|
+
parser.add_argument("--env", choices=["process", "none", "keep"], default="process")
|
|
20
|
+
parser.add_argument("file")
|
|
21
|
+
args = parser.parse_args()
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
conf = ConfigFactory.parse_file(args.file, env={"process": os.environ, "none": None, "keep": KEEP}[args.env])
|
|
25
|
+
out = conf.to_json() + b"\n" if args.json else conf.to_hocon().encode("utf-8", "surrogatepass")
|
|
26
|
+
except ConfigException as e:
|
|
27
|
+
sys.exit(f"error: {e}")
|
|
28
|
+
|
|
29
|
+
# A closed stdout is None, a pipe whose reader has gone an OSError: the CLI's
|
|
30
|
+
# error for both. Python flushes stdout once more at exit; devnull keeps that quiet.
|
|
31
|
+
try:
|
|
32
|
+
sys.stdout.buffer.write(out)
|
|
33
|
+
sys.stdout.flush()
|
|
34
|
+
except (OSError, AttributeError):
|
|
35
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), 1)
|
|
36
|
+
sys.exit(f"error: {args.file}: WriteFailed")
|
zig_hocon/_capi.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""libhocon through ctypes: the library next to this file, the structs and
|
|
2
|
+
codes of c/hocon.h, and the types of its functions. Kept to hocon.h by
|
|
3
|
+
python/tests/test_capi.py."""
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# The codes of hocon.h.
|
|
11
|
+
OK, PARSE, SUBSTITUTION, MISSING, WRONG_TYPE, IO, BAD_PATH, TOO_DEEP, OTHER = range(9)
|
|
12
|
+
ENV_PROCESS, ENV_NONE, ENV_MAP, ENV_KEEP = range(4)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Options(ctypes.Structure):
|
|
16
|
+
"""``hocon_options``."""
|
|
17
|
+
|
|
18
|
+
env: int
|
|
19
|
+
env_vars: "ctypes._Pointer[ctypes.c_char_p] | None"
|
|
20
|
+
resolve: bool
|
|
21
|
+
include_dir: bytes | None
|
|
22
|
+
_fields_ = [
|
|
23
|
+
("env", ctypes.c_int),
|
|
24
|
+
("env_vars", ctypes.POINTER(ctypes.c_char_p)),
|
|
25
|
+
("resolve", ctypes.c_bool),
|
|
26
|
+
("include_dir", ctypes.c_char_p),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Error(ctypes.Structure):
|
|
31
|
+
"""``hocon_error``."""
|
|
32
|
+
|
|
33
|
+
code: int
|
|
34
|
+
name: bytes | None
|
|
35
|
+
file: bytes | None
|
|
36
|
+
line: int
|
|
37
|
+
column: int
|
|
38
|
+
source_line: bytes | None
|
|
39
|
+
key_path: bytes | None
|
|
40
|
+
expected: bytes | None
|
|
41
|
+
_fields_ = [
|
|
42
|
+
("code", ctypes.c_int),
|
|
43
|
+
("name", ctypes.c_char_p),
|
|
44
|
+
("file", ctypes.c_char_p),
|
|
45
|
+
("line", ctypes.c_size_t),
|
|
46
|
+
("column", ctypes.c_size_t),
|
|
47
|
+
("source_line", ctypes.c_char_p),
|
|
48
|
+
("key_path", ctypes.c_char_p),
|
|
49
|
+
("expected", ctypes.c_char_p),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load() -> ctypes.CDLL:
|
|
54
|
+
name = {"darwin": "libhocon.dylib", "win32": "hocon.dll"}.get(sys.platform, "libhocon.so")
|
|
55
|
+
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), name)
|
|
56
|
+
try:
|
|
57
|
+
return ctypes.CDLL(path)
|
|
58
|
+
except OSError as e:
|
|
59
|
+
raise ImportError(f"zig_hocon: cannot load {path} ({e}); `zig build python` puts it there") from e
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
lib = _load()
|
|
63
|
+
_doc, _path = ctypes.c_void_p, ctypes.c_char_p
|
|
64
|
+
lib.hocon_parse_file.argtypes = [ctypes.c_char_p, ctypes.POINTER(Options), ctypes.POINTER(Error)]
|
|
65
|
+
lib.hocon_parse_file.restype = _doc
|
|
66
|
+
lib.hocon_parse_string.argtypes = [ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(Options), ctypes.POINTER(Error)]
|
|
67
|
+
lib.hocon_parse_string.restype = _doc
|
|
68
|
+
# Each getter, and the types of its out parameters.
|
|
69
|
+
_getters: list[tuple[str, list[Any]]] = [
|
|
70
|
+
("int", [ctypes.c_int64]),
|
|
71
|
+
("float", [ctypes.c_double]),
|
|
72
|
+
("bool", [ctypes.c_bool]),
|
|
73
|
+
("string", [ctypes.c_void_p, ctypes.c_size_t]),
|
|
74
|
+
("duration_ns", [ctypes.c_int64]),
|
|
75
|
+
("bytes", [ctypes.c_uint64]),
|
|
76
|
+
("period", [ctypes.c_int32] * 3),
|
|
77
|
+
]
|
|
78
|
+
for _name, _out in _getters:
|
|
79
|
+
_getter = getattr(lib, "hocon_get_" + _name)
|
|
80
|
+
_getter.argtypes = [_doc, _path] + [ctypes.POINTER(t) for t in _out]
|
|
81
|
+
_getter.restype = ctypes.c_int
|
|
82
|
+
lib.hocon_get_config.argtypes = [_doc, _path]
|
|
83
|
+
lib.hocon_get_config.restype = ctypes.c_int
|
|
84
|
+
# A void pointer, not c_char_p: ctypes would copy the text and lose the
|
|
85
|
+
# pointer that hocon_free_string needs.
|
|
86
|
+
for _writer in (lib.hocon_to_json, lib.hocon_to_hocon):
|
|
87
|
+
_writer.argtypes = [_doc, _path, ctypes.POINTER(ctypes.c_int)]
|
|
88
|
+
_writer.restype = ctypes.c_void_p
|
|
89
|
+
lib.hocon_free_string.argtypes = [ctypes.c_void_p]
|
|
90
|
+
lib.hocon_free_string.restype = None
|
|
91
|
+
lib.hocon_error_free.argtypes = [ctypes.POINTER(Error)]
|
|
92
|
+
lib.hocon_error_free.restype = None
|
|
93
|
+
lib.hocon_free.argtypes = [_doc]
|
|
94
|
+
lib.hocon_free.restype = None
|
|
95
|
+
lib.hocon_last_error.argtypes = [_doc]
|
|
96
|
+
lib.hocon_last_error.restype = ctypes.POINTER(Error)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def c_string(data: bytes) -> bytes:
|
|
100
|
+
"""``data`` for C, which ends a string at its first 0: with one inside, it
|
|
101
|
+
would be another path or name, so that is a ValueError, as for open()."""
|
|
102
|
+
if b"\0" in data:
|
|
103
|
+
raise ValueError("embedded null byte")
|
|
104
|
+
return data
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Reading a document, as pyhocon's ``ConfigFactory``: from a file or from
|
|
2
|
+
text, with ``env`` and ``resolve`` saying how far it is worked out."""
|
|
3
|
+
|
|
4
|
+
import ctypes
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import _capi
|
|
10
|
+
from ._capi import lib
|
|
11
|
+
from .config_tree import ConfigTree, Doc
|
|
12
|
+
from .exceptions import ConfigException, ConfigParseException, ConfigSubstitutionException, name_of, placed
|
|
13
|
+
|
|
14
|
+
__all__ = ["KEEP", "ConfigFactory"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Keep:
|
|
18
|
+
def __repr__(self) -> str:
|
|
19
|
+
return "zig_hocon.KEEP"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
KEEP = _Keep()
|
|
23
|
+
"""``env=KEEP``: a substitution the document cannot answer stays in it, for
|
|
24
|
+
``to_hocon()`` to write out and the machine it runs on to resolve."""
|
|
25
|
+
|
|
26
|
+
Path = str | bytes | os.PathLike[str] | os.PathLike[bytes]
|
|
27
|
+
"""A file name, as open() takes it."""
|
|
28
|
+
|
|
29
|
+
Env = Mapping[str, str | bytes] | Mapping[bytes, str | bytes] | _Keep | None
|
|
30
|
+
"""Where ``${NAME}`` looks when NAME is not in the document (``ConfigFactory``)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _env_bytes(text: str | bytes, key: str | bytes) -> bytes:
|
|
34
|
+
# os.environ decodes what is not UTF-8 with surrogateescape; this gives the
|
|
35
|
+
# bytes back.
|
|
36
|
+
if isinstance(text, str):
|
|
37
|
+
return text.encode("utf-8", "surrogateescape")
|
|
38
|
+
if isinstance(text, bytes):
|
|
39
|
+
return text
|
|
40
|
+
raise TypeError(f"env {key!r}: expected str or bytes, not {type(text).__name__}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _options(env: Env, resolve: bool, include_dir: Path | None = None) -> tuple[_capi.Options, object]:
|
|
44
|
+
"""The options, and what has to live as long as they are used."""
|
|
45
|
+
opts = _capi.Options(resolve=bool(resolve))
|
|
46
|
+
entries = None
|
|
47
|
+
if env is KEEP:
|
|
48
|
+
opts.env = _capi.ENV_KEEP
|
|
49
|
+
elif env is None:
|
|
50
|
+
opts.env = _capi.ENV_NONE
|
|
51
|
+
elif isinstance(env, Mapping):
|
|
52
|
+
lines = []
|
|
53
|
+
for key, value in env.items():
|
|
54
|
+
k, v = _env_bytes(key, key), _env_bytes(value, key)
|
|
55
|
+
# No `${…}` can name it (Windows has `=C:=C:\` in its environment):
|
|
56
|
+
# skipped, as hocon.h skips it in `environ`.
|
|
57
|
+
if not k or b"=" in k:
|
|
58
|
+
continue
|
|
59
|
+
lines.append(_capi.c_string(k + b"=" + v))
|
|
60
|
+
entries = (ctypes.c_char_p * (len(lines) + 1))(*lines, None)
|
|
61
|
+
opts.env = _capi.ENV_MAP
|
|
62
|
+
opts.env_vars = ctypes.cast(entries, ctypes.POINTER(ctypes.c_char_p))
|
|
63
|
+
else:
|
|
64
|
+
raise TypeError(f"env: expected a mapping, None or KEEP, not {type(env).__name__}")
|
|
65
|
+
if include_dir is not None:
|
|
66
|
+
opts.include_dir = _capi.c_string(os.fsencode(include_dir))
|
|
67
|
+
return opts, entries
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse(parse: Callable[..., int | None], args: Sequence[Any], env: Env, resolve: bool, include_dir: Path | None, given: str | None) -> ConfigTree:
|
|
71
|
+
opts, _entries = _options(env, resolve, include_dir)
|
|
72
|
+
err = _capi.Error()
|
|
73
|
+
handle = parse(*args, ctypes.byref(opts), ctypes.byref(err))
|
|
74
|
+
if handle:
|
|
75
|
+
return ConfigTree(Doc(handle), "")
|
|
76
|
+
try:
|
|
77
|
+
raise _parse_error(err, given)
|
|
78
|
+
finally:
|
|
79
|
+
lib.hocon_error_free(ctypes.byref(err))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_error(err: _capi.Error, given: str | None) -> ConfigException:
|
|
83
|
+
"""`app.conf:7:12: Unresolved`, the line, and a caret under the column."""
|
|
84
|
+
name = name_of(err)
|
|
85
|
+
cls = {_capi.PARSE: ConfigParseException, _capi.SUBSTITUTION: ConfigSubstitutionException}.get(err.code, ConfigException)
|
|
86
|
+
if not err.line:
|
|
87
|
+
# An empty file name is shown as '' so the message still names it.
|
|
88
|
+
return cls(f"{given or repr(given)}: {name}" if given is not None else name, name=name)
|
|
89
|
+
return placed(cls, err, name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConfigFactory:
|
|
93
|
+
"""Reads a document. ``env`` is where ``${NAME}`` looks when NAME is not
|
|
94
|
+
in it: ``os.environ`` (the default, as pyhocon), any mapping of names to
|
|
95
|
+
values, ``None`` for nowhere, or ``KEEP`` to leave it in the document.
|
|
96
|
+
``resolve=False`` reads it with every ``${…}`` as written."""
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def parse_file(path: Path, *, env: Env = os.environ, resolve: bool = True) -> ConfigTree:
|
|
100
|
+
"""The file at ``path``; its includes are found next to it."""
|
|
101
|
+
return _parse(lib.hocon_parse_file, [_capi.c_string(os.fsencode(path))], env, resolve, None, os.fsdecode(path))
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def parse_string(content: str | bytes, *, basedir: Path | None = None, env: Env = os.environ, resolve: bool = True) -> ConfigTree:
|
|
105
|
+
"""HOCON text; an include in it is found in ``basedir``, and without
|
|
106
|
+
one is an error."""
|
|
107
|
+
data = content.encode("utf-8", "surrogatepass") if isinstance(content, str) else bytes(content)
|
|
108
|
+
return _parse(lib.hocon_parse_string, [data, len(data)], env, resolve, basedir, None)
|
zig_hocon/config_tree.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""A parsed document and the getters on it, as pyhocon's ``ConfigTree``."""
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from typing import Any, TypeVar, overload
|
|
9
|
+
|
|
10
|
+
from . import _capi
|
|
11
|
+
from ._capi import lib
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
ConfigException,
|
|
14
|
+
ConfigMissingException,
|
|
15
|
+
ConfigSubstitutionException,
|
|
16
|
+
ConfigWrongTypeException,
|
|
17
|
+
name_of,
|
|
18
|
+
placed,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = ["ConfigTree"]
|
|
22
|
+
|
|
23
|
+
_T = TypeVar("_T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Doc:
|
|
27
|
+
"""A parsed document, freed with the last ConfigTree that reads it.
|
|
28
|
+
|
|
29
|
+
hocon.h has one thread at a time on a document, and a call that fails
|
|
30
|
+
leaves why on it until the next one fails: a call and the reading of its
|
|
31
|
+
error hold ``lock``, so the trees of one document can be shared between
|
|
32
|
+
threads."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, handle: int) -> None:
|
|
35
|
+
self.handle = handle
|
|
36
|
+
self.lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
# The function is bound now: at interpreter exit the module's globals may
|
|
39
|
+
# be gone before this runs.
|
|
40
|
+
def __del__(self, _free: Callable[[int], None] = lib.hocon_free) -> None:
|
|
41
|
+
_free(self.handle)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _NoDefault:
|
|
45
|
+
"""What a getter's ``default`` is when none is given."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_NO_DEFAULT: Any = _NoDefault()
|
|
49
|
+
|
|
50
|
+
# HOCON's whitespace, which a path may have around it: Python's own but U+0085,
|
|
51
|
+
# and the BOM. Held to the library's by python/tests/test_capi.py.
|
|
52
|
+
BLANK = "\t\n\v\f\r\x1c\x1d\x1e\x1f \xa0
"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _path_bytes(full: str) -> bytes:
|
|
56
|
+
"""A path for C."""
|
|
57
|
+
return _capi.c_string(full.encode("utf-8", "surrogatepass"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _timedelta(ns: int) -> datetime.timedelta:
|
|
61
|
+
seconds, rest = divmod(ns, 1_000_000_000)
|
|
62
|
+
return datetime.timedelta(seconds=seconds, microseconds=rest / 1000)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConfigTree:
|
|
66
|
+
"""A document, or an object in one (``get_config``). A path is written as
|
|
67
|
+
in the file, ``db.host`` or ``a."b.c"``; ``""`` is this tree itself.
|
|
68
|
+
The values stay in the Zig library until a getter asks for one.
|
|
69
|
+
|
|
70
|
+
``default``, when given, is what a getter gives where there is nothing,
|
|
71
|
+
or ``null``. The trees of one document can be read from many threads."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, doc: Doc, prefix: str) -> None:
|
|
74
|
+
self._doc = doc
|
|
75
|
+
self._prefix = prefix
|
|
76
|
+
|
|
77
|
+
def _full(self, path: str) -> str:
|
|
78
|
+
"""``path`` from the root. Whitespace around a path is no part of it,
|
|
79
|
+
and joined to the prefix it would be inside a key."""
|
|
80
|
+
if not self._prefix:
|
|
81
|
+
return path
|
|
82
|
+
if not path:
|
|
83
|
+
return self._prefix
|
|
84
|
+
return f"{self._prefix.strip(BLANK)}.{path.strip(BLANK)}"
|
|
85
|
+
|
|
86
|
+
def _get(self, getter: Callable[..., int], path: str, outs: Sequence[Any], what: str, default: Any) -> tuple[bool, Any]:
|
|
87
|
+
"""Calls ``getter`` into ``outs``: (True, None) when it gives a value,
|
|
88
|
+
(False, default) when there is none and a default."""
|
|
89
|
+
full = self._full(path)
|
|
90
|
+
with self._doc.lock:
|
|
91
|
+
code = getter(self._doc.handle, _path_bytes(full), *[ctypes.byref(o) for o in outs])
|
|
92
|
+
error = self._error(code, full, what)
|
|
93
|
+
if error is None:
|
|
94
|
+
return True, None
|
|
95
|
+
# A path through a scalar or a list (`a.b` with `a = 5`) is the wrong
|
|
96
|
+
# type to a getter, WrongType, and has nothing at it: java's hasPath
|
|
97
|
+
# is false, pyhocon gives the default. A value that does not convert
|
|
98
|
+
# fails with an error of its own (TypeMismatch, InvalidCharacter…).
|
|
99
|
+
if default is not _NO_DEFAULT and (code == _capi.MISSING or error.name == "WrongType"):
|
|
100
|
+
return False, default
|
|
101
|
+
raise error
|
|
102
|
+
|
|
103
|
+
def _error(self, code: int, full: str, what: str) -> ConfigException | None:
|
|
104
|
+
"""The exception for ``code``, which a call on ``full`` just returned;
|
|
105
|
+
None for none. Read before any other call on the document, which
|
|
106
|
+
would say why it failed instead."""
|
|
107
|
+
if code == _capi.OK:
|
|
108
|
+
return None
|
|
109
|
+
err = lib.hocon_last_error(self._doc.handle).contents
|
|
110
|
+
name = name_of(err)
|
|
111
|
+
shown = full or "the document"
|
|
112
|
+
# A value that does not convert, or a `${…}` that waits, where it is
|
|
113
|
+
# written: `app.conf:3:8: InvalidCharacter at db.pool: expected i64`.
|
|
114
|
+
if err.line and code in (_capi.WRONG_TYPE, _capi.SUBSTITUTION):
|
|
115
|
+
said = f"{name} at {shown}" + (f": expected {err.expected.decode()}" if err.expected else "")
|
|
116
|
+
cls = ConfigWrongTypeException if code == _capi.WRONG_TYPE else ConfigSubstitutionException
|
|
117
|
+
return placed(cls, err, said)
|
|
118
|
+
if code == _capi.MISSING:
|
|
119
|
+
return ConfigMissingException(f"No configuration setting found for key {full}", name=name)
|
|
120
|
+
if code == _capi.WRONG_TYPE:
|
|
121
|
+
return ConfigWrongTypeException(f"{shown} is not {what}", name=name)
|
|
122
|
+
if code == _capi.SUBSTITUTION:
|
|
123
|
+
return ConfigSubstitutionException(
|
|
124
|
+
f"{shown} is not resolved: it was read with resolve=False or env=KEEP, and a ${{…}} is still in it; "
|
|
125
|
+
"to_hocon() of the whole document writes it as it is",
|
|
126
|
+
name=name,
|
|
127
|
+
)
|
|
128
|
+
if code == _capi.BAD_PATH:
|
|
129
|
+
return ConfigException(f"not a path: {full!r}", name=name)
|
|
130
|
+
if code == _capi.TOO_DEEP:
|
|
131
|
+
return ConfigException(f"{shown} is more than 256 levels deep, deeper than JSON is written", name=name)
|
|
132
|
+
# ponytail: no test runs the library out of memory, nor gives it a NULL.
|
|
133
|
+
return ConfigException(f"{shown}: failed (out of memory?)", name=name) # pragma: no cover
|
|
134
|
+
|
|
135
|
+
def _write(self, writer: Callable[..., int | None], what: str) -> bytes:
|
|
136
|
+
"""This tree as ``writer`` writes it."""
|
|
137
|
+
code = ctypes.c_int()
|
|
138
|
+
with self._doc.lock:
|
|
139
|
+
ptr = writer(self._doc.handle, _path_bytes(self._prefix), ctypes.byref(code))
|
|
140
|
+
error = self._error(code.value, self._prefix, what)
|
|
141
|
+
if error is not None:
|
|
142
|
+
raise error
|
|
143
|
+
assert ptr is not None # there whenever the code is OK
|
|
144
|
+
try:
|
|
145
|
+
return ctypes.string_at(ptr)
|
|
146
|
+
finally:
|
|
147
|
+
lib.hocon_free_string(ptr)
|
|
148
|
+
|
|
149
|
+
@overload
|
|
150
|
+
def get_string(self, path: str) -> str: ...
|
|
151
|
+
@overload
|
|
152
|
+
def get_string(self, path: str, default: _T) -> str | _T: ...
|
|
153
|
+
def get_string(self, path: str, default: Any = _NO_DEFAULT) -> Any:
|
|
154
|
+
"""Text, also of a number or a boolean as written (``1e3``, ``yes``)."""
|
|
155
|
+
ptr, size = ctypes.c_void_p(), ctypes.c_size_t()
|
|
156
|
+
found, value = self._get(lib.hocon_get_string, path, [ptr, size], "a string", default)
|
|
157
|
+
if not found:
|
|
158
|
+
return value
|
|
159
|
+
return ctypes.string_at(ptr, size.value).decode("utf-8", "surrogatepass") if size.value else ""
|
|
160
|
+
|
|
161
|
+
@overload
|
|
162
|
+
def get_int(self, path: str) -> int: ...
|
|
163
|
+
@overload
|
|
164
|
+
def get_int(self, path: str, default: _T) -> int | _T: ...
|
|
165
|
+
def get_int(self, path: str, default: Any = _NO_DEFAULT) -> Any:
|
|
166
|
+
"""A whole number, also from text (``"5"``); ``1.5`` is none."""
|
|
167
|
+
out = ctypes.c_int64()
|
|
168
|
+
found, value = self._get(lib.hocon_get_int, path, [out], "an int", default)
|
|
169
|
+
return out.value if found else value
|
|
170
|
+
|
|
171
|
+
@overload
|
|
172
|
+
def get_float(self, path: str) -> float: ...
|
|
173
|
+
@overload
|
|
174
|
+
def get_float(self, path: str, default: _T) -> float | _T: ...
|
|
175
|
+
def get_float(self, path: str, default: Any = _NO_DEFAULT) -> Any:
|
|
176
|
+
out = ctypes.c_double()
|
|
177
|
+
found, value = self._get(lib.hocon_get_float, path, [out], "a float", default)
|
|
178
|
+
return out.value if found else value
|
|
179
|
+
|
|
180
|
+
@overload
|
|
181
|
+
def get_bool(self, path: str) -> bool: ...
|
|
182
|
+
@overload
|
|
183
|
+
def get_bool(self, path: str, default: _T) -> bool | _T: ...
|
|
184
|
+
def get_bool(self, path: str, default: Any = _NO_DEFAULT) -> Any:
|
|
185
|
+
"""``true``/``yes``/``on`` and ``false``/``no``/``off``."""
|
|
186
|
+
out = ctypes.c_bool()
|
|
187
|
+
found, value = self._get(lib.hocon_get_bool, path, [out], "a bool", default)
|
|
188
|
+
return out.value if found else value
|
|
189
|
+
|
|
190
|
+
@overload
|
|
191
|
+
def get_duration(self, path: str) -> datetime.timedelta: ...
|
|
192
|
+
@overload
|
|
193
|
+
def get_duration(self, path: str, default: _T) -> datetime.timedelta | _T: ...
|
|
194
|
+
def get_duration(self, path: str, default: Any = _NO_DEFAULT) -> Any:
|
|
195
|
+
"""``30s``, ``2 minutes``; a number alone is milliseconds. A timedelta,
|
|
196
|
+
to the nearest microsecond."""
|
|
197
|
+
out = ctypes.c_int64()
|
|
198
|
+
found, value = self._get(lib.hocon_get_duration_ns, path, [out], "a duration", default)
|
|
199
|
+
return _timedelta(out.value) if found else value
|
|
200
|
+
|
|
201
|
+
@overload
|
|
202
|
+
def get_bytes(self, path: str) -> int: ...
|
|
203
|
+
@overload
|
|
204
|
+
def get_bytes(self, path: str, default: _T) -> int | _T: ...
|
|
205
|
+
def get_bytes(self, path: str, default: Any = _NO_DEFAULT) -> Any:
|
|
206
|
+
"""``512MB``, ``1.5 GiB``; a number alone is bytes."""
|
|
207
|
+
out = ctypes.c_uint64()
|
|
208
|
+
found, value = self._get(lib.hocon_get_bytes, path, [out], "a size in bytes", default)
|
|
209
|
+
return out.value if found else value
|
|
210
|
+
|
|
211
|
+
@overload
|
|
212
|
+
def get_period(self, path: str) -> dict[str, int]: ...
|
|
213
|
+
@overload
|
|
214
|
+
def get_period(self, path: str, default: _T) -> dict[str, int] | _T: ...
|
|
215
|
+
def get_period(self, path: str, default: Any = _NO_DEFAULT) -> Any:
|
|
216
|
+
"""``3mo``, ``2w``, ``1y``; a number alone is days. A dict of
|
|
217
|
+
``years``, ``months`` and ``days`` (weeks are days): months and years
|
|
218
|
+
have no fixed length, and the standard library has no calendar period."""
|
|
219
|
+
years, months, days = ctypes.c_int32(), ctypes.c_int32(), ctypes.c_int32()
|
|
220
|
+
found, value = self._get(lib.hocon_get_period, path, [years, months, days], "a period", default)
|
|
221
|
+
return {"years": years.value, "months": months.value, "days": days.value} if found else value
|
|
222
|
+
|
|
223
|
+
@overload
|
|
224
|
+
def get_config(self, path: str) -> "ConfigTree": ...
|
|
225
|
+
@overload
|
|
226
|
+
def get_config(self, path: str, default: _T) -> "ConfigTree | _T": ...
|
|
227
|
+
def get_config(self, path: str, default: Any = _NO_DEFAULT) -> Any:
|
|
228
|
+
"""The object at ``path``, as a ConfigTree of its own."""
|
|
229
|
+
found, value = self._get(lib.hocon_get_config, path, [], "an object", default)
|
|
230
|
+
return ConfigTree(self._doc, self._full(path)) if found else value
|
|
231
|
+
|
|
232
|
+
def to_json(self) -> bytes:
|
|
233
|
+
"""Compact JSON, as bytes: ``msgspec.json.decode`` takes them as they
|
|
234
|
+
are. Durations and sizes are the text they are written as, ``"30s"``."""
|
|
235
|
+
return self._write(lib.hocon_to_json, "JSON")
|
|
236
|
+
|
|
237
|
+
def as_dict(self) -> dict[str, Any]:
|
|
238
|
+
"""The tree as dicts, lists and scalars, by way of JSON."""
|
|
239
|
+
tree: dict[str, Any] = json.loads(self.to_json())
|
|
240
|
+
return tree
|
|
241
|
+
|
|
242
|
+
def to_hocon(self) -> str:
|
|
243
|
+
"""The tree as HOCON text. Read with ``env=KEEP`` that is everything
|
|
244
|
+
resolved but the variables, one file to deploy; with
|
|
245
|
+
``resolve=False`` it is the document as written. A subtree with a
|
|
246
|
+
``${…}`` still in it is a ConfigSubstitutionException: the ``${…}``
|
|
247
|
+
is the root's, and the subtree's text alone would read it wrong."""
|
|
248
|
+
return self._write(lib.hocon_to_hocon, "HOCON").decode("utf-8", "surrogatepass")
|
zig_hocon/exceptions.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""What the library refuses, as pyhocon names it (``pyhocon.exceptions``)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from ._capi import Error
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ConfigException",
|
|
10
|
+
"ConfigMissingException",
|
|
11
|
+
"ConfigParseException",
|
|
12
|
+
"ConfigSubstitutionException",
|
|
13
|
+
"ConfigWrongTypeException",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigException(Exception):
|
|
18
|
+
"""Anything the library refuses. ``name`` is the error's own name
|
|
19
|
+
(``Unresolved``, ``UnexpectedToken``); an error in a file also has
|
|
20
|
+
``file``, ``line``, ``column`` (1-based, in characters) and
|
|
21
|
+
``source_line``, and its message shows the line with a caret under the
|
|
22
|
+
column."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str,
|
|
27
|
+
name: str | None = None,
|
|
28
|
+
file: str | None = None,
|
|
29
|
+
line: int | None = None,
|
|
30
|
+
column: int | None = None,
|
|
31
|
+
source_line: str | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.name = name
|
|
35
|
+
self.file = file
|
|
36
|
+
self.line = line
|
|
37
|
+
self.column = column
|
|
38
|
+
self.source_line = source_line
|
|
39
|
+
|
|
40
|
+
# KeyError, a base of ConfigMissingException, would print the message quoted.
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return str(self.args[0])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConfigParseException(ConfigException):
|
|
46
|
+
"""The text is not valid HOCON, or an include failed."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConfigSubstitutionException(ConfigException):
|
|
50
|
+
"""A ``${…}`` with no value or in a cycle; or one that is still there,
|
|
51
|
+
read with ``resolve=False`` or ``env=KEEP``, where a value is needed."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConfigMissingException(ConfigException, KeyError):
|
|
55
|
+
"""Nothing at the path, or ``null`` there."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ConfigWrongTypeException(ConfigException, ValueError):
|
|
59
|
+
"""The value is there and is not what was asked for. A ValueError too, so
|
|
60
|
+
pydantic and msgspec report it as a validation error."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_E = TypeVar("_E", bound=ConfigException)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def name_of(err: Error) -> str:
|
|
67
|
+
"""The error's name; static in the library, there whenever it failed."""
|
|
68
|
+
return (err.name or b"").decode()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def placed(cls: type[_E], err: Error, said: str) -> _E:
|
|
72
|
+
"""``cls`` for an error with a place: `app.conf:7:12: <said>`, the line,
|
|
73
|
+
and a caret under the column."""
|
|
74
|
+
file = os.fsdecode(err.file or b"")
|
|
75
|
+
source_line = (err.source_line or b"").decode("utf-8", "replace")
|
|
76
|
+
# As the CLI prints it: a tab or a control character would move the text
|
|
77
|
+
# and leave the caret behind, so each is one space.
|
|
78
|
+
shown = "".join(" " if c < " " or c == "\x7f" else c for c in source_line)
|
|
79
|
+
where = f"{file}:" if file else ""
|
|
80
|
+
message = f"{where}{err.line}:{err.column}: {said}\n{shown}\n{' ' * (err.column - 1)}^"
|
|
81
|
+
return cls(message, name=name_of(err), file=file, line=err.line, column=err.column, source_line=source_line)
|
zig_hocon/hocon.dll
ADDED
|
Binary file
|
zig_hocon/units.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Durations and sizes in a program's own hands (a pydantic validator, a
|
|
2
|
+
msgspec dec_hook), by the rules of ``hocon.Duration`` and ``hocon.Size``."""
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from .config_parser import ConfigFactory
|
|
8
|
+
from .config_tree import ConfigTree
|
|
9
|
+
from .exceptions import ConfigException, ConfigWrongTypeException
|
|
10
|
+
|
|
11
|
+
__all__ = ["parse_bytes", "parse_duration"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _single(value: str | int | float, what: str) -> ConfigTree:
|
|
15
|
+
"""``value`` alone in a document, as ``v``, to be read by the getters."""
|
|
16
|
+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
|
17
|
+
raise TypeError(f"{what}: expected text or a number, not {type(value).__name__}")
|
|
18
|
+
return ConfigFactory.parse_string(f"v = {json.dumps(value)}", env=None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_duration(text: str | int | float) -> datetime.timedelta:
|
|
22
|
+
"""``"30s"``, ``"2 minutes"`` or a number of milliseconds as a timedelta,
|
|
23
|
+
by the rules of ``hocon.Duration``. ConfigWrongTypeException, a
|
|
24
|
+
ValueError, when it is none."""
|
|
25
|
+
try:
|
|
26
|
+
return _single(text, "parse_duration").get_duration("v")
|
|
27
|
+
except ConfigException:
|
|
28
|
+
raise ConfigWrongTypeException(f"not a duration: {text!r}", name="WrongType") from None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_bytes(text: str | int | float) -> int:
|
|
32
|
+
"""``"512MB"``, ``"1.5 GiB"`` or a number of bytes as an int, by the rules
|
|
33
|
+
of ``hocon.Size``. ConfigWrongTypeException, a ValueError, when it is none."""
|
|
34
|
+
try:
|
|
35
|
+
return _single(text, "parse_bytes").get_bytes("v")
|
|
36
|
+
except ConfigException:
|
|
37
|
+
raise ConfigWrongTypeException(f"not a size in bytes: {text!r}", name="WrongType") from None
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zig-hocon
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: HOCON configuration for Python: the whole spec, fast, with pyhocon's API
|
|
5
|
+
License-Expression: BSD-3-Clause
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Project-URL: Homepage, https://github.com/lejmr/zig-hocon
|
|
26
|
+
Project-URL: Source, https://github.com/lejmr/zig-hocon/tree/main/python
|
|
27
|
+
Project-URL: Issues, https://github.com/lejmr/zig-hocon/issues
|
|
28
|
+
Project-URL: Conformance, https://htmlpreview.github.io/?https://github.com/lejmr/zig-hocon/blob/main/conformance/report.html
|
|
29
|
+
Project-URL: Migrating, https://github.com/lejmr/zig-hocon/blob/main/python/MIGRATING.md
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<img src="https://raw.githubusercontent.com/lejmr/zig-hocon/main/docs/brand/zig-Hocon-badge-master-1650x500.png" width="600" alt="zig-hocon">
|
|
33
|
+
|
|
34
|
+
Read [HOCON](https://github.com/lightbend/config/blob/main/HOCON.md) config files in Python, to the spec. Fast, because the parser is written in Zig. Familiar, because the API is pyhocon's.
|
|
35
|
+
|
|
36
|
+
HOCON is JSON made for people: comments, no quotes needed, units like `30s` and `512MiB`, files that include other files, later settings that merge over earlier ones, and values taken from other keys or from environment variables. One base file, one small file per environment on top.
|
|
37
|
+
|
|
38
|
+
```hocon
|
|
39
|
+
# app.conf
|
|
40
|
+
db {
|
|
41
|
+
host = localhost
|
|
42
|
+
pool = 4
|
|
43
|
+
}
|
|
44
|
+
timeout = 30s
|
|
45
|
+
cache = 512MiB
|
|
46
|
+
retention = 3 months
|
|
47
|
+
debug = false
|
|
48
|
+
ratio = 0.75
|
|
49
|
+
include "prod.conf"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```hocon
|
|
53
|
+
# prod.conf
|
|
54
|
+
db.host = db.example.org
|
|
55
|
+
db.pool = ${?DB_POOL} # only when DB_POOL is set
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from zig_hocon import ConfigFactory
|
|
60
|
+
|
|
61
|
+
conf = ConfigFactory.parse_file("app.conf")
|
|
62
|
+
conf.get_string("db.host") # "db.example.org"
|
|
63
|
+
conf.get_int("db.pool") # 4, or $DB_POOL when it is set
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The rest of this page reads these two files.
|
|
67
|
+
|
|
68
|
+
## Why zig-hocon
|
|
69
|
+
|
|
70
|
+
- **Correct.** Passes 442 of the 445 tests in the [conformance suite](https://htmlpreview.github.io/?https://github.com/lejmr/zig-hocon/blob/main/conformance/report.html), one test for each sentence of the spec.\*
|
|
71
|
+
- **Fast.** Orders of magnitude faster than what Python usually has.\*\*
|
|
72
|
+
- **Clear errors.** File, line, column, and a caret under the mistake.
|
|
73
|
+
- **No dependencies.** A thin ctypes layer over one bundled library.
|
|
74
|
+
|
|
75
|
+
\* The other three are where the spec leaves room and implementations disagree.
|
|
76
|
+
\*\* A 6 KB config in 1 ms, a 10 MB one in 0.3 s, on an Apple M4 Pro.
|
|
77
|
+
|
|
78
|
+
## Install
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
pip install zig-hocon
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
One wheel per platform, for every Python from 3.10 on, free-threaded builds and PyPy included: macOS (arm64, x86_64), Linux (x86_64, aarch64; glibc 2.17+ and musl) and Windows (x86_64). The import name is `zig_hocon`. Anywhere else, build it from source (below).
|
|
85
|
+
|
|
86
|
+
Beta: the API can still change before 1.0. BSD-3-Clause.
|
|
87
|
+
|
|
88
|
+
## Use
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from zig_hocon import ConfigFactory, KEEP
|
|
92
|
+
|
|
93
|
+
conf = ConfigFactory.parse_file("app.conf") # DB_POOL from os.environ
|
|
94
|
+
conf = ConfigFactory.parse_file("app.conf", env={"DB_POOL": "8"}) # from a dict
|
|
95
|
+
conf = ConfigFactory.parse_file("app.conf", env=None) # no variables: pool stays 4
|
|
96
|
+
conf = ConfigFactory.parse_file("app.conf", env=KEEP) # ${?DB_POOL} kept, for later
|
|
97
|
+
conf = ConfigFactory.parse_file("app.conf", resolve=False) # every ${…} as written
|
|
98
|
+
conf = ConfigFactory.parse_string('include "app.conf"', basedir=".") # text, includes from basedir
|
|
99
|
+
|
|
100
|
+
conf.get_string("db.host") # "db.example.org"
|
|
101
|
+
conf.get_int("db.pool") # 4
|
|
102
|
+
conf.get_float("ratio") # 0.75
|
|
103
|
+
conf.get_bool("debug") # False: true/yes/on, false/no/off
|
|
104
|
+
conf.get_duration("timeout") # timedelta(seconds=30)
|
|
105
|
+
conf.get_bytes("cache") # 536870912: "512MB" counts by 1000, "512MiB" by 1024
|
|
106
|
+
conf.get_period("retention") # {"years": 0, "months": 3, "days": 0}: a timedelta has no months
|
|
107
|
+
conf.get_config("db") # the object at db, read the same way
|
|
108
|
+
conf.get_int("db.size", default=10) # 10: nothing at db.size
|
|
109
|
+
|
|
110
|
+
conf.as_dict() # plain dicts and lists
|
|
111
|
+
conf.to_json() # bytes, not str: for msgspec.json.decode
|
|
112
|
+
conf.to_hocon() # HOCON text
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A path is written as in the file: `db.host`, `a."b.c"`. A getter's default is its second argument, as in pyhocon; `parse_file` and `parse_string` take everything after the path by keyword. The package is typed (`py.typed`): `get_int("a")` is an `int`, `get_int("a", None)` an `int | None`. One document can be read from many threads at once.
|
|
116
|
+
|
|
117
|
+
**Errors** are `ConfigException`s, with `name`, `file`, `line`, `column` and `source_line`:
|
|
118
|
+
|
|
119
|
+
| exception | when |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `ConfigParseException` | the text is not valid HOCON, or an include fails |
|
|
122
|
+
| `ConfigSubstitutionException` | a `${…}` with no value, or a cycle |
|
|
123
|
+
| `ConfigMissingException` (also a `KeyError`) | nothing at the path, or `null` |
|
|
124
|
+
| `ConfigWrongTypeException` (also a `ValueError`) | the value is not what the getter wants |
|
|
125
|
+
|
|
126
|
+
**One file to deploy.** `env=KEEP` resolves includes, overrides and references between keys, and leaves the environment variables in; `to_hocon()` writes the result, to be read on the server with its own environment:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from pathlib import Path
|
|
130
|
+
|
|
131
|
+
Path("deploy.conf").write_text(ConfigFactory.parse_file("app.conf", env=KEEP).to_hocon())
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Durations and sizes** have helpers with the same rules as the getters: `zig_hocon.parse_duration("30s")` gives a `timedelta`, `zig_hocon.parse_bytes("512MiB")` an int.
|
|
135
|
+
|
|
136
|
+
**From the shell**, `python -m zig_hocon [--json] [--env=process|none|keep] app.conf` prints the file resolved.
|
|
137
|
+
|
|
138
|
+
## Coming from pyhocon
|
|
139
|
+
|
|
140
|
+
Change the import: `ConfigFactory`, the getters and the exception names are the same. What reads differently:
|
|
141
|
+
|
|
142
|
+
- **The tree is not a dict.** `conf["db"]["host"]` and `json.dumps(conf)` fail; take `conf.as_dict()` first. `as_plain_ordered_dict()` is `as_dict()`.
|
|
143
|
+
- **Fewer methods.** `get`, `get_list`, `with_fallback` and `put` are not there: a typed getter, an `include`, or the dict does the job.
|
|
144
|
+
- **Durations stay text.** `30s` is `"30s"` in `as_dict()` until a getter or a model reads it.
|
|
145
|
+
- **Strict syntax.** Input pyhocon quietly accepted, such as `a..b = 1` or `a = b:c`, is an error with its line and column.
|
|
146
|
+
- **No `include package(...)`.** It is pyhocon's own; include by path.
|
|
147
|
+
|
|
148
|
+
The [migration guide](https://github.com/lejmr/zig-hocon/blob/main/python/MIGRATING.md) has every difference, with what to write instead.
|
|
149
|
+
|
|
150
|
+
## Into a model
|
|
151
|
+
|
|
152
|
+
The package hands over a dict (`as_dict()`) or JSON bytes (`to_json()`); a model library does the checking. The same `app.conf` into the three usual ones.
|
|
153
|
+
|
|
154
|
+
**pydantic** reads the dict. A duration is text in the file, `"30s"`, so a `BeforeValidator` turns it into a `timedelta` first:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from datetime import timedelta
|
|
158
|
+
from typing import Annotated
|
|
159
|
+
|
|
160
|
+
import zig_hocon
|
|
161
|
+
from pydantic import BaseModel, BeforeValidator
|
|
162
|
+
|
|
163
|
+
Duration = Annotated[timedelta, BeforeValidator(zig_hocon.parse_duration)]
|
|
164
|
+
|
|
165
|
+
class Db(BaseModel):
|
|
166
|
+
host: str
|
|
167
|
+
pool: int
|
|
168
|
+
|
|
169
|
+
class Settings(BaseModel):
|
|
170
|
+
db: Db
|
|
171
|
+
timeout: Duration
|
|
172
|
+
debug: bool
|
|
173
|
+
|
|
174
|
+
conf = zig_hocon.ConfigFactory.parse_file("app.conf")
|
|
175
|
+
settings = Settings.model_validate(conf.as_dict())
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**msgspec** decodes the JSON bytes straight into a Struct. It reads a `timedelta` itself only from ISO 8601 (`"PT30S"`), so the field gets a type of its own that msgspec hands to `dec_hook`. An environment variable is always a string, so `strict=False` lets `DB_POOL=16` into the `int`:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from datetime import timedelta
|
|
182
|
+
|
|
183
|
+
import msgspec
|
|
184
|
+
import zig_hocon
|
|
185
|
+
|
|
186
|
+
class Duration(timedelta):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def dec_hook(type, value):
|
|
190
|
+
if type is Duration:
|
|
191
|
+
d = zig_hocon.parse_duration(value)
|
|
192
|
+
return Duration(d.days, d.seconds, d.microseconds)
|
|
193
|
+
raise NotImplementedError(type)
|
|
194
|
+
|
|
195
|
+
class Db(msgspec.Struct):
|
|
196
|
+
host: str
|
|
197
|
+
pool: int
|
|
198
|
+
|
|
199
|
+
class Settings(msgspec.Struct):
|
|
200
|
+
db: Db
|
|
201
|
+
timeout: Duration
|
|
202
|
+
debug: bool
|
|
203
|
+
|
|
204
|
+
conf = zig_hocon.ConfigFactory.parse_file("app.conf")
|
|
205
|
+
settings = msgspec.json.decode(conf.to_json(), type=Settings, dec_hook=dec_hook, strict=False)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**`@dataclass`** converts no types and does not build `Db` from a dict, so msgspec builds it, with the `Duration`, `dec_hook` and `conf` from above:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from dataclasses import dataclass
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class Db:
|
|
215
|
+
host: str
|
|
216
|
+
pool: int
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class Settings:
|
|
220
|
+
db: Db
|
|
221
|
+
timeout: Duration
|
|
222
|
+
debug: bool
|
|
223
|
+
|
|
224
|
+
settings = msgspec.json.decode(conf.to_json(), type=Settings, dec_hook=dec_hook, strict=False)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
All three give `settings.db.host == "db.example.org"` and `settings.timeout == timedelta(seconds=30)`, and fail with the model's own error, path included, on a value of the wrong type. A size works the same way through `zig_hocon.parse_bytes`. The [examples](https://github.com/lejmr/zig-hocon/tree/main/python/examples) have these as tests, with layered environments, errors and one-file deploys.
|
|
228
|
+
|
|
229
|
+
## Building from source
|
|
230
|
+
|
|
231
|
+
```sh
|
|
232
|
+
zig build python -Doptimize=ReleaseSafe # libhocon into python/zig_hocon/
|
|
233
|
+
pip install python/
|
|
234
|
+
zig build test-python # the package's tests, with uv
|
|
235
|
+
zig build wheels # a wheel for every platform, into zig-out/wheels/
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Needs Zig 0.16. Source, issues and the Zig and C libraries: [github.com/lejmr/zig-hocon](https://github.com/lejmr/zig-hocon). BSD-3-Clause.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
zig_hocon/__init__.py,sha256=btewURXKCpy_hsiiXtskMix-CoDla_XDga3wm_Dc_yg,1114
|
|
2
|
+
zig_hocon/__main__.py,sha256=M1Ptj0fPLhsXBEYY0SkR2RVH1PxpzLUJF85eeA6NWIg,1478
|
|
3
|
+
zig_hocon/_capi.py,sha256=0tM69Db9P0CKCs77qLFBoa87_MtIV7I7MehAQK6Ly1g,3564
|
|
4
|
+
zig_hocon/config_parser.py,sha256=EFn00rF0c1T67PSd3id4rbofR0sn3R8yOvFC7O6Refw,4616
|
|
5
|
+
zig_hocon/config_tree.py,sha256=tKIKvCMgWUhYEmiSBfOWRs5sU8xaWN9F0cS933loCZo,11157
|
|
6
|
+
zig_hocon/exceptions.py,sha256=vhCtHuH0b7hxdp58zpzuX7jFtIvF_D5wNOP8qQ3HMyU,2761
|
|
7
|
+
zig_hocon/units.py,sha256=5d_qq4nvFOEdwomv1CoGurcqNKS6DlsE184cuyCXR9Q,1613
|
|
8
|
+
zig_hocon/hocon.dll,sha256=4k-nehZvkItUaH2_5ZHUae7YKOAL-k7P6jiAPp3ZL7c,1480192
|
|
9
|
+
zig_hocon-0.4.0.dist-info/licenses/LICENSE,sha256=fsLifdlnCi9U8CLJk0WoTd9Neo6Jp6fCbdBJyySDQPM,1498
|
|
10
|
+
zig_hocon-0.4.0.dist-info/METADATA,sha256=7J8zIFm0U5-cj-XKMKXbHZT0WRzu5-NZMdlXBIIjmpM,10157
|
|
11
|
+
zig_hocon-0.4.0.dist-info/WHEEL,sha256=VKUr5WOkpMA6AjsxiecMAxPEFZveA2NmFD5VzmAYwLY,102
|
|
12
|
+
zig_hocon-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Milos Kozak
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|