flaskit-cli 0.2.1__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.
- flaskit/__init__.py +13 -0
- flaskit/__main__.py +6 -0
- flaskit/cli.py +294 -0
- flaskit/templates/module/blueprint.py.j2 +10 -0
- flaskit/templates/module/resource.py.j2 +48 -0
- flaskit/templates/module/schema.py.j2 +9 -0
- flaskit/templates/project/Dockerfile +56 -0
- flaskit/templates/project/app.py.j2 +551 -0
- flaskit/templates/project/assets/swagger-ui/swagger-ui-bundle.js +2 -0
- flaskit/templates/project/assets/swagger-ui/swagger-ui-standalone-preset.js +2 -0
- flaskit/templates/project/assets/swagger-ui/swagger-ui.css +3 -0
- flaskit/templates/project/config.py.j2 +127 -0
- flaskit/templates/project/dockerignore +21 -0
- flaskit/templates/project/env.example.j2 +61 -0
- flaskit/templates/project/python-version +1 -0
- flaskit/templates/project/requirements.txt.j2 +14 -0
- flaskit/templates/project/templates/swagger_ui.html +43 -0
- flaskit_cli-0.2.1.dist-info/METADATA +127 -0
- flaskit_cli-0.2.1.dist-info/RECORD +23 -0
- flaskit_cli-0.2.1.dist-info/WHEEL +5 -0
- flaskit_cli-0.2.1.dist-info/entry_points.txt +2 -0
- flaskit_cli-0.2.1.dist-info/licenses/LICENSE +674 -0
- flaskit_cli-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Flask application factory generated by flaskit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from base64 import b64encode
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
import config
|
|
20
|
+
from flask import Flask, g, jsonify, render_template, request
|
|
21
|
+
from flask_smorest import Api
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import bjoern
|
|
25
|
+
except ImportError: # pragma: no cover - optional production dependency
|
|
26
|
+
bjoern = None
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from opentelemetry import trace
|
|
30
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
31
|
+
OTLPSpanExporter as OTLPSpanExporterGrpc,
|
|
32
|
+
)
|
|
33
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
34
|
+
OTLPSpanExporter as OTLPSpanExporterHttp,
|
|
35
|
+
)
|
|
36
|
+
from opentelemetry.instrumentation.flask import FlaskInstrumentor
|
|
37
|
+
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
|
38
|
+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter
|
|
39
|
+
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
|
40
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
41
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
42
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
43
|
+
except ImportError: # pragma: no cover - optional
|
|
44
|
+
trace = None
|
|
45
|
+
OTLPSpanExporterGrpc = None
|
|
46
|
+
OTLPSpanExporterHttp = None
|
|
47
|
+
FlaskInstrumentor = None
|
|
48
|
+
LoggerProvider = None
|
|
49
|
+
LoggingHandler = None
|
|
50
|
+
BatchLogRecordProcessor = None
|
|
51
|
+
ConsoleLogExporter = None
|
|
52
|
+
SERVICE_NAME = None
|
|
53
|
+
Resource = None
|
|
54
|
+
TracerProvider = None
|
|
55
|
+
BatchSpanProcessor = None
|
|
56
|
+
TraceContextTextMapPropagator = None
|
|
57
|
+
|
|
58
|
+
ROOT = Path(__file__).resolve().parent
|
|
59
|
+
if str(ROOT) not in sys.path:
|
|
60
|
+
sys.path.insert(0, str(ROOT))
|
|
61
|
+
|
|
62
|
+
request_logger = logging.getLogger(config.LOG_REQUEST_NAME)
|
|
63
|
+
response_logger = logging.getLogger(config.LOG_RESPONSE_NAME)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _openapi_server_url():
|
|
67
|
+
"""Return BASE_PATH for OpenAPI servers, or None when unset/root."""
|
|
68
|
+
base = (config.BASE_PATH or "").strip()
|
|
69
|
+
if not base or base == "/":
|
|
70
|
+
return None
|
|
71
|
+
return base
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _FlushStreamHandler(logging.StreamHandler):
|
|
75
|
+
"""Stream handler that flushes after every record."""
|
|
76
|
+
|
|
77
|
+
def emit(self, record):
|
|
78
|
+
super().emit(record)
|
|
79
|
+
self.flush()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _make_file_handler(path, formatter):
|
|
83
|
+
path = Path(path)
|
|
84
|
+
if not path.is_absolute():
|
|
85
|
+
path = ROOT / path
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
if config.LOG_ROTATION == "time":
|
|
89
|
+
handler = TimedRotatingFileHandler(
|
|
90
|
+
path,
|
|
91
|
+
when=config.LOG_WHEN,
|
|
92
|
+
interval=config.LOG_INTERVAL,
|
|
93
|
+
backupCount=config.LOG_BACKUP_COUNT,
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
)
|
|
96
|
+
elif config.LOG_ROTATION == "none":
|
|
97
|
+
handler = logging.FileHandler(path, encoding="utf-8")
|
|
98
|
+
else:
|
|
99
|
+
handler = RotatingFileHandler(
|
|
100
|
+
path,
|
|
101
|
+
maxBytes=config.LOG_MAX_BYTES,
|
|
102
|
+
backupCount=config.LOG_BACKUP_COUNT,
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
)
|
|
105
|
+
handler.setFormatter(formatter)
|
|
106
|
+
return handler
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _configure_logger(logger, log_file):
|
|
110
|
+
"""Attach stream/file handlers to one named logger."""
|
|
111
|
+
level = getattr(logging, config.LOG_LEVEL, logging.INFO)
|
|
112
|
+
formatter = logging.Formatter(config.LOG_FORMAT, datefmt=config.LOG_DATE_FORMAT)
|
|
113
|
+
|
|
114
|
+
logger.handlers.clear()
|
|
115
|
+
logger.disabled = False
|
|
116
|
+
logger.setLevel(level)
|
|
117
|
+
logger.propagate = config.LOG_PROPAGATE
|
|
118
|
+
|
|
119
|
+
if "stream" in config.LOG_OUTPUT or not config.LOG_OUTPUT:
|
|
120
|
+
stream = sys.stderr if config.LOG_STREAM != "stdout" else sys.stdout
|
|
121
|
+
handler = _FlushStreamHandler(stream)
|
|
122
|
+
handler.setFormatter(formatter)
|
|
123
|
+
handler.setLevel(level)
|
|
124
|
+
logger.addHandler(handler)
|
|
125
|
+
|
|
126
|
+
if "file" in config.LOG_OUTPUT:
|
|
127
|
+
logger.addHandler(_make_file_handler(log_file, formatter))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def setup_logging(app):
|
|
131
|
+
"""Configure app, request, and response loggers (+ optional OTel log bridge)."""
|
|
132
|
+
for stream in (sys.stdout, sys.stderr):
|
|
133
|
+
try:
|
|
134
|
+
stream.reconfigure(line_buffering=True)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
_configure_logger(app.logger, config.LOG_APP_FILE)
|
|
139
|
+
app.logger.name = config.LOG_APP_NAME
|
|
140
|
+
_configure_logger(request_logger, config.LOG_REQUEST_FILE)
|
|
141
|
+
_configure_logger(response_logger, config.LOG_RESPONSE_FILE)
|
|
142
|
+
|
|
143
|
+
if not config.OTEL_LOGS_ENABLED or LoggerProvider is None:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
resource = Resource.create({SERVICE_NAME: config.OTEL_SERVICE_NAME})
|
|
147
|
+
provider = LoggerProvider(resource=resource)
|
|
148
|
+
if "stream" in config.LOG_OUTPUT or not config.LOG_OUTPUT:
|
|
149
|
+
provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter()))
|
|
150
|
+
# File mirroring stays on stdlib handlers above; OTel console exporter
|
|
151
|
+
# complements OTLP when a collector is configured in setup_otel.
|
|
152
|
+
otel_handler = LoggingHandler(level=getattr(logging, config.LOG_LEVEL, logging.INFO), logger_provider=provider)
|
|
153
|
+
for logger in (app.logger, request_logger, response_logger):
|
|
154
|
+
logger.addHandler(otel_handler)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _tz_offset_seconds():
|
|
158
|
+
now = datetime.now(config.TZ)
|
|
159
|
+
return int(now.utcoffset().total_seconds()) if now.utcoffset() else 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _trace_fields():
|
|
163
|
+
fields = {"trace_id": None, "span_id": None, "timezone": config.TIMEZONE}
|
|
164
|
+
if trace is None:
|
|
165
|
+
return fields
|
|
166
|
+
span = trace.get_current_span()
|
|
167
|
+
ctx = span.get_span_context()
|
|
168
|
+
if ctx.is_valid:
|
|
169
|
+
fields["trace_id"] = format(ctx.trace_id, "032x")
|
|
170
|
+
fields["span_id"] = format(ctx.span_id, "016x")
|
|
171
|
+
return fields
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _safe_headers(headers):
|
|
175
|
+
return {
|
|
176
|
+
key: ("***" if key.lower() in config.LOG_REDACT_HEADERS else value)
|
|
177
|
+
for key, value in headers.items()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _encode_body(data, logger):
|
|
182
|
+
"""Include body only when logger level is below INFO (DEBUG)."""
|
|
183
|
+
if not logger.isEnabledFor(logging.DEBUG):
|
|
184
|
+
return ""
|
|
185
|
+
if not data:
|
|
186
|
+
return ""
|
|
187
|
+
return b64encode(data).decode("ascii")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _base_log_fields():
|
|
191
|
+
return {
|
|
192
|
+
"timestamp": time.time(),
|
|
193
|
+
"tz_offset": _tz_offset_seconds(),
|
|
194
|
+
"timezone": config.TIMEZONE,
|
|
195
|
+
"api_version": config.API_VERSION,
|
|
196
|
+
"api_title": config.API_TITLE.lower(),
|
|
197
|
+
"request_id": getattr(g, "request_id", None),
|
|
198
|
+
"log_id": str(uuid.uuid4()),
|
|
199
|
+
**_trace_fields(),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def setup_request_logging(app):
|
|
204
|
+
"""Log each request and response as JSON (stdlib + OTel context fields)."""
|
|
205
|
+
|
|
206
|
+
@app.before_request
|
|
207
|
+
def before_request():
|
|
208
|
+
g.start = time.time()
|
|
209
|
+
g.request_id = str(uuid.uuid4())
|
|
210
|
+
|
|
211
|
+
payload = {
|
|
212
|
+
**_base_log_fields(),
|
|
213
|
+
"log_level": "debug" if request_logger.isEnabledFor(logging.DEBUG) else "info",
|
|
214
|
+
"method": request.method,
|
|
215
|
+
"path": request.path,
|
|
216
|
+
"url": request.url,
|
|
217
|
+
"client_ip": request.headers.get("X-Forwarded-For", request.remote_addr),
|
|
218
|
+
"query_string": request.query_string.decode("utf-8", errors="replace"),
|
|
219
|
+
"headers": _safe_headers(request.headers),
|
|
220
|
+
"body": _encode_body(request.get_data(cache=True), request_logger),
|
|
221
|
+
"body_size": len(request.get_data(cache=True) or b""),
|
|
222
|
+
"module": config.LOG_REQUEST_NAME,
|
|
223
|
+
}
|
|
224
|
+
request_logger.info(json.dumps(payload, ensure_ascii=False))
|
|
225
|
+
|
|
226
|
+
@app.after_request
|
|
227
|
+
def after_request(response):
|
|
228
|
+
response.direct_passthrough = False
|
|
229
|
+
|
|
230
|
+
if (
|
|
231
|
+
trace is not None
|
|
232
|
+
and TraceContextTextMapPropagator is not None
|
|
233
|
+
):
|
|
234
|
+
span = trace.get_current_span()
|
|
235
|
+
if span.get_span_context().is_valid:
|
|
236
|
+
carrier = {}
|
|
237
|
+
TraceContextTextMapPropagator().inject(carrier)
|
|
238
|
+
if carrier.get("traceparent"):
|
|
239
|
+
response.headers["traceparent"] = carrier["traceparent"]
|
|
240
|
+
|
|
241
|
+
body = response.get_data()
|
|
242
|
+
payload = {
|
|
243
|
+
**_base_log_fields(),
|
|
244
|
+
"log_level": "debug" if response_logger.isEnabledFor(logging.DEBUG) else "info",
|
|
245
|
+
"status": response.status_code,
|
|
246
|
+
"duration": time.time() - getattr(g, "start", time.time()),
|
|
247
|
+
"headers": _safe_headers(response.headers),
|
|
248
|
+
"body": _encode_body(body, response_logger),
|
|
249
|
+
"body_size": len(body or b""),
|
|
250
|
+
"module": config.LOG_RESPONSE_NAME,
|
|
251
|
+
"method": request.method,
|
|
252
|
+
"path": request.path,
|
|
253
|
+
}
|
|
254
|
+
response_logger.info(json.dumps(payload, ensure_ascii=False))
|
|
255
|
+
return response
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def setup_static_caching(app):
|
|
259
|
+
"""Apply Cache-Control under {BASE_PATH}/assets/statics."""
|
|
260
|
+
statics_prefix = config.app_path("assets", "statics")
|
|
261
|
+
max_age = max(0, int(config.STATIC_CACHE_MAX_AGE))
|
|
262
|
+
|
|
263
|
+
@app.after_request
|
|
264
|
+
def cache_statics(response):
|
|
265
|
+
path = request.path
|
|
266
|
+
if path == statics_prefix or path.startswith(statics_prefix + "/"):
|
|
267
|
+
if response.status_code in {200, 304}:
|
|
268
|
+
if max_age > 0:
|
|
269
|
+
response.cache_control.public = True
|
|
270
|
+
response.cache_control.max_age = max_age
|
|
271
|
+
response.headers["Cache-Control"] = f"public, max-age={max_age}"
|
|
272
|
+
else:
|
|
273
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
274
|
+
return response
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def setup_otel(app):
|
|
278
|
+
if not config.OTEL_ENABLED:
|
|
279
|
+
return
|
|
280
|
+
if trace is None or TracerProvider is None or FlaskInstrumentor is None:
|
|
281
|
+
app.logger.warning("OpenTelemetry not installed; skipping")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
provider = TracerProvider(
|
|
285
|
+
resource=Resource.create({SERVICE_NAME: config.OTEL_SERVICE_NAME})
|
|
286
|
+
)
|
|
287
|
+
try:
|
|
288
|
+
if config.OTEL_EXPORTER_OTLP_PROTOCOL in {"http", "http/protobuf"}:
|
|
289
|
+
exporter = OTLPSpanExporterHttp(endpoint=config.OTEL_EXPORTER_OTLP_ENDPOINT)
|
|
290
|
+
else:
|
|
291
|
+
exporter = OTLPSpanExporterGrpc(endpoint=config.OTEL_EXPORTER_OTLP_ENDPOINT)
|
|
292
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
293
|
+
except Exception:
|
|
294
|
+
app.logger.exception("OTLP exporter failed")
|
|
295
|
+
|
|
296
|
+
trace.set_tracer_provider(provider)
|
|
297
|
+
FlaskInstrumentor().instrument_app(app)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def apply_security(api):
|
|
301
|
+
name = config.AUTH_SECURITY_NAME
|
|
302
|
+
if config.AUTH_SCHEME == "bearer":
|
|
303
|
+
scheme = {
|
|
304
|
+
"type": "http",
|
|
305
|
+
"scheme": "bearer",
|
|
306
|
+
"bearerFormat": config.AUTH_BEARER_FORMAT,
|
|
307
|
+
}
|
|
308
|
+
else:
|
|
309
|
+
scheme = {"type": "apiKey", "in": "header", "name": config.AUTH_HEADER}
|
|
310
|
+
api.spec.components.security_scheme(name, scheme)
|
|
311
|
+
api.spec.options.setdefault("security", [{name: []}])
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def discover_modules():
|
|
315
|
+
resources_dir = ROOT / "resources"
|
|
316
|
+
found = []
|
|
317
|
+
if not resources_dir.is_dir():
|
|
318
|
+
return found
|
|
319
|
+
|
|
320
|
+
for folder in sorted(resources_dir.iterdir()):
|
|
321
|
+
if not folder.is_dir() or folder.name.startswith((".", "_")):
|
|
322
|
+
continue
|
|
323
|
+
if not (folder / "blueprint.py").exists() or not (folder / "resource.py").exists():
|
|
324
|
+
continue
|
|
325
|
+
try:
|
|
326
|
+
bp_mod = importlib.import_module(f"resources.{folder.name}.blueprint")
|
|
327
|
+
res_mod = importlib.import_module(f"resources.{folder.name}.resource")
|
|
328
|
+
flask_bp = getattr(bp_mod, "flask_bp", None) or getattr(bp_mod, "bp", None)
|
|
329
|
+
smorest_blp = getattr(res_mod, "smorest_blp", None) or getattr(res_mod, "blp", None)
|
|
330
|
+
if flask_bp and smorest_blp:
|
|
331
|
+
url_prefix = getattr(bp_mod, "URL_PREFIX", None) or f"/{folder.name}"
|
|
332
|
+
found.append((folder.name, flask_bp, smorest_blp, url_prefix))
|
|
333
|
+
except Exception:
|
|
334
|
+
logging.getLogger(config.LOG_APP_NAME).exception(
|
|
335
|
+
"Failed to load module %s", folder.name
|
|
336
|
+
)
|
|
337
|
+
return found
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def filter_spec(spec, tag):
|
|
341
|
+
result = deepcopy(spec)
|
|
342
|
+
paths = {}
|
|
343
|
+
for path, methods in (spec.get("paths") or {}).items():
|
|
344
|
+
kept = {
|
|
345
|
+
method: op
|
|
346
|
+
for method, op in methods.items()
|
|
347
|
+
if isinstance(op, dict) and tag in (op.get("tags") or [])
|
|
348
|
+
}
|
|
349
|
+
if kept:
|
|
350
|
+
paths[path] = kept
|
|
351
|
+
result["paths"] = paths
|
|
352
|
+
result["tags"] = [t for t in (spec.get("tags") or []) if t.get("name") == tag]
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def setup_docs(app, api, modules):
|
|
357
|
+
prefix = config.docs_url_prefix().rstrip("/") or "/docs"
|
|
358
|
+
assets = config.assets_url_path().rstrip("/")
|
|
359
|
+
urls = [{"name": "All modules", "url": f"{prefix}/openapi.json"}]
|
|
360
|
+
for name in modules:
|
|
361
|
+
urls.append({"name": name, "url": f"{prefix}/modules/{name}/openapi.json"})
|
|
362
|
+
|
|
363
|
+
@app.get(f"{prefix}/modules/<module_name>/openapi.json")
|
|
364
|
+
def module_openapi(module_name):
|
|
365
|
+
if module_name not in modules:
|
|
366
|
+
return jsonify({"message": "Unknown module"}), 404
|
|
367
|
+
return jsonify(filter_spec(api.spec.to_dict(), module_name))
|
|
368
|
+
|
|
369
|
+
@app.get(prefix)
|
|
370
|
+
@app.get(f"{prefix}/")
|
|
371
|
+
def swagger_ui():
|
|
372
|
+
return render_template(
|
|
373
|
+
"swagger_ui.html",
|
|
374
|
+
title=config.API_TITLE,
|
|
375
|
+
css_url=f"{assets}/swagger-ui/swagger-ui.css",
|
|
376
|
+
bundle_url=f"{assets}/swagger-ui/swagger-ui-bundle.js",
|
|
377
|
+
preset_url=f"{assets}/swagger-ui/swagger-ui-standalone-preset.js",
|
|
378
|
+
urls=urls,
|
|
379
|
+
show_servers=_openapi_server_url() is not None,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def init_app(app):
|
|
384
|
+
"""Wire discovered modules, OpenAPI tags, and docs onto an existing app.
|
|
385
|
+
|
|
386
|
+
Called at the end of ``create_app``. Safe to call more than once (no-op if
|
|
387
|
+
already initialized).
|
|
388
|
+
"""
|
|
389
|
+
if app.extensions.get("flaskit_ready"):
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
api = app.extensions["flaskit_api"]
|
|
393
|
+
modules = []
|
|
394
|
+
|
|
395
|
+
for name, flask_bp, smorest_blp, blueprint_path in discover_modules():
|
|
396
|
+
prefix = config.route_prefix(blueprint_path)
|
|
397
|
+
app.register_blueprint(flask_bp, url_prefix=prefix)
|
|
398
|
+
smorest_blp.register_views_in_doc(
|
|
399
|
+
api, app, api.spec, name=f"{flask_bp.name}.{smorest_blp.name}", parameters=None
|
|
400
|
+
)
|
|
401
|
+
api.spec.tag({"name": name, "description": smorest_blp.description or name})
|
|
402
|
+
modules.append(name)
|
|
403
|
+
app.logger.info("Registered %s at %s", name, prefix)
|
|
404
|
+
|
|
405
|
+
setup_docs(app, api, modules)
|
|
406
|
+
|
|
407
|
+
@app.cli.command("list-modules")
|
|
408
|
+
def list_modules():
|
|
409
|
+
for name in modules:
|
|
410
|
+
click.echo(name)
|
|
411
|
+
|
|
412
|
+
app.extensions["flaskit_ready"] = True
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def create_app():
|
|
416
|
+
"""Application factory: Flask + logging + Smorest + module discovery.
|
|
417
|
+
|
|
418
|
+
Customize here (database, cache, auth extensions). See docs/extending.md.
|
|
419
|
+
"""
|
|
420
|
+
app = Flask(
|
|
421
|
+
__name__,
|
|
422
|
+
static_folder="assets",
|
|
423
|
+
static_url_path=config.assets_url_path(),
|
|
424
|
+
template_folder="templates",
|
|
425
|
+
)
|
|
426
|
+
docs_prefix = config.docs_url_prefix()
|
|
427
|
+
app.config.update(
|
|
428
|
+
SECRET_KEY=config.SECRET_KEY,
|
|
429
|
+
API_TITLE=config.API_TITLE,
|
|
430
|
+
API_VERSION=config.API_VERSION,
|
|
431
|
+
OPENAPI_VERSION=config.OPENAPI_VERSION,
|
|
432
|
+
OPENAPI_URL_PREFIX=docs_prefix,
|
|
433
|
+
OPENAPI_JSON_PATH="openapi.json",
|
|
434
|
+
OPENAPI_SWAGGER_UI_PATH=None,
|
|
435
|
+
OPENAPI_SWAGGER_UI_URL=None,
|
|
436
|
+
API_SPEC_OPTIONS={
|
|
437
|
+
"info": {"description": config.API_DESCRIPTION},
|
|
438
|
+
**(
|
|
439
|
+
{"servers": [{"url": server}]}
|
|
440
|
+
if (server := _openapi_server_url()) is not None
|
|
441
|
+
else {}
|
|
442
|
+
),
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
setup_logging(app)
|
|
447
|
+
setup_request_logging(app)
|
|
448
|
+
setup_static_caching(app)
|
|
449
|
+
setup_otel(app)
|
|
450
|
+
|
|
451
|
+
(ROOT / "assets" / "statics").mkdir(parents=True, exist_ok=True)
|
|
452
|
+
|
|
453
|
+
# Example extension hook (database, redis, ...):
|
|
454
|
+
# from mydb import init_db
|
|
455
|
+
# init_db(app)
|
|
456
|
+
|
|
457
|
+
api = Api(app)
|
|
458
|
+
apply_security(api)
|
|
459
|
+
app.extensions["flaskit_api"] = api
|
|
460
|
+
|
|
461
|
+
init_app(app)
|
|
462
|
+
return app
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _run_bjoern():
|
|
466
|
+
"""Production WSGI server (bjoern / libev — installed only in Docker)."""
|
|
467
|
+
if bjoern is None:
|
|
468
|
+
raise SystemExit(
|
|
469
|
+
"bjoern is required for ENV=production and is installed only in Docker. "
|
|
470
|
+
"Build/run the Dockerfile, or use ENV=development locally (Flask)."
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
app = create_app()
|
|
474
|
+
app.logger.info(
|
|
475
|
+
"Starting bjoern on %s:%s (reuse_port=%s)",
|
|
476
|
+
config.HOST,
|
|
477
|
+
config.PORT,
|
|
478
|
+
config.BJOERN_REUSE_PORT,
|
|
479
|
+
)
|
|
480
|
+
bjoern.run(
|
|
481
|
+
app,
|
|
482
|
+
config.HOST,
|
|
483
|
+
config.PORT,
|
|
484
|
+
reuse_port=config.BJOERN_REUSE_PORT,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _parse_cli_args(argv=None):
|
|
489
|
+
parser = argparse.ArgumentParser(
|
|
490
|
+
prog="app.py",
|
|
491
|
+
description=(
|
|
492
|
+
"Run the API. development -> Flask; production -> bjoern. "
|
|
493
|
+
"Most settings come from environment variables (see config.py / .env.example)."
|
|
494
|
+
),
|
|
495
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
496
|
+
epilog=(
|
|
497
|
+
"examples:\n"
|
|
498
|
+
" python app.py\n"
|
|
499
|
+
" python app.py -h\n"
|
|
500
|
+
" python app.py --host 0.0.0.0 -p 8082\n"
|
|
501
|
+
" python app.py -e production --host 0.0.0.0 -p 80\n"
|
|
502
|
+
" set HOST=0.0.0.0&& set PORT=8082&& python app.py"
|
|
503
|
+
),
|
|
504
|
+
)
|
|
505
|
+
parser.add_argument(
|
|
506
|
+
"-e",
|
|
507
|
+
"--env",
|
|
508
|
+
choices=("development", "production"),
|
|
509
|
+
default=None,
|
|
510
|
+
help="Override ENV for this process (default: value of ENV / FLASK_ENV).",
|
|
511
|
+
)
|
|
512
|
+
parser.add_argument(
|
|
513
|
+
"--host",
|
|
514
|
+
default=None,
|
|
515
|
+
help=f"Bind host (default: {config.HOST}).",
|
|
516
|
+
)
|
|
517
|
+
parser.add_argument(
|
|
518
|
+
"-p",
|
|
519
|
+
"--port",
|
|
520
|
+
type=int,
|
|
521
|
+
default=None,
|
|
522
|
+
help=f"Bind port (default: {config.PORT}).",
|
|
523
|
+
)
|
|
524
|
+
parser.add_argument(
|
|
525
|
+
"--debug",
|
|
526
|
+
action=argparse.BooleanOptionalAction,
|
|
527
|
+
default=None,
|
|
528
|
+
help="Enable/disable Flask debug (development only).",
|
|
529
|
+
)
|
|
530
|
+
return parser.parse_args(argv)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
if __name__ == "__main__":
|
|
534
|
+
args = _parse_cli_args()
|
|
535
|
+
if args.env is not None:
|
|
536
|
+
config.ENV = args.env
|
|
537
|
+
if args.host is not None:
|
|
538
|
+
config.HOST = args.host
|
|
539
|
+
if args.port is not None:
|
|
540
|
+
config.PORT = args.port
|
|
541
|
+
if args.debug is not None:
|
|
542
|
+
config.DEBUG = args.debug
|
|
543
|
+
|
|
544
|
+
if config.ENV in {"production", "prod"}:
|
|
545
|
+
_run_bjoern()
|
|
546
|
+
else:
|
|
547
|
+
create_app().run(
|
|
548
|
+
host=config.HOST,
|
|
549
|
+
port=config.PORT,
|
|
550
|
+
debug=config.DEBUG,
|
|
551
|
+
)
|