efj-parser 0.9.3__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
1
  Metadata-Version: 2.2
2
2
  Name: efj-parser
3
- Version: 0.9.3
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
@@ -1,4 +1,5 @@
1
1
  import re
2
+ from enum import IntEnum
2
3
  from typing import NamedTuple, Optional, Callable, Union, cast
3
4
  import datetime as dt
4
5
 
@@ -77,7 +78,38 @@ class ValidationError(Exception):
77
78
 
78
79
  class _VE(Exception):
79
80
 
80
- def __init__(self, message): self.message = message
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")
81
113
 
82
114
 
83
115
  ParseRet = Union[dt.date, Duty, Aircraft, list[Crewmember], Sector, str]
@@ -88,56 +120,76 @@ Flags = tuple[Flag, ...]
88
120
 
89
121
  class Parser():
90
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
+
91
146
  def __init__(self) -> None:
92
147
  self.date: Optional[dt.date] = None
93
148
  self.airports = Airports("", "")
94
149
  self.aircraft = Aircraft("", "", "")
95
150
  self.class_lookup: dict[str, str] = {}
96
- self.captain = ""
97
151
  self.crewlist: list[Crewmember] = []
98
152
 
99
153
  def __parse_date(self, mo: re.Match) -> dt.date:
154
+ assert mo
100
155
  try:
101
156
  self.date = dt.date.fromisoformat(mo.group(1))
102
157
  return self.date
103
158
  except ValueError:
104
- raise _VE("Incorrect Date entry")
159
+ raise _VE(_VE.Code.BAD_DATE)
105
160
 
106
161
  def __parse_nextdate(self, mo: re.Match) -> dt.date:
162
+ assert mo
107
163
  if not self.date:
108
- raise _VE("Short date without preceding Date entry")
164
+ raise _VE(_VE.Code.MISSING_DATE)
109
165
  self.date += dt.timedelta(len(mo.group(1)))
110
166
  return self.date
111
167
 
112
168
  def __parse_duty(self, mo: re.Match) -> Duty:
169
+ assert mo
113
170
  if not self.date:
114
- raise _VE("Duty entry without preceding Date entry")
115
- try:
116
- t_start = dt.time.fromisoformat(mo.group(1))
117
- t_end = dt.time.fromisoformat(mo.group(2))
118
- except ValueError:
119
- raise _VE("Invalid time string")
120
- dt_start = dt.datetime.combine(self.date, t_start)
121
- dt_end = dt.datetime.combine(self.date, t_end)
122
- if dt_end < dt_start:
123
- dt_end += dt.timedelta(days=1)
124
- duration = int((dt_end - dt_start).total_seconds() / 60)
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)
125
175
  ftl_correction = 0
126
176
  unused_flags = []
127
- for f in _split_flags(mo.group(3)):
177
+ for f in _split_flags(flags):
128
178
  if f[0] == "r":
129
179
  ftl_correction += f[1] if f[1] else duration
130
180
  else:
131
181
  unused_flags.append(f)
132
- comment = mo.group(4)[1:].strip() if mo.group(4) else ""
133
182
  return Duty(dt_start, duration, ftl_correction,
134
183
  _join_flags(tuple(unused_flags)), comment)
135
184
 
136
185
  def __parse_aircraft(self, mo: re.Match) -> Aircraft:
137
- reg, type_ = mo.group(1).strip(), mo.group(2).strip()
138
- if mo.lastindex == 3:
139
- self.class_lookup[type_] = mo.group(3).strip()
140
- class_ = self.class_lookup.get(type_, "")
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_, "")
141
193
  self.aircraft = Aircraft(reg, type_, class_)
142
194
  return self.aircraft
143
195
 
@@ -157,37 +209,24 @@ class Parser():
157
209
  raise ValueError
158
210
  self.crewlist.append(Crewmember(role, name))
159
211
  except ValueError:
160
- raise _VE("Incorrect crew listing format")
161
- captains = [X.name for X in self.crewlist if X.role == "CP"]
162
- if captains:
163
- self.captain = ", ".join(captains)
212
+ raise _VE(_VE.Code.BAD_CREWLIST)
164
213
  return self.crewlist
165
214
 
