psdi-data-conversion 0.1.7__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. psdi_data_conversion/app.py +5 -408
  2. psdi_data_conversion/constants.py +11 -7
  3. psdi_data_conversion/converter.py +41 -28
  4. psdi_data_conversion/converters/base.py +18 -13
  5. psdi_data_conversion/database.py +284 -88
  6. psdi_data_conversion/gui/__init__.py +5 -0
  7. psdi_data_conversion/gui/accessibility.py +51 -0
  8. psdi_data_conversion/gui/env.py +239 -0
  9. psdi_data_conversion/gui/get.py +53 -0
  10. psdi_data_conversion/gui/post.py +176 -0
  11. psdi_data_conversion/gui/setup.py +102 -0
  12. psdi_data_conversion/main.py +70 -13
  13. psdi_data_conversion/static/content/convert.htm +105 -74
  14. psdi_data_conversion/static/content/convertato.htm +36 -26
  15. psdi_data_conversion/static/content/convertc2x.htm +39 -26
  16. psdi_data_conversion/static/content/download.htm +5 -5
  17. psdi_data_conversion/static/content/feedback.htm +2 -2
  18. psdi_data_conversion/static/content/header-links.html +2 -2
  19. psdi_data_conversion/static/content/index-versions/header-links.html +2 -2
  20. psdi_data_conversion/static/content/index-versions/psdi-common-header.html +9 -12
  21. psdi_data_conversion/static/content/psdi-common-header.html +9 -12
  22. psdi_data_conversion/static/javascript/accessibility.js +88 -61
  23. psdi_data_conversion/static/javascript/data.js +1 -3
  24. psdi_data_conversion/static/javascript/load_accessibility.js +50 -33
  25. psdi_data_conversion/static/styles/format.css +72 -18
  26. psdi_data_conversion/templates/accessibility.htm +274 -0
  27. psdi_data_conversion/templates/documentation.htm +6 -6
  28. psdi_data_conversion/templates/index.htm +73 -56
  29. psdi_data_conversion/{static/content → templates}/report.htm +28 -10
  30. psdi_data_conversion/testing/conversion_test_specs.py +26 -6
  31. psdi_data_conversion/testing/utils.py +6 -6
  32. {psdi_data_conversion-0.1.7.dist-info → psdi_data_conversion-0.2.0.dist-info}/METADATA +6 -2
  33. {psdi_data_conversion-0.1.7.dist-info → psdi_data_conversion-0.2.0.dist-info}/RECORD +36 -30
  34. psdi_data_conversion/static/content/accessibility.htm +0 -255
  35. {psdi_data_conversion-0.1.7.dist-info → psdi_data_conversion-0.2.0.dist-info}/WHEEL +0 -0
  36. {psdi_data_conversion-0.1.7.dist-info → psdi_data_conversion-0.2.0.dist-info}/entry_points.txt +0 -0
  37. {psdi_data_conversion-0.1.7.dist-info → psdi_data_conversion-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,51 @@
1
+ """
2
+ # post.py
3
+
4
+ This module defines the various web addresses which do something (the "POST" methods) provided by the website,
5
+ connecting them to relevant functions.
6
+ """
7
+
8
+
9
+ import json
10
+
11
+ from flask import Flask, make_response, render_template, request
12
+
13
+ from psdi_data_conversion import constants as const
14
+ from psdi_data_conversion.gui.env import get_env_kwargs
15
+
16
+
17
+ def accessibility():
18
+ """Return the accessibility page
19
+ """
20
+ return render_template("accessibility.htm",
21
+ **get_env_kwargs())
22
+
23
+
24
+ def save_accessibility():
25
+ """Save the user's accessibility settings in a cookie
26
+ """
27
+
28
+ resp = make_response("Cookie saved successfully")
29
+
30
+ d_settings: dict[str, str] = json.loads(request.form['data'])
31
+
32
+ for key, val in d_settings.items():
33
+ resp.set_cookie(key, val, max_age=const.YEAR)
34
+
35
+ return resp
36
+
37
+
38
+ def load_accessibility():
39
+ """Load the user's accessibility settings from the cookie
40
+ """
41
+ return json.dumps(request.cookies)
42
+
43
+
44
+ def init_accessibility(app: Flask):
45
+ """Connect the provided Flask app to each of the post methods
46
+ """
47
+
48
+ app.route('/accessibility.htm')(accessibility)
49
+
50
+ app.route('/save_accessibility/', methods=["POST"])(save_accessibility)
51
+ app.route('/load_accessibility/', methods=["GET"])(load_accessibility)
@@ -0,0 +1,239 @@
1
+ """
2
+ # env.py
3
+
4
+ This module handles setting up and storing the state of the environment for the website, e.g. environmental variables
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from argparse import Namespace
10
+ from datetime import datetime
11
+ from hashlib import md5
12
+ from subprocess import run
13
+ from traceback import format_exc
14
+ from typing import TypeVar
15
+
16
+ from psdi_data_conversion import constants as const
17
+ from psdi_data_conversion import log_utility
18
+
19
+ # Env var for the tag and SHA of the latest commit
20
+ TAG_EV = "TAG"
21
+ TAG_SHA_EV = "TAG_SHA"
22
+ SHA_EV = "SHA"
23
+
24
+ # Env var for whether this is a production release or development
25
+ PRODUCTION_EV = "PRODUCTION_MODE"
26
+
27
+ # Env var for whether this is a production release or development
28
+ DEBUG_EV = "DEBUG_MODE"
29
+
30
+
31
+ class SiteEnv:
32
+ def __init__(self, args: Namespace | None = None):
33
+
34
+ self._args = args
35
+ """The parsed arguments provided to the script to start the server"""
36
+
37
+ self.log_mode: str = self._determine_log_mode()
38
+ """The logging mode"""
39
+
40
+ self.log_level: str = self._determine_log_level()
41
+ """The logging level"""
42
+
43
+ self.max_file_size = self._determine_value(ev=const.MAX_FILESIZE_EV,
44
+ arg="max_file_size",
45
+ default=const.DEFAULT_MAX_FILE_SIZE /
46
+ const.MEGABYTE)*const.MEGABYTE
47
+ """The maximum file size for converters other than Open Babel"""
48
+
49
+ self.max_file_size_ob = self._determine_value(ev=const.MAX_FILESIZE_OB_EV,
50
+ arg="max_file_size_ob",
51
+ default=const.DEFAULT_MAX_FILE_SIZE_OB /
52
+ const.MEGABYTE)*const.MEGABYTE
53
+ """The maximum file size for the Open Babel converter"""
54
+
55
+ self.service_mode = self._determine_value(ev=const.SERVICE_MODE_EV,
56
+ arg="service_mode",
57
+ value_type=bool,
58
+ default=False)
59
+ """True if the app is running in service mode, False if it's running in local mode"""
60
+
61
+ self.production_mode = self._determine_value(ev=PRODUCTION_EV,
62
+ arg="!dev_mode",
63
+ value_type=bool,
64
+ default=False)
65
+ """True if the app is running in production mode, False if it's running in developmennt mode"""
66
+
67
+ self.debug_mode = self._determine_value(ev=DEBUG_EV,
68
+ arg="debug",
69
+ value_type=bool,
70
+ default=False)
71
+ """True if the app is running in debug mode, False if not"""
72
+
73
+ tag, sha = self._determine_tag_and_sha()
74
+
75
+ self.tag: str = tag
76
+ """The latest tag in the repo"""
77
+
78
+ self.sha: str = sha
79
+ """The SHA of the latest commit, if the latest commit isn't tagged, otherwise an empty string"""
80
+
81
+ dt = str(datetime.now())
82
+ self.token = md5(dt.encode('utf8')).hexdigest()
83
+ """A token for this session, created by hashing the the current date and time"""
84
+
85
+ self._kwargs: dict[str, str] | None = None
86
+ """Cached value for dict containing all env values"""
87
+
88
+ @property
89
+ def kwargs(self) -> dict[str, str]:
90
+ """Get a dict which can be used to provide kwargs for rendering a template"""
91
+ if not self._kwargs:
92
+ self._kwargs = {}
93
+ for key, val in self.__dict__.items():
94
+ if not key.startswith("_"):
95
+ self._kwargs[key] = val
96
+ return self._kwargs
97
+
98
+ def _determine_log_mode(self) -> str:
99
+ """Determine the log mode from args and environmental variables, preferring the former"""
100
+ if self._args:
101
+ return self._args.log_mode
102
+
103
+ ev_log_mode = os.environ.get(const.LOG_MODE_EV)
104
+ if ev_log_mode is None:
105
+ return const.LOG_MODE_DEFAULT
106
+
107
+ ev_log_mode = ev_log_mode.lower()
108
+ if ev_log_mode not in const.L_ALLOWED_LOG_MODES:
109
+ raise ValueError(f"ERROR: Unrecognised logging option: {ev_log_mode}. "
110
+ f"Allowed options are: {const.L_ALLOWED_LOG_MODES}")
111
+
112
+ return ev_log_mode
113
+
114
+ def _determine_log_level(self) -> str | None:
115
+ """Determine the log level from args and environmental variables, preferring the former"""
116
+ if self._args:
117
+ return self._args.log_level
118
+
119
+ ev_log_level = os.environ.get(const.LOG_LEVEL_EV)
120
+ if ev_log_level is None:
121
+ return None
122
+
123
+ return log_utility.get_log_level_from_str(ev_log_level)
124
+
125
+ T = TypeVar('T')
126
+
127
+ def _determine_value(self,
128
+ ev: str,
129
+ arg: str,
130
+ value_type: type[T] = float,
131
+ default: T = None) -> T | None:
132
+ """Determine a value using input arguments (preferred if present) and environmental variables"""
133
+ if self._args and arg:
134
+ # Special handling for bool, which allows flipping the value of an arg
135
+ if value_type is bool and arg.startswith("!"):
136
+ return not getattr(self._args, arg[1:])
137
+ return getattr(self._args, arg)
138
+
139
+ ev_value = os.environ.get(ev)
140
+ if not ev_value:
141
+ return default
142
+
143
+ # Special handling for bool, to properly parse strings into bools
144
+ if value_type is bool:
145
+ return ev_value.lower().startswith("t")
146
+
147
+ return value_type(ev_value)
148
+
149
+ def _determine_tag_and_sha(self) -> tuple[str, str]:
150
+ """Get latest tag and SHA of latest commit, if the latest commit differs from the latest tagged commit
151
+ """
152
+
153
+ # Get the tag of the latest commit
154
+ ev_tag = os.environ.get(TAG_EV)
155
+ if ev_tag:
156
+ tag = ev_tag
157
+ else:
158
+ try:
159
+ # This bash command calls `git tag` to get a sorted list of tags, with the most recent at the top, then
160
+ # uses `head` to trim it to one line
161
+ cmd = "git tag --sort -version:refname | head -n 1"
162
+
163
+ out_bytes = run(cmd, shell=True, capture_output=True).stdout
164
+ tag = str(out_bytes.decode()).strip()
165
+
166
+ except Exception:
167
+ # Failsafe exception block, since this is reasonably likely to occur (e.g. due to a shallow fetch of the
168
+ # repo, and we don't want to crash the whole app because of it)
169
+ print("ERROR: Could not determine most recent tag. Error was:\n" + format_exc(),
170
+ file=sys.stderr)
171
+ tag = ""
172
+
173
+ # Get the SHA associated with this tag
174
+ ev_tag_sha = os.environ.get(TAG_SHA_EV)
175
+ if ev_tag_sha:
176
+ tag_sha: str | None = ev_tag_sha
177
+ else:
178
+ try:
179
+ cmd = f"git show {tag}" + " | head -n 1 | gawk '{print($2)}'"
180
+
181
+ out_bytes = run(cmd, shell=True, capture_output=True).stdout
182
+ tag_sha = str(out_bytes.decode()).strip()
183
+
184
+ except Exception:
185
+ # Another failsafe block, same reason as before
186
+ print("ERROR: Could not determine SHA for most recent tag. Error was:\n" + format_exc(),
187
+ file=sys.stderr)
188
+ tag_sha = None
189
+
190
+ # First check if the SHA is provided through an environmental variable
191
+ ev_sha = os.environ.get(SHA_EV)
192
+ if ev_sha:
193
+ sha = ev_sha
194
+ else:
195
+ try:
196
+ # This bash command calls `git log` to get info on the last commit, uses `head` to trim it to one line,
197
+ # then uses `gawk` to get just the second word of this line, which is the SHA of this commit
198
+ cmd = "git log -n 1 | head -n 1 | gawk '{print($2)}'"
199
+
200
+ out_bytes = run(cmd, shell=True, capture_output=True).stdout
201
+ sha = str(out_bytes.decode()).strip()
202
+
203
+ except Exception:
204
+ # Another failsafe block, same reason as before
205
+ print("ERROR: Could not determine SHA of most recent commit. Error was:\n" + format_exc(),
206
+ file=sys.stderr)
207
+ sha = ""
208
+
209
+ # If the SHA of the tag is the same as the current SHA, we indicate this by returning a blank SHA
210
+ if tag_sha == sha:
211
+ sha = ""
212
+
213
+ return (tag, sha)
214
+
215
+
216
+ _env: SiteEnv | None = None
217
+
218
+
219
+ def get_env():
220
+ """Get a reference to the global `SiteEnv` object, creating it if necessary.
221
+ """
222
+ global _env
223
+ if not _env:
224
+ _env = SiteEnv()
225
+ return _env
226
+
227
+
228
+ def update_env(args: Namespace | None = None):
229
+ """Update the global `SiteEnv` object, optionally using arguments passed at the command-line to override values
230
+ passed through environmental variables.
231
+ """
232
+ global _env
233
+ _env = SiteEnv(args)
234
+
235
+
236
+ def get_env_kwargs():
237
+ """Get a dict of common kwargs for the environment
238
+ """
239
+ return get_env().kwargs
@@ -0,0 +1,53 @@
1
+ """
2
+ # get.py
3
+
4
+ This module defines the various webpages (the "GET" methods) provided by the website, connecting them to relevant
5
+ functions to return rendered templates.
6
+ """
7
+
8
+
9
+ from flask import Flask, render_template
10
+
11
+ from psdi_data_conversion.database import get_database_path
12
+ from psdi_data_conversion.gui.env import get_env_kwargs
13
+
14
+
15
+ def index():
16
+ """Return the web page along with relevant data
17
+ """
18
+ return render_template("index.htm",
19
+ **get_env_kwargs())
20
+
21
+
22
+ def documentation():
23
+ """Return the documentation page
24
+ """
25
+ return render_template("documentation.htm",
26
+ **get_env_kwargs())
27
+
28
+
29
+ def database():
30
+ """Return the raw database JSON file
31
+ """
32
+ return open(get_database_path(), "r").read()
33
+
34
+
35
+ def report():
36
+ """Return the report page
37
+ """
38
+ return render_template("report.htm",
39
+ **get_env_kwargs())
40
+
41
+
42
+ def init_get(app: Flask):
43
+ """Connect the provided Flask app to each of the pages on the site
44
+ """
45
+
46
+ app.route('/')(index)
47
+ app.route('/index.htm')(index)
48
+
49
+ app.route('/documentation.htm')(documentation)
50
+
51
+ app.route('/report.htm')(report)
52
+
53
+ app.route('/database/')(database)
@@ -0,0 +1,176 @@
1
+ """
2
+ # post.py
3
+
4
+ This module defines the various web addresses which do something (the "POST" methods) provided by the website,
5
+ connecting them to relevant functions.
6
+ """
7
+
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from multiprocessing import Lock
13
+ from traceback import format_exc
14
+
15
+ from flask import Flask, Response, abort, request
16
+ from werkzeug.utils import secure_filename
17
+
18
+ from psdi_data_conversion import constants as const
19
+ from psdi_data_conversion import log_utility
20
+ from psdi_data_conversion.converter import run_converter
21
+ from psdi_data_conversion.database import get_format_info
22
+ from psdi_data_conversion.file_io import split_archive_ext
23
+ from psdi_data_conversion.gui.env import get_env
24
+
25
+ # Key for the label given to the file uploaded in the web interface
26
+ FILE_TO_UPLOAD_KEY = 'fileToUpload'
27
+
28
+ # A lock to prevent multiple threads from logging at the same time
29
+ logLock = Lock()
30
+
31
+
32
+ def convert():
33
+ """Convert file to a different format and save to folder 'downloads'. Delete original file. Note that downloading is
34
+ achieved in format.js
35
+ """
36
+
37
+ env = get_env()
38
+
39
+ # Make sure the upload directory exists
40
+ os.makedirs(const.DEFAULT_INPUT_DIR, exist_ok=True)
41
+
42
+ file = request.files[FILE_TO_UPLOAD_KEY]
43
+ filename = secure_filename(file.filename)
44
+
45
+ qualified_filename = os.path.join(const.DEFAULT_INPUT_DIR, filename)
46
+ file.save(qualified_filename)
47
+ qualified_output_log = os.path.join(const.DEFAULT_OUTPUT_DIR,
48
+ split_archive_ext(filename)[0] + const.OUTPUT_LOG_EXT)
49
+
50
+ # Determine the input and output formats
51
+ d_formats = {}
52
+ for format_label in "to", "from":
53
+ name = request.form[format_label]
54
+ full_note = request.form[format_label+"_full"]
55
+
56
+ l_possible_formats = get_format_info(name, which="all")
57
+
58
+ # If there's only one possible format, use that
59
+ if len(l_possible_formats) == 1:
60
+ d_formats[format_label] = l_possible_formats[0]
61
+ continue
62
+
63
+ # Otherwise, find the format with the matching note
64
+ for possible_format in l_possible_formats:
65
+ if possible_format.note in full_note:
66
+ d_formats[format_label] = possible_format
67
+ break
68
+ else:
69
+ print(f"Format '{name}' with full description '{full_note}' could not be found in database.",
70
+ file=sys.stderr)
71
+ abort(const.STATUS_CODE_GENERAL)
72
+
73
+ if (not env.service_mode) or (request.form['token'] == env.token and env.token != ''):
74
+ try:
75
+ conversion_output = run_converter(name=request.form['converter'],
76
+ filename=qualified_filename,
77
+ data=request.form,
78
+ to_format=d_formats["to"],
79
+ from_format=d_formats["from"],
80
+ strict=(request.form['check_ext'] != "false"),
81
+ log_mode=env.log_mode,
82
+ log_level=env.log_level,
83
+ delete_input=True,
84
+ abort_callback=abort)
85
+ except Exception as e:
86
+
87
+ # Check for anticipated exceptions, and write a simpler message for them
88
+ for err_message in (const.ERR_CONVERSION_FAILED, const.ERR_CONVERTER_NOT_RECOGNISED,
89
+ const.ERR_EMPTY_ARCHIVE, const.ERR_WRONG_EXTENSIONS):
90
+ if log_utility.string_with_placeholders_matches(err_message, str(e)):
91
+ with open(qualified_output_log, "w") as fo:
92
+ fo.write(str(e))
93
+ abort(const.STATUS_CODE_GENERAL)
94
+
95
+ # If the exception provides a status code, get it
96
+ status_code: int
97
+ if hasattr(e, "status_code"):
98
+ status_code = e.status_code
99
+ else:
100
+ status_code = const.STATUS_CODE_GENERAL
101
+
102
+ # If the exception provides a message, report it
103
+ if hasattr(e, "message"):
104
+ msg = f"An unexpected exception was raised by the converter, with error message:\n{e.message}\n"
105
+ else:
106
+ # Failsafe exception message
107
+ msg = ("The following unexpected exception was raised by the converter:\n" +
108
+ format_exc()+"\n")
109
+ with open(qualified_output_log, "w") as fo:
110
+ fo.write(msg)
111
+ abort(status_code)
112
+
113
+ return repr(conversion_output)
114
+ else:
115
+ # return http status code 405
116
+ abort(405)
117
+
118
+
119
+ def feedback():
120
+ """Take feedback data from the web app and log it
121
+ """
122
+
123
+ try:
124
+
125
+ entry = {
126
+ "datetime": log_utility.get_date_time(),
127
+ }
128
+
129
+ report = json.loads(request.form['data'])
130
+
131
+ for key in ["type", "missing", "reason", "from", "to"]:
132
+ if key in report:
133
+ entry[key] = str(report[key])
134
+
135
+ # Write data in JSON format and send to stdout
136
+ logLock.acquire()
137
+ sys.stdout.write(f"{json.dumps(entry) + '\n'}")
138
+ logLock.release()
139
+
140
+ return Response(status=201)
141
+
142
+ except Exception:
143
+
144
+ return Response(status=400)
145
+
146
+
147
+ def delete():
148
+ """Delete files in folder 'downloads'
149
+ """
150
+
151
+ realbase = os.path.realpath(const.DEFAULT_OUTPUT_DIR)
152
+
153
+ realfilename = os.path.realpath(os.path.join(const.DEFAULT_OUTPUT_DIR, request.form['filename']))
154
+ reallogname = os.path.realpath(os.path.join(const.DEFAULT_OUTPUT_DIR, request.form['logname']))
155
+
156
+ if realfilename.startswith(realbase + os.sep) and reallogname.startswith(realbase + os.sep):
157
+
158
+ os.remove(realfilename)
159
+ os.remove(reallogname)
160
+
161
+ return 'okay'
162
+
163
+ else:
164
+
165
+ return Response(status=400)
166
+
167
+
168
+ def init_post(app: Flask):
169
+ """Connect the provided Flask app to each of the post methods
170
+ """
171
+
172
+ app.route('/convert/', methods=["POST"])(convert)
173
+
174
+ app.route('/feedback/', methods=["POST"])(feedback)
175
+
176
+ app.route('/delete/', methods=["POST"])(delete)
@@ -0,0 +1,102 @@
1
+ """
2
+ # setup.py
3
+
4
+ This module handles setting up the Flask app
5
+ """
6
+
7
+
8
+ import os
9
+ from collections.abc import Callable
10
+ from functools import wraps
11
+ from typing import Any
12
+
13
+ import werkzeug
14
+ from flask import Flask, cli
15
+
16
+ import psdi_data_conversion
17
+ from psdi_data_conversion import constants as const
18
+ from psdi_data_conversion.gui.accessibility import init_accessibility
19
+ from psdi_data_conversion.gui.env import get_env
20
+ from psdi_data_conversion.gui.get import init_get
21
+ from psdi_data_conversion.gui.post import init_post
22
+
23
+ _app: Flask | None = None
24
+
25
+
26
+ def _patch_flask_warning():
27
+ """Monkey-patch Flask to disable the warnings that would otherwise appear for this so they don't confuse the user
28
+ """
29
+
30
+ def suppress_warning(func: Callable[..., Any]) -> Callable[..., Any]:
31
+ @wraps(func)
32
+ def wrapper(*args, **kwargs) -> Any:
33
+ if args and isinstance(args[0], str) and args[0].startswith('WARNING: This is a development server.'):
34
+ return ''
35
+ return func(*args, **kwargs)
36
+ return wrapper
37
+
38
+ werkzeug.serving._ansi_style = suppress_warning(werkzeug.serving._ansi_style)
39
+ cli.show_server_banner = lambda *_: None
40
+
41
+
42
+ def _init_app():
43
+ """Create and return the Flask app with appropriate settings"""
44
+
45
+ # Suppress Flask's warning, since we're using the dev server as a GUI
46
+ _patch_flask_warning()
47
+
48
+ app = Flask(const.APP_NAME)
49
+
50
+ # Set the file upload limit based on env vars
51
+ limit_upload_size(app)
52
+
53
+ # Connect the app to the various pages and methods of the website
54
+ init_get(app)
55
+ init_post(app)
56
+ init_accessibility(app)
57
+
58
+ return app
59
+
60
+
61
+ def limit_upload_size(app: Flask | None = None):
62
+ """Impose a limit on the maximum file that can be uploaded before Flask will raise an error"""
63
+
64
+ if app is None:
65
+ app = get_app()
66
+
67
+ env = get_env()
68
+
69
+ # Determine the largest possible file size that can be uploaded, keeping in mind that 0 indicates unlimited
70
+ larger_max_file_size = env.max_file_size
71
+ if (env.max_file_size > 0) and (env.max_file_size_ob > env.max_file_size):
72
+ larger_max_file_size = env.max_file_size_ob
73
+
74
+ if larger_max_file_size > 0:
75
+ app.config['MAX_CONTENT_LENGTH'] = larger_max_file_size
76
+ else:
77
+ app.config['MAX_CONTENT_LENGTH'] = None
78
+
79
+
80
+ def get_app() -> Flask:
81
+ """Get a reference to the global `Flask` app, creating it if necessary.
82
+ """
83
+ global _app
84
+ if not _app:
85
+ _app = _init_app()
86
+ return _app
87
+
88
+
89
+ def start_app():
90
+ """Start the Flask app - this requires being run from the base directory of the project, so this changes the
91
+ current directory to there. Anything else which changes it while the app is running may interfere with its proper
92
+ execution.
93
+ """
94
+
95
+ old_cwd = os.getcwd()
96
+
97
+ try:
98
+ os.chdir(os.path.join(psdi_data_conversion.__path__[0], ".."))
99
+ get_app().run(debug=get_env().debug_mode)
100
+ finally:
101
+ # Return to the previous directory after running the app
102
+ os.chdir(old_cwd)