Passed
Push — master ( 938ce3...bc2c02 )
by
unknown
12:15
created

TabPyApp._parse_pwd_file()   A

Complexity

Conditions 3

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 7
dl 0
loc 10
ccs 6
cts 6
cp 1
rs 10
c 0
b 0
f 0
cc 3
nop 1
crap 3
1 1
import concurrent.futures
2 1
import configparser
3 1
import logging
4 1
import multiprocessing
5 1
import os
6 1
import shutil
7 1
import signal
8 1
import sys
9 1
import tabpy
10 1
from tabpy.tabpy import __version__
11 1
from tabpy.tabpy_server.app.app_parameters import ConfigParameters, SettingsParameters
12 1
from tabpy.tabpy_server.app.util import parse_pwd_file
13 1
from tabpy.tabpy_server.management.state import TabPyState
14 1
from tabpy.tabpy_server.management.util import _get_state_from_file
15 1
from tabpy.tabpy_server.psws.callbacks import init_model_evaluator, init_ps_server
16 1
from tabpy.tabpy_server.psws.python_service import PythonService, PythonServiceHandler
17 1
from tabpy.tabpy_server.handlers import (
18
    EndpointHandler,
19
    EndpointsHandler,
20
    EvaluationPlaneHandler,
21
    EvaluationPlaneDisabledHandler,
22
    QueryPlaneHandler,
23
    ServiceInfoHandler,
24
    StatusHandler,
25
    UploadDestinationHandler,
26
)
27 1
import tornado
28
29
30 1
logger = logging.getLogger(__name__)
31
32
33 1
def _init_asyncio_patch():
34
    """
35
    Select compatible event loop for Tornado 5+.
36
    As of Python 3.8, the default event loop on Windows is `proactor`,
37
    however Tornado requires the old default "selector" event loop.
38
    As Tornado has decided to leave this to users to set, MkDocs needs
39
    to set it. See https://github.com/tornadoweb/tornado/issues/2608.
40
    """
41 1
    if sys.platform.startswith("win") and sys.version_info >= (3, 8):
42
        import asyncio
43
        try:
44
            from asyncio import WindowsSelectorEventLoopPolicy
45
        except ImportError:
46
            pass  # Can't assign a policy which doesn't exist.
47
        else:
48
            if not isinstance(asyncio.get_event_loop_policy(), WindowsSelectorEventLoopPolicy):
49
                asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
50
51
52 1
class TabPyApp:
53
    """
54
    TabPy application class for keeping context like settings, state, etc.
55
    """
56
57 1
    settings = {}
58 1
    subdirectory = ""
59 1
    tabpy_state = None
60 1
    python_service = None
61 1
    credentials = {}
62
63 1
    def __init__(self, config_file):
64 1
        if config_file is None:
65 1
            config_file = os.path.join(
66
                os.path.dirname(__file__), os.path.pardir, "common", "default.conf"
67
            )
68
69 1
        if os.path.isfile(config_file):
70 1
            try:
71 1
                from logging import config
72 1
                config.fileConfig(config_file, disable_existing_loggers=False)
73 1
            except KeyError:
74 1
                logging.basicConfig(level=logging.DEBUG)
75
76 1
        self._parse_config(config_file)
77
78 1
    def run(self):
79
        application = self._create_tornado_web_app()
80
        max_request_size = (
81
            int(self.settings[SettingsParameters.MaxRequestSizeInMb]) * 1024 * 1024
82
        )
83
        logger.info(f"Setting max request size to {max_request_size} bytes")
84
85
        init_model_evaluator(self.settings, self.tabpy_state, self.python_service)
86
87
        protocol = self.settings[SettingsParameters.TransferProtocol]
88
        ssl_options = None
89
        if protocol == "https":
90
            ssl_options = {
91
                "certfile": self.settings[SettingsParameters.CertificateFile],
92
                "keyfile": self.settings[SettingsParameters.KeyFile],
93
            }
94
        elif protocol != "http":
95
            msg = f"Unsupported transfer protocol {protocol}."
96
            logger.critical(msg)
97
            raise RuntimeError(msg)
98
99
        application.listen(
100
            self.settings[SettingsParameters.Port],
101
            ssl_options=ssl_options,
102
            max_buffer_size=max_request_size,
103
            max_body_size=max_request_size,
104
        )
105
106
        logger.info(
107
            "Web service listening on port "
108
            f"{str(self.settings[SettingsParameters.Port])}"
109
        )
110
        tornado.ioloop.IOLoop.instance().start()
111
112 1
    def _create_tornado_web_app(self):
