nmhit 0.3.1__tar.gz → 0.3.2__tar.gz
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.
- {nmhit-0.3.1 → nmhit-0.3.2}/CMakeLists.txt +1 -1
- {nmhit-0.3.1 → nmhit-0.3.2}/PKG-INFO +1 -1
- {nmhit-0.3.1 → nmhit-0.3.2}/generated/Lexer.cpp +1 -1
- {nmhit-0.3.1 → nmhit-0.3.2}/pyproject.toml +1 -1
- nmhit-0.3.2/python/tests/test_nmhit.py +277 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/src/Lexer.l +1 -1
- {nmhit-0.3.1 → nmhit-0.3.2}/src/Node.cpp +11 -1
- {nmhit-0.3.1 → nmhit-0.3.2}/tests/test_hit.cpp +58 -0
- nmhit-0.3.1/python/tests/test_nmhit.py +0 -678
- {nmhit-0.3.1 → nmhit-0.3.2}/.clang-format +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/.github/workflows/ci.yml +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/.github/workflows/release.yml +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/.gitignore +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/.pre-commit-config.yaml +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/.pre-commit-hooks.yaml +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/CONTRIBUTING.md +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/README.md +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/cmake/nmhit.pc.in +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/cmake/nmhitConfig.cmake.in +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/generated/Lexer.h +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/generated/Parser.cpp +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/generated/Parser.h +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/generated/location.hh +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/include/nmhit/BraceExpr.h +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/include/nmhit/Node.h +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/include/nmhit/TypeRegistry.h +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/include/nmhit/nmhit.h +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/python/nmhit/__init__.py +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/python/nmhit/_cli.py +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/python/nmhit/py.typed +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/python/src/_nmhit.cpp +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/src/BraceExpr.cpp +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/src/ParseDriver.h +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/src/Parser.y +0 -0
- {nmhit-0.3.1 → nmhit-0.3.2}/tests/CMakeLists.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
cmake_minimum_required(VERSION 3.20)
|
|
2
2
|
# Keep this version in sync with [project] version in pyproject.toml.
|
|
3
|
-
project(neml2-hit VERSION 0.3.
|
|
3
|
+
project(neml2-hit VERSION 0.3.2 LANGUAGES CXX)
|
|
4
4
|
|
|
5
5
|
set(CMAKE_CXX_STANDARD 17)
|
|
6
6
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
@@ -736,7 +736,7 @@ static const YY_CHAR yy_ec[256] =
|
|
|
736
736
|
|
|
737
737
|
23, 10, 10, 10, 24, 10, 10, 25, 10, 26,
|
|
738
738
|
27, 10, 10, 28, 10, 10, 29, 30, 10, 10,
|
|
739
|
-
10, 10, 31, 1, 32,
|
|
739
|
+
10, 10, 31, 1, 32, 10, 1, 1, 1, 1,
|
|
740
740
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
741
741
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
742
742
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nmhit"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2" # Keep in sync with VERSION in CMakeLists.txt.
|
|
8
8
|
description = "Python bindings for the nmhit NEML2-flavored HIT parser"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Pytest test suite for the nmhit Python bindings.
|
|
2
|
+
|
|
3
|
+
Scope is intentionally narrow: each test verifies that a Python-exposed
|
|
4
|
+
binding is reachable AND returns / accepts the right Python type. Parser
|
|
5
|
+
semantics (int / float / bool parsing, brace expansion, round-trip,
|
|
6
|
+
override-wins, error messages, ...) are tested in tests/test_hit.cpp;
|
|
7
|
+
duplicating those here just means two test suites have to be kept in
|
|
8
|
+
sync for the same feature.
|
|
9
|
+
|
|
10
|
+
What lives here:
|
|
11
|
+
|
|
12
|
+
* Class + free-function existence (``isinstance(root, nmhit.Root)``,
|
|
13
|
+
``nmhit.parse_text`` callable, ``nmhit.NodeType`` enum reachable).
|
|
14
|
+
* Type-erasing wrappers and helpers that exist *only* in Python
|
|
15
|
+
(``nmhit.param`` auto-dispatch, ``nmhit-format`` CLI).
|
|
16
|
+
* Python-side machinery the C++ tests can't reach: ``Path``/str
|
|
17
|
+
acceptance, kwarg names, ``__repr__``, ``Error`` subclassing
|
|
18
|
+
``RuntimeError``, child references surviving parent GC.
|
|
19
|
+
|
|
20
|
+
What does NOT live here: anything where the body would amount to "call
|
|
21
|
+
the binding, then re-test the C++ behaviour the call delegates to."
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import gc
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
|
|
28
|
+
import nmhit
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── module surface ────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_module_classes_exposed():
|
|
35
|
+
"""The C++ types we expose by name in __init__.pyi are real Python
|
|
36
|
+
attributes (not stubs the user can't actually construct against)."""
|
|
37
|
+
for name in (
|
|
38
|
+
"Root",
|
|
39
|
+
"Section",
|
|
40
|
+
"Field",
|
|
41
|
+
"Comment",
|
|
42
|
+
"Blank",
|
|
43
|
+
"NodeType",
|
|
44
|
+
"Error",
|
|
45
|
+
):
|
|
46
|
+
assert hasattr(nmhit, name), f"nmhit.{name} missing"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_error_subclasses_runtime_error():
|
|
50
|
+
"""nmhit.Error is bound as a RuntimeError subclass so generic
|
|
51
|
+
``except RuntimeError`` paths in user code catch it."""
|
|
52
|
+
assert issubclass(nmhit.Error, RuntimeError)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_node_type_enum_round_trip():
|
|
56
|
+
"""NodeType is bound as a proper enum -- each value is comparable to
|
|
57
|
+
itself across separate parses (rules out the stub-int-constant trap)."""
|
|
58
|
+
root_a = nmhit.parse_text("[s]\n k = 1\n[]")
|
|
59
|
+
root_b = nmhit.parse_text("[t]\n[]")
|
|
60
|
+
assert root_a.type() == nmhit.NodeType.Root == root_b.type()
|
|
61
|
+
assert root_a.find("s").type() == nmhit.NodeType.Section
|
|
62
|
+
assert root_a.find("s/k").type() == nmhit.NodeType.Field
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── parse entry points ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_parse_text_returns_root():
|
|
69
|
+
assert isinstance(nmhit.parse_text("k = 42"), nmhit.Root)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_parse_file_accepts_pathlike(tmp_path):
|
|
73
|
+
"""parse_file's path argument accepts both pathlib.Path and str."""
|
|
74
|
+
f = tmp_path / "test.i"
|
|
75
|
+
f.write_text("x = 7\n")
|
|
76
|
+
assert isinstance(nmhit.parse_file(f), nmhit.Root)
|
|
77
|
+
assert isinstance(nmhit.parse_file(str(f)), nmhit.Root)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_parse_text_pre_post_kwargs():
|
|
81
|
+
"""pre / post are keyword-argument bindings (positional ordering and
|
|
82
|
+
name spelled correctly on the Python side)."""
|
|
83
|
+
root = nmhit.parse_text("b = 2", pre=["a = 1"], post=["b := 99"])
|
|
84
|
+
assert root.param_int("a") == 1
|
|
85
|
+
assert root.param_int("b") == 99
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_parse_file_pre_kwarg(tmp_path):
|
|
89
|
+
f = tmp_path / "test.i"
|
|
90
|
+
f.write_text("b = 2\n")
|
|
91
|
+
root = nmhit.parse_file(f, pre=["a = 1"])
|
|
92
|
+
assert root.param_int("a") == 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── find / children ───────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_find_missing_returns_none():
|
|
99
|
+
"""A missing path returns Python None (not raises, not a NULL pointer
|
|
100
|
+
surfaced as a crash)."""
|
|
101
|
+
root = nmhit.parse_text("k = 1")
|
|
102
|
+
assert root.find("nonexistent") is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_children_filter_returns_correct_subclass():
|
|
106
|
+
"""``children(NodeType.X)`` returns instances of the right
|
|
107
|
+
concrete Python subclass (Field / Section / Comment / Blank)."""
|
|
108
|
+
src = "# comment\n\na = 1\n[s]\n[]\n"
|
|
109
|
+
root = nmhit.parse_text(src)
|
|
110
|
+
assert all(isinstance(n, nmhit.Comment) for n in root.children(nmhit.NodeType.Comment))
|
|
111
|
+
assert all(isinstance(n, nmhit.Blank) for n in root.children(nmhit.NodeType.Blank))
|
|
112
|
+
assert all(isinstance(n, nmhit.Field) for n in root.children(nmhit.NodeType.Field))
|
|
113
|
+
assert all(isinstance(n, nmhit.Section) for n in root.children(nmhit.NodeType.Section))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── keepalive / lifetime ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_child_ref_survives_root_drop():
|
|
120
|
+
"""A node grabbed off a Root must keep the Root alive (nanobind
|
|
121
|
+
keep_alive policy). Without this, GC would free the C++ tree out
|
|
122
|
+
from under the Python handle."""
|
|
123
|
+
node = nmhit.parse_text("[s]\n k = 99\n[]").find("s/k")
|
|
124
|
+
gc.collect()
|
|
125
|
+
assert node.param_int() == 99
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_children_element_survives_list_drop():
|
|
129
|
+
"""Same keepalive contract for elements returned from children()."""
|
|
130
|
+
root = nmhit.parse_text("a = 1\nb = 2")
|
|
131
|
+
kids = root.children()
|
|
132
|
+
kid = kids[0]
|
|
133
|
+
del kids
|
|
134
|
+
gc.collect()
|
|
135
|
+
assert kid.path() in ("a", "b")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── parent / root_node ────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_parent_returns_section():
|
|
142
|
+
"""parent() returns a Python-typed node (not the raw void* you'd get
|
|
143
|
+
if the binding skipped the type-dispatch shim)."""
|
|
144
|
+
root = nmhit.parse_text("[s]\n k = 1\n[]")
|
|
145
|
+
parent = root.find("s/k").parent()
|
|
146
|
+
assert isinstance(parent, nmhit.Section)
|
|
147
|
+
assert parent.path() == "s"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_root_node_returns_root():
|
|
151
|
+
root = nmhit.parse_text("[s]\n k = 1\n[]")
|
|
152
|
+
r = root.find("s/k").root_node()
|
|
153
|
+
assert isinstance(r, nmhit.Root)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── error forwarding ──────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_parse_error_is_nmhit_error():
|
|
160
|
+
"""Parse failures raise nmhit.Error (forwarded from C++ exception)."""
|
|
161
|
+
with pytest.raises(nmhit.Error):
|
|
162
|
+
nmhit.parse_text("[mesh]\n dim = 3")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_error_messages_attribute():
|
|
166
|
+
"""The Error instance carries a ``messages`` list of ErrorMessage
|
|
167
|
+
objects with line / column / message / filename attributes (struct
|
|
168
|
+
binding)."""
|
|
169
|
+
with pytest.raises(nmhit.Error) as exc_info:
|
|
170
|
+
nmhit.parse_text("[mesh]\n dim = 3")
|
|
171
|
+
msgs = exc_info.value.messages
|
|
172
|
+
assert isinstance(msgs, list)
|
|
173
|
+
if msgs:
|
|
174
|
+
m = msgs[0]
|
|
175
|
+
for attr in ("line", "column", "message", "filename"):
|
|
176
|
+
assert hasattr(m, attr), f"ErrorMessage.{attr} missing"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_parse_file_missing_raises():
|
|
180
|
+
"""Filesystem-not-found is forwarded as nmhit.Error, not as a bare
|
|
181
|
+
Python FileNotFoundError or a segfault."""
|
|
182
|
+
with pytest.raises(nmhit.Error):
|
|
183
|
+
nmhit.parse_file("/nonexistent/path/file.i")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── tree mutation bindings ────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_field_constructor_callable_from_python():
|
|
190
|
+
"""nmhit.Field(name, value) constructor exposed for tree building."""
|
|
191
|
+
f = nmhit.Field("k", "42")
|
|
192
|
+
assert isinstance(f, nmhit.Field)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_add_child_round_trip():
|
|
196
|
+
"""add_child / param_int are reachable bindings on Root."""
|
|
197
|
+
root = nmhit.parse_text("")
|
|
198
|
+
root.add_child(nmhit.Field("k", "42"))
|
|
199
|
+
assert root.param_int("k") == 42
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_remove_child_returns_typed_node():
|
|
203
|
+
"""remove_child returns the right Python-typed node (Field here)."""
|
|
204
|
+
root = nmhit.parse_text("k = 42")
|
|
205
|
+
removed = root.remove_child("k")
|
|
206
|
+
assert isinstance(removed, nmhit.Field)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── Comment binding ──────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_comment_inline_property():
|
|
213
|
+
"""Comment.is_inline / set_inline are exposed as a callable
|
|
214
|
+
getter+setter pair (not a Python property — match what the C++
|
|
215
|
+
side declares)."""
|
|
216
|
+
c = nmhit.Comment("hello", is_inline=True)
|
|
217
|
+
assert c.is_inline() is True
|
|
218
|
+
c.set_inline(False)
|
|
219
|
+
assert c.is_inline() is False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ── repr ─────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_node_repr_contains_class_name():
|
|
226
|
+
"""__repr__ bindings produce something human-readable that names
|
|
227
|
+
the concrete subclass."""
|
|
228
|
+
assert "Root" in repr(nmhit.parse_text(""))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ── nmhit.param: Python-only auto-dispatch helper ────────────────────────────
|
|
232
|
+
# Has no C++ counterpart -- the C++ API is one method per type.
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_param_auto_int():
|
|
236
|
+
root = nmhit.parse_text("k = 42")
|
|
237
|
+
val = nmhit.param(root, "k")
|
|
238
|
+
assert isinstance(val, int) and val == 42
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_param_auto_float():
|
|
242
|
+
root = nmhit.parse_text("x = 3.14")
|
|
243
|
+
val = nmhit.param(root, "x")
|
|
244
|
+
assert isinstance(val, float)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_param_auto_bool():
|
|
248
|
+
root = nmhit.parse_text("flag = true")
|
|
249
|
+
assert nmhit.param(root, "flag") is True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_param_auto_str():
|
|
253
|
+
root = nmhit.parse_text("name = hello")
|
|
254
|
+
val = nmhit.param(root, "name")
|
|
255
|
+
assert isinstance(val, str) and val == "hello"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_param_with_explicit_type_hint():
|
|
259
|
+
"""Passing an explicit type forces the converter -- exercises the
|
|
260
|
+
Python-side dispatch table."""
|
|
261
|
+
root = nmhit.parse_text("n = 5")
|
|
262
|
+
val = nmhit.param(root, "n", float)
|
|
263
|
+
assert isinstance(val, float) and val == 5.0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── verbatim → non-string read forwarding ─────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_verbatim_field_raises_on_non_str_getter():
|
|
270
|
+
"""Verbatim (triple-quoted) fields can only be read as strings; every
|
|
271
|
+
other ``param_*`` getter raises nmhit.Error. Pinned here because the
|
|
272
|
+
error path crosses the bindings boundary."""
|
|
273
|
+
root = nmhit.parse_text("k = '''42'''")
|
|
274
|
+
assert root.param_str("k") == "42"
|
|
275
|
+
for getter in ("param_int", "param_float", "param_bool", "param_list_int"):
|
|
276
|
+
with pytest.raises(nmhit.Error, match="verbatim"):
|
|
277
|
+
getattr(root, getter)("k")
|
|
@@ -441,7 +441,17 @@ Node::_raw_string(const Node * n)
|
|
|
441
441
|
{
|
|
442
442
|
auto * f = as_field(n);
|
|
443
443
|
if (f->is_verbatim())
|
|
444
|
-
|
|
444
|
+
{
|
|
445
|
+
// Verbatim (triple-quoted) bodies are stored without their surrounding
|
|
446
|
+
// ''' / """ delimiters, so no unquoting step is needed. Brace expansion
|
|
447
|
+
// is still applied for parity with single-quoted strings -- this lets
|
|
448
|
+
// users interpolate `${var}` into multi-line Python blocks (NEML2
|
|
449
|
+
// benchmark inputs rely on this for ${nbatch} substitution).
|
|
450
|
+
const auto & v = f->raw_val();
|
|
451
|
+
if (has_brace_expr(v))
|
|
452
|
+
return expand_brace_expr(v, n);
|
|
453
|
+
return v;
|
|
454
|
+
}
|
|
445
455
|
return parse_string(f->raw_val(), n);
|
|
446
456
|
}
|
|
447
457
|
|
|
@@ -861,6 +861,64 @@ main()
|
|
|
861
861
|
EXPECT(root->param<int>("Models/k") == 1);
|
|
862
862
|
});
|
|
863
863
|
|
|
864
|
+
// ── tilde in identifiers ──────────────────────────────────────────────────
|
|
865
|
+
// NEML2 uses ``var~1`` to denote "state at start of step" history variables;
|
|
866
|
+
// the parser has to accept ``~`` in field names + section path components.
|
|
867
|
+
|
|
868
|
+
run("ident_with_tilde_field_name", []() {
|
|
869
|
+
auto root = p("strain~1 = 42");
|
|
870
|
+
EXPECT(root->param<int>("strain~1") == 42);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
run("ident_with_tilde_field_inside_section", []() {
|
|
874
|
+
auto root = p("[Settings]\n elastic_strain~1 = '(2; 5)'\n[]");
|
|
875
|
+
auto * sec = root->find("Settings");
|
|
876
|
+
EXPECT(sec != nullptr);
|
|
877
|
+
EXPECT(sec->param<std::string>("elastic_strain~1") == "(2; 5)");
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
run("ident_with_tilde_section_path", []() {
|
|
881
|
+
auto root = p("[a~1]\n k = 7\n[]");
|
|
882
|
+
auto * sec = root->find("a~1");
|
|
883
|
+
EXPECT(sec != nullptr);
|
|
884
|
+
EXPECT(sec->param<int>("k") == 7);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// ── brace expansion in triple-quoted strings ─────────────────────────────
|
|
888
|
+
// Verbatim triple-quoted bodies now go through the same ${var} expansion
|
|
889
|
+
// pass as single-quoted strings -- the verbatim flag controls quote
|
|
890
|
+
// stripping + delimiter preservation, not interpolation.
|
|
891
|
+
|
|
892
|
+
run("triple_single_quoted_brace_expansion", []() {
|
|
893
|
+
auto root = p("n = 5\n[blk]\n body = '''\nuse ${n} here\n'''\n[]");
|
|
894
|
+
auto * blk = root->find("blk");
|
|
895
|
+
EXPECT(blk != nullptr);
|
|
896
|
+
EXPECT(blk->param<std::string>("body").find("use 5 here") != std::string::npos);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
run("triple_double_quoted_brace_expansion", []() {
|
|
900
|
+
auto root = p("n = 7\n[blk]\n body = \"\"\"\nuse ${n} here\n\"\"\"\n[]");
|
|
901
|
+
auto * blk = root->find("blk");
|
|
902
|
+
EXPECT(blk != nullptr);
|
|
903
|
+
EXPECT(blk->param<std::string>("body").find("use 7 here") != std::string::npos);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
run("triple_quoted_without_brace_is_byte_exact", []() {
|
|
907
|
+
// A body with no ${ must pass through untouched -- no spurious
|
|
908
|
+
// interpolation pass that could mangle Python code etc.
|
|
909
|
+
auto root = p("k = '''\nline one\nline two\n'''");
|
|
910
|
+
EXPECT(root->param<std::string>("k") == "\nline one\nline two\n");
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
run("triple_quoted_brace_expansion_preserves_newlines", []() {
|
|
914
|
+
// Multi-line body around a ${var} interpolation -- the surrounding
|
|
915
|
+
// newlines must survive the expansion pass.
|
|
916
|
+
auto root = p("n = 3\n[blk]\n body = '''\nbefore\n${n}\nafter\n'''\n[]");
|
|
917
|
+
auto * blk = root->find("blk");
|
|
918
|
+
EXPECT(blk != nullptr);
|
|
919
|
+
EXPECT(blk->param<std::string>("body") == "\nbefore\n3\nafter\n");
|
|
920
|
+
});
|
|
921
|
+
|
|
864
922
|
// ── Summary ───────────────────────────────────────────────────────────────
|
|
865
923
|
|
|
866
924
|
std::cerr << "\n=== Results: " << g_passed << " passed, " << g_failed << " failed ===\n";
|
|
@@ -1,678 +0,0 @@
|
|
|
1
|
-
"""Pytest test suite for the nmhit Python bindings."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
import nmhit
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
# ── parse_text / basic types ──────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
def test_parse_text_returns_root():
|
|
10
|
-
root = nmhit.parse_text("k = 42")
|
|
11
|
-
assert isinstance(root, nmhit.Root)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_int_field():
|
|
15
|
-
root = nmhit.parse_text("k = 42")
|
|
16
|
-
assert root.param_int("k") == 42
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_negative_int():
|
|
20
|
-
root = nmhit.parse_text("k = -7")
|
|
21
|
-
assert root.param_int("k") == -7
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_float_field():
|
|
25
|
-
root = nmhit.parse_text("x = 3.14")
|
|
26
|
-
assert abs(root.param_float("x") - 3.14) < 1e-9
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_scientific_notation():
|
|
30
|
-
root = nmhit.parse_text("v = 1.5e-3")
|
|
31
|
-
assert abs(root.param_float("v") - 1.5e-3) < 1e-12
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_bool_true():
|
|
35
|
-
root = nmhit.parse_text("flag = true")
|
|
36
|
-
assert root.param_bool("flag") is True
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_bool_false():
|
|
40
|
-
root = nmhit.parse_text("flag = false")
|
|
41
|
-
assert root.param_bool("flag") is False
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_bool_invalid():
|
|
45
|
-
root = nmhit.parse_text("a = yes")
|
|
46
|
-
with pytest.raises(nmhit.Error):
|
|
47
|
-
root.param_bool("a")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_str_field():
|
|
51
|
-
root = nmhit.parse_text("name = hello")
|
|
52
|
-
assert root.param_str("name") == "hello"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# ── sections ──────────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
def test_section_navigation():
|
|
58
|
-
root = nmhit.parse_text("[mesh]\n dim = 3\n[]")
|
|
59
|
-
assert root.param_int("mesh/dim") == 3
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_nested_sections():
|
|
63
|
-
root = nmhit.parse_text("[a]\n [b]\n k = 42\n []\n[]")
|
|
64
|
-
assert root.param_int("a/b/k") == 42
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# ── find / children ───────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
def test_find_returns_correct_type():
|
|
70
|
-
root = nmhit.parse_text("k = 1")
|
|
71
|
-
node = root.find("k")
|
|
72
|
-
assert isinstance(node, nmhit.Field)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_find_returns_none_for_missing():
|
|
76
|
-
root = nmhit.parse_text("k = 1")
|
|
77
|
-
assert root.find("nonexistent") is None
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_children_unfiltered():
|
|
81
|
-
root = nmhit.parse_text("a = 1\nb = 2")
|
|
82
|
-
kids = root.children()
|
|
83
|
-
assert len(kids) == 2
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_children_filtered_by_field():
|
|
87
|
-
root = nmhit.parse_text("# comment\nk = 1")
|
|
88
|
-
fields = root.children(nmhit.NodeType.Field)
|
|
89
|
-
assert len(fields) == 1
|
|
90
|
-
assert isinstance(fields[0], nmhit.Field)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def test_children_filtered_by_section():
|
|
94
|
-
root = nmhit.parse_text("[a]\n x = 1\n[]\n[b]\n y = 2\n[]")
|
|
95
|
-
secs = root.children(nmhit.NodeType.Section)
|
|
96
|
-
assert len(secs) == 2
|
|
97
|
-
assert all(isinstance(s, nmhit.Section) for s in secs)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# ── lifetime / keepalive ──────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
def test_child_ref_survives_root_drop():
|
|
103
|
-
# Dropping the root Python object must not invalidate child references.
|
|
104
|
-
node = nmhit.parse_text("[s]\n k = 99\n[]").find("s/k")
|
|
105
|
-
import gc; gc.collect()
|
|
106
|
-
assert node.param_int() == 99
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def test_children_element_survives_list_drop():
|
|
110
|
-
root = nmhit.parse_text("a = 1\nb = 2")
|
|
111
|
-
kids = root.children()
|
|
112
|
-
kid = kids[0]
|
|
113
|
-
del kids
|
|
114
|
-
import gc; gc.collect()
|
|
115
|
-
assert kid.path() in ("a", "b")
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# ── parent / root_node ────────────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
def test_parent_ref():
|
|
121
|
-
root = nmhit.parse_text("[s]\n k = 1\n[]")
|
|
122
|
-
sec = root.find("s")
|
|
123
|
-
field = sec.find("k")
|
|
124
|
-
assert field.parent() is not None
|
|
125
|
-
assert field.parent().path() == "s"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_root_node():
|
|
129
|
-
root = nmhit.parse_text("[s]\n k = 1\n[]")
|
|
130
|
-
field = root.find("s/k")
|
|
131
|
-
r = field.root_node()
|
|
132
|
-
assert isinstance(r, nmhit.Root)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
# ── param_optional ────────────────────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
def test_param_optional_present():
|
|
138
|
-
root = nmhit.parse_text("x = 5")
|
|
139
|
-
assert root.param_optional_int("x", 0) == 5
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def test_param_optional_absent():
|
|
143
|
-
root = nmhit.parse_text("x = 5")
|
|
144
|
-
assert root.param_optional_int("y", 99) == 99
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def test_param_optional_float():
|
|
148
|
-
root = nmhit.parse_text("x = 1.5")
|
|
149
|
-
assert abs(root.param_optional_float("x", 0.0) - 1.5) < 1e-9
|
|
150
|
-
assert root.param_optional_float("z", -1.0) == -1.0
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def test_param_optional_str():
|
|
154
|
-
root = nmhit.parse_text("name = foo")
|
|
155
|
-
assert root.param_optional_str("name", "default") == "foo"
|
|
156
|
-
assert root.param_optional_str("other", "default") == "default"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# ── arrays ────────────────────────────────────────────────────────────────────
|
|
160
|
-
|
|
161
|
-
def test_list_int():
|
|
162
|
-
root = nmhit.parse_text("vals = '1 2 3'")
|
|
163
|
-
assert root.param_list_int("vals") == [1, 2, 3]
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def test_list_float():
|
|
167
|
-
root = nmhit.parse_text("vals = '1.0 2.5 3.14'")
|
|
168
|
-
v = root.param_list_float("vals")
|
|
169
|
-
assert len(v) == 3
|
|
170
|
-
assert abs(v[1] - 2.5) < 1e-9
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def test_list_str():
|
|
174
|
-
root = nmhit.parse_text("tags = 'alpha beta gamma'")
|
|
175
|
-
assert root.param_list_str("tags") == ["alpha", "beta", "gamma"]
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def test_list_bool():
|
|
179
|
-
root = nmhit.parse_text("flags = 'true false true'")
|
|
180
|
-
assert root.param_list_bool("flags") == [True, False, True]
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def test_list_list_int():
|
|
184
|
-
root = nmhit.parse_text("vals = '1 2 3; 4 5 6'")
|
|
185
|
-
v = root.param_list_list_int("vals")
|
|
186
|
-
assert len(v) == 2
|
|
187
|
-
assert v[0] == [1, 2, 3]
|
|
188
|
-
assert v[1] == [4, 5, 6]
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def test_list_list_float():
|
|
192
|
-
root = nmhit.parse_text("m = '1.0 2.0; 3.0 4.0'")
|
|
193
|
-
v = root.param_list_list_float("m")
|
|
194
|
-
assert len(v) == 2
|
|
195
|
-
assert abs(v[0][0] - 1.0) < 1e-9
|
|
196
|
-
assert abs(v[1][1] - 4.0) < 1e-9
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def test_list_list_str():
|
|
200
|
-
root = nmhit.parse_text("tags = 'a b; c d'")
|
|
201
|
-
v = root.param_list_list_str("tags")
|
|
202
|
-
assert v == [["a", "b"], ["c", "d"]]
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
# ── render / clone ────────────────────────────────────────────────────────────
|
|
206
|
-
|
|
207
|
-
def test_render_round_trip():
|
|
208
|
-
original = "[mesh]\n dim = 3\n[]\n"
|
|
209
|
-
root = nmhit.parse_text(original)
|
|
210
|
-
rendered = root.render()
|
|
211
|
-
root2 = nmhit.parse_text(rendered)
|
|
212
|
-
assert root2.param_int("mesh/dim") == 3
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def test_clone():
|
|
216
|
-
root = nmhit.parse_text("k = 42")
|
|
217
|
-
root2 = root.clone()
|
|
218
|
-
assert root2.param_int("k") == 42
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def test_clone_independence():
|
|
222
|
-
root = nmhit.parse_text("k = 1")
|
|
223
|
-
root2 = root.clone()
|
|
224
|
-
root2.find("k").set_val("99")
|
|
225
|
-
assert root.param_int("k") == 1
|
|
226
|
-
assert root2.param_int("k") == 99
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
# ── Field mutation ────────────────────────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
def test_field_raw_val():
|
|
232
|
-
root = nmhit.parse_text("k = 42")
|
|
233
|
-
field = root.find("k")
|
|
234
|
-
assert isinstance(field, nmhit.Field)
|
|
235
|
-
assert field.raw_val() == "42"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def test_field_set_val():
|
|
239
|
-
root = nmhit.parse_text("k = 1")
|
|
240
|
-
root.find("k").set_val("99")
|
|
241
|
-
assert root.param_int("k") == 99
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
# ── add_child / insert_child / remove_child ───────────────────────────────────
|
|
245
|
-
|
|
246
|
-
def test_add_child():
|
|
247
|
-
# add_child clones the supplied node into the tree.
|
|
248
|
-
root = nmhit.parse_text("")
|
|
249
|
-
root.add_child(nmhit.Field("k", "42"))
|
|
250
|
-
assert root.param_int("k") == 42
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def test_insert_child():
|
|
254
|
-
root = nmhit.parse_text("b = 2")
|
|
255
|
-
root.insert_child(0, nmhit.Field("a", "1"))
|
|
256
|
-
kids = root.children(nmhit.NodeType.Field)
|
|
257
|
-
assert kids[0].path() == "a"
|
|
258
|
-
assert kids[1].path() == "b"
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def test_remove_child():
|
|
262
|
-
root = nmhit.parse_text("k = 42")
|
|
263
|
-
removed = root.remove_child("k") # pass path string
|
|
264
|
-
assert isinstance(removed, nmhit.Field)
|
|
265
|
-
assert root.find("k") is None
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def test_remove_child_nested():
|
|
269
|
-
root = nmhit.parse_text("[s]\n k = 42\n[]")
|
|
270
|
-
removed = root.remove_child("s/k")
|
|
271
|
-
assert isinstance(removed, nmhit.Field)
|
|
272
|
-
assert root.find("s/k") is None
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def test_remove_child_missing():
|
|
276
|
-
root = nmhit.parse_text("k = 42")
|
|
277
|
-
with pytest.raises(nmhit.Error):
|
|
278
|
-
root.remove_child("nonexistent")
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def test_remove_child_empty_relpath():
|
|
282
|
-
root = nmhit.parse_text("k = 42")
|
|
283
|
-
with pytest.raises(nmhit.Error):
|
|
284
|
-
root.remove_child("")
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
# ── error handling ────────────────────────────────────────────────────────────
|
|
288
|
-
|
|
289
|
-
def test_error_on_missing_section_close():
|
|
290
|
-
with pytest.raises(nmhit.Error):
|
|
291
|
-
nmhit.parse_text("[mesh]\n dim = 3")
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def test_error_has_messages():
|
|
295
|
-
with pytest.raises(nmhit.Error) as exc_info:
|
|
296
|
-
nmhit.parse_text("[mesh]\n dim = 3")
|
|
297
|
-
assert hasattr(exc_info.value, "messages")
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def test_error_is_runtime_error():
|
|
301
|
-
with pytest.raises(RuntimeError):
|
|
302
|
-
nmhit.parse_text("[mesh]\n dim = 3")
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def test_error_on_duplicate_field():
|
|
306
|
-
with pytest.raises(nmhit.Error):
|
|
307
|
-
nmhit.parse_text("k = 1\nk = 2")
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
# ── ErrorMessage ──────────────────────────────────────────────────────────────
|
|
311
|
-
|
|
312
|
-
def test_error_message_attributes():
|
|
313
|
-
with pytest.raises(nmhit.Error) as exc_info:
|
|
314
|
-
nmhit.parse_text("[mesh]\n dim = 3")
|
|
315
|
-
msgs = exc_info.value.messages
|
|
316
|
-
assert isinstance(msgs, list)
|
|
317
|
-
if msgs:
|
|
318
|
-
m = msgs[0]
|
|
319
|
-
assert hasattr(m, "line")
|
|
320
|
-
assert hasattr(m, "column")
|
|
321
|
-
assert hasattr(m, "message")
|
|
322
|
-
assert hasattr(m, "filename")
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
# ── source location ───────────────────────────────────────────────────────────
|
|
326
|
-
|
|
327
|
-
def test_line_numbers():
|
|
328
|
-
root = nmhit.parse_text("a = hello\nb = world\nc = 3")
|
|
329
|
-
assert root.find("a").line() == 1
|
|
330
|
-
assert root.find("b").line() == 2
|
|
331
|
-
assert root.find("c").line() == 3
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def test_fullpath():
|
|
335
|
-
root = nmhit.parse_text("[a]\n [b]\n k = 1\n []\n[]")
|
|
336
|
-
node = root.find("a/b/k")
|
|
337
|
-
assert node.fullpath() == "a/b/k"
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
def test_path():
|
|
341
|
-
root = nmhit.parse_text("[mesh]\n dim = 3\n[]")
|
|
342
|
-
sec = root.find("mesh")
|
|
343
|
-
assert sec.path() == "mesh"
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
# ── parse_file ────────────────────────────────────────────────────────────────
|
|
347
|
-
|
|
348
|
-
def test_parse_file(tmp_path):
|
|
349
|
-
f = tmp_path / "test.i"
|
|
350
|
-
f.write_text("x = 7\n")
|
|
351
|
-
root = nmhit.parse_file(f)
|
|
352
|
-
assert root.param_int("x") == 7
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def test_parse_file_str_path(tmp_path):
|
|
356
|
-
f = tmp_path / "test.i"
|
|
357
|
-
f.write_text("x = 7\n")
|
|
358
|
-
root = nmhit.parse_file(str(f))
|
|
359
|
-
assert root.param_int("x") == 7
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def test_parse_file_missing():
|
|
363
|
-
with pytest.raises(nmhit.Error):
|
|
364
|
-
nmhit.parse_file("/nonexistent/path/file.i")
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def test_parse_file_with_pre(tmp_path):
|
|
368
|
-
f = tmp_path / "test.i"
|
|
369
|
-
f.write_text("b = 2\n")
|
|
370
|
-
root = nmhit.parse_file(f, pre=["a = 1"])
|
|
371
|
-
assert root.param_int("a") == 1
|
|
372
|
-
assert root.param_int("b") == 2
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
# ── pre / post overrides ──────────────────────────────────────────────────────
|
|
376
|
-
|
|
377
|
-
def test_pre_snippets():
|
|
378
|
-
root = nmhit.parse_text("b = 2", pre=["a = 1"])
|
|
379
|
-
assert root.param_int("a") == 1
|
|
380
|
-
assert root.param_int("b") == 2
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
def test_post_override():
|
|
384
|
-
root = nmhit.parse_text("k = 1", post=["k := 99"])
|
|
385
|
-
assert root.param_int("k") == 99
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
# ── free scalar converters ────────────────────────────────────────────────────
|
|
389
|
-
|
|
390
|
-
def test_parse_bool():
|
|
391
|
-
assert nmhit.parse_bool("true") is True
|
|
392
|
-
assert nmhit.parse_bool("false") is False
|
|
393
|
-
assert nmhit.parse_bool("'true'") is True
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def test_parse_bool_invalid():
|
|
397
|
-
for bad in ("yes", "no", "on", "off", "True", "1", "0"):
|
|
398
|
-
with pytest.raises(nmhit.Error):
|
|
399
|
-
nmhit.parse_bool(bad)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def test_parse_int():
|
|
403
|
-
assert nmhit.parse_int("42") == 42
|
|
404
|
-
assert nmhit.parse_int("-7") == -7
|
|
405
|
-
assert nmhit.parse_int("'10'") == 10
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def test_parse_int_invalid():
|
|
409
|
-
with pytest.raises(nmhit.Error):
|
|
410
|
-
nmhit.parse_int("3.14")
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
def test_parse_double():
|
|
414
|
-
assert abs(nmhit.parse_double("3.14") - 3.14) < 1e-9
|
|
415
|
-
assert nmhit.parse_double("1e3") == 1000.0
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
def test_parse_float():
|
|
419
|
-
assert abs(nmhit.parse_float("1.5") - 1.5) < 1e-6
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
# ── nmhit.param() convenience wrapper ────────────────────────────────────────
|
|
423
|
-
|
|
424
|
-
def test_param_auto_int():
|
|
425
|
-
root = nmhit.parse_text("k = 42")
|
|
426
|
-
assert nmhit.param(root, "k") == 42
|
|
427
|
-
assert isinstance(nmhit.param(root, "k"), int)
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def test_param_auto_float():
|
|
431
|
-
root = nmhit.parse_text("x = 3.14")
|
|
432
|
-
val = nmhit.param(root, "x")
|
|
433
|
-
assert isinstance(val, float)
|
|
434
|
-
assert abs(val - 3.14) < 1e-9
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
def test_param_auto_bool():
|
|
438
|
-
root = nmhit.parse_text("flag = true")
|
|
439
|
-
assert nmhit.param(root, "flag") is True
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def test_param_auto_str():
|
|
443
|
-
root = nmhit.parse_text("name = hello")
|
|
444
|
-
assert nmhit.param(root, "name") == "hello"
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def test_param_with_type_hint_float():
|
|
448
|
-
root = nmhit.parse_text("n = 5")
|
|
449
|
-
assert nmhit.param(root, "n", float) == 5.0
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
def test_param_with_type_hint_str():
|
|
453
|
-
root = nmhit.parse_text("n = 42")
|
|
454
|
-
assert nmhit.param(root, "n", str) == "42"
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
# ── Comment / Blank node types ────────────────────────────────────────────────
|
|
458
|
-
|
|
459
|
-
def test_comment_preserved():
|
|
460
|
-
root = nmhit.parse_text("# top comment\nk = 1")
|
|
461
|
-
comments = root.children(nmhit.NodeType.Comment)
|
|
462
|
-
assert len(comments) == 1
|
|
463
|
-
assert isinstance(comments[0], nmhit.Comment)
|
|
464
|
-
assert "top comment" in comments[0].text()
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
def test_blank_preserved():
|
|
468
|
-
root = nmhit.parse_text("a = 1\n\nb = 2")
|
|
469
|
-
blanks = root.children(nmhit.NodeType.Blank)
|
|
470
|
-
assert len(blanks) >= 1
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def test_comment_inline():
|
|
474
|
-
c = nmhit.Comment("hello", is_inline=True)
|
|
475
|
-
assert c.is_inline() is True
|
|
476
|
-
c.set_inline(False)
|
|
477
|
-
assert c.is_inline() is False
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
# ── node identity and type ────────────────────────────────────────────────────
|
|
481
|
-
|
|
482
|
-
def test_node_type_enum():
|
|
483
|
-
root = nmhit.parse_text("[s]\n k = 1\n[]")
|
|
484
|
-
assert root.type() == nmhit.NodeType.Root
|
|
485
|
-
assert root.find("s").type() == nmhit.NodeType.Section
|
|
486
|
-
assert root.find("s/k").type() == nmhit.NodeType.Field
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
def test_node_repr():
|
|
490
|
-
root = nmhit.parse_text("")
|
|
491
|
-
assert "Root" in repr(root)
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
# ── override assignment ───────────────────────────────────────────────────────
|
|
495
|
-
|
|
496
|
-
def test_override_assign():
|
|
497
|
-
root = nmhit.parse_text("k = 1\nk := 99")
|
|
498
|
-
assert root.param_int("k") == 99
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
# ── triple-quoted strings ─────────────────────────────────────────────────────
|
|
502
|
-
|
|
503
|
-
def test_triple_single_quote_basic():
|
|
504
|
-
"""'''value''' is parsed verbatim and returned by param_str."""
|
|
505
|
-
root = nmhit.parse_text("k = '''hello world'''")
|
|
506
|
-
assert root.param_str("k") == "hello world"
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
def test_triple_double_quote_basic():
|
|
510
|
-
"""\"\"\"value\"\"\" is parsed verbatim and returned by param_str."""
|
|
511
|
-
root = nmhit.parse_text('k = """hello world"""')
|
|
512
|
-
assert root.param_str("k") == "hello world"
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def test_triple_single_quote_multiline():
|
|
516
|
-
"""Triple single-quoted strings preserve internal newlines verbatim."""
|
|
517
|
-
root = nmhit.parse_text("k = '''\n line1\n line2\n'''")
|
|
518
|
-
val = root.param_str("k")
|
|
519
|
-
assert "line1" in val
|
|
520
|
-
assert "line2" in val
|
|
521
|
-
assert "\n" in val
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
def test_triple_double_quote_multiline():
|
|
525
|
-
"""Triple double-quoted strings preserve internal newlines verbatim."""
|
|
526
|
-
root = nmhit.parse_text('k = """\n line1\n line2\n"""')
|
|
527
|
-
val = root.param_str("k")
|
|
528
|
-
assert "line1" in val
|
|
529
|
-
assert "line2" in val
|
|
530
|
-
assert "\n" in val
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
def test_triple_single_quote_contains_double_quotes():
|
|
534
|
-
"""Triple single-quoted strings may contain double-quote characters."""
|
|
535
|
-
root = nmhit.parse_text("""k = '''say "hello"'''""")
|
|
536
|
-
assert root.param_str("k") == 'say "hello"'
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
def test_triple_double_quote_contains_single_quotes():
|
|
540
|
-
"""Triple double-quoted strings may contain single-quote characters."""
|
|
541
|
-
root = nmhit.parse_text("""k = \"\"\"it's fine\"\"\"""")
|
|
542
|
-
assert root.param_str("k") == "it's fine"
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def test_triple_single_quote_contains_single_quote():
|
|
546
|
-
"""A single embedded apostrophe inside triple single-quoted string is allowed."""
|
|
547
|
-
root = nmhit.parse_text("k = '''it's verbatim'''")
|
|
548
|
-
assert root.param_str("k") == "it's verbatim"
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
def test_triple_double_quote_contains_double_quote():
|
|
552
|
-
"""A single embedded double-quote inside triple double-quoted string is allowed."""
|
|
553
|
-
root = nmhit.parse_text('k = """say "hi" """')
|
|
554
|
-
assert '"hi"' in root.param_str("k")
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
def test_triple_single_quote_both_quote_types():
|
|
558
|
-
"""Triple single-quoted strings may contain both quote types."""
|
|
559
|
-
root = nmhit.parse_text("""k = '''it's "great"'''""")
|
|
560
|
-
val = root.param_str("k")
|
|
561
|
-
assert "it's" in val
|
|
562
|
-
assert '"great"' in val
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
def test_triple_double_quote_both_quote_types():
|
|
566
|
-
"""Triple double-quoted strings may contain both quote types."""
|
|
567
|
-
# """...""" delimiter allows single ' freely; single " is also fine mid-string.
|
|
568
|
-
root = nmhit.parse_text('k = """it\'s "great" """')
|
|
569
|
-
val = root.param_str("k")
|
|
570
|
-
assert "it's" in val
|
|
571
|
-
assert '"great"' in val
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
def test_triple_single_quote_in_section():
|
|
575
|
-
"""Triple-quoted strings work inside sections."""
|
|
576
|
-
root = nmhit.parse_text("[s]\n code = '''\n result = 1\n '''\n[]")
|
|
577
|
-
val = root.param_str("s/code")
|
|
578
|
-
assert "result" in val
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
def test_triple_single_quote_multiline_indented():
|
|
582
|
-
"""Indented multi-line triple-quoted content is preserved verbatim (no stripping)."""
|
|
583
|
-
root = nmhit.parse_text("k = '''\n x = 1\n y = 2\n '''")
|
|
584
|
-
val = root.param_str("k")
|
|
585
|
-
assert "x = 1" in val
|
|
586
|
-
assert "y = 2" in val
|
|
587
|
-
# Leading whitespace of interior lines is preserved
|
|
588
|
-
assert " x" in val or " x" in val # some indentation present
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
def test_triple_quote_empty():
|
|
592
|
-
"""An empty triple-quoted string returns an empty string."""
|
|
593
|
-
root = nmhit.parse_text("k = ''''''")
|
|
594
|
-
assert root.param_str("k") == ""
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
def test_triple_quote_python_expression():
|
|
598
|
-
"""Typical use case: Python code expression stored verbatim."""
|
|
599
|
-
code = "import torch\nresult = torch.tensor([1.0, 2.0, 3.0])"
|
|
600
|
-
hit = f"k = '''\n{code}\n'''"
|
|
601
|
-
root = nmhit.parse_text(hit)
|
|
602
|
-
val = root.param_str("k")
|
|
603
|
-
assert "import torch" in val
|
|
604
|
-
assert "torch.tensor" in val
|
|
605
|
-
assert "\n" in val
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
def test_triple_quote_only_readable_as_string():
|
|
609
|
-
"""Verbatim (triple-quoted) fields raise when read as non-string types."""
|
|
610
|
-
root = nmhit.parse_text("k = '''42'''")
|
|
611
|
-
# param_str must succeed
|
|
612
|
-
assert root.param_str("k") == "42"
|
|
613
|
-
# All non-string interpretations must raise
|
|
614
|
-
with pytest.raises(nmhit.Error, match="verbatim"):
|
|
615
|
-
root.param_int("k")
|
|
616
|
-
with pytest.raises(nmhit.Error, match="verbatim"):
|
|
617
|
-
root.param_float("k")
|
|
618
|
-
with pytest.raises(nmhit.Error, match="verbatim"):
|
|
619
|
-
root.param_bool("k")
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def test_triple_quote_not_readable_as_list():
|
|
623
|
-
"""Verbatim fields also raise when read as list types."""
|
|
624
|
-
root = nmhit.parse_text("k = '''1 2 3'''")
|
|
625
|
-
assert root.param_str("k") == "1 2 3"
|
|
626
|
-
with pytest.raises(nmhit.Error, match="verbatim"):
|
|
627
|
-
root.param_list_int("k")
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
# ── triple-quoted round-trip rendering ────────────────────────────────────────
|
|
631
|
-
# Round-trip = parse_text(s).render() == s, byte-for-byte. The formatter
|
|
632
|
-
# (nmhit-format) walks this exact pipeline, so any divergence here is a
|
|
633
|
-
# direct formatter-breaking bug.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
def test_triple_single_quote_roundtrip_inline():
|
|
637
|
-
src = "k = '''hello world'''\n"
|
|
638
|
-
assert nmhit.parse_text(src).render() == src
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
def test_triple_double_quote_roundtrip_inline():
|
|
642
|
-
src = 'k = """hello world"""\n'
|
|
643
|
-
assert nmhit.parse_text(src).render() == src
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
def test_triple_single_quote_roundtrip_multiline():
|
|
647
|
-
src = "k = '''\n line1\n line2\n'''\n"
|
|
648
|
-
assert nmhit.parse_text(src).render() == src
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
def test_triple_double_quote_roundtrip_multiline():
|
|
652
|
-
src = 'k = """\n line1\n line2\n"""\n'
|
|
653
|
-
assert nmhit.parse_text(src).render() == src
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
def test_triple_quote_roundtrip_python_expression():
|
|
657
|
-
src = (
|
|
658
|
-
"[Tensors]\n"
|
|
659
|
-
" [strain]\n"
|
|
660
|
-
" type = Python\n"
|
|
661
|
-
" expr = '''\n"
|
|
662
|
-
"torch.stack([\n"
|
|
663
|
-
" torch.linspace(0, 1, 5),\n"
|
|
664
|
-
" torch.linspace(1, 2, 5),\n"
|
|
665
|
-
"])\n"
|
|
666
|
-
"'''\n"
|
|
667
|
-
" []\n"
|
|
668
|
-
"[]\n"
|
|
669
|
-
)
|
|
670
|
-
assert nmhit.parse_text(src).render() == src
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
def test_triple_quote_roundtrip_preserves_delimiter_style():
|
|
674
|
-
"""The renderer must wrap the body in the same delimiter the input used."""
|
|
675
|
-
src_sq = "k = '''\nbody\n'''\n"
|
|
676
|
-
src_dq = 'k = """\nbody\n"""\n'
|
|
677
|
-
assert nmhit.parse_text(src_sq).render() == src_sq
|
|
678
|
-
assert nmhit.parse_text(src_dq).render() == src_dq
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|