From 1408592d9e505a213bff48f186385b33e24fb78f Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Mon, 16 Mar 2026 16:20:10 +0200 Subject: [PATCH 01/18] chore: verbatim copy from zope.meta --- pyproject.toml | 1 + src/plone/meta/setup_to_pyproject.py | 506 +++++++++++++++++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 src/plone/meta/setup_to_pyproject.py diff --git a/pyproject.toml b/pyproject.toml index 99d5dcd..fad65dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ config-package = "plone.meta.config_package:main" multi-call = "plone.meta.multi_call:main" re-enable-actions = "plone.meta.re_enable_actions:main" switch-to-pep420 = "plone.meta.pep_420:main" +setup-to-pyproject = "plone.meta.setup_to_pyproject:main" [tool.towncrier] directory = "news/" diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py new file mode 100644 index 0000000..3af90e8 --- /dev/null +++ b/src/plone/meta/setup_to_pyproject.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +############################################################################## +# +# Copyright (c) 2025 Zope Foundation and Contributors. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## + +import ast +import contextlib +import os +import pathlib +import shutil +import sys +from importlib.util import module_from_spec +from importlib.util import spec_from_file_location + +import tomlkit + +from .shared.call import call +from .shared.git import git_branch +from .shared.packages import META_HINT +from .shared.packages import OLDEST_PYTHON_VERSION +from .shared.packages import get_pyproject_toml +from .shared.path import change_dir +from .shared.script_args import get_shared_parser + + +PROJECT_SIMPLE_KEYS = ( + 'name', 'version', 'description', +) +IGNORE_KEYS = ( + 'zip_safe', 'long_description_content_type', 'package_dir', + 'packages', 'include_package_data', 'test_suite', 'tests_require', +) +UNCONVERTIBLE_KEYS = ( + 'cmdclass', 'ext_modules', 'headers', 'cffi_modules', +) + + +def parse_setup_function(ast_node, assigned_names=None): + """ Parse values out of the setup call ast definition """ + setup_kwargs = {} + assigned_names = assigned_names or {} + + for kw_arg in ast_node.keywords: + if isinstance(kw_arg.value, (ast.Constant, ast.List, ast.Tuple)): + setup_kwargs[kw_arg.arg] = ast.literal_eval(kw_arg.value) + elif isinstance(kw_arg.value, ast.Dict): + # This could hide variables + try: + setup_kwargs[kw_arg.arg] = ast.literal_eval(kw_arg.value) + except ValueError: + # Need to crawl the dictionary + gathered = {} + for (key, value) in zip(kw_arg.value.keys, + kw_arg.value.values): + if isinstance(value, ast.Name): + gathered[key.value] = assigned_names.get(value.id, '') + elif isinstance(value, ast.BinOp): + if isinstance(value.left, ast.List): + # e. g. "['Sphinx'] + BROWSER_REQUIRES" + print('XXX Cannot convert list addition XXX') + print('XXX Please fix setup.py manually first XXX') + print('XXX list addition: ' + f'{ast.unparse(value)} XXX') + sys.exit(1) + # Interpolated string 'x%sy' % foo + unformatted = value.left.value + variable = assigned_names.get(value.right.id, '') + formatted = unformatted.replace('%s', variable) + gathered[key.value] = formatted + elif isinstance(value, (ast.List, ast.Tuple)): + try: + gathered[key.value] = ast.literal_eval(value) + except ValueError: + # Probably a variable in the list + lst = [] + for member in value.elts: + if isinstance(member, + (ast.Constant, + ast.List, + ast.Tuple)): + lst.append(ast.literal_eval(member.value)) + elif isinstance(member, ast.BinOp): + unformatted = member.left.value + variable = assigned_names.get( + member.right.id, '') + formatted = unformatted.replace( + '%s', variable) + lst.append(formatted) + else: + lst.append(ast.literal_eval(member.value)) + gathered[key.value] = lst + else: + try: + gathered[key.value] = ast.literal_eval(value) + except ValueError: + print('XXX Cannot convert dictionary value XXX') + print('XXX Please fix setup.py manually first XXX') + print(f'XXX Dictionary key: {key.value} XXX') + print(ast.dump(value, indent=2)) + sys.exit(1) + setup_kwargs[kw_arg.arg] = gathered + elif isinstance(kw_arg.value, ast.Name): + if kw_arg.value.id in assigned_names: + value = assigned_names.get(kw_arg.value.id) + else: + value = kw_arg.value.id + setup_kwargs[kw_arg.arg] = value + + return setup_kwargs + + +def setup_args_to_toml_dict(setup_py_path, setup_kwargs): + """ Iterate over setup_kwargs and generate a dictionary of values suitable + for pyproject.toml and a dictionary with unconverted arguments + """ + toml_dict = {'project': {}} + p_data = toml_dict['project'] + + for key in IGNORE_KEYS: + setup_kwargs.pop(key, None) + + for key in UNCONVERTIBLE_KEYS: + setup_kwargs.pop(key, None) + + for key in PROJECT_SIMPLE_KEYS: + if key in setup_kwargs: + p_data[key] = setup_kwargs.pop(key) + + license = setup_kwargs.pop('license', 'ZPL-2.1') + license.replace('ZPL 2.1', 'ZPL-2.1') + p_data['license'] = license + + classifiers = setup_kwargs.pop('classifiers', []) + new_classifiers = [] + for classifier in classifiers: + if classifier.startswith('License'): + continue + elif classifier in ('Framework :: Zope2', 'Framework :: Zope :: 2'): + continue + elif classifier == 'Framework :: Zope3': + new_classifiers.append('Framework :: Zope :: 3') + else: + new_classifiers.append(classifier) + p_data['classifiers'] = new_classifiers + + readme = None + for readme_name in ('README.rst', 'README.txt'): + if (setup_py_path.parent / readme_name).exists(): + readme = readme_name + break + + changelog = None + for changelog_name in ('CHANGES.rst', 'CHANGES.txt'): + if (setup_py_path.parent / changelog_name).exists(): + changelog = changelog_name + break + + if readme and not changelog: + p_data['readme'] = readme + elif readme and changelog: + readme_spec = tomlkit.inline_table() + readme_spec.update({'file': [readme, changelog]}) + toml_dict['tool'] = { + 'setuptools': {'dynamic': {'readme': readme_spec}}} + dynamic_attributes = p_data.setdefault('dynamic', []) + dynamic_attributes.append('readme') + else: + print('XXX WARNING XXX: This package has no README.rst or README.txt!') + + if 'python_requires' in setup_kwargs: + p_data['requires-python'] = setup_kwargs.pop('python_requires') + + if 'author' in setup_kwargs: + name = setup_kwargs.pop('author').replace('Zope Corporation', + 'Zope Foundation') + # Fix bad capitalization found in some packages + name = name.replace('Contributors', 'contributors') + author_dict = {'name': name} + if 'author_email' in setup_kwargs: + email = setup_kwargs.pop('author_email').replace('zope.org', + 'zope.dev') + author_dict['email'] = email + p_data['authors'] = tomlkit.array() + p_data['authors'].add_line(author_dict) + + maintainers_table = {'name': 'Plone Foundation and contributors', + 'email': 'zope-dev@zope.dev'} + p_data['maintainers'] = tomlkit.array() + p_data['maintainers'].add_line(maintainers_table) + + entry_points = {} + scripts = {} + ep_data = setup_kwargs.pop('entry_points', {}) + + if isinstance(ep_data, str): + ep_lines = [x.strip() for x in ep_data.split('\n') if x] + ep_data = {} + for line in ep_lines: + key_buffer = '' + if line.startswith('['): + line = line.replace('[', '').replace(']', '').strip() + key_buffer = line + else: + if line and key_buffer: + line = line.replace(' = ', '=').strip() + ep_data[key_buffer] = line + key_buffer = '' + + for ep_type, ep_list in ep_data.items(): + if ep_type == 'console_scripts': + for ep in ep_list: + ep_name, ep_target = (x.strip() for x in ep.split('=')) + scripts[ep_name] = ep_target + else: + entrypoint_dict = entry_points.setdefault(ep_type, {}) + for ep in ep_list: + ep_name, ep_target = (x.strip() for x in ep.split('=')) + entrypoint_dict[ep_name] = ep_target + + if scripts: + p_data['scripts'] = scripts + if entry_points: + p_data['entry-points'] = entry_points + + extras = setup_kwargs.pop('extras_require', {}) + if isinstance(extras, str): + print(' XXX Error converting setup.py XXX') + print(' XXX Clean up setup.py manually first:') + print(f' Change extras_require value to not use variable {extras}!') + print(f' Instead, insert the actual value of variable {extras}.') + sys.exit(1) + opt_deps = {} + for e_name, e_list in extras.items(): + opt_deps[e_name] = e_list + if opt_deps: + p_data['optional-dependencies'] = opt_deps + + install_reqs = setup_kwargs.pop('install_requires', []) + if install_reqs: + for dependency in install_reqs: + if dependency.startswith('setuptools'): + print('XXX Found "setuptools" as install time dependency.') + print('XXX Please check if it is really needed!') + break + p_data['dependencies'] = install_reqs + + keywords = setup_kwargs.pop('keywords', '') + if keywords and isinstance(keywords, str): + p_data['keywords'] = keywords.split() + elif isinstance(keywords, (list, tuple)): + p_data['keywords'] = keywords + + project_urls = setup_kwargs.pop('project_urls', {}) + url = setup_kwargs.pop('url', '') + if 'github' in url and 'Source' not in project_urls: + project_urls['Source'] = url + if 'Sources' in project_urls: + project_urls['Source'] = project_urls.pop('Sources') + if 'Issue Tracker' in project_urls: + project_urls['Issues'] = project_urls.pop('Issue Tracker') + if project_urls: + p_data['urls'] = project_urls + + return (setup_kwargs, toml_dict) + + +def parse_setup_py(path): + """ Parse values out of setup.py """ + setup_kwargs = {} + assigned_names = {} + + # Nasty: Import the setup module file to get at the resolved variables + import_spec = spec_from_file_location('setup', path) + setup_module = module_from_spec(import_spec) + try: + with open(os.devnull, 'w') as fp: + with contextlib.redirect_stderr(fp): + import_spec.loader.exec_module(setup_module) + except (FileNotFoundError, SystemExit): + pass + + for key in dir(setup_module): + assigned_names[key] = getattr(setup_module, key) + + with open(path) as fp: + file_contents = fp.read() + + # Create the ast tree for the setup module to find the setup call + # definition in order to parse out the call arguments. + ast_tree = ast.parse(file_contents) + setup_node = None + + for ast_node in ast_tree.body: + if isinstance(ast_node, ast.Expr) and \ + isinstance(ast_node.value, ast.Call) and \ + ast_node.value.func.id == 'setup': + setup_node = ast_node.value + break + + if setup_node is not None: + setup_kwargs = parse_setup_function(setup_node, assigned_names) + (leftover_setup_kwargs, + toml_dict) = setup_args_to_toml_dict(path, setup_kwargs) + + return leftover_setup_kwargs, toml_dict + + +def rewrite_pyproject_toml(path, toml_dict): + p_toml = get_pyproject_toml(path) + + def recursive_merge(dict1, dict2): + for key, value in dict2.items(): + if key in dict1 and \ + isinstance(dict1[key], dict) and \ + isinstance(value, dict): + dict1[key] = recursive_merge(dict1[key], value) + else: + # We will not overwrite existing values! + if key not in dict1: + dict1[key] = value + return dict1 + + p_toml = recursive_merge(p_toml, toml_dict) + + # Format long lists + p_toml['project']['classifiers'].multiline(True) + p_toml['project']['authors'].multiline(True) + p_toml['project']['maintainers'].multiline(True) + if 'dependencies' in p_toml['project'] and \ + len(p_toml['project']['dependencies']) > 1: + p_toml['project']['dependencies'].multiline(True) + if 'keywords' in p_toml['project'] and \ + len(p_toml['project']['keywords']) > 4: + p_toml['project']['keywords'].multiline(True) + + opt_deps = p_toml['project'].get('optional-dependencies', {}) + for key, value in opt_deps.items(): + if len(value) > 1: + p_toml['project']['optional-dependencies'][key].multiline(True) + + # Last sanity check to see if anything is missing + if 'requires-python' not in p_toml['project']: + print('XXX WARNING XXX: This package did not define the minimum' + ' required Python ("python_requires"). Forcing the minimum' + f' supported by zope.meta instead ({OLDEST_PYTHON_VERSION}).') + p_toml['project']['requires-python'] = f'>={OLDEST_PYTHON_VERSION}' + + # Create a fresh TOMLDocument instance so I can control section sorting + with open(path.absolute().parent / '.meta.toml', 'rb') as fp: + meta_cfg = tomlkit.load(fp) + config_type = meta_cfg['meta'].get('template') + new_doc = tomlkit.loads(META_HINT.format(config_type=config_type)) + for key in sorted(p_toml.keys()): + new_doc[key] = p_toml.get(key) + + return tomlkit.dumps(new_doc) + + +def rewrite_setup_py(path, leftover_setup_kwargs): + """ Write new setup py with unconverted call arguments + + While it's possible to take the ``setup.py`` source, parse out an ast + tree and manipulate that to generate new code it loses all comments, + spacing and formatting when dumping it back out. So I am doing it with an + axe. This code assumes that the call to ``setup`` is the last thing in the + file. + """ + new_setup_py = [] + with open(path) as fp: + old_setup_py = fp.readlines() + + for line in old_setup_py: + if line.startswith('setup('): + break + + new_setup_py.append(line) + + new_setup_py.append('# See pyproject.toml for package metadata\n') + if not leftover_setup_kwargs: + new_setup_py.append('setup()\n') + else: + new_setup_py.append('setup(\n') + for key, value in leftover_setup_kwargs.items(): + new_setup_py.append(f' {key}={value},\n') + new_setup_py.append(')\n') + + return ''.join(new_setup_py) + + +def package_sanity_check(path): + """ Sanity checks for the provided path """ + sane = True + + if not path.exists(): + print(f' - no such path {path}.') + sane = False + + if not path.is_dir(): + print(f' - {path} is not a folder') + sane = False + + if not (path / 'setup.py').exists(): + print(' - no setup.py found, cannot convert package.') + sane = False + + if not (path / '.meta.toml').exists(): + print(' - no .meta.toml found, cannot convert package.') + sane = False + + return sane + + +def main(): + parser = get_shared_parser( + "Move package metadata from setup.py to pyproject.toml.", + interactive=True) + parser.add_argument( + '--dry-run', + dest='dry_run', + action='store_true', + default=False, + help='Do not make any changes but output contents for the changed' + ' setup.py and pyproject.toml files.', + ) + args = parser.parse_args() + + print(f'Converting package {args.path.name}') + + if not package_sanity_check(args.path): + print('Conversion not possible, exiting.') + sys.exit() + + (leftover_setup_kwargs, toml_dict) = parse_setup_py(args.path / 'setup.py') + + # Sanity check - if project has been converted already, give up. + if 'name' not in toml_dict['project'] and \ + 'version' not in toml_dict['project']: + print('Package has been converted already, exiting.') + sys.exit() + + toml_content = rewrite_pyproject_toml(args.path / 'pyproject.toml', + toml_dict) + setup_content = rewrite_setup_py(args.path / 'setup.py', + leftover_setup_kwargs) + + # If this is a dry run, just print the end result and exit. + if args.dry_run: + print('\n------------> pyproject.toml with all changes applied:') + print(toml_content) + print('\n------------> setup.py with all changes applied:') + print(setup_content) + sys.exit() + + with change_dir(args.path) as cwd: + bin_dir = pathlib.Path(cwd) / "bin" + + call(bin_dir / "addchangelogentry", + 'Move package metadata from setup.py to pyproject.toml.') + + with open(args.path / 'pyproject.toml', 'w') as fp: + fp.write(toml_content) + with open(args.path / 'setup.py', 'w') as fp: + fp.write(setup_content) + + if args.interactive or args.commit: + print('Look through setup.py to see if it needs changes.') + call(os.environ['EDITOR'], 'setup.py') + call('git', 'add', 'setup.py') + print('Look through pyproject.toml to see if it needs changes.') + call(os.environ['EDITOR'], 'pyproject.toml') + call('git', 'add', 'pyproject.toml') + + if args.run_tests: + tox_path = shutil.which('tox') or ( + pathlib.Path(cwd) / 'bin' / 'tox') + call(tox_path, '-p', 'auto') + + branch_name = args.branch_name or "convert-setup-py-to-pyproject-toml" + updating = git_branch(branch_name) + + if args.commit: + if args.commit_msg: + commit_msg = args.commit_msg + else: + commit_msg = ('Move package metadata from setup.py' + ' to pyproject.toml.') + call('git', 'commit', '-m', commit_msg) + if args.push: + call('git', 'push', '--set-upstream', 'origin', branch_name) + + print('If everything went fine up to here:') + if updating: + print('Updated the previously created PR.') + else: + print('Create a PR, using the URL shown above.') + + print(f'Finished converting {args.path.name}.') From f0a552229b595fa32e1aedcd279d8bdedb65a6ba Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Mon, 16 Mar 2026 18:25:22 +0200 Subject: [PATCH 02/18] chore: run tox -e lint --- src/plone/meta/setup_to_pyproject.py | 397 ++++++++++++++------------- 1 file changed, 201 insertions(+), 196 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 3af90e8..f05a077 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -12,40 +12,48 @@ # ############################################################################## -import ast -import contextlib -import os -import pathlib -import shutil -import sys -from importlib.util import module_from_spec -from importlib.util import spec_from_file_location - -import tomlkit - from .shared.call import call from .shared.git import git_branch +from .shared.packages import get_pyproject_toml from .shared.packages import META_HINT from .shared.packages import OLDEST_PYTHON_VERSION -from .shared.packages import get_pyproject_toml from .shared.path import change_dir from .shared.script_args import get_shared_parser +from importlib.util import module_from_spec +from importlib.util import spec_from_file_location +import ast +import contextlib +import os +import pathlib +import shutil +import sys +import tomlkit PROJECT_SIMPLE_KEYS = ( - 'name', 'version', 'description', + "name", + "version", + "description", ) IGNORE_KEYS = ( - 'zip_safe', 'long_description_content_type', 'package_dir', - 'packages', 'include_package_data', 'test_suite', 'tests_require', + "zip_safe", + "long_description_content_type", + "package_dir", + "packages", + "include_package_data", + "test_suite", + "tests_require", ) UNCONVERTIBLE_KEYS = ( - 'cmdclass', 'ext_modules', 'headers', 'cffi_modules', + "cmdclass", + "ext_modules", + "headers", + "cffi_modules", ) def parse_setup_function(ast_node, assigned_names=None): - """ Parse values out of the setup call ast definition """ + """Parse values out of the setup call ast definition""" setup_kwargs = {} assigned_names = assigned_names or {} @@ -59,22 +67,20 @@ def parse_setup_function(ast_node, assigned_names=None): except ValueError: # Need to crawl the dictionary gathered = {} - for (key, value) in zip(kw_arg.value.keys, - kw_arg.value.values): + for key, value in zip(kw_arg.value.keys, kw_arg.value.values): if isinstance(value, ast.Name): - gathered[key.value] = assigned_names.get(value.id, '') + gathered[key.value] = assigned_names.get(value.id, "") elif isinstance(value, ast.BinOp): if isinstance(value.left, ast.List): # e. g. "['Sphinx'] + BROWSER_REQUIRES" - print('XXX Cannot convert list addition XXX') - print('XXX Please fix setup.py manually first XXX') - print('XXX list addition: ' - f'{ast.unparse(value)} XXX') + print("XXX Cannot convert list addition XXX") + print("XXX Please fix setup.py manually first XXX") + print("XXX list addition: " f"{ast.unparse(value)} XXX") sys.exit(1) # Interpolated string 'x%sy' % foo unformatted = value.left.value - variable = assigned_names.get(value.right.id, '') - formatted = unformatted.replace('%s', variable) + variable = assigned_names.get(value.right.id, "") + formatted = unformatted.replace("%s", variable) gathered[key.value] = formatted elif isinstance(value, (ast.List, ast.Tuple)): try: @@ -83,17 +89,14 @@ def parse_setup_function(ast_node, assigned_names=None): # Probably a variable in the list lst = [] for member in value.elts: - if isinstance(member, - (ast.Constant, - ast.List, - ast.Tuple)): + if isinstance( + member, (ast.Constant, ast.List, ast.Tuple) + ): lst.append(ast.literal_eval(member.value)) elif isinstance(member, ast.BinOp): unformatted = member.left.value - variable = assigned_names.get( - member.right.id, '') - formatted = unformatted.replace( - '%s', variable) + variable = assigned_names.get(member.right.id, "") + formatted = unformatted.replace("%s", variable) lst.append(formatted) else: lst.append(ast.literal_eval(member.value)) @@ -102,9 +105,9 @@ def parse_setup_function(ast_node, assigned_names=None): try: gathered[key.value] = ast.literal_eval(value) except ValueError: - print('XXX Cannot convert dictionary value XXX') - print('XXX Please fix setup.py manually first XXX') - print(f'XXX Dictionary key: {key.value} XXX') + print("XXX Cannot convert dictionary value XXX") + print("XXX Please fix setup.py manually first XXX") + print(f"XXX Dictionary key: {key.value} XXX") print(ast.dump(value, indent=2)) sys.exit(1) setup_kwargs[kw_arg.arg] = gathered @@ -119,11 +122,11 @@ def parse_setup_function(ast_node, assigned_names=None): def setup_args_to_toml_dict(setup_py_path, setup_kwargs): - """ Iterate over setup_kwargs and generate a dictionary of values suitable + """Iterate over setup_kwargs and generate a dictionary of values suitable for pyproject.toml and a dictionary with unconverted arguments """ - toml_dict = {'project': {}} - p_data = toml_dict['project'] + toml_dict = {"project": {}} + p_data = toml_dict["project"] for key in IGNORE_KEYS: setup_kwargs.pop(key, None) @@ -135,154 +138,153 @@ def setup_args_to_toml_dict(setup_py_path, setup_kwargs): if key in setup_kwargs: p_data[key] = setup_kwargs.pop(key) - license = setup_kwargs.pop('license', 'ZPL-2.1') - license.replace('ZPL 2.1', 'ZPL-2.1') - p_data['license'] = license + license = setup_kwargs.pop("license", "ZPL-2.1") + license.replace("ZPL 2.1", "ZPL-2.1") + p_data["license"] = license - classifiers = setup_kwargs.pop('classifiers', []) + classifiers = setup_kwargs.pop("classifiers", []) new_classifiers = [] for classifier in classifiers: - if classifier.startswith('License'): + if classifier.startswith("License"): continue - elif classifier in ('Framework :: Zope2', 'Framework :: Zope :: 2'): + elif classifier in ("Framework :: Zope2", "Framework :: Zope :: 2"): continue - elif classifier == 'Framework :: Zope3': - new_classifiers.append('Framework :: Zope :: 3') + elif classifier == "Framework :: Zope3": + new_classifiers.append("Framework :: Zope :: 3") else: new_classifiers.append(classifier) - p_data['classifiers'] = new_classifiers + p_data["classifiers"] = new_classifiers readme = None - for readme_name in ('README.rst', 'README.txt'): + for readme_name in ("README.rst", "README.txt"): if (setup_py_path.parent / readme_name).exists(): readme = readme_name break changelog = None - for changelog_name in ('CHANGES.rst', 'CHANGES.txt'): + for changelog_name in ("CHANGES.rst", "CHANGES.txt"): if (setup_py_path.parent / changelog_name).exists(): changelog = changelog_name break if readme and not changelog: - p_data['readme'] = readme + p_data["readme"] = readme elif readme and changelog: readme_spec = tomlkit.inline_table() - readme_spec.update({'file': [readme, changelog]}) - toml_dict['tool'] = { - 'setuptools': {'dynamic': {'readme': readme_spec}}} - dynamic_attributes = p_data.setdefault('dynamic', []) - dynamic_attributes.append('readme') + readme_spec.update({"file": [readme, changelog]}) + toml_dict["tool"] = {"setuptools": {"dynamic": {"readme": readme_spec}}} + dynamic_attributes = p_data.setdefault("dynamic", []) + dynamic_attributes.append("readme") else: - print('XXX WARNING XXX: This package has no README.rst or README.txt!') + print("XXX WARNING XXX: This package has no README.rst or README.txt!") - if 'python_requires' in setup_kwargs: - p_data['requires-python'] = setup_kwargs.pop('python_requires') + if "python_requires" in setup_kwargs: + p_data["requires-python"] = setup_kwargs.pop("python_requires") - if 'author' in setup_kwargs: - name = setup_kwargs.pop('author').replace('Zope Corporation', - 'Zope Foundation') + if "author" in setup_kwargs: + name = setup_kwargs.pop("author").replace("Zope Corporation", "Zope Foundation") # Fix bad capitalization found in some packages - name = name.replace('Contributors', 'contributors') - author_dict = {'name': name} - if 'author_email' in setup_kwargs: - email = setup_kwargs.pop('author_email').replace('zope.org', - 'zope.dev') - author_dict['email'] = email - p_data['authors'] = tomlkit.array() - p_data['authors'].add_line(author_dict) - - maintainers_table = {'name': 'Plone Foundation and contributors', - 'email': 'zope-dev@zope.dev'} - p_data['maintainers'] = tomlkit.array() - p_data['maintainers'].add_line(maintainers_table) + name = name.replace("Contributors", "contributors") + author_dict = {"name": name} + if "author_email" in setup_kwargs: + email = setup_kwargs.pop("author_email").replace("zope.org", "zope.dev") + author_dict["email"] = email + p_data["authors"] = tomlkit.array() + p_data["authors"].add_line(author_dict) + + maintainers_table = { + "name": "Plone Foundation and contributors", + "email": "zope-dev@zope.dev", + } + p_data["maintainers"] = tomlkit.array() + p_data["maintainers"].add_line(maintainers_table) entry_points = {} scripts = {} - ep_data = setup_kwargs.pop('entry_points', {}) + ep_data = setup_kwargs.pop("entry_points", {}) if isinstance(ep_data, str): - ep_lines = [x.strip() for x in ep_data.split('\n') if x] + ep_lines = [x.strip() for x in ep_data.split("\n") if x] ep_data = {} for line in ep_lines: - key_buffer = '' - if line.startswith('['): - line = line.replace('[', '').replace(']', '').strip() + key_buffer = "" + if line.startswith("["): + line = line.replace("[", "").replace("]", "").strip() key_buffer = line else: if line and key_buffer: - line = line.replace(' = ', '=').strip() + line = line.replace(" = ", "=").strip() ep_data[key_buffer] = line - key_buffer = '' + key_buffer = "" for ep_type, ep_list in ep_data.items(): - if ep_type == 'console_scripts': + if ep_type == "console_scripts": for ep in ep_list: - ep_name, ep_target = (x.strip() for x in ep.split('=')) + ep_name, ep_target = (x.strip() for x in ep.split("=")) scripts[ep_name] = ep_target else: entrypoint_dict = entry_points.setdefault(ep_type, {}) for ep in ep_list: - ep_name, ep_target = (x.strip() for x in ep.split('=')) + ep_name, ep_target = (x.strip() for x in ep.split("=")) entrypoint_dict[ep_name] = ep_target if scripts: - p_data['scripts'] = scripts + p_data["scripts"] = scripts if entry_points: - p_data['entry-points'] = entry_points + p_data["entry-points"] = entry_points - extras = setup_kwargs.pop('extras_require', {}) + extras = setup_kwargs.pop("extras_require", {}) if isinstance(extras, str): - print(' XXX Error converting setup.py XXX') - print(' XXX Clean up setup.py manually first:') - print(f' Change extras_require value to not use variable {extras}!') - print(f' Instead, insert the actual value of variable {extras}.') + print(" XXX Error converting setup.py XXX") + print(" XXX Clean up setup.py manually first:") + print(f" Change extras_require value to not use variable {extras}!") + print(f" Instead, insert the actual value of variable {extras}.") sys.exit(1) opt_deps = {} for e_name, e_list in extras.items(): opt_deps[e_name] = e_list if opt_deps: - p_data['optional-dependencies'] = opt_deps + p_data["optional-dependencies"] = opt_deps - install_reqs = setup_kwargs.pop('install_requires', []) + install_reqs = setup_kwargs.pop("install_requires", []) if install_reqs: for dependency in install_reqs: - if dependency.startswith('setuptools'): + if dependency.startswith("setuptools"): print('XXX Found "setuptools" as install time dependency.') - print('XXX Please check if it is really needed!') + print("XXX Please check if it is really needed!") break - p_data['dependencies'] = install_reqs + p_data["dependencies"] = install_reqs - keywords = setup_kwargs.pop('keywords', '') + keywords = setup_kwargs.pop("keywords", "") if keywords and isinstance(keywords, str): - p_data['keywords'] = keywords.split() + p_data["keywords"] = keywords.split() elif isinstance(keywords, (list, tuple)): - p_data['keywords'] = keywords - - project_urls = setup_kwargs.pop('project_urls', {}) - url = setup_kwargs.pop('url', '') - if 'github' in url and 'Source' not in project_urls: - project_urls['Source'] = url - if 'Sources' in project_urls: - project_urls['Source'] = project_urls.pop('Sources') - if 'Issue Tracker' in project_urls: - project_urls['Issues'] = project_urls.pop('Issue Tracker') + p_data["keywords"] = keywords + + project_urls = setup_kwargs.pop("project_urls", {}) + url = setup_kwargs.pop("url", "") + if "github" in url and "Source" not in project_urls: + project_urls["Source"] = url + if "Sources" in project_urls: + project_urls["Source"] = project_urls.pop("Sources") + if "Issue Tracker" in project_urls: + project_urls["Issues"] = project_urls.pop("Issue Tracker") if project_urls: - p_data['urls'] = project_urls + p_data["urls"] = project_urls return (setup_kwargs, toml_dict) def parse_setup_py(path): - """ Parse values out of setup.py """ + """Parse values out of setup.py""" setup_kwargs = {} assigned_names = {} # Nasty: Import the setup module file to get at the resolved variables - import_spec = spec_from_file_location('setup', path) + import_spec = spec_from_file_location("setup", path) setup_module = module_from_spec(import_spec) try: - with open(os.devnull, 'w') as fp: + with open(os.devnull, "w") as fp: with contextlib.redirect_stderr(fp): import_spec.loader.exec_module(setup_module) except (FileNotFoundError, SystemExit): @@ -300,16 +302,17 @@ def parse_setup_py(path): setup_node = None for ast_node in ast_tree.body: - if isinstance(ast_node, ast.Expr) and \ - isinstance(ast_node.value, ast.Call) and \ - ast_node.value.func.id == 'setup': + if ( + isinstance(ast_node, ast.Expr) + and isinstance(ast_node.value, ast.Call) + and ast_node.value.func.id == "setup" + ): setup_node = ast_node.value break if setup_node is not None: setup_kwargs = parse_setup_function(setup_node, assigned_names) - (leftover_setup_kwargs, - toml_dict) = setup_args_to_toml_dict(path, setup_kwargs) + leftover_setup_kwargs, toml_dict = setup_args_to_toml_dict(path, setup_kwargs) return leftover_setup_kwargs, toml_dict @@ -319,9 +322,11 @@ def rewrite_pyproject_toml(path, toml_dict): def recursive_merge(dict1, dict2): for key, value in dict2.items(): - if key in dict1 and \ - isinstance(dict1[key], dict) and \ - isinstance(value, dict): + if ( + key in dict1 + and isinstance(dict1[key], dict) + and isinstance(value, dict) + ): dict1[key] = recursive_merge(dict1[key], value) else: # We will not overwrite existing values! @@ -332,32 +337,35 @@ def recursive_merge(dict1, dict2): p_toml = recursive_merge(p_toml, toml_dict) # Format long lists - p_toml['project']['classifiers'].multiline(True) - p_toml['project']['authors'].multiline(True) - p_toml['project']['maintainers'].multiline(True) - if 'dependencies' in p_toml['project'] and \ - len(p_toml['project']['dependencies']) > 1: - p_toml['project']['dependencies'].multiline(True) - if 'keywords' in p_toml['project'] and \ - len(p_toml['project']['keywords']) > 4: - p_toml['project']['keywords'].multiline(True) - - opt_deps = p_toml['project'].get('optional-dependencies', {}) + p_toml["project"]["classifiers"].multiline(True) + p_toml["project"]["authors"].multiline(True) + p_toml["project"]["maintainers"].multiline(True) + if ( + "dependencies" in p_toml["project"] + and len(p_toml["project"]["dependencies"]) > 1 + ): + p_toml["project"]["dependencies"].multiline(True) + if "keywords" in p_toml["project"] and len(p_toml["project"]["keywords"]) > 4: + p_toml["project"]["keywords"].multiline(True) + + opt_deps = p_toml["project"].get("optional-dependencies", {}) for key, value in opt_deps.items(): if len(value) > 1: - p_toml['project']['optional-dependencies'][key].multiline(True) + p_toml["project"]["optional-dependencies"][key].multiline(True) # Last sanity check to see if anything is missing - if 'requires-python' not in p_toml['project']: - print('XXX WARNING XXX: This package did not define the minimum' - ' required Python ("python_requires"). Forcing the minimum' - f' supported by zope.meta instead ({OLDEST_PYTHON_VERSION}).') - p_toml['project']['requires-python'] = f'>={OLDEST_PYTHON_VERSION}' + if "requires-python" not in p_toml["project"]: + print( + "XXX WARNING XXX: This package did not define the minimum" + ' required Python ("python_requires"). Forcing the minimum' + f" supported by zope.meta instead ({OLDEST_PYTHON_VERSION})." + ) + p_toml["project"]["requires-python"] = f">={OLDEST_PYTHON_VERSION}" # Create a fresh TOMLDocument instance so I can control section sorting - with open(path.absolute().parent / '.meta.toml', 'rb') as fp: + with open(path.absolute().parent / ".meta.toml", "rb") as fp: meta_cfg = tomlkit.load(fp) - config_type = meta_cfg['meta'].get('template') + config_type = meta_cfg["meta"].get("template") new_doc = tomlkit.loads(META_HINT.format(config_type=config_type)) for key in sorted(p_toml.keys()): new_doc[key] = p_toml.get(key) @@ -366,7 +374,7 @@ def recursive_merge(dict1, dict2): def rewrite_setup_py(path, leftover_setup_kwargs): - """ Write new setup py with unconverted call arguments + """Write new setup py with unconverted call arguments While it's possible to take the ``setup.py`` source, parse out an ast tree and manipulate that to generate new code it loses all comments, @@ -379,41 +387,41 @@ def rewrite_setup_py(path, leftover_setup_kwargs): old_setup_py = fp.readlines() for line in old_setup_py: - if line.startswith('setup('): + if line.startswith("setup("): break new_setup_py.append(line) - new_setup_py.append('# See pyproject.toml for package metadata\n') + new_setup_py.append("# See pyproject.toml for package metadata\n") if not leftover_setup_kwargs: - new_setup_py.append('setup()\n') + new_setup_py.append("setup()\n") else: - new_setup_py.append('setup(\n') + new_setup_py.append("setup(\n") for key, value in leftover_setup_kwargs.items(): - new_setup_py.append(f' {key}={value},\n') - new_setup_py.append(')\n') + new_setup_py.append(f" {key}={value},\n") + new_setup_py.append(")\n") - return ''.join(new_setup_py) + return "".join(new_setup_py) def package_sanity_check(path): - """ Sanity checks for the provided path """ + """Sanity checks for the provided path""" sane = True if not path.exists(): - print(f' - no such path {path}.') + print(f" - no such path {path}.") sane = False if not path.is_dir(): - print(f' - {path} is not a folder') + print(f" - {path} is not a folder") sane = False - if not (path / 'setup.py').exists(): - print(' - no setup.py found, cannot convert package.') + if not (path / "setup.py").exists(): + print(" - no setup.py found, cannot convert package.") sane = False - if not (path / '.meta.toml').exists(): - print(' - no .meta.toml found, cannot convert package.') + if not (path / ".meta.toml").exists(): + print(" - no .meta.toml found, cannot convert package.") sane = False return sane @@ -421,68 +429,66 @@ def package_sanity_check(path): def main(): parser = get_shared_parser( - "Move package metadata from setup.py to pyproject.toml.", - interactive=True) + "Move package metadata from setup.py to pyproject.toml.", interactive=True + ) parser.add_argument( - '--dry-run', - dest='dry_run', - action='store_true', + "--dry-run", + dest="dry_run", + action="store_true", default=False, - help='Do not make any changes but output contents for the changed' - ' setup.py and pyproject.toml files.', + help="Do not make any changes but output contents for the changed" + " setup.py and pyproject.toml files.", ) args = parser.parse_args() - print(f'Converting package {args.path.name}') + print(f"Converting package {args.path.name}") if not package_sanity_check(args.path): - print('Conversion not possible, exiting.') + print("Conversion not possible, exiting.") sys.exit() - (leftover_setup_kwargs, toml_dict) = parse_setup_py(args.path / 'setup.py') + leftover_setup_kwargs, toml_dict = parse_setup_py(args.path / "setup.py") # Sanity check - if project has been converted already, give up. - if 'name' not in toml_dict['project'] and \ - 'version' not in toml_dict['project']: - print('Package has been converted already, exiting.') + if "name" not in toml_dict["project"] and "version" not in toml_dict["project"]: + print("Package has been converted already, exiting.") sys.exit() - toml_content = rewrite_pyproject_toml(args.path / 'pyproject.toml', - toml_dict) - setup_content = rewrite_setup_py(args.path / 'setup.py', - leftover_setup_kwargs) + toml_content = rewrite_pyproject_toml(args.path / "pyproject.toml", toml_dict) + setup_content = rewrite_setup_py(args.path / "setup.py", leftover_setup_kwargs) # If this is a dry run, just print the end result and exit. if args.dry_run: - print('\n------------> pyproject.toml with all changes applied:') + print("\n------------> pyproject.toml with all changes applied:") print(toml_content) - print('\n------------> setup.py with all changes applied:') + print("\n------------> setup.py with all changes applied:") print(setup_content) sys.exit() with change_dir(args.path) as cwd: bin_dir = pathlib.Path(cwd) / "bin" - call(bin_dir / "addchangelogentry", - 'Move package metadata from setup.py to pyproject.toml.') + call( + bin_dir / "addchangelogentry", + "Move package metadata from setup.py to pyproject.toml.", + ) - with open(args.path / 'pyproject.toml', 'w') as fp: + with open(args.path / "pyproject.toml", "w") as fp: fp.write(toml_content) - with open(args.path / 'setup.py', 'w') as fp: + with open(args.path / "setup.py", "w") as fp: fp.write(setup_content) if args.interactive or args.commit: - print('Look through setup.py to see if it needs changes.') - call(os.environ['EDITOR'], 'setup.py') - call('git', 'add', 'setup.py') - print('Look through pyproject.toml to see if it needs changes.') - call(os.environ['EDITOR'], 'pyproject.toml') - call('git', 'add', 'pyproject.toml') + print("Look through setup.py to see if it needs changes.") + call(os.environ["EDITOR"], "setup.py") + call("git", "add", "setup.py") + print("Look through pyproject.toml to see if it needs changes.") + call(os.environ["EDITOR"], "pyproject.toml") + call("git", "add", "pyproject.toml") if args.run_tests: - tox_path = shutil.which('tox') or ( - pathlib.Path(cwd) / 'bin' / 'tox') - call(tox_path, '-p', 'auto') + tox_path = shutil.which("tox") or (pathlib.Path(cwd) / "bin" / "tox") + call(tox_path, "-p", "auto") branch_name = args.branch_name or "convert-setup-py-to-pyproject-toml" updating = git_branch(branch_name) @@ -491,16 +497,15 @@ def main(): if args.commit_msg: commit_msg = args.commit_msg else: - commit_msg = ('Move package metadata from setup.py' - ' to pyproject.toml.') - call('git', 'commit', '-m', commit_msg) + commit_msg = "Move package metadata from setup.py" " to pyproject.toml." + call("git", "commit", "-m", commit_msg) if args.push: - call('git', 'push', '--set-upstream', 'origin', branch_name) + call("git", "push", "--set-upstream", "origin", branch_name) - print('If everything went fine up to here:') + print("If everything went fine up to here:") if updating: - print('Updated the previously created PR.') + print("Updated the previously created PR.") else: - print('Create a PR, using the URL shown above.') + print("Create a PR, using the URL shown above.") - print(f'Finished converting {args.path.name}.') + print(f"Finished converting {args.path.name}.") From c30a63a793c85528cfec6bbfc0a305c3568dd89c Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Wed, 18 Mar 2026 11:01:24 +0200 Subject: [PATCH 03/18] chore(setup-to-pyproject): simplify --- src/plone/meta/setup_to_pyproject.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index f05a077..58b6728 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -293,8 +293,7 @@ def parse_setup_py(path): for key in dir(setup_module): assigned_names[key] = getattr(setup_module, key) - with open(path) as fp: - file_contents = fp.read() + file_contents = pathlib.Path(path).read_text() # Create the ast tree for the setup module to find the setup call # definition in order to parse out the call arguments. From 3f84373a124a57bef2b7ccc53b8c1e141d3b92ee Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Wed, 18 Mar 2026 11:07:11 +0200 Subject: [PATCH 04/18] chore(setup-to-pyproject): simplify argparser --- src/plone/meta/setup_to_pyproject.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 58b6728..fe3be17 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -18,10 +18,10 @@ from .shared.packages import META_HINT from .shared.packages import OLDEST_PYTHON_VERSION from .shared.path import change_dir -from .shared.script_args import get_shared_parser from importlib.util import module_from_spec from importlib.util import spec_from_file_location +import argparse import ast import contextlib import os @@ -427,16 +427,19 @@ def package_sanity_check(path): def main(): - parser = get_shared_parser( - "Move package metadata from setup.py to pyproject.toml.", interactive=True + parser = argparse.ArgumentParser( + description="Move package metadata from setup.py to pyproject.toml." ) parser.add_argument( - "--dry-run", - dest="dry_run", - action="store_true", - default=False, - help="Do not make any changes but output contents for the changed" - " setup.py and pyproject.toml files.", + "path", type=pathlib.Path, help="path to the repository to be configured" + ) + parser.add_argument( + "--branch", + dest="branch_name", + default=None, + help="Define a git branch name to be used for the changes. " + "If not given it is constructed automatically and includes " + 'the configuration type. Use "current" to update the current branch.', ) args = parser.parse_args() From fc5aac9af867e1d33a608fa0769a4941a0ea4523 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Wed, 18 Mar 2026 10:58:44 +0200 Subject: [PATCH 05/18] chore(setup-to-pyproject): move functions around Move `get_pyproject_toml` inline on the module as in `plone.meta` we don't have `.shared.packages` module. `META_HINT` is on our `config_package` module. Simplify the `OLDEST_PYTHON_VERSION` to a static value (3.10). --- src/plone/meta/setup_to_pyproject.py | 38 ++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index fe3be17..5f57482 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -12,11 +12,9 @@ # ############################################################################## +from .config_package import META_HINT from .shared.call import call from .shared.git import git_branch -from .shared.packages import get_pyproject_toml -from .shared.packages import META_HINT -from .shared.packages import OLDEST_PYTHON_VERSION from .shared.path import change_dir from importlib.util import module_from_spec from importlib.util import spec_from_file_location @@ -52,6 +50,30 @@ ) +def get_pyproject_toml(path, comment=""): + """Parse ``pyproject.toml`` and return its content as ``TOMLDocument``. + + Args: + path (str, pathlib.Path): Filesystem path to a pyproject.toml file. + + Kwargs: + comment (str): Optional comment added to the top of the file. + + Returns: + A TOMLDocument instance from the pyproject.toml file. + """ + toml_contents = "" + if path.exists(): + toml_contents = path.read_text() + + if comment and not ( + toml_contents.startswith(comment) or toml_contents.startswith(f"# \n{comment}") + ): + toml_contents = f"{comment}\n{toml_contents}" + + return tomlkit.loads(toml_contents) + + def parse_setup_function(ast_node, assigned_names=None): """Parse values out of the setup call ast definition""" setup_kwargs = {} @@ -317,7 +339,8 @@ def parse_setup_py(path): def rewrite_pyproject_toml(path, toml_dict): - p_toml = get_pyproject_toml(path) + toml_file = path / "pyproject.toml" + p_toml = get_pyproject_toml(toml_file) def recursive_merge(dict1, dict2): for key, value in dict2.items(): @@ -354,12 +377,7 @@ def recursive_merge(dict1, dict2): # Last sanity check to see if anything is missing if "requires-python" not in p_toml["project"]: - print( - "XXX WARNING XXX: This package did not define the minimum" - ' required Python ("python_requires"). Forcing the minimum' - f" supported by zope.meta instead ({OLDEST_PYTHON_VERSION})." - ) - p_toml["project"]["requires-python"] = f">={OLDEST_PYTHON_VERSION}" + p_toml["project"]["requires-python"] = ">=3.10" # Create a fresh TOMLDocument instance so I can control section sorting with open(path.absolute().parent / ".meta.toml", "rb") as fp: From 3e6c977608c01da64a73b536a6f457e7ca7d8ae1 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Wed, 18 Mar 2026 11:07:49 +0200 Subject: [PATCH 06/18] chore(setup-to-pyproject): simplify the global operations --- src/plone/meta/setup_to_pyproject.py | 78 +++++++++++----------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 5f57482..b982b8a 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -24,7 +24,6 @@ import contextlib import os import pathlib -import shutil import sys import tomlkit @@ -444,6 +443,26 @@ def package_sanity_check(path): return sane +def write_news_entry(path): + news_folder = path / "news" + if not news_folder.exists(): + print("WARNING: no news entry created as there is no 'news' folder") + return + + filename = "+setup-to-pyproject.internal" + news_entry = news_folder / filename + if (path / "CHANGES.md").exists(): + changelog_text = ( + "Move package metadata from `setup.py` to `pyproject.toml` @plone\n" + ) + else: + changelog_text = "Move package metadata from ``setup.py`` to ``pyproject.toml``.\n[plone devs]\n" + news_entry.write_text(changelog_text) + + with change_dir(path): + call("git", "add", f"news/{filename}") + + def main(): parser = argparse.ArgumentParser( description="Move package metadata from setup.py to pyproject.toml." @@ -474,58 +493,21 @@ def main(): print("Package has been converted already, exiting.") sys.exit() - toml_content = rewrite_pyproject_toml(args.path / "pyproject.toml", toml_dict) + toml_content = rewrite_pyproject_toml(args.path, toml_dict) setup_content = rewrite_setup_py(args.path / "setup.py", leftover_setup_kwargs) - # If this is a dry run, just print the end result and exit. - if args.dry_run: - print("\n------------> pyproject.toml with all changes applied:") - print(toml_content) - print("\n------------> setup.py with all changes applied:") - print(setup_content) - sys.exit() - - with change_dir(args.path) as cwd: - bin_dir = pathlib.Path(cwd) / "bin" - - call( - bin_dir / "addchangelogentry", - "Move package metadata from setup.py to pyproject.toml.", - ) - - with open(args.path / "pyproject.toml", "w") as fp: - fp.write(toml_content) - with open(args.path / "setup.py", "w") as fp: - fp.write(setup_content) + (args.path / "pyproject.toml").write_text(toml_content) + (args.path / "setup.py").write_text(setup_content) - if args.interactive or args.commit: - print("Look through setup.py to see if it needs changes.") - call(os.environ["EDITOR"], "setup.py") - call("git", "add", "setup.py") - print("Look through pyproject.toml to see if it needs changes.") - call(os.environ["EDITOR"], "pyproject.toml") - call("git", "add", "pyproject.toml") - - if args.run_tests: - tox_path = shutil.which("tox") or (pathlib.Path(cwd) / "bin" / "tox") - call(tox_path, "-p", "auto") + print("Look through setup.py and pyproject.toml to see if it needs changes.") + write_news_entry(args.path) + with change_dir(args.path): branch_name = args.branch_name or "convert-setup-py-to-pyproject-toml" - updating = git_branch(branch_name) + git_branch(branch_name) - if args.commit: - if args.commit_msg: - commit_msg = args.commit_msg - else: - commit_msg = "Move package metadata from setup.py" " to pyproject.toml." - call("git", "commit", "-m", commit_msg) - if args.push: - call("git", "push", "--set-upstream", "origin", branch_name) - - print("If everything went fine up to here:") - if updating: - print("Updated the previously created PR.") - else: - print("Create a PR, using the URL shown above.") + commit_msg = "feat: move metadata from setup.py to pyproject.toml." + call("git", "add", "setup.py", "pyproject.toml") + call("git", "commit", "-m", commit_msg) print(f"Finished converting {args.path.name}.") From 317dcd228cc26bd69a19505cb772250746261e82 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Wed, 18 Mar 2026 10:57:47 +0200 Subject: [PATCH 07/18] feat(setup-to-pyproject): classifiers and license Check the license and trove classifiers from `setup.py` against a known set of licenses and complain in certain scenarios: - if there is more than one license trove classifier - if there is license trove classifier out of our known set - if the license and the license trove classifier disagree If all works well and there is either none or only one trove classifier and it matches with the license argument on setup.py, get a valid SPDX license expression. This last part is important, otherwise `pyroma`, and probably PyPI when uploading, will complain and refuse new releases. --- src/plone/meta/setup_to_pyproject.py | 95 +++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index b982b8a..8d9f046 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -48,6 +48,24 @@ "cffi_modules", ) +LICENSE_CLASSIFIER_TO_SPDX = { + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)": "GPL-2.0-only", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)": "GPL-2.0-or-later", + "License :: OSI Approved :: Zope Public License": "ZPL-2.1", + "License :: OSI Approved :: BSD License": "BSD-3-Clause", +} + +LICENSE_TO_SPDX = { + "bsd": "BSD-3-Clause", + "gpl": "GPL-2.0-only", + "gplv2": "GPL-2.0-only", + "gplversion2": "GPL-2.0-only", + "gplv2orlater": "GPL-2.0-or-later", + "gplversion2orlater": "GPL-2.0-or-later", + "lgpl": "LGPL-2.1-only", + "zpl21": "ZPL-2.1", +} + def get_pyproject_toml(path, comment=""): """Parse ``pyproject.toml`` and return its content as ``TOMLDocument``. @@ -142,6 +160,62 @@ def parse_setup_function(ast_node, assigned_names=None): return setup_kwargs +def handle_classifiers(classifiers): + new_classifiers = [] + license_counter = 0 + license_classifiers = [] + + for classifier in classifiers: + if classifier.startswith("License"): + if classifier not in LICENSE_CLASSIFIER_TO_SPDX.keys(): + print(f"License classifier {classifier} was not expected") + print("either remove it and run the script again,") + print("or double check if that was the intended classifier.") + sys.exit() + license_counter += 1 + license_classifiers.append(classifier) + continue + elif classifier in ("Framework :: Zope2", "Framework :: Zope :: 2"): + continue + elif classifier == "Framework :: Zope3": + new_classifiers.append("Framework :: Zope :: 3") + else: + new_classifiers.append(classifier) + + if license_counter > 1: + print("There are too many License :: classifiers, fix that first!") + sys.exit() + + return new_classifiers, license_classifiers + + +def check_license(license, license_classifier): + """Check license sanity check. + + Compare that the license key on setup.py and the license related classifier + match, otherwise complain. + + If they match, return a SPDX license complain expression. + """ + normalized_license = "".join(ch for ch in license.lower() if ch.isalnum()) + license_spdx = LICENSE_TO_SPDX.get(normalized_license) + if len(license_classifier) == 0: + if license_spdx: + return license_spdx + print(f'Unknown license "{license}", please fix it or remove it ') + print("so that the script does not complain.") + sys.exit(1) + + classifier_spdx = LICENSE_CLASSIFIER_TO_SPDX[license_classifier[0]] + if license_spdx and license_spdx != classifier_spdx: + print( + f'License "{license}" does not match classifier "{license_classifier[0]}".' + ) + sys.exit(1) + + return classifier_spdx + + def setup_args_to_toml_dict(setup_py_path, setup_kwargs): """Iterate over setup_kwargs and generate a dictionary of values suitable for pyproject.toml and a dictionary with unconverted arguments @@ -159,22 +233,13 @@ def setup_args_to_toml_dict(setup_py_path, setup_kwargs): if key in setup_kwargs: p_data[key] = setup_kwargs.pop(key) - license = setup_kwargs.pop("license", "ZPL-2.1") - license.replace("ZPL 2.1", "ZPL-2.1") - p_data["license"] = license + original_classifiers = setup_kwargs.pop("classifiers", []) + p_data["classifiers"], license_classifiers = handle_classifiers( + original_classifiers + ) - classifiers = setup_kwargs.pop("classifiers", []) - new_classifiers = [] - for classifier in classifiers: - if classifier.startswith("License"): - continue - elif classifier in ("Framework :: Zope2", "Framework :: Zope :: 2"): - continue - elif classifier == "Framework :: Zope3": - new_classifiers.append("Framework :: Zope :: 3") - else: - new_classifiers.append(classifier) - p_data["classifiers"] = new_classifiers + license = setup_kwargs.pop("license") + p_data["license"] = check_license(license, license_classifiers) readme = None for readme_name in ("README.rst", "README.txt"): From 3771f4b87caf04c6437746565cb37239b3b8e54a Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Wed, 18 Mar 2026 11:04:48 +0200 Subject: [PATCH 08/18] feat(setup-to-pyproject): add guard marks and url table Surround `pyproject.toml` `project` table with some special comments that `config-package` will use to avoid dropping that content whenever it runs. Add the `[project.urls]` table on `pyproject.toml` with a few project related URLs: source, issue tracker and change log. --- src/plone/meta/setup_to_pyproject.py | 33 ++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 8d9f046..816c202 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -14,6 +14,7 @@ from .config_package import META_HINT from .shared.call import call +from .shared.git import get_branch_name from .shared.git import git_branch from .shared.path import change_dir from importlib.util import module_from_spec @@ -444,12 +445,36 @@ def recursive_merge(dict1, dict2): p_toml["project"]["requires-python"] = ">=3.10" # Create a fresh TOMLDocument instance so I can control section sorting - with open(path.absolute().parent / ".meta.toml", "rb") as fp: - meta_cfg = tomlkit.load(fp) - config_type = meta_cfg["meta"].get("template") - new_doc = tomlkit.loads(META_HINT.format(config_type=config_type)) + new_doc = tomlkit.loads(META_HINT.format(config_type="default")) + + changes_file = "CHANGES.rst" + if (path / "CHANGES.md").exists(): + changes_file = "CHANGES.md" + project_name = path.resolve().parts[-1] + existing_branch = get_branch_name(override="current", config_type="default") + issues_url = "https://github.com/plone/Products.CMFPlone/issues" + project_url = f"https://github.com/plone/{project_name}" + changelog = f"{project_url}/blob/{existing_branch}/{changes_file}" + + comment_header = ( + "START-MARKER-MANUAL-CONFIG", + "Anything from here until END-MARKER-MANUAL-CONFIG", + "will be kept by plone.meta", + ) + for key in sorted(p_toml.keys()): + if key == "project": + for text in comment_header: + new_doc.add(tomlkit.comment(text)) new_doc[key] = p_toml.get(key) + if key == "project": + if "urls" not in new_doc[key]: + urls_table = tomlkit.table() + urls_table.append("Source", project_url) + new_doc[key].append("urls", urls_table) + new_doc[key]["urls"].append("Issues", issues_url) + new_doc[key]["urls"].append("Changelog", changelog) + new_doc.add(tomlkit.comment("END-MARKER-MANUAL-CONFIG")) return tomlkit.dumps(new_doc) From a01e20d361f85ac05df6b15b78910beddc5ce604 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Wed, 18 Mar 2026 17:09:21 +0200 Subject: [PATCH 09/18] fix(setup-to-pyproject): handle issues and changelog URLs --- src/plone/meta/setup_to_pyproject.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 816c202..4301904 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -403,7 +403,8 @@ def parse_setup_py(path): return leftover_setup_kwargs, toml_dict -def rewrite_pyproject_toml(path, toml_dict): +def rewrite_pyproject_toml(args, toml_dict): + path = args.path toml_file = path / "pyproject.toml" p_toml = get_pyproject_toml(toml_file) @@ -451,9 +452,19 @@ def recursive_merge(dict1, dict2): if (path / "CHANGES.md").exists(): changes_file = "CHANGES.md" project_name = path.resolve().parts[-1] - existing_branch = get_branch_name(override="current", config_type="default") issues_url = "https://github.com/plone/Products.CMFPlone/issues" project_url = f"https://github.com/plone/{project_name}" + if args.issues_url == "own": + issues_url = f"{project_url}/issues" + elif args.issues_url: + issues_url = args.issues_url + + existing_branch = get_branch_name(override="current", config_type="default") + if existing_branch not in ("master", "main"): + print( + "WARNING: check the projects.url.Changelog for accuracy, no proper default branch could be found" + ) + existing_branch = "master" changelog = f"{project_url}/blob/{existing_branch}/{changes_file}" comment_header = ( @@ -568,6 +579,14 @@ def main(): "If not given it is constructed automatically and includes " 'the configuration type. Use "current" to update the current branch.', ) + parser.add_argument( + "--issues", + dest="issues_url", + default=None, + help="Define the URL where to report issues about this project. " + "If not given it defaults to Products.CMFPlone issue tracker. " + 'Use "own" to use the repository own issue tracker (assuming GitHub).', + ) args = parser.parse_args() print(f"Converting package {args.path.name}") @@ -583,7 +602,7 @@ def main(): print("Package has been converted already, exiting.") sys.exit() - toml_content = rewrite_pyproject_toml(args.path, toml_dict) + toml_content = rewrite_pyproject_toml(args, toml_dict) setup_content = rewrite_setup_py(args.path / "setup.py", leftover_setup_kwargs) (args.path / "pyproject.toml").write_text(toml_content) From 9a0213aa55e7e22bb08a976f96b339810475b611 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Fri, 20 Mar 2026 10:34:28 +0100 Subject: [PATCH 10/18] fix(setp-to-pyproject): handle broken extras They are meant to be lists, but somehow `setup.py` is happy if it is a string (when it is only a single dependency). On `pyproject.toml` that is no longer valid. --- src/plone/meta/setup_to_pyproject.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 4301904..3c5f0b8 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -438,9 +438,13 @@ def recursive_merge(dict1, dict2): opt_deps = p_toml["project"].get("optional-dependencies", {}) for key, value in opt_deps.items(): - if len(value) > 1: - p_toml["project"]["optional-dependencies"][key].multiline(True) - + if isinstance(value, list): + if len(value) > 1: + p_toml["project"]["optional-dependencies"][key].multiline(True) + else: + print(f"XXX: {key} optional-dependencies needs to be a list.") + print("Fix it on setup.py and re-run.") + sys.exit() # Last sanity check to see if anything is missing if "requires-python" not in p_toml["project"]: p_toml["project"]["requires-python"] = ">=3.10" From f5c16e980e0f6300b49975597a72b54476778909 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Fri, 20 Mar 2026 10:36:12 +0100 Subject: [PATCH 11/18] feat(setup-to-pyproject): adjust maintainers --- src/plone/meta/setup_to_pyproject.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 3c5f0b8..91dedac 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -16,6 +16,7 @@ from .shared.call import call from .shared.git import get_branch_name from .shared.git import git_branch +from .shared.git import git_server_url from .shared.path import change_dir from importlib.util import module_from_spec from importlib.util import spec_from_file_location @@ -224,6 +225,12 @@ def setup_args_to_toml_dict(setup_py_path, setup_kwargs): toml_dict = {"project": {}} p_data = toml_dict["project"] + is_plone_org_repo = False + with change_dir(setup_py_path.parent): + repository = git_server_url() + if "github.com:plone/" in repository or "github.com/plone/" in repository: + is_plone_org_repo = True + for key in IGNORE_KEYS: setup_kwargs.pop(key, None) @@ -279,12 +286,13 @@ def setup_args_to_toml_dict(setup_py_path, setup_kwargs): p_data["authors"] = tomlkit.array() p_data["authors"].add_line(author_dict) - maintainers_table = { - "name": "Plone Foundation and contributors", - "email": "zope-dev@zope.dev", - } - p_data["maintainers"] = tomlkit.array() - p_data["maintainers"].add_line(maintainers_table) + if is_plone_org_repo: + maintainers_table = { + "name": "Plone Foundation and contributors", + "email": "plone-developers@lists.sourceforge.net", + } + p_data["maintainers"] = tomlkit.array() + p_data["maintainers"].add_line(maintainers_table) entry_points = {} scripts = {} From a237335662f42eee34d1bc3d64f1d00c72fe2bfb Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Fri, 20 Mar 2026 10:36:26 +0100 Subject: [PATCH 12/18] fix(setup-to-pyproject): handle md files as well --- src/plone/meta/setup_to_pyproject.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 91dedac..bf2500f 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -250,13 +250,13 @@ def setup_args_to_toml_dict(setup_py_path, setup_kwargs): p_data["license"] = check_license(license, license_classifiers) readme = None - for readme_name in ("README.rst", "README.txt"): + for readme_name in ("README.rst", "README.txt", "README.md"): if (setup_py_path.parent / readme_name).exists(): readme = readme_name break changelog = None - for changelog_name in ("CHANGES.rst", "CHANGES.txt"): + for changelog_name in ("CHANGES.rst", "CHANGES.txt", "CHANGES.md"): if (setup_py_path.parent / changelog_name).exists(): changelog = changelog_name break @@ -270,7 +270,7 @@ def setup_args_to_toml_dict(setup_py_path, setup_kwargs): dynamic_attributes = p_data.setdefault("dynamic", []) dynamic_attributes.append("readme") else: - print("XXX WARNING XXX: This package has no README.rst or README.txt!") + print("XXX WARNING XXX: This package has no README.(rst|txt|md)!") if "python_requires" in setup_kwargs: p_data["requires-python"] = setup_kwargs.pop("python_requires") From b85a474eef0b44c10f34a63bc63fd60974dde659 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Mon, 16 Mar 2026 18:21:25 +0200 Subject: [PATCH 13/18] chore: update coverage --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index fad65dd..592b0ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,4 +120,5 @@ omit = [ "src/plone/meta/re_enable_actions.py", "src/plone/meta/pep_420.py", "src/plone/meta/multi_call.py", + "src/plone/meta/setup_to_pyproject.py", ] From f245656ade86d071cfee28d024e62338568bca64 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Tue, 17 Mar 2026 16:15:57 +0200 Subject: [PATCH 14/18] feat(docs): explain how to run setup-to-pyproject --- docs/sources/how-to/index.md | 7 +++ docs/sources/how-to/setup-to-pyproject.md | 71 +++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 docs/sources/how-to/setup-to-pyproject.md diff --git a/docs/sources/how-to/index.md b/docs/sources/how-to/index.md index b5ee296..c814667 100644 --- a/docs/sources/how-to/index.md +++ b/docs/sources/how-to/index.md @@ -45,6 +45,13 @@ Set up CI jobs, Python version matrices, OS dependencies, and environment variab Set up CI pipelines for GitLab-hosted repositories, including custom Docker images and OS dependencies. ::: +:::{grid-item-card} Convert `setup.py` metadata to `pyproject.toml` +:link: setup-to-pyproject +:link-type: doc + +Convert the metadata in `setup.py` to `pyproject.toml` +::: + :::{grid-item-card} Re-enable GitHub Actions :link: re-enable-actions :link-type: doc diff --git a/docs/sources/how-to/setup-to-pyproject.md b/docs/sources/how-to/setup-to-pyproject.md new file mode 100644 index 0000000..6a7cc90 --- /dev/null +++ b/docs/sources/how-to/setup-to-pyproject.md @@ -0,0 +1,71 @@ +# Convert setup.py metadata to pyproject.toml + +`pyproject.toml`'s `[project]` metadata is the standard place to find a project metadata. + +Historically this was on `setup.py`, but it had plenty of problems. + +Now, you can write all of a project's metadata in `pyproject.toml`, +[see the specification](https://packaging.python.org/en/latest/specifications/pyproject-toml/). + +All plone and collective python distributions are using `setup.py` to keep each project's metadata. + +[`zope.meta`](https://pypi.org/project/zope.meta) created a script to move the metadata from `setup.py` to `pyproject.toml`. + +In `plone.meta` this script from `zope.meta` has been adapted to suit the needs of the Plone ecosystem. + +## Conversion + +To convert the metadata do the following: + +```bash +cd $REPOSITORY +uvx --from plone.meta setup-to-pyproject . +``` + +i.e. go to your repository and run the `setup-to-pyproject` script from `plone.meta`. + +This will automatically create a commit on your repository with the changes. + +:::{note} +Please review them carefully to ensure that the conversion was done properly. +::: + +Ideally `setup.py` should look like this: + +```python +from setuptools import setup + +# See pyproject.toml for package metadata +setup() +``` + +### Issues link + +`setup-to-pyproject` accepts an optional argument: `--issues`. + +This option is to customize the issues link displayed on PyPI related to the project. + +It accepts the following options: + +- `own`: use the repository itself as the issue tracker +- _URL_: provide a custom URL that will be used verbatim +- _None_: if no value is provided `Products.CMFPlone` issue tracker is used + +## Clean up + +Run some tooling `tox run -e test` to ensure that the conversion worked. + +:::{note} +It might be that the license field in `project.license` within `pyproject.toml` is broken. + +Please have a look at valid [license expressions](https://packaging.python.org/en/latest/specifications/license-expression/) to solve it. +::: + +Re-configure the repository with `plone.meta` to ensure that the project metadata is kept: + +```bash +cd $REPOSITORY +uvx --from plone.meta config-package branch=current . +``` + +Make sure to review the commit generated by `config-package`. From e358940d5c71573b85eb89c816d8533db9d94c00 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Wed, 18 Mar 2026 11:20:00 +0200 Subject: [PATCH 15/18] Add news entry --- news/315.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/315.feature diff --git a/news/315.feature b/news/315.feature new file mode 100644 index 0000000..19ce8a2 --- /dev/null +++ b/news/315.feature @@ -0,0 +1 @@ +Add `setup-to-pyproject` script to move `setup.py` metadata into `pyproject.toml` @gforcada From ba853b46f9bcb59cb9680eba929546310f7f3c97 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Fri, 27 Mar 2026 14:59:14 +0100 Subject: [PATCH 16/18] Update src/plone/meta/setup_to_pyproject.py Co-authored-by: Maurits van Rees --- src/plone/meta/setup_to_pyproject.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index bf2500f..10099b3 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -433,9 +433,9 @@ def recursive_merge(dict1, dict2): p_toml = recursive_merge(p_toml, toml_dict) # Format long lists - p_toml["project"]["classifiers"].multiline(True) - p_toml["project"]["authors"].multiline(True) - p_toml["project"]["maintainers"].multiline(True) + for long_list in ("classifiers", "authors", "maintainers"): + if long_list in p_toml["project"]: + p_toml["project"][long_list].multiline(True) if ( "dependencies" in p_toml["project"] and len(p_toml["project"]["dependencies"]) > 1 From df9f12b07602f44123272eec01417f28dfda3756 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Fri, 27 Mar 2026 15:00:04 +0100 Subject: [PATCH 17/18] Update src/plone/meta/setup_to_pyproject.py Co-authored-by: Maurits van Rees --- src/plone/meta/setup_to_pyproject.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 10099b3..748fcae 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -466,6 +466,19 @@ def recursive_merge(dict1, dict2): project_name = path.resolve().parts[-1] issues_url = "https://github.com/plone/Products.CMFPlone/issues" project_url = f"https://github.com/plone/{project_name}" + with change_dir(path): + # Let's see if we can find a different url. + repository = git_server_url() + if "github.com" in repository: + # Can be 'git@github.com:' or 'https://github.com/'. + repo_path = repository[ + repository.find("github.com") + len("github.com") + 1 : + ] + # repo_path is like 'plone/project.name.git' + organisation = repo_path.split("/")[0] + project_url = f"https://github.com/{organisation}/{project_name}" + if organisation != "plone": + issues_url = f"{project_url}/issues" if args.issues_url == "own": issues_url = f"{project_url}/issues" elif args.issues_url: From 504b64dca0f958e58a7eb432d8c47c2c02816061 Mon Sep 17 00:00:00 2001 From: Gil Forcada Codinachs Date: Fri, 27 Mar 2026 15:00:37 +0100 Subject: [PATCH 18/18] Update src/plone/meta/setup_to_pyproject.py Co-authored-by: Maurits van Rees --- src/plone/meta/setup_to_pyproject.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plone/meta/setup_to_pyproject.py b/src/plone/meta/setup_to_pyproject.py index 748fcae..48bb970 100644 --- a/src/plone/meta/setup_to_pyproject.py +++ b/src/plone/meta/setup_to_pyproject.py @@ -484,7 +484,8 @@ def recursive_merge(dict1, dict2): elif args.issues_url: issues_url = args.issues_url - existing_branch = get_branch_name(override="current", config_type="default") + with change_dir(path): + existing_branch = get_branch_name(override="current", config_type="default") if existing_branch not in ("master", "main"): print( "WARNING: check the projects.url.Changelog for accuracy, no proper default branch could be found"