113 1
        class TabPyTornadoApp(tornado.web.Application):
114 1
            is_closing = False
115
116 1
            def signal_handler(self, signal, _):
117
                logger.critical(f"Exiting on signal {signal}...")
118
                self.is_closing = True
119
120 1
            def try_exit(self):
121
                if self.is_closing:
122
                    tornado.ioloop.IOLoop.instance().stop()
123
                    logger.info("Shutting down TabPy...")
124
125 1
        logger.info("Initializing TabPy...")
126 1
        tornado.ioloop.IOLoop.instance().run_sync(
127
            lambda: init_ps_server(self.settings, self.tabpy_state)
128
        )
129 1
        logger.info("Done initializing TabPy.")
130
131 1
        executor = concurrent.futures.ThreadPoolExecutor(
132
            max_workers=multiprocessing.cpu_count()
133
        )
134
135
        # initialize Tornado application
136 1
        _init_asyncio_patch()
137 1
        application = TabPyTornadoApp(
138
            [
139
                (
140
                    self.subdirectory + r"/query/([^/]+)",
141
                    QueryPlaneHandler,
142
                    dict(app=self),
143
                ),
144
                (self.subdirectory + r"/status", StatusHandler, dict(app=self)),
145
                (self.subdirectory + r"/info", ServiceInfoHandler, dict(app=self)),
146
                (self.subdirectory + r"/endpoints", EndpointsHandler, dict(app=self)),
147
                (
148
                    self.subdirectory + r"/endpoints/([^/]+)?",
149
                    EndpointHandler,
150
                    dict(app=self),
151
                ),
152
                (
153
                    self.subdirectory + r"/evaluate",
154
                    EvaluationPlaneHandler if self.settings[SettingsParameters.EvaluateEnabled]
155
                    else EvaluationPlaneDisabledHandler,
156
                    dict(executor=executor, app=self),
157
                ),
158
                (
159
                    self.subdirectory + r"/configurations/endpoint_upload_destination",
160
                    UploadDestinationHandler,
161
                    dict(app=self),
162
                ),
163
                (
164
                    self.subdirectory + r"/(.*)",
165
                    tornado.web.StaticFileHandler,
166
                    dict(
167
                        path=self.settings[SettingsParameters.StaticPath],
168
                        default_filename="index.html",
169
                    ),
170
                ),
171
            ],
172
            debug=False,
173
            **self.settings,
174
        )
175
176 1
        signal.signal(signal.SIGINT, application.signal_handler)
177 1
        tornado.ioloop.PeriodicCallback(application.try_exit, 500).start()
178
179 1
        signal.signal(signal.SIGINT, application.signal_handler)
180 1
        tornado.ioloop.PeriodicCallback(application.try_exit, 500).start()
181
182 1
        return application
183
184 1
    def _set_parameter(self, parser, settings_key, config_key, default_val, parse_function):
185 1
        key_is_set = False
186
187 1
        if (
188
            config_key is not None
189
            and parser.has_section("TabPy")
190
            and parser.has_option("TabPy", config_key)
191
        ):
192 1
            if parse_function is None:
193 1
                parse_function = parser.get
194 1
            self.settings[settings_key] = parse_function("TabPy", config_key)
195 1
            key_is_set = True
196 1
            logger.debug(
197
                f"Parameter {settings_key} set to "
198
                f'"{self.settings[settings_key]}" '
199
                "from config file or environment variable"
200
            )
201
202 1
        if not key_is_set and default_val is not None:
203 1
            self.settings[settings_key] = default_val
204 1
            key_is_set = True
205 1
            logger.debug(
206
                f"Parameter {settings_key} set to "
207
                f'"{self.settings[settings_key]}" '
208
                "from default value"
209
            )
210
211 1
        if not key_is_set:
212 1
            logger.debug(f"Parameter {settings_key} is not set")
213
214 1
    def _parse_config(self, config_file):
215
        """Provide consistent mechanism for pulling in configuration.
216
217
        Attempt to retain backward compatibility for
218
        existing implementations by grabbing port
219
        setting from CLI first.
220
221
        Take settings in the following order:
222
223
        1. CLI arguments if present
224
        2. config file
225
        3. OS environment variables (for ease of
226
           setting defaults if not present)
227
        4. current defaults if a setting is not present in any location
228
229
        Additionally provide similar configuration capabilities in between
230
        config file and environment variables.
231
        For consistency use the same variable name in the config file as
232
        in the os environment.
233
        For naming standards use all capitals and start with 'TABPY_'
234
        """
235 1
        self.settings = {}
