diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java index 4809bf64f156..510cf09b44c7 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java @@ -50,6 +50,9 @@ public abstract class AbstractPythonCodegen extends DefaultCodegen implements Co private final Logger LOGGER = LoggerFactory.getLogger(AbstractPythonCodegen.class); public static final String MAP_NUMBER_TO = "mapNumberTo"; + public static final String PYDANTIC = "pydantic"; + public static final Set SUPPORTED_NUMBER_MAPPINGS = + Set.of("Union[StrictFloat, StrictInt]", "StrictFloat", "float"); protected String packageName = "openapi_client"; @Setter protected String packageVersion = "1.0.0"; @@ -985,16 +988,16 @@ private ModelsMap postProcessModelsMap(ModelsMap objs) { codegenProperties = model.getComposedSchemas().getOneOf(); moduleImports.add("typing", "Any"); moduleImports.add("typing", "List"); - moduleImports.add("pydantic", "Field"); - moduleImports.add("pydantic", "StrictStr"); - moduleImports.add("pydantic", "ValidationError"); - moduleImports.add("pydantic", "field_validator"); + moduleImports.add(PYDANTIC, "Field"); + moduleImports.add(PYDANTIC, "StrictStr"); + moduleImports.add(PYDANTIC, "ValidationError"); + moduleImports.add(PYDANTIC, "field_validator"); } else if (!model.anyOf.isEmpty()) { // anyOF codegenProperties = model.getComposedSchemas().getAnyOf(); - moduleImports.add("pydantic", "Field"); - moduleImports.add("pydantic", "StrictStr"); - moduleImports.add("pydantic", "ValidationError"); - moduleImports.add("pydantic", "field_validator"); + moduleImports.add(PYDANTIC, "Field"); + moduleImports.add(PYDANTIC, "StrictStr"); + moduleImports.add(PYDANTIC, "ValidationError"); + moduleImports.add(PYDANTIC, "field_validator"); } else { // typical model codegenProperties = model.vars; @@ -1029,7 +1032,7 @@ private ModelsMap postProcessModelsMap(ModelsMap objs) { // if pydantic model if (!model.isEnum) { - moduleImports.add("pydantic", "ConfigDict"); + moduleImports.add(PYDANTIC, "ConfigDict"); } //loop through properties/schemas to set up typing, pydantic @@ -1054,7 +1057,7 @@ private ModelsMap postProcessModelsMap(ModelsMap objs) { if (!StringUtils.isEmpty(model.parent)) { modelImports.add(model.parent); } else if (!model.isEnum) { - moduleImports.add("pydantic", "BaseModel"); + moduleImports.add(PYDANTIC, "BaseModel"); } // set enum type in extensions and update `name` in enumVars @@ -1155,12 +1158,10 @@ private PythonType getPydanticType(CodegenProperty cp, } public void setMapNumberTo(String mapNumberTo) { - if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo) - || "StrictFloat".equals(mapNumberTo) - || "float".equals(mapNumberTo)) { + if (SUPPORTED_NUMBER_MAPPINGS.contains(mapNumberTo)) { this.mapNumberTo = mapNumberTo; } else { - throw new IllegalArgumentException("mapNumberTo value must be Union[StrictFloat, StrictInt], StrictFloat or float"); + throw new IllegalArgumentException(String.format(Locale.ROOT, "mapNumberTo supports %s", SUPPORTED_NUMBER_MAPPINGS)); } } @@ -1721,7 +1722,7 @@ private String asTypeConstraint(PythonImports imports, boolean withAnnotations) } if (fieldParams.size() > 0) { - imports.add("pydantic", "Field"); + imports.add(PYDANTIC, "Field"); imports.add("typing_extensions", "Annotated"); currentType = "Annotated[" + currentType + ", Field(" + StringUtils.join(fieldParams, ", ") + ")]"; } @@ -1761,7 +1762,7 @@ public String asTypeValue(PythonImports imports) { ants.add(ans); } - imports.add("pydantic", "Field"); + imports.add(PYDANTIC, "Field"); typeValue = "Field(" + StringUtils.join(ants, ", ") + ")"; return typeValue; } @@ -1827,6 +1828,15 @@ public boolean isEmpty() { } class PydanticType { + + private static final String LESS_THAN = "lt"; + private static final String GREATER_THAN = "gt"; + private static final String GREATER_OR_EQUAL_TO = "ge"; + private static final String LESS_OR_EQUAL_TO = "le"; + private static final String TYPING = "typing"; + + private static final String DECIMAL = "Decimal"; + private Set modelImports; private Set exampleImports; private Set postponedModelImports; @@ -1868,10 +1878,10 @@ private PythonType arrayType(IJsonSchemaValidationProperties cp) { //pt.setType("Set"); //moduleImports.add("typing", "Set"); pt.setType("List"); - moduleImports.add("typing", "List"); + moduleImports.add(TYPING, "List"); } else { pt.setType("List"); - moduleImports.add("typing", "List"); + moduleImports.add(TYPING, "List"); } pt.addTypeParam(collectionItemType(cp.getItems())); return pt; @@ -1880,7 +1890,7 @@ private PythonType arrayType(IJsonSchemaValidationProperties cp) { private PythonType collectionItemType(CodegenProperty itemCp) { PythonType itemPt = getType(itemCp); if (itemCp != null && !itemPt.type.equals("Any") && itemCp.isNullable) { - moduleImports.add("typing", "Optional"); + moduleImports.add(TYPING, "Optional"); PythonType opt = new PythonType("Optional"); opt.addTypeParam(itemPt); itemPt = opt; @@ -1903,24 +1913,24 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { } if (cp.getPattern() != null) { - moduleImports.add("pydantic", "field_validator"); + moduleImports.add(PYDANTIC, "field_validator"); // use validator instead as regex doesn't support flags, e.g. IGNORECASE //fieldCustomization.add(Locale.ROOT, String.format(Locale.ROOT, "regex=r'%s'", cp.getPattern())); } return pt; } else { if ("password".equals(cp.getFormat())) { // TODO avoid using format, use `is` boolean flag instead - moduleImports.add("pydantic", "SecretStr"); + moduleImports.add(PYDANTIC, "SecretStr"); return new PythonType("SecretStr"); } else { - moduleImports.add("pydantic", "StrictStr"); + moduleImports.add(PYDANTIC, "StrictStr"); return new PythonType("StrictStr"); } } } private PythonType mapType(IJsonSchemaValidationProperties cp) { - moduleImports.add("typing", "Dict"); + moduleImports.add(TYPING, "Dict"); PythonType pt = new PythonType("Dict"); pt.addTypeParam(new PythonType("str")); pt.addTypeParam(collectionItemType(cp.getItems())); @@ -1935,20 +1945,20 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { // e.g. confloat(ge=10, le=100, strict=True) if (cp.getMaximum() != null) { if (cp.getExclusiveMaximum()) { - floatt.constrain("lt", cp.getMaximum(), false); - intt.constrain("lt", (int) Math.ceil(Double.valueOf(cp.getMaximum()))); // e.g. < 7.59 => < 8 + floatt.constrain(LESS_THAN, cp.getMaximum(), false); + intt.constrain(LESS_THAN, (int) Math.ceil(Double.valueOf(cp.getMaximum()))); // e.g. < 7.59 => < 8 } else { - floatt.constrain("le", cp.getMaximum(), false); - intt.constrain("le", (int) Math.floor(Double.valueOf(cp.getMaximum()))); // e.g. <= 7.59 => <= 7 + floatt.constrain(LESS_OR_EQUAL_TO, cp.getMaximum(), false); + intt.constrain(LESS_OR_EQUAL_TO, (int) Math.floor(Double.valueOf(cp.getMaximum()))); // e.g. <= 7.59 => <= 7 } } if (cp.getMinimum() != null) { if (cp.getExclusiveMinimum()) { - floatt.constrain("gt", cp.getMinimum(), false); - intt.constrain("gt", (int) Math.floor(Double.valueOf(cp.getMinimum()))); // e.g. > 7.59 => > 7 + floatt.constrain(GREATER_THAN, cp.getMinimum(), false); + intt.constrain(GREATER_THAN, (int) Math.floor(Double.valueOf(cp.getMinimum()))); // e.g. > 7.59 => > 7 } else { - floatt.constrain("ge", cp.getMinimum(), false); - intt.constrain("ge", (int) Math.ceil(Double.valueOf(cp.getMinimum()))); // e.g. >= 7.59 => >= 8 + floatt.constrain(GREATER_OR_EQUAL_TO, cp.getMinimum(), false); + intt.constrain(GREATER_OR_EQUAL_TO, (int) Math.ceil(Double.valueOf(cp.getMinimum()))); // e.g. >= 7.59 => >= 8 } } if (cp.getMultipleOf() != null) { @@ -1959,7 +1969,7 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { floatt.constrain("strict", true); intt.constrain("strict", true); - moduleImports.add("typing", "Union"); + moduleImports.add(TYPING, "Union"); PythonType pt = new PythonType("Union"); pt.addTypeParam(floatt); pt.addTypeParam(intt); @@ -1972,15 +1982,15 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } } else { if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { - moduleImports.add("typing", "Union"); - moduleImports.add("pydantic", "StrictFloat"); - moduleImports.add("pydantic", "StrictInt"); + moduleImports.add(TYPING, "Union"); + moduleImports.add(PYDANTIC, "StrictFloat"); + moduleImports.add(PYDANTIC, "StrictInt"); PythonType pt = new PythonType("Union"); pt.addTypeParam(new PythonType("StrictFloat")); pt.addTypeParam(new PythonType("StrictInt")); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { - moduleImports.add("pydantic", "StrictFloat"); + moduleImports.add(PYDANTIC, "StrictFloat"); return new PythonType("StrictFloat"); } else { return new PythonType("float"); @@ -1993,26 +2003,10 @@ private PythonType intType(IJsonSchemaValidationProperties cp) { PythonType pt = new PythonType("int"); // e.g. conint(ge=10, le=100, strict=True) pt.constrain("strict", true); - if (cp.getMaximum() != null) { - if (cp.getExclusiveMaximum()) { - pt.constrain("lt", cp.getMaximum(), false); - } else { - pt.constrain("le", cp.getMaximum(), false); - } - } - if (cp.getMinimum() != null) { - if (cp.getExclusiveMinimum()) { - pt.constrain("gt", cp.getMinimum(), false); - } else { - pt.constrain("ge", cp.getMinimum(), false); - } - } - if (cp.getMultipleOf() != null) { - pt.constrain("multiple_of", cp.getMultipleOf()); - } + applyConstraints(pt, cp); return pt; } else { - moduleImports.add("pydantic", "StrictInt"); + moduleImports.add(PYDANTIC, "StrictInt"); return new PythonType("StrictInt"); } } @@ -2034,19 +2028,19 @@ private PythonType binaryType(IJsonSchemaValidationProperties cp) { strt.constrain("min_length", cp.getMinLength()); } if (cp.getPattern() != null) { - moduleImports.add("pydantic", "field_validator"); + moduleImports.add(PYDANTIC, "field_validator"); // use validator instead as regex doesn't support flags, e.g. IGNORECASE //fieldCustomization.add(Locale.ROOT, String.format(Locale.ROOT, "regex=r'%s'", cp.getPattern())); } - moduleImports.add("typing", "Union"); + moduleImports.add(TYPING, "Union"); PythonType pt = new PythonType("Union"); pt.addTypeParam(bytest); pt.addTypeParam(strt); if (cp.getIsBinary()) { - moduleImports.add("typing", "Tuple"); + moduleImports.add(TYPING, "Tuple"); PythonType tt = new PythonType("Tuple"); // this string is a filename, not a validated value @@ -2059,16 +2053,16 @@ private PythonType binaryType(IJsonSchemaValidationProperties cp) { return pt; } else { // same as above which has validation - moduleImports.add("pydantic", "StrictBytes"); - moduleImports.add("pydantic", "StrictStr"); - moduleImports.add("typing", "Union"); + moduleImports.add(PYDANTIC, "StrictBytes"); + moduleImports.add(PYDANTIC, "StrictStr"); + moduleImports.add(TYPING, "Union"); PythonType pt = new PythonType("Union"); pt.addTypeParam(new PythonType("StrictBytes")); pt.addTypeParam(new PythonType("StrictStr")); if (cp.getIsBinary()) { - moduleImports.add("typing", "Tuple"); + moduleImports.add(TYPING, "Tuple"); PythonType tt = new PythonType("Tuple"); tt.addTypeParam(new PythonType("StrictStr")); @@ -2082,41 +2076,25 @@ private PythonType binaryType(IJsonSchemaValidationProperties cp) { } private PythonType boolType(IJsonSchemaValidationProperties cp) { - moduleImports.add("pydantic", "StrictBool"); + moduleImports.add(PYDANTIC, "StrictBool"); return new PythonType("StrictBool"); } private PythonType decimalType(IJsonSchemaValidationProperties cp) { - PythonType pt = new PythonType("Decimal"); - moduleImports.add("decimal", "Decimal"); + PythonType pt = new PythonType(DECIMAL); + moduleImports.add("decimal", DECIMAL); if (cp.getHasValidation()) { // e.g. condecimal(ge=10, le=100, strict=True) pt.constrain("strict", true); - if (cp.getMaximum() != null) { - if (cp.getExclusiveMaximum()) { - pt.constrain("gt", cp.getMaximum(), false); - } else { - pt.constrain("ge", cp.getMaximum(), false); - } - } - if (cp.getMinimum() != null) { - if (cp.getExclusiveMinimum()) { - pt.constrain("lt", cp.getMinimum(), false); - } else { - pt.constrain("le", cp.getMinimum(), false); - } - } - if (cp.getMultipleOf() != null) { - pt.constrain("multiple_of", cp.getMultipleOf()); - } + applyConstraints(pt, cp); } return pt; } private PythonType anyType(IJsonSchemaValidationProperties cp) { - moduleImports.add("typing", "Any"); + moduleImports.add(TYPING, "Any"); return new PythonType("Any"); } @@ -2148,16 +2126,16 @@ private PythonType fromCommon(IJsonSchemaValidationProperties cp) { if (cp == null) { // if codegen property (e.g. map/dict of undefined type) is null, default to string LOGGER.warn("Codegen property is null (e.g. map/dict of undefined type). Default to typing.Any."); - moduleImports.add("typing", "Any"); + moduleImports.add(TYPING, "Any"); return new PythonType("Any"); } if (cp.getIsEnum()) { - moduleImports.add("pydantic", "field_validator"); + moduleImports.add(PYDANTIC, "field_validator"); } if (cp.getPattern() != null) { - moduleImports.add("pydantic", "field_validator"); + moduleImports.add(PYDANTIC, "field_validator"); } if (cp.getIsArray()) { @@ -2199,7 +2177,7 @@ private PythonType getType(CodegenProperty cp) { also need to put cp.isEnum check after isArray, isMap check if (cp.isEnum) { // use Literal for inline enum - moduleImports.add("typing", "Literal"); + moduleImports.add(TYPING, "Literal"); List values = new ArrayList<>(); List> enumVars = (List>) cp.allowableValues.get("enumVars"); if (enumVars != null) { @@ -2248,7 +2226,7 @@ private PythonType getType(CodegenProperty cp) { private String finalizeType(CodegenProperty cp, PythonType pt) { if (!cp.required || cp.isNullable) { - moduleImports.add("typing", "Optional"); + moduleImports.add(TYPING, "Optional"); PythonType opt = new PythonType("Optional"); opt.addTypeParam(pt); pt = opt; @@ -2327,6 +2305,26 @@ private PythonType getType(CodegenParameter cp) { return result; } + private void applyConstraints(PythonType pythonType, IJsonSchemaValidationProperties cp) { + if (cp.getMaximum() != null) { + if (cp.getExclusiveMaximum()) { + pythonType.constrain(LESS_THAN, cp.getMaximum(), false); + } else { + pythonType.constrain(LESS_OR_EQUAL_TO, cp.getMaximum(), false); + } + } + if (cp.getMinimum() != null) { + if (cp.getExclusiveMinimum()) { + pythonType.constrain(GREATER_THAN, cp.getMinimum(), false); + } else { + pythonType.constrain(GREATER_OR_EQUAL_TO, cp.getMinimum(), false); + } + } + if (cp.getMultipleOf() != null) { + pythonType.constrain("multiple_of", cp.getMultipleOf()); + } + } + private String finalizeType(CodegenParameter cp, PythonType pt) { if (!cp.required || cp.isNullable) { moduleImports.add("typing", "Optional"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index f5bc24393993..bd97269b4e36 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -736,4 +736,27 @@ public void testNonPoetry1LicenseFormat() throws IOException { // Verify it does NOT use the legacy string format TestUtils.assertFileNotContains(pyprojectPath, "license = \"BSD-3-Clause\""); } + + @Test + public void testConstraintMapping() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("python") + .setInputSpec("src/test/resources/3_0/unit_test_spec/format.yaml") + .setOutputDir(output.getAbsolutePath()); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path filePath = Paths.get(output.getAbsolutePath(), "openapi_client/models/format_test.py"); + TestUtils.assertFileExists(filePath); + + TestUtils.assertFileContains(filePath, "integer: Optional[Annotated[int, Field(multiple_of=2, le=100, strict=True, ge=10)]]"); + TestUtils.assertFileContains(filePath, "number: Union[Annotated[float, Field(multiple_of=32.5, le=543.2, strict=True, ge=32.1)], Annotated[int, Field(le=543, strict=True, ge=33)]]"); + TestUtils.assertFileContains(filePath, "double: Optional[Union[Annotated[float, Field(le=123.4, strict=True, ge=67.8)], Annotated[int, Field(le=123, strict=True, ge=68)]]]"); + TestUtils.assertFileContains(filePath, "decimal: Optional[Annotated[Decimal, Field(multiple_of=0.1, lt=123.4, strict=True, gt=67.8)]]"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/unit_test_spec/format.yaml b/modules/openapi-generator/src/test/resources/3_0/unit_test_spec/format.yaml new file mode 100644 index 000000000000..ff8482df794b --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/unit_test_spec/format.yaml @@ -0,0 +1,189 @@ +openapi: 3.0.0 +info: + description: >- + This spec is mainly for testing Petstore server and contains fake endpoints, + models. Please do not use this for any other purpose. Special characters: " + \ + version: 1.0.0 + title: OpenAPI Petstore + license: + name: Apache-2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user +paths: + /fake: + get: + tags: + - fake + summary: To test enum parameters + description: To test enum parameters + operationId: testEnumParameters + responses: + '400': + description: Invalid request + '404': + description: Not found + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + enum_form_string_array: + description: Form parameter enum test (string array) + type: array + items: + type: string + default: $ + enum: + - '>' + - $ + enum_form_string: + description: Form parameter enum test (string) + type: string + enum: + - _abc + - '-efg' + - (xyz) + default: '-efg' +servers: + - url: 'http://{server}.swagger.io:{port}/v2' + description: petstore server + variables: + server: + enum: + - 'petstore' + - 'qa-petstore' + - 'dev-petstore' + default: 'petstore' + port: + enum: + - 80 + - 8080 + default: 80 + - url: https://localhost:8080/{version} + description: The local server + variables: + version: + enum: + - 'v1' + - 'v2' + default: 'v2' + - url: https://127.0.0.1/no_variable + description: The local server without variables +components: + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header + api_key_query: + type: apiKey + name: api_key_query + in: query + http_basic_test: + type: http + scheme: basic + bearer_test: + type: http + scheme: bearer + bearerFormat: JWT + http_signature_test: + # Test the 'HTTP signature' security scheme. + # Each HTTP request is cryptographically signed as specified + # in https://datatracker.ietf.org/doc/draft-cavage-http-signatures/ + type: http + scheme: signature + schemas: + format_test: + type: object + required: + - number + - byte + - date + - password + properties: + integer: + type: integer + maximum: 100 + minimum: 10 + multipleOf: 2 + int32: + type: integer + format: int32 + maximum: 200 + minimum: 20 + int64: + type: integer + format: int64 + number: + maximum: 543.2 + minimum: 32.1 + type: number + multipleOf: 32.5 + float: + type: number + format: float + maximum: 987.6 + minimum: 54.3 + double: + type: number + format: double + maximum: 123.4 + minimum: 67.8 + decimal: + type: string + format: number + minimum: 67.8 + maximum: 123.4 + multipleOf: 0.1 + exclusiveMinimum: true + exclusiveMaximum: true + string: + type: string + pattern: '/[a-z]/i' + byte: + type: string + format: byte + binary: + type: string + format: binary + date: + type: string + format: date + example: '2020-02-02' + dateTime: + type: string + format: date-time + example: '2007-12-03T10:15:30+01:00' + uuid: + type: string + format: uuid + example: 72f98069-206d-4f12-9f12-3d1e525a8e84 + password: + type: string + format: password + maxLength: 64 + minLength: 10 + pattern_with_digits: + description: A string that is a 10 digit number. Can have leading zeros. + type: string + pattern: '^\d{10}$' + pattern_with_digits_and_delimiter: + description: A string starting with 'image_' (case insensitive) and one to three digits following i.e. Image_01. + type: string + pattern: '/^image_\d{1,3}$/i' \ No newline at end of file