diff --git a/README.rst b/README.rst index e4c2233a..147c1dde 100644 --- a/README.rst +++ b/README.rst @@ -116,6 +116,21 @@ Python package For more details, read about `Python package `__. +Performance tuning +****************** + +You can tune resolved-path caching with an environment variable: + +.. code-block:: bash + + OPENAPI_SPEC_VALIDATOR_RESOLVED_CACHE_MAXSIZE=2048 + +Rules: + +* Default is ``128``. +* Set ``0`` to disable the resolved cache. +* Invalid values (non-integer or negative) fall back to ``128``. + Related projects ################ diff --git a/docs/cli.rst b/docs/cli.rst index 88a4e894..879ed6a5 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -68,3 +68,8 @@ CLI (Command Line Interface) Legacy note: ``--errors`` / ``--error`` are deprecated and emit warnings by default. Set ``OPENAPI_SPEC_VALIDATOR_WARN_DEPRECATED=0`` to silence warnings. + +Performance note: + You can tune resolved-path caching with + ``OPENAPI_SPEC_VALIDATOR_RESOLVED_CACHE_MAXSIZE``. + Default is ``128``; set ``0`` to disable. diff --git a/docs/python.rst b/docs/python.rst index 88eaf97b..e7dd0ac0 100644 --- a/docs/python.rst +++ b/docs/python.rst @@ -59,3 +59,19 @@ If you want to iterate through validation errors: from openapi_spec_validator import OpenAPIV32SpecValidator errors_iterator = OpenAPIV32SpecValidator(spec).iter_errors() + +Resolved path cache +------------------- + +``openapi-spec-validator`` can configure the ``jsonschema-path`` resolved +path cache through an environment variable: + +.. code-block:: bash + + OPENAPI_SPEC_VALIDATOR_RESOLVED_CACHE_MAXSIZE=2048 + +Rules: + +* Default is ``128``. +* Set ``0`` to disable the resolved cache. +* Invalid values (non-integer or negative) fall back to ``128``. diff --git a/openapi_spec_validator/settings.py b/openapi_spec_validator/settings.py new file mode 100644 index 00000000..5da2b210 --- /dev/null +++ b/openapi_spec_validator/settings.py @@ -0,0 +1,43 @@ +from pydantic import field_validator +from pydantic_settings import BaseSettings +from pydantic_settings import SettingsConfigDict + +ENV_PREFIX = "OPENAPI_SPEC_VALIDATOR_" +RESOLVED_CACHE_MAXSIZE_DEFAULT = 128 + + +class OpenAPISpecValidatorSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix=ENV_PREFIX, + extra="ignore", + ) + + resolved_cache_maxsize: int = RESOLVED_CACHE_MAXSIZE_DEFAULT + + @field_validator("resolved_cache_maxsize", mode="before") + @classmethod + def normalize_resolved_cache_maxsize( + cls, value: int | str | None + ) -> int: + if value is None: + return RESOLVED_CACHE_MAXSIZE_DEFAULT + + if isinstance(value, int): + parsed_value = value + elif isinstance(value, str): + try: + parsed_value = int(value) + except ValueError: + return RESOLVED_CACHE_MAXSIZE_DEFAULT + else: + return RESOLVED_CACHE_MAXSIZE_DEFAULT + + if parsed_value < 0: + return RESOLVED_CACHE_MAXSIZE_DEFAULT + + return parsed_value + + +def get_resolved_cache_maxsize() -> int: + settings = OpenAPISpecValidatorSettings() + return settings.resolved_cache_maxsize diff --git a/openapi_spec_validator/shortcuts.py b/openapi_spec_validator/shortcuts.py index 3b32ee14..e7535813 100644 --- a/openapi_spec_validator/shortcuts.py +++ b/openapi_spec_validator/shortcuts.py @@ -7,6 +7,7 @@ from jsonschema_path.handlers import all_urls_handler from jsonschema_path.typing import Schema +from openapi_spec_validator.settings import OpenAPISpecValidatorSettings from openapi_spec_validator.validation import OpenAPIV2SpecValidator from openapi_spec_validator.validation import OpenAPIV30SpecValidator from openapi_spec_validator.validation import OpenAPIV31SpecValidator @@ -44,7 +45,12 @@ def validate( ) -> None: if cls is None: cls = get_validator_cls(spec) - sp = SchemaPath.from_dict(spec, base_uri=base_uri) + settings = OpenAPISpecValidatorSettings() + sp = SchemaPath.from_dict( + spec, + base_uri=base_uri, + resolved_cache_maxsize=settings.resolved_cache_maxsize, + ) v = cls(sp) return v.validate() diff --git a/openapi_spec_validator/validation/validators.py b/openapi_spec_validator/validation/validators.py index 3eca7a42..796db96b 100644 --- a/openapi_spec_validator/validation/validators.py +++ b/openapi_spec_validator/validation/validators.py @@ -17,6 +17,7 @@ from openapi_spec_validator.schemas import openapi_v31_schema_validator from openapi_spec_validator.schemas import openapi_v32_schema_validator from openapi_spec_validator.schemas.types import AnySchema +from openapi_spec_validator.settings import OpenAPISpecValidatorSettings from openapi_spec_validator.validation import keywords from openapi_spec_validator.validation.decorators import unwraps_iter from openapi_spec_validator.validation.decorators import wraps_cached_iter @@ -54,11 +55,13 @@ def __init__( self.schema_path = schema self.schema = schema.read_value() else: + settings = OpenAPISpecValidatorSettings() self.schema = schema self.schema_path = SchemaPath.from_dict( self.schema, base_uri=self.base_uri, handlers=self.resolver_handlers, + resolved_cache_maxsize=settings.resolved_cache_maxsize, ) self.keyword_validators_registry = KeywordValidatorRegistry( diff --git a/poetry.lock b/poetry.lock index 57cb2225..499a3488 100644 --- a/poetry.lock +++ b/poetry.lock @@ -18,7 +18,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -686,14 +686,14 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "jsonschema-path" -version = "0.4.2" +version = "0.4.4" description = "JSONSchema Spec with object-oriented paths" optional = false python-versions = "<4.0.0,>=3.10" groups = ["main"] files = [ - {file = "jsonschema_path-0.4.2-py3-none-any.whl", hash = "sha256:9c3d88e727cc4f1a88e51dbbed4211dbcd815d27799d2685efd904435c3d39e7"}, - {file = "jsonschema_path-0.4.2.tar.gz", hash = "sha256:5f5ff183150030ea24bb51cf1ddac9bf5dbf030272e2792a7ffe8262f7eea2a5"}, + {file = "jsonschema_path-0.4.4-py3-none-any.whl", hash = "sha256:669bb69cb92cd4c54acf38ee2ff7c3d9ab6b69991698f7a2f17d2bb0e5c9c394"}, + {file = "jsonschema_path-0.4.4.tar.gz", hash = "sha256:4c55842890fc384262a59fb63a25c86cc0e2b059e929c18b851c1d19ef612026"}, ] [package.dependencies] @@ -1171,7 +1171,7 @@ version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -1193,7 +1193,7 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -1323,14 +1323,14 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-extra-types" -version = "2.10.5" +version = "2.11.0" description = "Extra Pydantic types." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["docs"] files = [ - {file = "pydantic_extra_types-2.10.5-py3-none-any.whl", hash = "sha256:b60c4e23d573a69a4f1a16dd92888ecc0ef34fb0e655b4f305530377fa70e7a8"}, - {file = "pydantic_extra_types-2.10.5.tar.gz", hash = "sha256:1dcfa2c0cf741a422f088e0dbb4690e7bfadaaf050da3d6f80d6c3cf58a2bad8"}, + {file = "pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6"}, + {file = "pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e"}, ] [package.dependencies] @@ -1338,13 +1338,38 @@ pydantic = ">=2.5.2" typing-extensions = "*" [package.extras] -all = ["pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<10)", "pycountry (>=23)", "pymongo (>=4.0.0,<5.0.0)", "python-ulid (>=1,<2) ; python_version < \"3.9\"", "python-ulid (>=1,<4) ; python_version >= \"3.9\"", "pytz (>=2024.1)", "semver (>=3.0.2)", "semver (>=3.0.2,<3.1.0)", "tzdata (>=2024.1)"] +all = ["cron-converter (>=1.2.2)", "pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<10)", "pycountry (>=23)", "pymongo (>=4.0.0,<5.0.0)", "python-ulid (>=1,<2) ; python_version < \"3.9\"", "python-ulid (>=1,<4) ; python_version >= \"3.9\"", "pytz (>=2024.1)", "semver (>=3.0.2)", "semver (>=3.0.2,<3.1.0)", "tzdata (>=2024.1)"] +cron = ["cron-converter (>=1.2.2)"] pendulum = ["pendulum (>=3.0.0,<4.0.0)"] phonenumbers = ["phonenumbers (>=8,<10)"] pycountry = ["pycountry (>=23)"] python-ulid = ["python-ulid (>=1,<2) ; python_version < \"3.9\"", "python-ulid (>=1,<4) ; python_version >= \"3.9\""] semver = ["semver (>=3.0.2)"] +[[package]] +name = "pydantic-settings" +version = "2.13.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, + {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pyflakes" version = "2.5.0" @@ -1460,6 +1485,21 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["pytest (>=6,!=8.1.*)"] type = ["pytest-mypy"] +[[package]] +name = "python-dotenv" +version = "1.2.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "pytokens" version = "0.4.1" @@ -2153,7 +2193,6 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {main = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -2161,7 +2200,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -2225,4 +2264,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "ebc7d9e9b9a064919a58af13b305dde677f41b4bc22b00b39bc1599adee6dbba" +content-hash = "c074adacdbb632312f0d709078ddbb0146655aa4dbae73bd33a05235e0ef6068" diff --git a/pyproject.toml b/pyproject.toml index ce63d0ca..18a1b091 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,10 @@ classifiers = [ dependencies = [ "jsonschema >=4.24.0,<4.25.0", "openapi-schema-validator >=0.7.3,<0.9.0", - "jsonschema-path >=0.4.2,<0.5.0", + "jsonschema-path >=0.4.3,<0.5.0", "lazy-object-proxy >=1.7.1,<2.0", + "pydantic-settings (>=2.0.0,<3.0.0)", + "pydantic (>=2.0.0,<3.0.0)", ] [project.urls] diff --git a/tests/bench/runner.py b/tests/bench/runner.py index d059519c..2f147fe5 100644 --- a/tests/bench/runner.py +++ b/tests/bench/runner.py @@ -21,13 +21,11 @@ from pathlib import Path from typing import Any -from jsonschema_path import SchemaPath from jsonschema_path.typing import Schema from openapi_spec_validator import schemas from openapi_spec_validator import validate from openapi_spec_validator.readers import read_from_filename -from openapi_spec_validator.shortcuts import get_validator_cls @dataclass @@ -110,11 +108,7 @@ def get_spec_version(spec: Schema) -> str: def run_once(spec: Schema) -> float: """Run validation once and return elapsed time.""" t0 = time.perf_counter() - cls = get_validator_cls(spec) - sp = SchemaPath.from_dict(spec) - v = cls(sp) - v.validate() - # validate(spec) + validate(spec) return time.perf_counter() - t0 @@ -271,8 +265,8 @@ def get_synthetic_specs_iterator( configs: list[tuple[int, int, str]], ) -> Iterator[tuple[dict[str, Any], str, float]]: """Iterator over synthetic specs based on provided configurations.""" - for paths, schemas, size in configs: - spec = generate_synthetic_spec(paths, schemas) + for paths, schema_count, size in configs: + spec = generate_synthetic_spec(paths, schema_count) yield spec, f"synthetic_{size}", 0 @@ -348,7 +342,10 @@ def main(): results.append(result.as_dict()) if result.success: print( - f" ✅ {result.median_s:.4f}s, {result.validations_per_sec:.2f} val/s" + " ✅ {:.4f}s, {:.2f} val/s".format( + result.median_s, + result.validations_per_sec, + ) ) else: print(f" ❌ Error: {result.error}") diff --git a/tests/integration/test_shortcuts.py b/tests/integration/test_shortcuts.py index cb79007c..351681f7 100644 --- a/tests/integration/test_shortcuts.py +++ b/tests/integration/test_shortcuts.py @@ -7,10 +7,12 @@ from openapi_spec_validator import openapi_v2_spec_validator from openapi_spec_validator import openapi_v30_spec_validator from openapi_spec_validator import openapi_v32_spec_validator +from openapi_spec_validator import shortcuts as shortcuts_module from openapi_spec_validator import validate from openapi_spec_validator import validate_spec from openapi_spec_validator import validate_spec_url from openapi_spec_validator import validate_url +from openapi_spec_validator.settings import RESOLVED_CACHE_MAXSIZE_DEFAULT from openapi_spec_validator.validation.exceptions import OpenAPIValidationError from openapi_spec_validator.validation.exceptions import ValidatorDetectError @@ -23,6 +25,56 @@ def test_spec_schema_version_not_detected(self): validate(spec) +def test_validate_uses_resolved_cache_maxsize_env(monkeypatch): + captured: dict[str, int] = {} + original_from_dict = shortcuts_module.SchemaPath.from_dict + spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "0.0.1"}, + "paths": {}, + } + + def fake_from_dict(cls, *args, **kwargs): + captured["resolved_cache_maxsize"] = kwargs["resolved_cache_maxsize"] + return original_from_dict(*args, **kwargs) + + monkeypatch.setenv("OPENAPI_SPEC_VALIDATOR_RESOLVED_CACHE_MAXSIZE", "256") + monkeypatch.setattr( + shortcuts_module.SchemaPath, + "from_dict", + classmethod(fake_from_dict), + ) + + validate(spec, cls=OpenAPIV30SpecValidator) + + assert captured["resolved_cache_maxsize"] == 256 + + +def test_validate_uses_default_resolved_cache_on_invalid_env(monkeypatch): + captured: dict[str, int] = {} + original_from_dict = shortcuts_module.SchemaPath.from_dict + spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "0.0.1"}, + "paths": {}, + } + + def fake_from_dict(cls, *args, **kwargs): + captured["resolved_cache_maxsize"] = kwargs["resolved_cache_maxsize"] + return original_from_dict(*args, **kwargs) + + monkeypatch.setenv("OPENAPI_SPEC_VALIDATOR_RESOLVED_CACHE_MAXSIZE", "-1") + monkeypatch.setattr( + shortcuts_module.SchemaPath, + "from_dict", + classmethod(fake_from_dict), + ) + + validate(spec, cls=OpenAPIV30SpecValidator) + + assert captured["resolved_cache_maxsize"] == RESOLVED_CACHE_MAXSIZE_DEFAULT + + class TestLocalValidateSpecUrl: def test_spec_schema_version_not_detected(self, factory): spec_path = "data/empty.yaml" diff --git a/tests/integration/validation/test_validators.py b/tests/integration/validation/test_validators.py index cd49ac27..17a347d2 100644 --- a/tests/integration/validation/test_validators.py +++ b/tests/integration/validation/test_validators.py @@ -6,6 +6,8 @@ from openapi_spec_validator import OpenAPIV30SpecValidator from openapi_spec_validator import OpenAPIV31SpecValidator from openapi_spec_validator import OpenAPIV32SpecValidator +from openapi_spec_validator.settings import RESOLVED_CACHE_MAXSIZE_DEFAULT +from openapi_spec_validator.validation import validators as validators_module from openapi_spec_validator.validation.exceptions import OpenAPIValidationError @@ -67,6 +69,60 @@ def test_ref_failed(self, factory, spec_file): OpenAPIV2SpecValidator(spec, base_uri=spec_url).validate() +def test_spec_validator_uses_resolved_cache_maxsize_env(monkeypatch): + captured: dict[str, int] = {} + original_from_dict = validators_module.SchemaPath.from_dict + + def fake_from_dict(cls, *args, **kwargs): + captured["resolved_cache_maxsize"] = kwargs["resolved_cache_maxsize"] + return original_from_dict(*args, **kwargs) + + monkeypatch.setenv("OPENAPI_SPEC_VALIDATOR_RESOLVED_CACHE_MAXSIZE", "64") + monkeypatch.setattr( + validators_module.SchemaPath, + "from_dict", + classmethod(fake_from_dict), + ) + + OpenAPIV30SpecValidator( + { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "0.0.1"}, + "paths": {}, + } + ) + + assert captured["resolved_cache_maxsize"] == 64 + + +def test_spec_validator_uses_default_resolved_cache_on_invalid_env( + monkeypatch, +): + captured: dict[str, int] = {} + original_from_dict = validators_module.SchemaPath.from_dict + + def fake_from_dict(cls, *args, **kwargs): + captured["resolved_cache_maxsize"] = kwargs["resolved_cache_maxsize"] + return original_from_dict(*args, **kwargs) + + monkeypatch.setenv("OPENAPI_SPEC_VALIDATOR_RESOLVED_CACHE_MAXSIZE", "bad") + monkeypatch.setattr( + validators_module.SchemaPath, + "from_dict", + classmethod(fake_from_dict), + ) + + OpenAPIV30SpecValidator( + { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "0.0.1"}, + "paths": {}, + } + ) + + assert captured["resolved_cache_maxsize"] == RESOLVED_CACHE_MAXSIZE_DEFAULT + + class TestLocalOpenAPIv30Validator: LOCAL_SOURCE_DIRECTORY = "data/v3.0/"