plain 0.75.0__py3-none-any.whl → 0.76.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.
@@ -0,0 +1,555 @@
1
+ from __future__ import annotations
2
+
3
+ #
4
+ #
5
+ # This file is part of gunicorn released under the MIT license.
6
+ # See the LICENSE for more information.
7
+ #
8
+ # Vendored and modified for Plain.
9
+ import errno
10
+ import os
11
+ import random
12
+ import select
13
+ import signal
14
+ import socket
15
+ import sys
16
+ import time
17
+ import traceback
18
+ from types import FrameType
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ import plain.runtime
22
+
23
+ from . import sock, util
24
+ from .errors import AppImportError, HaltServer
25
+ from .pidfile import Pidfile
26
+
27
+ if TYPE_CHECKING:
28
+ from .app import ServerApplication
29
+ from .config import Config
30
+ from .glogging import Logger
31
+ from .workers.base import Worker
32
+
33
+
34
+ class Arbiter:
35
+ """
36
+ Arbiter maintain the workers processes alive. It launches or
37
+ kills them if needed. It also manages application reloading
38
+ via SIGHUP.
39
+ """
40
+
41
+ # A flag indicating if a worker failed to
42
+ # to boot. If a worker process exist with
43
+ # this error code, the arbiter will terminate.
44
+ WORKER_BOOT_ERROR: int = 3
45
+
46
+ # A flag indicating if an application failed to be loaded
47
+ APP_LOAD_ERROR: int = 4
48
+
49
+ START_CTX: dict[int | str, Any] = {}
50
+
51
+ LISTENERS: list[socket.socket] = []
52
+ WORKERS: dict[int, Worker] = {}
53
+ PIPE: list[int] = []
54
+
55
+ # I love dynamic languages
56
+ SIG_QUEUE: list[int] = []
57
+ SIGNALS: list[int] = [
58
+ getattr(signal, f"SIG{x}")
59
+ for x in "HUP QUIT INT TERM TTIN TTOU USR1 USR2 WINCH".split()
60
+ ]
61
+ SIG_NAMES: dict[int, str] = {
62
+ getattr(signal, name): name[3:].lower()
63
+ for name in dir(signal)
64
+ if name[:3] == "SIG" and name[3] != "_"
65
+ }
66
+
67
+ def __init__(self, app: ServerApplication):
68
+ os.environ["SERVER_SOFTWARE"] = f"plain/{plain.runtime.__version__}"
69
+
70
+ self._num_workers: int | None = None
71
+ self._last_logged_active_worker_count: int | None = None
72
+ self.log: Logger | None = None
73
+
74
+ self.setup(app)
75
+
76
+ self.pidfile: Pidfile | None = None
77
+ self.worker_age: int = 0
78
+
79
+ cwd = util.getcwd()
80
+
81
+ args = sys.argv[:]
82
+ args.insert(0, sys.executable)
83
+
84
+ # init start context
85
+ self.START_CTX = {"args": args, "cwd": cwd, 0: sys.executable}
86
+
87
+ def _get_num_workers(self) -> int | None:
88
+ return self._num_workers
89
+
90
+ def _set_num_workers(self, value: int) -> None:
91
+ self._num_workers = value
92
+
93
+ num_workers = property(_get_num_workers, _set_num_workers)
94
+
95
+ def setup(self, app: ServerApplication) -> None:
96
+ self.app: ServerApplication = app
97
+ assert app.cfg is not None, "Application config must be initialized"
98
+ self.cfg: Config = app.cfg
99
+
100
+ if self.log is None:
101
+ from .glogging import Logger
102
+
103
+ self.log = Logger(self.cfg)
104
+
105
+ self.worker_class: type[Worker] = self.cfg.worker_class
106
+ self.address: str = self.cfg.address
107
+ self.num_workers = self.cfg.workers
108
+ self.timeout: int = self.cfg.timeout
109
+
110
+ def start(self) -> None:
111
+ """\
112
+ Initialize the arbiter. Start listening and set pidfile if needed.
113
+ """
114
+ self.pid: int = os.getpid()
115
+ if self.cfg.pidfile is not None:
116
+ self.pidfile = Pidfile(self.cfg.pidfile)
117
+ self.pidfile.create(self.pid)
118
+
119
+ self.init_signals()
120
+
121
+ if not self.LISTENERS:
122
+ self.LISTENERS = sock.create_sockets(self.cfg, self.log)
123
+
124
+ listeners_str = ",".join([str(lnr) for lnr in self.LISTENERS])
125
+ self.log.info(
126
+ "Plain server started address=%s pid=%s worker=%s version=%s",
127
+ listeners_str,
128
+ self.pid,
129
+ self.cfg.worker_class_str,
130
+ plain.runtime.__version__,
131
+ )
132
+
133
+ # check worker class requirements
134
+ if hasattr(self.worker_class, "check_config"):
135
+ self.worker_class.check_config(self.cfg, self.log)
136
+
137
+ def init_signals(self) -> None:
138
+ """\
139
+ Initialize master signal handling. Most of the signals
140
+ are queued. Child signals only wake up the master.
141
+ """
142
+ # close old PIPE
143
+ for p in self.PIPE:
144
+ os.close(p)
145
+
146
+ # initialize the pipe
147
+ self.PIPE = pair = os.pipe()
148
+ for p in pair:
149
+ util.set_non_blocking(p)
150
+ util.close_on_exec(p)
151
+
152
+ self.log.close_on_exec()
153
+
154
+ # initialize all signals
155
+ for s in self.SIGNALS:
156
+ signal.signal(s, self.signal)
157
+ signal.signal(signal.SIGCHLD, self.handle_chld)
158
+
159
+ def signal(self, sig: int, frame: FrameType | None) -> None:
160
+ if len(self.SIG_QUEUE) < 5:
161
+ self.SIG_QUEUE.append(sig)
162
+ self.wakeup()
163
+
164
+ def run(self) -> None:
165
+ "Main master loop."
166
+ self.start()
167
+
168
+ try:
169
+ self.manage_workers()
170
+
171
+ while True:
172
+ sig = self.SIG_QUEUE.pop(0) if self.SIG_QUEUE else None
173
+ if sig is None:
174
+ self.sleep()
175
+ self.murder_workers()
176
+ self.manage_workers()
177
+ continue
178
+
179
+ if sig not in self.SIG_NAMES:
180
+ self.log.info("Ignoring unknown signal: %s", sig)
181
+ continue
182
+
183
+ signame = self.SIG_NAMES.get(sig)
184
+ handler = getattr(self, f"handle_{signame}", None)
185
+ if not handler:
186
+ self.log.error("Unhandled signal: %s", signame)
187
+ continue
188
+ self.log.info("Handling signal: %s", signame)
189
+ handler()
190
+ self.wakeup()
191
+ except (StopIteration, KeyboardInterrupt):
192
+ self.halt()
193
+ except HaltServer as inst:
194
+ self.halt(reason=inst.reason, exit_status=inst.exit_status)
195
+ except SystemExit:
196
+ raise
197
+ except Exception:
198
+ self.log.error("Unhandled exception in main loop", exc_info=True)
199
+ self.stop(False)
200
+ if self.pidfile is not None:
201
+ self.pidfile.unlink()
202
+ sys.exit(-1)
203
+
204
+ def handle_chld(self, sig: int, frame: FrameType | None) -> None:
205
+ "SIGCHLD handling"
206
+ self.reap_workers()
207
+ self.wakeup()
208
+
209
+ def handle_hup(self) -> None:
210
+ """\
211
+ HUP handling.
212
+ - Reload configuration
213
+ - Start the new worker processes with a new configuration
214
+ - Gracefully shutdown the old worker processes
215
+ """
216
+ self.log.info("Hang up: Master")
217
+ self.reload()
218
+
219
+ def handle_term(self) -> None:
220
+ "SIGTERM handling"
221
+ raise StopIteration
222
+
223
+ def handle_int(self) -> None:
224
+ "SIGINT handling"
225
+ self.stop(False)
226
+ raise StopIteration
227
+
228
+ def handle_quit(self) -> None:
229
+ "SIGQUIT handling"
230
+ self.stop(False)
231
+ raise StopIteration
232
+
233
+ def handle_ttin(self) -> None:
234
+ """\
235
+ SIGTTIN handling.
236
+ Increases the number of workers by one.
237
+ """
238
+ self.num_workers += 1
239
+ self.manage_workers()
240
+
241
+ def handle_ttou(self) -> None:
242
+ """\
243
+ SIGTTOU handling.
244
+ Decreases the number of workers by one.
245
+ """
246
+ if self.num_workers <= 1:
247
+ return None
248
+ self.num_workers -= 1
249
+ self.manage_workers()
250
+
251
+ def handle_usr1(self) -> None:
252
+ """\
253
+ SIGUSR1 handling.
254
+ Kill all workers by sending them a SIGUSR1
255
+ """
256
+ self.log.reopen_files()
257
+ self.kill_workers(signal.SIGUSR1)
258
+
259
+ def handle_usr2(self) -> None:
260
+ """SIGUSR2 handling"""
261
+ # USR2 for graceful restart is not supported
262
+ self.log.debug("SIGUSR2 ignored")
263
+
264
+ def handle_winch(self) -> None:
265
+ """SIGWINCH handling"""
266
+ # SIGWINCH is typically used to gracefully stop workers when running as daemon
267
+ # Since we don't support daemon mode, just log that it's ignored
268
+ self.log.debug("SIGWINCH ignored")
269
+
270
+ def wakeup(self) -> None:
271
+ """\
272
+ Wake up the arbiter by writing to the PIPE
273
+ """
274
+ try:
275
+ os.write(self.PIPE[1], b".")
276
+ except OSError as e:
277
+ if e.errno not in [errno.EAGAIN, errno.EINTR]:
278
+ raise
279
+
280
+ def halt(self, reason: str | None = None, exit_status: int = 0) -> None:
281
+ """halt arbiter"""
282
+ self.stop()
283
+
284
+ log_func = self.log.info if exit_status == 0 else self.log.error
285
+ log_func("Shutting down: Master")
286
+ if reason is not None:
287
+ log_func("Reason: %s", reason)
288
+
289
+ if self.pidfile is not None:
290
+ self.pidfile.unlink()
291
+ sys.exit(exit_status)
292
+
293
+ def sleep(self) -> None:
294
+ """\
295
+ Sleep until PIPE is readable or we timeout.
296
+ A readable PIPE means a signal occurred.
297
+ """
298
+ try:
299
+ ready = select.select([self.PIPE[0]], [], [], 1.0)
300
+ if not ready[0]:
301
+ return
302
+ while os.read(self.PIPE[0], 1):
303
+ pass
304
+ except OSError as e:
305
+ # TODO: select.error is a subclass of OSError since Python 3.3.
306
+ error_number = getattr(e, "errno", e.args[0])
307
+ if error_number not in [errno.EAGAIN, errno.EINTR]:
308
+ raise
309
+ except KeyboardInterrupt:
310
+ sys.exit()
311
+
312
+ def stop(self, graceful: bool = True) -> None:
313
+ """\
314
+ Stop workers
315
+
316
+ :attr graceful: boolean, If True (the default) workers will be
317
+ killed gracefully (ie. trying to wait for the current connection)
318
+ """
319
+ sock.close_sockets(self.LISTENERS, unlink=True)
320
+
321
+ self.LISTENERS = []
322
+ sig = signal.SIGTERM
323
+ if not graceful:
324
+ sig = signal.SIGQUIT
325
+ limit = time.time() + self.cfg.graceful_timeout
326
+ # instruct the workers to exit
327
+ self.kill_workers(sig)
328
+ # wait until the graceful timeout
329
+ while self.WORKERS and time.time() < limit:
330
+ time.sleep(0.1)
331
+
332
+ self.kill_workers(signal.SIGKILL)
333
+
334
+ def reload(self) -> None:
335
+ old_address = self.cfg.address
336
+
337
+ self.setup(self.app)
338
+
339
+ # reopen log files
340
+ self.log.reopen_files()
341
+
342
+ # do we need to change listener ?
343
+ if old_address != self.cfg.address:
344
+ # close all listeners
345
+ for lnr in self.LISTENERS:
346
+ lnr.close()
347
+ # init new listeners
348
+ self.LISTENERS = sock.create_sockets(self.cfg, self.log)
349
+ listeners_str = ",".join([str(lnr) for lnr in self.LISTENERS])
350
+ self.log.info("Listening at: %s", listeners_str)
351
+
352
+ # unlink pidfile
353
+ if self.pidfile is not None:
354
+ self.pidfile.unlink()
355
+
356
+ # create new pidfile
357
+ if self.cfg.pidfile is not None:
358
+ self.pidfile = Pidfile(self.cfg.pidfile)
359
+ self.pidfile.create(self.pid)
360
+
361
+ # spawn new workers
362
+ for _ in range(self.cfg.workers):
363
+ self.spawn_worker()
364
+
365
+ # manage workers
366
+ self.manage_workers()
367
+
368
+ def murder_workers(self) -> None:
369
+ """\
370
+ Kill unused/idle workers
371
+ """
372
+ if not self.timeout:
373
+ return None
374
+ workers = list(self.WORKERS.items())
375
+ for pid, worker in workers:
376
+ try:
377
+ if time.monotonic() - worker.tmp.last_update() <= self.timeout:
378
+ continue
379
+ except (OSError, ValueError):
380
+ continue
381
+
382
+ if not worker.aborted:
383
+ self.log.critical("WORKER TIMEOUT (pid:%s)", pid)
384
+ worker.aborted = True
385
+ self.kill_worker(pid, signal.SIGABRT)
386
+ else:
387
+ self.kill_worker(pid, signal.SIGKILL)
388
+
389
+ def reap_workers(self) -> None:
390
+ """\
391
+ Reap workers to avoid zombie processes
392
+ """
393
+ try:
394
+ while True:
395
+ wpid, status = os.waitpid(-1, os.WNOHANG)
396
+ if not wpid:
397
+ break
398
+
399
+ # A worker was terminated. If the termination reason was
400
+ # that it could not boot, we'll shut it down to avoid
401
+ # infinite start/stop cycles.
402
+ exitcode = status >> 8
403
+ if exitcode != 0:
404
+ self.log.error(
405
+ "Worker (pid:%s) exited with code %s", wpid, exitcode
406
+ )
407
+ if exitcode == self.WORKER_BOOT_ERROR:
408
+ reason = "Worker failed to boot."
409
+ raise HaltServer(reason, self.WORKER_BOOT_ERROR)
410
+ if exitcode == self.APP_LOAD_ERROR:
411
+ reason = "App failed to load."
412
+ raise HaltServer(reason, self.APP_LOAD_ERROR)
413
+
414
+ if exitcode > 0:
415
+ # If the exit code of the worker is greater than 0,
416
+ # let the user know.
417
+ self.log.error(
418
+ "Worker (pid:%s) exited with code %s.", wpid, exitcode
419
+ )
420
+ elif status > 0:
421
+ # If the exit code of the worker is 0 and the status
422
+ # is greater than 0, then it was most likely killed
423
+ # via a signal.
424
+ try:
425
+ sig_name = signal.Signals(status).name
426
+ except ValueError:
427
+ sig_name = f"code {status}"
428
+ msg = f"Worker (pid:{wpid}) was sent {sig_name}!"
429
+
430
+ # Additional hint for SIGKILL
431
+ if status == signal.SIGKILL:
432
+ msg += " Perhaps out of memory?"
433
+ self.log.error(msg)
434
+
435
+ worker = self.WORKERS.pop(wpid, None)
436
+ if not worker:
437
+ continue
438
+ worker.tmp.close()
439
+ except OSError as e:
440
+ if e.errno != errno.ECHILD:
441
+ raise
442
+
443
+ def manage_workers(self) -> None:
444
+ """\
445
+ Maintain the number of workers by spawning or killing
446
+ as required.
447
+ """
448
+ if len(self.WORKERS) < self.num_workers:
449
+ self.spawn_workers()
450
+
451
+ workers = self.WORKERS.items()
452
+ workers = sorted(workers, key=lambda w: w[1].age)
453
+ while len(workers) > self.num_workers:
454
+ (pid, _) = workers.pop(0)
455
+ self.kill_worker(pid, signal.SIGTERM)
456
+
457
+ active_worker_count = len(workers)
458
+ if self._last_logged_active_worker_count != active_worker_count:
459
+ self._last_logged_active_worker_count = active_worker_count
460
+ self.log.debug(
461
+ f"{active_worker_count} workers",
462
+ extra={
463
+ "metric": "plain.server.workers",
464
+ "value": active_worker_count,
465
+ "mtype": "gauge",
466
+ },
467
+ )
468
+
469
+ def spawn_worker(self) -> int:
470
+ self.worker_age += 1
471
+ worker = self.worker_class(
472
+ self.worker_age,
473
+ self.pid,
474
+ self.LISTENERS,
475
+ self.app,
476
+ self.timeout / 2.0,
477
+ self.cfg,
478
+ self.log,
479
+ )
480
+ pid = os.fork()
481
+ if pid != 0:
482
+ worker.pid = pid
483
+ self.WORKERS[pid] = worker
484
+ return pid
485
+
486
+ # Do not inherit the temporary files of other workers
487
+ for sibling in self.WORKERS.values():
488
+ sibling.tmp.close()
489
+
490
+ # Process Child
491
+ worker.pid = os.getpid()
492
+ try:
493
+ self.log.info("Plain server worker started pid=%s", worker.pid)
494
+ worker.init_process()
495
+ sys.exit(0)
496
+ except SystemExit:
497
+ raise
498
+ except AppImportError as e:
499
+ self.log.debug("Exception while loading the application", exc_info=True)
500
+ print(f"{e}", file=sys.stderr)
501
+ sys.stderr.flush()
502
+ sys.exit(self.APP_LOAD_ERROR)
503
+ except Exception:
504
+ self.log.exception("Exception in worker process")
505
+ if not worker.booted:
506
+ sys.exit(self.WORKER_BOOT_ERROR)
507
+ sys.exit(-1)
508
+ finally:
509
+ self.log.info("Worker exiting (pid: %s)", worker.pid)
510
+ try:
511
+ worker.tmp.close()
512
+ except Exception:
513
+ self.log.warning(
514
+ "Exception during worker exit:\n%s", traceback.format_exc()
515
+ )
516
+
517
+ def spawn_workers(self) -> None:
518
+ """\
519
+ Spawn new workers as needed.
520
+
521
+ This is where a worker process leaves the main loop
522
+ of the master process.
523
+ """
524
+
525
+ for _ in range(self.num_workers - len(self.WORKERS)):
526
+ self.spawn_worker()
527
+ time.sleep(0.1 * random.random())
528
+
529
+ def kill_workers(self, sig: int) -> None:
530
+ """\
531
+ Kill all workers with the signal `sig`
532
+ :attr sig: `signal.SIG*` value
533
+ """
534
+ worker_pids = list(self.WORKERS.keys())
535
+ for pid in worker_pids:
536
+ self.kill_worker(pid, sig)
537
+
538
+ def kill_worker(self, pid: int, sig: int) -> None:
539
+ """\
540
+ Kill a worker
541
+
542
+ :attr pid: int, worker pid
543
+ :attr sig: `signal.SIG*` value
544
+ """
545
+ try:
546
+ os.kill(pid, sig)
547
+ except OSError as e:
548
+ if e.errno == errno.ESRCH:
549
+ try:
550
+ worker = self.WORKERS.pop(pid)
551
+ worker.tmp.close()
552
+ return None
553
+ except (KeyError, OSError):
554
+ return None
555
+ raise
plain/server/config.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ #
4
+ #
5
+ # This file is part of gunicorn released under the MIT license.
6
+ # See the LICENSE for more information.
7
+ #
8
+ # Vendored and modified for Plain.
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+ from . import util
13
+
14
+
15
+ @dataclass
16
+ class Config:
17
+ """Plain server configuration.
18
+
19
+ All configuration values are required and provided by the CLI.
20
+ Defaults are defined in the CLI layer, not here.
21
+ """
22
+
23
+ # Core settings (from CLI)
24
+ bind: list[str]
25
+ workers: int
26
+ threads: int
27
+ timeout: int
28
+ max_requests: int
29
+ reload: bool
30
+ reload_extra_files: list[str]
31
+ pidfile: str | None
32
+ certfile: str | None
33
+ keyfile: str | None
34
+ loglevel: str
35
+ accesslog: str
36
+ errorlog: str
37
+ log_format: str
38
+ access_log_format: str
39
+
40
+ @property
41
+ def worker_class_str(self) -> str:
42
+ # Auto-select based on threads
43
+ if self.threads > 1:
44
+ return "gthread"
45
+ return "sync"
46
+
47
+ @property
48
+ def worker_class(self) -> type:
49
+ # Auto-select based on threads
50
+ if self.threads > 1:
51
+ uri = "plain.server.workers.gthread.ThreadWorker"
52
+ else:
53
+ uri = "plain.server.workers.sync.SyncWorker"
54
+
55
+ worker_class = util.load_class(uri)
56
+ if hasattr(worker_class, "setup"):
57
+ worker_class.setup() # type: ignore[call-non-callable] # hasattr check doesn't narrow type
58
+ return worker_class
59
+
60
+ @property
61
+ def address(self) -> list[tuple[str, int] | str]:
62
+ return [util.parse_address(util.bytes_to_str(bind)) for bind in self.bind]
63
+
64
+ @property
65
+ def is_ssl(self) -> bool:
66
+ return self.certfile is not None or self.keyfile is not None
67
+
68
+ @property
69
+ def sendfile(self) -> bool:
70
+ if "SENDFILE" in os.environ:
71
+ sendfile = os.environ["SENDFILE"].lower()
72
+ return sendfile in ["y", "1", "yes", "true"]
73
+
74
+ return True
75
+
76
+ @property
77
+ def graceful_timeout(self) -> int:
78
+ """Timeout for graceful worker shutdown in seconds."""
79
+ return 30
80
+
81
+ @property
82
+ def forwarded_allow_ips(self) -> list[str]:
83
+ """
84
+ Trusted proxy IPs allowed to set secure headers.
85
+ Default: ['127.0.0.1', '::1'] (localhost only)
86
+ Can be overridden via FORWARDED_ALLOW_IPS environment variable.
87
+ """
88
+ val = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
89
+ return [v.strip() for v in val.split(",") if v]
90
+
91
+ @property
92
+ def secure_scheme_headers(self) -> dict[str, str]:
93
+ """
94
+ Headers that indicate HTTPS when set by a trusted proxy.
95
+ Default headers: X-FORWARDED-PROTOCOL, X-FORWARDED-PROTO, X-FORWARDED-SSL
96
+ """
97
+ return {
98
+ "X-FORWARDED-PROTOCOL": "ssl",
99
+ "X-FORWARDED-PROTO": "https",
100
+ "X-FORWARDED-SSL": "on",
101
+ }
102
+
103
+ @property
104
+ def forwarder_headers(self) -> list[str]:
105
+ """
106
+ Header names that proxies can use to override WSGI environment.
107
+ Default: ['SCRIPT_NAME', 'PATH_INFO']
108
+ """
109
+ return ["SCRIPT_NAME", "PATH_INFO"]
110
+
111
+ @property
112
+ def header_map(self) -> str:
113
+ """
114
+ How to handle header names with underscores.
115
+ Default: 'drop' (silently drop ambiguous headers)
116
+ Options: 'drop', 'refuse', 'dangerous'
117
+ """
118
+ return "drop"
plain/server/errors.py ADDED
@@ -0,0 +1,31 @@
1
+ #
2
+ #
3
+ # This file is part of gunicorn released under the MIT license.
4
+ # See the LICENSE for more information.
5
+ #
6
+ # Vendored and modified for Plain.
7
+
8
+ # We don't need to call super() in __init__ methods of our
9
+ # BaseException and Exception classes because we also define
10
+ # our own __str__ methods so there is no need to pass 'message'
11
+ # to the base class to get a meaningful output from 'str(exc)'.
12
+ # pylint: disable=super-init-not-called
13
+
14
+
15
+ # we inherit from BaseException here to make sure to not be caught
16
+ # at application level
17
+ class HaltServer(BaseException):
18
+ def __init__(self, reason: str, exit_status: int = 1) -> None:
19
+ self.reason = reason
20
+ self.exit_status = exit_status
21
+
22
+ def __str__(self) -> str:
23
+ return f"<HaltServer {self.reason!r} {self.exit_status}>"
24
+
25
+
26
+ class ConfigError(Exception):
27
+ """Exception raised on config error"""
28
+
29
+
30
+ class AppImportError(Exception):
31
+ """Exception raised when loading an application"""