166
- def __pre_validate_sector(self, mo: re.Match) -> None:
167
- if not self.date:
168
- raise _VE("Sector with no preceding Date entry")
169
- if not (self.aircraft.reg and self.aircraft.type_):
170
- raise _VE("Sector with no preceding Aircraft entry")
171
- if not mo.group(1) and not self.airports.dest: # Blank destination
172
- raise _VE("Blank From without previous To")
173
- if not mo.group(2) and not self.airports.origin: # Blank origin
174
- raise _VE("Blank To without previous From")
175
-
176
- def __parse_sector_times(
215
+ def __parse_times(
177
216
  self, t_start: str, t_end: str
178
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()
179
221
  try:
180
222
  ts = dt.time.fromisoformat(t_start) # Off blocks
181
223
  te = dt.time.fromisoformat(t_end) # On blocks
182
- except ValueError:
183
- raise _VE("Incorrect time field")
184
- start = dt.datetime.combine(cast(dt.date, self.date), ts)
185
- duration = int(
186
- (dt.datetime.combine(cast(dt.date, self.date), te) - start)
187
- .total_seconds() // 60)
224
+ except (ValueError, TypeError):
225
+ raise _VE(_VE.Code.BAD_TIME)
226
+ duration = (te.hour - ts.hour) * 60 + (te.minute - ts.minute)
188
227
  if duration < 0:
189
228
  duration += 1440
190
- return (start, duration)
229
+ return (dt.datetime.combine(self.date, ts), duration)
191
230
 
192
231
  def __parse_sector_flags(
193
232
  self,
@@ -203,45 +242,53 @@ class Parser():
203
242
  conditions.night)
204
243
  return conditions, roles, landings, unused_flags
205
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
+
206
253
  def __parse_sector(self, mo: re.Match) -> Sector:
207
- self.__pre_validate_sector(mo)
208
- self.airports = Airports(mo.group(1) or self.airports.dest,
209
- mo.group(2) or self.airports.origin)
210
- start, duration = self.__parse_sector_times(mo.group(3), mo.group(4))
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)
211
269
  try:
212
- flags = _split_flags(mo.group(5))
270
+ flags = _split_flags(flag_str)
213
271
  except ValueError:
214
- raise _VE("Bad flags")
272
+ raise _VE(_VE.Code.BAD_FLAGS)
215
273
  conditions, roles, landings, unused_flags = (
216
274
  self.__parse_sector_flags(flags, duration))
217
- if roles.p1 == duration:
218
- self.captain = "Self"
219
- else:
220
- if not self.captain:
221
- raise _VE("No Captain specified")
222
- if roles.p1:
223
- self.captain = f"Self, {self.captain}"
224
- comment = ""
225
- if mo.group(6): # Comment
226
- comment = mo.group(6)[1:].strip()
275
+ captain = self.__captain(roles, duration)
276
+ if not captain:
277
+ raise _VE(_VE.Code.MISSING_CAPTAIN)
227
278
  return Sector(
228
- start,
229
- duration,
230
- roles,
231
- conditions,
232
- landings,
233
- self.aircraft,
234
- self.airports,
235
- self.captain,
236
- _join_flags(unused_flags),
279
+ start, duration, roles, conditions,
280
+ landings, self.aircraft, self.airports,
281
+ captain, _join_flags(unused_flags),
237
282
  comment,
238
283
  tuple(self.crewlist))
239
284
 
240
285
  def __parse_comment(self, mo: re.Match) -> str:
241
286
  return mo.group(1) or ""
242
287
 
243
- def parse(self, s: str, hook: ParseHook = None
244
- ) -> tuple[tuple[Duty, ...], tuple[Sector, ...]]:
288
+ def parse(
289
+ self, s: str,
290
+ hook: ParseHook = None
291
+ ) -> tuple[tuple[Duty, ...], tuple[Sector, ...]]:
245
292
  """Extract duties and sectors from an eFJ string
246
293
 
247
294
  :param s: A string containing data in eFJ format
@@ -257,21 +304,13 @@ class Parser():
257
304
  duties: list[Duty] = []
258
305
  sectors: list[Sector] = []
259
306
  func_map = [
260
- (re.compile(r"(\w*)/(\w*)\s*(\d{4})/(\d{4})([^#]*+)#?(.*)"),
261
- self.__parse_sector, "sector"),
262
- (re.compile(r"\{([^}]*)}"),
263
- self.__parse_crewlist, "crewlist"),
264
- (re.compile(r"(\d{4}-\d{2}-\d{2})"),
265
- self.__parse_date, "date"),
266
- (re.compile(r"(\d{4})/(\d{4})([^#]*+)#?(.*)"),
267
- self.__parse_duty, "duty"),
268
- (re.compile(r"([-\w]{2,10})(?>\s*:\s*)([-\w]+)"
269
- r"(?:\s*:\s*(mc|spse|spme))?"),
270
- self.__parse_aircraft, "aircraft"),
271
- (re.compile(r"(\++)"),
272
- self.__parse_nextdate, "short_date"),
273
- (re.compile(r"#(.*)"),
274
- self.__parse_comment, "comment"),
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"),
275
314
  ]
276
315
  for c, line in enumerate(s.splitlines()):
277
316
  line = line.strip()
@@ -288,7 +327,8 @@ class Parser():
288
327
  hook and hook(line, c + 1, entry_type, cast(ParseRet, ret))
289
328
  break
290
329
  else:
291
- raise _VE("Bad syntax")
330
+ raise ValidationError(
331
+ c + 1, _VE(_VE.Code.BAD_SYNTAX).message, line)
292
332
  if isinstance(ret, Duty):
293
333
  duties.append(ret)
294
334
  elif isinstance(ret, Sector):
@@ -303,13 +343,17 @@ def _split_flags(flag_str: str) -> Flags:
303
343
  :returns: tuple of pairs like (("p2", 20), ("ln", 1), ("ins", None), ...)
304
344
  :raises: ValueError if RHS of : is not convertible to integer
305
345
  """
306
- fields = flag_str.strip().split()
346
+ fields = flag_str.split()
347
+ if not fields:
348
+ return ()
307
349
  return tuple((L[0], int(L[1])) if len(L) == 2 else (L[0], None)
308
350
  for L in (X.split(":", 1) for X in fields))
309
351
 
310
352
 
311
353
  def _join_flags(flags: Flags) -> tuple[str, ...]:
312
354
  """Join Flags into a tuple of str"""
355
+ if not flags:
356
+ return ()
313
357
  return tuple(f"{X[0]}:{X[1]}" if X[1] else X[0] for X in flags)
314
358
 
315
359
 
@@ -364,12 +408,12 @@ def _process_roles(flags: Flags, duration: int) -> tuple[Roles, Flags]:
364
408
  case "ins":
365
409
  ins += f[1] if f[1] else duration
366
410
  if ins > duration:
367
- raise _VE("Bad instructor flag")
411
+ raise _VE(_VE.Code.BAD_ROLE_FLAGS)
368
412
  case _:
369
413
  unused.append(f)
370
414
  p1 = duration - (p1s + p2 + put + p0)
371
415
  if p1 < 0:
372
- raise _VE("Too many roles")
416
+ raise _VE(_VE.Code.BAD_ROLE_FLAGS)
373
417
  return Roles(p1, p1s, p2, put, ins), tuple(unused)
374
418
 
375
419
 
@@ -388,11 +432,11 @@ def _process_conditions(flags: Flags, dur: int) -> tuple[Conditions, Flags]:
388
432
  case "n":
389
433
  night += f[1] if f[1] else dur
390
434
  if night > dur:
391
- raise _VE("Night duration more than flight duration")
435
+ raise _VE(_VE.Code.BAD_CONDITION_FLAGS)
392
436
  case "v":
393
437
  vfr += f[1] if f[1] else dur
394
438
  if vfr > dur:
395
- raise _VE("VFR duration more than flight duration")
439
+ raise _VE(_VE.Code.BAD_CONDITION_FLAGS)
396
440
  case _:
397
441
  unused.append(f)
398
442
  return Conditions(night, dur - vfr), tuple(unused)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: efj-parser
3
- Version: 0.9.3
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
@@ -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.3",
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",
@@ -4,6 +4,134 @@ import datetime as dt
4
4
  import efj_parser as efj
5
5
 
6
6
 
7
+ class TestRegexp(unittest.TestCase):
8
+
9
+ def test_shortdate(self):
10
+ f = efj.Parser._Parser__RE_SHORTDATE.fullmatch
11
+ # Next day
12
+ self.assertEqual(f("+").group(1), "+")
13
+ # Next next next day
14
+ self.assertEqual(f("+++").group(1), "+++")
15
+ # Can only be +
16
+ self.assertIsNone(f("++-+"))
17
+ # Empty gives None
18
+ self.assertIsNone(f(""))
19
+
20
+ def test_date(self):
21
+ f = efj.Parser._Parser__RE_DATE.fullmatch
22
+ self.assertEqual(f("2025-01-01").group(1), "2025-01-01")
23
+ # Must be ISO format
24
+ self.assertIsNone(f("01/01/2025"))
25
+ # Bad dates are OK -- will be detected when processing
26
+ self.assertEqual(f("2025-21-01").group(1), "2025-21-01")
27
+ # Can't be anything on the end
28
+ self.assertIsNone(f("2025-01-01A"))
29
+ # ... or the start
30
+ self.assertIsNone(f("A2025-01-01"))
31
+ # Dashes are mandatory
32
+ self.assertIsNone(f("20250101"))
33
+ # Empty gives None
34
+ self.assertIsNone(f(""))
35
+
36
+ def test_aircraft(self):
37
+ f = efj.Parser._Parser__RE_AIRCRAFT.fullmatch
38
+ for class_ in ["mc", "spse", "spme"]:
39
+ self.assertEqual(
40
+ f("G-ABCD:A320:" + class_).group(1, 2, 3),
41
+ ("G-ABCD", "A320", class_)
42
+ )
43
+ # Other classes are no good
44
+ self.assertIsNone(f("G-ABCD:A320:mcme"))
45
+ # No class is fine
46
+ self.assertEqual(
47
+ f("G-ABCD:A320").group(1, 2, 3),
48
+ ("G-ABCD", "A320", None)
49
+ )
50
+ # ... although trailing colon not allowed
51
+ self.assertIsNone(f("G-ABCD:A320:"))
52
+ # Space around colons is fine
53
+ self.assertEqual(
54
+ f("G-ABCD : A320 : mc").group(1, 2, 3),
55
+ ("G-ABCD", "A320", "mc")
56
+ )
57
+ # Empty gives None
58
+ self.assertIsNone(f(""))
59
+
60
+ def test_comment(self):
61
+ f = efj.Parser._Parser__RE_COMMENT.fullmatch
62
+ self.assertEqual(f("# my comment").group(1), " my comment")
63
+ # Multiple #
64
+ self.assertEqual(f("## my #comment").group(1), "# my #comment")
65
+ # Empty comment
66
+ self.assertEqual(f("#").group(1), "")
67
+ # Empty
68
+ self.assertIsNone(f(""))
69
+
70
+ def test_crew(self):
71
+ f = efj.Parser._Parser__RE_CREWLIST.fullmatch
72
+ self.assertEqual(f("{ cp: Pugwash, fo: Bloggs}").group(1),
73
+ " cp: Pugwash, fo: Bloggs")
74
+ # Double close shouldn't match
75
+ self.assertIsNone(f("{ cp: Pugwash, fo: Bloggs}}"))
76
+ # Otherwise pretty much anything goes -- errors detected later
77
+ self.assertEqual(f("{{sdkfjl cp: :fo}").group(1),
78
+ "{sdkfjl cp: :fo")
79
+ # Empty
80
+ self.assertIsNone(f(""))
81
+
82
+ def test_duty(self):
83
+ f = efj.Parser._Parser__RE_DUTY.fullmatch
84
+ # just times
85
+ self.assertEqual(f("1000/1100").group(1, 2, 3, 4),
86
+ ("1000", "1100", None, None))
87
+ # with a comment
88
+ self.assertEqual(f("1000/1100#test").group(1, 2, 3, 4),
89
+ ("1000", "1100", None, "test"))
90
+ # with flags
91
+ self.assertEqual(f("1000/1100 r a:10").group(1, 2, 3, 4),
92
+ ("1000", "1100", "r a:10", None))
93
+ # comment and flags
94
+ self.assertEqual(f("1000/1100 r:30 # test").group(1, 2, 3, 4),
95
+ ("1000", "1100", "r:30 ", " test"))
96
+ # empty
97
+ self.assertIsNone(f(""))
98
+ # flag without space
99
+ self.assertIsNone(f("1000/1100r "))
100
+ # times with suffix
101
+ self.assertIsNone(f("1000z/1100z "))
102
+ # letters
103
+ self.assertIsNone(f("abcd/efgh"))
104
+
105
+ def test_sector(self):
106
+ f = efj.Parser._Parser__RE_SECTOR.fullmatch
107
+ # simplest
108
+ self.assertEqual(
109
+ f("BRS/FNC 1000/1300").group(1, 2, 3, 4, 5, 6),
110
+ ("BRS", "FNC", "1000", "1300", None, None))
111
+ # flags
112
+ self.assertEqual(
113
+ f("BRS/FNC 1000/1300 n:30 m").group(1, 2, 3, 4, 5, 6),
114
+ ("BRS", "FNC", "1000", "1300", "n:30 m", None))
115
+ # comment
116
+ self.assertEqual(
117
+ f("BRS/FNC 1000/1300 # test").group(1, 2, 3, 4, 5, 6),
118
+ ("BRS", "FNC", "1000", "1300", None, " test"))
119
+ # flags and comment
120
+ self.assertEqual(
121
+ f("BRS/FNC 1000/1300 m# test").group(1, 2, 3, 4, 5, 6),
122
+ ("BRS", "FNC", "1000", "1300", "m", " test"))
123
+ # underscore
124
+ self.assertEqual(
125
+ f("my_house/FNC 1000/1300"). group(1, 2, 3, 4, 5, 6),
126
+ ("my_house", "FNC", "1000", "1300", None, None))
127
+ # no underscore
128
+ self.assertIsNone(f("my house/FNC 1000/1300"))
129
+ # time zones
130
+ self.assertIsNone(f("BRS/FNC 1000z/1300z"))
131
+ # empty
132
+ self.assertIsNone(f(""))
133
+
134
+
7
135
  class TestParser(unittest.TestCase):
8
136
 
9
137
  def test_basic(self):
@@ -215,21 +343,21 @@ BRS/BFS 1100/1200 v n:30
215
343
  efj.Parser().parse(data)
216
344
  self.assertEqual(
217
345
  str(e.exception),
218
- "Line 1: [Incorrect crew listing format] {just a string}")
346
+ "Line 1: [Invalid crew list] {just a string}")
219
347
  with self.subTest("No name"):
220
348
  data = "2024-01-22\n{CP:, FO:Bloggs}"
221
349
  with self.assertRaises(efj.ValidationError) as e:
222
350
  efj.Parser().parse(data)
223
351
  self.assertEqual(
224
352
  str(e.exception),
225
- "Line 2: [Incorrect crew listing format] {CP:, FO:Bloggs}")
353
+ "Line 2: [Invalid crew list] {CP:, FO:Bloggs}")
226
354
  with self.subTest("No comma"):
