aims-convert 2.1__tar.gz → 2.2__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.
Files changed (27) hide show
  1. {aims_convert-2.1 → aims_convert-2.2}/PKG-INFO +3 -1
  2. {aims_convert-2.1 → aims_convert-2.2}/aims/cli.py +9 -3
  3. {aims_convert-2.1 → aims_convert-2.2}/aims/data_structures.py +6 -2
  4. {aims_convert-2.1 → aims_convert-2.2}/aims/gui.py +7 -5
  5. {aims_convert-2.1 → aims_convert-2.2}/aims/logbook_report.py +7 -5
  6. {aims_convert-2.1 → aims_convert-2.2}/aims/output.py +67 -44
  7. aims_convert-2.2/aims/parse.py +16 -0
  8. {aims_convert-2.1 → aims_convert-2.2}/aims/roster.py +38 -25
  9. aims_convert-2.2/aims/version.py +4 -0
  10. {aims_convert-2.1 → aims_convert-2.2}/aims_convert.egg-info/PKG-INFO +3 -1
  11. {aims_convert-2.1 → aims_convert-2.2}/aims_convert.egg-info/SOURCES.txt +3 -0
  12. {aims_convert-2.1 → aims_convert-2.2}/aims_convert.egg-info/requires.txt +3 -0
  13. {aims_convert-2.1 → aims_convert-2.2}/setup.py +4 -1
  14. aims_convert-2.2/tests/test_compound.py +34 -0
  15. {aims_convert-2.1 → aims_convert-2.2}/tests/test_logbook.py +0 -1
  16. aims_convert-2.2/tests/test_output.py +231 -0
  17. {aims_convert-2.1 → aims_convert-2.2}/tests/test_roster.py +26 -32
  18. aims_convert-2.1/aims/parse.py +0 -24
  19. {aims_convert-2.1 → aims_convert-2.2}/README.md +0 -0
  20. {aims_convert-2.1 → aims_convert-2.2}/aims/__init__.py +0 -0
  21. {aims_convert-2.1 → aims_convert-2.2}/aims/py.typed +0 -0
  22. {aims_convert-2.1 → aims_convert-2.2}/aims_convert.egg-info/dependency_links.txt +0 -0
  23. {aims_convert-2.1 → aims_convert-2.2}/aims_convert.egg-info/entry_points.txt +0 -0
  24. {aims_convert-2.1 → aims_convert-2.2}/aims_convert.egg-info/top_level.txt +0 -0
  25. {aims_convert-2.1 → aims_convert-2.2}/pyproject.toml +0 -0
  26. {aims_convert-2.1 → aims_convert-2.2}/setup.cfg +0 -0
  27. {aims_convert-2.1 → aims_convert-2.2}/tests/test_parse.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: aims-convert
3
- Version: 2.1
3
+ Version: 2.2
4
4
  Summary: Extract useful information from AIMS
5
5
  Home-page: https://github.com/JonHurst/aims-convert
6
6
  Author: Jon Hurst
@@ -15,6 +15,8 @@ Description-Content-Type: text/markdown
15
15
  Requires-Dist: nightflight
16
16
  Requires-Dist: bs4
17
17
  Requires-Dist: html5lib
18
+ Provides-Extra: unit-tests
19
+ Requires-Dist: freezegun; extra == "unit-tests"
18
20
 
19
21
  # AIMS Roster Data Extraction #
20
22
 
@@ -5,6 +5,7 @@ import argparse
5
5
 
6
6
  from aims.parse import parse
7
7
  import aims.output as output
8
+ from aims.version import VERSION
8
9
 
9
10
 
10
11
  def _args():
@@ -12,14 +13,17 @@ def _args():
12
13
  description=(
13
14
  'Process an AIMS detailed roster into various useful formats.'))
14
15
  parser.add_argument('format',
15
- choices=['roster', 'efj', 'csv', 'ical'])
16
+ choices=['roster', 'efj', 'csv', 'ical', 'version'])
16
17
  parser.add_argument('--ade', action="store_true")
17
18
  return parser.parse_args()
18
19
 
19
20
 
20
21
  def main() -> int:
21
22
  args = _args()
22
- duties = parse(sys.stdin.read())
23
+ if args.format == "version":
24
+ print(f"Version: {VERSION}")
25
+ return 0
26
+ duties, ade = parse(sys.stdin.read())
23
27
  if args.format == "roster":
24
28
  print(output.roster(duties))
25
29
  elif args.format == "efj":
@@ -27,7 +31,9 @@ def main() -> int:
27
31
  elif args.format == "csv":
28
32
  print(output.csv(duties))
29
33
  elif args.format == "ical":
30
- print(output.ical(duties, args.ade))
34
+ if not args.ade:
35
+ ade = ()
36
+ print(output.ical(duties, ade))
31
37
  return 0
32
38
 
33
39
 
@@ -2,6 +2,11 @@ from typing import NamedTuple, Optional
2
2
  import datetime as dt
3
3
 
4
4
 
5
+ class AllDayEvent(NamedTuple):
6
+ date: dt.date
7
+ code: str
8
+
9
+
5
10
  class CrewMember(NamedTuple):
6
11
  name: str
7
12
  role: str
@@ -21,9 +26,8 @@ class Sector(NamedTuple):
21
26
 
22
27
 
23
28
  class Duty(NamedTuple):
24
- code: Optional[str] # only all day event has code
25
29
  start: dt.datetime
26
- finish: Optional[dt.datetime] # all day event has None
30
+ finish: dt.datetime
27
31
  sectors: tuple[Sector, ...]
28
32
 
29
33
 
@@ -9,8 +9,8 @@ import ctypes
9
9
  import aims.parse
10
10
  from aims.data_structures import RosterException, InputFileException
11
11
  from aims.output import csv, ical, efj
12
+ from aims.version import VERSION
12
13
 
13
- VERSION = "2.1"
14
14
 
15
15
  SETTINGS_FILE = os.path.expanduser("~/.aimsgui")
16
16
 
