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.
- bimdelta-0.4.0/.gitignore +48 -0
- bimdelta-0.4.0/PKG-INFO +47 -0
- bimdelta-0.4.0/README.md +26 -0
- bimdelta-0.4.0/pyproject.toml +43 -0
- bimdelta-0.4.0/src/bimdelta_cli/__init__.py +8 -0
- bimdelta-0.4.0/src/bimdelta_cli/__main__.py +3 -0
- bimdelta-0.4.0/src/bimdelta_cli/blame.py +275 -0
- bimdelta-0.4.0/src/bimdelta_cli/filters.py +49 -0
- bimdelta-0.4.0/src/bimdelta_cli/gitdriver.py +120 -0
- bimdelta-0.4.0/src/bimdelta_cli/gitutil.py +70 -0
- bimdelta-0.4.0/src/bimdelta_cli/main.py +398 -0
- bimdelta-0.4.0/src/bimdelta_cli/render.py +232 -0
- bimdelta-0.4.0/src/bimdelta_cli/snapshot.py +92 -0
- bimdelta-0.4.0/tests/__init__.py +0 -0
- bimdelta-0.4.0/tests/conftest.py +116 -0
- bimdelta-0.4.0/tests/test_diff.py +148 -0
- bimdelta-0.4.0/tests/test_git.py +192 -0
- bimdelta-0.4.0/tests/test_quantities_render.py +36 -0
|
@@ -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
|
bimdelta-0.4.0/PKG-INFO
ADDED
|
@@ -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.
|
bimdelta-0.4.0/README.md
ADDED
|
@@ -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,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=")
|