diff --git a/.github/workflows/check_profiles.yml b/.github/workflows/check_profiles.yml index 4167cc705b..0b29732fc8 100644 --- a/.github/workflows/check_profiles.yml +++ b/.github/workflows/check_profiles.yml @@ -232,6 +232,31 @@ jobs: mkdir -p ${{ runner.temp }}/profile-check-results echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt + # Builds a readable diff of the custom G-code the PR changes, which GitHub shows as + # one long line per setting, for check_profiles_comment.yml to post. + - name: Diff custom G-code + if: ${{ always() && github.event_name == 'pull_request' }} + continue-on-error: true + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)" + results=${{ runner.temp }}/profile-check-results + diff=${{ runner.temp }}/gcode_diff.md + # HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto. + python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff" + if [ -s "$diff" ]; then + { + echo "" + cat "$diff" + echo "" + # The comment workflow finds this line as the only one starting with . + echo "For commit ${PR_HEAD_SHA} of this PR." + } > "$results/gcode_diff.md" + fi + # Tells the comment workflow that a missing gcode_diff.md means no G-code changed. + touch "$results/gcode_diff_done.txt" + - name: Prepare comment artifact if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }} run: | diff --git a/.github/workflows/check_profiles_comment.yml b/.github/workflows/check_profiles_comment.yml index 21098cc467..bd42b8622d 100644 --- a/.github/workflows/check_profiles_comment.yml +++ b/.github/workflows/check_profiles_comment.yml @@ -38,6 +38,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} run: | if [ ! -f pr_number.txt ]; then echo "No pr_number.txt in artifact, skipping." @@ -49,18 +50,72 @@ jobs: echo "Invalid PR number: $PR_NUMBER" exit 1 fi + # PR code wrote the artifact, so only a run for the named PR's current head may post. + if [ -z "$HEAD_SHA" ]; then + echo "The triggering run names no head commit." + exit 1 + fi + if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then + echo "Cannot look up PR #${PR_NUMBER}." + exit 1 + fi + if [ "$HEAD_SHA" != "$PR_HEAD" ]; then + echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping." + exit 0 + fi + + # Lists every bot comment on the PR, one JSON object per line. + COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json') # Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading. - OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ - --jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id') + OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id') for comment_id in $OLD_IDS; do echo "Deleting outdated profile-validation comment ${comment_id}" gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true done # Post a new comment only when validation failed (pr_comment.md present). + # A failed post fails the job only after the G-code diff is handled. + failed=0 if [ -f pr_comment.md ]; then - gh pr comment "$PR_NUMBER" --body-file pr_comment.md + gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1 else echo "Validation succeeded; cleaned up prior comments without posting." fi + + # Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none. + update_gcode_diff_comment() { + if [ ! -f gcode_diff_done.txt ]; then + echo "The G-code diff step did not finish; leaving any G-code diff comment as it is." + return 0 + fi + GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("")) | .id') + if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then + echo "gcode_diff.md is over GitHub's comment limit; not posting it." + rm gcode_diff.md + fi + if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then + OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r') + NEW_BODY=$(tr -d '\r' < gcode_diff.md) + if [ "$OLD_BODY" = "$NEW_BODY" ]; then + echo "G-code diff unchanged; keeping comment ${GCODE_IDS}." + return 0 + fi + # Edits in place when only the line differs, since edits notify nobody. + if [ "$(grep -v '^' <<< "$OLD_BODY")" = "$(grep -v '^' <<< "$NEW_BODY")" ]; then + echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}." + gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null + return 0 + fi + fi + for comment_id in $GCODE_IDS; do + echo "Deleting outdated G-code diff comment ${comment_id}" + gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true + done + if [ -f gcode_diff.md ]; then + gh pr comment "$PR_NUMBER" --body-file gcode_diff.md + fi + } + update_gcode_diff_comment + exit "$failed" diff --git a/scripts/orca_profile_tool.py b/scripts/orca_profile_tool.py index f1a428cdfe..2519ce9e43 100755 --- a/scripts/orca_profile_tool.py +++ b/scripts/orca_profile_tool.py @@ -12,6 +12,7 @@ commands: trim delete profile files no .json list references update-index regenerate the *_list sections of .json legacy-compat write the printers user presets can be migrated back to + gcode-diff print a readable markdown diff of changed custom G-code options shared by several commands: --vendor VENDOR act on one vendor bundle only; repeatable, empty means all @@ -94,11 +95,14 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way. """ import argparse +import difflib import functools +import html import json import os import posixpath import re +import subprocess import sys import uuid from collections import Counter, defaultdict @@ -192,6 +196,7 @@ VECTOR_KEYS = { PRINT_CONFIG_CPP = os.path.normpath( os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp")) +TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp")) @functools.lru_cache(maxsize=None) @@ -313,11 +318,11 @@ REMEDY_HINTS = { } -def print_error(msg): - print(f"\033[91m[ERROR]\033[0m {msg}") # Red +def print_error(msg, file=None): + print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red -def print_warning(msg): - print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow +def print_warning(msg, file=None): + print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow def print_info(msg): print(f"\033[94m[INFO]\033[0m {msg}") # Blue @@ -3209,6 +3214,223 @@ def run_legacy_compat(base_dir, profiles_dir, output, check): return 0 +# --------------------------------------------------------------------------- +# gcode-diff +# --------------------------------------------------------------------------- + +# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit. +GCODE_DIFF_BUDGET = 60000 +# The file names a section's summary shows before "and N more". +GCODE_DIFF_SUMMARY_FILES = 3 + +GCODE_DIFF_HEADER = ( + "## Custom G-code changes\n" + "\n" + "GitHub shows custom G-code in a profile as one long line, so below is a line by line " + "diff of each changed G-code or other text setting.\n") + + +@functools.lru_cache(maxsize=None) +def _text_option_keys(): + """The string options PrintConfig.cpp defines as G-code or other multi-line text + (every *_gcode option, and every one it marks multiline, such as notes), plus the + ones Tab.cpp edits as code, such as filename_format.""" + with open(PRINT_CONFIG_CPP, encoding="utf-8") as f: + source = f.read() + keys = set() + for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]: + match = re.match(r'"(\w+)",\s*coStrings?\b', body) + if match and (match.group(1).endswith("_gcode") + or re.search(r"def->multiline\s*=\s*true", body)): + keys.add(match.group(1)) + if "machine_start_gcode" not in keys: + raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode") + # Tab.cpp only adds keys, so a missing file is not an error. + try: + with open(TAB_CPP, encoding="utf-8") as f: + gui = f.read() + except OSError: + gui = "" + keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true', + gui, re.DOTALL)) + return frozenset(keys) + + +def _git(cwd, *args, stdin=None): + return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True, + capture_output=True).stdout + + +def _blobs_at(profiles_dir, specs): + """{(ref, path): bytes, or None if not a file there} for (ref, path relative to + profiles_dir) pairs, all read by one git cat-file process.""" + prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip() + out = _git(profiles_dir, "cat-file", "--batch", + stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8")) + blobs, pos = {}, 0 + for spec in specs: + end = out.index(b"\n", pos) + header = out[pos:end] + pos = end + 1 + # " missing" and " ambiguous" have no content; " " does. + if header.endswith((b" missing", b" ambiguous")): + blobs[spec] = None + continue + _oid, kind, size = header.split() + blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None + pos += int(size) + 1 + return blobs + + +def _parse_profile(raw, rel, where): + """The JSON in raw, or None with a warning when it is absent or not JSON.""" + if raw is None: + print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr) + return None + try: + return json.loads(raw.decode("utf-8-sig")) + except ValueError as e: + print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr) + return None + + +def _changed_profiles(profiles_dir, base, head): + """(old path, new path) of each JSON file under profiles_dir that differs between + base and head (the working tree, untracked files included, when None); the absent + side of an added or deleted file is None, and so is a side that is not JSON. Renames + are paired.""" + out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base, + *([head] if head else []), "--", ".") + fields = out.decode("utf-8").split("\0") + pairs, i = [], 0 + while i + 1 < len(fields): + status = fields[i] + old = new = fields[i + 1] + if status[0] in "RC": + new = fields[i + 2] + i += 3 + else: + i += 2 + old = None if status[0] == "A" or not old.endswith(".json") else old + new = None if status[0] == "D" or not new.endswith(".json") else new + if old or new: + pairs.append((old, new)) + if head is None: + out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".") + pairs += [(None, rel) for rel in out.decode("utf-8").split("\0") + if rel.endswith(".json") and rel.split("/")[0] != USER_DIR] + return pairs + + +def _text_lines(value): + return [line.rstrip() for line in value.split("\n")] if value else [] + + +def _text_changes(old, new): + """(setting, unified diff) for every G-code or other text value that differs + between two profiles; a vector option with several values gives one per value.""" + changes = [] + for key in list(new) + [k for k in old if k not in new]: + a, b = _as_list(old.get(key, [])), _as_list(new.get(key, [])) + if a == b or not all(isinstance(v, str) for v in a + b): + continue + if key not in _text_option_keys() and not any("\n" in v for v in a + b): + continue + width = max(len(a), len(b)) + for i in range(width): + diff = list(difflib.unified_diff( + _text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""), + lineterm=""))[2:] # drops the ---/+++ file header + if diff: + changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff))) + return changes + + +def _gcode_diff_section(files, setting, diff): + # A fence longer than any backtick run in the G-code cannot be closed by it. + fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)]) + # In , the "@BBL" of a filament file name does not mention a GitHub user. + def code(text): + return f"{html.escape(text, quote=False)}" + names, listing = ", ".join(map(code, files)), "" + if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more" + listing = f"

