Skip to content

Commit b855ed1

Browse files
Fix #561 (#666)
Support for baked OpenAPI Specification files (PYTHONOPTIMIZE=2 / -OO)
1 parent 248df16 commit b855ed1

4 files changed

Lines changed: 376 additions & 8 deletions

File tree

blacksheep/server/openapi/common.py

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -299,11 +299,12 @@ def __init__(
299299
preferred_format: Format = Format.JSON,
300300
anonymous_access: bool = True,
301301
serializer: Serializer | None = None,
302+
spec_file: str | None = None,
302303
) -> None:
303304
self._handlers_docs: dict[Any, EndpointDocs] = {}
304305
self._controllers_docs: dict[Any, ControllerDocs] = {}
305306
self.use_docstrings: bool = True
306-
self.include: Callable[[str, Route | None, bool]] = None
307+
self.include: Callable[[str, Route], bool] | None = None
307308
self.json_spec_path = json_spec_path
308309
self.yaml_spec_path = yaml_spec_path
309310
self._json_docs: bytes = b""
@@ -315,6 +316,7 @@ def __init__(
315316
self.events = OpenAPIEvents(self)
316317
self.handle_optional_response_with_404 = True
317318
self._serializer = serializer
319+
self._spec_file = spec_file or os.environ.get("APP_SPEC_FILE")
318320

319321
def __call__(
320322
self,
@@ -448,7 +450,7 @@ def _get_request_handler(self, route: Route) -> Any:
448450
# any normalization
449451
return route.handler # pragma: no cover
450452

451-
def get_handler_tags(self, handler: Any) -> list[str | None]:
453+
def get_handler_tags(self, handler: Any) -> list[str] | None:
452454
docs = self.get_handler_docs(handler)
453455
if docs and docs.tags:
454456
return docs.tags
@@ -563,10 +565,104 @@ def on_docs_generated(self, docs: OpenAPIRootType) -> None:
563565
def get_ui_page_title(self) -> str:
564566
return "API Docs" # pragma: no cover
565567

568+
def _get_spec_file_paths(self, spec_file: str) -> tuple[str, str]:
569+
"""
570+
Returns (json_path, yaml_path) derived from a given spec file path.
571+
If the extension is .yaml or .yml, the JSON companion uses the same base
572+
with .json. Otherwise the YAML companion uses the same base with .yaml.
573+
"""
574+
base, ext = os.path.splitext(spec_file)
575+
if ext.lower() in (".yaml", ".yml"):
576+
return base + ".json", spec_file
577+
json_path = spec_file if ext == ".json" else spec_file + ".json"
578+
return json_path, base + ".yaml"
579+
580+
def _load_spec_from_file(self, spec_file: str) -> bool:
581+
"""
582+
Loads pre-baked OpenAPI specification bytes from disk.
583+
Reads both the JSON and YAML variants. Returns True if both files are
584+
found and loaded, False if either is missing (falling back to generation).
585+
"""
586+
json_path, yaml_path = self._get_spec_file_paths(spec_file)
587+
if not os.path.isfile(json_path) or not os.path.isfile(yaml_path):
588+
return False
589+
with open(json_path, "rb") as fp:
590+
self._json_docs = fp.read()
591+
with open(yaml_path, "rb") as fp:
592+
self._yaml_docs = fp.read()
593+
return True
594+
595+
def save_spec(self, destination: str) -> None:
596+
"""
597+
Saves the current in-memory OpenAPI specification to disk.
598+
Both JSON and YAML variants are always written, regardless of the
599+
extension given in *destination*.
600+
601+
This is meant to be used to "bake" the spec at build/CI time (without
602+
``PYTHONOPTIMIZE=2``) so that it can be loaded at runtime when docstrings
603+
are stripped.
604+
605+
**Typical workflow**
606+
607+
1. Bake the spec once (e.g. in a CI step, without ``-OO``)::
608+
609+
# bake_spec.py
610+
import asyncio
611+
from myapp import app, docs
612+
613+
asyncio.run(app.start())
614+
docs.save_spec("./openapi.json")
615+
# also writes ./openapi.yaml
616+
617+
2. Ship the baked files alongside the application.
618+
619+
3. At runtime (TEST / PROD) set the environment variable so that
620+
BlackSheep loads the baked spec instead of regenerating it::
621+
622+
APP_SPEC_FILE=openapi.json
623+
624+
No code change is needed between environments. Alternatively,
625+
pass the path explicitly::
626+
627+
docs = OpenAPIHandler(
628+
info=Info("My API", "1.0"),
629+
spec_file="openapi.json",
630+
)
631+
632+
If the files do not exist yet when the application starts, they are
633+
generated and saved automatically on the first startup, then loaded
634+
from disk on every subsequent startup.
635+
636+
Args:
637+
destination: file path with a ``.json`` or ``.yaml``/``.yml``
638+
extension. The companion format is written next to it
639+
automatically.
640+
"""
641+
if not self._json_docs and not self._yaml_docs:
642+
raise RuntimeError(
643+
"The specification has not been built yet. "
644+
"Call save_spec() only after the application has started "
645+
"(e.g. after asyncio.run(app.start()))."
646+
)
647+
json_path, yaml_path = self._get_spec_file_paths(destination)
648+
with open(json_path, "wb") as fp:
649+
fp.write(self._json_docs)
650+
with open(yaml_path, "wb") as fp:
651+
fp.write(self._yaml_docs)
652+
566653
async def build_docs(self, app: Application) -> None:
567-
docs = self.generate_documentation(app)
568-
self.on_docs_generated(docs)
569-
serializer = self._serializer or DefaultSerializer()
654+
spec_file = self._spec_file
655+
if spec_file and self._load_spec_from_file(spec_file):
656+
# Files are read from file system
657+
...
658+
else:
659+
docs = self.generate_documentation(app)
660+
self.on_docs_generated(docs)
661+
serializer = self._serializer or DefaultSerializer()
662+
self._json_docs = serializer.to_json(docs).encode("utf8")
663+
self._yaml_docs = serializer.to_yaml(docs).encode("utf8")
664+
if spec_file:
665+
self.save_spec(spec_file)
570666

571667
ui_options = UIOptions(
572668
spec_url=self.get_spec_path(), page_title=self.get_ui_page_title()
@@ -575,9 +671,6 @@ async def build_docs(self, app: Application) -> None:
575671
for ui_provider in self.ui_providers:
576672
ui_provider.build_ui(ui_options)
577673

578-
self._json_docs = serializer.to_json(docs).encode("utf8")
579-
self._yaml_docs = serializer.to_yaml(docs).encode("utf8")
580-
581674
def bind_app(self, app: Application) -> None:
582675
if app.started:
583676
raise TypeError(

blacksheep/server/openapi/docstrings.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"""
2020

2121
import re
22+
import sys
2223
import warnings
2324
from abc import ABC, abstractmethod
2425
from dataclasses import dataclass
@@ -510,15 +511,28 @@ def parse_docstring(
510511

511512

512513
_handlers_docstring_info = WeakKeyDictionary()
514+
_optimize_warning_issued = False
513515

514516

515517
def get_handler_docstring_info(handler) -> DocstringInfo:
518+
global _optimize_warning_issued
516519
if handler not in _handlers_docstring_info:
517520
docs = handler.__doc__
518521

519522
if docs:
520523
docstring_info = parse_docstring(docs)
521524
else:
525+
if sys.flags.optimize >= 2 and not _optimize_warning_issued:
526+
_optimize_warning_issued = True
527+
warnings.warn(
528+
"Python is running with PYTHONOPTIMIZE=2 (or -OO), so docstrings "
529+
"are not available and cannot be used to enrich OpenAPI "
530+
"Documentation. Consider baking the OpenAPI spec to a file before "
531+
"deploying: call docs.save_spec() after starting the application "
532+
"once without -OO, then set APP_SPEC_FILE to the saved path so "
533+
"that BlackSheep loads it at runtime instead of regenerating it.",
534+
stacklevel=2,
535+
)
522536
docstring_info = None
523537
_handlers_docstring_info[handler] = docstring_info
524538
return _handlers_docstring_info[handler]

blacksheep/server/openapi/v3.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ def __init__(
434434
security_schemes: dict[str, SecurityScheme] | None = None,
435435
servers: Sequence[Server] | None = None,
436436
serializer: Serializer | None = None,
437+
spec_file: str | None = None,
437438
) -> None:
438439
super().__init__(
439440
ui_path=ui_path,
@@ -442,6 +443,7 @@ def __init__(
442443
preferred_format=preferred_format,
443444
anonymous_access=anonymous_access,
444445
serializer=serializer,
446+
spec_file=spec_file,
445447
)
446448
self.info = info
447449
self._tags = tags

0 commit comments

Comments
 (0)