From fc84900aa8cbdedfce8e61b36e646514dbacd926 Mon Sep 17 00:00:00 2001 From: Owen Price Skelly <21372141+OwenPriceSkelly@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:11:39 -0800 Subject: [PATCH 1/4] ensure all `[tool.uv]` settings are honored --- .../templates/shell_command.sh.jinja | 9 +- src/groundhog_hpc/templating.py | 23 +- tests/test_templating.py | 245 ++++++++++++++++++ 3 files changed, 273 insertions(+), 4 deletions(-) diff --git a/src/groundhog_hpc/templates/shell_command.sh.jinja b/src/groundhog_hpc/templates/shell_command.sh.jinja index 610a858..e788cee 100644 --- a/src/groundhog_hpc/templates/shell_command.sh.jinja +++ b/src/groundhog_hpc/templates/shell_command.sh.jinja @@ -81,9 +81,16 @@ else "$UV_BIN" venv "$ENV_DIR"{% if requires_python %} --python "{{ requires_python }}"{% endif %} + {% if uv_config_toml %} + # Write uv config so pip install honours all [tool.uv] settings + cat > "$ENV_DIR/uv.toml" << 'UV_CONFIG_EOF' +{{ uv_config_toml | escape_braces }} +UV_CONFIG_EOF + {% endif %} + # Install dependencies "$UV_BIN" pip install --python "$ENV_DIR/bin/python" \ - {% if exclude_newer %}--exclude-newer "{{ exclude_newer }}" {% endif %}\ + {% if uv_config_toml %}--config-file "$ENV_DIR/uv.toml" {% endif %}\ --exclude-newer-package groundhog-hpc={{ groundhog_timestamp }} \ {% for dep in dependencies %}"{{ dep }}" {% endfor %}{{ version_spec }} diff --git a/src/groundhog_hpc/templating.py b/src/groundhog_hpc/templating.py index f59a4b3..5d0eb16 100644 --- a/src/groundhog_hpc/templating.py +++ b/src/groundhog_hpc/templating.py @@ -16,6 +16,7 @@ from hashlib import sha1 from pathlib import Path +import tomlkit from jinja2 import Environment, FileSystemLoader from groundhog_hpc.configuration.models import Pep723Metadata @@ -149,6 +150,8 @@ def template_shell_command(script_path: str, function_name: str, payload: str) - local_log_level = local_log_level.upper() logger.debug(f"Propagating log level to remote: {local_log_level}") + uv_config_toml = _serialize_uv_toml(metadata) + # Render shell command shell_template = jinja_env.get_template("shell_command.sh.jinja") shell_command_string = shell_template.render( @@ -165,9 +168,7 @@ def template_shell_command(script_path: str, function_name: str, payload: str) - groundhog_version=groundhog_version, requires_python=metadata.requires_python if metadata else "", dependencies=metadata.dependencies if metadata else [], - exclude_newer=metadata.tool.uv.exclude_newer - if metadata and metadata.tool and metadata.tool.uv - else None, + uv_config_toml=uv_config_toml, ) logger.debug(f"Generated shell command ({len(shell_command_string)} chars)") @@ -175,6 +176,22 @@ def template_shell_command(script_path: str, function_name: str, payload: str) - return shell_command_string +def _serialize_uv_toml(metadata: Pep723Metadata | None) -> str: + """Serialize [tool.uv] settings to uv.toml format for uv pip install. + + Returns a TOML string containing all non-None settings from the user's + [tool.uv] block, or an empty string if there are no settings. + """ + if not metadata or not metadata.tool or not metadata.tool.uv: + return "" + + uv_dict = metadata.tool.uv.model_dump(by_alias=True, exclude_none=True) + if not uv_dict: + return "" + + return tomlkit.dumps(uv_dict).strip() + + def _script_hash_prefix(contents: str, length: int = 8) -> str: return str(sha1(bytes(contents, "utf-8")).hexdigest()[:length]) diff --git a/tests/test_templating.py b/tests/test_templating.py index 9e825a6..9631a33 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -675,6 +675,251 @@ def func(): ) +class TestSerializeUvToml: + """Test TOML serialization of [tool.uv] settings. + + Note: UvMetadata fields use hyphenated aliases (e.g. "exclude-newer"). + With Pydantic's default populate_by_name=False, the aliases must be used + when constructing via **{...} unpacking to set the intended fields. + """ + + def test_returns_empty_string_for_none_metadata(self): + from groundhog_hpc.templating import _serialize_uv_toml + + result = _serialize_uv_toml(None) + + assert result == "" + + def test_returns_empty_string_when_tool_is_none(self): + from groundhog_hpc.configuration.models import Pep723Metadata + from groundhog_hpc.templating import _serialize_uv_toml + + metadata = Pep723Metadata( + requires_python=">=3.11", + dependencies=[], + tool=None, + ) + + result = _serialize_uv_toml(metadata) + + assert result == "" + + def test_serializes_string_values(self): + from groundhog_hpc.configuration.models import ( + Pep723Metadata, + ToolMetadata, + UvMetadata, + ) + from groundhog_hpc.templating import _serialize_uv_toml + + metadata = Pep723Metadata( + requires_python=">=3.11", + dependencies=[], + tool=ToolMetadata( + uv=UvMetadata( + **{ + "exclude-newer": "2025-01-01T00:00:00Z", + "python-preference": "only-managed", + "index-url": "https://private.example.com/simple", + } + ) + ), + ) + + result = _serialize_uv_toml(metadata) + + assert 'exclude-newer = "2025-01-01T00:00:00Z"' in result + assert 'python-preference = "only-managed"' in result + assert 'index-url = "https://private.example.com/simple"' in result + + def test_serializes_list_values(self): + from groundhog_hpc.configuration.models import ( + Pep723Metadata, + ToolMetadata, + UvMetadata, + ) + from groundhog_hpc.templating import _serialize_uv_toml + + metadata = Pep723Metadata( + requires_python=">=3.11", + dependencies=[], + tool=ToolMetadata( + uv=UvMetadata( + **{ + "extra-index-url": [ + "https://download.pytorch.org/whl/cpu", + "https://private.example.com/simple", + ], + } + ) + ), + ) + + result = _serialize_uv_toml(metadata) + + assert "extra-index-url" in result + assert '"https://download.pytorch.org/whl/cpu"' in result + assert '"https://private.example.com/simple"' in result + + def test_serializes_bool_values(self): + from groundhog_hpc.configuration.models import ( + Pep723Metadata, + ToolMetadata, + UvMetadata, + ) + from groundhog_hpc.templating import _serialize_uv_toml + + metadata = Pep723Metadata( + requires_python=">=3.11", + dependencies=[], + tool=ToolMetadata(uv=UvMetadata(**{"offline": True})), + ) + + result = _serialize_uv_toml(metadata) + + assert "offline = true" in result + + def test_fields_defaulting_to_none_are_excluded(self): + """Fields whose default is None (index-url, extra-index-url, offline) don't appear.""" + from groundhog_hpc.configuration.models import ( + Pep723Metadata, + ToolMetadata, + UvMetadata, + ) + from groundhog_hpc.templating import _serialize_uv_toml + + # Only set exclude-newer; leave index-url, extra-index-url, offline at None default + metadata = Pep723Metadata( + requires_python=">=3.11", + dependencies=[], + tool=ToolMetadata( + uv=UvMetadata(**{"exclude-newer": "2025-01-01T00:00:00Z"}) + ), + ) + + result = _serialize_uv_toml(metadata) + + assert "index-url" not in result + assert "extra-index-url" not in result + assert "offline" not in result + + def test_extra_fields_are_included(self): + """Extra uv settings (via extra='allow') round-trip through the TOML.""" + from groundhog_hpc.configuration.models import ( + Pep723Metadata, + ToolMetadata, + UvMetadata, + ) + from groundhog_hpc.templating import _serialize_uv_toml + + # Simulate a uv setting not explicitly modelled, parsed from TOML + metadata = Pep723Metadata( + requires_python=">=3.11", + dependencies=[], + tool=ToolMetadata( + uv=UvMetadata(**{"find-links": "https://example.com/wheels"}) + ), + ) + + result = _serialize_uv_toml(metadata) + + assert 'find-links = "https://example.com/wheels"' in result + + +class TestUvTomlInShellCommand: + """Test that uv.toml config file is written and used in shell commands.""" + + def test_shell_command_writes_uv_toml_when_tool_uv_present(self, tmp_path): + """When [tool.uv] is configured, the shell command writes a uv.toml.""" + script_path = tmp_path / "script.py" + script_path.write_text("""# /// script +# requires-python = ">=3.11" +# dependencies = ["numpy"] +# +# [tool.uv] +# exclude-newer = "2025-01-01T00:00:00Z" +# extra-index-url = ["https://download.pytorch.org/whl/cpu"] +# /// + +import groundhog_hpc as hog + +@hog.function() +def func(): + return 1 +""") + + shell_command = template_shell_command(str(script_path), "func", "payload") + + assert '"$ENV_DIR/uv.toml"' in shell_command + assert 'exclude-newer = "2025-01-01T00:00:00Z"' in shell_command + assert '"https://download.pytorch.org/whl/cpu"' in shell_command + + def test_shell_command_uses_config_file_flag_for_pip_install(self, tmp_path): + """uv pip install receives --config-file pointing at the written uv.toml.""" + script_path = tmp_path / "script.py" + script_path.write_text("""# /// script +# requires-python = ">=3.11" +# dependencies = [] +# +# [tool.uv] +# exclude-newer = "2025-06-01T00:00:00Z" +# /// + +import groundhog_hpc as hog + +@hog.function() +def func(): + return 1 +""") + + shell_command = template_shell_command(str(script_path), "func", "payload") + + assert '--config-file "$ENV_DIR/uv.toml"' in shell_command + + def test_exclude_newer_not_passed_as_cli_flag(self, tmp_path): + """--exclude-newer is no longer a CLI flag; it lives in uv.toml.""" + script_path = tmp_path / "script.py" + script_path.write_text("""# /// script +# requires-python = ">=3.11" +# dependencies = [] +# +# [tool.uv] +# exclude-newer = "2025-01-01T00:00:00Z" +# /// + +import groundhog_hpc as hog + +@hog.function() +def func(): + return 1 +""") + + shell_command = template_shell_command(str(script_path), "func", "payload") + + # --exclude-newer as a standalone CLI flag should be gone + import re + + assert not re.search(r'--exclude-newer\s+"', shell_command), ( + "--exclude-newer should not appear as a standalone CLI flag; " + "it should be in uv.toml instead" + ) + + def test_no_uv_toml_written_for_script_without_pep723_metadata(self, tmp_path): + """Scripts without PEP 723 metadata don't write a uv.toml.""" + script_path = tmp_path / "script.py" + script_path.write_text("""import groundhog_hpc as hog + +@hog.function() +def func(): + return 1 +""") + + shell_command = template_shell_command(str(script_path), "func", "payload") + + assert "UV_CONFIG_EOF" not in shell_command + assert "--config-file" not in shell_command + + class TestDottedQualnames: """Test that templating handles dotted qualnames (class methods).""" From a38d53113a73a09554555a936257d7216cce9bb9 Mon Sep 17 00:00:00 2001 From: Owen Price Skelly <21372141+OwenPriceSkelly@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:25:30 -0800 Subject: [PATCH 2/4] point `uv venv` command at user config too --- .../templates/shell_command.sh.jinja | 18 ++++-- tests/test_templating.py | 57 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/groundhog_hpc/templates/shell_command.sh.jinja b/src/groundhog_hpc/templates/shell_command.sh.jinja index e788cee..d89f3be 100644 --- a/src/groundhog_hpc/templates/shell_command.sh.jinja +++ b/src/groundhog_hpc/templates/shell_command.sh.jinja @@ -79,22 +79,30 @@ else fi {% endraw %} - "$UV_BIN" venv "$ENV_DIR"{% if requires_python %} --python "{{ requires_python }}"{% endif %} - {% if uv_config_toml %} - # Write uv config so pip install honours all [tool.uv] settings - cat > "$ENV_DIR/uv.toml" << 'UV_CONFIG_EOF' + + # Both `uv venv` and `uv pip install` will receive --config-file, then + # the file is moved into $ENV_DIR as a human-readable audit trail. + {% raw %}UV_TOML="${{ENV_DIR}}.uv.toml"{% endraw %} + + cat > "$UV_TOML" << 'UV_CONFIG_EOF' {{ uv_config_toml | escape_braces }} UV_CONFIG_EOF {% endif %} + "$UV_BIN" venv "$ENV_DIR"{% if requires_python %} --python "{{ requires_python }}"{% endif %}{% if uv_config_toml %} --config-file "$UV_TOML"{% endif %} + + {% if uv_config_toml %} + mv "$UV_TOML" "$ENV_DIR/uv.toml" + {% endif %} + # Install dependencies "$UV_BIN" pip install --python "$ENV_DIR/bin/python" \ {% if uv_config_toml %}--config-file "$ENV_DIR/uv.toml" {% endif %}\ --exclude-newer-package groundhog-hpc={{ groundhog_timestamp }} \ {% for dep in dependencies %}"{{ dep }}" {% endfor %}{{ version_spec }} - # Write metadata for debugging + # Write metadata for posterity cat > "$ENV_DIR/groundhog-meta.json" << 'META_EOF' {{ '{{' }} "created_at": "{{ groundhog_timestamp }}", diff --git a/tests/test_templating.py b/tests/test_templating.py index 9631a33..ac27f7f 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -904,6 +904,63 @@ def func(): "it should be in uv.toml instead" ) + def test_uv_venv_receives_config_file_flag(self, tmp_path): + """uv venv also receives --config-file so python-preference etc. take effect.""" + script_path = tmp_path / "script.py" + script_path.write_text("""# /// script +# requires-python = ">=3.11" +# dependencies = [] +# +# [tool.uv] +# exclude-newer = "2025-06-01T00:00:00Z" +# python-preference = "managed" +# /// + +import groundhog_hpc as hog + +@hog.function() +def func(): + return 1 +""") + + shell_command = template_shell_command(str(script_path), "func", "payload") + + # uv venv line should carry --config-file + venv_line = next( + (line for line in shell_command.splitlines() if '"$UV_BIN" venv' in line), + None, + ) + assert venv_line is not None, "No uv venv line found" + assert "--config-file" in venv_line + + def test_uv_toml_written_before_venv_creation(self, tmp_path): + """uv.toml must be written before uv venv so the flag can reference it.""" + script_path = tmp_path / "script.py" + script_path.write_text("""# /// script +# requires-python = ">=3.11" +# dependencies = [] +# +# [tool.uv] +# exclude-newer = "2025-06-01T00:00:00Z" +# /// + +import groundhog_hpc as hog + +@hog.function() +def func(): + return 1 +""") + + shell_command = template_shell_command(str(script_path), "func", "payload") + + toml_write_pos = shell_command.find("UV_CONFIG_EOF") + venv_pos = shell_command.find('"$UV_BIN" venv') + assert toml_write_pos != -1, "UV_CONFIG_EOF not found" + assert venv_pos != -1, '"$UV_BIN" venv not found' + assert toml_write_pos < venv_pos, ( + "uv.toml must be written before uv venv creates the directory" + ) + def test_no_uv_toml_written_for_script_without_pep723_metadata(self, tmp_path): """Scripts without PEP 723 metadata don't write a uv.toml.""" script_path = tmp_path / "script.py" From 112f474e6ac81ffadb39dd5eb4e7c6008770ef8b Mon Sep 17 00:00:00 2001 From: Owen Price Skelly <21372141+OwenPriceSkelly@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:00:25 -0800 Subject: [PATCH 3/4] mkdir before creating env --- src/groundhog_hpc/templates/shell_command.sh.jinja | 1 + 1 file changed, 1 insertion(+) diff --git a/src/groundhog_hpc/templates/shell_command.sh.jinja b/src/groundhog_hpc/templates/shell_command.sh.jinja index d89f3be..3a81241 100644 --- a/src/groundhog_hpc/templates/shell_command.sh.jinja +++ b/src/groundhog_hpc/templates/shell_command.sh.jinja @@ -84,6 +84,7 @@ else # Both `uv venv` and `uv pip install` will receive --config-file, then # the file is moved into $ENV_DIR as a human-readable audit trail. {% raw %}UV_TOML="${{ENV_DIR}}.uv.toml"{% endraw %} + mkdir -p "$(dirname "$UV_TOML")" cat > "$UV_TOML" << 'UV_CONFIG_EOF' {{ uv_config_toml | escape_braces }} From 1642e01aed4a8dd97c4254f6f43247afa16571fe Mon Sep 17 00:00:00 2001 From: Owen Price Skelly <21372141+OwenPriceSkelly@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:02:32 -0800 Subject: [PATCH 4/4] only-managed by default --- src/groundhog_hpc/configuration/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/groundhog_hpc/configuration/models.py b/src/groundhog_hpc/configuration/models.py index ba1f5e4..e12c5de 100644 --- a/src/groundhog_hpc/configuration/models.py +++ b/src/groundhog_hpc/configuration/models.py @@ -87,7 +87,9 @@ class UvMetadata(BaseModel, extra="allow", serialize_by_alias=True): exclude_newer: str | None = Field( default_factory=_default_exclude_newer, alias="exclude-newer" ) - python_preference: str | None = Field(default="managed", alias="python-preference") + python_preference: str | None = Field( + default="only-managed", alias="python-preference" + ) index_url: str | None = Field(default=None, alias="index-url") extra_index_url: list[str] | None = Field(default=None, alias="extra-index-url") python_downloads: str | None = Field(default=None, alias="python-downloads")