Skip to content

Commit 1231f9e

Browse files
committed
feat(param files): Add support for UTF-16 and UTF-32 parameter files
1 parent 1207a62 commit 1231f9e

6 files changed

Lines changed: 284 additions & 72 deletions

File tree

ardupilot_methodic_configurator/annotate_params.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
PARAM_NAME_REGEX,
4444
ParamFileError,
4545
ParDict,
46+
read_param_file_lines,
4647
)
4748

4849
# URL of the XML file
@@ -461,8 +462,7 @@ def update_parameter_documentation(
461462
continue
462463

463464
# Read the entire file contents
464-
with open(param_file, encoding="utf-8") as file:
465-
lines = file.readlines()
465+
lines = list(read_param_file_lines(param_file))
466466

467467
update_parameter_documentation_file(
468468
doc, sort_type, param_default_dict, param_file, lines, delete_documentation_annotations

ardupilot_methodic_configurator/data_model_par_dict.py

Lines changed: 73 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
import logging
1313
import re
14-
from collections.abc import Callable
14+
from codecs import BOM_UTF16_BE, BOM_UTF16_LE, BOM_UTF32_BE, BOM_UTF32_LE
15+
from collections.abc import Callable, Iterator
16+
from io import TextIOWrapper
1517
from math import isfinite as math_isfinite
1618
from os import path as os_path
1719
from shutil import get_terminal_size
@@ -41,6 +43,47 @@ class ParamFileError(ValueError):
4143
"""Raised when a .param file contains invalid or malformed data."""
4244

4345

46+
def _has_zero_byte_pattern(sample: bytes, stride: int, zero_offsets: tuple[int, ...]) -> bool:
47+
"""Return whether the sample matches zero-byte offsets for a fixed-width encoding."""
48+
usable_length = len(sample) - len(sample) % stride
49+
return usable_length >= stride * 2 and all(
50+
sample[offset] == 0 for zero_offset in zero_offsets for offset in range(zero_offset, usable_length, stride)
51+
)
52+
53+
54+
def _detect_param_file_encoding(sample: bytes) -> str:
55+
"""Detect UTF-8/16/32 using a BOM or the zero-byte pattern of ASCII parameter text."""
56+
for byte_order_mark, encoding in (
57+
(BOM_UTF32_LE, "utf-32"),
58+
(BOM_UTF32_BE, "utf-32"),
59+
(BOM_UTF16_LE, "utf-16"),
60+
(BOM_UTF16_BE, "utf-16"),
61+
):
62+
if sample.startswith(byte_order_mark):
63+
return encoding
64+
65+
for encoding, stride, zero_offsets in (
66+
("utf-32-le", 4, (1, 2, 3)),
67+
("utf-32-be", 4, (0, 1, 2)),
68+
("utf-16-le", 2, (1,)),
69+
("utf-16-be", 2, (0,)),
70+
):
71+
if _has_zero_byte_pattern(sample, stride, zero_offsets):
72+
return encoding
73+
74+
return "utf-8-sig"
75+
76+
77+
def read_param_file_lines(param_file: str) -> Iterator[str]:
78+
"""Yield parameter-file lines from UTF-8/16/32 files, with or without BOMs."""
79+
with open(param_file, "rb") as binary_handle:
80+
sample = binary_handle.read(256)
81+
binary_handle.seek(0)
82+
encoding = _detect_param_file_encoding(sample)
83+
with TextIOWrapper(binary_handle, encoding=encoding) as text_handle:
84+
yield from text_handle
85+
86+
4487
def validate_param_name(param_name: str) -> tuple[bool, str]:
4588
"""
4689
Validate parameter name according to ArduPilot standards.
@@ -151,37 +194,36 @@ def load_param_file_into_dict(param_file: str) -> "ParDict":
151194
"""
152195
parameter_dict = ParDict()
153196
try:
154-
with open(param_file, encoding="utf-8-sig") as f_handle:
155-
for i, f_line in enumerate(f_handle, start=1):
156-
original_line = f_line
157-
line = f_line.strip()
158-
comment = None
159-
if not line:
160-
continue # skip empty lines
161-
if line[0] == "#":
162-
continue # skip comments
163-
if "#" in line:
164-
line, comment = line.split("#", 1) # strip trailing comments
165-
comment = comment.strip()
166-
if "," in line:
167-
# parse mission planner style parameter files
168-
parameter, value = line.split(",", 1)
169-
elif " " in line:
170-
# parse mavproxy style parameter files
171-
parameter, value = line.split(" ", 1)
172-
elif "\t" in line:
173-
parameter, value = line.split("\t", 1)
174-
else:
175-
msg = _("Missing parameter-value separator: {line} in {param_file} line {i}").format(
176-
line=line, param_file=param_file, i=i
177-
)
178-
raise ParamFileError(msg)
179-
# Strip whitespace from both parameter name and value immediately after splitting
180-
parameter = parameter.strip()
181-
value = value.strip()
182-
ParDict._validate_parameter(param_file, parameter_dict, i, original_line, comment, parameter, value)
197+
for i, f_line in enumerate(read_param_file_lines(param_file), start=1):
198+
original_line = f_line
199+
line = f_line.strip()
200+
comment = None
201+
if not line:
202+
continue # skip empty lines
203+
if line[0] == "#":
204+
continue # skip comments
205+
if "#" in line:
206+
line, comment = line.split("#", 1) # strip trailing comments
207+
comment = comment.strip()
208+
if "," in line:
209+
# parse mission planner style parameter files
210+
parameter, value = line.split(",", 1)
211+
elif " " in line:
212+
# parse mavproxy style parameter files
213+
parameter, value = line.split(" ", 1)
214+
elif "\t" in line:
215+
parameter, value = line.split("\t", 1)
216+
else:
217+
msg = _("Missing parameter-value separator: {line} in {param_file} line {i}").format(
218+
line=line, param_file=param_file, i=i
219+
)
220+
raise ParamFileError(msg)
221+
# Strip whitespace from both parameter name and value immediately after splitting
222+
parameter = parameter.strip()
223+
value = value.strip()
224+
ParDict._validate_parameter(param_file, parameter_dict, i, original_line, comment, parameter, value)
183225
except UnicodeDecodeError as exp:
184-
msg = _("Fatal error reading {param_file}, file must be UTF-8 encoded: {exp}").format(
226+
msg = _("Fatal error reading {param_file}, file must be UTF-8, UTF-16, or UTF-32 encoded: {exp}").format(
185227
param_file=param_file, exp=exp
186228
)
187229
raise ParamFileError(msg) from exp

ardupilot_methodic_configurator/param_pid_adjustment_update.py

Lines changed: 44 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
import argcomplete
2323
from argcomplete.completers import DirectoriesCompleter, FilesCompleter
2424

25-
from ardupilot_methodic_configurator.data_model_par_dict import PARAM_NAME_MAX_LEN, PARAM_NAME_REGEX, Par, ParDict
25+
from ardupilot_methodic_configurator.data_model_par_dict import (
26+
PARAM_NAME_MAX_LEN,
27+
PARAM_NAME_REGEX,
28+
Par,
29+
ParDict,
30+
read_param_file_lines,
31+
)
2632

2733
VERSION = "1.1"
2834

@@ -114,45 +120,44 @@ def load_param_file_with_content(param_file: str) -> tuple[ParDict, list[str]]:
114120
parameter_dict = ParDict()
115121
content = []
116122
try:
117-
with open(param_file, encoding="utf-8-sig") as f_handle:
118-
for n, f_line in enumerate(f_handle, start=1):
119-
line = f_line.strip()
120-
content.append(line)
121-
comment = None
122-
if not line or line.startswith("#"):
123-
continue
124-
if "#" in line:
125-
line, comment = line.split("#", 1)
126-
comment = comment.strip()
127-
if "," in line:
128-
parameter, value = line.split(",", 1)
129-
elif " " in line:
130-
parameter, value = line.split(" ", 1)
131-
elif "\t" in line:
132-
parameter, value = line.split("\t", 1)
133-
else:
134-
msg = f"Missing parameter-value separator: {line} in {param_file} line {n}"
135-
raise SystemExit(msg)
136-
# Strip whitespace from both parameter name and value immediately after splitting
137-
parameter = parameter.strip()
138-
value = value.strip()
139-
if len(parameter) > PARAM_NAME_MAX_LEN:
140-
msg = f"Too long parameter name: {parameter} in {param_file} line {n}"
141-
raise SystemExit(msg)
142-
if not re.match(PARAM_NAME_REGEX, parameter):
143-
msg = f"Invalid characters in parameter name {parameter} in {param_file} line {n}"
144-
raise SystemExit(msg)
145-
try:
146-
fvalue = float(value)
147-
except ValueError as exc:
148-
msg = f"Invalid parameter value {value} in {param_file} line {n}"
149-
raise SystemExit(msg) from exc
150-
if parameter in parameter_dict:
151-
msg = f"Duplicated parameter {parameter} in {param_file} line {n}"
152-
raise SystemExit(msg)
153-
parameter_dict[parameter] = Par(fvalue, comment)
123+
for n, f_line in enumerate(read_param_file_lines(param_file), start=1):
124+
line = f_line.strip()
125+
content.append(line)
126+
comment = None
127+
if not line or line.startswith("#"):
128+
continue
129+
if "#" in line:
130+
line, comment = line.split("#", 1)
131+
comment = comment.strip()
132+
if "," in line:
133+
parameter, value = line.split(",", 1)
134+
elif " " in line:
135+
parameter, value = line.split(" ", 1)
136+
elif "\t" in line:
137+
parameter, value = line.split("\t", 1)
138+
else:
139+
msg = f"Missing parameter-value separator: {line} in {param_file} line {n}"
140+
raise SystemExit(msg)
141+
# Strip whitespace from both parameter name and value immediately after splitting
142+
parameter = parameter.strip()
143+
value = value.strip()
144+
if len(parameter) > PARAM_NAME_MAX_LEN:
145+
msg = f"Too long parameter name: {parameter} in {param_file} line {n}"
146+
raise SystemExit(msg)
147+
if not re.match(PARAM_NAME_REGEX, parameter):
148+
msg = f"Invalid characters in parameter name {parameter} in {param_file} line {n}"
149+
raise SystemExit(msg)
150+
try:
151+
fvalue = float(value)
152+
except ValueError as exc:
153+
msg = f"Invalid parameter value {value} in {param_file} line {n}"
154+
raise SystemExit(msg) from exc
155+
if parameter in parameter_dict:
156+
msg = f"Duplicated parameter {parameter} in {param_file} line {n}"
157+
raise SystemExit(msg)
158+
parameter_dict[parameter] = Par(fvalue, comment)
154159
except UnicodeDecodeError as exp:
155-
msg = f"Fatal error reading {param_file}, file must be UTF-8 encoded: {exp}"
160+
msg = f"Fatal error reading {param_file}, file must be UTF-8, UTF-16, or UTF-32 encoded: {exp}"
156161
raise SystemExit(msg) from exp
157162
return parameter_dict, content
158163

tests/test_annotate_params.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,44 @@ def test_update_parameter_documentation(self) -> None:
347347
assert "Code1: Value1" in updated_content
348348
assert "Code2: Value2" in updated_content
349349

350+
def test_user_can_annotate_utf16_parameter_file(self) -> None:
351+
"""
352+
User can annotate a UTF-16 parameter file.
353+
354+
GIVEN: A valid UTF-16 parameter file containing a documented parameter
355+
WHEN: Parameter documentation is added
356+
THEN: The annotated output is written successfully as UTF-8 text
357+
"""
358+
with open(self.temp_file.name, "wb") as file:
359+
file.write("PARAM1,100\r\n".encode("utf-16"))
360+
361+
update_parameter_documentation(self.doc_dict, self.temp_file.name)
362+
363+
with open(self.temp_file.name, encoding="utf-8") as file:
364+
updated_content = file.read()
365+
366+
assert "# Param 1" in updated_content
367+
assert "PARAM1,100" in updated_content
368+
369+
def test_user_can_annotate_utf32_parameter_file(self) -> None:
370+
"""
371+
User can annotate a UTF-32 parameter file.
372+
373+
GIVEN: A valid UTF-32 parameter file containing a documented parameter
374+
WHEN: Parameter documentation is added
375+
THEN: The annotated output is written successfully as UTF-8 text
376+
"""
377+
with open(self.temp_file.name, "wb") as file:
378+
file.write("PARAM1,100\r\n".encode("utf-32"))
379+
380+
update_parameter_documentation(self.doc_dict, self.temp_file.name)
381+
382+
with open(self.temp_file.name, encoding="utf-8") as file:
383+
updated_content = file.read()
384+
385+
assert "# Param 1" in updated_content
386+
assert "PARAM1,100" in updated_content
387+
350388
def test_update_parameter_documentation_sorting_none(self) -> None:
351389
# Write some initial content to the temporary file
352390
# With stray leading and trailing whitespaces

0 commit comments

Comments
 (0)