From 1389166e765f4268ae8ed2e6b0e595518ccbb99d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Thu, 19 Feb 2026 12:39:40 +0100 Subject: [PATCH] feat: support schemas with additional properties --- .github/workflows/codegen.yaml | 2 +- .../builder/intermediate_representation.go | 2 + codegen/pkg/builder/transform.go | 39 +++++++---- codegen/pkg/builder/types.go | 64 ++++++++++++++----- codegen/templates/types.py.tmpl | 1 - sumup/members/types.py | 33 +++++++++- sumup/memberships/types.py | 4 +- sumup/merchants/types.py | 33 +++++++++- sumup/readers/resource.py | 2 +- sumup/readers/types.py | 37 +++++++++-- sumup/roles/types.py | 31 ++++++++- sumup/subaccounts/types.py | 29 +++++++++ tests/test_additional_properties.py | 24 +++++++ 13 files changed, 261 insertions(+), 40 deletions(-) create mode 100644 tests/test_additional_properties.py diff --git a/.github/workflows/codegen.yaml b/.github/workflows/codegen.yaml index a458657..08b6ce7 100644 --- a/.github/workflows/codegen.yaml +++ b/.github/workflows/codegen.yaml @@ -15,7 +15,7 @@ on: - ".github/workflows/codegen.yaml" env: - GOLANGCI_LINT_VERSION: v2.6.2 + GOLANGCI_LINT_VERSION: v2.9.0 permissions: contents: read diff --git a/codegen/pkg/builder/intermediate_representation.go b/codegen/pkg/builder/intermediate_representation.go index 358117f..c81da55 100644 --- a/codegen/pkg/builder/intermediate_representation.go +++ b/codegen/pkg/builder/intermediate_representation.go @@ -17,6 +17,8 @@ type ClassDeclaration struct { Fields []Property // Description holds the description of the type Description string + // AdditionalPropertiesType holds the value type for additional properties if enabled. + AdditionalPropertiesType string } type OneOfDeclaration struct { diff --git a/codegen/pkg/builder/transform.go b/codegen/pkg/builder/transform.go index 0e0c840..d248a1e 100644 --- a/codegen/pkg/builder/transform.go +++ b/codegen/pkg/builder/transform.go @@ -321,24 +321,38 @@ func (b *Builder) genSchema(sp *base.SchemaProxy, name string) (string, []Writab // createObject converts openapi schema into golang object. func (b *Builder) createObject(schema *base.Schema, name string) (Writable, []Writable) { - if (schema.Properties == nil || schema.Properties.Len() == 0) && - schema.AdditionalProperties != nil && - ((schema.AdditionalProperties.IsB() && schema.AdditionalProperties.B) || - (schema.AdditionalProperties.IsA())) { + hasProperties := schema.Properties != nil && schema.Properties.Len() > 0 + hasAdditionalProperties := schema.AdditionalProperties != nil && + ((schema.AdditionalProperties.IsB() && schema.AdditionalProperties.B) || schema.AdditionalProperties.IsA()) + + additionalPropertyType := "" + additionalTypes := []Writable{} + if hasAdditionalProperties { + additionalPropertyType = "typing.Any" + if schema.AdditionalProperties.IsA() { + var generatedType string + generatedType, additionalTypes = b.genSchema(schema.AdditionalProperties.A, name+"AdditionalProperty") + additionalPropertyType = generatedType + } + } + if !hasProperties && hasAdditionalProperties { return &TypeAlias{ Comment: schemaDoc(name, schema), Name: name, - Type: "dict[typing.Any, typing.Any]", - }, nil + Type: "dict[str, " + additionalPropertyType + "]", + }, additionalTypes } - fields, additionalTypes := b.createFields(schema.Properties, name, schema.Required) + fields, fieldTypes := b.createFields(schema.Properties, name, schema.Required) + additionalTypes = append(additionalTypes, fieldTypes...) + return &ClassDeclaration{ - Description: schemaDoc(name, schema), - Name: name, - Type: "class", - Fields: fields, + Description: schemaDoc(name, schema), + Name: name, + Type: "class", + Fields: fields, + AdditionalPropertiesType: additionalPropertyType, }, additionalTypes } @@ -346,6 +360,9 @@ func (b *Builder) createObject(schema *base.Schema, name string) (Writable, []Wr func (b *Builder) createFields(properties *orderedmap.Map[string, *base.SchemaProxy], name string, required []string) ([]Property, []Writable) { fields := []Property{} types := []Writable{} + if properties == nil { + return fields, types + } for property, schema := range properties.FromOldest() { typeName, moreTypes := b.genSchema(schema, name+strcase.ToCamel(property)) diff --git a/codegen/pkg/builder/types.go b/codegen/pkg/builder/types.go index 5d2fae6..633e8f3 100644 --- a/codegen/pkg/builder/types.go +++ b/codegen/pkg/builder/types.go @@ -43,6 +43,37 @@ func (c *ClassDeclaration) String() string { fmt.Fprint(buf, indent(1, ft.String())) } } + if c.AdditionalPropertiesType != "" { + fmt.Fprint(buf, "\n") + fmt.Fprint(buf, "\tmodel_config = pydantic.ConfigDict(extra=\"allow\")\n") + fmt.Fprint(buf, "\n") + fmt.Fprint(buf, "\t@pydantic.model_validator(mode=\"before\")\n") + fmt.Fprint(buf, "\t@classmethod\n") + fmt.Fprint(buf, "\tdef _merge_additional_properties(cls, values: typing.Any) -> typing.Any:\n") + fmt.Fprint(buf, "\t\tif not isinstance(values, dict):\n") + fmt.Fprint(buf, "\t\t\treturn values\n") + fmt.Fprint(buf, "\n") + fmt.Fprint(buf, "\t\tadditional = values.get(\"additional_properties\")\n") + fmt.Fprint(buf, "\t\tif not isinstance(additional, dict):\n") + fmt.Fprint(buf, "\t\t\treturn values\n") + fmt.Fprint(buf, "\n") + fmt.Fprint(buf, "\t\tmerged = dict(additional)\n") + fmt.Fprint(buf, "\t\tfor key, value in values.items():\n") + fmt.Fprint(buf, "\t\t\tif key != \"additional_properties\":\n") + fmt.Fprint(buf, "\t\t\t\tmerged[key] = value\n") + fmt.Fprint(buf, "\n") + fmt.Fprint(buf, "\t\treturn merged\n") + fmt.Fprint(buf, "\n") + fmt.Fprint(buf, "\t@property\n") + fmt.Fprintf(buf, "\tdef additional_properties(self) -> dict[str, %s]:\n", c.AdditionalPropertiesType) + fmt.Fprint(buf, "\t\tif self.model_extra is None:\n") + fmt.Fprint(buf, "\t\t\tobject.__setattr__(self, \"__pydantic_extra__\", {})\n") + fmt.Fprintf(buf, "\t\treturn typing.cast(dict[str, %s], self.model_extra)\n", c.AdditionalPropertiesType) + fmt.Fprint(buf, "\n") + fmt.Fprint(buf, "\t@additional_properties.setter\n") + fmt.Fprintf(buf, "\tdef additional_properties(self, value: dict[str, %s]) -> None:\n", c.AdditionalPropertiesType) + fmt.Fprint(buf, "\t\tobject.__setattr__(self, \"__pydantic_extra__\", dict(value))\n") + } fmt.Fprint(buf, "\n") return buf.String() } @@ -68,21 +99,7 @@ func (p *Property) String() string { name := p.Name alias := p.SerializedName - // TODO: extract into helper - if strings.HasPrefix(name, "+") { - name = strings.Replace(name, "+", "Plus", 1) - } - if strings.HasPrefix(name, "-") { - name = strings.Replace(name, "-", "Minus", 1) - } - if strings.HasPrefix(name, "@") { - name = strings.Replace(name, "@", "At", 1) - } - if strings.HasPrefix(name, "$") { - name = strings.Replace(name, "$", "", 1) - } - - fieldName := name + fieldName := pythonFieldName(name) useAlias := alias != "" && alias != fieldName @@ -122,3 +139,20 @@ func (e *EnumDeclaration[E]) String() string { func (e *EnumDeclaration[E]) TypeName() string { return e.Name } + +func pythonFieldName(name string) string { + if strings.HasPrefix(name, "+") { + name = strings.Replace(name, "+", "Plus", 1) + } + if strings.HasPrefix(name, "-") { + name = strings.Replace(name, "-", "Minus", 1) + } + if strings.HasPrefix(name, "@") { + name = strings.Replace(name, "@", "At", 1) + } + if strings.HasPrefix(name, "$") { + name = strings.Replace(name, "$", "", 1) + } + + return name +} diff --git a/codegen/templates/types.py.tmpl b/codegen/templates/types.py.tmpl index a52f0a9..131334c 100644 --- a/codegen/templates/types.py.tmpl +++ b/codegen/templates/types.py.tmpl @@ -1,5 +1,4 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. -from ._service import Service import datetime import typing import pydantic diff --git a/sumup/members/types.py b/sumup/members/types.py index 9e41a87..65629a5 100755 --- a/sumup/members/types.py +++ b/sumup/members/types.py @@ -85,13 +85,13 @@ class Invite(pydantic.BaseModel): MembershipStatus = typing.Literal["accepted", "disabled", "expired", "pending", "unknown"] -Metadata = dict[typing.Any, typing.Any] +Metadata = dict[str, typing.Any] """ Set of user-defined key-value pairs attached to the object. Partial updates are not supported. When updating, alwayssubmit whole metadata. Maximum of 64 parameters are allowed in the object. Max properties: 64 """ -Attributes = dict[typing.Any, typing.Any] +Attributes = dict[str, typing.Any] """ Object attributes that are modifiable only by SumUp applications. """ @@ -188,3 +188,32 @@ class Problem(pydantic.BaseModel): """ A short, human-readable summary of the problem type. """ + + model_config = pydantic.ConfigDict(extra="allow") + + @pydantic.model_validator(mode="before") + @classmethod + def _merge_additional_properties(cls, values: typing.Any) -> typing.Any: + if not isinstance(values, dict): + return values + + additional = values.get("additional_properties") + if not isinstance(additional, dict): + return values + + merged = dict(additional) + for key, value in values.items(): + if key != "additional_properties": + merged[key] = value + + return merged + + @property + def additional_properties(self) -> dict[str, typing.Any]: + if self.model_extra is None: + object.__setattr__(self, "__pydantic_extra__", {}) + return typing.cast(dict[str, typing.Any], self.model_extra) + + @additional_properties.setter + def additional_properties(self, value: dict[str, typing.Any]) -> None: + object.__setattr__(self, "__pydantic_extra__", dict(value)) diff --git a/sumup/memberships/types.py b/sumup/memberships/types.py index f39007c..6166727 100755 --- a/sumup/memberships/types.py +++ b/sumup/memberships/types.py @@ -28,13 +28,13 @@ class Invite(pydantic.BaseModel): MembershipStatus = typing.Literal["accepted", "disabled", "expired", "pending", "unknown"] -Metadata = dict[typing.Any, typing.Any] +Metadata = dict[str, typing.Any] """ Set of user-defined key-value pairs attached to the object. Partial updates are not supported. When updating, alwayssubmit whole metadata. Maximum of 64 parameters are allowed in the object. Max properties: 64 """ -Attributes = dict[typing.Any, typing.Any] +Attributes = dict[str, typing.Any] """ Object attributes that are modifiable only by SumUp applications. """ diff --git a/sumup/merchants/types.py b/sumup/merchants/types.py index 3a35f1b..efc60c0 100755 --- a/sumup/merchants/types.py +++ b/sumup/merchants/types.py @@ -166,7 +166,7 @@ class CompanyIdentifier(pydantic.BaseModel): Max length: 64 """ -Attributes = dict[typing.Any, typing.Any] +Attributes = dict[str, typing.Any] """ Object attributes that are modifiable only by SumUp applications. """ @@ -335,7 +335,7 @@ class BusinessProfile(pydantic.BaseModel): """ -Meta = dict[typing.Any, typing.Any] +Meta = dict[str, str] """ A set of key-value pairs that you can attach to an object. This can be useful for storing additional informationabout the object in a structured format. @@ -536,6 +536,35 @@ class Problem(pydantic.BaseModel): A short, human-readable summary of the problem type. """ + model_config = pydantic.ConfigDict(extra="allow") + + @pydantic.model_validator(mode="before") + @classmethod + def _merge_additional_properties(cls, values: typing.Any) -> typing.Any: + if not isinstance(values, dict): + return values + + additional = values.get("additional_properties") + if not isinstance(additional, dict): + return values + + merged = dict(additional) + for key, value in values.items(): + if key != "additional_properties": + merged[key] = value + + return merged + + @property + def additional_properties(self) -> dict[str, typing.Any]: + if self.model_extra is None: + object.__setattr__(self, "__pydantic_extra__", {}) + return typing.cast(dict[str, typing.Any], self.model_extra) + + @additional_properties.setter + def additional_properties(self, value: dict[str, typing.Any]) -> None: + object.__setattr__(self, "__pydantic_extra__", dict(value)) + class Ownership(pydantic.BaseModel): """ diff --git a/sumup/readers/resource.py b/sumup/readers/resource.py index 137da9e..c7b42a0 100755 --- a/sumup/readers/resource.py +++ b/sumup/readers/resource.py @@ -58,7 +58,7 @@ class UpdateReaderBody(pydantic.BaseModel): """ -CreateReaderCheckoutBodyAffiliateTags = dict[typing.Any, typing.Any] +CreateReaderCheckoutBodyAffiliateTags = dict[str, typing.Any] """ Additional metadata for the transaction. It is key-value object that can be associated with the transaction. diff --git a/sumup/readers/types.py b/sumup/readers/types.py index 2d1211f..1b43bc6 100755 --- a/sumup/readers/types.py +++ b/sumup/readers/types.py @@ -39,7 +39,7 @@ class ReaderDevice(pydantic.BaseModel): """ -Metadata = dict[typing.Any, typing.Any] +Metadata = dict[str, typing.Any] """ Set of user-defined key-value pairs attached to the object. Partial updates are not supported. When updating, alwayssubmit whole metadata. Maximum of 64 parameters are allowed in the object. Max properties: 64 @@ -142,6 +142,35 @@ class Problem(pydantic.BaseModel): A short, human-readable summary of the problem type. """ + model_config = pydantic.ConfigDict(extra="allow") + + @pydantic.model_validator(mode="before") + @classmethod + def _merge_additional_properties(cls, values: typing.Any) -> typing.Any: + if not isinstance(values, dict): + return values + + additional = values.get("additional_properties") + if not isinstance(additional, dict): + return values + + merged = dict(additional) + for key, value in values.items(): + if key != "additional_properties": + merged[key] = value + + return merged + + @property + def additional_properties(self) -> dict[str, typing.Any]: + if self.model_extra is None: + object.__setattr__(self, "__pydantic_extra__", {}) + return typing.cast(dict[str, typing.Any], self.model_extra) + + @additional_properties.setter + def additional_properties(self, value: dict[str, typing.Any]) -> None: + object.__setattr__(self, "__pydantic_extra__", dict(value)) + ReaderPairingCode = str """ @@ -191,7 +220,7 @@ class CreateReaderCheckoutError(pydantic.BaseModel): errors: CreateReaderCheckoutErrorErrors -CreateReaderCheckoutUnprocessableEntityErrors = dict[typing.Any, typing.Any] +CreateReaderCheckoutUnprocessableEntityErrors = dict[str, typing.Any] """ CreateReaderCheckoutUnprocessableEntityErrors is a schema definition. """ @@ -205,7 +234,7 @@ class CreateReaderCheckoutUnprocessableEntity(pydantic.BaseModel): errors: CreateReaderCheckoutUnprocessableEntityErrors -CreateReaderCheckoutRequestAffiliateTags = dict[typing.Any, typing.Any] +CreateReaderCheckoutRequestAffiliateTags = dict[str, typing.Any] """ Additional metadata for the transaction. It is key-value object that can be associated with the transaction. @@ -564,7 +593,7 @@ class CreateReaderTerminateError(pydantic.BaseModel): errors: CreateReaderTerminateErrorErrors -CreateReaderTerminateUnprocessableEntityErrors = dict[typing.Any, typing.Any] +CreateReaderTerminateUnprocessableEntityErrors = dict[str, typing.Any] """ CreateReaderTerminateUnprocessableEntityErrors is a schema definition. """ diff --git a/sumup/roles/types.py b/sumup/roles/types.py index fff899e..c15e443 100755 --- a/sumup/roles/types.py +++ b/sumup/roles/types.py @@ -3,7 +3,7 @@ import typing import pydantic -Metadata = dict[typing.Any, typing.Any] +Metadata = dict[str, typing.Any] """ Set of user-defined key-value pairs attached to the object. Partial updates are not supported. When updating, alwayssubmit whole metadata. Maximum of 64 parameters are allowed in the object. Max properties: 64 @@ -91,3 +91,32 @@ class Problem(pydantic.BaseModel): """ A short, human-readable summary of the problem type. """ + + model_config = pydantic.ConfigDict(extra="allow") + + @pydantic.model_validator(mode="before") + @classmethod + def _merge_additional_properties(cls, values: typing.Any) -> typing.Any: + if not isinstance(values, dict): + return values + + additional = values.get("additional_properties") + if not isinstance(additional, dict): + return values + + merged = dict(additional) + for key, value in values.items(): + if key != "additional_properties": + merged[key] = value + + return merged + + @property + def additional_properties(self) -> dict[str, typing.Any]: + if self.model_extra is None: + object.__setattr__(self, "__pydantic_extra__", {}) + return typing.cast(dict[str, typing.Any], self.model_extra) + + @additional_properties.setter + def additional_properties(self, value: dict[str, typing.Any]) -> None: + object.__setattr__(self, "__pydantic_extra__", dict(value)) diff --git a/sumup/subaccounts/types.py b/sumup/subaccounts/types.py index d044ad8..019ac2f 100755 --- a/sumup/subaccounts/types.py +++ b/sumup/subaccounts/types.py @@ -90,3 +90,32 @@ class Problem(pydantic.BaseModel): """ A short, human-readable summary of the problem type. """ + + model_config = pydantic.ConfigDict(extra="allow") + + @pydantic.model_validator(mode="before") + @classmethod + def _merge_additional_properties(cls, values: typing.Any) -> typing.Any: + if not isinstance(values, dict): + return values + + additional = values.get("additional_properties") + if not isinstance(additional, dict): + return values + + merged = dict(additional) + for key, value in values.items(): + if key != "additional_properties": + merged[key] = value + + return merged + + @property + def additional_properties(self) -> dict[str, typing.Any]: + if self.model_extra is None: + object.__setattr__(self, "__pydantic_extra__", {}) + return typing.cast(dict[str, typing.Any], self.model_extra) + + @additional_properties.setter + def additional_properties(self, value: dict[str, typing.Any]) -> None: + object.__setattr__(self, "__pydantic_extra__", dict(value)) diff --git a/tests/test_additional_properties.py b/tests/test_additional_properties.py new file mode 100644 index 0000000..79effa7 --- /dev/null +++ b/tests/test_additional_properties.py @@ -0,0 +1,24 @@ +from sumup.merchants.types import Problem + + +def test_problem_collects_and_serializes_additional_properties(): + payload = { + "type": "https://developer.sumup.com/problem/not-found", + "title": "Requested resource couldn't be found.", + "foo": "bar", + "nested": {"answer": 42}, + } + + problem = Problem.model_validate(payload) + + assert problem.type == payload["type"] + assert problem.title == payload["title"] + assert problem.additional_properties == { + "foo": "bar", + "nested": {"answer": 42}, + } + + dumped = problem.model_dump() + assert dumped["foo"] == "bar" + assert dumped["nested"] == {"answer": 42} + assert "additional_properties" not in dumped