Repository navigation
Batch jobs from a worktree run on the main checkout's environment #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
903d281
2fcb989
8a4ee13
4bdd4ad
d89ed25
a79d649
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,11 +6,22 @@ | |
| """ | ||
|
|
||
| import json | ||
| import shlex | ||
| import subprocess | ||
| from pathlib import Path | ||
|
|
||
| PLUGIN = "analysis-workflow" | ||
|
|
||
| #: `pixi` subcommands that change the workspace environment, or use it and so install it first if it is missing. | ||
| PIXI_ENV_COMMANDS = frozenset( | ||
| {"install", "add", "lock", "upgrade", "update", "reinstall", "remove", "run", "shell", "shell-hook"} | ||
| ) | ||
| #: The first of these after `pixi` is the command, whatever global options precede it. | ||
| PIXI_SUBCOMMANDS = PIXI_ENV_COMMANDS | { | ||
| "auth", "build", "clean", "completion", "config", "exec", "global", "import", "info", "init", "list", | ||
| "search", "self-update", "task", "tree", "upload", "workspace", | ||
| } # fmt: skip | ||
|
|
||
|
|
||
| def checkouts(directory: Path) -> tuple[Path, Path] | None: | ||
| """The checkout containing ``directory`` and the repository's main checkout; None outside git.""" | ||
|
|
@@ -37,3 +48,74 @@ def adopted(checkout: Path) -> bool: | |
| return any(key.split("@")[0] == PLUGIN and on is True for key, on in enabled.items()) | ||
| except (OSError, ValueError, AttributeError): | ||
| return False | ||
|
|
||
|
|
||
| def nearest_manifest(directory: Path) -> Path: | ||
| """The `pixi.toml` pixi itself finds from ``directory``: the nearest at or above it.""" | ||
| return next( | ||
| (d / "pixi.toml" for d in (directory, *directory.parents) if (d / "pixi.toml").exists()), | ||
| directory / "pixi.toml", | ||
| ) | ||
|
|
||
|
|
||
| def is_root_of_worktree(manifest: Path) -> bool: | ||
| """Whether ``manifest`` is the root workspace of a worktree of an adopted repo. | ||
|
|
||
| Its editable self-dependency (`path = "."`) would rebase the repo's package, and every path it | ||
| resolves, into the worktree. A task-local manifest is its own workspace. | ||
| """ | ||
| if ".claude/worktrees" not in str(manifest): | ||
| return False | ||
| if manifest.exists() and 'path = "."' not in manifest.read_text(errors="ignore"): | ||
| return False | ||
| found = checkouts(manifest.parent) | ||
| return found is not None and adopted(found[0]) | ||
|
|
||
|
|
||
| def _is_separator(tok: str) -> bool: | ||
| """A token made only of command separators (`;`, `&&`, `|`, newlines, parentheses).""" | ||
| return bool(tok) and set(tok) <= set(";&|()\n") | ||
|
|
||
|
|
||
| def pixi_calls(command: str, comments: bool = False) -> list[list[str]] | None: | ||
| """Argument lists of the `pixi` calls in ``command`` that change or use an environment; None if unparsable. | ||
|
|
||
| With ``comments``, an unquoted `#` starts a comment to the end of its line, as in a script. Quotes are | ||
| honoured either way, so quoted text (a PR body, an echoed message) is never read as a call. | ||
| """ | ||
| tokens = [] | ||
| for text in command.splitlines(keepends=True) if comments else [command]: | ||
| lexer = shlex.shlex(text, posix=True, punctuation_chars="();<>|&\n") | ||
| lexer.whitespace = " \t\r" # newlines separate commands, so keep them as tokens | ||
| lexer.commenters = "#" if comments else "" # one line at a time: a comment swallows its newline | ||
| lexer.whitespace_split = True | ||
| try: | ||
| tokens += [*lexer, "\n"] | ||
| except ValueError: | ||
| return None | ||
| calls = [] | ||
| for i, tok in enumerate(tokens): | ||
| if Path(tok).name != "pixi": | ||
| continue | ||
| args = [] | ||
| for t in tokens[i + 1 :]: | ||
| if _is_separator(t): | ||
| break | ||
| args.append(t) | ||
| if next((a for a in args if a in PIXI_SUBCOMMANDS), None) in PIXI_ENV_COMMANDS: | ||
| calls.append(args) | ||
| return calls | ||
|
|
||
|
|
||
| def manifest_arg(args: list[str]) -> str | None: | ||
| """The `--manifest-path` a pixi call passes, if any. In `pixi run`, arguments after the task are the task's.""" | ||
| command = next((a for a in args if a in PIXI_SUBCOMMANDS), None) | ||
| rest = args[args.index(command) + 1 :] if command else args | ||
| for i, a in enumerate(rest): | ||
| if a in ("--manifest-path", "-m"): | ||
| return rest[i + 1] if i + 1 < len(rest) else None | ||
| if a.startswith("--manifest-path="): | ||
| return a.split("=", 1)[1] | ||
| if command == "run" and not a.startswith("-"): | ||
| return None | ||
|
Comment on lines
+119
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence in this revision is that the new “first non-option is the task” rule also mistakes values belonging to Pixi options for the task. For example, Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving this one: it errs towards blocking, as the Bash guard does, and the fix is to put the flag first, |
||
| return None | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,9 @@ | |
| usually gitignored, so git never warns. Absolute paths into a worktree, a temp dir or the checkout | ||
| itself outlive what they point at, or break on the next machine. | ||
|
|
||
| A job script written in a worktree must not run `pixi` on the workspace it finds itself: the job | ||
| would build the root environment into the worktree, out of reach of the Bash pixi guard. | ||
|
|
||
| It acts only in a repo that has adopted the plugin. Only the text being written is inspected, so | ||
| pre-existing violations elsewhere in a file do not trip the hook; they surface when that line is next | ||
| touched, which is when to fix them. | ||
|
|
@@ -19,10 +22,23 @@ | |
| import sys | ||
| from pathlib import Path | ||
|
|
||
| from _repo import adopted, checkouts | ||
| from _repo import PIXI_ENV_COMMANDS, adopted, checkouts, is_root_of_worktree, manifest_arg, nearest_manifest, pixi_calls | ||
|
|
||
| # Code that runs. Prose may legitimately quote any of these paths. | ||
| CODE_SUFFIXES = {".py", ".sh", ".sbatch", ".ipynb", ".R"} | ||
| JOB_SCRIPT_SUFFIXES = {".sh", ".sbatch"} | ||
|
|
||
| #: The one workspace a job may name: the main checkout's, as `$MAIN` passed in at submit. Any other path, relative | ||
| #: or derived in the job (`git rev-parse --show-toplevel`), can resolve to the worktree itself. | ||
| MAIN_MANIFEST = re.compile(r"\$\{?MAIN\}?/") | ||
|
|
||
|
|
||
| def _pixi_on_found_workspace(script: str) -> bool: | ||
| """Whether any command in ``script`` runs `pixi` on a workspace other than the main checkout's.""" | ||
| calls = pixi_calls(script.replace("\\\n", " "), comments=True) | ||
| if calls is None: # unparsable: err towards blocking | ||
| return bool(re.search(rf"\bpixi\b.*\b({'|'.join(PIXI_ENV_COMMANDS)})\b", script)) | ||
| return any(not MAIN_MANIFEST.match(manifest_arg(args) or "") for args in calls) | ||
|
|
||
|
|
||
| def _rules(repo: str) -> list[tuple[re.Pattern, str, str]]: | ||
|
|
@@ -65,6 +81,18 @@ def main() -> int: | |
| # Whatever this call would add: Write.content, Edit.new_string, NotebookEdit.new_source | ||
| written = "\n".join(str(tool_input.get(key, "")) for key in ("content", "new_string", "new_source")) | ||
| hits = [(what, fix) for pattern, what, fix in _rules(found[1].name) if pattern.search(written)] | ||
| if ( | ||
| file_path.suffix in JOB_SCRIPT_SUFFIXES | ||
| and _pixi_on_found_workspace(written) | ||
| and is_root_of_worktree(nearest_manifest(file_path.parent)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a task directory contains a task-local Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving this one: the skill and the task template submit batch jobs from the task directory, where a task-local manifest is the one pixi finds. Requiring every task-local job to name its manifest through a variable would cost more than a submission from elsewhere, which the conventions already rule out. |
||
| ): | ||
| hits.append( | ||
| ( | ||
| "a `pixi` call on the worktree's own workspace", | ||
| 'jobs run on the main checkout\'s environment: pixi run --manifest-path "$MAIN/pixi.toml", ' | ||
| "with MAIN passed in at submit", | ||
| ) | ||
| ) | ||
| if not hits: | ||
| return 0 | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fresh evidence in the current comment-stripping fix is that
shlex.commenterstreats every unquoted#as the start of a comment, whereas Bash starts a comment only when#begins a word. Consequently, a valid script such asecho job#1; pixi run python a.pyis truncated before the Pixi invocation and passes this guard, even though the unsafe invocation executes. Apply Bash's word-boundary rule when removing comments so the remainder of the command is still inspected. The Bash manual documents the comment boundary semantics.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leaving this one: it needs an unquoted
#inside a word followed by apixicall on the same line, which job scripts rarely have. The guard is against accidents, and a Bash-exact comment scanner costs more than the case is worth.