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 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)
@@ -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")
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: zig-hocon tools/wheel.py
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
@@ -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.