Skip to content
This repository was archived by the owner on Dec 26, 2025. It is now read-only.

Commit 3b4f39f

Browse files
committed
Warn instead of exception per default if strings contains non ASCII chars in dump/s/i
Added doc
1 parent f639982 commit 3b4f39f

2 files changed

Lines changed: 106 additions & 62 deletions

File tree

src/adif_file/adi.py

Lines changed: 76 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55

66
import re
77
import copy
8+
from warnings import warn
89
from collections.abc import Iterator
910

1011
from . import __version_str__, __proj_name__
11-
from .util import get_cur_adif_dt
12+
from .util import get_cur_adif_dt, replace_non_ascii
1213

1314

1415
class TooMuchHeadersException(Exception):
@@ -31,6 +32,10 @@ class IllegalDataTypeException(Exception):
3132
pass
3233

3334

35+
class NonASCIIWarning(Warning):
36+
pass
37+
38+
3439
REGEX_ASCII = re.compile(r'[ -~\n\r]*')
3540
REGEX_PARAM = re.compile(r'[a-zA-Z][a-zA-Z_0-9]*')
3641

@@ -40,7 +45,8 @@ def unpack(data: str, strip_tags: bool = True) -> dict[str, str]:
4045
The parameters are converted to uppercase
4146
:param data: string with multiple ADIF tag and value for a whole record
4247
:param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
43-
:return: dictionary of ADIF tag and value"""
48+
:return: dictionary of ADIF tag and value
49+
:raises TagDefinitionException: if the tag definition is invalid or the length is not an integer"""
4450

4551
unpacked = {}
4652

