55
66import re
77import copy
8+ from warnings import warn
89from collections .abc import Iterator
910
1011from . 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
1415class TooMuchHeadersException (Exception ):
@@ -31,6 +32,10 @@ class IllegalDataTypeException(Exception):
3132 pass
3233
3334
35+ class NonASCIIWarning (Warning ):
36+ pass
37+
38+
3439REGEX_ASCII = re .compile (r'[ -~\n\r]*' )
3540REGEX_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
144154def 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
196215def 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
0 commit comments