Skip to content
Merged
16 changes: 16 additions & 0 deletions .chronus/changes/preserve-pyproject-fields-2026-06-15-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
changeKind: feature
packages:
- "@typespec/http-client-python"
---

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: true
description: true
```
8 changes: 8 additions & 0 deletions packages/http-client-python/emitter/src/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ async function onEmitMain(context: EmitContext<PythonEmitterOptions>) {
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
Expand Down
36 changes: 36 additions & 0 deletions packages/http-client-python/emitter/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface PythonEmitterOptions {
"head-as-boolean"?: boolean;
"use-pyodide"?: boolean;
"keep-setup-py"?: boolean;
"keep-pyproject-fields"?: {
authors?: boolean;
description?: boolean;
classifiers?: boolean;
urls?: boolean;
};
"clear-output-folder"?: boolean;
"emit-yaml-only"?: boolean;
}
Expand Down Expand Up @@ -105,6 +111,36 @@ export const PythonEmitterOptionsSchema: JSONSchemaType<PythonEmitterOptions> =
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": {
Comment thread
iscai-msft marked this conversation as resolved.
type: "object",
nullable: true,
description:
"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",
nullable: true,
Expand Down
1 change: 1 addition & 0 deletions packages/http-client-python/generator/pygen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class OptionsDict(MutableMapping):
"from-typespec": False,
"generate-sample": False,
"keep-setup-py": False,
"keep-pyproject-fields": "",
"generate-test": False,
"head-as-boolean": True,
"keep-version-file": False,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,26 @@ 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:
# 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: tuple[str, ...] = (),
) -> dict:
# Load the pyproject.toml file if it exists and extract fields to keep.
result: dict = {"KEEP_FIELDS": {}}
try:
Expand All @@ -85,6 +104,14 @@ 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.
# 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"]:
kept_deps = []
Expand Down Expand Up @@ -127,7 +154,10 @@ 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 = 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 = {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,32 @@ 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 %} },
]
{% if options.get("azure-arm") %}
{% endif %}
{% 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",
Expand All @@ -29,10 +46,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 %}
Expand Down Expand Up @@ -77,7 +99,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"
Expand Down
Loading
Loading