Skip to content

Commit ac5d897

Browse files
pmhahnPhilipp Hahn
andauthored
Add source folder variable to Python Client (#24980)
Currently the Python client generator generates a "flat layout", where the generated Python module is directly below the project root. This is a problem for editable installs as tools like `uv` have to generate a complicated "finder" to load the modules. Several tools like `mypy` and `ty` fail to use it as they implement their own "import mechanism" incompatible with the generated finder. This can be mitigated by switching to the "src layout", where all files live in the sub-directory "src". Tools like `uv` then only have to place the absolute path in a `.pth` file, which also works with `mypy` and `ty`. Similar to #12119 but in the reverse add a new configuration option "sourceFolder" to make the base folder configurable. For backward compatibility it is unset by default, which generates the previous "flat layout". Set it to "src" to generate the "src layout". Link: https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/ Signed-off-by: Philipp Hahn <p.hahn@avm.de> Co-authored-by: Philipp Hahn <p.hahn@avm.de>
1 parent 4c2d787 commit ac5d897

6 files changed

Lines changed: 74 additions & 21 deletions

File tree

‎bin/configs/python.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ generatorName: python
22
outputDir: samples/openapi3/client/petstore/python
33
inputSpec: modules/openapi-generator/src/test/resources/3_0/python/petstore-with-fake-endpoints-models-for-testing.yaml
44
templateDir: modules/openapi-generator/src/main/resources/python
5+
# sourceFolder: "src"
56
additionalProperties:
67
packageName: petstore_api
78
useOneOfDiscriminatorLookup: "true"

‎docs/generators/python.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
3636
|projectName|python project name in setup.py (e.g. petstore-api).| |null|
3737
|recursionLimit|Set the recursion limit. If not set, use the system default value.| |null|
3838
|setEnsureAsciiToFalse|When set to true, add `ensure_ascii=False` in json.dumps when creating the HTTP request body.| |false|
39+
|sourceFolder|directory for generated python source code; 'src' for _src layout_, unset (or '.') for _flat layout_.| |null|
3940
|supportHttpxSync|Generate synchronous '_sync' variants of each API method (httpx and httpx2 libraries only). Each '_sync' method simply calls the corresponding async method and waits for its completion, so both synchronous and asynchronous methods are available from the same API class.| |false|
4041
|useIndependentImplicitClients|Give API instances without an explicit or registered default ApiClient an owned client with a copied Configuration.| |false|
4142
|useOneOfDiscriminatorLookup|Use the discriminator's mapping in oneOf to speed up the model lookup. IMPORTANT: Validation (e.g. one and only one match in oneOf's schemas) will be skipped.| |false|

‎modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java‎

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ public class PythonClientCodegen extends AbstractPythonCodegen implements Codege
120120
@Setter protected boolean useIndependentImplicitClients = false;
121121
@Setter protected boolean compatibleWithPythonLegacy = false;
122122

123+
private String sourceFolder;
123124
private String testFolder;
124125