227
355
  data = "{ CP: Bloggs1 FO:Bloggs2}"
228
356
  with self.assertRaises(efj.ValidationError) as e:
229
357
  efj.Parser().parse(data)
230
358
  self.assertEqual(
231
359
  str(e.exception),
232
- "Line 1: [Incorrect crew listing format]"
360
+ "Line 1: [Invalid crew list]"
233
361
  " { CP: Bloggs1 FO:Bloggs2}")
234
362
  with self.subTest("No colon"):
235
363
  data = "{just a, string}"
@@ -237,14 +365,14 @@ BRS/BFS 1100/1200 v n:30
237
365
  efj.Parser().parse(data)
238
366
  self.assertEqual(
239
367
  str(e.exception),
240
- "Line 1: [Incorrect crew listing format] {just a, string}")
368
+ "Line 1: [Invalid crew list] {just a, string}")
241
369
  with self.subTest("Multi word role"):
242
370
  data = "{just a: string}"
243
371
  with self.assertRaises(efj.ValidationError) as e:
244
372
  efj.Parser().parse(data)
245
373
  self.assertEqual(
246
374
  str(e.exception),
247
- "Line 1: [Incorrect crew listing format] {just a: string}")
375
+ "Line 1: [Invalid crew list] {just a: string}")
248
376
 
