Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

What changed for someone using zig-hocon: the Zig package, the `hocon` command line tool, libhocon for C and the `zig-hocon` Python package, which are released together under one version. The technical detail is in the commits each release links to.

## [Unreleased]

### Added

- Python: `zig_hocon.pyhocon`, pyhocon's API over zig-hocon, so moving over is a change of import. Its modules, names, arguments and output are pyhocon 0.3.63's: the tree an `OrderedDict` with `get`, `put`, `pop`, `with_fallback` and `get_list`, `ConfigFactory.parse_URL` and `from_dict`, `HOCONConverter`, and the command line as `python -m zig_hocon.pyhocon.tool`. `30s` is a `timedelta` (a `relativedelta` with python-dateutil) and `0024` the number 24, as in pyhocon. pyhocon's own syntax reads as in pyhocon: `x += [3, 4]` extends a list, `include "conf.d/*.conf"`, `include package(…)` and an include in a list work, and a syntax error is pyparsing's `ParseException` too when pyparsing is installed; the rest reads by the spec. pyhocon's own test suite passes 303 of 308 with the import swapped, and `python/MIGRATING.md` says what the rest are. Parts of it are changed from pyhocon's code and stay under its Apache License 2.0, so the package's license is `BSD-3-Clause AND Apache-2.0`, with both texts in the wheel.

### Fixed

- Python: the wheels have `py.typed`, which `pip install python/` had and the release wheels did not, so a type checker reads the package's types.

## [0.5.3] - 2026-10-26

### Changed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ b.step("check-config", "Check the config against Config").dependOn(&check.step);
| py.hocon <sub>hocon-parser 1.14.2 (Python)</sub> | 72.1% (398/552) | 70.1% (387/552) |
| pyhocon <sub>pyhocon 0.3.63</sub> | 63.8% (352/552) | 59.8% (330/552) |
| rs.hocon <sub>hocon-parser 1.14.2 (Rust)</sub> | 72.1% (398/552) | 69.6% (384/552) |
| zig-hocon <sub>zig-hocon code@bb9d8e4</sub> | 100% (552/552) | 83.0% (458/552) |
| zig-hocon <sub>zig-hocon code@c6118dd</sub> | 100% (552/552) | 83.0% (458/552) |

