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

Commit 9185a1d

Browse files
committed
Accept USERDEFx on dump and load ADI and added some basic tests
1 parent aaef3a7 commit 9185a1d

File tree

6 files changed

+240
-9
lines changed

6 files changed

+240
-9
lines changed

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ Convert ADIF ADI content to dictionary and vice versa
55
The required/resulting dictionary format is
66

77
{
8-
'HEADER': None,
8+
'HEADER':
9+
{Header param: Value,
10+
'USERDEFS': [list of user definitions]},
911
'RECORDS': [list of records]
1012
}
1113

@@ -15,6 +17,13 @@ The header or each record is/must be a dictionary in the format
1517
ADIF parameter name: Text value,
1618
}
1719

20+
A user definition is a dictionary of
21+
22+
{
23+
'dtype': one char representing the type,
24+
'userdef': the field definition text
25+
}
26+
1827
You have to care about reading/writing the content from/to the file.
1928

2029
Installation

adif_file/__init__.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ class StringNotASCIIException(Exception):
2727
pass
2828

2929

30+
class UnknownDataTypeException(Exception):
31+
pass
32+
33+
3034
REGEX_ASCII = re.compile(r'[ -~]*')
3135

3236

@@ -48,18 +52,29 @@ def unpack(data: str) -> dict:
4852

4953
end = data.index('>', start)
5054
tag = data[start + 1:end]
55+
dtype = None
5156
try:
52-
param, length = tag.split(':')
57+
tag_def = tag.split(':')
58+
param = tag_def[0]
59+
length = tag_def[1]
60+
if len(tag_def) == 3:
61+
dtype = tag_def[2]
5362
except ValueError:
54-
raise TagDefinitionException('Wrong param:length')
63+
raise TagDefinitionException('Wrong tag definition')
5564

5665
try:
5766
length = int(length)
5867
except ValueError:
5968
raise TagDefinitionException('Wrong length')
6069

6170
value = data[end + 1:end + 1 + length]
62-
unpacked[param.upper()] = value
71+
if param.upper().startswith('USERDEF'):
72+
if 'USERDEFS' not in unpacked:
73+
unpacked['USERDEFS'] = []
74+
unpacked['USERDEFS'].append({'dtype': dtype,
75+
'userdef': value})
76+
else:
77+
unpacked[param.upper()] = value
6378

6479
return unpacked
6580

@@ -95,18 +110,24 @@ def adi2dict(adi: str) -> dict:
95110
return doc
96111

97112

98-
def pack(param: str, value: str) -> str:
113+
def pack(param: str, value: str, dtype: str = None) -> str:
99114
"""Generates ADI tag if value is not empty
100115
Does not generate tags for *_INTL types as required by specification.
101116
102117
:param param: the tag parameter (converte to uppercase)
103-
:param value: the tag value
118+
:param value: the tag value (or tag definition if param is a USERDEF field)
119+
:param dtype: the optional datatype (mainly used for USERDEFx in header)
104120
:return: <param:length>value
105121
"""
106122

107-
if not param.endswith('_INTL'):
123+
if not param.upper().endswith('_INTL'):
108124
if re.fullmatch(REGEX_ASCII, value):
109-
return f'<{param.upper()}:{len(str(value))}>{value}' if value else ''
125+
if dtype:
126+
if len(dtype) > 1 or dtype not in 'BNDTSIMGEL':
127+
raise UnknownDataTypeException(f'Datatype "{dtype}" in "{param}"')
128+
return f'<{param.upper()}:{len(str(value))}:{dtype}>{value}' if value else ''
129+
else:
130+
return f'<{param.upper()}:{len(str(value))}>{value}' if value else ''
110131
else:
111132
raise StringNotASCIIException(f'Value "{value}" in parameter "{param}" contains non ASCII characters')
112133
else:
@@ -115,11 +136,14 @@ def pack(param: str, value: str) -> str:
115136

