Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/+ruff-target-version-auto.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion mise.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 0 additions & 7 deletions src/coppy_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
4 changes: 3 additions & 1 deletion template/ruff.toml.jinja
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
line-length = 100
target-version = '{{ ruff_python_version() }}'
output-format = 'concise'

[format]
Expand Down Expand Up @@ -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]
Expand Down
16 changes: 15 additions & 1 deletion template/tasks/mise-uv-init.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
44 changes: 40 additions & 4 deletions tests/coppy_tests/test_template.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
from pathlib import Path
import subprocess

import pytest

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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.
Expand Down