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("\nV/a *b*.json, ", section)
+
+
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------