116137
def dict2adi(data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__) -> str:
117138
"""Takes a dictionary and converts it to ADI format
118-
Parameters can be in upper or lower case. The output is upper case. The user must take care that parameters are not doubled!
139+
Parameters can be in upper or lower case. The output is upper case. The user must take care
140+
that parameters are not doubled!
119141
*_INTL parameters are ignored as they are not allowed in ADI.
120142
Empty records are skipped.
121143
122144
If 'HEADER' is present the comment is added and missing header fields are filled with defaults.
145+
The header can contain a list of user definitions as USERDEFS. Each user definition is expected as a dictionary
146+
with datatype as "dtype" and field definition as "userdef" instead of a string value.
123147
124148
:param data_dict: the dictionary with header and records
125149
:param comment: the comment to induce the header"""
@@ -139,6 +163,9 @@ def dict2adi(data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__)
139163
if p.upper() in ('ADIF_VER', 'PROGRAMID', 'PROGRAMVERSION', 'CREATED_TIMESTAMP'):
140164
data += pack(p.upper(), data_dict['HEADER'][p]) + '\n'
141165
default.pop(p.upper())
166+
elif p.upper() == 'USERDEFS':
167+
for i, u in enumerate(data_dict['HEADER'][p], 1):
168+
data += pack(f'USERDEF{i}', u['userdef'], u['dtype']) + '\n'
142169
for p in default:
143170
data += pack(p, default[p]) + '\n'
144171
data += '<EOH>\n\n'

test/test_dumpadi.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import unittest
2+
3+
from adif_file import *
4+
5+
6+
class DumpADI(unittest.TestCase):
7+
def test_10_pack_header_tag(self):
8+
self.assertEqual('<PROGRAMID:8>Testprog', pack('PROGRAMID', 'Testprog'))
9+
self.assertEqual('<USERDEF1:4:N>Test', pack('USERDEF1', 'Test', 'N'))
10+
self.assertEqual('<USERDEF1:19:E>SweaterSize,{S,M,L}',
11+
pack('USERDEF1', 'SweaterSize,{S,M,L}', 'E'))
12+
13+
self.assertRaises(UnknownDataTypeException, pack, 'USERDEF1', 'SweaterSize,{S,M,L}', 'X')
14+
self.assertRaises(UnknownDataTypeException, pack, 'USERDEF1', 'SweaterSize,{S,M,L}', 'NN')
15+
16+
def test_15_pack_record_tag(self):
17+
self.assertEqual('<NAME:5>Joerg', pack('NAME', 'Joerg'))
18+
self.assertEqual('<NAME:5>Joerg', pack('name', 'Joerg'))
19+
self.assertEqual('<NAME:5>Joerg', pack('Name', 'Joerg'))
20+
self.assertEqual('', pack('name_intl', 'Joerg'))
21+
22+
self.assertRaises(StringNotASCIIException, pack, 'NAME', 'Jörg')
23+
24+
def test_20_dump_header(self):
25+
adi_dict = {
26+
'HEADER': {'PROGRAMID': 'TProg',
27+
'ADIF_VER': '3',
28+
'PROGRAMVERSION': '1',
29+
'CREATED_TIMESTAMP': '1234'},
30+
}
31+
32+
exp_hdr = '''ADIF export by PyADIF-File
33+
<PROGRAMID:5>TProg
34+
<ADIF_VER:1>3
35+
<PROGRAMVERSION:1>1
36+
<CREATED_TIMESTAMP:4>1234
37+
<EOH>'''
38+
39+
self.assertEqual(exp_hdr, dict2adi(adi_dict))
40+
41+
# Test same with udef
42+
adi_udef = [{'dtype': 'E',
43+
'userdef': 'Test,{A,B,C}'},
44+
{'dtype': 'N',
45+
'userdef': 'Test2,{5:20}'}]
46+
47+
exp_hdr_udef = '''ADIF export by PyADIF-File
48+
<PROGRAMID:5>TProg
49+
<ADIF_VER:1>3
50+
<PROGRAMVERSION:1>1
51+
<CREATED_TIMESTAMP:4>1234
52+
<USERDEF1:12:E>Test,{A,B,C}
53+
<USERDEF2:12:N>Test2,{5:20}
54+
<EOH>'''
55+
56+
adi_dict['HEADER']['USERDEFS'] = adi_udef
57+
self.assertEqual(exp_hdr_udef, dict2adi(adi_dict))
58+
59+
def test_25_dump_records(self):
60+
adi_dict = {
61+
'RECORDS': [{'TEST1': 'test',
62+
'TEST2': 'test2'},
63+
{'TEST1': 'test3',
64+
'TEST2': 'test4'}]}
65+
66+
adi_exp = '''<TEST1:4>test <TEST2:5>test2
67+
<EOR>
68+
69+
<TEST1:5>test3 <TEST2:5>test4
70+
<EOR>'''
71+
72+
self.assertEqual(adi_exp, dict2adi(adi_dict))
73+
74+
75+
76+
77+
if __name__ == '__main__':
78+
unittest.main()

