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.
@@ -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
+ )