From 91b16a85aa933eec77d6bb3cbffac9006fb5f691 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Mon, 23 Feb 2026 21:55:40 +0100 Subject: [PATCH 1/5] Start feature --- blacksheep/server/openapi/common.py | 83 +++++++++++++-- blacksheep/server/openapi/v3.py | 2 + tests/test_openapi_v3.py | 153 ++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 6 deletions(-) diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py index 1fe9bcf3..b2021050 100644 --- a/blacksheep/server/openapi/common.py +++ b/blacksheep/server/openapi/common.py @@ -299,6 +299,7 @@ def __init__( preferred_format: Format = Format.JSON, anonymous_access: bool = True, serializer: Serializer | None = None, + spec_file: str | None = None, ) -> None: self._handlers_docs: dict[Any, EndpointDocs] = {} self._controllers_docs: dict[Any, ControllerDocs] = {} @@ -315,6 +316,7 @@ def __init__( self.events = OpenAPIEvents(self) self.handle_optional_response_with_404 = True self._serializer = serializer + self._spec_file = spec_file def __call__( self, @@ -563,10 +565,82 @@ def on_docs_generated(self, docs: OpenAPIRootType) -> None: def get_ui_page_title(self) -> str: return "API Docs" # pragma: no cover + def _get_spec_file_paths(self, spec_file: str) -> tuple[str, str]: + """ + Returns (json_path, yaml_path) derived from a given spec file path. + If the extension is .yaml or .yml, the JSON companion uses the same base + with .json. Otherwise the YAML companion uses the same base with .yaml. + """ + base, ext = os.path.splitext(spec_file) + if ext in (".yaml", ".yml"): + return base + ".json", spec_file + json_path = spec_file if ext == ".json" else spec_file + ".json" + return json_path, base + ".yaml" + + def _load_spec_from_file(self, spec_file: str) -> None: + """ + Loads pre-baked OpenAPI specification bytes from disk. + Reads both the JSON and YAML variants (whichever exist). + """ + json_path, yaml_path = self._get_spec_file_paths(spec_file) + if os.path.isfile(json_path): + with open(json_path, "rb") as fp: + self._json_docs = fp.read() + if os.path.isfile(yaml_path): + with open(yaml_path, "rb") as fp: + self._yaml_docs = fp.read() + + def save_spec(self, destination: str) -> None: + """ + Saves the current in-memory OpenAPI specification to disk. + Both JSON and YAML variants are always written, regardless of the + extension given in *destination*. + + This is meant to be used to "bake" the spec at build/CI time (without + ``PYTHONOPTIMIZE=2``) so that it can be loaded at runtime when docstrings + are stripped. Typical usage:: + + # bake_spec.py – run once, without -OO + import asyncio + from myapp import app, docs + + asyncio.run(app.start()) + docs.save_spec("./static/openapi.json") + # also writes ./static/openapi.yaml + + Then configure the handler to load the file at runtime:: + + docs = OpenAPIHandler( + info=Info("My API", "1.0"), + spec_file="./static/openapi.json", + ) + + Args: + destination: file path with a ``.json`` or ``.yaml``/``.yml`` + extension. The companion format is written next to it + automatically. + """ + if not self._json_docs and not self._yaml_docs: + raise RuntimeError( + "The specification has not been built yet. " + "Call save_spec() only after the application has started " + "(e.g. after asyncio.run(app.start()))." + ) + json_path, yaml_path = self._get_spec_file_paths(destination) + with open(json_path, "wb") as fp: + fp.write(self._json_docs) + with open(yaml_path, "wb") as fp: + fp.write(self._yaml_docs) + async def build_docs(self, app: Application) -> None: - docs = self.generate_documentation(app) - self.on_docs_generated(docs) - serializer = self._serializer or DefaultSerializer() + if self._spec_file: + self._load_spec_from_file(self._spec_file) + else: + docs = self.generate_documentation(app) + self.on_docs_generated(docs) + serializer = self._serializer or DefaultSerializer() + self._json_docs = serializer.to_json(docs).encode("utf8") + self._yaml_docs = serializer.to_yaml(docs).encode("utf8") ui_options = UIOptions( spec_url=self.get_spec_path(), page_title=self.get_ui_page_title() @@ -575,9 +649,6 @@ async def build_docs(self, app: Application) -> None: for ui_provider in self.ui_providers: ui_provider.build_ui(ui_options) - self._json_docs = serializer.to_json(docs).encode("utf8") - self._yaml_docs = serializer.to_yaml(docs).encode("utf8") - def bind_app(self, app: Application) -> None: if app.started: raise TypeError( diff --git a/blacksheep/server/openapi/v3.py b/blacksheep/server/openapi/v3.py index 15d77a1f..108087c3 100644 --- a/blacksheep/server/openapi/v3.py +++ b/blacksheep/server/openapi/v3.py @@ -434,6 +434,7 @@ def __init__( security_schemes: dict[str, SecurityScheme] | None = None, servers: Sequence[Server] | None = None, serializer: Serializer | None = None, + spec_file: str | None = None, ) -> None: super().__init__( ui_path=ui_path, @@ -442,6 +443,7 @@ def __init__( preferred_format=preferred_format, anonymous_access=anonymous_access, serializer=serializer, + spec_file=spec_file, ) self.info = info self._tags = tags diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py index b83f2ee4..abe9a0f5 100644 --- a/tests/test_openapi_v3.py +++ b/tests/test_openapi_v3.py @@ -4454,3 +4454,156 @@ async def test_filedata_schema_generation(): assert list_schema.type == ValueType.ARRAY assert list_schema.items.type == ValueType.STRING assert list_schema.items.format == ValueFormat.BINARY + + +# --------------------------------------------------------------------------- +# Tests for save_spec / spec_file (PYTHONOPTIMIZE=2 support) +# --------------------------------------------------------------------------- + + +def test_save_spec_raises_if_not_built(): + """save_spec raises RuntimeError when called before the app has started.""" + docs = OpenAPIHandler(info=Info("Test API", "0.0.1")) + with pytest.raises(RuntimeError, match="not been built yet"): + docs.save_spec("/tmp/openapi.json") + + +async def test_save_spec_writes_both_formats(tmp_path): + """save_spec always writes both a .json and a .yaml file.""" + import json as _json + + app = Application() + + @app.router.get("/hello") + async def hello(): + """Returns a greeting.""" + return "Hello" + + docs = OpenAPIHandler(info=Info("Test API", "0.0.1")) + docs.bind_app(app) + await app.start() + + destination = str(tmp_path / "openapi.json") + docs.save_spec(destination) + + json_path = tmp_path / "openapi.json" + yaml_path = tmp_path / "openapi.yaml" + + assert json_path.exists(), "JSON spec file should be written" + assert yaml_path.exists(), "YAML spec file should be written" + + # Both files should be valid and contain the /hello path + data = _json.loads(json_path.read_bytes()) + assert "/hello" in data["paths"] + + yaml_text = yaml_path.read_text() + assert "/hello" in yaml_text + + +async def test_save_spec_yaml_destination_writes_both_formats(tmp_path): + """save_spec writes both formats even when destination has .yaml extension.""" + app = Application() + + @app.router.get("/ping") + async def ping(): + return "pong" + + docs = OpenAPIHandler(info=Info("Test API", "0.0.1")) + docs.bind_app(app) + await app.start() + + destination = str(tmp_path / "openapi.yaml") + docs.save_spec(destination) + + assert (tmp_path / "openapi.yaml").exists() + assert (tmp_path / "openapi.json").exists() + + +async def test_spec_file_loads_json_on_build_docs(tmp_path): + """When spec_file is set, build_docs loads the spec from disk.""" + import json as _json + + # Step 1: build and bake the spec + app_bake = Application() + + @app_bake.router.get("/cats") + async def get_cats(): + return [] + + docs_bake = OpenAPIHandler(info=Info("Cats API", "1.0.0")) + docs_bake.bind_app(app_bake) + await app_bake.start() + docs_bake.save_spec(str(tmp_path / "openapi.json")) + + # Step 2: load the spec at "runtime" via spec_file= + app_runtime = Application() + docs_runtime = OpenAPIHandler( + info=Info("Cats API", "1.0.0"), + spec_file=str(tmp_path / "openapi.json"), + ) + docs_runtime.bind_app(app_runtime) + await app_runtime.start() + + # The loaded JSON should match what was baked + loaded = _json.loads(docs_runtime._json_docs) + assert "/cats" in loaded["paths"] + + # The YAML variant should also be populated + assert b"/cats" in docs_runtime._yaml_docs + + +async def test_spec_file_loads_yaml_on_build_docs(tmp_path): + """When spec_file points to a .yaml file, build_docs loads both formats.""" + import json as _json + + app_bake = Application() + + @app_bake.router.get("/dogs") + async def get_dogs(): + return [] + + docs_bake = OpenAPIHandler(info=Info("Dogs API", "1.0.0")) + docs_bake.bind_app(app_bake) + await app_bake.start() + docs_bake.save_spec(str(tmp_path / "openapi.yaml")) + + app_runtime = Application() + docs_runtime = OpenAPIHandler( + info=Info("Dogs API", "1.0.0"), + spec_file=str(tmp_path / "openapi.yaml"), + ) + docs_runtime.bind_app(app_runtime) + await app_runtime.start() + + loaded = _json.loads(docs_runtime._json_docs) + assert "/dogs" in loaded["paths"] + + assert b"/dogs" in docs_runtime._yaml_docs + + +def test_get_spec_file_paths_json_extension(): + docs = OpenAPIHandler(info=Info("Test", "0.0.1")) + json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi.json") + assert json_path == "/tmp/spec/openapi.json" + assert yaml_path == "/tmp/spec/openapi.yaml" + + +def test_get_spec_file_paths_yaml_extension(): + docs = OpenAPIHandler(info=Info("Test", "0.0.1")) + json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi.yaml") + assert json_path == "/tmp/spec/openapi.json" + assert yaml_path == "/tmp/spec/openapi.yaml" + + +def test_get_spec_file_paths_yml_extension(): + docs = OpenAPIHandler(info=Info("Test", "0.0.1")) + json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi.yml") + assert json_path == "/tmp/spec/openapi.json" + assert yaml_path == "/tmp/spec/openapi.yml" + + +def test_get_spec_file_paths_no_extension(): + docs = OpenAPIHandler(info=Info("Test", "0.0.1")) + json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi") + assert json_path == "/tmp/spec/openapi.json" + assert yaml_path == "/tmp/spec/openapi.yaml" From ff0b566ae5b5f144f05fe5d981e4a30ce87c233e Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 24 Feb 2026 06:42:47 +0100 Subject: [PATCH 2/5] Update common.py --- blacksheep/server/openapi/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py index b2021050..f2d843ac 100644 --- a/blacksheep/server/openapi/common.py +++ b/blacksheep/server/openapi/common.py @@ -304,7 +304,7 @@ def __init__( self._handlers_docs: dict[Any, EndpointDocs] = {} self._controllers_docs: dict[Any, ControllerDocs] = {} self.use_docstrings: bool = True - self.include: Callable[[str, Route | None, bool]] = None + self.include: Callable[[str, Route], bool] | None = None self.json_spec_path = json_spec_path self.yaml_spec_path = yaml_spec_path self._json_docs: bytes = b"" From 4c31c7e5aba2d1a49a4a89a461f28812c27a5f6b Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 24 Feb 2026 06:57:21 +0100 Subject: [PATCH 3/5] Improvements --- blacksheep/server/openapi/common.py | 49 ++++++++++++++++++---------- tests/test_openapi_v3.py | 50 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py index f2d843ac..a69eb251 100644 --- a/blacksheep/server/openapi/common.py +++ b/blacksheep/server/openapi/common.py @@ -450,7 +450,7 @@ def _get_request_handler(self, route: Route) -> Any: # any normalization return route.handler # pragma: no cover - def get_handler_tags(self, handler: Any) -> list[str | None]: + def get_handler_tags(self, handler: Any) -> list[str] | None: docs = self.get_handler_docs(handler) if docs and docs.tags: return docs.tags @@ -577,18 +577,19 @@ def _get_spec_file_paths(self, spec_file: str) -> tuple[str, str]: json_path = spec_file if ext == ".json" else spec_file + ".json" return json_path, base + ".yaml" - def _load_spec_from_file(self, spec_file: str) -> None: + def _load_spec_from_file(self, spec_file: str) -> bool: """ Loads pre-baked OpenAPI specification bytes from disk. - Reads both the JSON and YAML variants (whichever exist). + Reads both the JSON and YAML variants. Returns True if both files are present, + False otherwise. """ json_path, yaml_path = self._get_spec_file_paths(spec_file) - if os.path.isfile(json_path): - with open(json_path, "rb") as fp: - self._json_docs = fp.read() - if os.path.isfile(yaml_path): - with open(yaml_path, "rb") as fp: - self._yaml_docs = fp.read() + both_exist = os.path.isfile(json_path) and os.path.isfile(yaml_path) + with open(json_path, "rb") as fp: + self._json_docs = fp.read() + with open(yaml_path, "rb") as fp: + self._yaml_docs = fp.read() + return both_exist def save_spec(self, destination: str) -> None: """ @@ -598,21 +599,33 @@ def save_spec(self, destination: str) -> None: This is meant to be used to "bake" the spec at build/CI time (without ``PYTHONOPTIMIZE=2``) so that it can be loaded at runtime when docstrings - are stripped. Typical usage:: + are stripped. - # bake_spec.py – run once, without -OO + **Typical workflow** + + 1. Bake the spec once (e.g. in a CI step, without ``-OO``):: + + # bake_spec.py import asyncio from myapp import app, docs asyncio.run(app.start()) - docs.save_spec("./static/openapi.json") - # also writes ./static/openapi.yaml + docs.save_spec("./openapi.json") + # also writes ./openapi.yaml + + 2. Ship the baked files alongside the application. + + 3. At runtime (TEST / PROD) set the environment variable so that + BlackSheep loads the baked spec instead of regenerating it:: + + APP_SPEC_FILE=openapi.json - Then configure the handler to load the file at runtime:: + No code change is needed between environments. Alternatively, + pass the path explicitly:: docs = OpenAPIHandler( info=Info("My API", "1.0"), - spec_file="./static/openapi.json", + spec_file="openapi.json", ) Args: @@ -633,8 +646,10 @@ def save_spec(self, destination: str) -> None: fp.write(self._yaml_docs) async def build_docs(self, app: Application) -> None: - if self._spec_file: - self._load_spec_from_file(self._spec_file) + effective_spec_file = self._spec_file or os.environ.get("APP_SPEC_FILE") + if effective_spec_file and self._load_spec_from_file(effective_spec_file): + # Files read from file system + ... else: docs = self.generate_documentation(app) self.on_docs_generated(docs) diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py index abe9a0f5..01256b1c 100644 --- a/tests/test_openapi_v3.py +++ b/tests/test_openapi_v3.py @@ -4607,3 +4607,53 @@ def test_get_spec_file_paths_no_extension(): json_path, yaml_path = docs._get_spec_file_paths("/tmp/spec/openapi") assert json_path == "/tmp/spec/openapi.json" assert yaml_path == "/tmp/spec/openapi.yaml" + + +async def test_app_spec_file_env_var_is_used_when_set(tmp_path, monkeypatch): + """When APP_SPEC_FILE env var is set, build_docs loads from that file.""" + import json as _json + + # Bake a spec first + app_bake = Application() + + @app_bake.router.get("/birds") + async def get_birds(): + return [] + + docs_bake = OpenAPIHandler(info=Info("Birds API", "1.0.0")) + docs_bake.bind_app(app_bake) + await app_bake.start() + docs_bake.save_spec(str(tmp_path / "openapi.json")) + + # Now simulate what a deploy environment looks like: no spec_file kwarg, + # only the env var is set. + monkeypatch.setenv("APP_SPEC_FILE", str(tmp_path / "openapi.json")) + + app_runtime = Application() + docs_runtime = OpenAPIHandler(info=Info("Birds API", "1.0.0")) + docs_runtime.bind_app(app_runtime) + await app_runtime.start() + + loaded = _json.loads(docs_runtime._json_docs) + assert "/birds" in loaded["paths"] + assert b"/birds" in docs_runtime._yaml_docs + + +async def test_app_spec_file_env_var_not_set_generates_spec(monkeypatch): + """When APP_SPEC_FILE is not set, the spec is generated normally.""" + monkeypatch.delenv("APP_SPEC_FILE", raising=False) + + app = Application() + + @app.router.get("/fish") + async def get_fish(): + return [] + + docs = OpenAPIHandler(info=Info("Fish API", "1.0.0")) + docs.bind_app(app) + await app.start() + + import json as _json + + loaded = _json.loads(docs._json_docs) + assert "/fish" in loaded["paths"] From 53fdfbe8c201fffe788218fc3dfbd21744450652 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 24 Feb 2026 07:17:33 +0100 Subject: [PATCH 4/5] Update common.py --- blacksheep/server/openapi/common.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py index a69eb251..6f392613 100644 --- a/blacksheep/server/openapi/common.py +++ b/blacksheep/server/openapi/common.py @@ -580,16 +580,17 @@ def _get_spec_file_paths(self, spec_file: str) -> tuple[str, str]: def _load_spec_from_file(self, spec_file: str) -> bool: """ Loads pre-baked OpenAPI specification bytes from disk. - Reads both the JSON and YAML variants. Returns True if both files are present, - False otherwise. + Reads both the JSON and YAML variants. Returns True if both files are + found and loaded, False if either is missing (falling back to generation). """ json_path, yaml_path = self._get_spec_file_paths(spec_file) - both_exist = os.path.isfile(json_path) and os.path.isfile(yaml_path) + if not os.path.isfile(json_path) or not os.path.isfile(yaml_path): + return False with open(json_path, "rb") as fp: self._json_docs = fp.read() with open(yaml_path, "rb") as fp: self._yaml_docs = fp.read() - return both_exist + return True def save_spec(self, destination: str) -> None: """ From 7558bc72bfce09248d1ec18912f00da8fad9d5f6 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 24 Feb 2026 07:36:42 +0100 Subject: [PATCH 5/5] Complete improvements for optimize mode --- blacksheep/server/openapi/common.py | 16 ++++--- blacksheep/server/openapi/docstrings.py | 14 +++++++ tests/test_openapi_v3.py | 56 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py index 6f392613..71e3673f 100644 --- a/blacksheep/server/openapi/common.py +++ b/blacksheep/server/openapi/common.py @@ -316,7 +316,7 @@ def __init__( self.events = OpenAPIEvents(self) self.handle_optional_response_with_404 = True self._serializer = serializer - self._spec_file = spec_file + self._spec_file = spec_file or os.environ.get("APP_SPEC_FILE") def __call__( self, @@ -572,7 +572,7 @@ def _get_spec_file_paths(self, spec_file: str) -> tuple[str, str]: with .json. Otherwise the YAML companion uses the same base with .yaml. """ base, ext = os.path.splitext(spec_file) - if ext in (".yaml", ".yml"): + if ext.lower() in (".yaml", ".yml"): return base + ".json", spec_file json_path = spec_file if ext == ".json" else spec_file + ".json" return json_path, base + ".yaml" @@ -629,6 +629,10 @@ def save_spec(self, destination: str) -> None: spec_file="openapi.json", ) + If the files do not exist yet when the application starts, they are + generated and saved automatically on the first startup, then loaded + from disk on every subsequent startup. + Args: destination: file path with a ``.json`` or ``.yaml``/``.yml`` extension. The companion format is written next to it @@ -647,9 +651,9 @@ def save_spec(self, destination: str) -> None: fp.write(self._yaml_docs) async def build_docs(self, app: Application) -> None: - effective_spec_file = self._spec_file or os.environ.get("APP_SPEC_FILE") - if effective_spec_file and self._load_spec_from_file(effective_spec_file): - # Files read from file system + spec_file = self._spec_file + if spec_file and self._load_spec_from_file(spec_file): + # Files are read from file system ... else: docs = self.generate_documentation(app) @@ -657,6 +661,8 @@ async def build_docs(self, app: Application) -> None: serializer = self._serializer or DefaultSerializer() self._json_docs = serializer.to_json(docs).encode("utf8") self._yaml_docs = serializer.to_yaml(docs).encode("utf8") + if spec_file: + self.save_spec(spec_file) ui_options = UIOptions( spec_url=self.get_spec_path(), page_title=self.get_ui_page_title() diff --git a/blacksheep/server/openapi/docstrings.py b/blacksheep/server/openapi/docstrings.py index 940e21e7..b59c038c 100644 --- a/blacksheep/server/openapi/docstrings.py +++ b/blacksheep/server/openapi/docstrings.py @@ -19,6 +19,7 @@ """ import re +import sys import warnings from abc import ABC, abstractmethod from dataclasses import dataclass @@ -510,15 +511,28 @@ def parse_docstring( _handlers_docstring_info = WeakKeyDictionary() +_optimize_warning_issued = False def get_handler_docstring_info(handler) -> DocstringInfo: + global _optimize_warning_issued if handler not in _handlers_docstring_info: docs = handler.__doc__ if docs: docstring_info = parse_docstring(docs) else: + if sys.flags.optimize >= 2 and not _optimize_warning_issued: + _optimize_warning_issued = True + warnings.warn( + "Python is running with PYTHONOPTIMIZE=2 (or -OO), so docstrings " + "are not available and cannot be used to enrich OpenAPI " + "Documentation. Consider baking the OpenAPI spec to a file before " + "deploying: call docs.save_spec() after starting the application " + "once without -OO, then set APP_SPEC_FILE to the saved path so " + "that BlackSheep loads it at runtime instead of regenerating it.", + stacklevel=2, + ) docstring_info = None _handlers_docstring_info[handler] = docstring_info return _handlers_docstring_info[handler] diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py index 01256b1c..307023bf 100644 --- a/tests/test_openapi_v3.py +++ b/tests/test_openapi_v3.py @@ -4657,3 +4657,59 @@ async def get_fish(): loaded = _json.loads(docs._json_docs) assert "/fish" in loaded["paths"] + + +async def test_spec_file_auto_saves_on_first_start(tmp_path, monkeypatch): + """When spec_file is set but files don't exist yet, the spec is generated, + saved to disk, and subsequent startups load from the saved files.""" + import json as _json + + monkeypatch.delenv("APP_SPEC_FILE", raising=False) + + spec_path = str(tmp_path / "openapi.json") + + # First startup: files don't exist yet → generate and auto-save + app_first = Application() + + @app_first.router.get("/frogs") + async def get_frogs(): + return [] + + docs_first = OpenAPIHandler(info=Info("Frogs API", "1.0.0"), spec_file=spec_path) + docs_first.bind_app(app_first) + await app_first.start() + + assert (tmp_path / "openapi.json").exists(), "JSON spec should be auto-saved" + assert (tmp_path / "openapi.yaml").exists(), "YAML spec should be auto-saved" + assert "/frogs" in _json.loads(docs_first._json_docs)["paths"] + + # Second startup: files exist → loaded from disk, generation is skipped + app_second = Application() + docs_second = OpenAPIHandler(info=Info("Frogs API", "1.0.0"), spec_file=spec_path) + docs_second.bind_app(app_second) + await app_second.start() + + assert "/frogs" in _json.loads(docs_second._json_docs)["paths"] + assert b"/frogs" in docs_second._yaml_docs + + +async def test_spec_file_env_var_auto_saves_on_first_start(tmp_path, monkeypatch): + """APP_SPEC_FILE pointing to a non-existent file causes auto-save on first start.""" + import json as _json + + spec_path = str(tmp_path / "openapi.json") + monkeypatch.setenv("APP_SPEC_FILE", spec_path) + + app = Application() + + @app.router.get("/foxes") + async def get_foxes(): + return [] + + docs = OpenAPIHandler(info=Info("Foxes API", "1.0.0")) + docs.bind_app(app) + await app.start() + + assert (tmp_path / "openapi.json").exists() + assert (tmp_path / "openapi.yaml").exists() + assert "/foxes" in _json.loads(docs._json_docs)["paths"]