@@ -328,7 +328,7 @@ class MainWindow(ttk.Frame):
328
328
  self.txt.insert(tk.END, "Getting registration and type info...")
329
329
  self.txt.update()
330
330
 
331
- duties = aims.parse.parse(html)
331
+ duties, _ = aims.parse.parse(html)
332
332
  # note: normalise newlines for Text widget - will restore on output
333
333
  txt = csv(duties).replace("\r\n", "\n")
334
334
  self.txt.delete('1.0', tk.END)
@@ -338,9 +338,11 @@ class MainWindow(ttk.Frame):
338
338
  html = self.__roster_html()
339
339
  if not html:
340
340
  return
341
- duties = aims.parse.parse(html)
341
+ duties, ade = aims.parse.parse(html)
342
342
  # note: normalise newlines for Text widget - will restore on output
343
- txt = ical(duties, self.ms.with_ade.get()).replace("\r\n", "\n")
343
+ if not self.ms.with_ade.get():
344
+ ade = ()
345
+ txt = ical(duties, ade).replace("\r\n", "\n")
344
346
  self.txt.delete('1.0', tk.END)
345
347
  self.txt.insert(tk.END, txt, 'ical')
346
348
 
@@ -351,7 +353,7 @@ class MainWindow(ttk.Frame):
351
353
  self.txt.delete('1.0', tk.END)
352
354
  self.txt.insert(tk.END, "Working…", 'efj')
353
355
  self.txt.update()
354
- duties = aims.parse.parse(html)
356
+ duties, _ = aims.parse.parse(html)
355
357
  txt = efj(duties)
356
358
  self.txt.delete('1.0', tk.END)
357
359
  self.txt.insert(tk.END, txt, 'efj')
@@ -2,7 +2,9 @@ import datetime as dt
2
2
  import re
3
3
  from typing import Optional
4
4
 
5
- from aims.data_structures import Duty, Sector, CrewMember
5
+ from bs4 import BeautifulSoup # type: ignore
6
+
7
+ from aims.data_structures import Duty, Sector, CrewMember, AllDayEvent
6
8
 
7
9
 
8
10
  DATE, FLTNUM, FROM, OFF, TO, ON, TYPE, REG, BLOCK, CP = range(1, 11)
@@ -30,13 +32,13 @@ def _sector(row: tuple[str, ...]) -> Optional[Sector]:
30
32
  def _duty(sectors: tuple[Sector, ...]) -> Duty:
31
33
  lowest_off = min([X.off for X in sectors])
32
34
  highest_on = max([X.on for X in sectors])
