From 0060f83e46eb3679ea94020726b80c2e898c1739 Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 16:55:20 +1100 Subject: [PATCH 1/9] add support for type arrays and null in type arrays --- open_alchemy/helpers/peek.py | 994 ------------------ .../helpers/test_peek/test_peek_open_api.py | 83 +- 2 files changed, 77 insertions(+), 1000 deletions(-) delete mode 100644 open_alchemy/helpers/peek.py diff --git a/open_alchemy/helpers/peek.py b/open_alchemy/helpers/peek.py deleted file mode 100644 index bf2fed95..00000000 --- a/open_alchemy/helpers/peek.py +++ /dev/null @@ -1,994 +0,0 @@ -"""Assemble the final schema and return its type.""" - -import typing - -from open_alchemy import exceptions -from open_alchemy import types -from open_alchemy.facades import jsonschema - -from . import ext_prop as ext_prop_helper -from . import ref as ref_helper - - -class PeekValue(types.Protocol): - """Defines interface for peek functions.""" - - def __call__(self, *, schema: types.Schema, schemas: types.Schemas) -> typing.Any: - """Call signature for peek functions.""" - ... - - -def type_(*, schema: types.Schema, schemas: types.Schemas) -> str: - """ - Get the type of the schema. - - Raises TypeMissingError if the final schema does not have a type or the value is - not a string. - - Args: - schema: The schema for which to get the type. - schemas: The schemas for $ref lookup. - - Returns: - The type of the schema. - - """ - value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.TYPE) - if value is None: - raise exceptions.TypeMissingError("Every property requires a type.") - if not isinstance(value, str): - raise exceptions.TypeMissingError( - "A type property value must be of type string." - ) - return value - - -def nullable(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: - """ - Retrieve the nullable property from a property schema. - - Raises MalformedSchemaError if the nullable value is not a boolean. - - Args: - schema: The schema to get the nullable from. - schemas: The schemas for $ref lookup. - - Returns: - The nullable value. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.OpenApiProperties.NULLABLE - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "A nullable value must be of type boolean." - ) - return value - - -def format_(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: - """ - Retrieve the format property from a property schema. - - Raises MalformedSchemaError if the format value is not a string. - - Args: - schema: The schema to get the format from. - schemas: The schemas for $ref lookup. - - Returns: - The format value or None if it was not found. - - """ - value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.FORMAT) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError("A format value must be of type string.") - return value - - -def autoincrement( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[bool]: - """ - Retrieve the x-autoincrement property from a property schema. - - Raises MalformedSchemaError if the x-autoincrement value is not a boolean. - - Args: - schema: The schema to get the x-autoincrement from. - schemas: The schemas for $ref lookup. - - Returns: - The x-autoincrement value. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.AUTOINCREMENT - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "A autoincrement value must be of type boolean." - ) - return value - - -def index(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: - """ - Retrieve the x-index property from a property schema. - - Raises MalformedSchemaError if the x-index value is not a boolean. - - Args: - schema: The schema to get the x-index from. - schemas: The schemas for $ref lookup. - - Returns: - The x-index value. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.INDEX - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError("A index value must be of type boolean.") - return value - - -def unique(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: - """ - Retrieve the x-unique property from a property schema. - - Raises MalformedSchemaError if the x-unique value is not a boolean. - - Args: - schema: The schema to get the x-unique from. - schemas: The schemas for $ref lookup. - - Returns: - The x-unique value. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.UNIQUE - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError("A unique value must be of type boolean.") - return value - - -def max_length(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[int]: - """ - Retrieve the maxLength property from a property schema. - - Raises MalformedSchemaError if the maxLength value is not an integer. - - Args: - schema: The schema to get the maxLength from. - schemas: The schemas for $ref lookup. - - Returns: - The maxLength value or None if it was not found. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.OpenApiProperties.MAX_LENGTH - ) - if value is None: - return None - if not isinstance(value, int) or isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "A maxLength value must be of type integer." - ) - return value - - -def read_only(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: - """ - Determine whether schema is readOnly. - - Raises MalformedSchemaError if the readOnly value is not a boolean. - - Args: - schema: The schema to get readOnly from. - schemas: The schemas for $ref lookup. - - Returns: - Whether the schema is readOnly. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.OpenApiProperties.READ_ONLY - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "A readOnly property must be of type boolean." - ) - return value - - -def write_only( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[bool]: - """ - Determine whether schema is writeOnly. - - Raises MalformedSchemaError if the writeOnly value is not a boolean. - - Args: - schema: The schema to get writeOnly from. - schemas: The schemas for $ref lookup. - - Returns: - Whether the schema is writeOnly. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.OpenApiProperties.WRITE_ONLY - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "A writeOnly property must be of type boolean." - ) - return value - - -def description( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[str]: - """ - Retrieve the description property from a property schema. - - Raises MalformedSchemaError if the description value is not a string. - - Args: - schema: The schema to get the description from. - schemas: The schemas for $ref lookup. - - Returns: - The description value or None if it was not found. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.OpenApiProperties.DESCRIPTION - ) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError( - "A description value must be of type string." - ) - return value - - -def primary_key( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[bool]: - """ - Determine whether property schema is for a primary key. - - Raises MalformedSchemaError if the x-primary-key value is not a boolean. - - Args: - schema: The schema to get x-primary-key from. - schemas: The schemas for $ref lookup. - - Returns: - Whether the schema is for a primary key property. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.PRIMARY_KEY - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "The x-primary-key property must be of type boolean." - ) - return value - - -def tablename(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: - """ - Retrieve the x-tablename of the schema. - - Raises MalformedSchemaError if the x-tablename value is not a string. - - Args: - schema: The schema to get x-tablename from. - schemas: The schemas for $ref lookup. - - Returns: - The x-tablename or None. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.TABLENAME - ) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError( - "The x-tablename property must be of type string." - ) - return value - - -def inherits( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[typing.Union[str, bool]]: - """ - Retrieve the value of the x-inherits extension property of the schema. - - Raises MalformedSchemaError if the value is not a string nor a boolean. - - Args: - schema: The schema to get x-inherits from. - schemas: The schemas for $ref lookup. - - Returns: - The inherits or None. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.INHERITS - ) - if value is None: - return None - if not isinstance(value, (str, bool)): - raise exceptions.MalformedSchemaError( - "The x-inherits property must be of type string or boolean." - ) - return value - - -def json(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: - """ - Retrieve the value of the x-json extension property of the schema. - - Raises MalformedSchemaError if the value is not a boolean. - - Args: - schema: The schema to get x-json from. - schemas: The schemas for $ref lookup. - - Returns: - The x-json value or None if the schema does not have the key. - - """ - value = peek_key(schema=schema, schemas=schemas, key=types.ExtensionProperties.JSON) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "The x-json property must be of type boolean." - ) - return value - - -def backref(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: - """ - Retrieve the x-backref of the schema. - - Raises MalformedSchemaError if the x-backref value is not a string. - - Args: - schema: The schema to get x-backref from. - schemas: The schemas for $ref lookup. - - Returns: - The x-backref or None. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.BACKREF - ) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError( - "The x-backref property must be of type string." - ) - return value - - -def secondary(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: - """ - Retrieve the x-secondary of the schema. - - Raises MalformedSchemaError if the x-secondary value is not a string. - - Args: - schema: The schema to get x-secondary from. - schemas: The schemas for $ref lookup. - - Returns: - The x-secondary or None. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.SECONDARY - ) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError( - "The x-secondary property must be of type string." - ) - return value - - -def uselist(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: - """ - Retrieve the x-uselist of the schema. - - Raises MalformedSchemaError if the x-uselist value is not a boolean. - - Args: - schema: The schema to get x-uselist from. - schemas: The schemas for $ref lookup. - - Returns: - The x-uselist or None. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.USELIST - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "The x-uselist property must be of type boolean." - ) - return value - - -def items(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[dict]: - """ - Retrieve the items of the schema. - - Raises MalformedSchemaError if the items value is not a dictionary. - - Args: - schema: The schema to get items from. - schemas: The schemas for $ref lookup. - - Returns: - The items or None. - - """ - value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.ITEMS) - if value is None: - return None - if not isinstance(value, dict): - raise exceptions.MalformedSchemaError( - "The items property must be of type dict." - ) - return value - - -def _check_kwargs(*, value: typing.Any, key: str) -> typing.Dict[str, typing.Any]: - """Check the kwargs value.""" - # Check value - if not isinstance(value, dict): - raise exceptions.MalformedSchemaError( - f"The {key} property must be of type dict." - ) - # Check keys - not_str_keys = filter(lambda key: not isinstance(key, str), value.keys()) - if next(not_str_keys, None) is not None: - raise exceptions.MalformedSchemaError( - f"The {key} property must have string keys." - ) - return value - - -def kwargs( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[typing.Dict[str, typing.Any]]: - """ - Retrieve the x-kwargs of the schema. - - Raises MalformedSchemaError if the x-kwargs value is not a dictionary. - - Args: - schema: The schema to get x-kwargs from. - schemas: The schemas for $ref lookup. - - Returns: - The x-kwargs or None. - - """ - key = types.ExtensionProperties.KWARGS - value = peek_key(schema=schema, schemas=schemas, key=key) - if value is None: - return None - # Check value - return _check_kwargs(value=value, key=key) - - -def foreign_key_kwargs( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[dict]: - """ - Retrieve the x-foreign-key-kwargs of the schema. - - Raises MalformedSchemaError if the x-foreign-key-kwargs value is not a dictionary. - - Args: - schema: The schema to get x-foreign-key-kwargs from. - schemas: The schemas for $ref lookup. - - Returns: - The x-foreign-key-kwargs or None. - - """ - key = types.ExtensionProperties.FOREIGN_KEY_KWARGS - value = peek_key(schema=schema, schemas=schemas, key=key) - if value is None: - return None - # Check value - return _check_kwargs(value=value, key=key) - - -def ref(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: - """ - Retrieve the $ref of the schema. - - Raises MalformedSchemaError if the $ref value is not a dictionary. - - Args: - schema: The schema to get $ref from. - schemas: The schemas for $ref lookup. - - Returns: - The $ref or None. - - """ - value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.REF) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError( - "The $ref property must be of type string." - ) - return value - - -def foreign_key( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[str]: - """ - Retrieve the x-foreign-key of the schema. - - Raises MalformedSchemaError if the x-foreign-key value is not a string. - - Args: - schema: The schema to get x-foreign-key from. - schemas: The schemas for $ref lookup. - - Returns: - The x-foreign-key or None. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.FOREIGN_KEY - ) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError( - "The x-foreign-key property must be of type string." - ) - return value - - -def foreign_key_column( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[str]: - """ - Retrieve the x-foreign-key-column of the schema. - - Raises MalformedSchemaError if the x-foreign-key-column value is not a string. - - Args: - schema: The schema to get x-foreign-key-column from. - schemas: The schemas for $ref lookup. - - Returns: - The x-foreign-key-column or None. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.FOREIGN_KEY_COLUMN - ) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError( - "The x-foreign-key-column property must be of type string." - ) - return value - - -def composite_index( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[typing.List]: - """ - Retrieve the x-composite-index of the schema. - - Args: - schema: The schema to get x-composite-index from. - schemas: The schemas for $ref lookup. - - Returns: - The x-composite-index or None. - - """ - key = types.ExtensionProperties.COMPOSITE_INDEX - value = peek_key(schema=schema, schemas=schemas, key=key) - if value is None: - return None - # Check value - ext_prop_helper.get(source={key: value}, name=key) # type: ignore - return value - - -def composite_unique( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[typing.List]: - """ - Retrieve the x-composite-unique of the schema. - - Args: - schema: The schema to get x-composite-unique from. - schemas: The schemas for $ref lookup. - - Returns: - The x-composite-unique or None. - - """ - key = types.ExtensionProperties.COMPOSITE_UNIQUE - value = peek_key(schema=schema, schemas=schemas, key=key) - if value is None: - return None - # Check value - ext_prop_helper.get(source={key: value}, name=key) # type: ignore - return value - - -def default(*, schema: types.Schema, schemas: types.Schemas) -> types.TColumnDefault: - """ - Retrieve the default value and check it against the schema. - - Raises MalformedSchemaError if the default value does not conform with the schema. - - Args: - schema: The schema to retrieve the default value from. - - Returns: - The default or None. - - """ - # Retrieve value - value = peek_key( - schema=schema, schemas=schemas, key=types.OpenApiProperties.DEFAULT - ) - if value is None: - return None - # Assemble schema - resolved_schema: types.ColumnSchema = { - types.OpenApiProperties.TYPE.value: type_(schema=schema, schemas=schemas) - } - format_value = format_(schema=schema, schemas=schemas) - max_length_value = max_length(schema=schema, schemas=schemas) - if format_value is not None: - resolved_schema[types.OpenApiProperties.FORMAT.value] = format_value - if max_length_value is not None: - resolved_schema[types.OpenApiProperties.MAX_LENGTH.value] = max_length_value - try: - jsonschema.validate(value, resolved_schema) - except jsonschema.ValidationError as exc: - raise exceptions.MalformedSchemaError( - "The default value does not conform to the schema. " - f"The value is: {repr(value)}" - ) from exc - return value - - -def server_default( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[str]: - """ - Retrieve the x-server-default property from a property schema. - - Raises MalformedSchemaError if the x-server-default value is not a string. - - Args: - schema: The schema to get the x-server-default from. - schemas: The schemas for $ref lookup. - - Returns: - The x-server-default value. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.SERVER_DEFAULT - ) - if value is None: - return None - if not isinstance(value, str): - raise exceptions.MalformedSchemaError( - "A x-server-default value must be of type string." - ) - return value - - -def mixins( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[typing.List[str]]: - """ - Retrieve the x-mixins of the schema. - - Args: - schema: The schema to get x-mixins from. - schemas: The schemas for $ref lookup. - - Returns: - The x-mixins or None. - - """ - key = types.ExtensionProperties.MIXINS - value = peek_key(schema=schema, schemas=schemas, key=key) - if value is None: - return None - - # Check value - ext_prop_helper.get(source={key: value}, name=key) # type: ignore - - # Transform string to list - if isinstance(value, str): - value = [value] - - # Check that each value is a valid dot-separated identifier - def valid(mixin_value: str) -> bool: - """Check whether a mixin value is valid.""" - components = mixin_value.split(".") - if len(components) < 2: - return False - - invalid_components = map( - lambda component: not component.isidentifier(), components - ) - return not any(invalid_components) - - values_valid = map(lambda mixin_value: (mixin_value, valid(mixin_value)), value) - first_invalid_value = next(filter(lambda args: not args[1], values_valid), None) - if first_invalid_value is not None: - raise exceptions.MalformedExtensionPropertyError( - f'mixin values must be a valid import path, "{first_invalid_value[0]}" is ' - "not" - ) - - return value - - -def dict_ignore( - *, schema: types.Schema, schemas: types.Schemas -) -> typing.Optional[bool]: - """ - Retrieve the x-dict-ignore property from a property schema. - - Raises MalformedSchemaError if the x-dict-ignore value is not a boolean. - - Args: - schema: The schema to get the x-dict-ignore from. - schemas: The schemas for $ref lookup. - - Returns: - The x-dict-ignore value. - - """ - value = peek_key( - schema=schema, schemas=schemas, key=types.ExtensionProperties.DICT_IGNORE - ) - if value is None: - return None - if not isinstance(value, bool): - raise exceptions.MalformedSchemaError( - "A x-dict-ignore value must be of type boolean." - ) - return value - - -def peek_key( - *, - schema: types.Schema, - schemas: types.Schemas, - key: str, - skip_ref: typing.Optional[str] = None, -) -> typing.Any: - """ - Recursive type lookup. - - Raise MalformedSchemaError of a $ref value is seen again. - - Args: - schema: The schema to look up the key in. - schemas: All the schemas to resolve any $ref. - key: The key to check for. - seen_refs: All the $ref that have already been seen. - skip_ref: The name of a reference to not follow. - - Returns: - The key value (if found) or None. - - """ - return _peek_key(schema, schemas, key, set(), skip_ref=skip_ref) - - -def _check_schema_schemas_dict(schema: types.Schema, schemas: types.Schemas) -> None: - """Check that schema and schemas are dict.""" - # Check schema and schemas are dict - if not isinstance(schema, dict): - raise exceptions.MalformedSchemaError("The schema must be a dictionary.") - if not isinstance(schemas, dict): - raise exceptions.MalformedSchemaError("The schemas must be a dictionary.") - - -def _check_ref_string(ref_value: typing.Any) -> str: - """Check that value of $ref is string.""" - if not isinstance(ref_value, str): - raise exceptions.MalformedSchemaError("The value of $ref must be a string.") - return ref_value - - -def _check_circular_ref(ref_value: str, seen_refs: typing.Set[str]) -> None: - """Check whether ref has ever been seen.""" - if ref_value in seen_refs: - raise exceptions.MalformedSchemaError("Circular reference detected.") - seen_refs.add(ref_value) - - -def _check_all_of_list(all_of: typing.Any) -> list: - """Check that value of allOf is a list.""" - if not isinstance(all_of, list): - raise exceptions.MalformedSchemaError("The value of allOf must be a list.") - return all_of - - -def _check_sub_schema_dict(sub_schema: typing.Any) -> dict: - """Check that a sub schema in an allOf is a dict.""" - if not isinstance(sub_schema, dict): - raise exceptions.MalformedSchemaError( - "The elements of allOf must be dictionaries." - ) - return sub_schema - - -def _peek_key( - schema: types.Schema, - schemas: types.Schemas, - key: str, - seen_refs: typing.Set[str], - skip_ref: typing.Optional[str], -) -> typing.Any: - """Execute peek_key.""" - _check_schema_schemas_dict(schema, schemas) - - # Base case, look for type key - keys = ( - [key.replace("x-", prefix) for prefix in types.KeyPrefixes] - if key.startswith("x-") - else [key] - ) - value = next(filter(lambda value: value is not None, map(schema.get, keys)), None) - if value is not None: - return value - - # Recursive case, look for $ref - ref_value = schema.get(types.OpenApiProperties.REF) - if ref_value is not None: - ref_value_str = _check_ref_string(ref_value) - _check_circular_ref(ref_value_str, seen_refs) - - ref_name, ref_schema = ref_helper.get_ref(ref=ref_value_str, schemas=schemas) - if skip_ref is not None and ref_name == skip_ref: - return None - return _peek_key(ref_schema, schemas, key, seen_refs, skip_ref) - - # Recursive case, look for allOf - all_of = schema.get("allOf") - if all_of is not None: - all_of_list = _check_all_of_list(all_of) - for sub_schema in all_of_list: - sub_schema_dict = _check_sub_schema_dict(sub_schema) - value = _peek_key(sub_schema_dict, schemas, key, seen_refs, skip_ref) - if value is not None: - return value - - # Base case, type or ref not found or no type in allOf - return None - - -def prefer_local( - *, get_value: PeekValue, schema: types.Schema, schemas: types.Schemas -) -> typing.Any: - """ - Retrieve the value using a function preferably without having to follow a $ref. - - 1. Check for allOf: - if found, iterate over schemas in allOf and skip any that contain $ref and - return the value returned by get_value if it is not None. - 2. Return output of get_value called on the schema. - - Args: - get_value: The function that knows how to retrieve the value. - schema: The schema to process. - schemas: All the schemas. - - Returns: - The value returned by get_value preferably without following any $ref. - - """ - return _prefer_local(get_value, schema, schemas, set()) - - -def _prefer_local( - get_value: PeekValue, - schema: types.Schema, - schemas: types.Schemas, - seen_refs: typing.Set[str], -) -> typing.Any: - """Execute prefer_local.""" - _check_schema_schemas_dict(schema, schemas) - - # Handle $ref - ref_value = schema.get(types.OpenApiProperties.REF) - if ref_value is not None: - ref_value_str = _check_ref_string(ref_value) - _check_circular_ref(ref_value_str, seen_refs) - - _, ref_schema = ref_helper.get_ref(ref=ref_value_str, schemas=schemas) - return _prefer_local(get_value, ref_schema, schemas, seen_refs) - - # Handle allOf - all_of = schema.get("allOf") - if all_of is not None: - all_of_list = _check_all_of_list(all_of) - all_of_list_dict = map(_check_sub_schema_dict, all_of_list) - # Order putting any $ref last - sorted_all_of = sorted( - all_of_list_dict, - key=lambda sub_schema: sub_schema.get(types.OpenApiProperties.REF) - is not None, - ) - - def map_to_value(sub_schema: types.Schema) -> typing.Any: - """Use get_value to turn the schema into the value.""" - return _prefer_local(get_value, sub_schema, schemas, seen_refs) - - retrieved_values = map(map_to_value, sorted_all_of) - not_none_retrieved_values = filter( - lambda value: value is not None, retrieved_values - ) - retrieved_value = next(not_none_retrieved_values, None) - return retrieved_value - - return get_value(schema=schema, schemas=schemas) diff --git a/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py b/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py index 3e397acf..6c693fe3 100644 --- a/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py +++ b/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py @@ -7,23 +7,46 @@ @pytest.mark.parametrize( - "schema, schemas", - [({}, {}), ({"type": True}, {})], - ids=["plain", "not string value"], + "schema", + [ + {}, + {"type": True}, + {"type": ["type 1", "type 2"]}, + {"type": ["type 1", "type 2", "null"]}, + ], + ids=[ + "plain", + "not string value", + "multiple types not null", + "multiples types with null", + ], ) @pytest.mark.helper -def test_type_no_type(schema, schemas): +def test_type_invalid(schema): """ - GIVEN schema without a type + GIVEN schema with an invalid type WHEN type_ is called with the schema THEN TypeMissingError is raised. """ with pytest.raises(exceptions.TypeMissingError): - peek.type_(schema=schema, schemas=schemas) + peek.type_(schema=schema, schemas={}) VALID_TESTS = [ pytest.param([("type", "type 1")], peek.type_, "type 1", id="type"), + pytest.param([("type", ["type 1"])], peek.type_, "type 1", id="type openapi 3.1"), + pytest.param( + [("type", ["type 1", "null"])], + peek.type_, + "type 1", + id="type openapi 3.1 with null last", + ), + pytest.param( + [("type", ["null", "type 1"])], + peek.type_, + "type 1", + id="type openapi 3.1 with null first", + ), pytest.param([], peek.nullable, None, id="nullable missing"), pytest.param([("nullable", True)], peek.nullable, True, id="nullable defined"), pytest.param( @@ -32,6 +55,54 @@ def test_type_no_type(schema, schemas): False, id="nullable defined different", ), + pytest.param( + [("type", [])], + peek.nullable, + False, + id="nullable openapi 3.1 not null", + ), + pytest.param( + [("type", ["null"])], + peek.nullable, + True, + id="nullable openapi 3.1 null", + ), + pytest.param( + [("type", ["type 1", "null"])], + peek.nullable, + True, + id="nullable openapi 3.1 null with type first", + ), + pytest.param( + [("type", ["null", "type 1"])], + peek.nullable, + True, + id="nullable openapi 3.1 null with type last", + ), + pytest.param( + [("type", []), ("nullable", False)], + peek.nullable, + False, + id="nullable openapi 3.1 false and 3.0 false", + ), + pytest.param( + [("type", ["null"]), ("nullable", False)], + peek.nullable, + True, + id="nullable openapi 3.1 true and 3.0 false", + ), + pytest.param( + [("type", []), ("nullable", True)], + peek.nullable, + True, + id="nullable openapi 3.1 false and 3.0 true", + ), + pytest.param( + [("type", ["null"]), ("nullable", True)], + peek.nullable, + True, + id="nullable openapi 3.1 true and 3.0 true", + ), pytest.param([], peek.format_, None, id="format missing"), pytest.param( [("format", "format 1")], From d662eeed53def4b6f41ff8d6e94354dbdaea158f Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 17:35:21 +1100 Subject: [PATCH 2/9] add examples for nullable --- .../nullable/openapi-3-0/example-spec.yml | 52 + examples/nullable/openapi-3-0/models.py | 3 + examples/nullable/openapi-3-0/models_auto.py | 127 +++ .../openapi-3-0/models_traditional.py | 14 + .../nullable/openapi-3-1/example-spec.yml | 51 + examples/nullable/openapi-3-1/models.py | 3 + examples/nullable/openapi-3-1/models_auto.py | 127 +++ .../openapi-3-1/models_traditional.py | 14 + open_alchemy/helpers/peek/__init__.py | 894 ++++++++++++++++++ open_alchemy/helpers/peek/helpers.py | 144 +++ tests/examples/test_example_specs.py | 14 + .../helpers/test_peek/test_peek_open_api.py | 23 +- .../property_/test_one_to_many.py | 2 +- .../relationship/property_/test_x_to_one.py | 3 +- .../validation/property_/test_simple.py | 3 +- tests/test_examples | 8 + 16 files changed, 1469 insertions(+), 13 deletions(-) create mode 100644 examples/nullable/openapi-3-0/example-spec.yml create mode 100644 examples/nullable/openapi-3-0/models.py create mode 100644 examples/nullable/openapi-3-0/models_auto.py create mode 100644 examples/nullable/openapi-3-0/models_traditional.py create mode 100644 examples/nullable/openapi-3-1/example-spec.yml create mode 100644 examples/nullable/openapi-3-1/models.py create mode 100644 examples/nullable/openapi-3-1/models_auto.py create mode 100644 examples/nullable/openapi-3-1/models_traditional.py create mode 100644 open_alchemy/helpers/peek/__init__.py create mode 100644 open_alchemy/helpers/peek/helpers.py diff --git a/examples/nullable/openapi-3-0/example-spec.yml b/examples/nullable/openapi-3-0/example-spec.yml new file mode 100644 index 00000000..2154b689 --- /dev/null +++ b/examples/nullable/openapi-3-0/example-spec.yml @@ -0,0 +1,52 @@ +openapi: "3.0.0" + +info: + title: Test Schema + description: API to illustrate nullable. + version: "0.1" + +paths: + /employee: + get: + summary: Used to retrieve all employees. + responses: + 200: + description: Return all employees from the database. + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/Employee" + +components: + schemas: + Employee: + description: Person that works for a company. + type: object + x-tablename: employee + properties: + id: + type: integer + description: Unique identifier for the employee. + example: 0 + x-primary-key: true + x-autoincrement: true + name: + type: string + description: The name of the employee. + example: David Andersson + x-index: true + nullable: true + division: + type: string + description: The part of the company the employee works in. + example: Engineering + x-index: true + salary: + type: number + description: The amount of money the employee is paid. + example: 1000000.00 + required: + - name + - division diff --git a/examples/nullable/openapi-3-0/models.py b/examples/nullable/openapi-3-0/models.py new file mode 100644 index 00000000..e4dd867e --- /dev/null +++ b/examples/nullable/openapi-3-0/models.py @@ -0,0 +1,3 @@ +from open_alchemy import init_yaml + +init_yaml("example-spec.yml", models_filename="models_auto.py") diff --git a/examples/nullable/openapi-3-0/models_auto.py b/examples/nullable/openapi-3-0/models_auto.py new file mode 100644 index 00000000..a2c4114f --- /dev/null +++ b/examples/nullable/openapi-3-0/models_auto.py @@ -0,0 +1,127 @@ +"""Autogenerated SQLAlchemy models based on OpenAlchemy models.""" +# pylint: disable=no-member,super-init-not-called,unused-argument + +import typing + +import sqlalchemy +from sqlalchemy import orm + +from open_alchemy import models + +Base = models.Base # type: ignore + + +class _EmployeeDictBase(typing.TypedDict, total=True): + """TypedDict for properties that are required.""" + + name: str + division: str + + +class EmployeeDict(_EmployeeDictBase, total=False): + """TypedDict for properties that are not required.""" + + id: int + salary: typing.Optional[float] + + +class TEmployee(typing.Protocol): + """ + SQLAlchemy model protocol. + + Person that works for a company. + + Attrs: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + """ + + # SQLAlchemy properties + __table__: sqlalchemy.Table + __tablename__: str + query: orm.Query + + # Model properties + id: "sqlalchemy.Column[int]" + name: "sqlalchemy.Column[str]" + division: "sqlalchemy.Column[str]" + salary: "sqlalchemy.Column[typing.Optional[float]]" + + def __init__( + self, + name: str, + division: str, + id: typing.Optional[int] = None, + salary: typing.Optional[float] = None, + ) -> None: + """ + Construct. + + Args: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + """ + ... + + @classmethod + def from_dict( + cls, + name: str, + division: str, + id: typing.Optional[int] = None, + salary: typing.Optional[float] = None, + ) -> "TEmployee": + """ + Construct from a dictionary (eg. a POST payload). + + Args: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + Returns: + Model instance based on the dictionary. + + """ + ... + + @classmethod + def from_str(cls, value: str) -> "TEmployee": + """ + Construct from a JSON string (eg. a POST payload). + + Returns: + Model instance based on the JSON string. + + """ + ... + + def to_dict(self) -> EmployeeDict: + """ + Convert to a dictionary (eg. to send back for a GET request). + + Returns: + Dictionary based on the model instance. + + """ + ... + + def to_str(self) -> str: + """ + Convert to a JSON string (eg. to send back for a GET request). + + Returns: + JSON string based on the model instance. + + """ + ... + + +Employee: typing.Type[TEmployee] = models.Employee # type: ignore diff --git a/examples/nullable/openapi-3-0/models_traditional.py b/examples/nullable/openapi-3-0/models_traditional.py new file mode 100644 index 00000000..f78a4954 --- /dev/null +++ b/examples/nullable/openapi-3-0/models_traditional.py @@ -0,0 +1,14 @@ +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class Employee(Base): + """Person that works for a company.""" + + __tablename__ = "employee" + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + name = sa.Column(sa.String, index=True, nullable=True) + division = sa.Column(sa.String, index=True) + salary = sa.Column(sa.Float) diff --git a/examples/nullable/openapi-3-1/example-spec.yml b/examples/nullable/openapi-3-1/example-spec.yml new file mode 100644 index 00000000..9252794c --- /dev/null +++ b/examples/nullable/openapi-3-1/example-spec.yml @@ -0,0 +1,51 @@ +openapi: "3.0.0" + +info: + title: Test Schema + description: API to illustrate nullable. + version: "0.1" + +paths: + /employee: + get: + summary: Used to retrieve all employees. + responses: + 200: + description: Return all employees from the database. + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/Employee" + +components: + schemas: + Employee: + description: Person that works for a company. + type: object + x-tablename: employee + properties: + id: + type: integer + description: Unique identifier for the employee. + example: 0 + x-primary-key: true + x-autoincrement: true + name: + type: [string, "null"] + description: The name of the employee. + example: David Andersson + x-index: true + division: + type: string + description: The part of the company the employee works in. + example: Engineering + x-index: true + salary: + type: number + description: The amount of money the employee is paid. + example: 1000000.00 + required: + - name + - division diff --git a/examples/nullable/openapi-3-1/models.py b/examples/nullable/openapi-3-1/models.py new file mode 100644 index 00000000..e4dd867e --- /dev/null +++ b/examples/nullable/openapi-3-1/models.py @@ -0,0 +1,3 @@ +from open_alchemy import init_yaml + +init_yaml("example-spec.yml", models_filename="models_auto.py") diff --git a/examples/nullable/openapi-3-1/models_auto.py b/examples/nullable/openapi-3-1/models_auto.py new file mode 100644 index 00000000..a2c4114f --- /dev/null +++ b/examples/nullable/openapi-3-1/models_auto.py @@ -0,0 +1,127 @@ +"""Autogenerated SQLAlchemy models based on OpenAlchemy models.""" +# pylint: disable=no-member,super-init-not-called,unused-argument + +import typing + +import sqlalchemy +from sqlalchemy import orm + +from open_alchemy import models + +Base = models.Base # type: ignore + + +class _EmployeeDictBase(typing.TypedDict, total=True): + """TypedDict for properties that are required.""" + + name: str + division: str + + +class EmployeeDict(_EmployeeDictBase, total=False): + """TypedDict for properties that are not required.""" + + id: int + salary: typing.Optional[float] + + +class TEmployee(typing.Protocol): + """ + SQLAlchemy model protocol. + + Person that works for a company. + + Attrs: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + """ + + # SQLAlchemy properties + __table__: sqlalchemy.Table + __tablename__: str + query: orm.Query + + # Model properties + id: "sqlalchemy.Column[int]" + name: "sqlalchemy.Column[str]" + division: "sqlalchemy.Column[str]" + salary: "sqlalchemy.Column[typing.Optional[float]]" + + def __init__( + self, + name: str, + division: str, + id: typing.Optional[int] = None, + salary: typing.Optional[float] = None, + ) -> None: + """ + Construct. + + Args: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + """ + ... + + @classmethod + def from_dict( + cls, + name: str, + division: str, + id: typing.Optional[int] = None, + salary: typing.Optional[float] = None, + ) -> "TEmployee": + """ + Construct from a dictionary (eg. a POST payload). + + Args: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + Returns: + Model instance based on the dictionary. + + """ + ... + + @classmethod + def from_str(cls, value: str) -> "TEmployee": + """ + Construct from a JSON string (eg. a POST payload). + + Returns: + Model instance based on the JSON string. + + """ + ... + + def to_dict(self) -> EmployeeDict: + """ + Convert to a dictionary (eg. to send back for a GET request). + + Returns: + Dictionary based on the model instance. + + """ + ... + + def to_str(self) -> str: + """ + Convert to a JSON string (eg. to send back for a GET request). + + Returns: + JSON string based on the model instance. + + """ + ... + + +Employee: typing.Type[TEmployee] = models.Employee # type: ignore diff --git a/examples/nullable/openapi-3-1/models_traditional.py b/examples/nullable/openapi-3-1/models_traditional.py new file mode 100644 index 00000000..f78a4954 --- /dev/null +++ b/examples/nullable/openapi-3-1/models_traditional.py @@ -0,0 +1,14 @@ +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class Employee(Base): + """Person that works for a company.""" + + __tablename__ = "employee" + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + name = sa.Column(sa.String, index=True, nullable=True) + division = sa.Column(sa.String, index=True) + salary = sa.Column(sa.Float) diff --git a/open_alchemy/helpers/peek/__init__.py b/open_alchemy/helpers/peek/__init__.py new file mode 100644 index 00000000..de8e1fb2 --- /dev/null +++ b/open_alchemy/helpers/peek/__init__.py @@ -0,0 +1,894 @@ +"""Assemble the final schema and return its type.""" + +import typing + +from open_alchemy import exceptions +from open_alchemy import types +from open_alchemy.facades import jsonschema + +from .. import ext_prop as ext_prop_helper +from . import helpers + +PeekValue = helpers.PeekValue + + +def type_(*, schema: types.Schema, schemas: types.Schemas) -> str: + """ + Get the type of the schema. + + Raises TypeMissingError if the final schema does not have a type or the value is + not a string or list of string or has multiple non-null types. + + Args: + schema: The schema for which to get the type. + schemas: The schemas for $ref lookup. + + Returns: + The type of the schema. + + """ + value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.TYPE) + if value is None: + raise exceptions.TypeMissingError("Every property requires a type.") + + if isinstance(value, str): + return value + + if isinstance(value, list): + # ignore null + type_values = filter(lambda item: item != "null", value) + + try: + item_value = next(type_values) + except StopIteration as exc: + raise exceptions.TypeMissingError( + "An array type property must have at least 1 element that is not " + "'null'." + ) from exc + + try: + next(type_values) + raise exceptions.TypeMissingError( + "An array type property must have at most 1 element that is not " + "'null'." + ) + except StopIteration: + pass + + if isinstance(item_value, str): + return item_value + + raise exceptions.TypeMissingError( + "A type property value must be of type string or list of string." + ) + + +def nullable(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: + """ + Retrieve the nullable property from a property schema or null from the type array. + + Raises MalformedSchemaError if the nullable value is not a boolean. + + Args: + schema: The schema to get the nullable from. + schemas: The schemas for $ref lookup. + + Returns: + The nullable value or whether 'null' is in the type array. + + """ + nullable_value = peek_key( + schema=schema, schemas=schemas, key=types.OpenApiProperties.NULLABLE + ) + if nullable_value is not None and not isinstance(nullable_value, bool): + raise exceptions.MalformedSchemaError( + "A nullable value must be of type boolean." + ) + + type_value = peek_key( + schema=schema, schemas=schemas, key=types.OpenApiProperties.TYPE + ) + + if nullable_value is None and not isinstance(type_value, list): + return None + + return nullable_value is True or ( + isinstance(type_value, list) and "null" in type_value + ) + + +def format_(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: + """ + Retrieve the format property from a property schema. + + Raises MalformedSchemaError if the format value is not a string. + + Args: + schema: The schema to get the format from. + schemas: The schemas for $ref lookup. + + Returns: + The format value or None if it was not found. + + """ + value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.FORMAT) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError("A format value must be of type string.") + return value + + +def autoincrement( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[bool]: + """ + Retrieve the x-autoincrement property from a property schema. + + Raises MalformedSchemaError if the x-autoincrement value is not a boolean. + + Args: + schema: The schema to get the x-autoincrement from. + schemas: The schemas for $ref lookup. + + Returns: + The x-autoincrement value. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.AUTOINCREMENT + ) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError( + "A autoincrement value must be of type boolean." + ) + return value + + +def index(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: + """ + Retrieve the x-index property from a property schema. + + Raises MalformedSchemaError if the x-index value is not a boolean. + + Args: + schema: The schema to get the x-index from. + schemas: The schemas for $ref lookup. + + Returns: + The x-index value. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.INDEX + ) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError("A index value must be of type boolean.") + return value + + +def unique(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: + """ + Retrieve the x-unique property from a property schema. + + Raises MalformedSchemaError if the x-unique value is not a boolean. + + Args: + schema: The schema to get the x-unique from. + schemas: The schemas for $ref lookup. + + Returns: + The x-unique value. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.UNIQUE + ) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError("A unique value must be of type boolean.") + return value + + +def max_length(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[int]: + """ + Retrieve the maxLength property from a property schema. + + Raises MalformedSchemaError if the maxLength value is not an integer. + + Args: + schema: The schema to get the maxLength from. + schemas: The schemas for $ref lookup. + + Returns: + The maxLength value or None if it was not found. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.OpenApiProperties.MAX_LENGTH + ) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool): + raise exceptions.MalformedSchemaError( + "A maxLength value must be of type integer." + ) + return value + + +def read_only(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: + """ + Determine whether schema is readOnly. + + Raises MalformedSchemaError if the readOnly value is not a boolean. + + Args: + schema: The schema to get readOnly from. + schemas: The schemas for $ref lookup. + + Returns: + Whether the schema is readOnly. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.OpenApiProperties.READ_ONLY + ) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError( + "A readOnly property must be of type boolean." + ) + return value + + +def write_only( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[bool]: + """ + Determine whether schema is writeOnly. + + Raises MalformedSchemaError if the writeOnly value is not a boolean. + + Args: + schema: The schema to get writeOnly from. + schemas: The schemas for $ref lookup. + + Returns: + Whether the schema is writeOnly. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.OpenApiProperties.WRITE_ONLY + ) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError( + "A writeOnly property must be of type boolean." + ) + return value + + +def description( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[str]: + """ + Retrieve the description property from a property schema. + + Raises MalformedSchemaError if the description value is not a string. + + Args: + schema: The schema to get the description from. + schemas: The schemas for $ref lookup. + + Returns: + The description value or None if it was not found. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.OpenApiProperties.DESCRIPTION + ) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError( + "A description value must be of type string." + ) + return value + + +def primary_key( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[bool]: + """ + Determine whether property schema is for a primary key. + + Raises MalformedSchemaError if the x-primary-key value is not a boolean. + + Args: + schema: The schema to get x-primary-key from. + schemas: The schemas for $ref lookup. + + Returns: + Whether the schema is for a primary key property. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.PRIMARY_KEY + ) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError( + "The x-primary-key property must be of type boolean." + ) + return value + + +def tablename(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: + """ + Retrieve the x-tablename of the schema. + + Raises MalformedSchemaError if the x-tablename value is not a string. + + Args: + schema: The schema to get x-tablename from. + schemas: The schemas for $ref lookup. + + Returns: + The x-tablename or None. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.TABLENAME + ) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError( + "The x-tablename property must be of type string." + ) + return value + + +def inherits( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[typing.Union[str, bool]]: + """ + Retrieve the value of the x-inherits extension property of the schema. + + Raises MalformedSchemaError if the value is not a string nor a boolean. + + Args: + schema: The schema to get x-inherits from. + schemas: The schemas for $ref lookup. + + Returns: + The inherits or None. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.INHERITS + ) + if value is None: + return None + if not isinstance(value, (str, bool)): + raise exceptions.MalformedSchemaError( + "The x-inherits property must be of type string or boolean." + ) + return value + + +def json(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: + """ + Retrieve the value of the x-json extension property of the schema. + + Raises MalformedSchemaError if the value is not a boolean. + + Args: + schema: The schema to get x-json from. + schemas: The schemas for $ref lookup. + + Returns: + The x-json value or None if the schema does not have the key. + + """ + value = peek_key(schema=schema, schemas=schemas, key=types.ExtensionProperties.JSON) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError( + "The x-json property must be of type boolean." + ) + return value + + +def backref(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: + """ + Retrieve the x-backref of the schema. + + Raises MalformedSchemaError if the x-backref value is not a string. + + Args: + schema: The schema to get x-backref from. + schemas: The schemas for $ref lookup. + + Returns: + The x-backref or None. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.BACKREF + ) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError( + "The x-backref property must be of type string." + ) + return value + + +def secondary(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: + """ + Retrieve the x-secondary of the schema. + + Raises MalformedSchemaError if the x-secondary value is not a string. + + Args: + schema: The schema to get x-secondary from. + schemas: The schemas for $ref lookup. + + Returns: + The x-secondary or None. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.SECONDARY + ) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError( + "The x-secondary property must be of type string." + ) + return value + + +def uselist(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: + """ + Retrieve the x-uselist of the schema. + + Raises MalformedSchemaError if the x-uselist value is not a boolean. + + Args: + schema: The schema to get x-uselist from. + schemas: The schemas for $ref lookup. + + Returns: + The x-uselist or None. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.USELIST + ) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError( + "The x-uselist property must be of type boolean." + ) + return value + + +def items(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[dict]: + """ + Retrieve the items of the schema. + + Raises MalformedSchemaError if the items value is not a dictionary. + + Args: + schema: The schema to get items from. + schemas: The schemas for $ref lookup. + + Returns: + The items or None. + + """ + value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.ITEMS) + if value is None: + return None + if not isinstance(value, dict): + raise exceptions.MalformedSchemaError( + "The items property must be of type dict." + ) + return value + + +def _check_kwargs(*, value: typing.Any, key: str) -> typing.Dict[str, typing.Any]: + """Check the kwargs value.""" + # Check value + if not isinstance(value, dict): + raise exceptions.MalformedSchemaError( + f"The {key} property must be of type dict." + ) + # Check keys + not_str_keys = filter(lambda key: not isinstance(key, str), value.keys()) + if next(not_str_keys, None) is not None: + raise exceptions.MalformedSchemaError( + f"The {key} property must have string keys." + ) + return value + + +def kwargs( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[typing.Dict[str, typing.Any]]: + """ + Retrieve the x-kwargs of the schema. + + Raises MalformedSchemaError if the x-kwargs value is not a dictionary. + + Args: + schema: The schema to get x-kwargs from. + schemas: The schemas for $ref lookup. + + Returns: + The x-kwargs or None. + + """ + key = types.ExtensionProperties.KWARGS + value = peek_key(schema=schema, schemas=schemas, key=key) + if value is None: + return None + # Check value + return _check_kwargs(value=value, key=key) + + +def foreign_key_kwargs( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[dict]: + """ + Retrieve the x-foreign-key-kwargs of the schema. + + Raises MalformedSchemaError if the x-foreign-key-kwargs value is not a dictionary. + + Args: + schema: The schema to get x-foreign-key-kwargs from. + schemas: The schemas for $ref lookup. + + Returns: + The x-foreign-key-kwargs or None. + + """ + key = types.ExtensionProperties.FOREIGN_KEY_KWARGS + value = peek_key(schema=schema, schemas=schemas, key=key) + if value is None: + return None + # Check value + return _check_kwargs(value=value, key=key) + + +def ref(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: + """ + Retrieve the $ref of the schema. + + Raises MalformedSchemaError if the $ref value is not a dictionary. + + Args: + schema: The schema to get $ref from. + schemas: The schemas for $ref lookup. + + Returns: + The $ref or None. + + """ + value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.REF) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError( + "The $ref property must be of type string." + ) + return value + + +def foreign_key( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[str]: + """ + Retrieve the x-foreign-key of the schema. + + Raises MalformedSchemaError if the x-foreign-key value is not a string. + + Args: + schema: The schema to get x-foreign-key from. + schemas: The schemas for $ref lookup. + + Returns: + The x-foreign-key or None. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.FOREIGN_KEY + ) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError( + "The x-foreign-key property must be of type string." + ) + return value + + +def foreign_key_column( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[str]: + """ + Retrieve the x-foreign-key-column of the schema. + + Raises MalformedSchemaError if the x-foreign-key-column value is not a string. + + Args: + schema: The schema to get x-foreign-key-column from. + schemas: The schemas for $ref lookup. + + Returns: + The x-foreign-key-column or None. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.FOREIGN_KEY_COLUMN + ) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError( + "The x-foreign-key-column property must be of type string." + ) + return value + + +def composite_index( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[typing.List]: + """ + Retrieve the x-composite-index of the schema. + + Args: + schema: The schema to get x-composite-index from. + schemas: The schemas for $ref lookup. + + Returns: + The x-composite-index or None. + + """ + key = types.ExtensionProperties.COMPOSITE_INDEX + value = peek_key(schema=schema, schemas=schemas, key=key) + if value is None: + return None + # Check value + ext_prop_helper.get(source={key: value}, name=key) # type: ignore + return value + + +def composite_unique( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[typing.List]: + """ + Retrieve the x-composite-unique of the schema. + + Args: + schema: The schema to get x-composite-unique from. + schemas: The schemas for $ref lookup. + + Returns: + The x-composite-unique or None. + + """ + key = types.ExtensionProperties.COMPOSITE_UNIQUE + value = peek_key(schema=schema, schemas=schemas, key=key) + if value is None: + return None + # Check value + ext_prop_helper.get(source={key: value}, name=key) # type: ignore + return value + + +def default(*, schema: types.Schema, schemas: types.Schemas) -> types.TColumnDefault: + """ + Retrieve the default value and check it against the schema. + + Raises MalformedSchemaError if the default value does not conform with the schema. + + Args: + schema: The schema to retrieve the default value from. + + Returns: + The default or None. + + """ + # Retrieve value + value = peek_key( + schema=schema, schemas=schemas, key=types.OpenApiProperties.DEFAULT + ) + if value is None: + return None + # Assemble schema + resolved_schema: types.ColumnSchema = { + types.OpenApiProperties.TYPE.value: type_(schema=schema, schemas=schemas) + } + format_value = format_(schema=schema, schemas=schemas) + max_length_value = max_length(schema=schema, schemas=schemas) + if format_value is not None: + resolved_schema[types.OpenApiProperties.FORMAT.value] = format_value + if max_length_value is not None: + resolved_schema[types.OpenApiProperties.MAX_LENGTH.value] = max_length_value + try: + jsonschema.validate(value, resolved_schema) + except jsonschema.ValidationError as exc: + raise exceptions.MalformedSchemaError( + "The default value does not conform to the schema. " + f"The value is: {repr(value)}" + ) from exc + return value + + +def server_default( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[str]: + """ + Retrieve the x-server-default property from a property schema. + + Raises MalformedSchemaError if the x-server-default value is not a string. + + Args: + schema: The schema to get the x-server-default from. + schemas: The schemas for $ref lookup. + + Returns: + The x-server-default value. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.SERVER_DEFAULT + ) + if value is None: + return None + if not isinstance(value, str): + raise exceptions.MalformedSchemaError( + "A x-server-default value must be of type string." + ) + return value + + +def mixins( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[typing.List[str]]: + """ + Retrieve the x-mixins of the schema. + + Args: + schema: The schema to get x-mixins from. + schemas: The schemas for $ref lookup. + + Returns: + The x-mixins or None. + + """ + key = types.ExtensionProperties.MIXINS + value = peek_key(schema=schema, schemas=schemas, key=key) + if value is None: + return None + + # Check value + ext_prop_helper.get(source={key: value}, name=key) # type: ignore + + # Transform string to list + if isinstance(value, str): + value = [value] + + # Check that each value is a valid dot-separated identifier + def valid(mixin_value: str) -> bool: + """Check whether a mixin value is valid.""" + components = mixin_value.split(".") + if len(components) < 2: + return False + + invalid_components = map( + lambda component: not component.isidentifier(), components + ) + return not any(invalid_components) + + values_valid = map(lambda mixin_value: (mixin_value, valid(mixin_value)), value) + first_invalid_value = next(filter(lambda args: not args[1], values_valid), None) + if first_invalid_value is not None: + raise exceptions.MalformedExtensionPropertyError( + f'mixin values must be a valid import path, "{first_invalid_value[0]}" is ' + "not" + ) + + return value + + +def dict_ignore( + *, schema: types.Schema, schemas: types.Schemas +) -> typing.Optional[bool]: + """ + Retrieve the x-dict-ignore property from a property schema. + + Raises MalformedSchemaError if the x-dict-ignore value is not a boolean. + + Args: + schema: The schema to get the x-dict-ignore from. + schemas: The schemas for $ref lookup. + + Returns: + The x-dict-ignore value. + + """ + value = peek_key( + schema=schema, schemas=schemas, key=types.ExtensionProperties.DICT_IGNORE + ) + if value is None: + return None + if not isinstance(value, bool): + raise exceptions.MalformedSchemaError( + "A x-dict-ignore value must be of type boolean." + ) + return value + + +def peek_key( + *, + schema: types.Schema, + schemas: types.Schemas, + key: str, + skip_ref: typing.Optional[str] = None, +) -> typing.Any: + """ + Recursive type lookup. + + Raise MalformedSchemaError of a $ref value is seen again. + + Args: + schema: The schema to look up the key in. + schemas: All the schemas to resolve any $ref. + key: The key to check for. + seen_refs: All the $ref that have already been seen. + skip_ref: The name of a reference to not follow. + + Returns: + The key value (if found) or None. + + """ + return helpers.peek_key(schema, schemas, key, set(), skip_ref=skip_ref) + + +def prefer_local( + *, get_value: PeekValue, schema: types.Schema, schemas: types.Schemas +) -> typing.Any: + """ + Retrieve the value using a function preferably without having to follow a $ref. + + 1. Check for allOf: + if found, iterate over schemas in allOf and skip any that contain $ref and + return the value returned by get_value if it is not None. + 2. Return output of get_value called on the schema. + + Args: + get_value: The function that knows how to retrieve the value. + schema: The schema to process. + schemas: All the schemas. + + Returns: + The value returned by get_value preferably without following any $ref. + + """ + return helpers.prefer_local(get_value, schema, schemas, set()) diff --git a/open_alchemy/helpers/peek/helpers.py b/open_alchemy/helpers/peek/helpers.py new file mode 100644 index 00000000..dc27c66a --- /dev/null +++ b/open_alchemy/helpers/peek/helpers.py @@ -0,0 +1,144 @@ +"""Helpers for the peek functions.""" + +import typing + +from open_alchemy import exceptions +from open_alchemy import types + +from .. import ref as ref_helper + + +class PeekValue(types.Protocol): + """Defines interface for peek functions.""" + + def __call__(self, *, schema: types.Schema, schemas: types.Schemas) -> typing.Any: + """Call signature for peek functions.""" + ... + + +def check_schema_schemas_dict(schema: types.Schema, schemas: types.Schemas) -> None: + """Check that schema and schemas are dict.""" + # Check schema and schemas are dict + if not isinstance(schema, dict): + raise exceptions.MalformedSchemaError("The schema must be a dictionary.") + if not isinstance(schemas, dict): + raise exceptions.MalformedSchemaError("The schemas must be a dictionary.") + + +def check_ref_string(ref_value: typing.Any) -> str: + """Check that value of $ref is string.""" + if not isinstance(ref_value, str): + raise exceptions.MalformedSchemaError("The value of $ref must be a string.") + return ref_value + + +def check_circular_ref(ref_value: str, seen_refs: typing.Set[str]) -> None: + """Check whether ref has ever been seen.""" + if ref_value in seen_refs: + raise exceptions.MalformedSchemaError("Circular reference detected.") + seen_refs.add(ref_value) + + +def check_all_of_list(all_of: typing.Any) -> list: + """Check that value of allOf is a list.""" + if not isinstance(all_of, list): + raise exceptions.MalformedSchemaError("The value of allOf must be a list.") + return all_of + + +def check_sub_schema_dict(sub_schema: typing.Any) -> dict: + """Check that a sub schema in an allOf is a dict.""" + if not isinstance(sub_schema, dict): + raise exceptions.MalformedSchemaError( + "The elements of allOf must be dictionaries." + ) + return sub_schema + + +def peek_key( + schema: types.Schema, + schemas: types.Schemas, + key: str, + seen_refs: typing.Set[str], + skip_ref: typing.Optional[str], +) -> typing.Any: + """Execute peek_key.""" + check_schema_schemas_dict(schema, schemas) + + # Base case, look for type key + keys = ( + [key.replace("x-", prefix) for prefix in types.KeyPrefixes] + if key.startswith("x-") + else [key] + ) + value = next(filter(lambda value: value is not None, map(schema.get, keys)), None) + if value is not None: + return value + + # Recursive case, look for $ref + ref_value = schema.get(types.OpenApiProperties.REF) + if ref_value is not None: + ref_value_str = check_ref_string(ref_value) + check_circular_ref(ref_value_str, seen_refs) + + ref_name, ref_schema = ref_helper.get_ref(ref=ref_value_str, schemas=schemas) + if skip_ref is not None and ref_name == skip_ref: + return None + return peek_key(ref_schema, schemas, key, seen_refs, skip_ref) + + # Recursive case, look for allOf + all_of = schema.get("allOf") + if all_of is not None: + all_of_list = check_all_of_list(all_of) + for sub_schema in all_of_list: + sub_schema_dict = check_sub_schema_dict(sub_schema) + value = peek_key(sub_schema_dict, schemas, key, seen_refs, skip_ref) + if value is not None: + return value + + # Base case, type or ref not found or no type in allOf + return None + + +def prefer_local( + get_value: PeekValue, + schema: types.Schema, + schemas: types.Schemas, + seen_refs: typing.Set[str], +) -> typing.Any: + """Execute prefer_local.""" + check_schema_schemas_dict(schema, schemas) + + # Handle $ref + ref_value = schema.get(types.OpenApiProperties.REF) + if ref_value is not None: + ref_value_str = check_ref_string(ref_value) + check_circular_ref(ref_value_str, seen_refs) + + _, ref_schema = ref_helper.get_ref(ref=ref_value_str, schemas=schemas) + return prefer_local(get_value, ref_schema, schemas, seen_refs) + + # Handle allOf + all_of = schema.get("allOf") + if all_of is not None: + all_of_list = check_all_of_list(all_of) + all_of_list_dict = map(check_sub_schema_dict, all_of_list) + # Order putting any $ref last + sorted_all_of = sorted( + all_of_list_dict, + key=lambda sub_schema: sub_schema.get(types.OpenApiProperties.REF) + is not None, + ) + + def map_to_value(sub_schema: types.Schema) -> typing.Any: + """Use get_value to turn the schema into the value.""" + return prefer_local(get_value, sub_schema, schemas, seen_refs) + + retrieved_values = map(map_to_value, sorted_all_of) + not_none_retrieved_values = filter( + lambda value: value is not None, retrieved_values + ) + retrieved_value = next(not_none_retrieved_values, None) + return retrieved_value + + return get_value(schema=schema, schemas=schemas) diff --git a/tests/examples/test_example_specs.py b/tests/examples/test_example_specs.py index 703ab0b9..3a82060b 100644 --- a/tests/examples/test_example_specs.py +++ b/tests/examples/test_example_specs.py @@ -44,6 +44,20 @@ def cleanup_models(): {}, id="simple Employee all", ), + pytest.param( + "nullable/openapi-3-0/example-spec.yml", + "Employee", + {"division": "division 1"}, + {"name": None, "id": 1, "salary": None}, + id="nullable openapi 3.0 Employee required only", + ), + pytest.param( + "nullable/openapi-3-1/example-spec.yml", + "Employee", + {"division": "division 1"}, + {"name": None, "id": 1, "salary": None}, + id="nullable openapi 3.1 Employee required only", + ), pytest.param( "namespaced/example-spec.yml", "Employee", diff --git a/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py b/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py index 6c693fe3..ff7777ad 100644 --- a/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py +++ b/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py @@ -1,5 +1,7 @@ """Tests for peek helpers.""" +import copy + import pytest from open_alchemy import exceptions @@ -9,16 +11,15 @@ @pytest.mark.parametrize( "schema", [ - {}, - {"type": True}, - {"type": ["type 1", "type 2"]}, - {"type": ["type 1", "type 2", "null"]}, - ], - ids=[ - "plain", - "not string value", - "multiple types not null", - "multiples types with null", + pytest.param({}, id="plain"), + pytest.param({"type": True}, id="not string value"), + pytest.param({"type": [True]}, id="array not string"), + pytest.param({"type": ["null", True]}, id="array with null first not string"), + pytest.param({"type": [True, "null"]}, id="array with null second not string"), + pytest.param({"type": ["type 1", "type 2"]}, id="multiple types not null"), + pytest.param( + {"type": ["type 1", "type 2", "null"]}, id="multiples types with null" + ), ], ) @pytest.mark.helper @@ -191,10 +192,12 @@ def test_key_value(key_values, func, expected_value): THEN expected value is returned. """ schema = dict(key_values) + original_schema = copy.deepcopy(schema) returned_type = func(schema=schema, schemas={}) assert returned_type == expected_value + assert schema == original_schema INVALID_TESTS = [ diff --git a/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_one_to_many.py b/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_one_to_many.py index 7161b54e..a97093f4 100644 --- a/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_one_to_many.py +++ b/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_one_to_many.py @@ -26,7 +26,7 @@ ( False, "items property :: malformed schema :: A type property " - "value must be of type string. ", + "value must be of type string or list of string. ", ), id="array items type not string", ), diff --git a/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_x_to_one.py b/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_x_to_one.py index 28137500..cd8ac089 100644 --- a/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_x_to_one.py +++ b/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_x_to_one.py @@ -25,7 +25,8 @@ {}, ( False, - "malformed schema :: A type property value must be of type string. ", + "malformed schema :: A type property value must be of type string or list " + "of string. ", ), id="type not a string", ), diff --git a/tests/open_alchemy/schemas/validation/property_/test_simple.py b/tests/open_alchemy/schemas/validation/property_/test_simple.py index 3cdf1a5d..ef7d9ae3 100644 --- a/tests/open_alchemy/schemas/validation/property_/test_simple.py +++ b/tests/open_alchemy/schemas/validation/property_/test_simple.py @@ -19,7 +19,8 @@ {}, ( False, - "malformed schema :: A type property value must be of type string. ", + "malformed schema :: A type property value must be of type string or list " + "of string. ", ), id="type not a string", ), diff --git a/tests/test_examples b/tests/test_examples index 0a516f3b..81f9c4a5 100755 --- a/tests/test_examples +++ b/tests/test_examples @@ -8,6 +8,14 @@ cd examples/simple python models.py python models_traditional.py cd - +cd examples/nullable/openapi-3-0 +python models.py +python models_traditional.py +cd - +cd examples/nullable/openapi-3-1 +python models.py +python models_traditional.py +cd - cd examples/namespaced python models.py python models_traditional.py From 2962266c62629a6aaf78b4dee006b0a501385082 Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 17:37:14 +1100 Subject: [PATCH 3/9] add auto generated models --- examples/nullable/openapi-3-0/models_auto.py | 8 ++++---- examples/nullable/openapi-3-1/example-spec.yml | 2 +- examples/nullable/openapi-3-1/models_auto.py | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/nullable/openapi-3-0/models_auto.py b/examples/nullable/openapi-3-0/models_auto.py index a2c4114f..5d1f3d87 100644 --- a/examples/nullable/openapi-3-0/models_auto.py +++ b/examples/nullable/openapi-3-0/models_auto.py @@ -14,7 +14,7 @@ class _EmployeeDictBase(typing.TypedDict, total=True): """TypedDict for properties that are required.""" - name: str + name: typing.Optional[str] division: str @@ -46,13 +46,13 @@ class TEmployee(typing.Protocol): # Model properties id: "sqlalchemy.Column[int]" - name: "sqlalchemy.Column[str]" + name: "sqlalchemy.Column[typing.Optional[str]]" division: "sqlalchemy.Column[str]" salary: "sqlalchemy.Column[typing.Optional[float]]" def __init__( self, - name: str, + name: typing.Optional[str], division: str, id: typing.Optional[int] = None, salary: typing.Optional[float] = None, @@ -72,7 +72,7 @@ def __init__( @classmethod def from_dict( cls, - name: str, + name: typing.Optional[str], division: str, id: typing.Optional[int] = None, salary: typing.Optional[float] = None, diff --git a/examples/nullable/openapi-3-1/example-spec.yml b/examples/nullable/openapi-3-1/example-spec.yml index 9252794c..af3de72a 100644 --- a/examples/nullable/openapi-3-1/example-spec.yml +++ b/examples/nullable/openapi-3-1/example-spec.yml @@ -1,4 +1,4 @@ -openapi: "3.0.0" +openapi: "3.1.0" info: title: Test Schema diff --git a/examples/nullable/openapi-3-1/models_auto.py b/examples/nullable/openapi-3-1/models_auto.py index a2c4114f..5d1f3d87 100644 --- a/examples/nullable/openapi-3-1/models_auto.py +++ b/examples/nullable/openapi-3-1/models_auto.py @@ -14,7 +14,7 @@ class _EmployeeDictBase(typing.TypedDict, total=True): """TypedDict for properties that are required.""" - name: str + name: typing.Optional[str] division: str @@ -46,13 +46,13 @@ class TEmployee(typing.Protocol): # Model properties id: "sqlalchemy.Column[int]" - name: "sqlalchemy.Column[str]" + name: "sqlalchemy.Column[typing.Optional[str]]" division: "sqlalchemy.Column[str]" salary: "sqlalchemy.Column[typing.Optional[float]]" def __init__( self, - name: str, + name: typing.Optional[str], division: str, id: typing.Optional[int] = None, salary: typing.Optional[float] = None, @@ -72,7 +72,7 @@ def __init__( @classmethod def from_dict( cls, - name: str, + name: typing.Optional[str], division: str, id: typing.Optional[int] = None, salary: typing.Optional[float] = None, From 11f5e9fb5c2ec7eb1e461275d058a53a21f0cae3 Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 17:40:13 +1100 Subject: [PATCH 4/9] update changelog --- CHANGELOG.md | 5 +++++ README.md | 2 ++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c96d879a..9499bb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add support for OpenAPI 3.1. [#276] + ## [v2.2.0] - 2021-01-23 ### Fixed @@ -505,3 +509,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#236]: https://github.com/jdkandersson/OpenAlchemy/issues/236 [#251]: https://github.com/jdkandersson/OpenAlchemy/issues/251 [#255]: https://github.com/jdkandersson/OpenAlchemy/issues/255 +[#276]: https://github.com/jdkandersson/OpenAlchemy/issues/276 diff --git a/README.md b/README.md index cd0aa5d3..b56a020d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Translates an OpenAPI schema to SQLAlchemy models. +Supports OpenAPI 3.0 and 3.1. + Get started with the online editor that will guide you through using your existing OpenAPI specification to define your database schema and offers installing your models using `pip`: From 579e19f70b5ddffc9a8d8027aafb74c450bdb9bf Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 17:51:43 +1100 Subject: [PATCH 5/9] update docs --- docs/source/examples/alembic.rst | 3 ++ docs/source/examples/all_of.rst | 3 ++ docs/source/examples/composite_index.rst | 3 ++ docs/source/examples/composite_unique.rst | 3 ++ docs/source/examples/connexion.rst | 3 ++ docs/source/examples/default.rst | 3 ++ docs/source/examples/index.rst | 1 + docs/source/examples/inheritance.rst | 3 ++ docs/source/examples/json.rst | 3 ++ docs/source/examples/mixins.rst | 3 ++ docs/source/examples/namespaced.rst | 3 ++ docs/source/examples/nullable.rst | 39 +++++++++++++++++++ docs/source/examples/read_only.rst | 3 ++ docs/source/examples/ref.rst | 3 ++ docs/source/examples/server_default.rst | 3 ++ docs/source/examples/write_only.rst | 3 ++ docs/source/technical_details/null.rst | 9 +++-- .../source/technical_details/type_mapping.rst | 3 ++ 18 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 docs/source/examples/nullable.rst diff --git a/docs/source/examples/alembic.rst b/docs/source/examples/alembic.rst index 624f3127..a5efe56c 100644 --- a/docs/source/examples/alembic.rst +++ b/docs/source/examples/alembic.rst @@ -12,3 +12,6 @@ models which means that they work with Alembic. `Alembic documentation `_ Documentation for Alembic. + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/all_of.rst b/docs/source/examples/all_of.rst index dad95fbd..ef295c84 100644 --- a/docs/source/examples/all_of.rst +++ b/docs/source/examples/all_of.rst @@ -66,3 +66,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/all_of/model_models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/composite_index.rst b/docs/source/examples/composite_index.rst index 71770a42..5d67decc 100644 --- a/docs/source/examples/composite_index.rst +++ b/docs/source/examples/composite_index.rst @@ -36,3 +36,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/composite_index/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/composite_unique.rst b/docs/source/examples/composite_unique.rst index 07233172..4bf2c488 100644 --- a/docs/source/examples/composite_unique.rst +++ b/docs/source/examples/composite_unique.rst @@ -36,3 +36,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/composite_unique/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/connexion.rst b/docs/source/examples/connexion.rst index 9ff9f278..b2bfb257 100644 --- a/docs/source/examples/connexion.rst +++ b/docs/source/examples/connexion.rst @@ -67,3 +67,6 @@ The duplication of the data schema has been reduced by defining the SQLAlchemy models based on the OpenAPI specification. This means that, to change the database schema, the OpenAPI specification has to be updated and vice-versa. This ensures that the two are always in synch and up to date. + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/default.rst b/docs/source/examples/default.rst index cadb702d..29885c01 100644 --- a/docs/source/examples/default.rst +++ b/docs/source/examples/default.rst @@ -37,3 +37,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/default/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 6683c592..d59eddc8 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -8,6 +8,7 @@ Examples connexion alembic simple + nullable namespaced default server_default diff --git a/docs/source/examples/inheritance.rst b/docs/source/examples/inheritance.rst index 9428b414..81effd83 100644 --- a/docs/source/examples/inheritance.rst +++ b/docs/source/examples/inheritance.rst @@ -88,3 +88,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/inheritance/single_models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/json.rst b/docs/source/examples/json.rst index 5d6d26c3..6f7aa07c 100644 --- a/docs/source/examples/json.rst +++ b/docs/source/examples/json.rst @@ -37,3 +37,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/json/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/mixins.rst b/docs/source/examples/mixins.rst index 218886f1..91c2ff97 100644 --- a/docs/source/examples/mixins.rst +++ b/docs/source/examples/mixins.rst @@ -35,3 +35,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/mixins/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/namespaced.rst b/docs/source/examples/namespaced.rst index b5bf8ad5..2e25d248 100644 --- a/docs/source/examples/namespaced.rst +++ b/docs/source/examples/namespaced.rst @@ -31,3 +31,6 @@ SQLAlchemy models: .. literalinclude:: ../../../examples/namespaced/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/nullable.rst b/docs/source/examples/nullable.rst new file mode 100644 index 00000000..a37dde97 --- /dev/null +++ b/docs/source/examples/nullable.rst @@ -0,0 +1,39 @@ +Nullable +======== + +A property can be set to be nullable using the :samp:`nullable` property for +OpenAPI 3.0: + +.. literalinclude:: ../../../examples/nullable/openapi-3-0/example-spec.yml + :language: yaml + :linenos: + +Or by including :samp:`null` in the :samp:`type` array for OpenAPI 3.1: + +.. literalinclude:: ../../../examples/nullable/openapi-3-1/example-spec.yml + :language: yaml + :linenos: + +The following example models file makes use of the OpenAPI specification to +define the SQLAlchemy models: + +.. literalinclude:: ../../../examples/nullable/openapi-3-1/models.py + :language: python + :linenos: + +This models file instructs OpenAlchemy to construct the SQLAlchemy models +equivalent to the following traditional SQLAlchemy models.py file: + +.. literalinclude:: ../../../examples/nullable/openapi-3-1/models_traditional.py + :language: python + :linenos: + +OpenAlchemy also generates a fully type hinted version of the generated +SQLAlchemy models: + +.. literalinclude:: ../../../examples/nullable/openapi-3-1/models_auto.py + :language: python + :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/read_only.rst b/docs/source/examples/read_only.rst index 6c631908..c0ad66bd 100644 --- a/docs/source/examples/read_only.rst +++ b/docs/source/examples/read_only.rst @@ -35,3 +35,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/read_only/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/ref.rst b/docs/source/examples/ref.rst index c09a014d..60ab519d 100644 --- a/docs/source/examples/ref.rst +++ b/docs/source/examples/ref.rst @@ -56,3 +56,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/ref/model_models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/server_default.rst b/docs/source/examples/server_default.rst index 85373bc3..f326978b 100644 --- a/docs/source/examples/server_default.rst +++ b/docs/source/examples/server_default.rst @@ -42,3 +42,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/server_default/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/write_only.rst b/docs/source/examples/write_only.rst index 3a4b9f90..9175f6f4 100644 --- a/docs/source/examples/write_only.rst +++ b/docs/source/examples/write_only.rst @@ -34,3 +34,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/write_only/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/technical_details/null.rst b/docs/source/technical_details/null.rst index 5e9b11a4..a32b41b1 100644 --- a/docs/source/technical_details/null.rst +++ b/docs/source/technical_details/null.rst @@ -8,10 +8,11 @@ There are 3 methods used to determine the value of :samp:`nullable` for a :samp:`SQLAlchemy` column. The first is the :samp:`required` property of the schema, the second is whether the column value is generated (using, for example, :samp:`x-autoincrement`) and the third is the :samp:`nullable` -property of an object property. :samp:`nullable` overrides :samp:`required`. -If :samp:`required` would indicate that the column is nullable but the value -is generated, then it is not nullable. The following truth table shows the -logic: +property of an object property or the presence of :samp:`null` if :samp:`type` +is an array (consider these to be equivalent for this discussion). +:samp:`nullable` overrides :samp:`required`. If :samp:`required` would indicate +that the column is nullable but the value is generated, then it is not +nullable. The following truth table shows the logic: +-------------+-----------+-------------------+-----------------+ | required | generated | property nullable | column nullable | diff --git a/docs/source/technical_details/type_mapping.rst b/docs/source/technical_details/type_mapping.rst index 8e4108b7..181b21bf 100644 --- a/docs/source/technical_details/type_mapping.rst +++ b/docs/source/technical_details/type_mapping.rst @@ -34,6 +34,9 @@ following mappings: | :samp:`boolean` | | :samp:`Boolean` | :samp:`bool` | +----------------------+------------------------+-------------------------+---------------------------+ +:samp:`type` as an array is supported, however, exactly one type (other than +:samp:`null`) is required. + String ------ From ab778fdd1bfb85e4d8db644e9f14ca3866418896 Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 17:57:58 +1100 Subject: [PATCH 6/9] add test for empty type array --- tests/open_alchemy/helpers/test_peek/test_peek_open_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py b/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py index ff7777ad..8836e986 100644 --- a/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py +++ b/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py @@ -13,6 +13,7 @@ [ pytest.param({}, id="plain"), pytest.param({"type": True}, id="not string value"), + pytest.param({"type": []}, id="array empty"), pytest.param({"type": [True]}, id="array not string"), pytest.param({"type": ["null", True]}, id="array with null first not string"), pytest.param({"type": [True, "null"]}, id="array with null second not string"), From cf09dcc89ca8f9658817005834fca6c425521314 Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 18:00:55 +1100 Subject: [PATCH 7/9] retry for python static and explude OpenAPI 3.1 from open api spac validation --- .github/workflows/code-quality.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 02ea7471..71fba368 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -86,7 +86,7 @@ jobs: poetry install - name: Run static code analyser run: | - ${{ matrix.command }} + ${{ matrix.command }} || (poetry install && ${{ matrix.command }}) staticNode: runs-on: ubuntu-latest @@ -94,7 +94,7 @@ jobs: matrix: command: - npx cspell "open_alchemy/**/*.py" "open_alchemy/**/*.json" "open_alchemy/**/*.j2" "docs/**/*.rst" "docs/**/*/yml" "docs/**/*.yaml" "tests/**/*.py" "*.yaml" "*.json" "*.yml" "examples/**/*.py" "examples/**/*.yaml" "examples/**/*.yml" - - find examples -name "*spec.yml" ! -path "*/remote/*" | xargs -n 1 sh -c 'npx swagger-cli validate $0 || exit 255' + - find examples -name "*spec.yml" ! -path "*/remote/*" ! -path "*/openapi-3-1/*" | xargs -n 1 sh -c 'npx swagger-cli validate $0 || exit 255' steps: - uses: actions/checkout@v2 - name: Set up Node From 7fabbf22a91834b07cb59c1153f0f81f6662995a Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 18:08:13 +1100 Subject: [PATCH 8/9] add retry for building docs --- .github/workflows/code-quality.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 71fba368..4fb3a819 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -155,7 +155,7 @@ jobs: - name: Build the documentation run: | cd docs - poetry run make html + poetry run make html || (poetry install && poetry run make html) - name: Upload documentation for release if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v2.2.2 From b2cb7c1749fa47080512c32a6c4e3ceff0689a67 Mon Sep 17 00:00:00 2001 From: David Andersson Date: Sun, 7 Mar 2021 18:16:04 +1100 Subject: [PATCH 9/9] fix automated release creation --- .github/workflows/code-quality.yaml | 20 +++++----- CHANGELOG.md | 58 ++++++++++++++--------------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 4fb3a819..e5efaaf3 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -271,21 +271,19 @@ jobs: shell: bash - uses: actions/checkout@v2 - name: Get latest Changelog Entry - id: changelog_entry + id: changelog_reader uses: mindsers/changelog-reader-action@v2 with: version: v${{ steps.tag_name.outputs.current_version }} path: ./CHANGELOG.md - - name: Retrieve packages - uses: actions/download-artifact@v2.0.8 - with: - name: wheel - path: dist/ - - name: Publish the release - uses: softprops/action-gh-release@v1 + - name: Create Release + id: create_release + uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - body: ${{ steps.changelog_entry.outputs.log_entry }} - files: | - dist/* + tag_name: ${{ steps.changelog_reader.outputs.version }} + release_name: Release ${{ steps.changelog_reader.outputs.version }} + body: ${{ steps.changelog_reader.outputs.changes }} + prerelease: ${{ steps.changelog_reader.outputs.status == 'prereleased' }} + draft: ${{ steps.changelog_reader.outputs.status == 'unreleased' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9499bb15..ea56ba87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for OpenAPI 3.1. [#276] -## [v2.2.0] - 2021-01-23 +## [2.2.0] - 2021-01-23 ### Fixed @@ -21,27 +21,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Caching validation results to speed up startup. [#251] -## [v2.1.0] - 2020-12-20 +## [2.1.0] - 2020-12-20 ### Added - Add support for namespaced `x-open-alchemy-` prefix on top of the shorter `x-` prefix for extension properties. [#236] -## [v2.0.2] - 2020-12-19 +## [2.0.2] - 2020-12-19 ### Changed - Changed from `setup.py` to poetry -## [v2.0.1] - 2020-12-08 +## [2.0.1] - 2020-12-08 ### Added - Add version, title and description (if defined) into the JSON OpenAPI specification stored with the package generated by the build module. -## [v2.0.0] - 2020-11-15 +## [2.0.0] - 2020-11-15 ### Added @@ -83,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `True`. _This means that a pure model reference (a schema with only the `$ref` key) can no longer be used to change the name of a model._ [#189] -## [v1.6.0] - 2020-10-10 +## [1.6.0] - 2020-10-10 ### Added @@ -98,7 +98,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 directory. - Drop support for Python 3.6 and add support for Python 3.9. [#198] -## [v1.5.4] - 2020-08-30 +## [1.5.4] - 2020-08-30 ### Changed @@ -110,20 +110,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correct `format` key to no longer have a trailing `_` for artifacts. -## [v1.5.2] - 2020-08-29 +## [1.5.2] - 2020-08-29 ### Changed - Expose function that collects artifacts for the models. - Expose function that collects artifacts for the model properties. -## [v1.5.1] - 2020-08-23 +## [1.5.1] - 2020-08-23 ### Added - Add support for arbitrary mix in classes. -## [v1.5.0] - 2020-08-22 +## [1.5.0] - 2020-08-22 ### Added @@ -139,13 +139,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Change schema validation to process properties even if the model is not valid. -## [v1.4.3] - 2020-08-16 +## [1.4.3] - 2020-08-16 ### Removed - Remove dependency on black -## [v1.4.2] - 2020-08-16 +## [1.4.2] - 2020-08-16 ### Fixed @@ -153,13 +153,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 exceptions - Add black dependency back in -## [v1.4.1] - 2020-08-09 +## [1.4.1] - 2020-08-09 ### Removed - Remove black dependency -## [v1.4.0] - 2020-08-09 +## [1.4.0] - 2020-08-09 ### Added @@ -484,21 +484,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.1.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.1.1 [1.2.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.2.0 [1.3.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.3.0 -[v1.4.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.0 -[v1.4.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.1 -[v1.4.2]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.2 -[v1.4.3]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.3 -[v1.5.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.0 -[v1.5.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.1 -[v1.5.2]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.2 -[v1.5.3]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.3 -[v1.5.4]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.4 -[v1.6.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.6.0 -[v2.0.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.0 -[v2.0.1]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.1 -[v2.0.2]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.2 -[v2.1.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.1.0 -[v2.2.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.2.0 +[1.4.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.0 +[1.4.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.1 +[1.4.2]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.2 +[1.4.3]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.3 +[1.5.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.0 +[1.5.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.1 +[1.5.2]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.2 +[1.5.3]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.3 +[1.5.4]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.4 +[1.6.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.6.0 +[2.0.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.0 +[2.0.1]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.1 +[2.0.2]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.2 +[2.1.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.1.0 +[2.2.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.2.0 [///]: # "Issue/PR links" [#189]: https://github.com/jdkandersson/OpenAlchemy/issues/189 [#190]: https://github.com/jdkandersson/OpenAlchemy/issues/190