249
377
  def test_bad_date(self):
250
378
  data = "2024-02-30"
@@ -252,7 +380,7 @@ BRS/BFS 1100/1200 v n:30
252
380
  efj.Parser().parse(data)
253
381
  self.assertEqual(
254
382
  str(e.exception),
255
- "Line 1: [Incorrect Date entry] 2024-02-30")
383
+ "Line 1: [Invalid date format] 2024-02-30")
256
384
 
257
385
  def test_bad_nextdate(self):
258
386
  data = "2024-02-01\n++-"
@@ -260,7 +388,7 @@ BRS/BFS 1100/1200 v n:30
260
388
  efj.Parser().parse(data)
261
389
  self.assertEqual(
262
390
  str(e.exception),
263
- "Line 2: [Bad syntax] ++-")
391
+ "Line 2: [Invalid syntax] ++-")
264
392
 
265
393
  def test_bad_duty(self):
266
394
  with self.subTest("No preceding date"):
@@ -269,21 +397,21 @@ BRS/BFS 1100/1200 v n:30
269
397
  efj.Parser().parse(data)
270
398
  self.assertEqual(
271
399
  str(e.exception),
272
- "Line 1: [Duty entry without preceding Date entry] 1000/1100")
400
+ "Line 1: [Prior date specifier required] 1000/1100")
273
401
  with self.subTest("Bad time format"):
