efjtk 0.5__py3-none-any.whl

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.
efjtk/__init__.py ADDED
File without changes
efjtk/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+ import argparse
5
+ from typing import Optional
6
+ import os.path
7
+
8
+ import efj_parser
9
+ import efjtk.convert
10
+ import efjtk.modify
11
+ from efjtk.config import build_config, aircraft_classes
12
+
13
+
14
+ def _args():
15
+ parser = argparse.ArgumentParser(
16
+ description=(
17
+ """Process an electronic Flight Journal (eFJ) file. Tools to aid in
18
+ manual creation of eFJ files (expand, night, vfr, ins, fo) and
19
+ tools to convert to useful formats (logbook, summary) are included.
20
+ Also included is a tool to help create a config file, which is
21
+ required for generation of the FCL.050 logbook."""))
22
+ parser.add_argument('format',
23
+ choices=['expand', 'night', 'vfr', 'ins', 'fo',
24
+ 'logbook', 'summary',
25
+ 'config'])
26
+ parser.add_argument('-c', '--config', default=None)
27
+ return parser.parse_args()
28
+
29
+
30
+ def _config(filename: Optional[str]) -> str:
31
+ if filename and os.path.exists(filename):
32
+ with open(filename) as f:
33
+ return f.read()
34
+ else:
35
+ for filename in (os.path.expanduser("~/.efjtkrc"),
36
+ os.path.expanduser("~/.config/efjtkrc")):
37
+ if os.path.exists(filename):
38
+ with open(filename) as f:
39
+ return f.read()
40
+ return ""
41
+
42
+
43
+ _func_map = {
44
+ "expand": efjtk.modify.expand_efj,
45
+ "night": efjtk.modify.add_night_data,
46
+ "summary": efjtk.convert.build_summary,
47
+ "vfr": efjtk.modify.add_vfr_flag,
48
+ "fo": efjtk.modify.add_fo_role_flag,
49
+ "ins": efjtk.modify.add_ins_flag,
50
+ }
51
+
52
+
53
+ def main() -> int:
54
+ args = _args()
55
+ data = sys.stdin.read()
56
+ try:
57
+ if args.format == "logbook":
58
+ ac_classes = aircraft_classes(_config(args.config))
59
+ print(efjtk.convert.build_logbook(data, ac_classes))
60
+ elif args.format == "config":
61
+ sys.stdout.write(
62
+ build_config(data, _config(args.config)))
63
+ elif args.format in _func_map:
64
+ print(_func_map[args.format](data))
65
+ else:
66
+ return -1
67
+ return 0
68
+ except efj_parser.ValidationError as ve:
69
+ print(str(ve), file=sys.stderr)
70
+ return -1
71
+ except efjtk.convert.UnknownAircraftType as t:
72
+ print(f"No class for type: {t}", file=sys.stderr)
73
+ return -3
74
+
75
+
76
+ if __name__ == "__main__":
77
+ retval = main()
78
+ sys.exit(retval)
efjtk/config.py ADDED
@@ -0,0 +1,67 @@
1
+ import io
2
+ import efj_parser as ep
3
+ import configparser as cp
4
+
5
+
6
+ def _parse_config(config: str) -> cp.ConfigParser:
7
+ """Create a clean ConfigParser from a string
8
+
9
+ :param config: The contents of an INI file as a string
10
+ :return: A ConfigParser that is guaranteed to have an aircraft.classes
11
+ section, with the entries within guaranteed to have a valid value.
12
+ :raises configparser.Error: Any configparser exception when parsing the
13
+ string is not caught, so must be handled at a higher level
14
+ """
15
+ parser = cp.ConfigParser()
16
+ parser.read_string(config) # raises cp.Error on failure
17
+ if "aircraft.classes" not in parser:
18
+ parser.add_section("aircraft.classes")
19
+ for name, value in parser.items("aircraft.classes"):
20
+ if value not in {"spse", "spme", "mc"}:
21
+ parser.remove_option("aircraft.classes", name)
22
+ return parser
23
+
24
+
25
+ def build_config(in_: str, config: str, raise_on_error: bool = False) -> str:
26
+ """Build a template for an INI file incorporating any unknown types
27
+
28
+ :param in_: An eFJ file in string form
29
+ :param config: An INI file in string form. This can be an empty string or
30
+ can be the contents of an existing INI file to update.
31
+ :param raise_on_error: If an exception occurs while parsing the config
32
+ string, reraise it rather than continuing with empty parser
33
+ :return: An updated INI file in string form. Any non pre-existing types are
34
+ added to the [aircraft.classes] section and assigned "spse" as a value.
35
+ :raises configparser.Error: Any configparser exception caused by errors in
36
+ config must be handled at higher level if raise_on_error is True
37
+ """
38
+ _, sectors = ep.Parser().parse(in_)
39
+ try:
40
+ parser = _parse_config(config)
41
+ except cp.Error as e:
42
+ if raise_on_error:
43
+ raise e
44
+ parser = cp.ConfigParser()
45
+ parser.add_section("aircraft.classes")
46
+ for s in sectors:
47
+ if s.aircraft.type_ not in parser["aircraft.classes"]:
48
+ parser["aircraft.classes"][s.aircraft.type_] = "spse"
49
+ f = io.StringIO()
50
+ parser.write(f)
51
+ return f.getvalue()
52
+
53
+
54
+ def aircraft_classes(config: str) -> cp.SectionProxy:
55
+ """Extract the [aircraft.classes] section from an INI string.
56
+
57
+ :param config: An INI file in string form
58
+ :return: A ConfigParser SectionProxy object. This can be treated as a non
59
+ case-sensitive dict, with the aircraft type as key and its category as
60
+ value. Uses empty parser if parsing of config fails.
61
+ """
62
+ try:
63
+ parser = _parse_config(config)
64
+ except cp.Error:
65
+ parser = cp.ConfigParser()
66
+ parser.add_section("aircraft_classes")
67
+ return parser["aircraft.classes"]
efjtk/convert.py ADDED
@@ -0,0 +1,167 @@
1
+ import configparser as cp
2
+ import importlib.resources as res
3
+ import datetime as dt
4
+
5
+ import efj_parser as ep
6
+
7
+
8
+ class UnknownAircraftType(Exception):
9
+ """Aircraft type with no matching class encountered"""
10
+
11
+ def __init__(self, type_):
12
+ self.missing_type = type_
13
+
14
+
15
+ def _get_template(filename):
16
+ template_file = res.files("efjtk").joinpath(filename)
17
+ with template_file.open() as f:
18
+ template = f.read()
19
+ for old, new in (("{", "{{"), ("}", "}}"),
20
+ ("<!--{{", "{"), ("}}-->", "}")):
21
+ template = template.replace(old, new)
22
+ return template
23
+
24
+
25
+ def _duration(minutes):
26
+ if minutes:
27
+ return f"{minutes // 60}:{minutes % 60:02}"
28
+ return ""
29
+
30
+
31
+ def _aircraft_class_cells(
32
+ sector: ep.Sector,
33
+ ac_classes: cp.SectionProxy,
34
+ duration: str
35
+ ) -> list[str]:
36
+ if sector.aircraft.class_:
37
+ aircraft_class = sector.aircraft.class_
38
+ else:
39
+ try:
40
+ aircraft_class = ac_classes[sector.aircraft.type_]
41
+ except KeyError:
42
+ raise UnknownAircraftType(sector.aircraft.type_)
43
+ if aircraft_class == "mc":
44
+ return ["", "", duration]
45
+ if aircraft_class == "spse":
46
+ return ["✓", "", ""]
47
+ return ["", "✓", ""] # must be "spme"
48
+
49
+
50
+ def build_logbook(in_: str, ac_classes: cp.SectionProxy) -> str:
51
+ _, sectors = ep.Parser().parse(in_)
52
+ rows = []
53
+ for s in sectors:
54
+ cells = [f"{s.start:%d/%m/%Y}",
55
+ s.airports.origin, f"{s.start:%H:%M}",
56
+ s.airports.dest,
57
+ f"{s.start + dt.timedelta(minutes=s.total):%H:%M}",
58
+ s.aircraft.type_, s.aircraft.reg]
59
+ duration = _duration(s.total)
60
+ cells.extend(_aircraft_class_cells(s, ac_classes, duration))
61
+ cells.append(duration)
62
+ cells.append(s.captain)
63
+ cells.extend([str(s.landings.day or ""), str(s.landings.night or "")])
64
+ night, ifr = "", ""
65
+ if s.conditions.night:
66
+ night = _duration(s.conditions.night)
67
+ if s.conditions.ifr:
68
+ ifr = _duration(s.conditions.ifr)
69
+ cells.extend([night, ifr])
70
+ cells.extend([_duration(X) if X else ""
71
+ for X in (s.roles.p1 + s.roles.p1s, s.roles.p2,
72
+ s.roles.put, s.roles.instructor)])
73
+ cells.append(s.comment)
74
+ rows.append(f"<tr><td>{'</td><td>'.join(cells)}</td></tr>")
75
+ return _get_template("logbook-template.html").format(rows="\n".join(rows))
76
+
77
+
78
+ def _build_roles(sectors):
79
+ rpt = {}
80
+ for s in sectors:
81
+ type_ = s.aircraft.type_
82
+ roles = [s.roles.p1, s.roles.p1s, s.roles.p2, s.roles.put]
83
+ if s.aircraft.type_ not in rpt:
84
+ rpt[type_] = roles
85
+ else:
86
+ rpt[type_] = [X + Y for X, Y in zip(rpt[type_], roles)]
87
+ rows = []
88
+ role_total = [0, 0, 0, 0]
89
+ for type_ in sorted(rpt.keys()):
90
+ role_total = [X + Y for X, Y in zip(role_total, rpt[type_])]
91
+ total = sum(rpt[type_])
92
+ data = '</td><td>'.join([_duration(X) for X in rpt[type_]])
93
+ rows.append(f"<tr><th>{type_}</th><td>{data}</td>"
94
+ f"<td class='total'>{_duration(total)}</td></tr>")
95
+ data = '</td><td class="total">'.join([_duration(X) for X in role_total])
96
+ rows.append(f"<tr class='col_total'><th>Total</th><td class='total'>{data}"
97
+ f"</td><td class='total'>{_duration(sum(role_total))}"
98
+ f"</td></tr>")
99
+ return rows
100
+
101
+
102
+ def _build_conditions(sectors):
103
+ cond_pt = {}
104
+ for s in sectors:
105
+ type_ = s.aircraft.type_
106
+ conditions = [s.total - s.conditions.ifr, s.conditions.ifr,
107
+ s.total - s.conditions.night, s.conditions.night]
108
+ if s.aircraft.type_ not in cond_pt:
109
+ cond_pt[type_] = conditions
110
+ else:
111
+ cond_pt[type_] = [X + Y for X, Y in
112
+ zip(cond_pt[type_], conditions)]
113
+ rows = []
114
+ cond_total = [0, 0, 0, 0]
115
+ for type_ in sorted(cond_pt.keys()):
116
+ cond_total = [X + Y for X, Y in zip(cond_total, cond_pt[type_])]
117
+ data = '</td><td>'.join([_duration(X) for X in cond_pt[type_]])
118
+ rows.append(f"<tr><th>{type_}</th><td>{data}</td></tr>")
119
+ data = '</td><td class="total">'.join([_duration(X) for X in cond_total])
120
+ rows.append(f"<tr class='col_total'><th>Total</th>"
121
+ f"<td class='total'>{data}</td></tr>")
122
+ return rows
123
+
124
+
125
+ def _build_landings(sectors):
126
+ ldg_pt = {}
127
+ for s in sectors:
128
+ type_ = s.aircraft.type_
129
+ landings = [s.landings.day, s.landings.night]
130
+ if s.aircraft.type_ not in ldg_pt:
131
+ ldg_pt[type_] = landings
132
+ else:
133
+ ldg_pt[type_] = [X + Y for X, Y in
134
+ zip(ldg_pt[type_], landings)]
135
+ rows = []
136
+ landing_total = [0, 0]
137
+ for type_ in sorted(ldg_pt.keys()):
138
+ landing_total = [X + Y for X, Y in zip(landing_total, ldg_pt[type_])]
139
+ data = '</td><td>'.join([str(X) for X in ldg_pt[type_]])
140
+ total = sum(ldg_pt[type_])
141
+ rows.append(f"<tr><th>{type_}</th><td>{data}</td>"
142
+ f"<td class='total'>{total}</td></tr>")
143
+ data = '</td><td class="total">'.join([str(X) for X in landing_total])
144
+ rows.append(f"<tr class='col_total'><th>Total</th>"
145
+ f"<td class='total'>{data}</td>"
146
+ f"<td class='total'>{sum(landing_total)}</td></tr>")
147
+ return rows
148
+
149
+
150
+ def build_summary(in_: str) -> str:
151
+ """Build an HTML file with a summary table.
152
+
153
+ :param in_: An EFJ format text file as a string
154
+ :return: An HTML file as a string
155
+ """
156
+ _, sectors = ep.Parser().parse(in_)
157
+ roles = _build_roles(sectors)
158
+ conditions = _build_conditions(sectors)
159
+ landings = _build_landings(sectors)
160
+ return _get_template("summary-template.html").format(
161
+ roles_body="\n".join(roles[:-1]),
162
+ roles_totals=roles[-1],
163
+ cond_body="\n".join(conditions[:-1]),
164
+ cond_totals=conditions[-1],
165
+ ldg_body="\n".join(landings[:-1]),
166
+ ldg_totals=landings[-1]
167
+ )