From f31d86ec7b6954813aa9c6907ecbb08cd990a8e0 Mon Sep 17 00:00:00 2001 From: Glen Gerbush Date: Tue, 2 Jun 2026 20:20:56 -0400 Subject: [PATCH 1/4] Bug fixes --- mise.lock | 2 +- ruff.toml | 3 +++ template/ruff.toml.jinja | 3 +++ template/tasks/mise-uv-init.py | 16 +++++++++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/mise.lock b/mise.lock index f3130c3..ca4eaf1 100644 --- a/mise.lock +++ b/mise.lock @@ -1,4 +1,4 @@ -# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html [[tools.python]] version = "3.12.11" diff --git a/ruff.toml b/ruff.toml index ce9d500..1bd9ff7 100644 --- a/ruff.toml +++ b/ruff.toml @@ -74,6 +74,9 @@ ignore = [ [lint.per-file-ignores] 'tasks/*.py' = ['EXE003'] +# Polyglot sh/python bootstrap: the sh preamble must be the first statement, so imports +# can't be at the top of the file. (EXE003 already covered by the tasks/*.py glob above.) +'tasks/mise-uv-init.py' = ['E402'] [lint.flake8-builtins] diff --git a/template/ruff.toml.jinja b/template/ruff.toml.jinja index a3c07a2..534b7b3 100644 --- a/template/ruff.toml.jinja +++ b/template/ruff.toml.jinja @@ -74,6 +74,9 @@ ignore = [ [lint.per-file-ignores] 'tasks/*.py' = ['EXE003'] +# Polyglot sh/python bootstrap: the sh preamble must be the first statement, so imports +# can't be at the top of the file. (EXE003 already covered by the tasks/*.py glob above.) +'tasks/mise-uv-init.py' = ['E402'] [lint.flake8-builtins] diff --git a/template/tasks/mise-uv-init.py b/template/tasks/mise-uv-init.py index 592d9f6..c052000 100755 --- a/template/tasks/mise-uv-init.py +++ b/template/tasks/mise-uv-init.py @@ -1,4 +1,18 @@ -#!/usr/bin/env python3 +#!/bin/sh +# Polyglot bootstrap (valid as both /bin/sh and python). This runs during mise's env +# evaluation, before any venv/tool exists, so it must use a real *system* python3 and never +# a mise/uv shim. `#!/usr/bin/env python3` can't be used: env resolves `python3` to mise's +# shim, which re-enters mise and exhausts process limits (os error 11). The sh preamble +# below re-execs this file under the first real python3 it finds (covers Linux + macOS); +# to python the whole preamble is just an ignored string literal. +""":" +for _py in /usr/bin/python3 /opt/homebrew/bin/python3 /usr/local/bin/python3; do + [ -x "$_py" ] && exec "$_py" "$0" "$@" +done +echo 'mise-uv-init: no system python3 found (checked /usr/bin, Homebrew, /usr/local)' >&2 +exit 1 +""" + """ #MISE hide=true From e0f661aa2418889912577c16392f09dbe13bbf0f Mon Sep 17 00:00:00 2001 From: Glen Gerbush Date: Thu, 4 Jun 2026 22:12:23 -0400 Subject: [PATCH 2/4] Fix polyglot terminator --- template/tasks/mise-uv-init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/tasks/mise-uv-init.py b/template/tasks/mise-uv-init.py index c052000..25d1100 100755 --- a/template/tasks/mise-uv-init.py +++ b/template/tasks/mise-uv-init.py @@ -11,7 +11,7 @@ done echo 'mise-uv-init: no system python3 found (checked /usr/bin, Homebrew, /usr/local)' >&2 exit 1 -""" +":""" """ #MISE hide=true From ee748a33cd521557eb6ae11547a692eb42293f5e Mon Sep 17 00:00:00 2001 From: Glen Gerbush Date: Tue, 9 Jun 2026 14:45:12 -0400 Subject: [PATCH 3/4] Add test for recursive mise-uv-init and strip py version from ruff.toml --- .../+ruff-target-version-auto.fixed.md | 5 +++ src/coppy/migrate.py | 28 ++++++++++++ src/coppy_extensions.py | 7 --- template/ruff.toml.jinja | 1 - tests/coppy_tests/test_migrate.py | 40 +++++++++++++++++ tests/coppy_tests/test_template.py | 44 +++++++++++++++++-- 6 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 changelog.d/+ruff-target-version-auto.fixed.md diff --git a/changelog.d/+ruff-target-version-auto.fixed.md b/changelog.d/+ruff-target-version-auto.fixed.md new file mode 100644 index 0000000..9442650 --- /dev/null +++ b/changelog.d/+ruff-target-version-auto.fixed.md @@ -0,0 +1,5 @@ +Generated `ruff.toml` no longer pins `target-version` + +Ruff now auto-derives its target from `pyproject.toml`'s `requires-python`, so edits to the +project's supported Python versions stay in sync with ruff without re-running copier. +`copier update` will strip the legacy `target-version = 'py3XX'` line from existing projects. diff --git a/src/coppy/migrate.py b/src/coppy/migrate.py index c5304dc..35907c5 100644 --- a/src/coppy/migrate.py +++ b/src/coppy/migrate.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field from pathlib import Path +import re import sys import click @@ -7,6 +8,12 @@ from coppy.utils import CalledProcessError, sub_run +# Matches the exact line older versions of the template emitted in ruff.toml: +# target-version = 'py313' +# Stripping it lets ruff auto-derive the target from pyproject.toml's `requires-python`. +LEGACY_RUFF_TARGET_VERSION_RE = re.compile(r"^target-version = 'py\d+'\n", re.MULTILINE) + + @dataclass(slots=True) class Migrator: project_dpath: Path @@ -29,6 +36,10 @@ def prek_fpath(self) -> Path: def mise_lock_fpath(self) -> Path: return self.project_dpath / 'mise.lock' + @property + def ruff_toml_fpath(self) -> Path: + return self.project_dpath / 'ruff.toml' + def before(self) -> None: if not self.pre_commit_config_fpath.exists(): self.temp_prek_fpath.unlink(missing_ok=True) @@ -69,9 +80,26 @@ def after(self) -> None: cwd=self.project_dpath, ) + self.strip_legacy_ruff_target_version() + if self.mise_lock: self.ensure_mise_lock() + def strip_legacy_ruff_target_version(self) -> None: + if not self.ruff_toml_fpath.exists(): + return + + old_text = self.ruff_toml_fpath.read_text() + new_text = LEGACY_RUFF_TARGET_VERSION_RE.sub('', old_text) + if new_text == old_text: + return + + self.ruff_toml_fpath.write_text(new_text) + click.echo( + 'Removed legacy `target-version` from `ruff.toml`; ' + "ruff will derive it from `pyproject.toml`'s `requires-python`", + ) + def ensure_mise_lock(self) -> None: if self.mise_lock_fpath.exists() and self.mise_lock_fpath.read_text().strip(): return diff --git a/src/coppy_extensions.py b/src/coppy_extensions.py index be33f56..d8a3748 100644 --- a/src/coppy_extensions.py +++ b/src/coppy_extensions.py @@ -14,12 +14,6 @@ def git_user_email() -> str: return subprocess.getoutput('git config user.email').strip() -@jinja2.pass_context -def ruff_python_version(ctx) -> str: - python_version = ctx.get('python_version_min') or ctx.get('python_version') - return 'py' + python_version.replace('.', '') - - def slugify(value, separator='-'): value = unicodedata.normalize('NFKD', str(value)).encode('ascii', 'ignore').decode('ascii') value = re.sub(r'[^\w\s-]', '', value.lower()) @@ -42,7 +36,6 @@ def __init__(self, environment): environment.globals['git_user_name'] = git_user_name environment.globals['git_user_email'] = git_user_email environment.globals['gh_action_badge'] = gh_action_badge - environment.globals['ruff_python_version'] = ruff_python_version # filters environment.filters['slugify'] = slugify diff --git a/template/ruff.toml.jinja b/template/ruff.toml.jinja index 534b7b3..4f0524f 100644 --- a/template/ruff.toml.jinja +++ b/template/ruff.toml.jinja @@ -1,5 +1,4 @@ line-length = 100 -target-version = '{{ ruff_python_version() }}' output-format = 'concise' [format] diff --git a/tests/coppy_tests/test_migrate.py b/tests/coppy_tests/test_migrate.py index bee6cf7..fc31298 100644 --- a/tests/coppy_tests/test_migrate.py +++ b/tests/coppy_tests/test_migrate.py @@ -206,6 +206,46 @@ def test_after_runs_prek_install_before_failed_mise_lock(self, project_dpath: Pa assert m_sub_run.call_args_list[0].args[1:4] == ('-m', 'prek', 'install') assert m_sub_run.call_args_list[1].args == ('mise', 'lock') + def test_after_strips_legacy_ruff_target_version(self, project_dpath: Path, capsys): + ruff_fpath = project_dpath / 'ruff.toml' + ruff_fpath.write_text( + "line-length = 100\ntarget-version = 'py313'\noutput-format = 'concise'\n", + ) + + Migrator(project_dpath, mise_lock=False).after() + + assert ruff_fpath.read_text() == "line-length = 100\noutput-format = 'concise'\n" + assert 'Removed legacy `target-version` from `ruff.toml`' in capsys.readouterr().out + + def test_after_leaves_modern_ruff_toml_alone(self, project_dpath: Path, capsys): + ruff_fpath = project_dpath / 'ruff.toml' + original = "line-length = 100\noutput-format = 'concise'\n" + ruff_fpath.write_text(original) + + Migrator(project_dpath, mise_lock=False).after() + + assert ruff_fpath.read_text() == original + assert 'Removed legacy' not in capsys.readouterr().out + + def test_after_preserves_customized_target_version(self, project_dpath: Path): + # Only the exact single-quoted form the template emitted is stripped. Anything the user + # has customized away from that form (different quoting, trailing comment, etc.) survives. + ruff_fpath = project_dpath / 'ruff.toml' + original = ( + 'target-version = "py313"\n' + "target-version = 'py313' # pinned\n" + ) + ruff_fpath.write_text(original) + + Migrator(project_dpath, mise_lock=False).after() + + assert ruff_fpath.read_text() == original + + def test_after_skips_when_ruff_toml_missing(self, project_dpath: Path): + Migrator(project_dpath, mise_lock=False).after() + + assert not (project_dpath / 'ruff.toml').exists() + def test_copier_wires_hidden_migrate_commands(self): copier_cfg = yaml.safe_load((utils.pkg_dpath / 'copier.yaml').read_text()) diff --git a/tests/coppy_tests/test_template.py b/tests/coppy_tests/test_template.py index fa3cb1e..80db846 100644 --- a/tests/coppy_tests/test_template.py +++ b/tests/coppy_tests/test_template.py @@ -1,5 +1,6 @@ import datetime from pathlib import Path +import subprocess import pytest @@ -49,12 +50,14 @@ def test_pyproject_python_version_min(self, package: Package): assert config.project['requires-python'] == '>=3.12' assert package.read_text('.python-version').strip() == '3.13' - def test_ruff_tracks_python_version_min(self, gen_pkg: Package, package: Package): - assert gen_pkg.toml_config('ruff.toml')['target-version'] == 'py313' + def test_ruff_target_version_unset(self, gen_pkg: Package, package: Package): + # Omit `target-version` from ruff.toml so ruff auto-derives it from `requires-python` + # in pyproject.toml. Otherwise the two pins drift whenever a user later edits + # `requires-python` without re-running copier. + assert 'target-version' not in gen_pkg.toml_config('ruff.toml') package.generate(python_version='3.13', python_version_min='3.12') - - assert package.toml_config('ruff.toml')['target-version'] == 'py312' + assert 'target-version' not in package.toml_config('ruff.toml') def test_hatch_uv(self, gen_pkg: Package): hatch = gen_pkg.toml_config('hatch.toml') @@ -145,6 +148,39 @@ def test_scripts(self, gen_pkg: Package, package: Package): assert snippet in package.read_text('pyproject.toml') +class TestMiseUvInitPolyglot: + """`template/tasks/mise-uv-init.py` is a polyglot that must parse as both /bin/sh and Python. + + Mise runs the script's sh preamble before any python3 shim exists, so the preamble must + re-exec under a real system python3 without sh choking on the trailing terminator. + """ + + @pytest.fixture(scope='class') + def script_fpath(self) -> Path: + return utils.pkg_dpath / 'template' / 'tasks' / 'mise-uv-init.py' + + def test_python_compiles(self, script_fpath: Path): + compile(script_fpath.read_text(), str(script_fpath), 'exec') + + def test_sh_preamble_syntax(self, script_fpath: Path): + # The preamble is delimited by the polyglot opener/closer. An invalid closer (e.g. a + # bare `"""`) leaves an unterminated sh quote and fails `sh -n`. + lines = script_fpath.read_text().splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith('""":"')) + end = next( + i for i, line in enumerate(lines[start + 1 :], start + 1) if line.startswith('":"""') + ) + preamble = '\n'.join(lines[start : end + 1]) + + result = subprocess.run( + ['/bin/sh', '-n'], + input=preamble, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + class TestTemplateWithSandbox: """ Sandbox tests take longer. Separate them out for easier targeting of quicker tests. From 0a587cf4f2a41589e3d0fe088737f12141cd0178 Mon Sep 17 00:00:00 2001 From: Glen Gerbush Date: Tue, 9 Jun 2026 16:15:02 -0400 Subject: [PATCH 4/4] Remove redundant migration step --- .../+ruff-target-version-auto.fixed.md | 1 - src/coppy/migrate.py | 28 ------------- tests/coppy_tests/test_migrate.py | 40 ------------------- 3 files changed, 69 deletions(-) diff --git a/changelog.d/+ruff-target-version-auto.fixed.md b/changelog.d/+ruff-target-version-auto.fixed.md index 9442650..dccaf20 100644 --- a/changelog.d/+ruff-target-version-auto.fixed.md +++ b/changelog.d/+ruff-target-version-auto.fixed.md @@ -2,4 +2,3 @@ Generated `ruff.toml` no longer pins `target-version` Ruff now auto-derives its target from `pyproject.toml`'s `requires-python`, so edits to the project's supported Python versions stay in sync with ruff without re-running copier. -`copier update` will strip the legacy `target-version = 'py3XX'` line from existing projects. diff --git a/src/coppy/migrate.py b/src/coppy/migrate.py index 35907c5..c5304dc 100644 --- a/src/coppy/migrate.py +++ b/src/coppy/migrate.py @@ -1,6 +1,5 @@ from dataclasses import dataclass, field from pathlib import Path -import re import sys import click @@ -8,12 +7,6 @@ from coppy.utils import CalledProcessError, sub_run -# Matches the exact line older versions of the template emitted in ruff.toml: -# target-version = 'py313' -# Stripping it lets ruff auto-derive the target from pyproject.toml's `requires-python`. -LEGACY_RUFF_TARGET_VERSION_RE = re.compile(r"^target-version = 'py\d+'\n", re.MULTILINE) - - @dataclass(slots=True) class Migrator: project_dpath: Path @@ -36,10 +29,6 @@ def prek_fpath(self) -> Path: def mise_lock_fpath(self) -> Path: return self.project_dpath / 'mise.lock' - @property - def ruff_toml_fpath(self) -> Path: - return self.project_dpath / 'ruff.toml' - def before(self) -> None: if not self.pre_commit_config_fpath.exists(): self.temp_prek_fpath.unlink(missing_ok=True) @@ -80,26 +69,9 @@ def after(self) -> None: cwd=self.project_dpath, ) - self.strip_legacy_ruff_target_version() - if self.mise_lock: self.ensure_mise_lock() - def strip_legacy_ruff_target_version(self) -> None: - if not self.ruff_toml_fpath.exists(): - return - - old_text = self.ruff_toml_fpath.read_text() - new_text = LEGACY_RUFF_TARGET_VERSION_RE.sub('', old_text) - if new_text == old_text: - return - - self.ruff_toml_fpath.write_text(new_text) - click.echo( - 'Removed legacy `target-version` from `ruff.toml`; ' - "ruff will derive it from `pyproject.toml`'s `requires-python`", - ) - def ensure_mise_lock(self) -> None: if self.mise_lock_fpath.exists() and self.mise_lock_fpath.read_text().strip(): return diff --git a/tests/coppy_tests/test_migrate.py b/tests/coppy_tests/test_migrate.py index fc31298..bee6cf7 100644 --- a/tests/coppy_tests/test_migrate.py +++ b/tests/coppy_tests/test_migrate.py @@ -206,46 +206,6 @@ def test_after_runs_prek_install_before_failed_mise_lock(self, project_dpath: Pa assert m_sub_run.call_args_list[0].args[1:4] == ('-m', 'prek', 'install') assert m_sub_run.call_args_list[1].args == ('mise', 'lock') - def test_after_strips_legacy_ruff_target_version(self, project_dpath: Path, capsys): - ruff_fpath = project_dpath / 'ruff.toml' - ruff_fpath.write_text( - "line-length = 100\ntarget-version = 'py313'\noutput-format = 'concise'\n", - ) - - Migrator(project_dpath, mise_lock=False).after() - - assert ruff_fpath.read_text() == "line-length = 100\noutput-format = 'concise'\n" - assert 'Removed legacy `target-version` from `ruff.toml`' in capsys.readouterr().out - - def test_after_leaves_modern_ruff_toml_alone(self, project_dpath: Path, capsys): - ruff_fpath = project_dpath / 'ruff.toml' - original = "line-length = 100\noutput-format = 'concise'\n" - ruff_fpath.write_text(original) - - Migrator(project_dpath, mise_lock=False).after() - - assert ruff_fpath.read_text() == original - assert 'Removed legacy' not in capsys.readouterr().out - - def test_after_preserves_customized_target_version(self, project_dpath: Path): - # Only the exact single-quoted form the template emitted is stripped. Anything the user - # has customized away from that form (different quoting, trailing comment, etc.) survives. - ruff_fpath = project_dpath / 'ruff.toml' - original = ( - 'target-version = "py313"\n' - "target-version = 'py313' # pinned\n" - ) - ruff_fpath.write_text(original) - - Migrator(project_dpath, mise_lock=False).after() - - assert ruff_fpath.read_text() == original - - def test_after_skips_when_ruff_toml_missing(self, project_dpath: Path): - Migrator(project_dpath, mise_lock=False).after() - - assert not (project_dpath / 'ruff.toml').exists() - def test_copier_wires_hidden_migrate_commands(self): copier_cfg = yaml.safe_load((utils.pkg_dpath / 'copier.yaml').read_text())