274
402
  data = "2024-02-01\n2200/2400"
275
403
  with self.assertRaises(efj.ValidationError) as e:
276
404
  efj.Parser().parse(data)
277
405
  self.assertEqual(
278
406
  str(e.exception),
279
- "Line 2: [Invalid time string] 2200/2400")
407
+ "Line 2: [Invalid time format] 2200/2400")
280
408
  with self.subTest("Hyphen instead of slash"):
281
409
  data = "2024-02-01\n2200-2400"
282
410
  with self.assertRaises(efj.ValidationError) as e:
283
411
  efj.Parser().parse(data)
284
412
  self.assertEqual(
285
413
  str(e.exception),
286
- "Line 2: [Bad syntax] 2200-2400")
414
+ "Line 2: [Invalid syntax] 2200-2400")
287
415
 
288
416
 
289
417
  class TestSectorFlags (unittest.TestCase):
@@ -379,11 +507,11 @@ class TestSectorFlags (unittest.TestCase):
379
507
  with self.subTest("Role duration > duration"):
380
508
  with self.assertRaises(efj._VE) as ve:
381
509
  efj._process_roles((("p1s", 2),), 1)
382
- self.assertEqual(ve.exception.message, "Too many roles")
510
+ self.assertEqual(ve.exception.message, "Invalid role flags")
383
511
  with self.subTest("Two untimed roles"):