236 1
        self.subdirectory = ""
237 1
        self.tabpy_state = None
238 1
        self.python_service = None
239 1
        self.credentials = {}
240
241 1
        pkg_path = os.path.dirname(tabpy.__file__)
242
243 1
        parser = configparser.ConfigParser(os.environ)
244 1
        logger.info(f"Parsing config file {config_file}")
245
246 1
        file_exists = False
247 1
        if os.path.isfile(config_file):
248 1
            try:
249 1
                with open(config_file, 'r') as f:
250 1
                    parser.read_string(f.read())
251 1
                    file_exists = True
252
            except Exception:
253
                pass
254
255 1
        if not file_exists:
256 1
            logger.warning(
257
                f"Unable to open config file {config_file}, "
258
                "using default settings."
259
            )
260
261 1
        settings_parameters = [
262
            (SettingsParameters.Port, ConfigParameters.TABPY_PORT, 9004, None),
263
            (SettingsParameters.ServerVersion, None, __version__, None),
264
            (SettingsParameters.EvaluateEnabled, ConfigParameters.TABPY_EVALUATE_ENABLE,
265
             True, parser.getboolean),
266
            (SettingsParameters.EvaluateTimeout, ConfigParameters.TABPY_EVALUATE_TIMEOUT,
267
             30, parser.getfloat),
268
            (SettingsParameters.UploadDir, ConfigParameters.TABPY_QUERY_OBJECT_PATH,
269
             os.path.join(pkg_path, "tmp", "query_objects"), None),
270
            (SettingsParameters.TransferProtocol, ConfigParameters.TABPY_TRANSFER_PROTOCOL,
271
             "http", None),
272
            (SettingsParameters.CertificateFile, ConfigParameters.TABPY_CERTIFICATE_FILE,
273
             None, None),
274
            (SettingsParameters.KeyFile, ConfigParameters.TABPY_KEY_FILE, None, None),
275
            (SettingsParameters.StateFilePath, ConfigParameters.TABPY_STATE_PATH,
276
             os.path.join(pkg_path, "tabpy_server"), None),
277
            (SettingsParameters.StaticPath, ConfigParameters.TABPY_STATIC_PATH,
278
             os.path.join(pkg_path, "tabpy_server", "static"), None),
279
            (ConfigParameters.TABPY_PWD_FILE, ConfigParameters.TABPY_PWD_FILE, None, None),
280
            (SettingsParameters.LogRequestContext, ConfigParameters.TABPY_LOG_DETAILS,
281
             "false", None),
282
            (SettingsParameters.MaxRequestSizeInMb, ConfigParameters.TABPY_MAX_REQUEST_SIZE_MB,
283
             100, None),
284
        ]
285
286 1
        for setting, parameter, default_val, parse_function in settings_parameters:
287 1
            self._set_parameter(parser, setting, parameter, default_val, parse_function)
288
289 1
        if not os.path.exists(self.settings[SettingsParameters.UploadDir]):
290 1
            os.makedirs(self.settings[SettingsParameters.UploadDir])
291
292
        # set and validate transfer protocol
293 1
        self.settings[SettingsParameters.TransferProtocol] = self.settings[
294
            SettingsParameters.TransferProtocol
295
        ].lower()
296
297 1
        self._validate_transfer_protocol_settings()
298
299
        # if state.ini does not exist try and create it - remove
300
        # last dependence on batch/shell script
301 1
        self.settings[SettingsParameters.StateFilePath] = os.path.realpath(
302
            os.path.normpath(
303
                os.path.expanduser(self.settings[SettingsParameters.StateFilePath])
304
            )
305
        )
306 1
        state_config, self.tabpy_state = self._build_tabpy_state()
307
308 1
        self.python_service = PythonServiceHandler(PythonService())
309 1
        self.settings["compress_response"] = True
310 1
        self.settings[SettingsParameters.StaticPath] = os.path.abspath(
311
            self.settings[SettingsParameters.StaticPath]
312
        )
313 1
        logger.debug(
314
            f"Static pages folder set to "
315
            f'"{self.settings[SettingsParameters.StaticPath]}"'
316
        )
317
318
        # Set subdirectory from config if applicable
319 1
        if state_config.has_option("Service Info", "Subdirectory"):
320 1
            self.subdirectory = "/" + state_config.get("Service Info", "Subdirectory")
321
322
        # If passwords file specified load credentials
323 1
        if ConfigParameters.TABPY_PWD_FILE in self.settings:
324 1
            if not self._parse_pwd_file():