33
- return Duty(None,
34
- lowest_off - dt.timedelta(hours=1),
35
+ return Duty(lowest_off - dt.timedelta(hours=1),
35
36
  highest_on + dt.timedelta(minutes=30),
36
37
  sectors)
37
38
 
38
39
 
39
- def duties(soup) -> tuple[Duty, ...]:
40
+ def duties(html) -> tuple[tuple[Duty, ...], tuple[AllDayEvent, ...]]:
41
+ soup = BeautifulSoup(html, "html5lib")
40
42
  sectors: list[Sector] = []
41
43
  re_date = re.compile(r"^\d{2}/\d{2}/\d{2}$")
42
44
  for row in soup.find_all("tr"):
@@ -54,4 +56,4 @@ def duties(soup) -> tuple[Duty, ...]:
54
56
  else:
55
57
  groups[-1].append(sector)
56
58
  last_on = sector.on
57
- return tuple(_duty(tuple(X)) for X in groups)
59
+ return (tuple(_duty(tuple(X)) for X in groups), ())
@@ -5,12 +5,15 @@ import io
5
5
  import csv as libcsv
6
6
  import datetime as dt
7
7
  import re
8
- from typing import Optional
8
+ import itertools
9
9
 
10
- from aims.data_structures import Duty, Sector, CrewMember
10
+ from aims.data_structures import Duty, Sector, CrewMember, AllDayEvent
11
11
  import nightflight.night as nightcalc # type: ignore
12
12
  from nightflight.airport_nvecs import airfields as nvecs # type: ignore
13
13
 
14
+ UTC = Z("UTC")
15
+ LT = Z("Europe/London")
16
+
14
17
 
15
18
  def clean_name(name: str) -> str:
16
19
  parts = [X.strip().capitalize() for X in name.split()]
@@ -52,46 +55,68 @@ def _night(sector: Sector) -> tuple[int, bool]:
52
55
  return (round(night_duration), night_landing)
53
56
 
54
57
 
55
- def _roster_sectors(
56
- sectors: tuple[Sector, ...]
57
- ) -> tuple[tuple[str, ...], int]:
58
- airports: list[str] = []
58
+ def _build_roster_string(start, end, block, string):
59
+ start, end = [X.replace(tzinfo=UTC).astimezone(LT)
60
+ for X in (start, end)]
61
+ duration = int((end - start).total_seconds()) // 60
62
+ duration_str = f"{duration // 60}:{duration % 60:02d}"
63
+ block_str = f"{block // 60}:{block % 60:02d}"
64
+ return (f"{start:%d/%m/%Y %H:%M}-{end:%H:%M} "
65
+ f"{string} {block_str}/{duration_str}")
66
+
67
+
68
+ def _roster_quasi(
69
+ qsectors: tuple[Sector, ...],
70
+ cursor: dt.datetime
71
+ ) -> tuple[tuple[str, ...], dt.datetime]:
72
+ retval: list[str] = []
73
+ name = "Brief"
74
+ for s in qsectors:
75
+ if cursor < s.off:
76
+ retval.append(_build_roster_string(cursor, s.off, 0, name))
77
+ name = "Debrief"
78
+ retval.append(_build_roster_string(s.off, s.on, 0, s.name))
79
+ cursor = s.on
80
+ return (tuple(retval), cursor)
81
+
82
+
83
+ def _roster_real(
84
+ sectors: tuple[Sector, ...],
85
+ cursor: dt.datetime
86
+ ) -> tuple[str, dt.datetime]:
87
+ assert sectors[0].from_
88
+ airports: list[str] = [sectors[0].from_]
59
89
  block = 0
60
- from_: Optional[str] = None
90
+ start = cursor
61
91
  for sector in sectors:
62
- if not from_ and sector.from_:
63
- from_ = sector.from_
64
92
  if sector.position:
65
93
  airports.append("[psn]")
66
- elif sector.quasi:
67
- if sector.from_:
68
- airports.append(f"[{sector.name}]")
69
- else:
70
- airports.append(sector.name)
71
- if sector.to:
72
- airports.append(sector.to)
73
- if not sector.quasi and not sector.position:
94
+ else:
74
95
  block += int((sector.on - sector.off).total_seconds()) // 60
75
- if from_:
76
- airports = [from_] + airports
77
- return tuple(airports), block
96
+ assert sector.to
97
+ airports.append(sector.to)
98
+ cursor = sectors[-1].on + dt.timedelta(minutes=30)
99
+ return (
100
+ _build_roster_string(start, cursor, block, '-'.join(airports)),
101
+ cursor)
78
102
 
79
103
 
80
104
  def roster(duties: tuple[Duty, ...]) -> str:
81
- UTC = Z("UTC")
82
- LT = Z("Europe/London")
83
- output = []
105
+ output: list[str] = []
84
106
  for duty in duties:
85
- if not duty.finish:
107
+ if not duty.finish: # an all day duty
86
108
  continue
87
- start, end = [X.replace(tzinfo=UTC).astimezone(LT)
88
- for X in (duty.start, duty.finish)]
89
- duration = int((end - start).total_seconds()) // 60
90
- airports, block = _roster_sectors(duty.sectors)
91
- duration_str = f"{duration // 60}:{duration % 60:02d}"
92
- block_str = f"{block // 60}:{block % 60:02d}"
93
- output.append(f"{start:%d/%m/%Y %H:%M}-{end:%H:%M} "
94
- f"{'-'.join(airports)} {block_str}/{duration_str}")
109
+ cursor = duty.start
110
+ for k, g in itertools.groupby(duty.sectors, key=lambda X: X.quasi):
111
+ if k: # group of quasi sectors
112
+ new_output, cursor = _roster_quasi(tuple(g), cursor)
113
+ output += new_output
114
+ else: # group of real sectors
115
+ new, cursor = _roster_real(tuple(g), cursor)
116
+ output.append(new)
117
+ if cursor < duty.finish:
118
+ output.append(
119
+ _build_roster_string(cursor, duty.finish, 0, "Debrief"))
95
120
  return "\n".join(output)
96
121
 
97
122
 
@@ -246,20 +271,18 @@ def _build_dict(duty: Duty) -> dict[str, str]:
246
271
  return event
247
272
 
248
273
 
249
- def ical(duties: tuple[Duty, ...], do_ade) -> str:
274
+ def ical(duties: tuple[Duty, ...], ade: tuple[AllDayEvent, ...]) -> str:
250
275
  events = []
251
276
  for duty in duties:
252
- if duty.finish:
253
- d = _build_dict(duty)
254
- events.append(vevent.format(**d))
255
- elif do_ade:
256
- date = duty.start.date()
257
- uid = "{}{}@HURSTS.ORG.UK".format(
258
- date.isoformat(), duty.code)
259
- modified = ical_datetime.format(dt.datetime.utcnow())
260
- events.append(advevent.format(
261
- day=date,
262
- ev=duty.code,
277
+ d = _build_dict(duty)
278
+ events.append(vevent.format(**d))
279
+ for e in ade:
280
+ uid = "{}{}@HURSTS.ORG.UK".format(
281
+ e.date.isoformat(), e.code)
282
+ modified = ical_datetime.format(dt.datetime.utcnow())
283
+ events.append(advevent.format(
284
+ day=e.date,
285
+ ev=e.code,
263
286
  modified=modified,
264
287
  uid=uid))
265
288
  return vcalendar.format("\r\n".join(events))
@@ -0,0 +1,16 @@
1
+ from aims.data_structures import Duty, AllDayEvent, InputFileException
2
+ import aims.roster
3
+ import aims.logbook_report
4
+
5
+
6
+ def parse(html: str) -> tuple[tuple[Duty, ...], tuple[AllDayEvent, ...]]:
7
+ # check it's an html5 file
8
+ html5_header = "<!DOCTYPE html><html>"
9
+ if html[:len(html5_header)] != html5_header:
10
+ raise InputFileException("HTML5 header not found.")
11
+ if html.find("Personal&nbsp;Crew&nbsp;Schedule&nbsp;Report") != -1:
12
+ return aims.roster.duties(html)
13
+ elif html.find("Pilot&nbsp;Logbook") != -1:
14
+ return aims.logbook_report.duties(html)
15
+ else:
16
+ raise InputFileException("Report type marker not found")
@@ -6,9 +6,11 @@ produced by processing the roster with BeautifulSoup.
6
6
 
7
7
  import datetime as dt
8
8
  import re
9
- from typing import Optional
10
9
 
11
- from aims.data_structures import Duty, Sector, CrewMember, InputFileException
10
+ from bs4 import BeautifulSoup # type: ignore
11
+
12
+ from aims.data_structures import (
13
+ Duty, Sector, CrewMember, AllDayEvent, InputFileException)
12
14
 
13
15
 
14
16
  Row = tuple[tuple[str, ...], ...]
@@ -78,7 +80,10 @@ def _sectors(data: Row, date: dt.date) -> tuple[Sector, ...]:
78
80
  if airports[0][0] == "*": # Either ground or air positioning
79
81
  airports[0] = airports[0][1:]
80
82
  position = True
81
- quasi = not type_ # If no type in code, assume quasi sector
83
+ quasi = False
84
+ if not type_: # If no type in code, assume quasi sector
85
+ quasi = True
86
+ crew = ()
82
87
  retval.append(
83
88
  Sector(name, None, type_, airports[0], airports[1],
84
89
  _convert_timestring(times[0], date),
@@ -93,7 +98,7 @@ def _sectors(data: Row, date: dt.date) -> tuple[Sector, ...]:
93
98
  return tuple(retval)
94
99
 
95
100
 
96
- def _duty(row: Row) -> Optional[Duty]:
101
+ def _duty(row: Row) -> Duty:
97
102
  """Creates a Duty object from a structured representation of an HTML row.
98
103
 
99
104
  The input object is a 12 cell tuple with each cell containing the contents
@@ -158,15 +163,10 @@ def _duty(row: Row) -> Optional[Duty]:
158
163
 
159
164
  """
160
165
  try:
161
- if not row[CODES]:
162
- return None
166
+ assert row[CODES] and row[TIMES]
163
167
  date = _convert_datestring(row[DATE][0])
164
- # if there are no times, it is an all day event
165
- if not row[TIMES]:
166
- return Duty(row[CODES][0],
167
- dt.datetime.combine(date, dt.time()),
168
- None, ())
169
168
  if row[DSTART]:
169
+ assert row[DEND]
170
170
  start = _convert_timestring(row[DSTART][0], date)
171
171
  end = _convert_timestring(row[DEND][0], date)
172
172
  else:
@@ -183,18 +183,26 @@ def _duty(row: Row) -> Optional[Duty]:
183
183
  break
184
184
  start = min(start, sector.off)
185
185
  end = max(end, sector.on)
186
- return Duty(None, start, end, sectors)
186
+ return Duty(start, end, sectors)
187
+ except (IndexError, ValueError):
188
+ raise InputFileException(f"Bad Duty Record: {str(row)}")
189
+
190
+
191
+ def _ade(row: Row) -> AllDayEvent:
192
+ try:
193
+ return AllDayEvent(_convert_datestring(row[DATE][0]), row[CODES][0])
187
194
  except (IndexError, ValueError):
188
- raise InputFileException(f"Bad Record: {str(row)}")
195
+ raise InputFileException(f"Bad All Day Duty Record: {str(row)}")
189
196
 
190
197
 
191
- def duties(soup) -> tuple[Duty, ...]:
192
- """Create a tuple of Duty objects from a BeautifulSoup of a roster.
198
+ def duties(html: str) -> tuple[tuple[Duty, ...], tuple[AllDayEvent, ...]]:
199
+ """Extract the data from an AIMS vertical roster.
193
200
 
194
201
  The entire document is a single table (how retro!). The interesting part
195
202
  starts with the row two rows below the row with a cell containing the
196
203
  phrase "Schedule Details" and ends with the row above the first row with a
197
- blank date field. Each row between these represents a single duty.
204
+ blank date field. Each row between these represents a single duty or an all
205
+ dat event.
198
206
 
199
207
  The date to the left of each row is the date that the duty started on. If
200
208
  duties continue past midnight, relevant times are marked with a superscript
@@ -202,25 +210,30 @@ def duties(soup) -> tuple[Duty, ...]:
202
210
  vertical roster much saner to parse than the alternatives since there are
203
211
  no concerns about missing data at the start and end of the roster period.
204
212
 
205
- :param soup: The soup of a 'vertical' HTML AIMS roster, as produced by
206
- processing the HTML with BeautifulSoup.
207
- :return: A tuple of Duty objects encoding the roster in a standard form.
213
+ :param html: The html of a 'vertical' HTML AIMS roster.
214
+ :return: A tuple of Duty objects and a tuple of AllDayEvent objects
208
215
 
209
216
  """
217
+ soup = BeautifulSoup(html, "html5lib")
210
218
  rows = iter(soup.find_all("tr"))
211
219
  try:
212
220
  while "Schedule Details" not in next(rows).stripped_strings:
213
221
  pass
214
222
  next(rows)
215
- retval: list[Duty] = []
223
+ duty_list: list[Duty] = []
224
+ ade_list: list[AllDayEvent] = []
216
225
  while True:
217
- strings = tuple(
226
+ row: Row = tuple(
218
227
  tuple(Y.replace("\xa0", " ") for Y in X.stripped_strings)
219
228
  for X in next(rows)("td"))
220
- if not strings[DATE]: # line without date ends table
229
+ if not row[DATE]: # line without date ends table
221
230
  break
222
- if duty := _duty(strings):
223
- retval.append(duty)
231
+ if not row[CODES]: # unpublished duty
232
+ continue
233
+ if not row[TIMES]: # an all day event
234
+ ade_list.append(_ade(row))
235
+ elif duty := _duty(row): # a normal duty
236
+ duty_list.append(duty)
237
+ return (tuple(duty_list), tuple(ade_list))
224
238
  except (StopIteration, IndexError):
225
239
  raise InputFileException("Duty table ended unexpectedly")
226
- return tuple(retval)
@@ -0,0 +1,4 @@
1
+ from importlib.metadata import version
2
+
3
+
4
+ VERSION = version("aims-convert")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: aims-convert
3
- Version: 2.1
3
+ Version: 2.2
4
4
  Summary: Extract useful information from AIMS
5
5
  Home-page: https://github.com/JonHurst/aims-convert
6
6
  Author: Jon Hurst
@@ -15,6 +15,8 @@ Description-Content-Type: text/markdown
15
15
  Requires-Dist: nightflight
16
16
  Requires-Dist: bs4
17
17
  Requires-Dist: html5lib
18
+ Provides-Extra: unit-tests
19
+ Requires-Dist: freezegun; extra == "unit-tests"
18
20
 
19
21
  # AIMS Roster Data Extraction #
20
22
 
@@ -10,12 +10,15 @@ aims/output.py
10
10
  aims/parse.py
11
11
  aims/py.typed
12
12
  aims/roster.py
13
+ aims/version.py
13
14
  aims_convert.egg-info/PKG-INFO
14
15
  aims_convert.egg-info/SOURCES.txt
15
16
  aims_convert.egg-info/dependency_links.txt
16
17
  aims_convert.egg-info/entry_points.txt
17
18
  aims_convert.egg-info/requires.txt
18
19
  aims_convert.egg-info/top_level.txt
20
+ tests/test_compound.py
19
21
  tests/test_logbook.py
22
+ tests/test_output.py
20
23
  tests/test_parse.py
21
24
  tests/test_roster.py
@@ -1,3 +1,6 @@
1
1
  nightflight
2
2
  bs4
3
3
  html5lib
4
+
5
+ [unit_tests]
6
+ freezegun
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="aims-convert",
8
- version="2.1",
8
+ version="2.2",
9
9
  author="Jon Hurst",
10
10
  author_email="jon.a@hursts.org.uk",
11
11
  description="Extract useful information from AIMS",
@@ -34,4 +34,7 @@ setuptools.setup(
34
34
  "console_scripts": ["aims = aims.cli:main"],
35
35
  "gui_scripts" : ["aimsgui = aims.gui:main"]
36
36
  },
37
+ extras_require={
38
+ "unit_tests": ["freezegun"]
39
+ },
37
40
  )
@@ -0,0 +1,34 @@
1
+ import unittest
2
+ from freezegun import freeze_time
3
+ import glob
4
+
5
+ import aims.parse
6
+ import aims.output
7
+ from aims.data_structures import InputFileException
8
+
9
+
10
+ @freeze_time("2024-01-01")
11
+ class Test_compound(unittest.TestCase):
12
+
13
+ def test_compound(self):
14
+ for f in glob.glob("files/*.htm"):
15
+ with open(f) as input_:
16
+ duties, ade = aims.parse.parse(input_.read())
17
+ with open(f + ".txt") as roster_file:
18
+ self.assertEqual(roster_file.read(),
19
+ aims.output.roster(duties) + "\n")
20
+ with open(f + ".ical", "rb") as ical_file:
21
+ self.assertEqual(
22
+ ical_file.read(),
23
+ (aims.output.ical(duties, ade) + "\n").encode())
24
+ with open(f + ".csv", "rb") as csv_file:
25
+ self.assertEqual(csv_file.read(),
26
+ (aims.output.csv(duties) + "\n").encode())
27
+ with open(f + ".efj") as efj_file:
28
+ self.assertEqual(efj_file.read(),
29
+ aims.output.efj(duties) + "\n")
30
+
31
+ def test_bad_file(self):
32
+ with open("files/README") as f:
33
+ with self.assertRaises(InputFileException):
34
+ aims.parse.parse(f.read())
@@ -22,7 +22,6 @@ class TestDuty(unittest.TestCase):
22
22
  quasi=False, position=False,
23
23
  crew=(CrewMember("TEST-2", "CP"), )))
24
24
  expected = Duty(
25
- code=None,
26
25
  start=datetime.datetime(2022, 7, 23, 10, 44),
27
26
  finish=datetime.datetime(2022, 7, 23, 18, 11),
28
27
  sectors=(Sector(name='6053', reg='G-EZRY', type_='320',
@@ -0,0 +1,231 @@
1
+ import unittest
2
+ import datetime
3
+ from freezegun import freeze_time
4
+
5
+ from aims.output import roster, efj, ical
6
+ from aims.data_structures import Duty, Sector, CrewMember, AllDayEvent
7
+
8
+
9
+ crew = (
10
+ CrewMember(name='CAPTAIN THE', role='CP'),
11
+ CrewMember(name='FO THE', role='FO'),
12
+ CrewMember(name='PU THE', role='PU'),
13
+ CrewMember(name='FA A', role='FA'))
14
+
15
+
16
+ standard_duty = Duty(
17
+ start=datetime.datetime(2023, 6, 2, 5, 0),
18
+ finish=datetime.datetime(2023, 6, 2, 16, 1),
19
+ sectors=(
20
+ Sector(name='2867', reg=None, type_='320',
21
+ from_='BRS', to='LIS',
22
+ off=datetime.datetime(2023, 6, 2, 6, 0),
23
+ on=datetime.datetime(2023, 6, 2, 8, 36),
24
+ quasi=False, position=False, crew=crew),
25
+ Sector(name='2868', reg=None, type_='320',
26
+ from_='LIS', to='BRS',
27
+ off=datetime.datetime(2023, 6, 2, 9, 43),
28
+ on=datetime.datetime(2023, 6, 2, 12, 4),
29
+ quasi=False, position=False, crew=crew),
30
+ Sector(name='239', reg=None, type_='320',
31
+ from_='BRS', to='NCL',
32
+ off=datetime.datetime(2023, 6, 2, 12, 52),
33
+ on=datetime.datetime(2023, 6, 2, 13, 58),
34
+ quasi=False, position=False, crew=crew),
35
+ Sector(name='240', reg=None, type_='320',
36
+ from_='NCL', to='BRS',
37
+ off=datetime.datetime(2023, 6, 2, 14, 32),
38
+ on=datetime.datetime(2023, 6, 2, 15, 31),
39
+ quasi=False, position=False, crew=crew)))
40
+
41
+
42
+ standby_duty = Duty(
43
+ start=datetime.datetime(2024, 7, 25, 5, 15),
44
+ finish=datetime.datetime(2024, 7, 25, 13, 15),
45
+ sectors=(
46
+ Sector(name='ESBY', reg=None, type_=None,
47
+ from_=None, to=None,
48
+ off=datetime.datetime(2024, 7, 25, 5, 15),
49
+ on=datetime.datetime(2024, 7, 25, 13, 15),
50
+ quasi=True, position=False, crew=()), ))
51
+
52
+
53
+ standby_before_flight = Duty(
54
+ start=datetime.datetime(2023, 7, 2, 11, 45),
55
+ finish=datetime.datetime(2023, 7, 2, 19, 26),
56
+ sectors=(
57
+ Sector(name='LSBY', reg=None, type_=None,
58
+ from_='BRS', to='BRS',
59
+ off=datetime.datetime(2023, 7, 2, 11, 45),
60
+ on=datetime.datetime(2023, 7, 2, 13, 20),
61
+ quasi=True, position=False,
62
+ crew=()),
63
+ Sector(name='ADTY', reg=None, type_=None,
64
+ from_='BRS', to='BRS',
65
+ off=datetime.datetime(2023, 7, 2, 13, 20),
66
+ on=datetime.datetime(2023, 7, 2, 14, 0),
67
+ quasi=True, position=False,
68
+ crew=()),
69
+ Sector(name='227', reg=None, type_='319',
70
+ from_='BRS', to='BFS',
71
+ off=datetime.datetime(2023, 7, 2, 16, 14),
72
+ on=datetime.datetime(2023, 7, 2, 17, 23),
73
+ quasi=False, position=False,
74
+ crew=crew),
75
+ Sector(name='228', reg=None, type_='319',
76
+ from_='BFS', to='BRS',
77
+ off=datetime.datetime(2023, 7, 2, 17, 56),
78
+ on=datetime.datetime(2023, 7, 2, 18, 56),
79
+ quasi=False, position=False,
80
+ crew=crew)))
81
+
82
+
83
+ loe_day1 = Duty(
84
+ start=datetime.datetime(2023, 6, 19, 14, 30),
85
+ finish=datetime.datetime(2023, 6, 19, 18, 0),
86
+ sectors=(
87
+ Sector(name='TAXI227', reg=None, type_=None,
88
+ from_='BRS', to='LGW',
89
+ off=datetime.datetime(2023, 6, 19, 14, 30),
90
+ on=datetime.datetime(2023, 6, 19, 18, 0),
91
+ quasi=True, position=True, crew=()),))
92
+
93
+
94
+ loe_day2 = Duty(
95
+ start=datetime.datetime(2023, 6, 20, 12, 0),
96
+ finish=datetime.datetime(2023, 6, 20, 18, 30),
97
+ sectors=(
98
+ Sector(name='LOE', reg=None, type_=None,
99
+ from_=None, to=None,
100
+ off=datetime.datetime(2023, 6, 20, 13, 30),
101
+ on=datetime.datetime(2023, 6, 20, 17, 30),
102
+ quasi=True, position=False, crew=()),))
103
+
104
+
105
+ loe_day3 = Duty(
106
+ start=datetime.datetime(2023, 6, 21, 8, 0),
107
+ finish=datetime.datetime(2023, 6, 21, 18, 30),
108
+ sectors=(
109
+ Sector(name='FFST', reg=None, type_=None,
110
+ from_='LGW', to='LGW',
111
+ off=datetime.datetime(2023, 6, 21, 9, 30),
112
+ on=datetime.datetime(2023, 6, 21, 13, 30),
113
+ quasi=True, position=False, crew=()),
114
+ Sector(name='TAXI72', reg=None, type_=None,
115
+ from_='LGW', to='BRS',
116
+ off=datetime.datetime(2023, 6, 21, 15, 0),
117
+ on=datetime.datetime(2023, 6, 21, 18, 30),
118
+ quasi=True, position=True, crew=())))
119
+
120
+
121
+ class TestRoster(unittest.TestCase):
122
+
123
+ def test_standard(self):
124
+ self.assertEqual(
125
+ roster((standard_duty, standby_duty)),
126
+ "02/06/2023 06:00-17:01 BRS-LIS-BRS-NCL-BRS 7:02/11:01\n"
127
+ "25/07/2024 06:15-14:15 ESBY 0:00/8:00")
128
+
129
+ def test_standby_before_flight(self):
130
+ self.assertEqual(
131
+ roster((standby_before_flight, )),
132
+ "02/07/2023 12:45-14:20 LSBY 0:00/1:35\n"
133
+ "02/07/2023 14:20-15:00 ADTY 0:00/0:40\n"
134
+ "02/07/2023 15:00-20:26 BRS-BFS-BRS 2:09/5:26")
135
+
136
+ def test_loe(self):
137
+ self.assertEqual(
138
+ roster((loe_day1, loe_day2, loe_day3)),
139
+ "19/06/2023 15:30-19:00 TAXI227 0:00/3:30\n"
140
+ "20/06/2023 13:00-14:30 Brief 0:00/1:30\n"
141
+ "20/06/2023 14:30-18:30 LOE 0:00/4:00\n"
142
+ "20/06/2023 18:30-19:30 Debrief 0:00/1:00\n"
143
+ "21/06/2023 09:00-10:30 Brief 0:00/1:30\n"
144
+ "21/06/2023 10:30-14:30 FFST 0:00/4:00\n"
145
+ "21/06/2023 14:30-16:00 Debrief 0:00/1:30\n"
146
+ "21/06/2023 16:00-19:30 TAXI72 0:00/3:30")
147
+
148
+ def test_empty(self):
149
+ self.assertEqual(roster(()), "")
150
+
151
+
152
+ class TestEFJ(unittest.TestCase):
153
+
154
+ def test_standard(self):
155
+ self.assertEqual(
156
+ efj((standard_duty, standby_duty)),
157
+ "2023-06-02\n"
158
+ "0500/1601\n"
159
+ "{ CP:Captain The, FO:Fo The, PU:Pu The, FA:Fa A }\n"
160
+ "?-????:320\n"
161
+ "BRS/LIS 0600/0836\n"
162
+ "LIS/BRS 0943/1204\n"
163
+ "BRS/NCL 1252/1358\n"
164
+ "NCL/BRS 1432/1531\n\n"
165
+ "2024-07-25\n"
166
+ "0515/1315 #ESBY\n")
167
+
168
+ def test_standby_before_flight(self):
169
+ self.assertEqual(
170
+ efj((standby_before_flight, )),
171
+ "2023-07-02\n"
172
+ "1145/1926\n"
173
+ "{ CP:Captain The, FO:Fo The, PU:Pu The, FA:Fa A }\n"
174
+ "?-????:319\n"
175
+ "BRS/BFS 1614/1723\n"
176
+ "BFS/BRS 1756/1856\n")
177
+
178
+ def test_loe(self):
179
+ self.assertEqual(
180
+ efj((loe_day1, loe_day2, loe_day3)),
181
+ "2023-06-19\n"
182
+ "1430/1800 #TAXI227\n"
183
+ "\n"
184
+ "2023-06-20\n"
185
+ "1200/1830 #LOE\n"
186
+ "\n"
187
+ "2023-06-21\n"
188
+ "0800/1830\n")
189
+
190
+ def test_empty(self):
191
+ self.assertEqual(efj(()), "")
192
+
193
+
194
+ @freeze_time("2024-01-01")
195
+ class Test_ical(unittest.TestCase):
196
+
197
+ def test_standard(self):
198
+ expected = ("BEGIN:VCALENDAR\r\n"
199
+ "VERSION:2.0\r\n"
200
+ "PRODID:hursts.org.uk\r\n"
201
+ "BEGIN:VEVENT\r\n"
202
+ "UID:2023-06-02T05:00:00BRSLISBRSNCLBRS@HURSTS.ORG.UK\r\n"
203
+ "DTSTAMP:20240101T000000Z\r\n"
204
+ "DTSTART:20230602T050000Z\r\n"
205
+ "DTEND:20230602T160100Z\r\n"
206
+ "SUMMARY:BRS-LIS-BRS-NCL-BRS\r\n"
207
+ "DESCRIPTION:06:00z-08:36z 2867 BRS/LIS \\n\r\n"
208
+ " 09:43z-12:04z 2868 LIS/BRS \\n\r\n"
209
+ " 12:52z-13:58z 239 BRS/NCL \\n\r\n"
210
+ " 14:32z-15:31z 240 NCL/BRS \r\n"
211
+ "LAST-MODIFIED:20240101T000000Z\r\n"
212
+ "END:VEVENT\r\n"
213
+ "END:VCALENDAR\r\n")
214
+ self.assertEqual(ical((standard_duty, ), ()), expected)
215
+
216
+ def test_all_day_event(self):
217
+ expected = ("BEGIN:VCALENDAR\r\n"
218
+ "VERSION:2.0\r\n"
219
+ "PRODID:hursts.org.uk\r\n"
220
+ "BEGIN:VEVENT\r\n"
221
+ "UID:2024-01-02P/T@HURSTS.ORG.UK\r\n"
222
+ "DTSTAMP:20240101T000000Z\r\n"
223
+ "DTSTART;VALUE=DATE:20240102\r\n"
224
+ "SUMMARY:P/T\r\n"
225
+ "TRANSP:TRANSPARENT\r\n"
226
+ "LAST-MODIFIED:20240101T000000Z\r\n"
227
+ "END:VEVENT\r\n"
228
+ "END:VCALENDAR\r\n")
229
+ self.assertEqual(
230
+ ical((), (AllDayEvent(datetime.date(2024, 1, 2), "P/T"), )),
231
+ expected)
@@ -3,7 +3,7 @@ import datetime
3
3
 
4
4
  import aims.roster as roster
5
5
  from aims.data_structures import (
6
- Duty, Sector, CrewMember, InputFileException
6
+ Duty, Sector, CrewMember, AllDayEvent, InputFileException
7
7
  )
8
8
 
9
9
  crew = ('CP - 0000 - CAPTAIN THE',
@@ -34,7 +34,6 @@ class Test_duty(unittest.TestCase):
34
34
  ('11:01',),
35
35
  (), crew, ())
36
36
  expected = Duty(
37
- code=None,
38
37
  start=datetime.datetime(2023, 6, 2, 5, 0),
39
38
  finish=datetime.datetime(2023, 6, 2, 16, 1),
40
39
  sectors=(
@@ -61,17 +60,6 @@ class Test_duty(unittest.TestCase):
61
60
  )
62
61
  self.assertEqual(roster._duty(src), expected)
63
62
 
64
- def test_all_day_event(self):
65
- src = ((),
66
- ('04/06/2023 Sun',),
67
- ('D/O',),
68
- ('Day off',),
69
- (), (), (), (), (), (), (), ())
70
- expected = Duty(code='D/O',
71
- start=datetime.datetime(2023, 6, 4, 0, 0),
72
- finish=None, sectors=())
73
- self.assertEqual(roster._duty(src), expected)
74
-
75
63
  def test_loe(self):
76
64
  src = (
77
65
  ((), ('19/06/2023', 'Mon'),
@@ -89,8 +77,7 @@ class Test_duty(unittest.TestCase):
89
77
  (), ('10:30',), (), ('CP - PAX - 0000 - CAPTAIN THE',),
90
78
  ()))
91
79
  expected = (
92
- Duty(code=None,
93
- start=datetime.datetime(2023, 6, 19, 14, 30),
80
+ Duty(start=datetime.datetime(2023, 6, 19, 14, 30),
94
81
  finish=datetime.datetime(2023, 6, 19, 18, 0),
95
82
  sectors=(
96
83
  Sector(name='TAXI227', reg=None, type_=None,
@@ -98,8 +85,7 @@ class Test_duty(unittest.TestCase):
98
85
  off=datetime.datetime(2023, 6, 19, 14, 30),
99
86
  on=datetime.datetime(2023, 6, 19, 18, 0),
100
87
  quasi=True, position=True, crew=()),)),
101
- Duty(code=None,
102
- start=datetime.datetime(2023, 6, 20, 12, 0),
88
+ Duty(start=datetime.datetime(2023, 6, 20, 12, 0),
103
89
  finish=datetime.datetime(2023, 6, 20, 18, 30),
104
90
  sectors=(
105
91
  Sector(name='LOE', reg=None, type_=None,
@@ -107,7 +93,7 @@ class Test_duty(unittest.TestCase):
107
93
  off=datetime.datetime(2023, 6, 20, 13, 30),
108
94
  on=datetime.datetime(2023, 6, 20, 17, 30),
109
95
  quasi=True, position=False, crew=()),)),
110
- Duty(code=None, start=datetime.datetime(2023, 6, 21, 8, 0),
96
+ Duty(start=datetime.datetime(2023, 6, 21, 8, 0),
111
97
  finish=datetime.datetime(2023, 6, 21, 18, 30),
112
98
  sectors=(
113
99
  Sector(name='FFST', reg=None, type_=None,
@@ -129,8 +115,7 @@ class Test_duty(unittest.TestCase):
129
115
  (), ('05:15 - 13:15',), (),
130
116
  (), ('08:00',),
131
117
  (), (), ())
132
- expected = Duty(code=None,
133
- start=datetime.datetime(2024, 7, 25, 5, 15),
118
+ expected = Duty(start=datetime.datetime(2024, 7, 25, 5, 15),
134
119
  finish=datetime.datetime(2024, 7, 25, 13, 15),
135
120
  sectors=(
136
121
  Sector(name='ESBY', reg=None, type_=None,
@@ -149,8 +134,7 @@ class Test_duty(unittest.TestCase):
149
134
  ('11:45 - 13:20', '13:20 - 14:00',
150
135
  'A16:14 - A17:23', 'A17:56 - A18:56'),
151
136
  ('19:26',), ('02:09',), ('07:41',), (), crew, ())
152
- expected = Duty(code=None,
153
- start=datetime.datetime(2023, 7, 2, 11, 45),
137
+ expected = Duty(start=datetime.datetime(2023, 7, 2, 11, 45),
154
138
  finish=datetime.datetime(2023, 7, 2, 19, 26),
155
139
  sectors=(
156
140
  Sector(name='LSBY', reg=None, type_=None,
@@ -158,13 +142,13 @@ class Test_duty(unittest.TestCase):
158
142
  off=datetime.datetime(2023, 7, 2, 11, 45),
159
143
  on=datetime.datetime(2023, 7, 2, 13, 20),
160
144
  quasi=True, position=False,
161
- crew=crew_result),
145
+ crew=()),
162
146
  Sector(name='ADTY', reg=None, type_=None,
163
147
  from_='BRS', to='BRS',
164
148
  off=datetime.datetime(2023, 7, 2, 13, 20),
165
149
  on=datetime.datetime(2023, 7, 2, 14, 0),
166
150
  quasi=True, position=False,
167
- crew=crew_result),
151
+ crew=()),
168
152
  Sector(name='227', reg=None, type_='319',
169
153
  from_='BRS', to='BFS',
170
154
  off=datetime.datetime(2023, 7, 2, 16, 14),
@@ -189,8 +173,7 @@ class Test_duty(unittest.TestCase):
189
173
  'A19:21 - A21:56', 'A22:33 - A00:57⁺¹/00:24'),
190
174
  ('01:27⁺¹',), ('07:08',), ('11:27',), ('M',),
191
175
  crew, ())
192
- expected = Duty(code=None,
193
- start=datetime.datetime(2023, 6, 25, 14, 0),
176
+ expected = Duty(start=datetime.datetime(2023, 6, 25, 14, 0),
194
177
  finish=datetime.datetime(2023, 6, 26, 1, 27),
195
178
  sectors=(
196
179
  Sector(name='239', reg=None, type_='320',
@@ -220,10 +203,6 @@ class Test_duty(unittest.TestCase):
220
203
  )
221
204
  self.assertEqual(roster._duty(src), expected)
222
205
 
223
- def test_not_published(self):
224
- src = ((), ('01/08/2024 Thu',), (), (), (), (), (), (), (), ('D',), ())
225
- self.assertEqual(roster._duty(src), None)
226
-
227
206
  def test_emptyrow(self):
228
207
  with self.assertRaises(InputFileException):
229
208
  roster._duty(())
@@ -232,8 +211,11 @@ class Test_duty(unittest.TestCase):
232
211
 
233
212
  def test_bad_date(self):
234
213
  with self.assertRaises(InputFileException):
235
- roster._duty(((), ('31/06/2023 Sun',), ('D/O',),
236
- ('Day off',), (), (), (), (), (), (), (), ()))
214
+ roster._duty(((), ('32/07/2024 Thu',),
215
+ ('ESBY',), ('Early Standby',),
216
+ (), ('24:00 - 13:15',), (),
217
+ (), ('08:00',),
218
+ (), (), ()))
237
219
 
238
220
  def test_bad_time(self):
239
221
  with self.assertRaises(InputFileException):
@@ -242,3 +224,15 @@ class Test_duty(unittest.TestCase):
242
224
  (), ('24:00 - 13:15',), (),
243
225
  (), ('08:00',),
244
226
  (), (), ()))
227
+
228
+
229
+ class Test_ade(unittest.TestCase):
230
+
231
+ def test_simple(self):
232
+ src = ((),
233
+ ('04/06/2023 Sun',),
234
+ ('D/O',),
235
+ ('Day off',),
236
+ (), (), (), (), (), (), (), ())
237
+ self.assertEqual(roster._ade(src),
238
+ AllDayEvent(datetime.date(2023, 6, 4), "D/O"))
@@ -1,24 +0,0 @@
1
- from bs4 import BeautifulSoup # type: ignore
2
-
3
- from aims.data_structures import Duty, InputFileException
4
- import aims.roster
5
- import aims.logbook_report
6
-
7
-
8
- def parse(html: str) -> tuple[Duty, ...]:
9
- # check it's an html5 file
10
- html5_header = "<!DOCTYPE html><html>"
11
- if html[:len(html5_header)] != html5_header:
12
- raise InputFileException("HTML5 header not found.")
13
- # make some soup
14
- soup = BeautifulSoup(html, "html5lib")
15
- # route the soup
16
- strings = list(soup.find("body").stripped_strings)
17
- if "Personal Crew Schedule Report" in strings:
18
- return aims.roster.duties(soup)
19
- elif "Pilot Logbook" in strings:
20
- return aims.logbook_report.duties(soup)
21
- else:
22
- raise InputFileException(
23
- "Report marker not found: \n" +
24
- "; ".join(strings[:20]))
File without changes
File without changes
File without changes
File without changes
File without changes