aims-convert 1.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.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.1
2
+ Name: aims-convert
3
+ Version: 1.2
4
+ Summary: Extract information from AIMS detailed roster
5
+ Home-page: https://github.com/JonHurst/aims-convert
6
+ Author: Jon Hurst
7
+ Author-email: jon.a@hursts.org.uk
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: requests
16
+ Requires-Dist: nightflight
17
+
18
+ # AIMS Roster Data Extraction #
19
+
20
+ This will primarily be of interest to easyJet pilots. It extracts data from a
21
+ “detailed roster” as can be downloaded from AIMS. Pilots from other airlines
22
+ that use AIMS *may* also find this useful — it has only been tested against the
23
+ files from easyJet’s version of AIMS (as that is all that I have access to), so
24
+ its utility will depend on whether whatever version is being used by your
25
+ airline produces sufficiently similar output.
26
+
27
+ As well as extracting the data from the AIMS roster, the registration and type
28
+ of the aircraft operating the sectors is looked up, and a night flying
29
+ calculation is carried out.
30
+
31
+ The three main formats that the data can be extracted to are electronic Flight
32
+ Journal (eFJ), iCalendar and CSV.
33
+
34
+ An eFJ is a text file that can be used to store personal flight data in an
35
+ intuitive non-tabular form. Full details of the format of this text file can be
36
+ found at <https://hursts.org.uk/efjdocs/format.html>, and an online tool
37
+ capable of converting this format to an FCL.050 compliant logbook in HTML
38
+ format can be found at <https://hursts.org.uk/efj>.
39
+
40
+ The iCalendar format can be imported into most calendar applications. There is
41
+ an option to include full day events such as days off. This is useful for
42
+ managing and sharing your future roster.
43
+
44
+ The CSV format is for keeping logbook data in a spreadsheet.
45
+
46
+ The package provides entry points for a command line interface, a Tk based
47
+ graphical interface and an AWS lambda function. The AWS function is hooked up
48
+ to a website at <https://hursts.org.uk/aims>, which requires no installation,
49
+ just a browser. The other two interfaces require the installation of a Python
50
+ interpreter.
@@ -0,0 +1,33 @@
1
+ # AIMS Roster Data Extraction #
2
+
3
+ This will primarily be of interest to easyJet pilots. It extracts data from a
4
+ “detailed roster” as can be downloaded from AIMS. Pilots from other airlines
5
+ that use AIMS *may* also find this useful — it has only been tested against the
6
+ files from easyJet’s version of AIMS (as that is all that I have access to), so
7
+ its utility will depend on whether whatever version is being used by your
8
+ airline produces sufficiently similar output.
9
+
10
+ As well as extracting the data from the AIMS roster, the registration and type
11
+ of the aircraft operating the sectors is looked up, and a night flying
12
+ calculation is carried out.
13
+
14
+ The three main formats that the data can be extracted to are electronic Flight
15
+ Journal (eFJ), iCalendar and CSV.
16
+
17
+ An eFJ is a text file that can be used to store personal flight data in an
18
+ intuitive non-tabular form. Full details of the format of this text file can be
19
+ found at <https://hursts.org.uk/efjdocs/format.html>, and an online tool
20
+ capable of converting this format to an FCL.050 compliant logbook in HTML
21
+ format can be found at <https://hursts.org.uk/efj>.
22
+
23
+ The iCalendar format can be imported into most calendar applications. There is
24
+ an option to include full day events such as days off. This is useful for
25
+ managing and sharing your future roster.
26
+
27
+ The CSV format is for keeping logbook data in a spreadsheet.
28
+
29
+ The package provides entry points for a command line interface, a Tk based
30
+ graphical interface and an AWS lambda function. The AWS function is hooked up
31
+ to a website at <https://hursts.org.uk/aims>, which requires no installation,
32
+ just a browser. The other two interfaces require the installation of a Python
33
+ interpreter.
File without changes
@@ -0,0 +1,20 @@
1
+ import requests
2
+ from aims.roster import Sector
3
+
4
+
5
+ def sector_id(sector: Sector) -> str:
6
+ return f'{sector.off:%Y%m%dT%H%M}F{sector.name}'
7
+
8
+
9
+ def airframes(sectors: tuple[Sector, ...]) -> dict[str, tuple[str, str]]:
10
+ ids = [sector_id(X) for X in sectors if X.from_]
11
+ try:
12
+ r = requests.post(
13
+ "https://efwj6ola8d.execute-api.eu-west-1.amazonaws.com/"
14
+ "default/reg",
15
+ json={'flights': ids})
16
+ r.raise_for_status()
17
+ regntype_map = r.json()
18
+ except requests.exceptions.RequestException:
19
+ return {} # if anything goes wrong, just return empty dict
20
+ return regntype_map
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/python3
2
+
3
+ import sys
4
+ import argparse
5
+
6
+ import aims.roster as roster
7
+ import aims.output as output
8
+
9
+
10
+ def _args():
11
+ parser = argparse.ArgumentParser(
12
+ description=(
13
+ 'Process an AIMS detailed roster into various useful formats.'))
14
+ parser.add_argument('format',
15
+ choices=['roster', 'efj', 'csv', 'ical'])
16
+ parser.add_argument('--ade', action="store_true")
17
+ return parser.parse_args()
18
+
19
+
20
+ def main() -> int:
21
+ args = _args()
22
+ lines = roster.lines(sys.stdin.read())
23
+ columns = roster.columns(lines)
24
+ sectors = roster.sectors(columns)
25
+ if args.format == "roster":
26
+ print(output.roster(sectors))
27
+ elif args.format == "efj":
28
+ print(output.efj(
29
+ sectors,
30
+ roster.crew_dict(lines)))
31
+ elif args.format == "csv":
32
+ print(output.csv(
33
+ sectors,
34
+ roster.crew_dict(lines)))
35
+ elif args.format == "ical":
36
+ ade = roster.all_day_events(columns) if args.ade else ()
37
+ print(output.ical(sectors, ade))
38
+ return 0
39
+
40
+
41
+ if __name__ == "__main__":
42
+ retval = main()
43
+ sys.exit(retval)
@@ -0,0 +1,439 @@
1
+ import os.path
2
+ import json
3
+ import tkinter as tk
4
+ from tkinter import ttk
5
+ from tkinter import messagebox
6
+ from tkinter import filedialog
7
+ import ctypes
8
+
9
+ import aims.roster as roster
10
+
11
+ from aims.output import csv, ical, efj
12
+
13
+ VERSION = "1.2"
14
+
15
+ SETTINGS_FILE = os.path.expanduser("~/.aimsgui")
16
+
17
+
18
+ class ModeSelector(ttk.Frame):
19
+
20
+ def __init__(self, parent, with_ade):
21
+ ttk.Frame.__init__(self, parent)
22
+ self.output_type = tk.StringVar()
23
+ self.output_type.set('efj')
24
+ self.with_ade = tk.BooleanVar()
25
+ self.with_ade.set(with_ade)
26
+ self.__make_widgets()
27
+
28
+ def __make_widgets(self):
29
+ frm_output_type = ttk.LabelFrame(self, text="Output type")
30
+ frm_output_type.pack(fill=tk.X, expand=True, ipadx=5, pady=5)
31
+ efj_output = ttk.Radiobutton(
32
+ frm_output_type, text=" Flight Journal",
33
+ variable=self.output_type, value='efj',
34
+ command=self.output_type_changed)
35
+ efj_output.pack(fill=tk.X)
36
+ csv_output = ttk.Radiobutton(
37
+ frm_output_type, text=" Logbook (.csv)",
38
+ variable=self.output_type, value='csv',
39
+ command=self.output_type_changed)
40
+ csv_output.pack(fill=tk.X)
41
+ ical_output = ttk.Radiobutton(
42
+ frm_output_type, text=" Roster (.ics)",
43
+ variable=self.output_type, value='ical',
44
+ command=self.output_type_changed)
45
+ ical_output.pack(fill=tk.X)
46
+
47
+ self.frm_ical_options = ttk.LabelFrame(self, text="Options")
48
+ with_ade = ttk.Checkbutton(self.frm_ical_options,
49
+ text=" All Day Events",
50
+ variable=self.with_ade,
51
+ command=self.options_changed)
52
+ with_ade.pack(fill=tk.X)
53
+
54
+ def output_type_changed(self):
55
+ assert self.output_type.get() in ('csv', 'ical', 'efj')
56
+ if self.output_type.get() == 'ical':
57
+ self.frm_ical_options.pack(fill=tk.X, expand=True, ipadx=5, pady=5)
58
+ else:
59
+ self.frm_ical_options.pack_forget()
60
+ self.event_generate("<<ModeChange>>", when="tail")
61
+
62
+ def options_changed(self):
63
+ self.event_generate("<<OptionChange>>", when="tail")
64
+
65
+
66
+ class Actions(ttk.Frame):
67
+
68
+ def __init__(self, parent):
69
+ ttk.Frame.__init__(self, parent)
70
+ self.__make_widgets()
71
+
72
+ def __make_widgets(self):
73
+ frm_1 = ttk.Frame(self)
74
+ frm_1.pack(fill=tk.X)
75
+ btn_convert = ttk.Button(
76
+ frm_1, text="Load Roster", width=0,
77
+ command=lambda: self.event_generate("<<Action-Import>>"))
78
+ btn_convert.pack(fill=tk.X)
79
+
80
+ frm_2 = ttk.Frame(self)
81
+ frm_2.pack(fill=tk.X, pady=10)
82
+ btn_save = ttk.Button(
83
+ frm_2, text="Save", width=0,
84
+ command=lambda: self.event_generate("<<Action-Save>>"))
85
+ btn_save.pack(fill=tk.X, pady=2)
86
+ self.btn_copy = ttk.Button(
87
+ frm_2, text="Copy All", width=0,
88
+ command=lambda: self.event_generate("<<Action-Copy>>"))
89
+ self.btn_copy.pack(fill=tk.X, pady=2)
90
+
91
+ frm_3 = ttk.Frame(self)
92
+ frm_3.pack(fill=tk.X)
93
+ btn_quit = ttk.Button(
94
+ frm_3, text="Quit", width=0,
95
+ command=lambda: self.event_generate("<<Action-Quit>>"))
96
+ btn_quit.pack(fill=tk.X)
97
+
98
+ def set_copy_selected(self, selected):
99
+ if selected:
100
+ self.btn_copy.config(text="Copy Selected")
101
+ else:
102
+ self.btn_copy.config(text="Copy All")
103
+
104
+
105
+ class TextWithSyntaxHighlighting(tk.Text):
106
+
107
+ def __init__(self, parent=None, **kwargs):
108
+ tk.Text.__init__(self, parent, background='white',
109
+ wrap="none", **kwargs)
110
+ self.highlight_mode = None
111
+ self.tag_configure("grayed", foreground="#909090")
112
+ self.tag_configure("keyword", foreground="green")
113
+ self.tag_configure("datetime", foreground="blue")
114
+ self.bind(
115
+ '<KeyRelease>',
116
+ lambda *args: self.edit_modified() and self.highlight_syntax())
117
+
118
+ def insert(self, idx, text, mode=None, *args):
119
+ tk.Text.insert(self, idx, text, *args)
120
+ if mode:
121
+ self.highlight_mode = mode
122
+ self.highlight_syntax()
123
+
124
+ def highlight_syntax(self):
125
+ if not self.highlight_mode:
126
+ return
127
+ for tag in ("keyword", "datetime", "grayed"):
128
+ self.tag_remove(tag, "1.0", "end")
129
+ if self.highlight_mode == 'ical':
130
+ self.highlight_vcalendar()
131
+ elif self.highlight_mode == 'csv':
132
+ self.highlight_csv()
133
+ elif self.highlight_mode == 'efj':
134
+ self.highlight_efj()
135
+ self.edit_modified(False)
136
+
137
+ def highlight_vcalendar(self):
138
+ count = tk.IntVar()
139
+ start_idx = "1.0"
140
+ while True:
141
+ new_idx = self.search(
142
+ "(BEGIN|END):VEVENT",
143
+ start_idx, count=count, regexp=True,
144
+ stopindex="end")
145
+ if not new_idx:
146
+ break
147
+ start_idx = f"{new_idx} + {count.get()} chars"
148
+ self.tag_add("keyword", new_idx, start_idx)
149
+ start_idx = "1.0"
150
+ while True:
151
+ new_idx = self.search(
152
+ r"^[\w-]+:",
153
+ start_idx, count=count, regexp=True,
154
+ stopindex="end")
155
+ if not new_idx:
156
+ break
157
+ start_idx = f"{new_idx} + {count.get()} chars"
158
+ self.tag_add("grayed", new_idx, start_idx)
159
+ start_idx = "1.0"
160
+ while True:
161
+ new_idx = self.search(
162
+ r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}",
163
+ start_idx, count=count, regexp=True,
164
+ stopindex="end")
165
+ if not new_idx:
166
+ break
167
+ start_idx = f"{new_idx} + {count.get()} chars"
168
+ self.tag_add("datetime", new_idx, start_idx)
169
+
170
+ def highlight_csv(self):
171
+ count = tk.IntVar()
172
+ start_idx = "1.0"
173
+ while True:
174
+ new_idx = self.search(
175
+ r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}",
176
+ start_idx, count=count, regexp=True,
177
+ stopindex="end")
178
+ if not new_idx:
179
+ break
180
+ start_idx = f"{new_idx} + {count.get()} chars"
181
+ self.tag_add("datetime", new_idx, start_idx)
182
+ start_idx = "1.0"
183
+ while True:
184
+ new_idx = self.search(
185
+ r'(:00)?[",]+',
186
+ start_idx, count=count, regexp=True,
187
+ stopindex="end")
188
+ if not new_idx:
189
+ break
190
+ start_idx = f"{new_idx} + {count.get()} chars"
191
+ self.tag_add("grayed", new_idx, start_idx)
192
+
193
+ def highlight_efj(self):
194
+ count = tk.IntVar()
195
+ start_idx = "1.0"
196
+ while True:
197
+ new_idx = self.search(
198
+ r"\d{4}-\d{2}-\d{2}",
199
+ start_idx, count=count, regexp=True,
200
+ stopindex="end")
201
+ if not new_idx:
202
+ break
203
+ start_idx = f"{new_idx} + {count.get()} chars"
204
+ self.tag_add("datetime", new_idx, start_idx)
205
+ start_idx = "1.0"
206
+ while True:
207
+ new_idx = self.search(
208
+ r"\d{4}/\d{4}",
209
+ start_idx, count=count, regexp=True,
210
+ stopindex="end")
211
+ if not new_idx:
212
+ break
213
+ start_idx = f"{new_idx} + {count.get()} chars"
214
+ self.tag_add("datetime", new_idx, start_idx)
215
+ start_idx = "1.0"
216
+ while True:
217
+ new_idx = self.search(
218
+ "CP:|FO:|PU:|FA:",
219
+ start_idx, count=count, regexp=True,
220
+ stopindex="end")
221
+ if not new_idx:
222
+ break
223
+ start_idx = f"{new_idx} + {count.get()} chars"
224
+ self.tag_add("keyword", new_idx, start_idx)
225
+
226
+
227
+ class MainWindow(ttk.Frame):
228
+
229
+ def __init__(self, parent):
230
+ ttk.Frame.__init__(self, parent)
231
+ self.parent = parent
232
+ try:
233
+ with open(SETTINGS_FILE) as f:
234
+ self.settings = json.load(f)
235
+ except Exception:
236
+ self.settings = {}
237
+ self.__make_widgets()
238
+ self.txt.insert(tk.END, f"Version: {VERSION}")
239
+ self.copy_mode = "all"
240
+
241
+ def __make_widgets(self):
242
+ self.columnconfigure(1, weight=1)
243
+ self.rowconfigure(0, weight=1)
244
+ sb = ttk.Scrollbar(self, orient='vertical')
245
+ sbx = ttk.Scrollbar(self, orient='horizontal')
246
+ sb.grid(row=0, column=2, sticky=tk.NS)
247
+ sbx.grid(row=1, column=1, sticky=tk.EW)
248
+ sidebar = ttk.Frame(self, width=0)
249
+ sidebar.grid(row=0, column=0, sticky=tk.NS, padx=5, pady=5)
250
+ self.txt = TextWithSyntaxHighlighting(self)
251
+ self.txt.grid(row=0, column=1, sticky=tk.NSEW)
252
+ sb.config(command=self.txt.yview)
253
+ sbx.config(command=self.txt.xview)
254
+ self.txt.config(yscrollcommand=sb.set)
255
+ self.txt.config(xscrollcommand=sbx.set)
256
+ self.txt.bind("<<Selection>>", self.__on_selection_change)
257
+
258
+ sidebar.rowconfigure(1, weight=1)
259
+ self.ms = ModeSelector(sidebar,
260
+ self.settings.get('ADE', True))
261
+ self.ms.bind("<<ModeChange>>", self.__on_mode_change)
262
+ self.ms.bind("<<OptionChange>>", self.__on_option_change)
263
+ self.ms.grid(row=0, sticky=tk.N)
264
+ self.act = Actions(sidebar)
265
+ self.act.grid(row=1, sticky=(tk.EW + tk.S))
266
+ for event, func in (
267
+ ("<<Action-Import>>", self.__import),
268
+ ("<<Action-Copy>>", self.__copy),
269
+ ("<<Action-Save>>", self.__save),
270
+ ("<<Action-Quit>>", lambda _: self.parent.destroy())):
271
+ self.act.bind(event, func)
272
+ self.act.set_copy_selected(False)
273
+
274
+ def __on_mode_change(self, _):
275
+ self.txt.delete('1.0', tk.END)
276
+
277
+ def __on_option_change(self, _):
278
+ self.settings['ADE'] = self.ms.with_ade.get()
279
+ self.txt.delete('1.0', tk.END)
280
+
281
+ def __on_selection_change(self, _):
282
+ if self.txt.tag_ranges("sel"):
283
+ if self.copy_mode == "sel":
284
+ return
285
+ self.copy_mode = "sel"
286
+ self.act.set_copy_selected(True)
287
+ else:
288
+ self.copy_mode = "all"
289
+ self.act.set_copy_selected(False)
290
+
291
+ def __import(self, _):
292
+ assert self.ms.output_type.get() in ('csv', 'ical', 'efj')
293
+ try:
294
+ if self.ms.output_type.get() == 'efj':
295
+ self.__efj()
296
+ elif self.ms.output_type.get() == 'csv':
297
+ self.__csv()
298
+ else:
299
+ self.__ical()
300
+ except roster.RosterException as e:
301
+ self.txt.delete('1.0', tk.END)
302
+ messagebox.showerror("Error", str(e))
303
+
304
+ def __roster_html(self):
305
+ retval = ""
306
+ path = self.settings.get('openPath')
307
+ fn = filedialog.askopenfilename(
308
+ filetypes=(
309
+ ("HTML file", "*.htm"),
310
+ ("HTML file", "*.html"),
311
+ ("All", "*.*")),
312
+ initialdir=path)
313
+ if fn:
314
+ self.settings['openPath'] = os.path.dirname(fn)
315
+ try:
316
+ with open(fn) as f:
317
+ retval = f.read()
318
+ except Exception:
319
+ raise roster.InputFileException
320
+ return retval
321
+
322
+ def __csv(self):
323
+ txt = ""
324
+ html = self.__roster_html()
325
+ if not html:
326
+ return
327
+ self.txt.delete('1.0', tk.END)
328
+ self.txt.insert(tk.END, "Getting registration and type info...")
329
+ self.txt.update()
330
+
331
+ lines = roster.lines(html)
332
+ sectors = tuple(X for X in roster.sectors(roster.columns(lines))
333
+ if X.from_)
334
+ if not sectors:
335
+ messagebox.showinfo('Sectors', 'No relevant sectors found')
336
+ self.txt.delete('1.0', tk.END)
337
+ return
338
+ crewlist_map = roster.crew_dict(lines)
339
+ # note: normalise newlines for Text widget - will restore on output
340
+ txt = csv(sectors, crewlist_map).replace("\r\n", "\n")
341
+ self.txt.delete('1.0', tk.END)
342
+ self.txt.insert(tk.END, txt, 'csv')
343
+
344
+ def __ical(self):
345
+ html = self.__roster_html()
346
+ if not html:
347
+ return
348
+ lines = roster.lines(html)
349
+ columns = roster.columns(lines)
350
+ sectors = roster.sectors(columns)
351
+ ade = roster.all_day_events(columns) if self.ms.with_ade.get() else ()
352
+ if not sectors:
353
+ self.txt.delete('1.0', tk.END)
354
+ messagebox.showinfo('Duties', 'No relevant sectors found')
355
+ return
356
+ # note: normalise newlines for Text widget - will restore on output
357
+ txt = ical(sectors, ade).replace("\r\n", "\n")
358
+ self.txt.delete('1.0', tk.END)
359
+ self.txt.insert(tk.END, txt, 'ical')
360
+
361
+ def __efj(self):
362
+ html = self.__roster_html()
363
+ if not html:
364
+ return
365
+ self.txt.delete('1.0', tk.END)
366
+ self.txt.insert(tk.END, "Working…", 'efj')
367
+ self.txt.update()
368
+ lines = roster.lines(html)
369
+ columns = roster.columns(lines)
370
+ sectors = roster.sectors(columns)
371
+ txt = efj(sectors, roster.crew_dict(lines))
372
+ self.txt.delete('1.0', tk.END)
373
+ self.txt.insert(tk.END, txt, 'efj')
374
+
375
+ def __copy(self, _):
376
+ self.clipboard_clear()
377
+ if self.copy_mode == "all":
378
+ start, end = '1.0', tk.END
379
+ else:
380
+ start, end = self.txt.tag_ranges("sel")
381
+ text = self.txt.get(start, end)
382
+ # ical and excel dialect csv need DOS style line endings
383
+ if self.ms.output_type in ('ical', 'csv'):
384
+ text = text.replace("\n", "\r\n")
385
+ self.clipboard_append(text)
386
+ messagebox.showinfo('Copy', 'Text copied to clipboard.')
387
+
388
+ def __save(self, _):
389
+ output_type = self.ms.output_type.get()
390
+ assert output_type in ('csv', 'ical', 'efj')
391
+ if output_type == 'csv':
392
+ pathtype = 'csvSavePath'
393
+ filetypes = (("CSV file", "*.csv"),
394
+ ("All", "*.*"))
395
+ default_ext = '.csv'
396
+ elif output_type == 'efj':
397
+ pathtype = 'efjSavePath'
398
+ filetypes = (("Text file", "*.txt"),
399
+ ("All", "*.*"))
400
+ default_ext = '.txt'
401
+ else:
402
+ pathtype = 'icalSavePath'
403
+ filetypes = (("ICAL file", "*.ics"),
404
+ ("ICAL file", "*.ical"),
405
+ ("All", "*.*"))
406
+ default_ext = '.ics'
407
+ path = self.settings.get(pathtype)
408
+ fn = filedialog.asksaveasfilename(
409
+ initialdir=path,
410
+ filetypes=filetypes,
411
+ defaultextension=default_ext)
412
+ if fn:
413
+ self.settings[pathtype] = os.path.dirname(fn)
414
+ with open(fn, "w", encoding="utf-8", newline='') as f:
415
+ text = self.txt.get('1.0', tk.END)
416
+ # ical and excel dialect csv need DOS style line endings
417
+ if output_type in ('ical', 'csv'):
418
+ text = text.replace("\n", "\r\n")
419
+ f.write(text)
420
+ messagebox.showinfo('Saved', 'Save complete.')
421
+
422
+ def destroy(self):
423
+ with open(SETTINGS_FILE, "w") as f:
424
+ json.dump(self.settings, f, indent=4)
425
+ ttk.Frame.destroy(self)
426
+
427
+
428
+ def main():
429
+ if "windll" in dir(ctypes):
430
+ ctypes.windll.shcore.SetProcessDpiAwareness(1)
431
+ root = tk.Tk()
432
+ root.title("aimsgui")
433
+ mw = MainWindow(root)
434
+ mw.pack(fill=tk.BOTH, expand=True)
435
+ root.mainloop()
436
+
437
+
438
+ if __name__ == "__main__":
439
+ main()