384
512
  with self.assertRaises(efj._VE) as ve:
385
513
  efj._process_roles((("p1s", None), ("put", None)), 1)
386
- self.assertEqual(ve.exception.message, "Too many roles")
514
+ self.assertEqual(ve.exception.message, "Invalid role flags")
387
515
  with self.subTest("Instructor flag"):
388
516
  self.assertEqual(
389
517
  efj._process_roles((("ins", None),), 1),
@@ -426,12 +554,12 @@ class TestSectorFlags (unittest.TestCase):
426
554
  with self.assertRaises(efj._VE) as ve:
427
555
  efj._process_conditions((("v", 2),), 1),
428
556
  self.assertEqual(ve.exception.message,
429
- "VFR duration more than flight duration")
557
+ "Invalid flight condition flags")
430
558
  with self.subTest("Night duration > duration"):
431
559
  with self.assertRaises(efj._VE) as ve:
432
560
  efj._process_conditions((("n", 2),), 1),
433
561
  self.assertEqual(ve.exception.message,
434
- "Night duration more than flight duration")
562
+ "Invalid flight condition flags")
435
563
 
436
564
 
437
565
  class TestUtility(unittest.TestCase):
@@ -449,3 +577,182 @@ class TestUtility(unittest.TestCase):
449
577
  )
450
578
  exp = ("p2", "put:20", "ln:1", "ins")
451
579
  self.assertEqual(res, exp)
