diff --git a/.chronus/changes/fix-import-generation-subdir-2026-3-8-12-7-49.md b/.chronus/changes/fix-import-generation-subdir-2026-3-8-12-7-49.md new file mode 100644 index 00000000000..98bf7d7c52a --- /dev/null +++ b/.chronus/changes/fix-import-generation-subdir-2026-3-8-12-7-49.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +fix import for _validation.py/_types.py when "generation-subdir" is configured \ No newline at end of file diff --git a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts index 101cfc7b293..d0357fc3c29 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts @@ -208,10 +208,19 @@ export const BASE_EMITTER_OPTIONS: Record< "package-name": "typetest-model-singlediscriminator", namespace: "typetest.model.singlediscriminator", }, - "type/model/inheritance/recursive": { - "package-name": "typetest-model-recursive", - namespace: "typetest.model.recursive", - }, + "type/model/inheritance/recursive": [ + { + "package-name": "typetest-model-recursive", + namespace: "typetest.model.recursive", + }, + { + // basic test for configuration "generation-subdir" + "package-name": "generation-subdir", + namespace: "generation.subdir", + "generation-subdir": "_generated", + "clear-output-folder": "true", + }, + ], "type/model/usage": { "package-name": "typetest-model-usage", namespace: "typetest.model.usage", @@ -268,6 +277,18 @@ export const BASE_EMITTER_OPTIONS: Record< "package-name": "specs-documentation", namespace: "specs.documentation", }, + "versioning/added": [ + { + "package-name": "versioning-added", + namespace: "versioning.added", + }, + // check whether import of _validation.py/_types.py works when "generation-subdir" is configured + { + "package-name": "generation-subdir2", + namespace: "generation.subdir2", + "generation-subdir": "_generated", + }, + ], }; // ---- Shared interfaces ---- @@ -474,3 +495,44 @@ export async function regenerate( await runTaskPool(tasks, poolLimit); } } + +// Preprocess: create files that should be deleted after regeneration (for testing) +export async function preprocess(flavor: string, generatedFolder: string): Promise { + if (flavor === "azure") { + // Use tests/generated// structure (same as output) + const testsGeneratedDir = resolve(generatedFolder, "../tests/generated/azure"); + + const DELETE_CONTENT = "# This file is to be deleted after regeneration"; + const DELETE_FILE = "to_be_deleted.py"; + const entries: { folder: string[]; file: string; content: string }[] = [ + { + folder: ["authentication-api-key", "authentication", "apikey", "_operations"], + file: DELETE_FILE, + content: DELETE_CONTENT, + }, + { + folder: ["generation-subdir", "generation", "subdir", "_generated"], + file: DELETE_FILE, + content: DELETE_CONTENT, + }, + { + folder: ["generation-subdir", "generated_tests"], + file: DELETE_FILE, + content: DELETE_CONTENT, + }, + { + folder: ["generation-subdir", "generation", "subdir"], + file: "to_be_kept.py", + content: "# This file is to be kept after regeneration", + }, + ]; + + await Promise.all( + entries.map(async ({ folder, file, content }) => { + const targetFolder = join(testsGeneratedDir, ...folder); + await promises.mkdir(targetFolder, { recursive: true }); + await promises.writeFile(join(targetFolder, file), content); + }), + ); + } +} diff --git a/packages/http-client-python/eng/scripts/ci/regenerate.ts b/packages/http-client-python/eng/scripts/ci/regenerate.ts index 3bb5d37ef15..f18f3a3a9c0 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate.ts @@ -7,7 +7,7 @@ */ import { compile, NodeHost } from "@typespec/compiler"; -import { promises, rmSync } from "fs"; +import { rmSync } from "fs"; import { platform } from "os"; import { dirname, join, relative, resolve } from "path"; import pc from "picocolors"; @@ -17,6 +17,7 @@ import { BASE_AZURE_EMITTER_OPTIONS, BASE_EMITTER_OPTIONS, getSubdirectories, + preprocess, SpecialFlags, toPosix, type RegenerateFlags, @@ -108,10 +109,6 @@ const EMITTER_OPTIONS: Record | Record { - if (flavor === "azure") { - // Use tests/generated// structure (same as output) - const testsGeneratedDir = resolve(GENERATED_FOLDER, "../tests/generated"); - const folderParts = [ - "azure", - "authentication-api-key", - "authentication", - "apikey", - "_operations", - ]; - await promises.mkdir(join(testsGeneratedDir, ...folderParts), { recursive: true }); - await promises.writeFile( - join(testsGeneratedDir, ...folderParts, "to_be_deleted.py"), - "# This file is to be deleted after regeneration", - ); - } -} - async function regenerateFlavor( flavor: string, name: string | undefined, @@ -296,7 +273,7 @@ async function regenerateFlavor( const flags: RegenerateFlags = { flavor, debug, name }; // Preprocess - await preprocess(flavor); + await preprocess(flavor, GENERATED_FOLDER); // Collect specs const azureSpecs = flavor === "azure" ? await getSubdirectories(AZURE_HTTP_SPECS, flags) : []; diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py index 6a16ed93e48..018605575c9 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -482,6 +482,7 @@ def _serialize_and_write_utils_folder(self, env: Environment, namespace: str): def _serialize_and_write_top_level_folder(self, env: Environment, namespace: str) -> None: root_dir = self.code_model.get_root_dir() + generation_dir = self.code_model.get_generation_dir(namespace) # write _utils folder self._serialize_and_write_utils_folder(env, self.code_model.namespace) @@ -498,16 +499,18 @@ def _serialize_and_write_top_level_folder(self, env: Environment, namespace: str self.write_file(root_dir / Path("py.typed"), pytyped_value) # write _validation.py + # Use generation_dir so that relative imports from operations/clients + # within a generation-subdir resolve correctly. if any(og for client in self.code_model.clients for og in client.operation_groups if og.need_validation): self.write_file( - root_dir / Path("_validation.py"), + generation_dir / Path("_validation.py"), general_serializer.serialize_validation_file(), ) # write _types.py if self.code_model.named_unions: self.write_file( - root_dir / Path("_types.py"), + generation_dir / Path("_types.py"), TypesSerializer(code_model=self.code_model, env=env).serialize(), ) diff --git a/packages/http-client-python/tests/conftest.py b/packages/http-client-python/tests/conftest.py index 4694d58f218..d3780e9e216 100644 --- a/packages/http-client-python/tests/conftest.py +++ b/packages/http-client-python/tests/conftest.py @@ -73,9 +73,11 @@ def start_server_process(): node_bin = str(ROOT / "node_modules" / ".bin") env["PATH"] = f"{node_bin}{os.pathsep}{env.get('PATH', '')}" + # Suppress server stdout/stderr to avoid confusing "Request validation failed" warnings + # in test output. Server readiness is validated via HTTP polling in wait_for_server(). if os.name == "nt": - return subprocess.Popen(cmd, shell=True, cwd=str(cwd), env=env) - return subprocess.Popen(cmd, shell=True, cwd=str(cwd), env=env, preexec_fn=os.setsid) + return subprocess.Popen(cmd, shell=True, cwd=str(cwd), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return subprocess.Popen(cmd, shell=True, cwd=str(cwd), env=env, preexec_fn=os.setsid, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def terminate_server_process(process): diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_generation_subdir2_for_generated_code_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_generation_subdir2_for_generated_code_async.py new file mode 100644 index 00000000000..f7fa5f9d08f --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_generation_subdir2_for_generated_code_async.py @@ -0,0 +1,37 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import pytest +import pytest_asyncio +from generation.subdir2._generated.aio import AddedClient +from generation.subdir2._generated.models import ModelV1, ModelV2, EnumV1, EnumV2 + + +@pytest_asyncio.fixture +async def client(): + async with AddedClient(endpoint="http://localhost:3000", version="v2") as client: + yield client + + +@pytest.mark.asyncio +async def test_v1(client: AddedClient): + assert await client.v1( + ModelV1(prop="foo", enum_prop=EnumV1.ENUM_MEMBER_V2, union_prop=10), + header_v2="bar", + ) == ModelV1(prop="foo", enum_prop=EnumV1.ENUM_MEMBER_V2, union_prop=10) + + +@pytest.mark.asyncio +async def test_v2(client: AddedClient): + assert await client.v2(ModelV2(prop="foo", enum_prop=EnumV2.ENUM_MEMBER, union_prop="bar")) == ModelV2( + prop="foo", enum_prop=EnumV2.ENUM_MEMBER, union_prop="bar" + ) + + +@pytest.mark.asyncio +async def test_interface_v2(client: AddedClient): + assert await client.interface_v2.v2_in_interface( + ModelV2(prop="foo", enum_prop=EnumV2.ENUM_MEMBER, union_prop="bar") + ) == ModelV2(prop="foo", enum_prop=EnumV2.ENUM_MEMBER, union_prop="bar") diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_generation_subdir_for_generated_code_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_generation_subdir_for_generated_code_async.py new file mode 100644 index 00000000000..4fd9e7496ee --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_generation_subdir_for_generated_code_async.py @@ -0,0 +1,25 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import pytest +import pytest_asyncio +from generation.subdir._generated.aio import RecursiveClient +from generation.subdir._generated.models import Extension + + +@pytest_asyncio.fixture +async def client(): + async with RecursiveClient() as client: + yield client + + +@pytest.mark.asyncio +async def test_custom_method(client: RecursiveClient): + assert await client.get() == Extension( + { + "level": 0, + "extension": [{"level": 1, "extension": [{"level": 2}]}, {"level": 1}], + } + ) diff --git a/packages/http-client-python/tests/mock_api/shared/test_generation_subdir2_for_generated_code.py b/packages/http-client-python/tests/mock_api/shared/test_generation_subdir2_for_generated_code.py new file mode 100644 index 00000000000..32bbfc784dd --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/test_generation_subdir2_for_generated_code.py @@ -0,0 +1,33 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import pytest +from generation.subdir2._generated import AddedClient +from generation.subdir2._generated.models import ModelV1, ModelV2, EnumV1, EnumV2 + + +@pytest.fixture +def client(): + with AddedClient(endpoint="http://localhost:3000", version="v2") as client: + yield client + + +def test_v1(client: AddedClient): + assert client.v1( + ModelV1(prop="foo", enum_prop=EnumV1.ENUM_MEMBER_V2, union_prop=10), + header_v2="bar", + ) == ModelV1(prop="foo", enum_prop=EnumV1.ENUM_MEMBER_V2, union_prop=10) + + +def test_v2(client: AddedClient): + assert client.v2(ModelV2(prop="foo", enum_prop=EnumV2.ENUM_MEMBER, union_prop="bar")) == ModelV2( + prop="foo", enum_prop=EnumV2.ENUM_MEMBER, union_prop="bar" + ) + + +def test_interface_v2(client: AddedClient): + assert client.interface_v2.v2_in_interface( + ModelV2(prop="foo", enum_prop=EnumV2.ENUM_MEMBER, union_prop="bar") + ) == ModelV2(prop="foo", enum_prop=EnumV2.ENUM_MEMBER, union_prop="bar") \ No newline at end of file diff --git a/packages/http-client-python/tests/mock_api/shared/test_generation_subdir_for_generated_code.py b/packages/http-client-python/tests/mock_api/shared/test_generation_subdir_for_generated_code.py new file mode 100644 index 00000000000..3750a98f1a3 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/test_generation_subdir_for_generated_code.py @@ -0,0 +1,17 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from generation.subdir._generated import RecursiveClient +from generation.subdir._generated.models import Extension + + +def test_custom_method(): + client = RecursiveClient() + assert client.get() == Extension( + { + "level": 0, + "extension": [{"level": 1, "extension": [{"level": 2}]}, {"level": 1}], + } + ) \ No newline at end of file diff --git a/packages/http-client-python/tests/mock_api/unbranded/test_unbranded.py b/packages/http-client-python/tests/mock_api/unbranded/test_unbranded.py index f576368fc33..f7366edc0a5 100644 --- a/packages/http-client-python/tests/mock_api/unbranded/test_unbranded.py +++ b/packages/http-client-python/tests/mock_api/unbranded/test_unbranded.py @@ -55,13 +55,3 @@ def check_sensitive_word(folder: Path, word: str) -> list[str]: def test_sensitive_word(): check_folder = (Path(os.path.dirname(__file__)) / "../../generated/unbranded").resolve() assert [] == check_sensitive_word(check_folder, "azure") - # after update spector, it shall also equal to [] - expected = [ - "authentication-oauth2", - "authentication-noauth-union", - "authentication-union", - "setuppy-authentication-union", - ] - if (check_folder / "generation-subdir").exists(): - expected.append("generation-subdir") - assert sorted(expected) == sorted(check_sensitive_word(check_folder, "microsoft"))