125126
public PythonClientCodegen() {
@@ -198,6 +199,7 @@ public PythonClientCodegen() {
198199
modelDocTemplateFiles.put("model_doc.mustache", ".md");
199200
apiDocTemplateFiles.put("api_doc.mustache", ".md");
200201

202+
sourceFolder = "";
201203
testFolder = "test";
202204

203205
// default HIDE_GENERATION_TIMESTAMP to true
@@ -227,6 +229,7 @@ public PythonClientCodegen() {
227229
cliOptions.add(new CliOption(POETRY1_FALLBACK, "Fallback to formatting pyproject.toml to Poetry 1.x format."));
228230
cliOptions.add(new CliOption(LAZY_IMPORTS, "Enable lazy imports.").defaultValue(Boolean.FALSE.toString()));
229231
cliOptions.add(new CliOption(BUILD_SYSTEM, "Build system to use in pyproject.toml (setuptools, hatchling).").defaultValue("setuptools"));
232+
cliOptions.add(new CliOption(CodegenConstants.SOURCE_FOLDER, "directory for generated python source code; 'src' for _src layout_, unset (or '.') for _flat layout_."));
230233
cliOptions.add(CliOption.newBoolean(SUPPORT_HTTPX_SYNC, "Generate synchronous '_sync' variants of each API method (httpx and httpx2 libraries only). " +
231234
"Each '_sync' method simply calls the corresponding async method and waits for its completion, " +
232235
"so both synchronous and asynchronous methods are available from the same API class.").defaultValue(Boolean.FALSE.toString()));
@@ -296,6 +299,9 @@ public void processOpts() {
296299
if (additionalProperties.containsKey(CodegenConstants.PACKAGE_VERSION)) {
297300
setPackageVersion((String) additionalProperties.get(CodegenConstants.PACKAGE_VERSION));
298301
}
302+
if (additionalProperties.containsKey(CodegenConstants.SOURCE_FOLDER)) {
303+
this.sourceFolder = (String) additionalProperties.get(CodegenConstants.SOURCE_FOLDER);
304+
}
299305

300306
additionalProperties.put(CodegenConstants.PROJECT_NAME, projectName);
301307
additionalProperties.put(CodegenConstants.PACKAGE_NAME, packageName);
@@ -314,8 +320,8 @@ public void processOpts() {
314320
// tests in <package>/test
315321
testFolder = packagePath() + File.separatorChar + testFolder;
316322
// api/model docs in <package>/docs
317-
apiDocPath = packagePath() + "/" + apiDocPath;
318-
modelDocPath = packagePath() + "/" + modelDocPath;
323+
apiDocPath = packagePath() + File.separatorChar + apiDocPath;
324+
modelDocPath = packagePath() + File.separatorChar + modelDocPath;
319325
}
320326
// make api and model doc path available in mustache template
321327
additionalProperties.put("apiDocPath", apiDocPath);
@@ -392,8 +398,9 @@ public void processOpts() {
392398
USE_INDEPENDENT_IMPLICIT_CLIENTS, useIndependentImplicitClients);
393399
}
394400

395-
String modelPath = packagePath() + File.separatorChar + modelPackage.replace('.', File.separatorChar);
396-
String apiPath = packagePath() + File.separatorChar + apiPackage.replace('.', File.separatorChar);
401+
String srcPath = sourceFolder.isEmpty() ? packagePath() : (sourceFolder + File.separatorChar + packagePath());
402+
String modelPath = srcPath + File.separatorChar + modelPackage.replace('.', File.separatorChar);
403+
String apiPath = srcPath + File.separatorChar + apiPackage.replace('.', File.separatorChar);
397404

398405
String readmePath = "README.md";
399406
String readmeTemplate = "README.mustache";
@@ -416,23 +423,23 @@ public void processOpts() {
416423
supportingFiles.add(new SupportingFile("gitlab-ci.mustache", "", ".gitlab-ci.yml"));
417424
supportingFiles.add(new SupportingFile("setup.mustache", "", "setup.py"));
418425
supportingFiles.add(new SupportingFile("pyproject.mustache", "", "pyproject.toml"));
419-
supportingFiles.add(new SupportingFile("py.typed.mustache", packagePath(), "py.typed"));
426+
supportingFiles.add(new SupportingFile("py.typed.mustache", srcPath, "py.typed"));
420427
}
421-
supportingFiles.add(new SupportingFile("configuration.mustache", packagePath(), "configuration.py"));
422-
supportingFiles.add(new SupportingFile("__init__package.mustache", packagePath(), "__init__.py"));
428+
supportingFiles.add(new SupportingFile("configuration.mustache", srcPath, "configuration.py"));
429+
supportingFiles.add(new SupportingFile("__init__package.mustache", srcPath, "__init__.py"));
423430
supportingFiles.add(new SupportingFile("__init__model.mustache", modelPath, "__init__.py"));
424431
supportingFiles.add(new SupportingFile("__init__api.mustache", apiPath, "__init__.py"));
425432
// Generate the 'signing.py' module, but only if the 'HTTP signature' security scheme is specified in the OAS.
426433
Map<String, SecurityScheme> securitySchemeMap = openAPI != null ?
427434
(openAPI.getComponents() != null ? openAPI.getComponents().getSecuritySchemes() : null) : null;
428435
List<CodegenSecurity> authMethods = fromSecurity(securitySchemeMap);
429436
if (ProcessUtils.hasHttpSignatureMethods(authMethods)) {
430-
supportingFiles.add(new SupportingFile("signing.mustache", packagePath(), "signing.py"));
437+
supportingFiles.add(new SupportingFile("signing.mustache", srcPath, "signing.py"));
431438
}
432439

433440
// If the package name consists of dots(openapi.client), then we need to create the directory structure like openapi/client with __init__ files.
434441
String[] packageNameSplits = packageName.split("\\.");
435-
String currentPackagePath = "";
442+
String currentPackagePath = sourceFolder.isEmpty() ? "" : (sourceFolder + File.separatorChar);
436443
for (int i = 0; i < packageNameSplits.length - 1; i++) {
437444
if (i > 0) {
438445
currentPackagePath = currentPackagePath + File.separatorChar;
@@ -441,34 +448,34 @@ public void processOpts() {
441448
supportingFiles.add(new SupportingFile("__init__.mustache", currentPackagePath, "__init__.py"));
442449
}
443450

444-
supportingFiles.add(new SupportingFile("exceptions.mustache", packagePath(), "exceptions.py"));
451+
supportingFiles.add(new SupportingFile("exceptions.mustache", srcPath, "exceptions.py"));
445452

446453
if (Boolean.FALSE.equals(excludeTests)) {
447454
supportingFiles.add(new SupportingFile("__init__.mustache", testFolder, "__init__.py"));
448455
}
449456

450-
supportingFiles.add(new SupportingFile("api_client.mustache", packagePath(), "api_client.py"));
451-
supportingFiles.add(new SupportingFile("api_response.mustache", packagePath(), "api_response.py"));
457+
supportingFiles.add(new SupportingFile("api_client.mustache", srcPath, "api_client.py"));
458+
supportingFiles.add(new SupportingFile("api_response.mustache", srcPath, "api_response.py"));
452459

453460
if ("asyncio".equals(getLibrary())) {
454-
supportingFiles.add(new SupportingFile("asyncio/rest.mustache", packagePath(), "rest.py"));
461+
supportingFiles.add(new SupportingFile("asyncio/rest.mustache", srcPath, "rest.py"));
455462
additionalProperties.put("async", "true");
456463
additionalProperties.put("asyncio", "true");
457464
} else if (isHttpxLibrary()) {
458-
supportingFiles.add(new SupportingFile("httpx/rest.mustache", packagePath(), "rest.py"));
465+
supportingFiles.add(new SupportingFile("httpx/rest.mustache", srcPath, "rest.py"));
459466
additionalProperties.put("async", "true");
460467
additionalProperties.put(getLibrary(), "true");
461468
additionalProperties.put("httpxLibrary", true);
462469
additionalProperties.put("httpxModule", getLibrary());
463470
if (Boolean.parseBoolean(String.valueOf(additionalProperties.get(SUPPORT_HTTPX_SYNC)))) {
464471
// generate synchronous '_sync' method variants alongside the async ones
465472
additionalProperties.put(SUPPORT_HTTPX_SYNC, true);
466-
supportingFiles.add(new SupportingFile("httpx/sync_helper.mustache", packagePath(), "sync_helper.py"));
473+
supportingFiles.add(new SupportingFile("httpx/sync_helper.mustache", srcPath, "sync_helper.py"));
467474
} else {
468475
additionalProperties.remove(SUPPORT_HTTPX_SYNC);
469476
}
470477
} else {
471-
supportingFiles.add(new SupportingFile("rest.mustache", packagePath(), "rest.py"));
478+
supportingFiles.add(new SupportingFile("rest.mustache", srcPath, "rest.py"));
472479
}
473480

474481
// 'supportHttpxSync' only makes sense for the (async) httpx and httpx2 libraries
@@ -545,12 +552,26 @@ public String toApiDocFilename(String name) {
545552

546553
@Override
547554
public String apiFileFolder() {
548-
return outputFolder + File.separatorChar + apiPackage().replace('.', File.separatorChar);
555+
StringBuffer sb = new StringBuffer(outputFolder);
556+
sb.append(File.separatorChar);
557+
if (!sourceFolder.isEmpty()) {
558+
sb.append(sourceFolder);
559+
sb.append(File.separatorChar);
560+
}
561+
sb.append(apiPackage().replace('.', File.separatorChar));
562+
return sb.toString();
549563
}
550564

551565
@Override
552566
public String modelFileFolder() {
553-
return outputFolder + File.separatorChar + modelPackage().replace('.', File.separatorChar);
567+
StringBuffer sb = new StringBuffer(outputFolder);
568+
sb.append(File.separatorChar);
569+
if (!sourceFolder.isEmpty()) {
570+
sb.append(sourceFolder);
571+
sb.append(File.separatorChar);
572+
}
573+
sb.append(modelPackage().replace('.', File.separatorChar));
574+
return sb.toString();
554575
}
555576

556577
@Override

‎modules/openapi-generator/src/main/resources/python/pyproject.mustache‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ repository = "https://{{{gitHost}}}/{{{gitUserId}}}/{{{gitRepoId}}}"
2929
{{/poetry1}}
3030
keywords = ["OpenAPI", "OpenAPI-Generator", "{{{appName}}}"]
3131
{{#poetry1}}
32-
include = ["{{packageName}}/py.typed"]
32+
{{#sourceFolder}}
33+
packages = [
34+
{ include = "{{{packageName}}}", from = "{{{sourceFolder}}}" },
35+
]
36+
{{/sourceFolder}}
37+
include = ["{{#sourceFolder}}{{{sourceFolder}}}/{{/sourceFolder}}{{packageName}}/py.typed"]
3338

3439
[tool.poetry.dependencies]
3540
python = "^3.10"
@@ -122,7 +127,7 @@ extension-pkg-whitelist = "pydantic"
122127

123128
[tool.mypy]
124129
files = [
125-
"{{{packageName}}}",
130+
"{{#sourceFolder}}{{{sourceFolder}}}/{{/sourceFolder}}{{{packageName}}}",
126131
#"test", # auto-generated tests
127132
"tests", # hand-written tests
128133
]

‎modules/openapi-generator/src/main/resources/python/setup.mustache‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ setup(
4343
url="{{packageUrl}}",
4444
keywords=["OpenAPI", "OpenAPI-Generator", "{{{appName}}}"],
4545
install_requires=REQUIRES,
46-
packages=find_packages(exclude=["test", "tests"]),
46+
{{#sourceFolder}}
47+
package_dir={"": "{{{sourceFolder}}}"},
48+
{{/sourceFolder}}
49+
packages=find_packages({{#sourceFolder}}where="{{{sourceFolder}}}", {{/sourceFolder}}exclude=["test", "tests"]),
4750
include_package_data=True,
4851
{{#licenseInfo}}license="{{.}}",
4952
{{/licenseInfo}}long_description_content_type='text/markdown',

‎modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ public void testInitialConfigValues() throws Exception {
139139
PythonClientCodegen.USE_INDEPENDENT_IMPLICIT_CLIENTS),
140140
Boolean.FALSE);
141141
Assert.assertEquals(codegen.isHideGenerationTimestamp(), true);
142+
Assert.assertNull(codegen.additionalProperties().get(CodegenConstants.SOURCE_FOLDER));
142143
}
143144

144145
@Test
@@ -1685,4 +1686,25 @@ public void testIndependentImplicitClientsPreserveDefaultsWhenDisabled() throws
16851686
"def close(self) -> None:");
16861687
TestUtils.assertFileNotContains(rest, "def close(self) -> None:");
16871688
}
1689+
1690+
@Test( description = "test src-layout with setuptools" )
1691+
public void testSrcLayoutSetuptools() throws IOException {
1692+
PythonClientCodegen codegen = new PythonClientCodegen();
1693+
codegen.additionalProperties().put(CodegenConstants.SOURCE_FOLDER, "src");
1694+
final String output = generateFiles(codegen, "src/test/resources/3_0/generic.yaml");
1695+
1696+
final Path setup = Paths.get(output, "setup.py");
1697+
assertFileContains(setup, "package_dir={\"\": \"src\"}", "packages=find_packages(where=\"src\", exclude=");
1698+
}
1699+
1700+
@Test( description = "test src-layout with poetry1" )
1701+
public void testSrcLayoutPoetry() throws IOException {
1702+
PythonClientCodegen codegen = new PythonClientCodegen();
1703+
codegen.additionalProperties().put(CodegenConstants.SOURCE_FOLDER, "src");
1704+
codegen.additionalProperties().put("poetry1", true);
1705+
final String output = generateFiles(codegen, "src/test/resources/3_0/generic.yaml");
1706+
1707+
final Path pyproject = Paths.get(output, "pyproject.toml");
1708+
assertFileContains(pyproject, "{ include = \"openapi_client\", from = \"src\" },", "include = [\"src/openapi_client/py.typed\"]", "\"src/openapi_client\",");
1709+
}
16881710
}

0 commit comments

Comments
 (0)