efj-parser 0.9.2__tar.gz → 0.9.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: efj-parser
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Parse an electronic Flight Journal file
5
5
  Home-page: https://github.com/JonHurst/efj_parser
6
6
  Author: Jon Hurst
@@ -13,6 +13,15 @@ Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.11
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: project-url
23
+ Dynamic: requires-python
24
+ Dynamic: summary
16
25
 
17
26
  # eFJ Parser #
18
27
 
@@ -0,0 +1,442 @@
1
+ import re
2
+ from enum import IntEnum
3
+ from typing import NamedTuple, Optional, Callable, Union, cast
4
+ import datetime as dt
5
+
6
+
7
+ class Duty(NamedTuple):
8
+ start: dt.datetime #: Start of duty
9
+ duration: int #: Length of duty in minutes
10
+ ftl_correction: int = 0 #: Minutes to reduce duty length for FTL
11
+ extra_flags: tuple[str, ...] = () #: Any unprocessed flags
12
+ comment: str = "" #: Remarks
13
+
14
+
15
+ class Crewmember(NamedTuple):
16
+ role: str #: The crewmember's role (e.g. ``CP``, ``FO``, ``PU``, ``FA``)
17
+ name: str #: The crewmember's name
18
+
19
+
20
+ class Roles(NamedTuple):
21
+ p1: int = 0 #: Minutes operating as p1
22
+ p1s: int = 0 #: Minutes operating as p1s
23
+ p2: int = 0 #: Minutes operating as p2
24
+ put: int = 0 #: Minutes operating as put
25
+ instructor: int = 0 #: Minutes operating as instructor
26
+
27
+
28
+ class Conditions(NamedTuple):
29
+ night: int = 0 #: Minutes operating at night
30
+ ifr: int = 0 #: Minutes operating under IFR
31
+
32
+
33
+ class Landings(NamedTuple):
34
+ day: int = 0 #: Number of day landings
35
+ night: int = 0 #: Number of night landings
36
+
37
+
38
+ class Aircraft(NamedTuple):
39
+ reg: str #: The registration of the aircraft
40
+ type_: str #: The type of the aircraft
41
+ class_: str #: Class: ``spse``, ``spme`` or ``mc``
42
+
43
+
44
+ class Airports(NamedTuple):
45
+ origin: str #: The origin airport
46
+ dest: str #: The destination airport
47
+
48
+
49
+ class Sector(NamedTuple):
50
+ start: dt.datetime #: Off chocks date and time
51
+ total: int #: Minutes between off chocks and on chocks
52
+ roles: Roles #: Minutes operating in each role
53
+ conditions: Conditions #: Minutes operating in ifr and at night
54
+ landings: Landings #: Number of day and night landings
55
+ aircraft: Aircraft #: Type, registration and class of the aircraft
56
+ airports: Airports #: Origin and destination airports
57
+ captain: str #: Name(s) of captain(s), Self for oneself
58
+ extra_flags: tuple[str, ...] #: Any flags not processed by the parser
59
+ comment: str #: Remarks
60
+ crew: tuple[Crewmember, ...] #: A (possibly empty) list of crew
61
+
62
+
63
+ class ValidationError(Exception):
64
+ """An problem was found in the input data."""
65
+
66
+ def __init__(self, line_number, message, problem_string):
67
+ self.line_number = line_number
68
+ """The 1-indexed line number where the problem was found."""
69
+ self.message = message
70
+ """A message describing the problem."""
71
+ self.problem_string = problem_string
72
+ """The string that caused the problem."""
73
+
74
+ def __str__(self):
75
+ return (f"Line {self.line_number}: "
76
+ f"[{self.message}] {self.problem_string}")
77
+
78
+
79
+ class _VE(Exception):
80
+
81
+ class Code(IntEnum):
82
+ BAD_CREWLIST = 100
83
+ BAD_DATE = 110
84
+ BAD_FLAGS = 120
85
+ BAD_ROLE_FLAGS = 121
86
+ BAD_CONDITION_FLAGS = 122
87
+ BAD_SYNTAX = 130
88
+ BAD_TIME = 140
89
+ MISSING_AIRCRAFT = 200
90
+ MISSING_CAPTAIN = 210
91
+ MISSING_DATE = 220
92
+ MISSING_DEST = 230
93
+ MISSING_ORIGIN = 240
94
+
95
+ message_lookup = {
96
+ Code.BAD_CREWLIST: "Invalid crew list",
97
+ Code.BAD_DATE: "Invalid date format",
98
+ Code.BAD_FLAGS: "Invalid flags",
99
+ Code.BAD_ROLE_FLAGS: "Invalid role flags",
100
+ Code.BAD_CONDITION_FLAGS: "Invalid flight condition flags",
101
+ Code.BAD_SYNTAX: "Invalid syntax",
102
+ Code.BAD_TIME: "Invalid time format",
103
+ Code.MISSING_AIRCRAFT: "Prior aircraft specifier required",
104
+ Code.MISSING_AIRCRAFT: "No Captain specified",
105
+ Code.MISSING_DATE: "Prior date specifier required",
106
+ Code.MISSING_DEST: "No destination airfield specified",
107
+ Code.MISSING_ORIGIN: "No origin airfield specified",
108
+ }
109
+
110
+ def __init__(self, code: Code):
111
+ self.code = code
112
+ self.message = _VE.message_lookup.get(code, "Unknown error")
113
+
114
+
115
+ ParseRet = Union[dt.date, Duty, Aircraft, list[Crewmember], Sector, str]
116
+ ParseHook = Optional[Callable[[str, int, str, ParseRet], None]]
117
+ Flag = tuple[str, Optional[int]]
118
+ Flags = tuple[Flag, ...]
119
+
120
+
121
+ class Parser():
122
+ """Parser for electronic Flight Journal (eFJ) files."""
123
+
124
+ __RE_SECTOR = re.compile(
125
+ r"""(\w*)/(\w*) # origin/dest (both optional)
126
+ \s+(\d{4})/(\d{4}) # times
127
+ (?>\s+([^#]+))? # flags
128
+ (?>\s*\#(.*))? # comment
129
+ """, re.VERBOSE)
130
+ __RE_CREWLIST = re.compile(r"\{([^}]*)}")
131
+ __RE_DATE = re.compile(r"(\d{4}-\d{2}-\d{2})")
132
+ __RE_AIRCRAFT = re.compile(
133
+ r"""(?>([-\w]+) # registration
134
+ \s*:\s*
135
+ ([-\w]+)) # type
136
+ (?:\s*:\s*
137
+ (mc|spse|spme))? # optional class""", re.VERBOSE)
138
+ __RE_DUTY = re.compile(
139
+ r"""(\d{4})/(\d{4}) # times
140
+ (?>\s+([^#]+))? # flags
141
+ (?>\s*\#(.*))? # comment
142
+ """, re.VERBOSE)
143
+ __RE_SHORTDATE = re.compile(r"(\++)")
144
+ __RE_COMMENT = re.compile(r"#(.*)")
145
+
146
+ def __init__(self) -> None:
147
+ self.date: Optional[dt.date] = None
148
+ self.airports = Airports("", "")
149
+ self.aircraft = Aircraft("", "", "")
150
+ self.class_lookup: dict[str, str] = {}
151
+ self.crewlist: list[Crewmember] = []
152
+
153
+ def __parse_date(self, mo: re.Match) -> dt.date:
154
+ assert mo
155
+ try:
156
+ self.date = dt.date.fromisoformat(mo.group(1))
157
+ return self.date
158
+ except ValueError:
159
+ raise _VE(_VE.Code.BAD_DATE)
160
+
161
+ def __parse_nextdate(self, mo: re.Match) -> dt.date:
162
+ assert mo
163
+ if not self.date:
164
+ raise _VE(_VE.Code.MISSING_DATE)
165
+ self.date += dt.timedelta(len(mo.group(1)))
166
+ return self.date
167
+
168
+ def __parse_duty(self, mo: re.Match) -> Duty:
169
+ assert mo
170
+ if not self.date:
171
+ raise _VE(_VE.Code.MISSING_DATE)
172
+ start_str, end_str, flags, comment = (
173
+ X.strip() if X else "" for X in mo.group(1, 2, 3, 4))
174
+ dt_start, duration = self.__parse_times(start_str, end_str)
175
+ ftl_correction = 0
176
+ unused_flags = []
177
+ for f in _split_flags(flags):
178
+ if f[0] == "r":
179
+ ftl_correction += f[1] if f[1] else duration
180
+ else:
181
+ unused_flags.append(f)
182
+ return Duty(dt_start, duration, ftl_correction,
183
+ _join_flags(tuple(unused_flags)), comment)
184
+
185
+ def __parse_aircraft(self, mo: re.Match) -> Aircraft:
186
+ assert mo
187
+ reg, type_, class_ = (X.strip() if X else ""
188
+ for X in mo.group(1, 2, 3))
189
+ if class_:
190
+ self.class_lookup[type_] = class_
191
+ else:
192
+ class_ = self.class_lookup.get(type_, "")
193
+ self.aircraft = Aircraft(reg, type_, class_)
194
+ return self.aircraft
195
+
196
+ def __parse_crewlist(self, mo: re.Match) -> list[Crewmember]:
197
+ self.crewlist = []
198
+ try:
199
+ crew = mo.group(1).strip()
200
+ if not crew:
201
+ return []
202
+ for role, name in [X.strip().split(":", 1)
203
+ for X in crew.split(",")]:
204
+ role = role.strip()
205
+ if not role or " " in role:
206
+ raise ValueError
207
+ name = " ".join([X.strip() for X in name.split()])
208
+ if not name or ":" in name:
209
+ raise ValueError
210
+ self.crewlist.append(Crewmember(role, name))
211
+ except ValueError:
212
+ raise _VE(_VE.Code.BAD_CREWLIST)
213
+ return self.crewlist
214
+
215
+ def __parse_times(
216
+ self, t_start: str, t_end: str
217
+ ) -> tuple[dt.datetime, int]:
218
+ assert self.date
219
+ assert t_start and t_end
220
+ assert t_start.isnumeric() and t_end.isnumeric()
221
+ try:
222
+ ts = dt.time.fromisoformat(t_start) # Off blocks
223
+ te = dt.time.fromisoformat(t_end) # On blocks
224
+ except (ValueError, TypeError):
225
+ raise _VE(_VE.Code.BAD_TIME)
226
+ duration = (te.hour - ts.hour) * 60 + (te.minute - ts.minute)
227
+ if duration < 0:
228
+ duration += 1440
229
+ return (dt.datetime.combine(self.date, ts), duration)
230
+
231
+ def __parse_sector_flags(
232
+ self,
233
+ flags: Flags,
234
+ duration: int
235
+ ) -> tuple[Conditions, Roles, Landings, Flags]:
236
+ conditions, unused_flags = _process_conditions(flags, duration)
237
+ roles, unused_flags = _process_roles(unused_flags, duration)
238
+ if roles.p2 == duration:
239
+ landings = Landings()
240
+ else:
241
+ landings, unused_flags = _process_landings(unused_flags, duration,
242
+ conditions.night)
243
+ return conditions, roles, landings, unused_flags
244
+
245
+ def __captain(self, roles, duration) -> str:
246
+ captains = ["Self"] if roles.p1 else []
247
+ if roles.p1 < duration:
248
+ for m in self.crewlist:
249
+ if m.role.upper() == "CP":
250
+ captains.append(m.name)
251
+ return ", ".join(captains)
252
+
253
+ def __parse_sector(self, mo: re.Match) -> Sector:
254
+ if not self.date:
255
+ raise _VE(_VE.Code.MISSING_DATE)
256
+ if not (self.aircraft.reg and self.aircraft.type_):
257
+ raise _VE(_VE.Code.MISSING_AIRCRAFT)
258
+ (origin, dest, start_str, end_str, flag_str, comment
259
+ ) = (X.strip() if X else "" for X in mo.group(*range(1, 7)))
260
+ self.airports = Airports(
261
+ origin or self.airports.dest,
262
+ dest or self.airports.origin
263
+ )
264
+ if not self.airports.origin:
265
+ raise _VE(_VE.Code.MISSING_ORIGIN)
266
+ if not self.airports.dest:
267
+ raise _VE(_VE.Code.MISSING_DEST)
268
+ start, duration = self.__parse_times(start_str, end_str)
269
+ try:
270
+ flags = _split_flags(flag_str)
271
+ except ValueError:
272
+ raise _VE(_VE.Code.BAD_FLAGS)
273
+ conditions, roles, landings, unused_flags = (
274
+ self.__parse_sector_flags(flags, duration))
275
+ captain = self.__captain(roles, duration)
276
+ if not captain:
277
+ raise _VE(_VE.Code.MISSING_CAPTAIN)
278
+ return Sector(
279
+ start, duration, roles, conditions,
280
+ landings, self.aircraft, self.airports,
281
+ captain, _join_flags(unused_flags),
282
+ comment,
283
+ tuple(self.crewlist))
284
+
285
+ def __parse_comment(self, mo: re.Match) -> str:
286
+ return mo.group(1) or ""
287
+
288
+ def parse(
289
+ self, s: str,
290
+ hook: ParseHook = None
291
+ ) -> tuple[tuple[Duty, ...], tuple[Sector, ...]]:
292
+ """Extract duties and sectors from an eFJ string
293
+
294
+ :param s: A string containing data in eFJ format
295
+ :param hook: A function that, if specified, will be called after each
296
+ line is processed. The function will be called with the positional
297
+ parameters line text, line number, line type and parsed object.
298
+ Line type may be "blank", "comment", "date", "short_date",
299
+ "aircraft", "crewlist" or "sector".
300
+ :return: Two tuples containing the extracted data. The first is a tuple
301
+ of Duty structures and the second is a tuple of Sector structures.
302
+
303
+ """
304
+ duties: list[Duty] = []
305
+ sectors: list[Sector] = []
306
+ func_map = [
307
+ (Parser.__RE_SECTOR, self.__parse_sector, "sector"),
308
+ (Parser.__RE_CREWLIST, self.__parse_crewlist, "crewlist"),
309
+ (Parser.__RE_DATE, self.__parse_date, "date"),
310
+ (Parser.__RE_DUTY, self.__parse_duty, "duty"),
311
+ (Parser.__RE_AIRCRAFT, self.__parse_aircraft, "aircraft"),
312
+ (Parser.__RE_SHORTDATE, self.__parse_nextdate, "short_date"),
313
+ (Parser.__RE_COMMENT, self.__parse_comment, "comment"),
314
+ ]
315
+ for c, line in enumerate(s.splitlines()):
316
+ line = line.strip()
317
+ if not line:
318
+ hook and hook(line, c, 'blank', "")
319
+ continue
320
+ ret = None
321
+ for rexp, func, entry_type in func_map:
322
+ if mo := rexp.fullmatch(line):
323
+ try:
324
+ ret = func(mo)
325
+ except _VE as e:
326
+ raise ValidationError(c + 1, e.message, mo.group(0))
327
+ hook and hook(line, c + 1, entry_type, cast(ParseRet, ret))
328
+ break
329
+ else:
330
+ raise ValidationError(
331
+ c + 1, _VE(_VE.Code.BAD_SYNTAX).message, line)
332
+ if isinstance(ret, Duty):
333
+ duties.append(ret)
334
+ elif isinstance(ret, Sector):
335
+ sectors.append(ret)
336
+ return (tuple(duties), tuple(sectors))
337
+
338
+
339
+ def _split_flags(flag_str: str) -> Flags:
340
+ """Clean and split flag string.
341
+
342
+ :param flag_str: Input string such as "p2:20 ln:1 ins"
343
+ :returns: tuple of pairs like (("p2", 20), ("ln", 1), ("ins", None), ...)
344
+ :raises: ValueError if RHS of : is not convertible to integer
345
+ """
346
+ fields = flag_str.split()
347
+ if not fields:
348
+ return ()
349
+ return tuple((L[0], int(L[1])) if len(L) == 2 else (L[0], None)
350
+ for L in (X.split(":", 1) for X in fields))
351
+
352
+
353
+ def _join_flags(flags: Flags) -> tuple[str, ...]:
354
+ """Join Flags into a tuple of str"""
355
+ if not flags:
356
+ return ()
357
+ return tuple(f"{X[0]}:{X[1]}" if X[1] else X[0] for X in flags)
358
+
359
+
360
+ def _process_landings(
361
+ flags: Flags,
362
+ duration: int,
363
+ night: int
364
+ ) -> tuple[Landings, Flags]:
365
+ night_ldg, day_ldg = 0, 0
366
+ found_landing_flag = False
367
+ unused: list[Flag] = []
368
+ for f in flags:
369
+ match f[0]:
370
+ case "m":
371
+ found_landing_flag = True
372
+ case "ld":
373
+ day_ldg += 1 if f[1] is None else f[1]
374
+ found_landing_flag = True
375
+ case "ln":
376
+ night_ldg += 1 if f[1] is None else f[1]
377
+ found_landing_flag = True
378
+ case _:
379
+ unused.append(f)
380
+ if not found_landing_flag:
381
+ if duration == night:
382
+ night_ldg = 1
383
+ else:
384
+ day_ldg = 1
385
+ return Landings(day_ldg, night_ldg), tuple(unused)
386
+
387
+
388
+ def _process_roles(flags: Flags, duration: int) -> tuple[Roles, Flags]:
389
+ """Extract role durations from sector flags
390
+
391
+ :param flags: Sector flags
392
+ :param duration: Total duration of sector
393
+ :return: A Roles object and the flags input parameter with processed flags
394
+ removed.
395
+ """
396
+ p1s, p2, put, p0, ins = 0, 0, 0, 0, 0
397
+ unused: list[Flag] = []
398
+ for f in flags:
399
+ match f[0]:
400
+ case "p1s":
401
+ p1s += f[1] if f[1] else duration
402
+ case "p2":
403
+ p2 += f[1] if f[1] else duration
404
+ case "put":
405
+ put += f[1] if f[1] else duration
406
+ case "p0":
407
+ p0 += f[1] if f[1] else duration
408
+ case "ins":
409
+ ins += f[1] if f[1] else duration
410
+ if ins > duration:
411
+ raise _VE(_VE.Code.BAD_ROLE_FLAGS)
412
+ case _:
413
+ unused.append(f)
414
+ p1 = duration - (p1s + p2 + put + p0)
415
+ if p1 < 0:
416
+ raise _VE(_VE.Code.BAD_ROLE_FLAGS)
417
+ return Roles(p1, p1s, p2, put, ins), tuple(unused)
418
+
419
+
420
+ def _process_conditions(flags: Flags, dur: int) -> tuple[Conditions, Flags]:
421
+ """Extract sector conditions from sector flags
422
+
423
+ :param flags: The flags as a tuple of strings
424
+ :param dur: The total duration of the sector
425
+ :return: A tuple of the form (Conditions, unused_flags) where unused flags
426
+ is the input tuple with used flags removed.
427
+ """
428
+ night, vfr = 0, 0
429
+ unused: list[Flag] = []
430
+ for f in flags:
431
+ match f[0]:
432
+ case "n":
433
+ night += f[1] if f[1] else dur
434
+ if night > dur:
435
+ raise _VE(_VE.Code.BAD_CONDITION_FLAGS)
436
+ case "v":
437
+ vfr += f[1] if f[1] else dur
438
+ if vfr > dur:
439
+ raise _VE(_VE.Code.BAD_CONDITION_FLAGS)
440
+ case _:
441
+ unused.append(f)
442
+ return Conditions(night, dur - vfr), tuple(unused)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: efj-parser
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Parse an electronic Flight Journal file
5
5
  Home-page: https://github.com/JonHurst/efj_parser
6
6
  Author: Jon Hurst
@@ -13,6 +13,15 @@ Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.11
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: project-url
23
+ Dynamic: requires-python
24
+ Dynamic: summary
16
25
 
17
26
  # eFJ Parser #
18
27
 
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="efj-parser",
8
- version="0.9.2",
8
+ version="0.9.4",
9
9
  author="Jon Hurst",
10
10
  author_email="jon.a@hursts.org.uk",
11
11
  description="Parse an electronic Flight Journal file",