580
+
581
+
582
+ class TestPrivateMethods(unittest.TestCase):
583
+
584
+ def test_parse_times(self):
585
+ parser = efj.Parser()
586
+ if __debug__:
587
+ with self.assertRaises(AssertionError) as e:
588
+ efj.Parser._Parser__parse_times(parser, "1000", "1100")
589
+ parser.date = dt.date(2025, 1, 1)
590
+ ret = efj.Parser._Parser__parse_times(parser, "1000", "1100")
591
+ self.assertEqual(ret, (dt.datetime(2025, 1, 1, 10, 0), 60))
592
+ ret = efj.Parser._Parser__parse_times(parser, "2300", "0100")
593
+ self.assertEqual(ret, (dt.datetime(2025, 1, 1, 23, 0), 120))
594
+ with self.assertRaises(efj._VE) as e:
595
+ efj.Parser._Parser__parse_times(parser, "1000", "3100")
596
+ self.assertEqual(e.exception.code, efj._VE.Code.BAD_TIME)
597
+ if __debug__:
598
+ with self.assertRaises(AssertionError) as e:
599
+ efj.Parser._Parser__parse_times(parser, "abcd", "1100")
600
+ with self.assertRaises(AssertionError) as e:
601
+ efj.Parser._Parser__parse_times(parser, None, "1100")
602
+ if not __debug__:
603
+ with self.assertRaises(efj._VE) as e:
604
+ efj.Parser._Parser__parse_times(parser, "abcd", "1100")
605
+ self.assertEqual(e.exception.code, efj._VE.Code.BAD_TIME)
606
+ with self.assertRaises(efj._VE) as e:
607
+ efj.Parser._Parser__parse_times(parser, None, "1100")
608
+ self.assertEqual(e.exception.code, efj._VE.Code.BAD_TIME)
609
+
610
+ def test_parse_date(self):
611
+ parser = efj.Parser()
612
+ mo = efj.Parser._Parser__RE_DATE.fullmatch("2025-01-01")
613
+ self.assertEqual(
614
+ efj.Parser._Parser__parse_date(parser, mo),
615
+ dt.date(2025, 1, 1))
616
+ mo = efj.Parser._Parser__RE_DATE.fullmatch("2025-21-01")
617
+ with self.assertRaises(efj._VE) as e:
618
+ efj.Parser._Parser__parse_date(parser, mo)
619
+ self.assertEqual(e.exception.code, efj._VE.Code.BAD_DATE)
620
+ if __debug__:
621
+ with self.assertRaises(AssertionError):
622
+ efj.Parser._Parser__parse_date(parser, None)
623
+
624
+ def test_nextdate(self):
625
+ parser = efj.Parser()
626
+ parser.date = dt.date(2024, 12, 31)
627
+ mo = efj.Parser._Parser__RE_SHORTDATE.fullmatch("+")
628
+ efj.Parser._Parser__parse_nextdate(parser, mo)
629
+ self.assertEqual(parser.date, dt.date(2025, 1, 1))
630
+ mo = efj.Parser._Parser__RE_SHORTDATE.fullmatch("+++")
631
+ efj.Parser._Parser__parse_nextdate(parser, mo)
632
+ self.assertEqual(parser.date, dt.date(2025, 1, 4))
633
+ if __debug__:
634
+ with self.assertRaises(AssertionError):
635
+ efj.Parser._Parser__parse_nextdate(parser, None)
636
+
637
+ def test_duty(self):
638
+ parser = efj.Parser()
639
+ parser.date = dt.date(2024, 12, 31)
640
+ mo = efj.Parser._Parser__RE_DUTY.fullmatch("1000/1100")
641
+ res = efj.Parser._Parser__parse_duty(parser, mo)
642
+ expected = efj.Duty(dt.datetime(2024, 12, 31, 10, 0), 60)
643
+ self.assertEqual(res, expected)
644
+ mo = efj.Parser._Parser__RE_DUTY.fullmatch("1000/1100 r:30")
645
+ res = efj.Parser._Parser__parse_duty(parser, mo)
646
+ expected = expected._replace(ftl_correction=30)
647
+ self.assertEqual(res, expected)
648
+ mo = efj.Parser._Parser__RE_DUTY.fullmatch("1000/1100 r")
649
+ res = efj.Parser._Parser__parse_duty(parser, mo)
650
+ expected = expected._replace(ftl_correction=60)
651
+ self.assertEqual(res, expected)
652
+ mo = efj.Parser._Parser__RE_DUTY.fullmatch(
653
+ "1000/1100 r t1 t2:10 # comment")
654
+ res = efj.Parser._Parser__parse_duty(parser, mo)
655
+ expected = expected._replace(
656
+ extra_flags=("t1", "t2:10"), comment="comment")
657
+ self.assertEqual(res, expected)
658
+ mo = efj.Parser._Parser__RE_DUTY.fullmatch("1000/1100#comment")
659
+ res = efj.Parser._Parser__parse_duty(parser, mo)
660
+ expected = efj.Duty(
661
+ dt.datetime(2024, 12, 31, 10, 0), 60, 0, (), "comment")
662
+ self.assertEqual(res, expected)
663
+ if __debug__:
664
+ with self.assertRaises(AssertionError):
665
+ efj.Parser._Parser__parse_duty(parser, None)
666
+
667
+ def test_aircraft(self):
668
+ parser = efj.Parser()
669
+ mo = efj.Parser._Parser__RE_AIRCRAFT.fullmatch("G-ABCD:A320:mc")
670
+ res = efj.Parser._Parser__parse_aircraft(parser, mo)
671
+ exp = efj.Aircraft("G-ABCD", "A320", "mc")
672
+ self.assertEqual(res, exp)
673
+ mo = efj.Parser._Parser__RE_AIRCRAFT.fullmatch("GA-BC-DE : A-320-neo")
674
+ res = efj.Parser._Parser__parse_aircraft(parser, mo)
675
+ exp = efj.Aircraft("GA-BC-DE", "A-320-neo", "")
676
+ self.assertEqual(res, exp)
677
+
678
+ def test_crewlist(self):
679
+ parser = efj.Parser()
680
+ c = efj.Parser._Parser__RE_CREWLIST.fullmatch
681
+ f = efj.Parser._Parser__parse_crewlist
682
+ res = f(parser, c("{ cp: pugwash, fo: bloggs}"))
683
+ exp = [
684
+ efj.Crewmember("cp", "pugwash"),
685
+ efj.Crewmember("fo", "bloggs")
686
+ ]
687
+ self.assertEqual(res, exp)
688
+ res = efj.Parser._Parser__parse_crewlist(parser, c("{}"))
689
+ self.assertEqual(res, [])
690
+ with self.assertRaises(efj._VE) as e:
691
+ f(parser, c("{ cp: fo: bloggs }"))
692
+ self.assertEqual(e.exception.code, efj._VE.Code.BAD_CREWLIST)
693
+ with self.assertRaises(efj._VE) as e:
694
+ f(parser, c("{ cp fo: bloggs }"))
695
+ self.assertEqual(e.exception.code, efj._VE.Code.BAD_CREWLIST)
696
+ with self.assertRaises(efj._VE) as e:
697
+ f(parser, c("{{sdkfjl cp: :fo}"))
698
+ self.assertEqual(e.exception.code, efj._VE.Code.BAD_CREWLIST)
699
+
700
+ def test_sector(self):
701
+ parser = efj.Parser()
702
+ default_sector = efj.Sector(
703
+ start=dt.datetime(2025, 1, 1, 10),
704
+ total=60,
705
+ roles=efj.Roles(p1=60),
706
+ conditions=efj.Conditions(ifr=60),
707
+ landings=efj.Landings(1, 0),
708
+ aircraft=efj.Aircraft("G-ABCD", "A320", ""),
709
+ airports=efj.Airports("BRS", "FNC"),
710
+ captain="Self",
711
+ extra_flags=(),
712
+ comment="",
713
+ crew=())
714
+ r = efj.Parser._Parser__RE_SECTOR.fullmatch
715
+ f = efj.Parser._Parser__parse_sector
716
+ # parse errors
717
+ parser.date = dt.date(2025, 1, 1)
718
+ with self.assertRaises(efj._VE) as e:
719
+ f(parser, r("BRS/FNC 1000/1100"))
720
+ self.assertEqual(e.exception.code, efj._VE.Code.MISSING_AIRCRAFT)
721
+ parser.date = None
722
+ parser.aircraft = efj.Aircraft("G-ABCD", "A320", "")
723
+ with self.assertRaises(efj._VE) as e:
724
+ f(parser, r("BRS/FNC 1000/1100"))
725
+ self.assertEqual(e.exception.code, efj._VE.Code.MISSING_DATE)
726
+ parser.date = dt.date(2025, 1, 1)
727
+ parser.airports = efj.Airports("", "FNC")
728
+ with self.assertRaises(efj._VE) as e:
729
+ f(parser, r("/ 1000/1100"))
730
+ self.assertEqual(e.exception.code, efj._VE.Code.MISSING_DEST)
731
+ parser.airports = efj.Airports("BRS", "")
732
+ with self.assertRaises(efj._VE) as e:
733
+ f(parser, r("/ 1000/1100"))
734
+ self.assertEqual(e.exception.code, efj._VE.Code.MISSING_ORIGIN)
735
+ with self.assertRaises(efj._VE) as e:
736
+ f(parser, r("BRS/FNC 1000/1100 n:a"))
737
+ self.assertEqual(e.exception.code, efj._VE.Code.BAD_FLAGS)
738
+ with self.assertRaises(efj._VE) as e:
739
+ f(parser, r("BRS/FNC 1000/1100 p1s"))
740
+ self.assertEqual(e.exception.code, efj._VE.Code.MISSING_CAPTAIN)
741
+ # successful parse
742
+ self.assertEqual(f(parser, r("BRS/FNC 1000/1100")), default_sector)
743
+ self.assertEqual(
744
+ f(parser, r("/BHX 1000/1100")),
745
+ default_sector._replace(airports=efj.Airports("FNC", "BHX")))
746
+ self.assertEqual(
747
+ f(parser, r("/ 1000/1100")),
748
+ default_sector._replace(airports=efj.Airports("BHX", "FNC")))
749
+ self.assertEqual(f(parser, r("BRS/FNC 1000/1100#test comment")),
750
+ default_sector._replace(comment="test comment"))
751
+ parser.crewlist = [efj.Crewmember(role="cp", name="bloggs"),]
752
+ self.assertEqual(f(parser, r("BRS/FNC 1000/1100 p1s v n")),
753
+ default_sector._replace(
754
+ roles=efj.Roles(p1=0, p1s=60),
755
+ landings=efj.Landings(day=0, night=1),
756
+ captain="bloggs",
757
+ crew=(efj.Crewmember("cp", "bloggs"),),
758
+ conditions=efj.Conditions(night=60)))
File without changes
File without changes
File without changes
File without changes