From 2bacb4fd4b370fc92c6755ec39e44dd55ced5bc8 Mon Sep 17 00:00:00 2001 From: Maksym Bilan Date: Thu, 23 Jul 2026 15:39:21 +0200 Subject: [PATCH 1/2] Update every Package.resolved Xcode reads, not just the .xcodeproj The action only wrote the freshly-resolved pins to the lockfile embedded in the .xcodeproj (project.xcworkspace/.../Package.resolved). Projects built via a .xcworkspace resolve against the *workspace* lockfile instead, which the action never touched -- so dependency bumps landed in the project file but never reached `xcodebuild -workspace` builds, and the two files silently drifted (the project lockfile looked current while real builds compiled stale pins). Now the action discovers every lockfile Xcode reads -- the .xcodeproj-embedded one plus each sibling .xcworkspace's copy -- and patches each in place, values-only: - Preserves each file's schema version (v2/v3), originHash, and byte-exact SwiftPM formatting, so diffs are minimal with no formatting churn. - Only updates pins present in the fresh resolution; never removes pins that belong solely to a workspace's local packages (e.g. a transitive dep of a local SPM package the synthetic manifest never sees). - Won't author a workspace lockfile from scratch (it would be incomplete); only keeps existing ones current. - changed-count now spans all lockfiles; dependencies-changed is true if any lockfile would change. Adds test/test_merge.py (pure-Python, no Swift toolchain) covering discovery and the in-place merge, wired into CI as a fast Linux `unit` job. Updates README and CHANGELOG; the old "project-internal Package.resolved only" caveat is now obsolete. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 9 ++ .gitignore | 3 + CHANGELOG.md | 18 ++++ README.md | 37 +++++--- examples/update-spm.yml | 2 +- test/test_merge.py | 135 ++++++++++++++++++++++++++++ update_spm.py | 176 +++++++++++++++++++++++++++++-------- 7 files changed, 330 insertions(+), 50 deletions(-) create mode 100644 .gitignore create mode 100644 test/test_merge.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 09f0bae..dfa5e3b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,15 @@ on: workflow_dispatch: {} jobs: + unit: + # Pure-Python logic tests (lockfile discovery + in-place merge). No Swift + # toolchain needed, so run them fast on Linux. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run merge unit tests + run: python3 test/test_merge.py + selftest: # Must be macOS — the action calls `xcrun swift package update`. runs-on: macos-latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b908d4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 42866bb..9861189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Update **every** `Package.resolved` Xcode reads for the project, not just the + one embedded in the `.xcodeproj`. Previously, projects built through a + `.xcworkspace` (via `xcodebuild -workspace`) resolved against the workspace's + own lockfile, which the Action never touched — so dependency bumps landed in + the project lockfile but never reached real builds, and the two files drifted. + The Action now discovers the project lockfile and each sibling + `.xcworkspace/xcshareddata/swiftpm/Package.resolved`. + +### Changed +- Lockfiles are now patched **in place** (values only) instead of being + overwritten wholesale: each file keeps its own schema version (`v2`/`v3`), + `originHash`, formatting, and any pins that belong only to that container + (e.g. dependencies of a workspace's local packages). This yields minimal, + values-only diffs with no formatting churn. +- `changed-count` now reports the number of distinct pins changed across all + lockfiles; `dependencies-changed` is `true` if any lockfile would change. + ## [1.0.0] - 2026-06-05 Initial public release as a Marketplace composite action. diff --git a/README.md b/README.md index 49d3a34..307cd4c 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,16 @@ Rather than fight `xcodebuild`, the Action sidesteps it: ▼ synthetic Package.swift ──► xcrun swift package update ──► fresh Package.resolved │ - │ copy back into + │ merge resolved pins into + │ every lockfile Xcode reads ▼ - .xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + .xcodeproj/project.xcworkspace/…/Package.resolved (the project) + .xcworkspace/…/Package.resolved (each workspace) ``` -In other words: read the package URLs and version requirements straight out of `project.pbxproj`, hand them to the real Swift Package Manager solver (which still works fine), and drop the resulting lockfile back into the place Xcode reads it from. +In other words: read the package URLs and version requirements straight out of `project.pbxproj`, hand them to the real Swift Package Manager solver (which still works fine), and merge the resulting pins back into the place(s) Xcode reads them from. + +Xcode keeps a **separate** `Package.resolved` per container — one inside the `.xcodeproj`, and one inside each `.xcworkspace`. `xcodebuild -workspace` (what most CI/release builds run) reads the **workspace** copy, so updating only the project copy silently misses real builds. The Action therefore updates every lockfile it finds, patching each **in place** (values only) so it keeps its own schema version and formatting, and never touches pins that belong only to a workspace's local packages. Implementation is a single pure-Python script using only the standard library — no `pip install`, no Node dependencies — and it must run on a macOS runner because it shells out to the Swift toolchain. @@ -93,17 +97,18 @@ A complete working example lives at [`examples/update-spm.yml`](examples/update- | Name | Description | |------|-------------| -| `dependencies-changed` | `'true'` if at least one pin would change, otherwise `'false'`. | -| `changed-count` | Number of pins that changed, as an integer string. | +| `dependencies-changed` | `'true'` if at least one pin would change in any lockfile, otherwise `'false'`. | +| `changed-count` | Number of distinct package pins that changed across all lockfiles, as an integer string. | ## Caveats -- **Project-internal `Package.resolved` only.** This Action writes to - `/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` - — the path Xcode reads when SPM is managed inside the `.xcodeproj` itself. - Projects that drive SPM from a standalone `.xcworkspace` (where the resolved - file lives under the workspace, not the project) will need their workflow - adjusted, since this Action does not write to that location. +- **Existing workspace lockfiles only.** The Action updates the project's own + lockfile and every sibling `.xcworkspace/…/Package.resolved` that + already exists. It will **not** create a workspace lockfile from scratch: a + workspace's resolved graph includes dependencies pulled in by its local + packages, which the synthetic manifest never sees, so a freshly-authored file + would be incomplete. Open the workspace in Xcode once to generate the initial + lockfile, then the Action keeps it current. - **Unsupported reference kinds.** Only `XCRemoteSwiftPackageReference` is parsed. Local packages (`XCLocalSwiftPackageReference`) and registry dependencies (`.package(id:)`) are skipped — they don't appear in the @@ -129,9 +134,13 @@ A complete working example lives at [`examples/update-spm.yml`](examples/update- `"a"..<"b"`, `branch:` and `revision:` map literally. 3. Writes the synthetic `Package.swift` to a temp directory and runs `xcrun swift package update --package-path `. -4. Diffs the new `Package.resolved` against the project's current one and - reports which identities changed. -5. Unless `--dry-run` or `--fail-when-outdated`, copies the new file in place. +4. Finds every `Package.resolved` Xcode reads for the project (the one embedded + in the `.xcodeproj` plus each sibling `.xcworkspace`'s copy) and computes, per + file, which pins would change. +5. Unless `--dry-run` or `--fail-when-outdated`, merges the new pins into each + lockfile in place — updating only the pins present in the fresh resolution, + preserving every file's schema version, `originHash`, and formatting, and + leaving workspace-only pins untouched. The script is invoked from the Action via `$GITHUB_ACTION_PATH/update_spm.py`, so the Action is fully self-contained — consumers do not need to vendor the diff --git a/examples/update-spm.yml b/examples/update-spm.yml index 9f6bb11..20f8945 100644 --- a/examples/update-spm.yml +++ b/examples/update-spm.yml @@ -30,7 +30,7 @@ jobs: uses: maximbilan/xcode-spm-update@v1 with: # Omit `project` to autodetect a single .xcodeproj under the repo root. - project: MySwimPro.xcodeproj + project: MyApp.xcodeproj # Pin the Xcode version so Package.resolved format/originHash matches # what your team's Xcode expects. xcode-version: '16.4' diff --git a/test/test_merge.py b/test/test_merge.py new file mode 100644 index 0000000..3a3e2ae --- /dev/null +++ b/test/test_merge.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Unit tests for the lockfile-discovery and in-place merge logic in update_spm.py. + +Pure standard library, no Swift toolchain or network needed — these cover the +part of the Action that broke real builds: writing to *every* Package.resolved +Xcode reads (project + each workspace) while preserving each file's format and +never clobbering pins that belong only to a workspace's local packages. + +Run: python3 test/test_merge.py +""" + +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import update_spm as u # noqa: E402 + + +def _pin(identity, version, location=None): + return { + "identity": identity, + "kind": "remoteSourceControl", + "location": location or f"https://github.com/example/{identity}.git", + "state": {"revision": "0" * 40, "version": version}, + } + + +def _write(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(u._dump_resolved(data)) + + +def test_dump_matches_swiftpm_format(): + # Space before the colon, 2-space indent, sorted keys, trailing newline. + out = u._dump_resolved({"version": 2, "pins": []}) + assert out == '{\n "pins" : [],\n "version" : 2\n}\n', repr(out) + + +def test_find_targets_discovers_project_and_workspace_not_local_pkg(): + with tempfile.TemporaryDirectory() as d: + proj = os.path.join(d, "MyApp.xcodeproj") + proj_lock = os.path.join(proj, "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved") + ws_lock = os.path.join(d, "MyApp.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved") + local_lock = os.path.join(d, "LocalPkg", "Package.resolved") + for p in (proj_lock, ws_lock, local_lock): + _write(p, {"version": 2, "pins": []}) + + targets = u.find_resolved_targets(proj, d) + reals = {os.path.realpath(t["path"]) for t in targets} + + assert targets[0]["kind"] == "project" + assert os.path.realpath(proj_lock) in reals + assert os.path.realpath(ws_lock) in reals, "sibling .xcworkspace lockfile must be a target" + assert os.path.realpath(local_lock) not in reals, "a local package lockfile must NOT be a target" + # The .xcodeproj's *internal* project.xcworkspace must be counted once, as 'project'. + assert [t["kind"] for t in targets].count("project") == 1 + assert len(targets) == len(reals), "targets must be de-duplicated" + + +def test_merge_noop_is_byte_identical(): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "Package.resolved") + _write(path, {"version": 3, "originHash": "keepme", + "pins": [_pin("alpha", "1.0.0"), _pin("beta", "2.0.0")]}) + original = open(path, encoding="utf-8").read() + by_ident = {p["identity"]: p for p in json.loads(original)["pins"]} + + data, changes = u.plan_merge(path, by_ident) + assert changes == [] + assert u._dump_resolved(data) == original, "no-op merge must not alter a single byte" + + +def test_merge_updates_values_preserves_schema_and_foreign_pins(): + with tempfile.TemporaryDirectory() as d: + # v2 workspace file: has a pin ('local-only-dep') the resolution never sees. + path = os.path.join(d, "Package.resolved") + _write(path, {"version": 2, "pins": [ + _pin("remote-dep", "1.0.0"), + _pin("local-only-dep", "3.2.1"), # local-package dep, not in synthetic manifest + ]}) + foreign_before = [p for p in json.loads(open(path).read())["pins"] + if p["identity"] == "local-only-dep"][0] + + resolution = {"remote-dep": _pin("remote-dep", "9.9.9")} # note: no 'local-only-dep' + data, changes = u.plan_merge(path, resolution) + + assert data["version"] == 2, "v2 must stay v2" + assert "originHash" not in data, "must not inject originHash into a v2 file" + assert [c[0] for c in changes] == ["remote-dep"] + pins = {p["identity"]: p for p in data["pins"]} + assert pins["remote-dep"]["state"]["version"] == "9.9.9" + assert pins["local-only-dep"] == foreign_before, "workspace-only pin must be untouched" + assert len(data["pins"]) == 2, "no pin added or removed" + + +def test_merge_preserves_v3_origin_hash(): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "Package.resolved") + _write(path, {"version": 3, "originHash": "abc123", "pins": [_pin("alpha", "1.0.0")]}) + data, changes = u.plan_merge(path, {"alpha": _pin("alpha", "1.1.0")}) + assert data["version"] == 3 + assert data["originHash"] == "abc123", "originHash must be preserved (requirements unchanged)" + assert changes == [("alpha", "1.0.0", "1.1.0")] + + +def test_merge_adds_new_pin_sorted(): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "Package.resolved") + _write(path, {"version": 2, "pins": [_pin("mmm", "1.0.0")]}) + resolution = {"mmm": _pin("mmm", "1.0.0"), "aaa": _pin("aaa", "5.0.0")} + data, changes = u.plan_merge(path, resolution) + idents = [p["identity"] for p in data["pins"]] + assert idents == sorted(idents) == ["aaa", "mmm"], "pins must stay identity-sorted" + assert ("aaa", None, "5.0.0") in changes + + +def main(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failures = 0 + for t in tests: + try: + t() + print(f"ok {t.__name__}") + except AssertionError as e: + failures += 1 + print(f"FAIL {t.__name__}: {e}") + print(f"\n{len(tests) - failures}/{len(tests)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/update_spm.py b/update_spm.py index 8eec7ac..4188409 100644 --- a/update_spm.py +++ b/update_spm.py @@ -7,7 +7,11 @@ Xcode 16+. This routes around it: it reads the dependency *requirements* straight out of project.pbxproj, writes a throwaway Package.swift, lets the real `swift` toolchain do the resolving (PubGrub solver, transitive graph, the works), then -copies the freshly resolved Package.resolved back into the Xcode project. +merges the freshly resolved pins back into *every* Package.resolved Xcode reads +for the project — the one embedded in the .xcodeproj AND the one inside any sibling +.xcworkspace, since `xcodebuild -workspace` (CI/release builds) reads the workspace +copy and would otherwise never see the update. Each lockfile is patched in place, +values-only, preserving its own schema version and formatting. It does NOT reimplement version solving. SPM does that. We only do plumbing. @@ -27,7 +31,6 @@ import json import os import re -import shutil import subprocess import sys import tempfile @@ -104,28 +107,95 @@ def render_manifest(deps, tools_version="5.10"): return "\n".join(lines) -def find_resolved_path(project_path): - """Locate the Package.resolved Xcode reads for this .xcodeproj.""" - candidate = os.path.join( - project_path, "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved" - ) - return candidate - - -def summarize_diff(old_path, new_path): - def load(p): - if not os.path.exists(p): - return {} - data = json.load(open(p)) - return {pin["identity"]: pin["state"].get("version") or pin["state"].get("revision", "")[:7] - for pin in data.get("pins", [])} - old, new = load(old_path), load(new_path) - changed = [] - for ident in sorted(set(old) | set(new)): - a, b = old.get(ident), new.get(ident) - if a != b: - changed.append((ident, a, b)) - return changed +def _dump_resolved(data): + """Serialize a Package.resolved dict exactly the way SwiftPM does: 2-space + indent, sorted keys, a space *before* each colon, trailing newline. Byte-identical + to Xcode's own output, so an unchanged file round-trips with zero diff and a + values-only merge produces a values-only diff (no formatting churn).""" + return json.dumps(data, indent=2, separators=(",", " : "), sort_keys=True) + "\n" + + +def _state_summary(state): + """Short human-readable form of a pin's resolved state, for logging.""" + if not state: + return "(new)" + return state.get("version") or state.get("branch") or (state.get("revision") or "")[:7] or "(?)" + + +def find_resolved_targets(project, search_dir): + """Every Package.resolved that Xcode reads for this project. + + Xcode keeps a lockfile per *container*: one embedded in the .xcodeproj, and a + separate one inside each .xcworkspace. `xcodebuild -workspace` (what CI/release + builds usually run) resolves against the *workspace* lockfile, not the .xcodeproj + one. If we only touch the project file, the workspace file goes stale and real + builds never see the update — so we update every lockfile we can find. + + Returns a list of {"path", "kind"} dicts (kind is "project" or "workspace"), + with the .xcodeproj-embedded lockfile first. + """ + targets = [] + seen = set() + + def add(path, kind): + real = os.path.realpath(path) + if real not in seen: + seen.add(real) + targets.append({"path": path, "kind": kind}) + + # The lockfile embedded in the .xcodeproj bundle. + add(os.path.join(project, "project.xcworkspace", "xcshareddata", "swiftpm", + "Package.resolved"), "project") + + # Sibling .xcworkspace lockfiles, looked for next to the project and under + # search-dir. (The .xcodeproj's *internal* project.xcworkspace lives inside the + # bundle, so a `*.xcworkspace` glob against these dirs never matches it — no + # double-counting.) + for d in {os.path.dirname(os.path.abspath(project)) or ".", os.path.abspath(search_dir)}: + for ws in sorted(glob.glob(os.path.join(d, "*.xcworkspace"))): + add(os.path.join(ws, "xcshareddata", "swiftpm", "Package.resolved"), "workspace") + + return targets + + +def plan_merge(target_path, new_pins_by_ident): + """Compute an in-place, format-preserving update of an existing Package.resolved. + + Only pins whose identity appears in the fresh resolution are updated; pins unique + to this file are left byte-for-byte untouched and no pin is ever removed. This + matters for workspace lockfiles, whose graph is a superset of the synthetic + manifest's — it also pins dependencies pulled in by local packages that the + manifest never sees; blowing those away would break resolution. The file's schema + version and originHash are preserved: only resolved values change, not the version + requirements, so an existing originHash stays valid. + + Returns (data, changes) where `data` is the mutated dict ready to write and + `changes` is a list of (identity, old_summary, new_summary). + """ + data = json.loads(open(target_path, encoding="utf-8").read()) + pins = data.get("pins", []) + existing = {p.get("identity") for p in pins} + changes = [] + + for pin in pins: + newp = new_pins_by_ident.get(pin.get("identity")) + if newp is None: + continue # not in our resolution (e.g. a local package's dependency) + if pin.get("state") != newp.get("state"): + changes.append((pin["identity"], _state_summary(pin.get("state")), + _state_summary(newp.get("state")))) + pin["state"] = newp["state"] + + for ident, newp in new_pins_by_ident.items(): + if ident not in existing: + pins.append({k: newp[k] for k in ("identity", "kind", "location", "state") + if k in newp}) + changes.append((ident, None, _state_summary(newp.get("state")))) + + # SwiftPM writes pins sorted by identity; keep that so newly-added pins land in a + # stable spot (a no-op when nothing was added, preserving byte-for-byte output). + data["pins"] = sorted(pins, key=lambda p: p.get("identity", "")) + return data, changes def autodetect_project(search_dir): @@ -211,18 +281,50 @@ def main(): return 2 new_resolved = os.path.join(tmp, "Package.resolved") - target = find_resolved_path(project) - changed = summarize_diff(target, new_resolved) - changed_count = len(changed) + new_data = json.loads(open(new_resolved, encoding="utf-8").read()) + new_pins_by_ident = {p["identity"]: p for p in new_data.get("pins", [])} + + targets = find_resolved_targets(project, args.search_dir) + + # Build a write plan for every lockfile without touching disk yet, so --dry-run + # and --fail-when-outdated report accurately before anything is written. + plans = [] # list of (target_dict, data_to_write, changes) + changed_idents = set() + for t in targets: + path = t["path"] + if os.path.exists(path): + data, changes = plan_merge(path, new_pins_by_ident) + elif t["kind"] == "project": + # No lockfile in the .xcodeproj yet — create it from the full resolution + # (this container's graph is exactly the synthetic manifest's). + data = new_data + changes = [(ident, None, _state_summary(p.get("state"))) + for ident, p in new_pins_by_ident.items()] + else: + # A workspace with no lockfile yet: skip. We can't author a complete one + # from scratch — the workspace graph includes local-package dependencies + # the synthetic manifest never sees, so the file would be missing pins. + print(f"note: {path} does not exist yet; skipping " + "(open the workspace in Xcode once to create it, then re-run)") + continue + plans.append((t, data, changes)) + changed_idents.update(c[0] for c in changes) + + changed_count = len(changed_idents) emit_outputs(changed_count) - if not changed: + if changed_count == 0: print("\nAll dependencies already up to date. No changes.") return 0 - print(f"\n{changed_count} package(s) changed:") - for ident, old, new in changed: - print(f" {ident:<28} {old or '(new)'} -> {new}") + print(f"\n{changed_count} package(s) to update across {len(plans)} lockfile(s):") + for t, _data, changes in plans: + if not changes: + print(f" {t['path']} ({t['kind']}): already current") + continue + print(f" {t['path']} ({t['kind']}): {len(changes)} pin(s)") + for ident, old, new in changes: + print(f" {ident:<28} {old or '(new)'} -> {new}") if args.fail_when_outdated: print("\n--fail-when-outdated set: exiting with code 3 because pins are stale.", @@ -230,12 +332,16 @@ def main(): return 3 if args.dry_run: - print(f"\n[dry-run] Updated Package.resolved left at: {new_resolved}") + print(f"\n[dry-run] No files written. Fresh resolution left at: {new_resolved}") return 0 - os.makedirs(os.path.dirname(target), exist_ok=True) - shutil.copyfile(new_resolved, target) - print(f"\nWrote updated pins to {target}") + for t, data, changes in plans: + if not changes: + continue # already current — don't rewrite (avoids a no-op diff) + os.makedirs(os.path.dirname(t["path"]), exist_ok=True) + with open(t["path"], "w", encoding="utf-8") as fh: + fh.write(_dump_resolved(data)) + print(f"\nWrote {len(changes)} updated pin(s) to {t['path']}") return 0 From ba798ec12a6280270576db3ecd1940adb314b7ae Mon Sep 17 00:00:00 2001 From: Maksym Bilan Date: Thu, 23 Jul 2026 15:47:38 +0200 Subject: [PATCH 2/2] Preserve non-ASCII bytes in Package.resolved (ensure_ascii=False) Foundation's JSONEncoder (what SwiftPM/Xcode use) writes raw UTF-8; Python's json escapes non-ASCII to \uXXXX by default, which would rewrite a package name or URL containing non-ASCII on the first run. No-op for the common ASCII-only case; keeps output byte-identical to Xcode's for arbitrary repos. Co-Authored-By: Claude Opus 4.8 (1M context) --- update_spm.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/update_spm.py b/update_spm.py index 4188409..87b9f40 100644 --- a/update_spm.py +++ b/update_spm.py @@ -111,8 +111,11 @@ def _dump_resolved(data): """Serialize a Package.resolved dict exactly the way SwiftPM does: 2-space indent, sorted keys, a space *before* each colon, trailing newline. Byte-identical to Xcode's own output, so an unchanged file round-trips with zero diff and a - values-only merge produces a values-only diff (no formatting churn).""" - return json.dumps(data, indent=2, separators=(",", " : "), sort_keys=True) + "\n" + values-only merge produces a values-only diff (no formatting churn). Matches + Foundation's JSONEncoder, which emits raw UTF-8 (ensure_ascii=False) so a + non-ASCII package name or URL isn't rewritten as \\uXXXX escapes.""" + return json.dumps(data, indent=2, separators=(",", " : "), + sort_keys=True, ensure_ascii=False) + "\n" def _state_summary(state):