@@ -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 (
0 commit comments