test/test_loadadi.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import unittest
2+
3+
from adif_file import *
4+
5+
6+
class LoadADI(unittest.TestCase):
7+
def test_10_unpack_header(self):
8+
adi_hdr1 = '''ADIF Export by Testprog
9+
<ADIF_VER:5>3.1.4 <PROGRAMID:8>Testprog <PROGRAMVERSION:4>v0.2'''
10+
adi_hdr2 = '''ADIF Export by Testprog
11+
<ADIF_VER:5>3.1.4
12+
<PROGRAMID:8>Testprog
13+
<PROGRAMVERSION:4>v0.2'''
14+
exp_dict = {'ADIF_VER': '3.1.4', 'PROGRAMID': 'Testprog', 'PROGRAMVERSION': 'v0.2'}
15+
16+
self.assertDictEqual(exp_dict, unpack(adi_hdr1))
17+
self.assertDictEqual(exp_dict, unpack(adi_hdr2))
18+
19+
def test_15_unpack_userdef(self):
20+
adi_hdr1 = '''ADIF Export by Testprog
21+
<ADIF_VER:5>3.1.4 <PROGRAMID:8>Testprog <PROGRAMVERSION:4>v0.2 <USERDEF1:4:N>Test'''
22+
adi_hdr2 = '''ADIF Export by Testprog
23+
<ADIF_VER:5>3.1.4hh <PROGRAMID:8>Testprog <PROGRAMVERSION:4>v0.2 <USERDEF1:4:G>Test <USERDEF2:5:L>TestX'''
24+
25+
exp_dict1 = {'ADIF_VER': '3.1.4', 'PROGRAMID': 'Testprog', 'PROGRAMVERSION': 'v0.2',
26+
'USERDEFS': [{'dtype': 'N',
27+
'userdef': 'Test'}]}
28+
exp_dict2 = {'ADIF_VER': '3.1.4', 'PROGRAMID': 'Testprog', 'PROGRAMVERSION': 'v0.2',
29+
'USERDEFS': [{'dtype': 'G',
30+
'userdef': 'Test'},
31+
{'dtype': 'L',
32+
'userdef': 'TestX'}]}
33+
34+
self.assertDictEqual(exp_dict1, unpack(adi_hdr1))
35+
self.assertDictEqual(exp_dict2, unpack(adi_hdr2))
36+
37+
def test_20_goodfile(self):
38+
with open('testdata/goodfile.txt', encoding='ascii') as tf:
39+
adi_txt = tf.read()
40+
41+
adi_dict = adi2dict(adi_txt)
42+
43+
self.assertIn('HEADER', adi_dict)
44+
self.assertIn('RECORDS', adi_dict)
45+
self.assertEqual(3, len(adi_dict['HEADER']))
46+
self.assertEqual(5, len(adi_dict['RECORDS']))
47+
48+
def test_25_toomuchheaders(self):
49+
with open('testdata/toomuchheadersfile.txt', encoding='ascii') as tf:
50+
adi_txt = tf.read()
51+
52+
self.assertRaises(TooMuchHeadersException, adi2dict, adi_txt)
53+
54+
55+
if __name__ == '__main__':
56+
unittest.main()

