diff --git a/docs/generators/python.md b/docs/generators/python.md index bc3a0feb7b83..cc91bb65ae96 100644 --- a/docs/generators/python.md +++ b/docs/generators/python.md @@ -27,7 +27,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |hideGenerationTimestamp|Hides the generation timestamp when files are generated.| |true| |lazyImports|Enable lazy imports.| |false| |library|library template (sub-template) to use: asyncio, tornado (deprecated), urllib3, httpx| |urllib3| -|mapNumberTo|Map number to Union[StrictFloat, StrictInt], StrictStr or float.| |Union[StrictFloat, StrictInt]| +|mapNumberTo|Map number to Union[StrictFloat, StrictInt], StrictFloat, float or Decimal.| |Union[StrictFloat, StrictInt]| |packageName|python package name (convention: snake_case).| |openapi_client| |packageUrl|python package URL.| |null| |packageVersion|python package version.| |1.0.0| 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..464661527eb5 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", "Decimal"); 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); @@ -1967,21 +1977,26 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } else if ("StrictFloat".equals(mapNumberTo)) { floatt.constrain("strict", true); return floatt; + } else if (DECIMAL.equals(mapNumberTo)) { + return decimalType(cp); } else { // float return floatt; } } 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 if (DECIMAL.equals(mapNumberTo)) { + moduleImports.add("decimal", DECIMAL); + return new PythonType(DECIMAL); } else { return new PythonType("float"); } @@ -1995,16 +2010,16 @@ private PythonType intType(IJsonSchemaValidationProperties cp) { pt.constrain("strict", true); if (cp.getMaximum() != null) { if (cp.getExclusiveMaximum()) { - pt.constrain("lt", cp.getMaximum(), false); + pt.constrain(LESS_THAN, cp.getMaximum(), false); } else { - pt.constrain("le", cp.getMaximum(), false); + pt.constrain(LESS_OR_EQUAL_TO, cp.getMaximum(), false); } } if (cp.getMinimum() != null) { if (cp.getExclusiveMinimum()) { - pt.constrain("gt", cp.getMinimum(), false); + pt.constrain(GREATER_THAN, cp.getMinimum(), false); } else { - pt.constrain("ge", cp.getMinimum(), false); + pt.constrain(GREATER_OR_EQUAL_TO, cp.getMinimum(), false); } } if (cp.getMultipleOf() != null) { @@ -2012,7 +2027,7 @@ private PythonType intType(IJsonSchemaValidationProperties cp) { } return pt; } else { - moduleImports.add("pydantic", "StrictInt"); + moduleImports.add(PYDANTIC, "StrictInt"); return new PythonType("StrictInt"); } } @@ -2034,19 +2049,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 +2074,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,29 +2097,29 @@ 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); + pt.constrain(GREATER_THAN, cp.getMaximum(), false); } else { - pt.constrain("ge", cp.getMaximum(), false); + pt.constrain(GREATER_OR_EQUAL_TO, cp.getMaximum(), false); } } if (cp.getMinimum() != null) { if (cp.getExclusiveMinimum()) { - pt.constrain("lt", cp.getMinimum(), false); + pt.constrain(LESS_THAN, cp.getMinimum(), false); } else { - pt.constrain("le", cp.getMinimum(), false); + pt.constrain(LESS_OR_EQUAL_TO, cp.getMinimum(), false); } } if (cp.getMultipleOf() != null) { @@ -2116,7 +2131,7 @@ private PythonType decimalType(IJsonSchemaValidationProperties cp) { } private PythonType anyType(IJsonSchemaValidationProperties cp) { - moduleImports.add("typing", "Any"); + moduleImports.add(TYPING, "Any"); return new PythonType("Any"); } @@ -2148,16 +2163,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 +2214,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 +2263,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; @@ -2329,7 +2344,7 @@ private PythonType getType(CodegenParameter cp) { private String finalizeType(CodegenParameter 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; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java index fdf19feff107..49c3dda69862 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java @@ -148,7 +148,7 @@ public PythonClientCodegen() { cliOptions.add(new CliOption(SET_ENSURE_ASCII_TO_FALSE, "When set to true, add `ensure_ascii=False` in json.dumps when creating the HTTP request body.") .defaultValue(Boolean.FALSE.toString())); cliOptions.add(new CliOption(RECURSION_LIMIT, "Set the recursion limit. If not set, use the system default value.")); - cliOptions.add(new CliOption(MAP_NUMBER_TO, "Map number to Union[StrictFloat, StrictInt], StrictStr or float.") + cliOptions.add(new CliOption(MAP_NUMBER_TO, "Map number to Union[StrictFloat, StrictInt], StrictFloat, float or Decimal.") .defaultValue("Union[StrictFloat, StrictInt]")); cliOptions.add(new CliOption(DATETIME_FORMAT, "datetime format for query parameters") .defaultValue("%Y-%m-%dT%H:%M:%S%z")); 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..83667c7d12dd 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 @@ -29,6 +29,7 @@ import org.openapitools.codegen.languages.PythonClientCodegen; import org.openapitools.codegen.languages.features.CXFServerFeatures; import org.testng.Assert; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import java.io.File; @@ -36,10 +37,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -736,4 +734,36 @@ public void testNonPoetry1LicenseFormat() throws IOException { // Verify it does NOT use the legacy string format TestUtils.assertFileNotContains(pyprojectPath, "license = \"BSD-3-Clause\""); } + + @Test(dataProvider = "numberMappings") + public void testMapNumberTo(String mapToNumber, String expectedType) throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("python") + .setInputSpec("src/test/resources/3_0/echo_api.yaml") + .setOutputDir(output.getAbsolutePath()) + .addAdditionalProperty("mapNumberTo", mapToNumber); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path numberPropertiesOnlyPath = Paths.get(output.getAbsolutePath(), "openapi_client/models/number_properties_only.py"); + TestUtils.assertFileExists(numberPropertiesOnlyPath); + + TestUtils.assertFileContains(numberPropertiesOnlyPath, String.format(Locale.ROOT, "number: Optional[%s]", expectedType)); + TestUtils.assertFileContains(numberPropertiesOnlyPath, String.format(Locale.ROOT, "var_float: Optional[%s]", expectedType)); + } + + @DataProvider(name = "numberMappings") + public Object[][] numberMappings() { + return new Object[][] { + { "float", "float" }, + { "Decimal", "Decimal" }, + { "StrictFloat", "StrictFloat" }, + { "Union[StrictFloat, StrictInt]", "Union[StrictFloat, StrictInt]" } + }; + } }