@@ -91,6 +97,8 @@ def loadi(adi: str, skip: int = 0, strip_tags: bool = True) -> Iterator[dict[str
9197
:param skip: skip first number of records (does not apply for header)
9298
:param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
9399
:return: an iterator of records (first record is the header even if not available)
100+
:raises TooMuchHeadersException: if the data contains more than one header
101+
:raises TagDefinitionException: if the tag definition is invalid or the length is not an integer
94102
"""
95103

96104
hr_list = re.split(r'<[eE][oO][hH]>', adi)
@@ -124,6 +132,8 @@ def loads(adi: str, skip: int = 0, strip_tags: bool = True) -> dict:
124132
:param skip: skip first number of records (does not apply for header)
125133
:param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
126134
:return: the ADI as a dict
135+
:raises TooMuchHeadersException: if the data contains more than one header
136+
:raises TagDefinitionException: if the tag definition is invalid or the length is not an integer
127137
"""
128138

129139
doc = {'HEADER': {},
@@ -143,45 +153,54 @@ def loads(adi: str, skip: int = 0, strip_tags: bool = True) -> dict:
143153

144154
def load(file_name: str, skip: int = 0, encoding=None, strip_tags: bool = True) -> dict:
145155
"""Load ADI formated file to dictionary
146-
The parameters are converted to uppercase
156+
The parameters are converted to uppercase
147157
148-
{
149-
'HEADER': {},
150-
'RECORDS': [list of records]
151-
}
158+
{
159+
'HEADER': {},
160+
'RECORDS': [list of records]
161+
}
152162
153-
The skip option is useful if you want to watch a file for new records only. This saves processing time.
154-
In this case consider to use loadi() directly.
163+
The skip option is useful if you want to watch a file for new records only. This saves processing time.
164+
In this case consider to use loadi() directly.
155165
156-
:param file_name: the file name where the ADI data is stored
157-
:param skip: skip first number of records (does not apply for header)
158-
:param encoding: the file encoding
159-
:param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
160-
:return: the ADI as a dict
161-
"""
166+
:param file_name: the file name where the ADI data is stored
167+
:param skip: skip first number of records (does not apply for header)
168+
:param encoding: the file encoding
169+
:param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
170+
:return: the ADI as a dict
171+
:raises TooMuchHeadersException: if the data contains more than one header
172+
:raises TagDefinitionException: if the tag definition is invalid or the length is not an integer
173+
"""
162174

163175
with open(file_name, encoding=encoding) as af:
164176
data = af.read()
165177

166178
return loads(data, skip, strip_tags)
167179

168180

169-
def pack(param: str, value: str, dtype: str = None) -> str:
181+
def pack(param: str, value: str, dtype: str = None, repl_non_ascii=True) -> str:
170182
"""Generates ADI tag if value is not empty
171183
Does not generate tags for *_INTL types as required by specification.
172184
173185
:param param: the tag parameter (converted to uppercase)
174186
:param value: the tag value (or tag definition if param is a USERDEF field)
175187
:param dtype: the optional datatype (mainly used for USERDEFx in header)
188+
:param repl_non_ascii: replace non ASCII characters with "_" and generates a warning instead of raising StringNotASCIIException
176189
:return: <param:length>value
190+
:raises StringNotASCIIException: if the value contains non ASCII characters
191+
:raises IllegalParameterException: if parameter or data type contains invalid characters
177192
"""
178193

179194
if not re.fullmatch(REGEX_PARAM, param):
180195
raise IllegalParameterException(f'Parameter "{param}" contains not allowed characters')
181196

182197
if not param.upper().endswith('_INTL'):
183198
if isinstance(value, str) and not re.fullmatch(REGEX_ASCII, value):
184-
raise StringNotASCIIException(f'Value "{value}" in parameter "{param}" contains non ASCII characters')
199+
if repl_non_ascii:
200+
value = replace_non_ascii(value)
201+
warn(f'Replaced non ASCII chars in tag "{param}"', NonASCIIWarning)
202+
else:
203+
raise StringNotASCIIException(f'Value "{value}" in parameter "{param}" contains non ASCII characters')
185204

186205
if dtype:
187206
if len(dtype) > 1 or dtype not in 'BNDTSEL':
@@ -194,7 +213,7 @@ def pack(param: str, value: str, dtype: str = None) -> str:
194213

195214

196215
def dumpi(data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__,
197-
linebreaks: bool = True, spaces: int = 1) -> Iterator[str]:
216+
linebreaks: bool = True, spaces: int = 1, repl_non_ascii=True) -> Iterator[str]:
198217
"""Takes a dictionary and converts it to ADI format
199218
Parameters can be in upper or lower case. The output is upper case. The user must take care
200219
that parameters are not doubled!
@@ -209,7 +228,10 @@ def dumpi(data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__,
209228
:param comment: the comment to induce the header
210229
:param linebreaks: Format output with additional linebreaks for readability
211230
:param spaces: Number of spaces between fields
212-
:return: an iterator of chunks of the ADI (header, record 1, ..., record n)"""
231+
:param repl_non_ascii: replace non ASCII characters with "_" and generates a warning instead of raising StringNotASCIIException
232+
:return: an iterator of chunks of the ADI (header, record 1, ..., record n)
233+
:raises StringNotASCIIException: if a value in a record contains non ASCII characters
234+
:raises IllegalParameterException: if a parameter or data type in a record contains invalid characters"""
213235

214236
data_dict = copy.deepcopy(data_dict)
215237

@@ -224,30 +246,42 @@ def dumpi(data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__,
224246

225247
data = comment + ' \n'
226248

227-
for p in data_dict['HEADER']:
228-
if p.upper() in ('ADIF_VER', 'PROGRAMID', 'PROGRAMVERSION', 'CREATED_TIMESTAMP'):
229-
data += pack(p.upper(), data_dict['HEADER'][p]) + ('\n' if linebreaks else field_separator)
230-
default.pop(p.upper())
231-
elif p.upper() == 'USERDEFS':
232-
for i, u in enumerate(data_dict['HEADER'][p], 1):
233-
data += pack(f'USERDEF{i}', u['userdef'], u['dtype']) + ('\n' if linebreaks else field_separator)
234-
for p in default:
235-
data += pack(p, default[p]) + ('\n' if linebreaks else field_separator)
236-
data += '<EOH>'
237-
yield data
249+
try:
250+
for p in data_dict['HEADER']:
251+
if p.upper() in ('ADIF_VER', 'PROGRAMID', 'PROGRAMVERSION', 'CREATED_TIMESTAMP'):
252+
data += (pack(p.upper(), data_dict['HEADER'][p], repl_non_ascii=repl_non_ascii)
253+
+ ('\n' if linebreaks else field_separator))
254+
default.pop(p.upper())
255+
elif p.upper() == 'USERDEFS':
256+
for i, u in enumerate(data_dict['HEADER'][p], 1):
257+
data += pack(f'USERDEF{i}', u['userdef'], u['dtype'], repl_non_ascii=repl_non_ascii) + (
258+
'\n' if linebreaks else field_separator)
259+
for p in default:
260+
data += pack(p, default[p], repl_non_ascii=repl_non_ascii) + ('\n' if linebreaks else field_separator)
261+
data += '<EOH>'
262+
yield data
263+
except StringNotASCIIException as exc:
264+
raise StringNotASCIIException(f'Header: {exc.args[0]}') from None
265+
except IllegalParameterException as exc:
266+
raise IllegalParameterException(f'Header: {exc.args[0]}') from None
238267

239268
if 'RECORDS' in data_dict:
240-
for r in data_dict['RECORDS']:
269+
for r_num, r in enumerate(data_dict['RECORDS']):
241270
data = ''
242271
empty = True
243272
for i, pv in enumerate(zip(r.keys(), r.values()), 1):
244-
tag = pack(pv[0].upper(), pv[1])
245-
if tag:
246-
empty = False
247-
if linebreaks:
248-
data += tag + ('\n' if i % 5 == 0 else field_separator)
249-
else:
250-
data += tag + field_separator
273+
try:
274+
tag = pack(pv[0].upper(), pv[1], repl_non_ascii=repl_non_ascii)
275+
if tag:
276+
empty = False
277+
if linebreaks:
278+
data += tag + ('\n' if i % 5 == 0 else field_separator)
279+
else:
280+
data += tag + field_separator
281+
except StringNotASCIIException as exc:
282+
raise StringNotASCIIException(f'Record #{r_num + 1}: {exc.args[0]}') from None
283+
except IllegalParameterException as exc:
284+
raise IllegalParameterException(f'Record #{r_num + 1}: {exc.args[0]}') from None
251285
if not data.endswith('\n'):
252286
data += '\n' if linebreaks else ''
253287

@@ -270,7 +304,8 @@ def dumps(data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__, lin
270304
:param data_dict: the dictionary with header and records
271305
:param comment: the comment to induce the header
272306
:param linebreaks: Format output with additional linebreaks for readability
273-
:return: the complete ADI as a string"""
307+
:return: the complete ADI as a string
308+
:raises IllegalParameterException: if a parameter or data type in a record contains invalid characters"""
274309

275310
line_separator = '\n\n' if linebreaks else '\n'
276311

@@ -293,7 +328,8 @@ def dump(file_name: str, data_dict: dict, comment: str = 'ADIF export by ' + __p
293328
:param data_dict: the dictionary with header and records
294329
:param comment: the comment to induce the header
295330
:param linebreaks: format output with additional linebreaks for readability
296-
:param encoding: the file encoding"""
331+
:param encoding: the file encoding
332+
:raises IllegalParameterException: if a parameter or data type in a record contains invalid characters"""
297333

298334
with open(file_name, 'w', encoding=encoding) as af:
299335
first = True

test/test_dumpadi.py

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ def test_10_pack_header_tag(self):
1515
self.assertEqual('<USERDEF1:19:E>SweaterSize,{S,M,L}',
1616
adif_file.adi.pack('USERDEF1', 'SweaterSize,{S,M,L}', 'E'))
1717

18-
self.assertRaises(adif_file.adi.IllegalDataTypeException, adif_file.adi.pack, 'USERDEF1', 'SweaterSize,{S,M,L}', 'X')
19-
self.assertRaises(adif_file.adi.IllegalDataTypeException, adif_file.adi.pack, 'USERDEF1', 'SweaterSize,{S,M,L}', 'NN')
18+
self.assertRaises(adif_file.adi.IllegalDataTypeException, adif_file.adi.pack, 'USERDEF1', 'SweaterSize,{S,M,L}',
19+
'X')
20+
self.assertRaises(adif_file.adi.IllegalDataTypeException, adif_file.adi.pack, 'USERDEF1', 'SweaterSize,{S,M,L}',
21+
'NN')
2022

2123
def test_15_pack_record_tag(self):
2224
self.assertEqual('<NAME:5>Joerg', adif_file.adi.pack('NAME', 'Joerg'))
@@ -27,11 +29,17 @@ def test_15_pack_record_tag(self):
2729

2830
self.assertEqual('<MY_NAME:5>Peter', adif_file.adi.pack('MY_Name', 'Peter'))
2931

30-
self.assertRaises(adif_file.adi.StringNotASCIIException, adif_file.adi.pack, 'NAME', 'Jörg')
31-
self.assertRaises(adif_file.adi.StringNotASCIIException, adif_file.adi.pack, 'NAME', 'Schloß')
32+
self.assertRaises(adif_file.adi.StringNotASCIIException, adif_file.adi.pack, 'NAME', 'Jörg', None, False)
33+
self.assertRaises(adif_file.adi.StringNotASCIIException, adif_file.adi.pack, 'NAME', 'Schloß', None, False)
3234
self.assertRaises(adif_file.adi.IllegalParameterException, adif_file.adi.pack, 'MY_ NAME', 'Peter')
3335
self.assertRaises(adif_file.adi.IllegalParameterException, adif_file.adi.pack, 'MY~NAME', 'Peter')
3436

37+
self.assertWarns(adif_file.adi.NonASCIIWarning, adif_file.adi.pack, 'NAME', 'Jörg')
38+
self.assertWarns(adif_file.adi.NonASCIIWarning, adif_file.adi.pack, 'NAME', 'Schloß')
39+
self.assertEqual('<NAME:4>J_rg', adif_file.adi.pack('NAME', 'Jörg'))
40+
self.assertEqual('<NAME:6>Schlo_', adif_file.adi.pack('name', 'Schloß'))
41+
42+
3543
# noinspection PyTypeChecker
3644
self.assertEqual('<DIST:2>99', adif_file.adi.pack('DIST', 99))
3745
# noinspection PyTypeChecker
@@ -132,32 +140,32 @@ def test_30_dump_a_file(self):
132140
os.remove(temp_file)
133141

134142
def test_31_dump_a_file_ln_sp(self):
135-
adi_dict = {
136-
'HEADER': {'PROGRAMID': 'TProg',
137-
'ADIF_VER': '3',
138-
'PROGRAMVERSION': '1',
139-
'CREATED_TIMESTAMP': '1234'},
140-
'RECORDS': [{'TEST1': 'test',
141-
'TEST2': 'test2'},
142-
{'TEST1': 'test3',
143-
'TEST2': 'test4'}]
144-
}
145-
146-
adi_exp = '''ADIF export by PyADIF-File
143+
adi_dict = {
144+
'HEADER': {'PROGRAMID': 'TProg',
145+
'ADIF_VER': '3',
146+
'PROGRAMVERSION': '1',
147+
'CREATED_TIMESTAMP': '1234'},
148+
'RECORDS': [{'TEST1': 'test',
149+
'TEST2': 'test2'},
150+
{'TEST1': 'test3',
151+
'TEST2': 'test4'}]
152+
}
153+
154+
adi_exp = '''ADIF export by PyADIF-File
147155
<PROGRAMID:5>TProg <ADIF_VER:1>3 <PROGRAMVERSION:1>1 <CREATED_TIMESTAMP:4>1234 <EOH>
148156
<TEST1:4>test <TEST2:5>test2 <EOR>
149157
<TEST1:5>test3 <TEST2:5>test4 <EOR>'''
150158

151-
temp_file = get_file_path('testdata/~test.adi')
159+
temp_file = get_file_path('testdata/~test.adi')
152160

153-
adif_file.adi.dump(temp_file, adi_dict, linebreaks=False, spaces=2)
161+
adif_file.adi.dump(temp_file, adi_dict, linebreaks=False, spaces=2)
154162

155-
self.assertTrue(os.path.isfile(temp_file))
163+
self.assertTrue(os.path.isfile(temp_file))
156164

157-
with open(temp_file) as af:
158-
self.assertEqual(adi_exp, af.read())
165+
with open(temp_file) as af:
166+
self.assertEqual(adi_exp, af.read())
159167

160-
os.remove(temp_file)
168+
os.remove(temp_file)
161169

162170
def test_40_dump_no_change(self):
163171
adi_dict = {

0 commit comments

Comments
 (0)