test/testdata/goodfile.txt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
ADIF Export by Testprog
2+
<ADIF_VER:5>3.1.4 <PROGRAMID:8>Testprog <PROGRAMVERSION:4>v0.2
3+
<eoh>
4+
5+
<QSO_DATE:8>20231008 <TIME_ON:4>1145 <CALL:6>dl4bdf <NAME:6>Walter <QTH:8>Dortmund <GRIDSQUARE:8>Jo30Uj45
6+
<BAND:3>80M <MODE:2>AM <FREQ:5>4.000
7+
<STATION_CALLSIGN:6>XX1XXX <MY_GRIDSQUARE:8>JO35uj27 <DISTANCE:1>2
8+
<NOTES:5>Test1
9+
<eor>
10+
11+
<QSO_DATE:8>20231008 <TIME_ON:4>1146 <CALL:6>DL5HJK <NAME:5>Peter <QTH:13>Welschneudorf <GRIDSQUARE:8>Jo30uj12
12+
<RST_SENT:2>59 <RST_RCVD:2>47 <BAND:4>630M <MODE:2>AM <TX_PWR:3>4.0
13+
<STATION_CALLSIGN:6>XX1XXX <MY_GRIDSQUARE:8>JO35uj27 <DISTANCE:1>2
14+
<eor>
15+
16+
<QSO_DATE:8>20231008 <TIME_ON:4>1340
17+
<RST_SENT:2>59 <RST_RCVD:2>59 <BAND:4>630M <MODE:2>AM <FREQ:5>0.472 <TX_PWR:3>4.0
18+
<MY_GRIDSQUARE:8>JO35uj27
19+
<eor>
20+
21+
<QSO_DATE:8>20231008 <TIME_ON:4>1754
22+
<RST_SENT:2>59 <RST_RCVD:2>59 <BAND:5>2190M <MODE:2>AM <FREQ:5>0.137 <TX_PWR:3>4.0
23+
<STATION_CALLSIGN:6>XX1XXX <MY_GRIDSQUARE:8>JO35uj27
24+
<eor>
25+
26+
<QSO_DATE:8>20231008 <TIME_ON:4>1755
27+
<RST_SENT:2>59 <RST_RCVD:2>59 <BAND:3>12M <MODE:2>AM <FREQ:6>24.790 <TX_PWR:3>4.0
28+
<STATION_CALLSIGN:6>XX1XXX <MY_GRIDSQUARE:8>JO35uj27
29+
<eor>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
ADIF Export by Testprog
2+
<ADIF_VER:5>3.1.4 <PROGRAMID:8>Testprog <PROGRAMVERSION:4>v0.2
3+
<eoh>
4+
5+
<ADIF_VER:5>3.1.4 <PROGRAMID:8>Testprog <PROGRAMVERSION:4>v0.2
6+
<eoh>
7+
8+
<QSO_DATE:8>20231008 <TIME_ON:4>1145 <CALL:6>dl4bdf <NAME:6>Walter <QTH:8>Dortmund <GRIDSQUARE:8>Jo30Uj45
9+
<BAND:3>80M <MODE:2>AM <FREQ:5>4.000
10+
<STATION_CALLSIGN:6>XX1XXX <MY_GRIDSQUARE:8>JO35uj27 <DISTANCE:1>2
11+
<NOTES:5>Test1
12+
<eor>
13+
14+
<QSO_DATE:8>20231008 <TIME_ON:4>1146 <CALL:6>DL5HJK <NAME:5>Peter <QTH:13>Welschneudorf <GRIDSQUARE:8>Jo30uj12
15+
<RST_SENT:2>59 <RST_RCVD:2>47 <BAND:4>630M <MODE:2>AM <TX_PWR:3>4.0
16+
<STATION_CALLSIGN:6>XX1XXX <MY_GRIDSQUARE:8>JO35uj27 <DISTANCE:1>2
17+
<eor>
18+
19+
<QSO_DATE:8>20231008 <TIME_ON:4>1340
20+
<RST_SENT:2>59 <RST_RCVD:2>59 <BAND:4>630M <MODE:2>AM <FREQ:5>0.472 <TX_PWR:3>4.0
21+
<MY_GRIDSQUARE:8>JO35uj27
22+
<eor>
23+
24+
<QSO_DATE:8>20231008 <TIME_ON:4>1754
25+
<RST_SENT:2>59 <RST_RCVD:2>59 <BAND:5>2190M <MODE:2>AM <FREQ:5>0.137 <TX_PWR:3>4.0
26+
<STATION_CALLSIGN:6>XX1XXX <MY_GRIDSQUARE:8>JO35uj27
27+
<eor>
28+
29+
<QSO_DATE:8>20231008 <TIME_ON:4>1755
30+
<RST_SENT:2>59 <RST_RCVD:2>59 <BAND:3>12M <MODE:2>AM <FREQ:6>24.790 <TX_PWR:3>4.0
31+
<STATION_CALLSIGN:6>XX1XXX <MY_GRIDSQUARE:8>JO35uj27
32+
<eor>

0 commit comments

Comments
 (0)