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..dccaf20 --- /dev/null +++ b/changelog.d/+ruff-target-version-auto.fixed.md @@ -0,0 +1,4 @@ +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. 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/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 a3c07a2..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] @@ -74,6 +73,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..25d1100 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 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.