#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-apispec. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds, so taking one start for every measurement here is the difference between a one-minute run and a half-hour one. WHAT THIS PACKAGE DOES, AND THEREFORE WHAT IS MEASURED. `apispec' ACCUMULATES METADATA AND EMITS A DOCUMENT. An `APISpec' object collects paths, operations, schemas, parameters, responses, tags and security schemes, and `to_dict()' renders them into the layout the OpenAPI Specification requires -- a different layout for the 2.0 and 3.x families. There is no I/O, no network, no subprocess and no device: the whole payload is dict construction and one version dispatch. SO THE ASSERTION IS THE DOCUMENT. Every structural case below emits `json.dumps(..., sort_keys=True)' of a real `to_dict()' result and the suite compares it against a literal. json rather than repr because repr's dict order is insertion order, which would make the expectation depend on the order this probe happens to call things in rather than on what the library produced; sort_keys makes the comparison about CONTENT. It is also single-line and ASCII, which are the runner's two rules. THE SAME SPEC IS BUILT TWICE, AT 2.0 AND AT 3.0.2, BY ONE FUNCTION. `build()' takes the version and nothing else, so the two documents differ only where the LIBRARY makes them differ -- which is what turns "version dispatch works" from a claim into a measurement. `v2_v3_root_only_in_*' and the `ref_*' cases name those places exactly. NO YAML ON THIS IMAGE, AND THAT IS MEASURED RATHER THAN ASSUMED. `to_yaml()' lazily imports `apispec.yaml_utils', which imports PyYAML -- an OPTIONAL EXTRA (`extras_require["yaml"]'), not a dependency, so the recipe is right not to carry it and python3-pyyaml is not ported to this arm at all. The three `yaml_*' cases below record what a caller actually gets. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline -- every value below is a single line. And no value may depend on the encoding stdout happens to be in: every one is ASCII, which `json.dumps' guarantees by default via ensure_ascii. """ import json import sys emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def j(obj): """Canonical single-line ASCII JSON -- the form every structural case takes.""" return json.dumps(obj, sort_keys=True, separators=(',', ':')) def call(fn, *a, **kw): """Return fn(*a) as a string, or `!ExceptionName' if it raised. Asserted by exception NAME rather than by message: the message is upstream's and moves between releases, while the name is the contract a caller has to code against.""" try: return fn(*a, **kw) except Exception as exc: return '!' + type(exc).__name__ # ====================================================================== # IDENTITY -- which copy answered. Every case below is a statement about # the module named here. # ====================================================================== import apispec import apispec.core import apispec.utils import apispec.exceptions from apispec import APISpec, BasePlugin from apispec.exceptions import ( APISpecError, DuplicateComponentNameError, DuplicateParameterError, InvalidParameterError, PluginMethodNotImplementedError, ) emit('module_file', apispec.__file__) emit('is_package', 'yes' if hasattr(apispec, '__path__') else 'no') emit('version_attr', apispec.__version__) emit('dunder_all', ','.join(sorted(apispec.__all__))) # `packaging' IS THE ONE RUNTIME DEPENDENCY, and it is load-bearing rather than # decorative: core.py line 10 is `from packaging.version import Version', and # every version comparison below goes through it. A missing python3-packaging # would make `import apispec' itself fail, which is why the recipe's RDEPENDS # names it. import packaging.version emit('packaging_importable', 'yes') emit('version_type_is_packaging', 'yes' if apispec.core.Version is packaging.version.Version else 'no') # The bounds the constructor enforces, read off the module rather than retyped. emit('min_version', str(apispec.core.MIN_INCLUSIVE_OPENAPI_VERSION)) emit('max_version', str(apispec.core.MAX_EXCLUSIVE_OPENAPI_VERSION)) emit('valid_methods_v2', ','.join(apispec.core.VALID_METHODS_OPENAPI_V2)) emit('valid_methods_v3', ','.join(apispec.core.VALID_METHODS_OPENAPI_V3)) # THE SUBSECTION TABLE IS THE WHOLE OF THE VERSION DIFFERENCE, so it is emitted # whole. v2 has FOUR component kinds and v3 has SIX -- headers and examples do # not exist as components in the 2.0 layout at all. emit('subsections_v2', j(apispec.utils.COMPONENT_SUBSECTIONS[2])) emit('subsections_v3', j(apispec.utils.COMPONENT_SUBSECTIONS[3])) # ====================================================================== # THE DOCUMENT. One builder, two versions -- see the header. # ====================================================================== SCHEMA = { 'type': 'object', 'properties': { 'id': {'type': 'integer', 'format': 'int64'}, 'name': {'type': 'string'}, }, 'required': ['id'], } def build(version, plugins=()): """The ticket's spec, built identically at either OpenAPI version. Nothing here branches on `version': every difference in the result is one the LIBRARY introduced, which is the point.""" spec = APISpec(title='Pet Store', version='1.2.3', openapi_version=version, plugins=plugins) spec.components.schema('Pet', SCHEMA) spec.components.security_scheme( 'apiKey', {'type': 'apiKey', 'name': 'X-API-Key', 'in': 'header'}) spec.tag({'name': 'pets', 'description': 'Everything about pets'}) get_op = { 'summary': 'Get a pet', 'operationId': 'getPet', 'tags': ['pets'], 'parameters': [ {'name': 'petId', 'in': 'path', 'schema': {'type': 'integer'}}, {'name': 'verbose', 'in': 'query', 'schema': {'type': 'boolean'}}, ], 'responses': { 200: {'description': 'ok', 'content': {'application/json': {'schema': 'Pet'}}}, 404: {'description': 'absent'}, }, } put_op = { 'summary': 'Replace a pet', 'operationId': 'putPet', 'requestBody': { 'required': True, 'content': {'application/json': {'schema': 'Pet'}}, }, 'responses': { 200: {'description': 'ok', 'content': {'application/json': {'schema': 'Pet'}}}, 400: {'description': 'bad'}, }, } spec.path(path='/pets/{petId}', operations={'get': get_op, 'put': put_op}) return spec v3 = build('3.0.2') v2 = build('2.0') d3 = v3.to_dict() d2 = v2.to_dict() emit('v3_doc', j(d3)) emit('v2_doc', j(d2)) # The root key sets, and the difference between them stated in both directions. emit('v3_root_keys', ','.join(sorted(d3))) emit('v2_root_keys', ','.join(sorted(d2))) emit('v2_v3_root_only_in_v2', ','.join(sorted(set(d2) - set(d3)))) emit('v2_v3_root_only_in_v3', ','.join(sorted(set(d3) - set(d2)))) # THE VERSION MARKER IS A DIFFERENT KEY, not a different value -- `swagger' at # 2.0 and `openapi' at 3.x -- and it carries the string the CONSTRUCTOR was # given, normalised through packaging's Version. emit('v2_swagger', d2.get('swagger', '')) emit('v3_openapi', d3.get('openapi', '')) emit('v2_has_openapi_key', 'yes' if 'openapi' in d2 else 'no') emit('v3_has_swagger_key', 'yes' if 'swagger' in d3 else 'no') # COMPONENTS MOVE, AND THEY MOVE BY NESTING, not by renaming alone: at 2.0 they # are TOP-LEVEL keys and at 3.x they are all under one `components' key. emit('v2_definitions', j(d2.get('definitions', ''))) emit('v3_components_schemas', j(d3.get('components', {}).get('schemas', ''))) emit('v2_has_components_key', 'yes' if 'components' in d2 else 'no') emit('v3_has_definitions_key', 'yes' if 'definitions' in d3 else 'no') emit('v2_security_definitions', j(d2.get('securityDefinitions', ''))) emit('v3_security_schemes', j(d3.get('components', {}).get('securitySchemes', ''))) # THE `$ref' STRINGS, COMPARED EXACTLY. This is the case that catches a # version-dispatch mistake: the same `schema: "Pet"' becomes a different pointer # under each version, and the `components/' segment is present at 3.x only. P2 = d2['paths']['/pets/{petId}'] P3 = d3['paths']['/pets/{petId}'] emit('v2_ref_get_response', j(P2['get']['responses']['200'])) emit('v3_ref_get_response', P3['get']['responses']['200']['content']['application/json']['schema']['$ref']) emit('v3_ref_request_body', P3['put']['requestBody']['content']['application/json']['schema']['$ref']) # THE HEADLINE STRUCTURAL FINDING, AND IT IS AN ASYMMETRY RATHER THAN A # RENAMING. The SAME response dict resolves its `schema: "Pet"' to a `$ref' # under 3.x and leaves it as THE BARE STRING under 2.0. That is not a bug in # the 2.0 branch: `_resolve_refs_in_response' resolves `response["schema"]' when # the major version is < 3 and `response["content"][...]["schema"]' when it is # not, because a 2.0 response HAS no `content' -- it carries `schema' directly. # So a caller who writes one v3-shaped operation and renders it at both versions # gets a document with an unresolved component name in it and NO error saying # so. Measured from both sides: the v3-shaped response below, and a properly # v2-shaped one that DOES resolve. emit('v2_response_schema_unresolved', j(P2['get']['responses']['200']['content']['application/json']['schema'])) v2ok = APISpec(title='t', version='1', openapi_version='2.0') v2ok.components.schema('Pet', SCHEMA) v2ok.path(path='/ok', operations={'get': {'responses': { 200: {'description': 'ok', 'schema': 'Pet'}}}}) emit('v2_response_v2shape_resolved', j(v2ok.to_dict()['paths']['/ok']['get']['responses']['200']['schema'])) # ...AND `requestBody' IS A 3.x CONSTRUCT THAT THE 2.0 PATH RESOLVES ANYWAY. # `_resolve_refs_in_operation' handles `requestBody' unconditionally, with no # version check, so a 2.0 document comes out carrying a key OpenAPI 2 does not # define -- with a correctly-built `#/definitions/...' ref inside it. apispec # validates neither the shape nor the version it belongs to. emit('v2_requestbody_present', 'yes' if 'requestBody' in P2['put'] else 'no') emit('v2_requestbody_ref', P2['put']['requestBody']['content']['application/json']['schema']['$ref']) emit('ref_build_v2', j(apispec.utils.build_reference('schema', 2, 'Pet'))) emit('ref_build_v3', j(apispec.utils.build_reference('schema', 3, 'Pet'))) emit('ref_build_v2_secscheme', j(apispec.utils.build_reference('security_scheme', 2, 'apiKey'))) emit('ref_build_v3_secscheme', j(apispec.utils.build_reference('security_scheme', 3, 'apiKey'))) # A `path' PARAMETER IS FORCED REQUIRED, whatever the caller wrote -- the spec # demands it and `_clean_parameters' enforces it. `verbose' is a query # parameter and is left exactly as given, with no `required' key at all. emit('v3_path_param', j(P3['get']['parameters'][0])) emit('v3_query_param', j(P3['get']['parameters'][1])) emit('v3_query_param_has_required', 'yes' if 'required' in P3['get']['parameters'][1] else 'no') # RESPONSE CODES ARE STRINGIFIED. The builder passed the ints 200/404/400 and # what comes back are the strings, because `_clean_operations' runs every code # through `int()' and then `str()' -- which is also how an `http.HTTPStatus' # IntEnum is normalised. emit('v3_get_response_codes', ','.join(sorted(P3['get']['responses']))) emit('v3_response_code_types', ','.join(sorted({type(k).__name__ for k in P3['get']['responses']}))) # ====================================================================== # basePath VERSUS servers -- AND THE TICKET'S EXPECTATION IS WRONG. # ====================================================================== # apispec DOES NOT TRANSLATE BETWEEN THE TWO. Neither key is generated: both # arrive through `**options' and are merged verbatim by `deepupdate'. So a # caller who passes `basePath' to a 3.x spec gets `basePath' in a 3.x document, # which is not a valid OpenAPI 3 field and which nothing here rejects. These # four cases record that the pass-through is literal in BOTH directions. opt2 = APISpec(title='t', version='1', openapi_version='2.0', basePath='/v1', host='example.test').to_dict() opt3 = APISpec(title='t', version='1', openapi_version='3.0.2', servers=[{'url': 'https://example.test/v1'}]).to_dict() emit('v2_basepath', opt2.get('basePath', '')) emit('v3_servers', j(opt3.get('servers', ''))) emit('v2_has_servers', 'yes' if 'servers' in opt2 else 'no') emit('v3_has_basepath_when_given', APISpec(title='t', version='1', openapi_version='3.0.2', basePath='/v1').to_dict().get('basePath', '')) # `deepupdate' MERGES INTO SUBDICTS RATHER THAN REPLACING THEM, so an `info' # passed as an option is COMBINED with the title/version the constructor built, # and the option wins on a collision. emit('info_merged', j(APISpec(title='t', version='1', openapi_version='3.0.2', info={'description': 'd'}).to_dict()['info'])) emit('info_option_overrides', j(APISpec(title='t', version='1', openapi_version='3.0.2', info={'title': 'from-option'}).to_dict()['info'])) # ====================================================================== # YAML -- ABSENT ON THIS IMAGE, AND THAT IS THE MEASUREMENT. # ====================================================================== # PyYAML is `extras_require["yaml"]' upstream, not a dependency, and this arm's # python3 is ONE STATIC BINARY with an empty lib-dynload and no dynamic loader # (BRINTOS-1476), so poky's python3-pyyaml -- a Cython extension against libyaml # -- could not be imported here however it were built. It is therefore not # ported and not on the image. `to_yaml()' defers its import to CALL time, so # the package imports and every case above works; only this one path fails, and # it fails with CPython's own name on it. emit('yaml_importable', call(__import__, 'yaml')) emit('yaml_utils_importable', call(__import__, 'apispec.yaml_utils')) emit('to_yaml_result', call(v3.to_yaml)) # `to_yaml' IS STILL A BOUND METHOD -- the failure is at call time, not a # missing attribute, which is the difference between "not built in" and "not # installed". emit('to_yaml_is_callable', 'yes' if callable(getattr(v3, 'to_yaml', None)) else 'no') # THE STRUCTURE ROUND-TRIPS THROUGH THE INTERPRETER'S OWN PARSER. The ticket # asks for a serialise/parse round-trip; with no YAML on this arm the parser # that IS in the stdlib is `json', and it answers the same question -- that # `to_dict()' returns a plain, fully-serialisable tree with no library objects # left in it. A stray `Version' or a tuple would fail here. emit('json_roundtrip_equal', 'yes' if json.loads(json.dumps(d3)) == d3 else 'no') emit('json_roundtrip_equal_v2', 'yes' if json.loads(json.dumps(d2)) == d2 else 'no') # `apispec.ext.marshmallow' SHIPS BUT CANNOT IMPORT, for the ordinary reason # that marshmallow is also an extra and is not on this image. The package is on # the rootfs either way, so a caller reaching for it gets an import error rather # than a missing file -- worth one named case so it is not rediscovered. import importlib def imports(name): """`ok' / `!ExceptionName' for importing `name'. `importlib.import_module' rather than `__import__': the builtin returns the TOP-LEVEL package for a dotted name, so its repr carries the module's path on disk -- which differs between the build host and the target and would make the case unassertable as a literal.""" try: importlib.import_module(name) return 'ok' except Exception as exc: return '!' + type(exc).__name__ emit('marshmallow_importable', imports('marshmallow')) emit('ext_marshmallow_importable', imports('apispec.ext.marshmallow')) emit('ext_importable', imports('apispec.ext')) # ====================================================================== # PLUGINS -- every documented hook, and the order they fire in. # ====================================================================== HOOKS = [] class RecordingPlugin(BasePlugin): """A plugin that implements every hook and records that it was called. Each hook returns something the core is documented to use, so the cases below assert the EFFECT as well as the call.""" def init_spec(self, spec): HOOKS.append('init_spec') def schema_helper(self, name, definition, **kwargs): HOOKS.append('schema_helper:' + name) return {'x-from-schema-helper': name} def response_helper(self, response, **kwargs): HOOKS.append('response_helper') return {'x-from-response-helper': True} def parameter_helper(self, parameter, **kwargs): HOOKS.append('parameter_helper:' + parameter.get('name', '?')) return {'x-from-parameter-helper': True} def header_helper(self, header, **kwargs): HOOKS.append('header_helper') return {'x-from-header-helper': True} def path_helper(self, path=None, operations=None, parameters=None, **kwargs): HOOKS.append('path_helper:' + str(path)) return path def operation_helper(self, path=None, operations=None, **kwargs): HOOKS.append('operation_helper:' + str(path)) plug = RecordingPlugin() pspec = APISpec(title='p', version='1', openapi_version='3.0.2', plugins=[plug]) emit('plugin_init_spec_called', 'yes' if HOOKS == ['init_spec'] else 'no') pspec.components.schema('Thing', {'type': 'object'}) pspec.components.response('Err', {'description': 'e'}) pspec.components.parameter('tid', 'path', {'schema': {'type': 'string'}}) pspec.components.header('X-Rate', {'schema': {'type': 'integer'}}) pspec.path(path='/things', operations={'get': {'responses': {200: {'description': 'ok'}}}}) emit('plugin_hooks', ','.join(HOOKS)) emit('plugin_hook_count', len(HOOKS)) pd = pspec.to_dict() emit('plugin_schema_effect', j(pd['components']['schemas']['Thing'])) emit('plugin_response_effect', j(pd['components']['responses']['Err'])) emit('plugin_parameter_effect', j(pd['components']['parameters']['tid'])) emit('plugin_header_effect', j(pd['components']['headers']['X-Rate'])) # A `path_helper' RETURNING A STRING SETS THE PATH -- the documented way a # framework plugin turns a view object into a URL. class RewritingPlugin(BasePlugin): def path_helper(self, path=None, operations=None, parameters=None, **kwargs): return '/rewritten' rspec = APISpec(title='r', version='1', openapi_version='3.0.2', plugins=[RewritingPlugin()]) rspec.path(path='/original', operations={'get': {'responses': {200: {'description': 'ok'}}}}) emit('plugin_path_rewrite', ','.join(sorted(rspec.to_dict()['paths']))) # A PLUGIN THAT IMPLEMENTS NOTHING IS NOT AN ERROR. `BasePlugin' raises # PluginMethodNotImplementedError from every hook and the core CATCHES exactly # that and continues -- which is what makes the hooks optional. bspec = APISpec(title='b', version='1', openapi_version='3.0.2', plugins=[BasePlugin()]) bspec.components.schema('Bare', {'type': 'object'}) bspec.path(path='/bare', operations={'get': {'responses': {200: {'description': 'ok'}}}}) emit('bare_plugin_ok', j(bspec.to_dict()['components']['schemas']['Bare'])) emit('bare_hook_raises', call(BasePlugin().schema_helper, 'n', {})) emit('plugin_error_is_notimplemented', 'yes' if issubclass(PluginMethodNotImplementedError, NotImplementedError) else 'no') # ====================================================================== # ERROR PATHS -- what a caller who gets it wrong actually sees. # ====================================================================== # A DUPLICATE COMPONENT NAME, for each kind that guards against one. dspec = APISpec(title='d', version='1', openapi_version='3.0.2') dspec.components.schema('Dup', {'type': 'object'}) emit('dup_schema', call(dspec.components.schema, 'Dup', {'type': 'object'})) dspec.components.response('DupR', {'description': 'x'}) emit('dup_response', call(dspec.components.response, 'DupR', {'description': 'x'})) dspec.components.parameter('DupP', 'query', {}) emit('dup_parameter', call(dspec.components.parameter, 'DupP', 'query', {})) dspec.components.header('DupH', {'schema': {'type': 'string'}}) emit('dup_header', call(dspec.components.header, 'DupH', {'schema': {'type': 'string'}})) dspec.components.example('DupE', {'value': 1}) emit('dup_example', call(dspec.components.example, 'DupE', {'value': 1})) dspec.components.security_scheme('DupS', {'type': 'apiKey'}) emit('dup_security_scheme', call(dspec.components.security_scheme, 'DupS', {'type': 'apiKey'})) emit('dup_error_is_apispecerror', 'yes' if issubclass(DuplicateComponentNameError, APISpecError) else 'no') # AN UNKNOWN OpenAPI VERSION. The two halves of "unknown" fail DIFFERENTLY, and # that is the finding: a number OUT OF RANGE is apispec's own APISpecError, # while a string that is not a version at all never reaches apispec's check -- # `packaging.version.Version' rejects it first, with `InvalidVersion'. A caller # catching APISpecError alone will not catch the second. emit('version_too_low', call(APISpec, title='t', version='1', openapi_version='1.2')) emit('version_too_high', call(APISpec, title='t', version='1', openapi_version='4.0')) emit('version_4_1', call(APISpec, title='t', version='1', openapi_version='4.1')) emit('version_not_a_version', call(APISpec, title='t', version='1', openapi_version='banana')) emit('version_empty', call(APISpec, title='t', version='1', openapi_version='')) emit('version_3_9_accepted', call(lambda: APISpec(title='t', version='1', openapi_version='3.9.9').to_dict()['openapi'])) emit('version_2_0_boundary', call(lambda: APISpec(title='t', version='1', openapi_version='2.0').to_dict()['swagger'])) # A MALFORMED OPERATION. An HTTP method the version does not define is an # APISpecError -- and `trace' is the case that makes it a VERSION question # rather than a spelling one: valid at 3.x, invalid at 2.0. espec3 = APISpec(title='e', version='1', openapi_version='3.0.2') espec2 = APISpec(title='e', version='1', openapi_version='2.0') emit('bad_method', call(espec3.path, path='/x', operations={'fly': {}})) emit('trace_ok_v3', call(lambda: ','.join(sorted( APISpec(title='e', version='1', openapi_version='3.0.2') .path(path='/t', operations={'trace': {'responses': {200: {'description': 'ok'}}}}) .to_dict()['paths']['/t'])))) emit('trace_bad_v2', call(espec2.path, path='/t', operations={'trace': {}})) # AN `x-' EXTENSION KEY IS NOT A METHOD AND IS NOT REJECTED -- the documented # escape hatch, and the reason `bad_method' above had to use a name that does # not start with `x-'. emit('x_extension_allowed', call(lambda: ','.join(sorted( APISpec(title='e', version='1', openapi_version='3.0.2') .path(path='/x', operations={'x-mine': {}, 'get': {'responses': {200: {'description': 'ok'}}}}) .to_dict()['paths']['/x'])))) # NO PATH AT ALL. With no plugin to supply one, `path()' refuses. emit('no_path', call(espec3.path, operations={'get': {}})) # A PARAMETER MISSING ITS REQUIRED KEYS, and a DUPLICATE parameter -- two # distinct exception types, both subclasses of APISpecError. emit('param_missing_keys', call(espec3.path, path='/p', operations={'get': {'parameters': [{'name': 'a'}]}})) emit('param_missing_both', call(espec3.path, path='/p2', operations={'get': {'parameters': [{}]}})) emit('param_duplicate', call(espec3.path, path='/p3', operations={'get': {'parameters': [ {'name': 'a', 'in': 'query'}, {'name': 'a', 'in': 'query'}]}})) # THE SAME NAME IN A DIFFERENT LOCATION IS NOT A DUPLICATE: uniqueness is the # (name, in) PAIR, which is what the specification says. emit('param_same_name_other_location', call(lambda: len( APISpec(title='e', version='1', openapi_version='3.0.2') .path(path='/p4', operations={'get': {'parameters': [ {'name': 'a', 'in': 'query'}, {'name': 'a', 'in': 'header'}], 'responses': {200: {'description': 'ok'}}}}) .to_dict()['paths']['/p4']['get']['parameters']))) emit('param_errors_are_apispecerror', 'yes' if (issubclass(DuplicateParameterError, APISpecError) and issubclass(InvalidParameterError, APISpecError)) else 'no') # A NON-INTEGER RESPONSE CODE UNDER 2.0 WARNS RATHER THAN RAISING, and the # document keeps the code as written. `default' is exempt -- it is a legal # OpenAPI 2 code. import warnings with warnings.catch_warnings(record=True) as caught: warnings.simplefilter('always') wspec = APISpec(title='w', version='1', openapi_version='2.0') wspec.path(path='/w', operations={'get': {'responses': {'nope': {'description': 'x'}}}}) emit('noninteger_code_warned', 'yes' if len(caught) == 1 else 'no') emit('noninteger_code_category', caught[0].category.__name__ if caught else '') emit('noninteger_code_kept', ','.join(sorted(wspec.to_dict()['paths']['/w']['get']['responses']))) with warnings.catch_warnings(record=True) as caught2: warnings.simplefilter('always') dspec2 = APISpec(title='w', version='1', openapi_version='2.0') dspec2.path(path='/d', operations={'get': {'responses': {'default': {'description': 'x'}}}}) emit('default_code_not_warned', 'yes' if len(caught2) == 0 else 'no') # ====================================================================== # LAZY COMPONENTS -- registered only if something refers to them. # ====================================================================== lspec = APISpec(title='l', version='1', openapi_version='3.0.2') lspec.components.schema('Used', {'type': 'string'}, lazy=True) lspec.components.schema('Unused', {'type': 'string'}, lazy=True) lspec.path(path='/l', operations={'get': {'responses': { 200: {'description': 'ok', 'content': {'application/json': {'schema': 'Used'}}}}}}) ld = lspec.to_dict() emit('lazy_registered', ','.join(sorted(ld['components']['schemas']))) emit('lazy_unused_absent', 'yes' if 'Unused' not in ld['components']['schemas'] else 'no') # AN EMPTY SPEC HAS NO `components' KEY AT ALL at 3.x -- `to_dict' omits it # rather than emitting an empty dict, which matters to a strict validator. empty = APISpec(title='e', version='1', openapi_version='3.0.2').to_dict() emit('empty_doc', j(empty)) emit('empty_has_components', 'yes' if 'components' in empty else 'no') emit('empty_v2_doc', j(APISpec(title='e', version='1', openapi_version='2.0').to_dict())) # `to_dict()' IS RECOMPUTED, NOT CACHED: a path added after a first call shows # up in the second. inc = APISpec(title='i', version='1', openapi_version='3.0.2') first = inc.to_dict() inc.path(path='/late', operations={'get': {'responses': {200: {'description': 'ok'}}}}) emit('to_dict_recomputed', 'yes' if (first['paths'] == {} or True) and inc.to_dict()['paths'] != {} else 'no') emit('to_dict_not_cached', ','.join(sorted(inc.to_dict()['paths']))) # CALLING `path()' TWICE ON THE SAME PATH MERGES the operations rather than # replacing them -- `setdefault(...).update(...)'. mspec = APISpec(title='m', version='1', openapi_version='3.0.2') mspec.path(path='/m', operations={'get': {'responses': {200: {'description': 'ok'}}}}) mspec.path(path='/m', operations={'post': {'responses': {201: {'description': 'made'}}}}) emit('path_twice_merges', ','.join(sorted(mspec.to_dict()['paths']['/m']))) # THE FLUENT API RETURNS THE OBJECT ITSELF, which is what makes the chaining in # `build()' above legal. cspec = APISpec(title='c', version='1', openapi_version='3.0.2') emit('tag_returns_self', 'yes' if cspec.tag({'name': 't'}) is cspec else 'no') emit('path_returns_self', 'yes' if cspec.path(path='/c', operations={'get': {'responses': {200: {'description': 'ok'}}}}) is cspec else 'no') emit('schema_returns_components', 'yes' if cspec.components.schema('C', {'type': 'object'}) is cspec.components else 'no') # THE CALLER'S DICT IS NOT MUTATED. `path()' deepcopies its operations, so the # int response codes the caller passed are still ints in the caller's own dict # after the spec has stringified its copy. ops = {'get': {'responses': {200: {'description': 'ok'}}}} APISpec(title='n', version='1', openapi_version='3.0.2').path(path='/n', operations=ops) emit('caller_ops_unmutated', ','.join(sorted(str(k) + ':' + type(k).__name__ for k in ops['get']['responses']))) # ...and the same for a component definition, which `schema()' also deepcopies. mine = {'type': 'object', 'properties': {'a': {'type': 'string'}}} sspec = APISpec(title='s', version='1', openapi_version='3.0.2') sspec.components.schema('Mine', mine) emit('caller_schema_unmutated', j(mine)) emit('emitted_count', len(emitted)) print('probe_done=yes')