{names}

\n\n" # an HTML block, so markdown in names stays literal + names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES])) + + f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more") + return (f"\n
\n{names} - {code(setting)} (click to expand)" + f"\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n
\n") + + +def gcode_diff(profiles_dir, base, head=None, vendors=None): + """Markdown with one collapsed diff per changed G-code setting between base and + head, or "" when none changed. Files whose diff of a setting is identical share + one section. Cut to whole sections under GCODE_DIFF_BUDGET.""" + pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head) + if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))] + specs = ([(base, old) for old, _ in pairs if old] + + [(head, new) for _, new in pairs if new and head]) + blobs = _blobs_at(profiles_dir, specs) if specs else {} + + def load(ref, rel): + if rel is None: + return {} + if ref: + return _parse_profile(blobs[(ref, rel)], rel, ref) + try: + return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree") + except OSError as e: + print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr) + return None + + groups = {} # (setting, diff) -> files, in the order git lists them + for old_rel, new_rel in pairs: + old, new = load(base, old_rel), load(head, new_rel) + if not isinstance(old, dict) or not isinstance(new, dict): + continue + for setting, diff in _text_changes(old, new): + groups.setdefault((setting, diff), []).append(new_rel or old_rel) + if not groups: + return "" + + sections = [_gcode_diff_section(files, setting, diff) + for (setting, diff), files in groups.items()] + budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8")) + shown = 0 + for section in sections: + budget -= len(section.encode("utf-8")) + if budget < 0: + break + shown += 1 + note, hidden = "", len(sections) - shown + if hidden: + note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not " + "shown because the comment would be too long for GitHub. See the Files " + "changed tab for the rest.\n") + return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note + + +def _default_gcode_diff_base(profiles_dir, head): + for main in ("upstream/main", "origin/main"): + try: + return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip() + except subprocess.CalledProcessError: + continue + raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists") + + +def run_gcode_diff(profiles_dir, vendors, base, head): + try: + markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head), + head, vendors) + # UnicodeDecodeError means git listed a file name that is not UTF-8. + except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e: + stderr = getattr(e, "stderr", None) + print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr) + return 1 + sys.stdout.write(markdown) + return 0 + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -3233,6 +3455,9 @@ examples: orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles rewrite resources/info/legacy_compatible_printers.json against 2.4.2's profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2) + orca_profile_tool.py gcode-diff > gcode.md + the changed custom G-code of the working tree since it left upstream/main, + as markdown with one collapsed diff per setting after adding, renaming or deleting profile files, run in this order: normalize -> update-index -> generate-id -> check @@ -3387,6 +3612,25 @@ def build_parser(): legacy_cmd.add_argument("--check", action="store_true", help="fail if the map differs from what --base implies; write nothing") + gcode_cmd = add( + "gcode-diff", [vendor_opt, profiles_opt], + "print a readable markdown diff of changed custom G-code", + "Print, as markdown, a unified diff of every G-code or other multi-line\n" + "setting that differs between --base and --head in each changed profile\n" + "file, one collapsed section per setting. Files whose diff of a setting is\n" + "identical share a section. Trailing spaces are removed from every line, and\n" + "the output is cut to whole sections to fit a GitHub comment. Prints nothing\n" + "when no such setting changed. CI posts it on profile PRs.\n" + "\n" + "The settings are the *_gcode and multiline string options PrintConfig.cpp\n" + "defines, the options Tab.cpp edits as code (such as filename_format), and any\n" + "string value that spans several lines.") + gcode_cmd.add_argument("--base", metavar="REF", + help="compare against this git ref (default: the merge base " + "with upstream/main, else origin/main)") + gcode_cmd.add_argument("--head", metavar="REF", + help="compare this git ref (default: the working tree)") + return parser @@ -3423,6 +3667,9 @@ def main(argv=None): if args.command == "legacy-compat": return run_legacy_compat(args.base, profiles_dir, args.output, args.check) + if args.command == "gcode-diff": + return run_gcode_diff(profiles_dir, vendors, args.base, args.head) + if args.command == "fix-variant": _changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run, strict=args.strict) diff --git a/scripts/tests/test_profile_tool.py b/scripts/tests/test_profile_tool.py index 167951d609..ed329b478c 100644 --- a/scripts/tests/test_profile_tool.py +++ b/scripts/tests/test_profile_tool.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the -normalize, trim, update-index, fix-variant and check commands, and the subcommand -dispatch that reaches them (stdlib unittest, no external deps). +normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the +subcommand dispatch that reaches them (stdlib unittest, no external deps). The id halves are covered by test_filament_id.py and test_setting_id.py. @@ -14,9 +14,12 @@ import json import os import re import shutil +import stat +import subprocess import sys import tempfile import unittest +from unittest import mock sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -41,7 +44,24 @@ class Tree: os.makedirs(self.profiles) def cleanup(self): - shutil.rmtree(self.dir, ignore_errors=True) + root = os.path.abspath(self.dir) + + def writable_retry(func, path, _exc): + # Windows refuses to delete read-only files such as git's objects, and POSIX + # refuses to delete the entries of a read-only directory. + if not os.path.lexists(path): # already removed, as on a second cleanup + return + parent = os.path.dirname(os.path.abspath(path)) + try: + if os.path.commonpath([parent, root]) == root: # never the temp dir itself + os.chmod(parent, stat.S_IRWXU) + if not os.path.islink(path): # chmod would follow it out of the tree + os.chmod(path, stat.S_IRWXU) + func(path) + except (OSError, ValueError): # commonpath raises ValueError across drives + pass + hook = "onexc" if sys.version_info >= (3, 12) else "onerror" + shutil.rmtree(self.dir, **{hook: writable_retry}) def index_path(self, vendor): return os.path.join(self.profiles, vendor + ".json") @@ -1409,6 +1429,292 @@ class TestDispatch(TreeCase): self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament") +# --------------------------------------------------------------------------- +# gcode-diff +# --------------------------------------------------------------------------- + +@unittest.skipUnless(shutil.which("git"), "git not installed") +class TestGcodeDiff(TreeCase): + START = "G28\nM104 S200\nG1 Z5" + + def git(self, *args): + subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t", + "-c", "commit.gpgsign=false", *args], check=True, capture_output=True) + + def printer(self, name, **extra): + self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra}) + + def commit(self): + self.git("add", "-A") + self.git("commit", "-q", "--allow-empty", "-m", "x") + + @classmethod + def setUpClass(cls): + # git's list of the variables that tie a process to a repository, asked with no + # GIT_* set since a stray GIT_DIR fails the query. + clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + cls.repo_env_vars = set(subprocess.run( + ["git", "rev-parse", "--local-env-vars"], env=clean, check=True, + capture_output=True, text=True).stdout.split()) + + def setUp(self): + super().setUp() + # A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the + # tool's included, at the real repository instead of the temp tree. + env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars} + patcher = mock.patch.dict(os.environ, env, clear=True) + patcher.start() + self.addCleanup(patcher.stop) + self.git("init", "-q") + for name in ("A", "B", "C"): + self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"]) + self.printer("Gone", machine_end_gcode="M84") + self.commit() + + def diff(self): + rc, out = self.run_command("gcode-diff", "--base", "HEAD") + self.assertEqual(rc, 0, out) + return out + + def test_identical_edits_share_one_section(self): + for name in ("A", "B"): + self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5") + self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5") + out = self.diff() + self.assertEqual(out.count("
"), 2) + self.assertIn("V/machine/A.json, V/machine/B.json - " + "machine_start_gcode", out) + self.assertIn("V/machine/C.json - machine_start_gcode", out) + # Trailing spaces are removed. + self.assertIn("\n-M104 S200\n+M104 S210\n", out) + self.assertIn("\n\n```diff\n@@ ", out) + self.assertIn("\n```\n\n
", out) + + def test_nothing_is_printed_without_a_text_change(self): + self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"]) + self.assertEqual(self.diff(), "") + + def test_added_and_deleted_files_show_the_whole_value(self): + # An untracked file counts as added; the validator's user dir is not a profile. + self.printer("New", machine_start_gcode="G28\nG29") + self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"}) + os.remove(self.t.path("V", "machine/Gone.json")) + out = self.diff() + self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out) + self.assertIn("V/machine/Gone.json - machine_end_gcode", out) + self.assertIn("@@ -1 +0,0 @@\n-M84\n", out) + self.assertNotIn("U.json", out) + + def test_a_multi_line_value_counts_whatever_its_key(self): + self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo") + self.assertIn("V/machine/A.json - custom_note", self.diff()) + + def test_a_file_name_cannot_mention_a_user(self): + self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]}) + self.assertIn("V/filament/PLA @BBL A1.json", self.diff()) + + def test_head_compares_two_commits(self): + self.printer("A", machine_start_gcode="G28") + self.commit() + rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD") + self.assertEqual(rc, 0, out) + self.assertIn("V/machine/A.json - machine_start_gcode", out) + + def test_a_renamed_file_diffs_against_its_old_self(self): + os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json")) + self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"]) + self.commit() + rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD") + self.assertEqual(rc, 0, out) + self.assertIn("V/machine/A2.json - machine_start_gcode", out) + self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out) + + def test_a_file_renamed_off_json_loses_its_values(self): + os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak")) + self.commit() + rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD") + self.assertEqual(rc, 0, out) + self.assertIn("V/machine/Gone.json - machine_end_gcode", out) + self.assertIn("@@ -1 +0,0 @@\n-M84\n", out) + + def test_a_file_renamed_onto_json_is_new(self): + with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f: + json.dump({"type": "machine", "machine_start_gcode": self.START}, f) + self.commit() + os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json")) + self.commit() + rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD") + self.assertEqual(rc, 0, out) + self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out) + + def test_a_batch_read_survives_paths_that_are_not_files(self): + blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"), + ("HEAD", "V/machine/Gone.json")]) + self.assertIsNone(blobs[("HEAD", "V/machine")]) + self.assertIsNone(blobs[("HEAD", "V/no such.json")]) + self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"], + "M84") + + def test_each_value_of_a_vector_option_is_its_own_setting(self): + self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]}) + self.commit() + self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]}) + out = self.diff() + self.assertEqual(out.count("
"), 1) + self.assertIn("filament_start_gcode[1]", out) + + def test_vendor_limits_the_files(self): + self.printer("A", machine_start_gcode="G28") + self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"}) + rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V") + self.assertEqual(rc, 0, out) + self.assertIn("V/machine/A.json", out) + self.assertNotIn("W/machine/X.json", out) + + def test_vendor_includes_files_moved_out_of_it(self): + self.t.add_vendor("W") + os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json")) + data = self.t.read("W", "machine/A.json") + self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"}) + self.commit() + rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD", + "--vendor", "V") + self.assertEqual(rc, 0, out) + self.assertIn("W/machine/A.json - machine_start_gcode", out) + + def same_edit(self, names): + """The diff after one start G-code edit to each of names; returns (summary, output).""" + for name in names: + self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"]) + self.commit() + for name in names: + self.printer(name, machine_start_gcode="G28") + out = self.diff() + return re.search(r"(.*)", out).group(1), out + + def test_a_long_file_list_moves_into_the_section(self): + summary, out = self.same_edit("ABCDE") + self.assertIn("V/machine/C.json and 2 more - ", summary) + self.assertNotIn("D.json", summary) + self.assertIn("V/machine/D.json, V/machine/E.json

\n\n```diff", + out) + + def test_four_files_are_all_named_in_the_summary(self): + summary, _out = self.same_edit("ABCD") + self.assertIn("V/machine/D.json - ", summary) + self.assertNotIn("more", summary) + + def test_an_error_goes_to_stderr_only(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc, out = self.run_command("gcode-diff", "--base", "no-such-ref") + self.assertEqual(rc, 1) + self.assertEqual(out, "") + self.assertIn("no-such-ref", err.getvalue()) + + def test_a_file_name_that_is_not_utf8_is_an_error(self): + bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte") + err = io.StringIO() + with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \ + contextlib.redirect_stderr(err): + rc, out = self.run_command("gcode-diff", "--base", "HEAD") + self.assertEqual((rc, out), (1, "")) + self.assertIn("invalid continuation byte", err.getvalue()) + + def test_output_is_cut_to_whole_sections(self): + for name in ("A", "B", "C"): + self.printer(name, machine_start_gcode=name * 300) + size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8")) + with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1): + out = self.diff() + self.assertEqual(out.count("
"), 2) + self.assertEqual(out.count("
"), 2) + self.assertIn("\n1 of 3 sections is not shown", out) + with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1): + self.assertIn("\n3 of 3 sections are not shown", self.diff()) + + def test_cleanup_removes_the_repo(self): + # setUp's commit leaves read-only git objects, and a read-only directory blocks + # deletion on POSIX. + os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR) + self.t.cleanup() + self.assertFalse(os.path.exists(self.t.dir)) + with mock.patch.object(os, "chmod") as chmod: + self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir + chmod.assert_not_called() + + def test_only_repository_variables_are_stripped(self): + self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars) + self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars) + + def test_cleanup_leaves_symlink_targets_alone(self): + outside = tempfile.mkdtemp(prefix="profile_tool_outside_") + self.addCleanup(shutil.rmtree, outside, True) + target = os.path.join(outside, "f") + open(target, "w").close() + os.chmod(target, stat.S_IRUSR) + self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above + machine = self.t.path("V", "machine") + try: + os.symlink(target, os.path.join(machine, "link")) + except (OSError, NotImplementedError): + self.skipTest("cannot create symlinks here") + os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first + self.t.cleanup() + self.assertFalse(os.path.exists(self.t.dir)) + self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0) + + def test_a_git_hook_environment_is_ignored(self): + scripts = os.path.join(REPO_ROOT, "scripts") + result = subprocess.run( + [sys.executable, "-m", "unittest", + "tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"], + cwd=scripts, capture_output=True, text=True, + env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")}) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_the_options_are_read_from_the_engine(self): + keys = apt._text_option_keys() + self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode", + "notes", "post_process"} <= keys) + self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool + + START_DEF = 'def = this->add("machine_start_gcode", coString);\n' + + def keys_from(self, print_config, tab=None): + """_text_option_keys read from these sources; no tab means no Tab.cpp.""" + paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")] + for path, text in zip(paths, (print_config, tab)): + if text is not None: + with open(path, "w", encoding="utf-8") as f: + f.write(text) + apt._text_option_keys.cache_clear() + self.addCleanup(apt._text_option_keys.cache_clear) + with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \ + mock.patch.object(apt, "TAB_CPP", paths[1]): + return apt._text_option_keys() + + def test_multiline_is_read_whatever_its_spacing(self): + keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n' + ' def->multiline = true;\n') + self.assertIn("printer_notes", keys) + + def test_the_code_options_of_tab_cpp_are_read(self): + keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n' + 'option.opt.is_code = true;\n' + 'option = optgroup->get_option("layer_height");\n') + self.assertIn("filename_format", keys) + self.assertNotIn("layer_height", keys) + + def test_a_missing_tab_cpp_is_not_an_error(self): + self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF)) + + def test_markdown_in_a_file_name_stays_literal(self): + section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json", + "V/f.json"], "machine_start_gcode", "@@ -1 +1 @@") + self.assertIn("\n

V/a *b*.json, ", section) + + # --------------------------------------------------------------------------- # the real tree # ---------------------------------------------------------------------------