bimdelta 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ *.egg
10
+
11
+ # IDE
12
+ .vscode/
13
+ .idea/
14
+ *.swp
15
+ *.swo
16
+
17
+ # Environment
18
+ .env
19
+ .env.local
20
+ .env.production
21
+
22
+ # OS
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # Test / coverage
27
+ .pytest_cache/
28
+ htmlcov/
29
+ .coverage
30
+
31
+ # Node
32
+ node_modules/
33
+
34
+ # IFC temp files
35
+ *.ifc.tmp
36
+
37
+ # Docker
38
+ docker-compose.override.yml
39
+
40
+ # Claude
41
+ .claude/
42
+
43
+ # Revit project and family files, including backups (*.0001.rvt)
44
+ *.rvt
45
+ *.rfa
46
+
47
+ # Local session file
48
+ \!sessionid.txt
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: bimdelta
3
+ Version: 0.4.0
4
+ Summary: Code review for building models: element-level IFC diffs in the terminal and in Git
5
+ Project-URL: Repository, https://github.com/kaan36875/BIMDelta
6
+ License-Expression: MIT
7
+ Keywords: bim,diff,git,ifc,ifcopenshell,openbim
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Scientific/Engineering
12
+ Classifier: Topic :: Software Development :: Version Control :: Git
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: bimdelta-core==0.4.0
15
+ Requires-Dist: rich>=13.0.0
16
+ Requires-Dist: typer>=0.12.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # bimdelta
23
+
24
+ Code review for building models. `bimdelta` compares two versions of an IFC model element by
25
+ element and plugs into Git, so `git diff model.ifc` shows which walls, doors and columns changed
26
+ instead of thousands of lines of STEP text.
27
+
28
+ ```sh
29
+ pip install bimdelta
30
+
31
+ bimdelta diff rev-a.ifc rev-b.ifc # table in the terminal
32
+ bimdelta diff rev-a.ifc rev-b.ifc -f markdown # for a pull request or a report
33
+ bimdelta diff rev-a.ifc rev-b.ifc -f json # the full DiffResult
34
+ bimdelta diff rev-a.ifc rev-b.ifc --filter "class=IfcWall*,storey=Level 1"
35
+
36
+ bimdelta install-git-driver # once per clone
37
+ git diff HEAD~1 -- model.ifc
38
+ bimdelta blame 2O2Fr$t4X7Zf8NOew3FLOH model.ifc # commits that touched one element
39
+ bimdelta quantities rev-a.ifc rev-b.ifc --by material
40
+ ```
41
+
42
+ The diff engine is [`bimdelta-core`](https://pypi.org/project/bimdelta-core/), built on
43
+ [IfcOpenShell](https://ifcopenshell.org). Documentation:
44
+ [CLI reference](https://github.com/kaan36875/BIMDelta/blob/main/docs/cli.md) and
45
+ [Git integration](https://github.com/kaan36875/BIMDelta/blob/main/docs/git-integration.md).
46
+
47
+ Part of [BIMDelta](https://github.com/kaan36875/BIMDelta). MIT licensed.
@@ -0,0 +1,26 @@
1
+ # bimdelta
2
+
3
+ Code review for building models. `bimdelta` compares two versions of an IFC model element by
4
+ element and plugs into Git, so `git diff model.ifc` shows which walls, doors and columns changed
5
+ instead of thousands of lines of STEP text.
6
+
7
+ ```sh
8
+ pip install bimdelta
9
+
10
+ bimdelta diff rev-a.ifc rev-b.ifc # table in the terminal
11
+ bimdelta diff rev-a.ifc rev-b.ifc -f markdown # for a pull request or a report
12
+ bimdelta diff rev-a.ifc rev-b.ifc -f json # the full DiffResult
13
+ bimdelta diff rev-a.ifc rev-b.ifc --filter "class=IfcWall*,storey=Level 1"
14
+
15
+ bimdelta install-git-driver # once per clone
16
+ git diff HEAD~1 -- model.ifc
17
+ bimdelta blame 2O2Fr$t4X7Zf8NOew3FLOH model.ifc # commits that touched one element
18
+ bimdelta quantities rev-a.ifc rev-b.ifc --by material
19
+ ```
20
+
21
+ The diff engine is [`bimdelta-core`](https://pypi.org/project/bimdelta-core/), built on
22
+ [IfcOpenShell](https://ifcopenshell.org). Documentation:
23
+ [CLI reference](https://github.com/kaan36875/BIMDelta/blob/main/docs/cli.md) and
24
+ [Git integration](https://github.com/kaan36875/BIMDelta/blob/main/docs/git-integration.md).
25
+
26
+ Part of [BIMDelta](https://github.com/kaan36875/BIMDelta). MIT licensed.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bimdelta"
7
+ version = "0.4.0"
8
+ description = "Code review for building models: element-level IFC diffs in the terminal and in Git"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ keywords = ["ifc", "bim", "diff", "git", "openbim", "ifcopenshell"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Operating System :: OS Independent",
16
+ "Environment :: Console",
17
+ "Topic :: Scientific/Engineering",
18
+ "Topic :: Software Development :: Version Control :: Git",
19
+ ]
20
+ dependencies = [
21
+ # Released together; keep in step with this package's version.
22
+ "bimdelta-core==0.4.0",
23
+ "typer>=0.12.0",
24
+ "rich>=13.0.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0.0",
30
+ "ruff>=0.6.0",
31
+ ]
32
+
33
+ [project.scripts]
34
+ bimdelta = "bimdelta_cli.main:app"
35
+
36
+ [project.urls]
37
+ Repository = "https://github.com/kaan36875/BIMDelta"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/bimdelta_cli"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
@@ -0,0 +1,8 @@
1
+ """Command-line interface and Git integration for BIMDelta."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("bimdelta")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
@@ -0,0 +1,3 @@
1
+ from .main import app
2
+
3
+ app(prog_name="bimdelta")
@@ -0,0 +1,275 @@
1
+ """History of one element through the commits that touched an IFC file.
2
+
3
+ Consecutive versions of the file are compared with the diff engine, so an element is
4
+ followed the same way ``bimdelta diff`` matches it: by GlobalId, then Tag, then placement.
5
+ A re-export that changes an element's GlobalId therefore does not break its history.
6
+
7
+ Each comparison is cached in ``<git dir>/bimdelta-cache`` keyed by the two blob ids and the
8
+ diff settings; a later blame of any element in the same file reuses it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import tempfile
16
+ from dataclasses import asdict, dataclass, field
17
+ from pathlib import Path
18
+
19
+ from bimdelta_core import ChangeType, Settings, __version__, run_diff
20
+ from bimdelta_core.engine import IFC_LOCK
21
+ from bimdelta_core.parser import parse_ifc
22
+
23
+ from .gitutil import GitError, git, git_dir, git_text, repo_root, resolve_lfs
24
+ from .render import describe_changes
25
+
26
+ CACHE_DIRNAME = "bimdelta-cache"
27
+ CACHE_FORMAT = 1
28
+ _SEP = "\x1f"
29
+
30
+
31
+ @dataclass
32
+ class Commit:
33
+ sha: str
34
+ author: str
35
+ date: str
36
+ subject: str
37
+ blob: str | None # None where the file does not exist in this commit
38
+
39
+
40
+ @dataclass
41
+ class BlameEvent:
42
+ change: str # added | modified | removed | reidentified
43
+ sha: str
44
+ author: str
45
+ date: str
46
+ subject: str
47
+ global_id: str
48
+ ifc_class: str | None = None
49
+ name: str | None = None
50
+ details: list[str] = field(default_factory=list)
51
+
52
+
53
+ @dataclass
54
+ class BlameResult:
55
+ global_id: str
56
+ path: str
57
+ commits_scanned: int
58
+ truncated: bool
59
+ events: list[BlameEvent] # newest first
60
+ # The element already existed before the oldest commit scanned (--max-commits).
61
+ present_before: bool = False
62
+
63
+ def to_dict(self) -> dict:
64
+ return asdict(self)
65
+
66
+
67
+ class BlameError(RuntimeError):
68
+ pass
69
+
70
+
71
+ def blame(
72
+ global_id: str,
73
+ path: str | None,
74
+ cfg: Settings,
75
+ repo: Path | str = ".",
76
+ rev: str = "HEAD",
77
+ max_commits: int | None = None,
78
+ ) -> BlameResult:
79
+ root = repo_root(repo)
80
+ rel_path = _resolve_path(root, path, rev)
81
+ # One commit more than asked for: the version just before the window is what the
82
+ # oldest commit in it is compared with.
83
+ commits = _history(root, rel_path, rev, None if max_commits is None else max_commits + 1)
84
+ if not commits:
85
+ raise BlameError(f"{rel_path} has no history at {rev}")
86
+ truncated = max_commits is not None and len(commits) > max_commits
87
+ window = commits[:max_commits] if truncated else commits
88
+
89
+ cache = _Cache(git_dir(root) / CACHE_DIRNAME, cfg)
90
+
91
+ events: list[BlameEvent] = []
92
+ tracked = global_id
93
+ newest = commits[0].blob
94
+ found = newest is not None and tracked in cache.element_ids(root, newest)
95
+ present_before = False
96
+
97
+ # Walk from newest to oldest; each step compares a commit with its predecessor.
98
+ for i, newer in enumerate(window):
99
+ older = commits[i + 1] if i + 1 < len(commits) else None
100
+ if newer.blob is None:
101
+ continue
102
+ older_blob = older.blob if older is not None else None
103
+ if older_blob is None:
104
+ # File created (or re-created) here: everything in it was added.
105
+ info = cache.element_ids(root, newer.blob).get(tracked)
106
+ if info is not None:
107
+ events.append(_event("added", newer, tracked, info))
108
+ found = True
109
+ break
110
+ continue
111
+ if older_blob == newer.blob:
112
+ continue
113
+
114
+ rec = cache.pair(root, older_blob, newer.blob).get(tracked)
115
+ if rec is None:
116
+ continue
117
+ found = True
118
+ change = rec["change"]
119
+ if change == ChangeType.ADDED.value:
120
+ events.append(_event("added", newer, tracked, rec))
121
+ break
122
+ if change == ChangeType.REMOVED.value:
123
+ events.append(_event("removed", newer, tracked, rec))
124
+ continue
125
+ previous_id = rec.get("matched_global_id") or tracked
126
+ if change == ChangeType.MODIFIED.value:
127
+ events.append(_event("modified", newer, tracked, rec))
128
+ elif previous_id != tracked:
129
+ events.append(_event("reidentified", newer, tracked, rec))
130
+ tracked = previous_id
131
+ else:
132
+ if truncated and commits[-1].blob is not None:
133
+ present_before = tracked in cache.element_ids(root, commits[-1].blob)
134
+ found = found or present_before
135
+
136
+ if not found:
137
+ raise BlameError(f"no element with GlobalId {global_id} in the history of {rel_path}")
138
+
139
+ return BlameResult(
140
+ global_id=global_id,
141
+ path=rel_path,
142
+ commits_scanned=len(window),
143
+ truncated=truncated,
144
+ events=events,
145
+ present_before=present_before,
146
+ )
147
+
148
+
149
+ def _event(change: str, commit: Commit, global_id: str, rec: dict | None) -> BlameEvent:
150
+ rec = rec or {}
151
+ return BlameEvent(
152
+ change=change,
153
+ sha=commit.sha,
154
+ author=commit.author,
155
+ date=commit.date,
156
+ subject=commit.subject,
157
+ global_id=global_id,
158
+ ifc_class=rec.get("ifc_class"),
159
+ name=rec.get("name"),
160
+ details=list(rec.get("details", [])),
161
+ )
162
+
163
+
164
+ def _resolve_path(root: Path, path: str | None, rev: str) -> str:
165
+ if path is not None:
166
+ full = Path(path)
167
+ if not full.is_absolute():
168
+ full = (Path.cwd() / full).resolve()
169
+ try:
170
+ return full.relative_to(root.resolve()).as_posix()
171
+ except ValueError:
172
+ raise BlameError(f"{path} is not inside the repository at {root}") from None
173
+ tracked = [
174
+ p
175
+ for p in git_text("ls-tree", "-r", "--name-only", rev, cwd=root).splitlines()
176
+ if p.lower().endswith(".ifc")
177
+ ]
178
+ if len(tracked) == 1:
179
+ return tracked[0]
180
+ if not tracked:
181
+ raise BlameError(f"no .ifc file is tracked at {rev}")
182
+ raise BlameError("several .ifc files are tracked; name one: " + ", ".join(tracked))
183
+
184
+
185
+ def _history(root: Path, rel_path: str, rev: str, max_commits: int | None) -> list[Commit]:
186
+ args = ["log", f"--format=%H{_SEP}%an{_SEP}%aI{_SEP}%s"]
187
+ if max_commits is not None:
188
+ args.append(f"--max-count={max_commits}")
189
+ out = git_text(*args, rev, "--", rel_path, cwd=root)
190
+ commits = []
191
+ for line in out.splitlines():
192
+ sha, author, date, subject = line.split(_SEP, 3)
193
+ commits.append(Commit(sha, author, date, subject, _blob(root, sha, rel_path)))
194
+ return commits
195
+
196
+
197
+ def _blob(root: Path, sha: str, rel_path: str) -> str | None:
198
+ try:
199
+ return git_text("rev-parse", "--verify", "--quiet", f"{sha}:{rel_path}", cwd=root)
200
+ except GitError:
201
+ return None
202
+
203
+
204
+ class _Cache:
205
+ def __init__(self, directory: Path, cfg: Settings):
206
+ self.dir = directory
207
+ self.cfg = cfg
208
+ key = f"{CACHE_FORMAT}|{__version__}|{cfg.model_dump_json()}"
209
+ self.settings_key = hashlib.blake2b(key.encode(), digest_size=8).hexdigest()
210
+
211
+ def pair(self, root: Path, old_blob: str, new_blob: str) -> dict[str, dict]:
212
+ """Every element of the newer version that differs from the older one, keyed by
213
+ its GlobalId; removed elements are keyed by their GlobalId in the older version."""
214
+ path = self.dir / f"pair-{old_blob}-{new_blob}-{self.settings_key}.json"
215
+ cached = self._read(path)
216
+ if cached is not None:
217
+ return cached
218
+
219
+ with tempfile.TemporaryDirectory(prefix="bimdelta-") as tmp:
220
+ old = _materialise(root, old_blob, Path(tmp), "old")
221
+ new = _materialise(root, new_blob, Path(tmp), "new")
222
+ result = run_diff(str(old), "old.ifc", str(new), "new.ifc", self.cfg)
223
+
224
+ records: dict[str, dict] = {}
225
+ for el in result.elements:
226
+ if el.change_type == ChangeType.UNCHANGED and not el.matched_global_id:
227
+ continue
228
+ records[el.global_id] = {
229
+ "change": el.change_type.value,
230
+ "ifc_class": el.ifc_class,
231
+ "name": el.name,
232
+ "matched_global_id": el.matched_global_id,
233
+ "details": describe_changes(el) if el.change_type == ChangeType.MODIFIED else [],
234
+ }
235
+ self._write(path, records)
236
+ return records
237
+
238
+ def element_ids(self, root: Path, blob: str) -> dict[str, dict]:
239
+ """Class and name of every element in one version, keyed by GlobalId."""
240
+ path = self.dir / f"elements-{blob}.json"
241
+ cached = self._read(path)
242
+ if cached is not None:
243
+ return cached
244
+
245
+ with tempfile.TemporaryDirectory(prefix="bimdelta-") as tmp:
246
+ file = _materialise(root, blob, Path(tmp), "model")
247
+ with IFC_LOCK:
248
+ model = parse_ifc(str(file), "model.ifc", self.cfg.include_openings)
249
+ elements = {
250
+ gid: {"ifc_class": el.ifc_class, "name": el.name}
251
+ for gid, el in model.elements.items()
252
+ }
253
+ self._write(path, elements)
254
+ return elements
255
+
256
+ def _read(self, path: Path):
257
+ try:
258
+ return json.loads(path.read_text(encoding="utf-8"))
259
+ except (OSError, ValueError):
260
+ return None
261
+
262
+ def _write(self, path: Path, data) -> None:
263
+ try:
264
+ self.dir.mkdir(parents=True, exist_ok=True)
265
+ tmp = path.with_suffix(".tmp")
266
+ tmp.write_text(json.dumps(data), encoding="utf-8")
267
+ tmp.replace(path)
268
+ except OSError:
269
+ pass # The cache is an optimisation only.
270
+
271
+
272
+ def _materialise(root: Path, blob: str, directory: Path, stem: str) -> Path:
273
+ path = directory / f"{stem}.ifc"
274
+ path.write_bytes(git("cat-file", "blob", blob, cwd=root))
275
+ return resolve_lfs(path, directory, cwd=root)
@@ -0,0 +1,49 @@
1
+ """The ``--filter`` expression: ``class=IfcWall*,storey=Level 1,change=modified``.
2
+
3
+ Terms are separated by commas. Terms with the same key are alternatives (OR), different
4
+ keys must all match (AND). Values are case-insensitive and may use ``*`` and ``?``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from fnmatch import fnmatchcase
11
+
12
+ from bimdelta_core import ElementDiff
13
+
14
+ KEYS = ("class", "storey", "change", "name", "id")
15
+
16
+
17
+ class FilterError(ValueError):
18
+ pass
19
+
20
+
21
+ def parse_filter(expression: str) -> Callable[[ElementDiff], bool]:
22
+ terms: dict[str, list[str]] = {}
23
+ for raw in expression.split(","):
24
+ if not raw.strip():
25
+ continue
26
+ key, sep, value = raw.partition("=")
27
+ key = key.strip().lower()
28
+ if not sep or not value.strip():
29
+ raise FilterError(f"expected key=value, got {raw.strip()!r}")
30
+ if key not in KEYS:
31
+ raise FilterError(f"unknown filter key {key!r}; valid keys: {', '.join(KEYS)}")
32
+ terms.setdefault(key, []).append(value.strip().lower())
33
+
34
+ def keep(el: ElementDiff) -> bool:
35
+ fields = {
36
+ "class": [el.ifc_class],
37
+ "storey": [el.storey or "", el.old_storey or ""],
38
+ "change": [el.change_type.value],
39
+ "name": [el.name or "", el.old_name or ""],
40
+ "id": [el.global_id, el.matched_global_id or ""],
41
+ }
42
+ return all(
43
+ any(
44
+ fnmatchcase(field.lower(), pattern) for field in fields[key] for pattern in patterns
45
+ )
46
+ for key, patterns in terms.items()
47
+ )
48
+
49
+ return keep
@@ -0,0 +1,120 @@
1
+ """Registering BIMDelta as Git's diff driver for ``*.ifc`` files.
2
+
3
+ Two hooks are installed under ``[diff "ifc"]``:
4
+
5
+ * ``command``: an external diff driver. ``git diff`` hands it both versions of the file,
6
+ so it shows the real matched, noise-filtered element diff as Markdown.
7
+ * ``textconv``: turns one version into stable text (see ``snapshot``). Git uses it where no
8
+ external driver runs: ``git log -p``, ``git show`` and ``git diff --no-ext-diff``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from .gitutil import git, git_text, repo_root
20
+ from .snapshot import XFUNCNAME
21
+
22
+ DRIVER = "ifc"
23
+ PATTERN = "*.ifc"
24
+
25
+
26
+ @dataclass
27
+ class InstallReport:
28
+ scope: str
29
+ command: str
30
+ attributes_file: Path
31
+ attributes_changed: bool
32
+
33
+
34
+ def default_command() -> str:
35
+ """How Git should invoke this CLI.
36
+
37
+ The ``bimdelta`` script is used when it is on PATH. Otherwise (e.g. installed in a
38
+ virtual environment that is not activated) the current interpreter is called directly,
39
+ so the driver keeps working from any shell.
40
+ """
41
+ if shutil.which("bimdelta"):
42
+ return "bimdelta"
43
+ python = Path(sys.executable).as_posix()
44
+ return f'"{python}" -m bimdelta_cli'
45
+
46
+
47
+ def install(
48
+ global_: bool = False, command: str | None = None, cwd: Path | str = "."
49
+ ) -> InstallReport:
50
+ command = command or default_command()
51
+ scope = ["--global"] if global_ else []
52
+ if not global_:
53
+ cwd = repo_root(cwd)
54
+
55
+ git("config", *scope, f"diff.{DRIVER}.command", f"{command} git-diff", cwd=cwd)
56
+ git("config", *scope, f"diff.{DRIVER}.textconv", f"{command} textconv", cwd=cwd)
57
+ git("config", *scope, f"diff.{DRIVER}.xfuncname", XFUNCNAME, cwd=cwd)
58
+
59
+ attributes = global_attributes_file(cwd) if global_ else Path(cwd) / ".gitattributes"
60
+ changed = ensure_attribute(attributes)
61
+ return InstallReport(
62
+ scope="global" if global_ else "repository",
63
+ command=command,
64
+ attributes_file=attributes,
65
+ attributes_changed=changed,
66
+ )
67
+
68
+
69
+ def uninstall(global_: bool = False, cwd: Path | str = ".") -> None:
70
+ """Remove the ``[diff "ifc"]`` section. The ``.gitattributes`` line is left in place: it
71
+ is committed and shared, and does nothing without the driver."""
72
+ scope = ["--global"] if global_ else []
73
+ if not global_:
74
+ cwd = repo_root(cwd)
75
+ git("config", *scope, "--remove-section", f"diff.{DRIVER}", cwd=cwd)
76
+
77
+
78
+ def global_attributes_file(cwd: Path | str = ".") -> Path:
79
+ try:
80
+ configured = git_text("config", "--global", "--path", "core.attributesFile", cwd=cwd)
81
+ except Exception:
82
+ configured = ""
83
+ if configured:
84
+ return Path(configured).expanduser()
85
+ xdg = os.environ.get("XDG_CONFIG_HOME")
86
+ base = Path(xdg) if xdg else Path.home() / ".config"
87
+ return base / "git" / "attributes"
88
+
89
+
90
+ def ensure_attribute(path: Path) -> bool:
91
+ """Make ``*.ifc`` use ``diff=ifc`` in an attributes file. Returns True if it changed.
92
+
93
+ An existing ``*.ifc`` line keeps its other attributes; Git LFS writes ``diff=lfs`` there,
94
+ which is replaced (LFS still works: content is resolved through ``git lfs smudge``).
95
+ """
96
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
97
+ lines = text.splitlines()
98
+ wanted = f"diff={DRIVER}"
99
+ for i, line in enumerate(lines):
100
+ fields = line.split()
101
+ if not fields or fields[0] != PATTERN:
102
+ continue
103
+ attrs = [a for a in fields[1:] if not _is_diff_attr(a)]
104
+ new_line = " ".join([PATTERN, *attrs, wanted])
105
+ if fields[1:].count(wanted) == 1 and not any(
106
+ _is_diff_attr(a) and a != wanted for a in fields[1:]
107
+ ):
108
+ return False
109
+ lines[i] = new_line
110
+ break
111
+ else:
112
+ lines.append(f"{PATTERN} {wanted}")
113
+
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
116
+ return True
117
+
118
+
119
+ def _is_diff_attr(attr: str) -> bool:
120
+ return attr in ("diff", "-diff", "!diff") or attr.startswith("diff=")