325 1
                msg = (
326
                    "Failed to read passwords file "
327
                    f"{self.settings[ConfigParameters.TABPY_PWD_FILE]}"
328
                )
329 1
                logger.critical(msg)
330 1
                raise RuntimeError(msg)
331
        else:
332 1
            logger.info(
333
                "Password file is not specified: " "Authentication is not enabled"
334
            )
335
336 1
        features = self._get_features()
337 1
        self.settings[SettingsParameters.ApiVersions] = {"v1": {"features": features}}
338
339 1
        self.settings[SettingsParameters.LogRequestContext] = (
340
            self.settings[SettingsParameters.LogRequestContext].lower() != "false"
341
        )
342 1
        call_context_state = (
343
            "enabled"
344
            if self.settings[SettingsParameters.LogRequestContext]
345
            else "disabled"
346
        )
347 1
        logger.info(f"Call context logging is {call_context_state}")
348
349 1
    def _validate_transfer_protocol_settings(self):
350 1
        if SettingsParameters.TransferProtocol not in self.settings:
351
            msg = "Missing transfer protocol information."
352
            logger.critical(msg)
353
            raise RuntimeError(msg)
354
355 1
        protocol = self.settings[SettingsParameters.TransferProtocol]
356
357 1
        if protocol == "http":
358 1
            return
359
360 1
        if protocol != "https":
361 1
            msg = f"Unsupported transfer protocol: {protocol}"
362 1
            logger.critical(msg)
363 1
            raise RuntimeError(msg)
364
365 1
        self._validate_cert_key_state(
366
            "The parameter(s) {} must be set.",
367
            SettingsParameters.CertificateFile in self.settings,
368
            SettingsParameters.KeyFile in self.settings,
369
        )
370 1
        cert = self.settings[SettingsParameters.CertificateFile]
371
372 1
        self._validate_cert_key_state(
373
            "The parameter(s) {} must point to " "an existing file.",
374
            os.path.isfile(cert),
375
            os.path.isfile(self.settings[SettingsParameters.KeyFile]),
376
        )
377 1
        tabpy.tabpy_server.app.util.validate_cert(cert)
378
379 1
    @staticmethod
380
    def _validate_cert_key_state(msg, cert_valid, key_valid):
381 1
        cert_and_key_param = (
382
            f"{ConfigParameters.TABPY_CERTIFICATE_FILE} and "
383
            f"{ConfigParameters.TABPY_KEY_FILE}"
384
        )
385 1
        https_error = "Error using HTTPS: "
386 1
        err = None
387 1
        if not cert_valid and not key_valid:
388 1
            err = https_error + msg.format(cert_and_key_param)
389 1
        elif not cert_valid:
390 1
            err = https_error + msg.format(ConfigParameters.TABPY_CERTIFICATE_FILE)
391 1
        elif not key_valid:
392 1
            err = https_error + msg.format(ConfigParameters.TABPY_KEY_FILE)
393
394 1
        if err is not None:
395 1
            logger.critical(err)
396 1
            raise RuntimeError(err)
397
398 1
    def _parse_pwd_file(self):
399 1
        succeeded, self.credentials = parse_pwd_file(
400
            self.settings[ConfigParameters.TABPY_PWD_FILE]
401
        )
402
403 1
        if succeeded and len(self.credentials) == 0:
404 1
            logger.error("No credentials found")
405 1
            succeeded = False
406
407 1
        return succeeded
408
409 1
    def _get_features(self):
410 1
        features = {}
411
412
        # Check for auth
413 1
        if ConfigParameters.TABPY_PWD_FILE in self.settings:
414 1
            features["authentication"] = {
415
                "required": True,
416
                "methods": {"basic-auth": {}},
417
            }
418
419 1
        return features
420
421 1
    def _build_tabpy_state(self):
422 1
        pkg_path = os.path.dirname(tabpy.__file__)
423 1
        state_file_dir = self.settings[SettingsParameters.StateFilePath]
424 1
        state_file_path = os.path.join(state_file_dir, "state.ini")
425 1
        if not os.path.isfile(state_file_path):
426
            state_file_template_path = os.path.join(
427
                pkg_path, "tabpy_server", "state.ini.template"
428
            )
429
            logger.debug(
430
                f"File {state_file_path} not found, creating from "
431
                f"template {state_file_template_path}..."
432
            )
433
            shutil.copy(state_file_template_path, state_file_path)
434
435 1
        logger.info(f"Loading state from state file {state_file_path}")
436 1
        tabpy_state = _get_state_from_file(state_file_dir)
437
        return tabpy_state, TabPyState(config=tabpy_state, settings=self.settings)
438