The table keeps the best parsers; the others measured, and why they are not in it: [conformance/README.md](conformance/README.md#not-in-the-table).

Expand Down Expand Up @@ -253,4 +253,4 @@ If you need a HOCON feature that is not there, open a pull request; a failing te

## License

BSD 3-Clause, see [LICENSE](LICENSE).
BSD 3-Clause, see [LICENSE](LICENSE). The Python module `zig_hocon.pyhocon` is changed in parts from [pyhocon](https://github.com/chimpler/pyhocon)'s code and those parts are under pyhocon's Apache License 2.0, in [python/zig_hocon/pyhocon/LICENSE](python/zig_hocon/pyhocon/LICENSE).
19 changes: 16 additions & 3 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,13 @@ pub fn build(b: *std.Build) void {
// Every line and branch of the package runs, or it fails (`pragma: no
// cover` for what no test can reach). `python -m zig_hocon` is left out:
// test_main.py runs it as a process, as the conformance adapter does,
// where coverage does not follow.
// where coverage does not follow. dateutil is there for
// zig_hocon.pyhocon's months and years, as pyhocon has them with it, and
// the newest pyhocon for test_pyhocon_compat.py and
// test_pyhocon_signatures.py to hold it against: its next release shows.
const python_version = b.option([]const u8, "python", "The Python test-python runs on (default 3.12)") orelse "3.12";
const coverage_file = b.pathFromRoot(".zig-cache/python.coverage");
const pytest = b.addSystemCommand(&.{ "uv", "run", "--no-project", "--python", python_version, "--with", "pytest", "--with", "coverage", "python", "-m", "coverage", "run", "--branch", "--source=python/zig_hocon", "--omit=python/zig_hocon/__main__.py", "-m", "pytest", "-q", "python/tests" });
const pytest = b.addSystemCommand(&.{ "uv", "run", "--no-project", "--python", python_version, "--with", "pytest", "--with", "coverage", "--with", "python-dateutil", "--with", "pyhocon", "python", "-m", "coverage", "run", "--branch", "--source=python/zig_hocon", "--omit=python/zig_hocon/__main__.py", "-m", "pytest", "-q", "python/tests" });
pytest.setCwd(b.path("."));
pytest.setEnvironmentVariable("PYTHONPATH", "python");
pytest.setEnvironmentVariable("COVERAGE_FILE", coverage_file);
Expand All @@ -100,9 +103,19 @@ pub fn build(b: *std.Build) void {
mypy.setEnvironmentVariable("PYTHONPATH", "python");
mypy.has_side_effects = true;
mypy.step.dependOn(&py_lib.step);
const test_python_step = b.step("test-python", "Run the Python package's tests with coverage, and type check (needs uv)");
// ruff and ty on the package, as python/pyproject.toml sets them; pinned,
// as a new ruff formats differently.
const ruff_check = b.addSystemCommand(&.{ "uv", "run", "--no-project", "--with", "ruff==0.17.1", "ruff", "check", "python" });
const ruff_format = b.addSystemCommand(&.{ "uv", "run", "--no-project", "--with", "ruff==0.17.1", "ruff", "format", "--check", "python" });
const ty = b.addSystemCommand(&.{ "uv", "run", "--no-project", "--with", "ty==0.0.90", "ty", "check", "--project", "python" });
const test_python_step = b.step("test-python", "Run the Python package's tests with coverage, lint and type check (needs uv)");
test_python_step.dependOn(&coverage.step);
test_python_step.dependOn(&mypy.step);
for ([_]*std.Build.Step.Run{ ruff_check, ruff_format, ty }) |run| {
run.setCwd(b.path("."));
run.has_side_effects = true;
test_python_step.dependOn(&run.step);
}

// `zig build wheels`: a wheel for every platform into zig-out/wheels/,
// each with libhocon cross-compiled for it, packed by tools/wheel.py.
Expand Down
4 changes: 2 additions & 2 deletions conformance/report.html

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions conformance/reports/zig-hocon.java.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"suite": "hocon-conformance",
"implementation": "zig-hocon",
"version": "zig-hocon code@bb9d8e4",
"version": "zig-hocon code@c6118dd",
"mode": "java",
"java_version": "1.4.9",
"command": "conformance/adapters/zig-hocon.sh",
"ran_at": "2026-10-26T07:32:00Z",
"ran_at": "2026-10-26T16:43:43Z",
"suite_digest": "a19487267d239165",
"total": 552,
"passed": 458,
Expand Down
4 changes: 2 additions & 2 deletions conformance/reports/zig-hocon.spec.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"suite": "hocon-conformance",
"implementation": "zig-hocon",
"version": "zig-hocon code@bb9d8e4",
"version": "zig-hocon code@c6118dd",
"mode": "spec",
"command": "conformance/adapters/zig-hocon.sh",
"ran_at": "2026-10-26T07:31:50Z",
"ran_at": "2026-10-26T16:43:30Z",
"suite_digest": "a19487267d239165",
"total": 552,
"passed": 552,
Expand Down
70 changes: 61 additions & 9 deletions python/MIGRATING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Migrating from pyhocon

This guide moves a project from pyhocon to zig_hocon. For most code it is a change of imports: `ConfigFactory`, the typed getters and the exception names are the same. Code that uses the tree as a dict needs a few lines more, and a config with `include package(...)` needs a change. Steps 1 to 3 cover all of it, and the checklist at the end finds every place to look at.
This guide moves a project from pyhocon to zig_hocon. There are two ways:

- **The short way: `zig_hocon.pyhocon`, a change of import.** pyhocon's API as pyhocon 0.3.63 has it: the same modules, names, arguments and output, the tree an `OrderedDict` you can `put` into and `pop` from. Its section says what it covers and what it does not.
- **The native way: `zig_hocon`, the package's own API.** Typed getters on a document that stays in the library until one asks, and no tree in Python at all, in three steps.

`zig_hocon` reads a config by the spec, which in a few places is not how pyhocon reads it: Step 3. `zig_hocon.pyhocon` reads pyhocon's own syntax as pyhocon does, and the rest by the spec. The checklist at the end finds every place to look at.

## Why move

Expand Down Expand Up @@ -31,7 +36,53 @@ This guide moves a project from pyhocon to zig_hocon. For most code it is a chan

- **One exception to catch.** Every error is a `ConfigException`, with the file, line and column. A syntax error in pyhocon is pyparsing's `ParseSyntaxException`, which `except ConfigException` misses.

## Step 1: imports
## The short way: zig_hocon.pyhocon

Replace `pyhocon` with `zig_hocon.pyhocon` in the imports and leave the rest:

```python
from zig_hocon.pyhocon import ConfigFactory, ConfigTree, HOCONConverter
from zig_hocon.pyhocon.exceptions import ConfigMissingException
```

Everything of pyhocon's public API is there: `ConfigFactory.parse_file` (with `encoding` and `required`), `parse_string`, `parse_URL`, `from_dict`; the `ConfigTree` with `get`, the typed getters, `get_list`, `get_config`, `put`, `pop`, `with_fallback`, `merge_configs`, `as_plain_ordered_dict`, `conf["db.host"]` and `conf.db.host`; `ConfigParser.parse`, `resolve_substitutions` and `resolve_package_path`; `HOCONConverter` with its JSON, HOCON, YAML and .properties output to the character; `period_parser` and `period_serializer`; and pyhocon's command line as `python -m zig_hocon.pyhocon.tool`. The exceptions are zig_hocon's, so one `except` catches either module's.

Two tests hold it against the newest pyhocon on every change: [test_pyhocon_signatures.py](tests/test_pyhocon_signatures.py) has every public name of pyhocon's modules, with the same parameters, and [test_pyhocon_compat.py](tests/test_pyhocon_compat.py) reads documents with both and compares the values, their types and the converter's output. Each difference by design is in them with its reason.

Values have pyhocon's types: `30s` is a `timedelta`, or with python-dateutil installed a `relativedelta`, as pyhocon makes it; months and years need dateutil there too. A number pyhocon reads where JSON has none is that number, as in pyhocon: `0024` is 24, `.54` and `-.5` are 0.54 and -0.5. One difference: quoted, `"30s"` is a duration as well and `"01234"` the number 1234, where pyhocon keeps them text; `"8080"` or `"0.5"` stays text.

So is its syntax, written the spec's way before the core reads the text: `x += v` is `x = ${?x} v`, as pyhocon's parser reads it, so `x += [3, 4]` extends a list, `x += def` goes on after the text with a space and `x += { b = 2 }` merges; in a nested object, where pyhocon loses what `x` was, it is kept. An include finds what pyhocon finds: a glob (`include "conf.d/*.conf"`), `package("pkg:file.conf")`, a file named without an extension as it is, and in a list the included list's elements. An included file with any of this syntax is read the same way, and still included as the spec has it. An unquoted `abc\"test` is `abc"test`. With pyparsing installed, as it is beside pyhocon, a syntax error is pyparsing's `ParseException` and `ParseSyntaxException` too.

The measure is pyhocon's own test suite with the import swapped: 303 of its 308 tests pass. Of the 5 that do not, 4 are a config that reads differently by the spec:

| tests | what pyhocon does |
|---|---|
| 2 | resolves a self-reference typesafe/config refuses too: `x = [${x}, 2]` and an object that refers into itself |
| 1 | rejects `a {} {b: 1}`; by the spec the two objects merge |
| 1 | reads `${?nope} 4` as the number 4; by the spec it is the text `" 4"` |

The one left is `ConfigTree.resolve()`, which is not there. Nor are a few things pyhocon has inside its parser:

- **A value with a `${…}` is no `ConfigValues`.** A document read with `resolve=False` has its values where it resolves on its own; where it does not, it has none until `with_fallback()` or `ConfigParser.resolve_substitutions()` gives it what it waits for, and reading it before is a `ConfigSubstitutionException`.
- **`unresolved_value`** takes `DEFAULT_SUBSTITUTION`, `MANDATORY_SUBSTITUTION` and `NO_SUBSTITUTION`; `STR_SUBSTITUTION` and a value of your own are a `NotImplementedError`.
- **`parse_URL`** reads a document with no `include` in it: one is an error, as it has no directory to be found in. pyhocon follows a `url(…)` include and leaves out the others with a warning.

Where the spec has the answer, it reads by the spec, also around pyhocon's syntax:

- **`+=` of a number, a boolean or `null`** appends it: `x += 2` after `x = [1]` is `[1, 2]`, and after `x = 5` an error. pyhocon puts the value in place of `x`.
- **An included file under an object** looks its `${…}` and `+=` up in that object first: `n { include "f.conf" }` with `z = ${y}` in it reads `n.y` before `y`. pyhocon reads `y` at the root.
- **`include "f"`** with no file named `f` reads `f.conf`, `f.json` or `f.properties`; pyhocon includes nothing. Values from a `.properties` file are text, where pyhocon reads numbers and booleans.
- **An object file included in a list** is one element; pyhocon gives its fields as `(key, value)` tuples.
- **An include of a URL that cannot be reached** is a `ConfigParseException`; pyhocon leaves it out with a warning. A glob includes its files in sorted order, pyhocon in the file system's.
- **A text the rewrite cannot follow** is read as written, by the spec, with every `+=` in it appending one element. It is a document libhocon reports as an error, or one with HOCON the scan does not know.

The whole document is in Python, as in pyhocon, so it is a little slower than `zig_hocon`, still far from pyhocon: a 5 KB config in 0.3 ms (pyhocon 29 ms), 10 MB in 0.4 s (pyhocon 68 s), against 0.12 ms and 0.13 s for `zig_hocon`. A document with `+=`, an include or a backslash is rewritten in Python first, which takes about four times as long.

## The native way: zig_hocon

The package's own API: the document stays in libhocon, and a typed getter or `as_dict()` takes what it needs from it. It is the fastest way, and the one that reads every config by the spec. Steps 1 and 2 change the code, Step 3 lists the configs that read differently.

### Step 1: imports

| pyhocon | zig_hocon |
|---|---|
Expand All @@ -46,7 +97,7 @@ This guide moves a project from pyhocon to zig_hocon. For most code it is a chan

The exception names are the same: `ConfigException`, `ConfigMissingException` (also a `KeyError`), `ConfigSubstitutionException`, `ConfigWrongTypeException` (also a `ValueError`), plus `ConfigParseException` for text that is not valid HOCON.

## Step 2: what needs code
### Step 2: what needs code

**The tree is not a dict.** pyhocon's `ConfigTree` is an `OrderedDict`, so `conf["db"]["host"]`, `"db" in conf`, `for key in conf`, `dict(conf)` and `json.dumps(conf)` work on it. Here the values stay in the library until a getter asks; take a dict where you need one:

Expand All @@ -71,9 +122,9 @@ settings["db"]["host"]

`parse_file` takes everything after the path by keyword, so `parse_file(path, "utf-8")` fails loudly instead of meaning something else.

## Step 3: configs that read differently
### Step 3: configs that read differently

- **`include package(...)` is not supported.** It is pyhocon's own extension, not HOCON, and zig_hocon reports it as an error (`UnexpectedToken` where `package` starts). Include the file by its path instead, relative to the file that includes it:
- **`include package(...)` is not supported** (in `zig_hocon.pyhocon` it is). It is pyhocon's own extension, not HOCON, and zig_hocon reports it as an error (`UnexpectedToken` where `package` starts). Include the file by its path instead, relative to the file that includes it:

```hocon
include "defaults.conf"
Expand All @@ -87,17 +138,18 @@ settings["db"]["host"]
conf = ConfigFactory.parse_file(str(files("mypackage") / "defaults.conf"))
```

- **Durations and sizes are text until something asks.** pyhocon turns `30s` into a `timedelta` while it parses; here `as_dict()` has `"30s"`, and `get_duration` or the model converts it. With pydantic, a `BeforeValidator(zig_hocon.parse_duration)` on the field; [examples/durations-and-sizes](examples/durations-and-sizes) has it for pydantic and msgspec.
- **Durations and sizes are text until something asks** (not in `zig_hocon.pyhocon`, which has pyhocon's `timedelta`). pyhocon turns `30s` into a `timedelta` while it parses; here `as_dict()` has `"30s"`, and `get_duration` or the model converts it. With pydantic, a `BeforeValidator(zig_hocon.parse_duration)` on the field; [examples/durations-and-sizes](examples/durations-and-sizes) has it for pydantic and msgspec.
- **A concatenation is a string.** `a = ${?nope}42` is `"42"` by the spec; pyhocon gives the number `42`. A model that wants an int converts it.
- **An `include url(…)` gives up after 30 s.** pyhocon waits as long as `socket.setdefaulttimeout` says, by default for ever. Here `http_timeout=` on `parse_file` and `parse_string` sets it, and a server that does not answer in time is a `ConfigParseException` named `IncludeTimeout`.
- **A leading zero keeps the text.** A value that begins with a digit begins with the number JSON reads there, and the rest is text, so `zip = 01234` is `"01234"` by the spec; pyhocon gives the number 1234. An IP address, a version or a UUID is text as written, as in pyhocon. So is a value that begins with `-` and no number, `-foo` or `-Xmx2g`, and a fraction JSON has no number for: `.5` and `-.5` are the text `".5"` and `"-.5"`, where pyhocon gives 0.5 and -0.5. Text becomes a number only by the JSON rules, so `get_int("zip")` on `01234` and `get_float` on `-.5` raise `ConfigWrongTypeException`, where pyhocon gives 1234 and -0.5: write `zip = "01234"` and read it with `get_string`, or `-0.5`.
- **A leading zero keeps the text** (not in `zig_hocon.pyhocon`, which reads such a number as pyhocon does). A value that begins with a digit begins with the number JSON reads there, and the rest is text, so `zip = 01234` is `"01234"` by the spec; pyhocon gives the number 1234. An IP address, a version or a UUID is text as written, as in pyhocon. So is a value that begins with `-` and no number, `-foo` or `-Xmx2g`, and a fraction JSON has no number for: `.5` and `-.5` are the text `".5"` and `"-.5"`, where pyhocon gives 0.5 and -0.5. Text becomes a number only by the JSON rules, so `get_int("zip")` on `01234` and `get_float` on `-.5` raise `ConfigWrongTypeException`, where pyhocon gives 1234 and -0.5: write `zip = "01234"` and read it with `get_string`, or `-0.5`.
- **`+=` appends one element** (not in `zig_hocon.pyhocon`, which has pyhocon's `+=`). `x += [3, 4]` after `x = [1, 2]` is `[1, 2, [3, 4]]`, as `x = ${?x} [[3, 4]]` is; pyhocon gives `[1, 2, 3, 4]`. To extend a list with another, `x = ${x} [3, 4]`.
- **Strict syntax.** The other inputs in the table under *No silent guesses* are errors now. They are rare, and each error says where to look.

Everything else (variables, `+=`, includes, merging) reads the same, except where pyhocon breaks the spec; the conformance report lists those cases.
Everything else (variables, includes, merging) reads the same, except where pyhocon breaks the spec; the conformance report lists those cases.

## Checklist

Run this over the code that uses pyhocon; each hit is a row in Step 2 or 3:
With `zig_hocon.pyhocon`, only the differences in its section apply. With `zig_hocon`, run this over the code that uses pyhocon; each hit is a row in Step 2 or 3:

```sh
grep -rnE "pyhocon|include package|as_plain_ordered_dict|get_list|with_fallback|from_dict|parse_URL|HOCONConverter|\.put\(|\.pop\(|conf\[" .
Expand Down
Loading
Loading