From 46a69552b2ac7abaa7ef234bc4533a13e6b03021 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 15 Jun 2026 09:49:51 -0700 Subject: [PATCH 1/6] fix(http-client-python): preserve customized pyproject.toml fields Preserve manually customized description, classifiers, and [project.urls] fields from an existing pyproject.toml instead of overwriting them on emit. Fixes #10311 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../preserve-pyproject-fields-2026-06-15.md | 7 ++ .../codegen/serializers/general_serializer.py | 7 ++ .../packaging_templates/pyproject.toml.jinja2 | 24 ++++++- .../tests/unit/test_pyproject_keep_fields.py | 72 +++++++++++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/preserve-pyproject-fields-2026-06-15.md create mode 100644 packages/http-client-python/tests/unit/test_pyproject_keep_fields.py diff --git a/.chronus/changes/preserve-pyproject-fields-2026-06-15.md b/.chronus/changes/preserve-pyproject-fields-2026-06-15.md new file mode 100644 index 00000000000..3642556247f --- /dev/null +++ b/.chronus/changes/preserve-pyproject-fields-2026-06-15.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Preserve manually customized `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py index 6c022c9259b..d94282ac662 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py @@ -85,6 +85,13 @@ def external_lib_version_map(self, file_content: str, additional_version_map: di # Process dependencies if "project" in loaded_pyproject_toml: + project = loaded_pyproject_toml["project"] + + # Keep manually customized project fields the emitter would otherwise overwrite. + for field in ("description", "classifiers", "urls"): + if field in project: + result["KEEP_FIELDS"][f"project.{field}"] = project[field] + # Handle main dependencies if "dependencies" in loaded_pyproject_toml["project"]: kept_deps = [] diff --git a/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 index 4a012849501..851cc641b3a 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 @@ -14,12 +14,21 @@ name = "{{ options.get('package-name')|lower }}" authors = [ { name = "{{ code_model.company_name }}"{% if code_model.is_azure_flavor %}, email = "azpysdkhelp@microsoft.com"{% endif %} }, ] -{% if options.get("azure-arm") %} +{% if KEEP_FIELDS and KEEP_FIELDS.get('project.description') %} +description = {{ KEEP_FIELDS.get('project.description')|tojson }} +{% elif options.get("azure-arm") %} description = "Microsoft Azure {{ options.get('package-pprint-name') }} Client Library for Python" {% else %} description = "{{ code_model.company_name }} {% if code_model.is_azure_flavor and not options.get('package-pprint-name').startswith('Azure ') %}Azure {% endif %}{{ options.get('package-pprint-name') }} Client Library for Python" {% endif %} license = "MIT" +{% if KEEP_FIELDS and KEEP_FIELDS.get('project.classifiers') %} +classifiers = [ + {% for classifier in KEEP_FIELDS.get('project.classifiers') %} + {{ classifier|tojson }}, + {% endfor %} +] +{% else %} classifiers = [ "Development Status :: {{ dev_status }}", "Programming Language :: Python", @@ -29,10 +38,15 @@ classifiers = [ "Programming Language :: Python :: 3.{{ version }}", {% endfor %} ] +{% endif %} requires-python = ">={{ MIN_PYTHON_VERSION }}" {% else %} +{% if KEEP_FIELDS and KEEP_FIELDS.get('project.description') %} +description = {{ KEEP_FIELDS.get('project.description')|tojson }} +{% else %} description = "{{ options.get('package-name') }}" {% endif %} +{% endif %} {% if code_model.is_azure_flavor %} keywords = ["azure", "azure sdk"] {% endif %} @@ -77,7 +91,13 @@ version = "{{ options.get("package-version", "unknown") }}" ] {% endfor %} {% endif %} -{% if code_model.is_azure_flavor %} +{% if KEEP_FIELDS and KEEP_FIELDS.get('project.urls') %} + +[project.urls] +{% for key, val in KEEP_FIELDS.get('project.urls').items() %} +{{ key }} = {{ val|tojson }} +{% endfor %} +{% elif code_model.is_azure_flavor %} [project.urls] repository = "https://github.com/Azure/azure-sdk-for-python" diff --git a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py new file mode 100644 index 00000000000..b52a8a4f4a8 --- /dev/null +++ b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py @@ -0,0 +1,72 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +"""Unit tests for preserving manually customized pyproject.toml fields. + +The emitter regenerates pyproject.toml on every emit. Manual edits to fields +the emitter doesn't own (description, classifiers, project URLs) must be +preserved so they are not clobbered (see GitHub issue #10311). +""" +from pygen.codegen.serializers.general_serializer import GeneralSerializer + + +def _keep_fields(file_content: str) -> dict: + # external_lib_version_map only relies on module-level helpers, not on + # instance state, so we can bypass __init__ for a focused unit test. + serializer = GeneralSerializer.__new__(GeneralSerializer) + return serializer.external_lib_version_map(file_content, {})["KEEP_FIELDS"] + + +def test_preserve_description(): + content = """ +[project] +name = "azure-ai-sample" +description = "Microsoft Azure AI Sample Client Library for Python" +""" + keep_fields = _keep_fields(content) + assert keep_fields["project.description"] == "Microsoft Azure AI Sample Client Library for Python" + + +def test_preserve_classifiers(): + content = """ +[project] +name = "azure-ai-sample" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +""" + keep_fields = _keep_fields(content) + assert "Programming Language :: Python :: 3.14" in keep_fields["project.classifiers"] + + +def test_preserve_project_urls(): + content = """ +[project] +name = "azure-ai-sample" + +[project.urls] +repository = "https://github.com/Azure/azure-sdk-for-python-custom" +documentation = "https://aka.ms/custom-docs" +""" + keep_fields = _keep_fields(content) + assert keep_fields["project.urls"]["repository"] == "https://github.com/Azure/azure-sdk-for-python-custom" + assert keep_fields["project.urls"]["documentation"] == "https://aka.ms/custom-docs" + + +def test_missing_fields_not_kept(): + content = """ +[project] +name = "azure-ai-sample" +""" + keep_fields = _keep_fields(content) + assert "project.description" not in keep_fields + assert "project.classifiers" not in keep_fields + assert "project.urls" not in keep_fields + + +def test_invalid_toml_returns_empty(): + assert _keep_fields("this is : not valid = toml [[[") == {} From 71c58056e635bf61b98f9cb88a9deb01c4345832 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 15 Jun 2026 10:08:00 -0700 Subject: [PATCH 2/6] Gate pyproject field preservation behind keep-pyproject-fields option Add a keep-pyproject-fields emitter option so description, classifiers, and [project.urls] in an existing pyproject.toml are only preserved when the option is explicitly enabled in tspconfig.yaml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...rve-pyproject-fields-2026-06-15-feature.md | 14 +++++++++++++ .../preserve-pyproject-fields-2026-06-15.md | 7 ------- .../http-client-python/emitter/src/lib.ts | 7 +++++++ .../generator/pygen/__init__.py | 1 + .../codegen/serializers/general_serializer.py | 15 +++++++++----- .../tests/unit/test_pyproject_keep_fields.py | 20 +++++++++++++++++-- .../http-client-python/reference/emitter.md | 6 ++++++ 7 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 .chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md delete mode 100644 .chronus/changes/preserve-pyproject-fields-2026-06-15.md diff --git a/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md b/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md new file mode 100644 index 00000000000..eea256c5f69 --- /dev/null +++ b/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md @@ -0,0 +1,14 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client-python" +--- + +Add a `keep-pyproject-fields` emitter option that, when enabled, preserves manually customized `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. + +```yaml +# tspconfig.yaml +options: + "@typespec/http-client-python": + keep-pyproject-fields: true +``` diff --git a/.chronus/changes/preserve-pyproject-fields-2026-06-15.md b/.chronus/changes/preserve-pyproject-fields-2026-06-15.md deleted file mode 100644 index 3642556247f..00000000000 --- a/.chronus/changes/preserve-pyproject-fields-2026-06-15.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -changeKind: fix -packages: - - "@typespec/http-client-python" ---- - -Preserve manually customized `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index b267c836bf3..b590293642a 100644 --- a/packages/http-client-python/emitter/src/lib.ts +++ b/packages/http-client-python/emitter/src/lib.ts @@ -24,6 +24,7 @@ export interface PythonEmitterOptions { "head-as-boolean"?: boolean; "use-pyodide"?: boolean; "keep-setup-py"?: boolean; + "keep-pyproject-fields"?: boolean; "clear-output-folder"?: boolean; "emit-yaml-only"?: boolean; } @@ -105,6 +106,12 @@ export const PythonEmitterOptionsSchema: JSONSchemaType = description: "Whether to keep the existing `setup.py` when `generate-packaging-files` is `true`. If set to `false` and by default, `pyproject.toml` will be generated instead. To generate `setup.py`, use `basic-setup-py`.", }, + "keep-pyproject-fields": { + type: "boolean", + nullable: true, + description: + "Whether to preserve manually customized `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. Defaults to `false`.", + }, "clear-output-folder": { type: "boolean", nullable: true, diff --git a/packages/http-client-python/generator/pygen/__init__.py b/packages/http-client-python/generator/pygen/__init__.py index ce6b4d3c7bb..7abe656e4c2 100644 --- a/packages/http-client-python/generator/pygen/__init__.py +++ b/packages/http-client-python/generator/pygen/__init__.py @@ -29,6 +29,7 @@ class OptionsDict(MutableMapping): "from-typespec": False, "generate-sample": False, "keep-setup-py": False, + "keep-pyproject-fields": False, "generate-test": False, "head-as-boolean": True, "keep-version-file": False, diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py index d94282ac662..1da7bf610c0 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py @@ -66,7 +66,9 @@ def _update_version_map(self, version_map: dict[str, str], dep_name: str, dep: s if dep_version > default_version: version_map[dep_name] = str(dep_version) - def external_lib_version_map(self, file_content: str, additional_version_map: dict[str, str]) -> dict: + def external_lib_version_map( + self, file_content: str, additional_version_map: dict[str, str], keep_pyproject_fields: bool = False + ) -> dict: # Load the pyproject.toml file if it exists and extract fields to keep. result: dict = {"KEEP_FIELDS": {}} try: @@ -88,9 +90,11 @@ def external_lib_version_map(self, file_content: str, additional_version_map: di project = loaded_pyproject_toml["project"] # Keep manually customized project fields the emitter would otherwise overwrite. - for field in ("description", "classifiers", "urls"): - if field in project: - result["KEEP_FIELDS"][f"project.{field}"] = project[field] + # Only done when the "keep-pyproject-fields" option is explicitly enabled. + if keep_pyproject_fields: + for field in ("description", "classifiers", "urls"): + if field in project: + result["KEEP_FIELDS"][f"project.{field}"] = project[field] # Handle main dependencies if "dependencies" in loaded_pyproject_toml["project"]: @@ -134,7 +138,8 @@ def serialize_package_file(self, template_name: str, file_content: str, **kwargs # Add fields to keep from an existing pyproject.toml if template_name == "pyproject.toml.jinja2": - params = self.external_lib_version_map(file_content, additional_version_map) + keep_pyproject_fields = bool(self.code_model.options.get("keep-pyproject-fields")) + params = self.external_lib_version_map(file_content, additional_version_map, keep_pyproject_fields) else: params = {} diff --git a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py index b52a8a4f4a8..b403001e7f2 100644 --- a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py +++ b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py @@ -12,11 +12,11 @@ from pygen.codegen.serializers.general_serializer import GeneralSerializer -def _keep_fields(file_content: str) -> dict: +def _keep_fields(file_content: str, keep_pyproject_fields: bool = True) -> dict: # external_lib_version_map only relies on module-level helpers, not on # instance state, so we can bypass __init__ for a focused unit test. serializer = GeneralSerializer.__new__(GeneralSerializer) - return serializer.external_lib_version_map(file_content, {})["KEEP_FIELDS"] + return serializer.external_lib_version_map(file_content, {}, keep_pyproject_fields)["KEEP_FIELDS"] def test_preserve_description(): @@ -57,6 +57,22 @@ def test_preserve_project_urls(): assert keep_fields["project.urls"]["documentation"] == "https://aka.ms/custom-docs" +def test_fields_not_kept_when_option_disabled(): + content = """ +[project] +name = "azure-ai-sample" +description = "Microsoft Azure AI Sample Client Library for Python" +classifiers = ["Programming Language :: Python :: 3.14"] + +[project.urls] +repository = "https://github.com/Azure/azure-sdk-for-python-custom" +""" + keep_fields = _keep_fields(content, keep_pyproject_fields=False) + assert "project.description" not in keep_fields + assert "project.classifiers" not in keep_fields + assert "project.urls" not in keep_fields + + def test_missing_fields_not_kept(): content = """ [project] diff --git a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md index bab8db81a1a..83076ec7385 100644 --- a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md @@ -114,6 +114,12 @@ The subdirectory to generate the code in. If not specified, the code will be gen Whether to keep the existing `setup.py` when `generate-packaging-files` is `true`. If set to `false` and by default, `pyproject.toml` will be generated instead. To generate `setup.py`, use `basic-setup-py`. +### `keep-pyproject-fields` + +**Type:** `boolean` + +Whether to preserve manually customized `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. Defaults to `false`. + ### `clear-output-folder` **Type:** `boolean` From 101f04ad4843c689bedb57cd917e0563abdcd9eb Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 15 Jun 2026 10:13:11 -0700 Subject: [PATCH 3/6] Also preserve project.authors with keep-pyproject-fields option Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...reserve-pyproject-fields-2026-06-15-feature.md | 2 +- packages/http-client-python/emitter/src/lib.ts | 2 +- .../codegen/serializers/general_serializer.py | 2 +- .../packaging_templates/pyproject.toml.jinja2 | 8 ++++++++ .../tests/unit/test_pyproject_keep_fields.py | 15 +++++++++++++++ .../http-client-python/reference/emitter.md | 2 +- 6 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md b/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md index eea256c5f69..4ea2db5bd2b 100644 --- a/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md +++ b/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md @@ -4,7 +4,7 @@ packages: - "@typespec/http-client-python" --- -Add a `keep-pyproject-fields` emitter option that, when enabled, preserves manually customized `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. +Add a `keep-pyproject-fields` emitter option that, when enabled, preserves manually customized `authors`, `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. ```yaml # tspconfig.yaml diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index b590293642a..f31001d9b0b 100644 --- a/packages/http-client-python/emitter/src/lib.ts +++ b/packages/http-client-python/emitter/src/lib.ts @@ -110,7 +110,7 @@ export const PythonEmitterOptionsSchema: JSONSchemaType = type: "boolean", nullable: true, description: - "Whether to preserve manually customized `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. Defaults to `false`.", + "Whether to preserve manually customized `authors`, `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. Defaults to `false`.", }, "clear-output-folder": { type: "boolean", diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py index 1da7bf610c0..2ef24d7b84e 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py @@ -92,7 +92,7 @@ def external_lib_version_map( # Keep manually customized project fields the emitter would otherwise overwrite. # Only done when the "keep-pyproject-fields" option is explicitly enabled. if keep_pyproject_fields: - for field in ("description", "classifiers", "urls"): + for field in ("description", "classifiers", "urls", "authors"): if field in project: result["KEEP_FIELDS"][f"project.{field}"] = project[field] diff --git a/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 index 851cc641b3a..68915429341 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 @@ -11,9 +11,17 @@ build-backend = "setuptools.build_meta" [project] name = "{{ options.get('package-name')|lower }}" {% if options.get('package-mode') %} +{% if KEEP_FIELDS and KEEP_FIELDS.get('project.authors') %} +authors = [ + {% for author in KEEP_FIELDS.get('project.authors') %} + { {% for key, val in author.items() %}{{ key }} = {{ val|tojson }}{% if not loop.last %}, {% endif %}{% endfor %} }, + {% endfor %} +] +{% else %} authors = [ { name = "{{ code_model.company_name }}"{% if code_model.is_azure_flavor %}, email = "azpysdkhelp@microsoft.com"{% endif %} }, ] +{% endif %} {% if KEEP_FIELDS and KEEP_FIELDS.get('project.description') %} description = {{ KEEP_FIELDS.get('project.description')|tojson }} {% elif options.get("azure-arm") %} diff --git a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py index b403001e7f2..dd09d72233a 100644 --- a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py +++ b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py @@ -57,12 +57,25 @@ def test_preserve_project_urls(): assert keep_fields["project.urls"]["documentation"] == "https://aka.ms/custom-docs" +def test_preserve_authors(): + content = """ +[project] +name = "azure-ai-sample" +authors = [ + { name = "Custom Team", email = "custom-team@contoso.com" }, +] +""" + keep_fields = _keep_fields(content) + assert keep_fields["project.authors"] == [{"name": "Custom Team", "email": "custom-team@contoso.com"}] + + def test_fields_not_kept_when_option_disabled(): content = """ [project] name = "azure-ai-sample" description = "Microsoft Azure AI Sample Client Library for Python" classifiers = ["Programming Language :: Python :: 3.14"] +authors = [{ name = "Custom Team", email = "custom-team@contoso.com" }] [project.urls] repository = "https://github.com/Azure/azure-sdk-for-python-custom" @@ -71,6 +84,7 @@ def test_fields_not_kept_when_option_disabled(): assert "project.description" not in keep_fields assert "project.classifiers" not in keep_fields assert "project.urls" not in keep_fields + assert "project.authors" not in keep_fields def test_missing_fields_not_kept(): @@ -82,6 +96,7 @@ def test_missing_fields_not_kept(): assert "project.description" not in keep_fields assert "project.classifiers" not in keep_fields assert "project.urls" not in keep_fields + assert "project.authors" not in keep_fields def test_invalid_toml_returns_empty(): diff --git a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md index 83076ec7385..62fab7f70d5 100644 --- a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md @@ -118,7 +118,7 @@ Whether to keep the existing `setup.py` when `generate-packaging-files` is `true **Type:** `boolean` -Whether to preserve manually customized `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. Defaults to `false`. +Whether to preserve manually customized `authors`, `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. Defaults to `false`. ### `clear-output-folder` From 6e6e3c786f871bb0b0919f5a2cb2df5016cd5e83 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 16 Jun 2026 08:34:11 -0700 Subject: [PATCH 4/6] Make keep-pyproject-fields a configurable field list Per review feedback, change keep-pyproject-fields from a boolean toggle to a comma-separated list of project fields (authors, description, classifiers, urls) so users can choose exactly which fields to preserve. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...rve-pyproject-fields-2026-06-15-feature.md | 4 +- .../http-client-python/emitter/src/lib.ts | 6 +-- .../generator/pygen/__init__.py | 2 +- .../codegen/serializers/general_serializer.py | 32 ++++++++++--- .../tests/unit/test_pyproject_keep_fields.py | 47 +++++++++++++++++-- .../http-client-python/reference/emitter.md | 4 +- 6 files changed, 77 insertions(+), 18 deletions(-) diff --git a/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md b/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md index 4ea2db5bd2b..c640f1af670 100644 --- a/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md +++ b/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md @@ -4,11 +4,11 @@ packages: - "@typespec/http-client-python" --- -Add a `keep-pyproject-fields` emitter option that, when enabled, preserves manually customized `authors`, `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. +Add a `keep-pyproject-fields` emitter option that takes a comma-separated list of `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Supported fields: `authors`, `description`, `classifiers`, `urls`. ```yaml # tspconfig.yaml options: "@typespec/http-client-python": - keep-pyproject-fields: true + keep-pyproject-fields: "authors,description" ``` diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index f31001d9b0b..f4526cdda50 100644 --- a/packages/http-client-python/emitter/src/lib.ts +++ b/packages/http-client-python/emitter/src/lib.ts @@ -24,7 +24,7 @@ export interface PythonEmitterOptions { "head-as-boolean"?: boolean; "use-pyodide"?: boolean; "keep-setup-py"?: boolean; - "keep-pyproject-fields"?: boolean; + "keep-pyproject-fields"?: string; "clear-output-folder"?: boolean; "emit-yaml-only"?: boolean; } @@ -107,10 +107,10 @@ export const PythonEmitterOptionsSchema: JSONSchemaType = "Whether to keep the existing `setup.py` when `generate-packaging-files` is `true`. If set to `false` and by default, `pyproject.toml` will be generated instead. To generate `setup.py`, use `basic-setup-py`.", }, "keep-pyproject-fields": { - type: "boolean", + type: "string", nullable: true, description: - "Whether to preserve manually customized `authors`, `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. Defaults to `false`.", + "A comma-separated list of manually customized `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Supported fields: `authors`, `description`, `classifiers`, `urls`. For example, `authors,description` keeps only those two fields. Defaults to preserving nothing.", }, "clear-output-folder": { type: "boolean", diff --git a/packages/http-client-python/generator/pygen/__init__.py b/packages/http-client-python/generator/pygen/__init__.py index 7abe656e4c2..99c890b9553 100644 --- a/packages/http-client-python/generator/pygen/__init__.py +++ b/packages/http-client-python/generator/pygen/__init__.py @@ -29,7 +29,7 @@ class OptionsDict(MutableMapping): "from-typespec": False, "generate-sample": False, "keep-setup-py": False, - "keep-pyproject-fields": False, + "keep-pyproject-fields": "", "generate-test": False, "head-as-boolean": True, "keep-version-file": False, diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py index 2ef24d7b84e..c55dc359c65 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py @@ -66,8 +66,25 @@ def _update_version_map(self, version_map: dict[str, str], dep_name: str, dep: s if dep_version > default_version: version_map[dep_name] = str(dep_version) + # Project-level pyproject.toml fields that may be preserved across regeneration + # via the "keep-pyproject-fields" option. + KEEPABLE_PROJECT_FIELDS = ("authors", "description", "classifiers", "urls") + + @staticmethod + def _parse_keep_pyproject_fields(value: Any) -> tuple[str, ...]: + # The "keep-pyproject-fields" option may be a comma-separated string (e.g. + # "authors,description") or an already-parsed sequence of field names. + if not value: + return () + if isinstance(value, str): + return tuple(field.strip() for field in value.split(",") if field.strip()) + return tuple(value) + def external_lib_version_map( - self, file_content: str, additional_version_map: dict[str, str], keep_pyproject_fields: bool = False + self, + file_content: str, + additional_version_map: dict[str, str], + keep_pyproject_fields: tuple[str, ...] = (), ) -> dict: # Load the pyproject.toml file if it exists and extract fields to keep. result: dict = {"KEEP_FIELDS": {}} @@ -90,11 +107,10 @@ def external_lib_version_map( project = loaded_pyproject_toml["project"] # Keep manually customized project fields the emitter would otherwise overwrite. - # Only done when the "keep-pyproject-fields" option is explicitly enabled. - if keep_pyproject_fields: - for field in ("description", "classifiers", "urls", "authors"): - if field in project: - result["KEEP_FIELDS"][f"project.{field}"] = project[field] + # Only the fields explicitly listed in the "keep-pyproject-fields" option are preserved. + for field in keep_pyproject_fields: + if field in self.KEEPABLE_PROJECT_FIELDS and field in project: + result["KEEP_FIELDS"][f"project.{field}"] = project[field] # Handle main dependencies if "dependencies" in loaded_pyproject_toml["project"]: @@ -138,7 +154,9 @@ def serialize_package_file(self, template_name: str, file_content: str, **kwargs # Add fields to keep from an existing pyproject.toml if template_name == "pyproject.toml.jinja2": - keep_pyproject_fields = bool(self.code_model.options.get("keep-pyproject-fields")) + keep_pyproject_fields = self._parse_keep_pyproject_fields( + self.code_model.options.get("keep-pyproject-fields") + ) params = self.external_lib_version_map(file_content, additional_version_map, keep_pyproject_fields) else: params = {} diff --git a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py index dd09d72233a..253bc3154e7 100644 --- a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py +++ b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py @@ -12,7 +12,10 @@ from pygen.codegen.serializers.general_serializer import GeneralSerializer -def _keep_fields(file_content: str, keep_pyproject_fields: bool = True) -> dict: +_ALL_FIELDS = ("authors", "description", "classifiers", "urls") + + +def _keep_fields(file_content: str, keep_pyproject_fields=_ALL_FIELDS) -> dict: # external_lib_version_map only relies on module-level helpers, not on # instance state, so we can bypass __init__ for a focused unit test. serializer = GeneralSerializer.__new__(GeneralSerializer) @@ -69,7 +72,7 @@ def test_preserve_authors(): assert keep_fields["project.authors"] == [{"name": "Custom Team", "email": "custom-team@contoso.com"}] -def test_fields_not_kept_when_option_disabled(): +def test_fields_not_kept_when_option_empty(): content = """ [project] name = "azure-ai-sample" @@ -80,13 +83,51 @@ def test_fields_not_kept_when_option_disabled(): [project.urls] repository = "https://github.com/Azure/azure-sdk-for-python-custom" """ - keep_fields = _keep_fields(content, keep_pyproject_fields=False) + keep_fields = _keep_fields(content, keep_pyproject_fields=()) assert "project.description" not in keep_fields assert "project.classifiers" not in keep_fields assert "project.urls" not in keep_fields assert "project.authors" not in keep_fields +def test_only_selected_fields_kept(): + content = """ +[project] +name = "azure-ai-sample" +description = "Microsoft Azure AI Sample Client Library for Python" +classifiers = ["Programming Language :: Python :: 3.14"] +authors = [{ name = "Custom Team", email = "custom-team@contoso.com" }] + +[project.urls] +repository = "https://github.com/Azure/azure-sdk-for-python-custom" +""" + keep_fields = _keep_fields(content, keep_pyproject_fields=("authors", "description")) + assert "project.authors" in keep_fields + assert "project.description" in keep_fields + assert "project.classifiers" not in keep_fields + assert "project.urls" not in keep_fields + + +def test_unknown_field_ignored(): + content = """ +[project] +name = "azure-ai-sample" +description = "kept" +""" + keep_fields = _keep_fields(content, keep_pyproject_fields=("description", "not-a-real-field")) + assert keep_fields["project.description"] == "kept" + assert "project.not-a-real-field" not in keep_fields + + +def test_parse_keep_pyproject_fields(): + parse = GeneralSerializer._parse_keep_pyproject_fields + assert parse(None) == () + assert parse("") == () + assert parse("authors,description") == ("authors", "description") + assert parse(" authors , description ") == ("authors", "description") + assert parse(["authors", "description"]) == ("authors", "description") + + def test_missing_fields_not_kept(): content = """ [project] diff --git a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md index 62fab7f70d5..ab7ed9b0a62 100644 --- a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md @@ -116,9 +116,9 @@ Whether to keep the existing `setup.py` when `generate-packaging-files` is `true ### `keep-pyproject-fields` -**Type:** `boolean` +**Type:** `string` -Whether to preserve manually customized `authors`, `description`, `classifiers`, and `[project.urls]` fields in an existing `pyproject.toml` instead of overwriting them on regeneration. Defaults to `false`. +A comma-separated list of manually customized `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Supported fields: `authors`, `description`, `classifiers`, `urls`. For example, `authors,description` keeps only those two fields. Defaults to preserving nothing. ### `clear-output-folder` From e81346c94df49b1131a341d72689873b36ad1479 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 16 Jun 2026 08:44:35 -0700 Subject: [PATCH 5/6] Add integration tests for keep-pyproject-fields option end-to-end Exercise the real OptionsDict -> parse -> keep -> template render chain to verify selecting specific fields preserves only those and regenerates the rest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/unit/test_pyproject_keep_fields.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py index 253bc3154e7..b58c4ef6000 100644 --- a/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py +++ b/packages/http-client-python/tests/unit/test_pyproject_keep_fields.py @@ -142,3 +142,98 @@ def test_missing_fields_not_kept(): def test_invalid_toml_returns_empty(): assert _keep_fields("this is : not valid = toml [[[") == {} + + +# --- Integration test: option string -> OptionsDict -> parse -> keep -> render --- + +import tomli # noqa: E402 +from pathlib import Path # noqa: E402 +from types import SimpleNamespace # noqa: E402 +from jinja2 import Environment, FileSystemLoader # noqa: E402 +from pygen import OptionsDict # noqa: E402 + + +_TEMPLATE_DIR = str((Path(__file__).resolve().parents[2] / "generator" / "pygen" / "codegen" / "templates")) + + +def _render_pyproject(option_value, existing_content): + """Mirror the relevant wiring in GeneralSerializer.serialize_package_file: + read the option off a real OptionsDict, parse it, extract KEEP_FIELDS from + the existing pyproject.toml, then render the actual template.""" + options = OptionsDict( + { + "package-name": "azure-ai-sample", + "package-mode": "azure-dataplane", + "package-version": "1.0.0", + "keep-pyproject-fields": option_value, + } + ) + serializer = GeneralSerializer.__new__(GeneralSerializer) + parsed = serializer._parse_keep_pyproject_fields(options.get("keep-pyproject-fields")) + keep_fields = serializer.external_lib_version_map(existing_content, {}, parsed)["KEEP_FIELDS"] + + env = Environment(loader=FileSystemLoader(_TEMPLATE_DIR)) + template = env.get_template("packaging_templates/pyproject.toml.jinja2") + code_model = SimpleNamespace( + license_header="", + is_azure_flavor=True, + company_name="Microsoft Corporation", + is_tsp=True, + namespace="azure.ai.sample", + ) + rendered = template.render( + KEEP_FIELDS=keep_fields, + code_model=code_model, + options=options, + dev_status="5 - Production/Stable", + token_credential=False, + pkgutil_names=[], + init_names=[], + client_name="X", + VERSION_MAP={"isodate": "0.6.1", "azure-core": "1.37.0", "typing-extensions": "4.6.0"}, + MIN_PYTHON_VERSION="3.10", + MAX_PYTHON_VERSION="3.14", + ADDITIONAL_DEPENDENCIES=[], + ) + return tomli.loads(rendered) + + +_EXISTING = """ +[project] +name = "azure-ai-sample" +description = "Microsoft Azure AI Sample Client Library for Python" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python :: 3.99", +] +authors = [{ name = "Custom Team", email = "custom-team@contoso.com" }] + +[project.urls] +repository = "https://github.com/Azure/azure-sdk-for-python-custom" +""" + + +def test_render_keeps_selected_fields(): + parsed = _render_pyproject("authors,description", _EXISTING)["project"] + # Selected fields are preserved from the existing file. + assert parsed["authors"] == [{"name": "Custom Team", "email": "custom-team@contoso.com"}] + assert parsed["description"] == "Microsoft Azure AI Sample Client Library for Python" + # Unselected fields are regenerated (custom values dropped). + assert "Programming Language :: Python :: 3.99" not in parsed["classifiers"] + assert parsed["urls"]["repository"] == "https://github.com/Azure/azure-sdk-for-python" + + +def test_render_keeps_nothing_by_default(): + parsed = _render_pyproject("", _EXISTING)["project"] + assert parsed["authors"] == [{"name": "Microsoft Corporation", "email": "azpysdkhelp@microsoft.com"}] + assert parsed["description"] != "Microsoft Azure AI Sample Client Library for Python" + assert "Programming Language :: Python :: 3.99" not in parsed["classifiers"] + assert parsed["urls"]["repository"] == "https://github.com/Azure/azure-sdk-for-python" + + +def test_render_keeps_all_fields(): + parsed = _render_pyproject("authors,description,classifiers,urls", _EXISTING)["project"] + assert parsed["authors"] == [{"name": "Custom Team", "email": "custom-team@contoso.com"}] + assert parsed["description"] == "Microsoft Azure AI Sample Client Library for Python" + assert "Programming Language :: Python :: 3.99" in parsed["classifiers"] + assert parsed["urls"]["repository"] == "https://github.com/Azure/azure-sdk-for-python-custom" From 5b99f3f0890ac32ca2c2515e9a2b9b9f75dfb09a Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Wed, 17 Jun 2026 14:54:34 -0700 Subject: [PATCH 6/6] Make keep-pyproject-fields an object option per review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...rve-pyproject-fields-2026-06-15-feature.md | 6 ++-- .../http-client-python/emitter/src/emitter.ts | 8 +++++ .../http-client-python/emitter/src/lib.ts | 35 +++++++++++++++++-- .../http-client-python/reference/emitter.md | 4 +-- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md b/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md index c640f1af670..7f34b9afc5a 100644 --- a/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md +++ b/.chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md @@ -4,11 +4,13 @@ packages: - "@typespec/http-client-python" --- -Add a `keep-pyproject-fields` emitter option that takes a comma-separated list of `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Supported fields: `authors`, `description`, `classifiers`, `urls`. +Add a `keep-pyproject-fields` emitter option that selects which `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Supported fields: `authors`, `description`, `classifiers`, `urls`. ```yaml # tspconfig.yaml options: "@typespec/http-client-python": - keep-pyproject-fields: "authors,description" + keep-pyproject-fields: + authors: true + description: true ``` diff --git a/packages/http-client-python/emitter/src/emitter.ts b/packages/http-client-python/emitter/src/emitter.ts index 0208795e024..32561475861 100644 --- a/packages/http-client-python/emitter/src/emitter.ts +++ b/packages/http-client-python/emitter/src/emitter.ts @@ -210,6 +210,14 @@ async function onEmitMain(context: EmitContext) { commandArgs["packaging-files-config"] = keyValuePairs.join("|"); resolvedOptions["packaging-files-config"] = undefined; } + if (resolvedOptions["keep-pyproject-fields"]) { + // Flatten the object of enabled fields into a comma-separated list for the generator. + const enabledFields = Object.entries(resolvedOptions["keep-pyproject-fields"]) + .filter(([, value]) => value === true) + .map(([key]) => key); + commandArgs["keep-pyproject-fields"] = enabledFields.join(","); + resolvedOptions["keep-pyproject-fields"] = undefined; + } for (const [key, value] of Object.entries(resolvedOptions)) { if (key === "license") continue; // skip license since it is passed in codeModel diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index f4526cdda50..650dff7ea48 100644 --- a/packages/http-client-python/emitter/src/lib.ts +++ b/packages/http-client-python/emitter/src/lib.ts @@ -24,7 +24,12 @@ export interface PythonEmitterOptions { "head-as-boolean"?: boolean; "use-pyodide"?: boolean; "keep-setup-py"?: boolean; - "keep-pyproject-fields"?: string; + "keep-pyproject-fields"?: { + authors?: boolean; + description?: boolean; + classifiers?: boolean; + urls?: boolean; + }; "clear-output-folder"?: boolean; "emit-yaml-only"?: boolean; } @@ -107,10 +112,34 @@ export const PythonEmitterOptionsSchema: JSONSchemaType = "Whether to keep the existing `setup.py` when `generate-packaging-files` is `true`. If set to `false` and by default, `pyproject.toml` will be generated instead. To generate `setup.py`, use `basic-setup-py`.", }, "keep-pyproject-fields": { - type: "string", + type: "object", nullable: true, description: - "A comma-separated list of manually customized `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Supported fields: `authors`, `description`, `classifiers`, `urls`. For example, `authors,description` keeps only those two fields. Defaults to preserving nothing.", + "Which manually customized `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Set a field to `true` to keep it. By default no fields are preserved.", + properties: { + authors: { + type: "boolean", + nullable: true, + description: "Preserve the `authors` field (e.g. a custom author name and email).", + }, + description: { + type: "boolean", + nullable: true, + description: "Preserve the `description` field.", + }, + classifiers: { + type: "boolean", + nullable: true, + description: "Preserve the `classifiers` field.", + }, + urls: { + type: "boolean", + nullable: true, + description: "Preserve the `[project.urls]` table.", + }, + }, + required: [], + additionalProperties: false, }, "clear-output-folder": { type: "boolean", diff --git a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md index ab7ed9b0a62..438f8ae11f5 100644 --- a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md @@ -116,9 +116,9 @@ Whether to keep the existing `setup.py` when `generate-packaging-files` is `true ### `keep-pyproject-fields` -**Type:** `string` +**Type:** `object` -A comma-separated list of manually customized `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Supported fields: `authors`, `description`, `classifiers`, `urls`. For example, `authors,description` keeps only those two fields. Defaults to preserving nothing. +Which manually customized `[project]` fields to preserve in an existing `pyproject.toml` instead of overwriting them on regeneration. Set a field to `true` to keep it. Supported fields: `authors`, `description`, `classifiers`, `urls`. By default no fields are preserved. ### `clear-output-folder`