style: ruff --fix + black across ifcurl/ and tests/

Import sorting, quoted type annotation removal, and black reformatting.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-25 08:34:56 +01:00
parent fef6c16946
commit a6206a459e
18 changed files with 538 additions and 196 deletions

View file

@ -4,7 +4,7 @@
{"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-ndf","title":"gitconfig-ifcmerge: update to use ifcmerge --prioritise-local instead of swapping LOCAL/REMOTE","description":"The current gitconfig-ifcmerge uses the old technique of swapping the order of %A and %B arguments to preserve the base branch's STEP ID space:\n\n driver = ifcmerge %O %A %B %L (standard)\n driver = ifcmerge %O %B %A %L (ours variant)\n\nBonsai's current ifcgit code (tool/ifcgit.py, config_ifcmerge) uses the newer --prioritise-local flag instead:\n\n ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED\n\nThe --prioritise-local flag tells ifcmerge to preserve LOCAL's STEP ID space without needing to swap argument order. This is cleaner, more readable, and follows the canonical ifcmerge interface.\n\nThe gitconfig-ifcmerge needs updating to:\n driver = ifcmerge --prioritise-local %O %A %B %A\n\n(where %A is both LOCAL and the output file — git merge drivers write their result to %A)\n\nThe ifcmerge_ours variant (for rebase direction where base branch arrives as %B) would become:\n driver = ifcmerge --prioritise-local %O %B %A %B\n\nVerify that the --prioritise-local flag exists in the version of ifcmerge being used before merging.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:59Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:41:59Z","closed_at":"2026-04-25T06:41:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-qjd","title":"render_diff: auto-fit camera to changed elements, not whole model","description":"When rendering a diff of a large building model, the auto-fit camera fits to the entire head model. A wall added to one corner of a multi-storey building would appear as a tiny green speck. The camera should instead zoom to the bounding box of just the added + modified + removed elements so the changed area fills the viewport.\n\nImplementation sketch:\n- In render_diff(), after adding all shapes to plotter1, call plotter1.reset_camera() as now, but also compute the bounds of only the diff-coloured meshes (added + modified) and re-fit to those bounds.\n- For pass 2 (removed elements), the elements are already isolated in the iterator, so reset_camera() naturally fits to them. The pass 1 camera capture then needs to consider that pass 2 might be out of that frustum.\n- A combined approach: compute the bounding box union of added + modified + removed element meshes, then set the camera to fit that box with a small margin. This is the camera used for both passes.\n- pyvista Plotter has plotter.reset_camera(bounds=(xmin,xmax,ymin,ymax,zmin,zmax)) which can fit to an explicit box.\n- The bounds can be accumulated during the _add_shape loop for diff-coloured elements.\n\nAcceptance: a single modified wall in a large building renders with the wall clearly visible and filling most of the image.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:39Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:57:06Z","closed_at":"2026-04-25T06:57:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-zxs","title":"sandbox.py: add seccomp filter to block execve and socket in render subprocess","description":"The subprocess isolation in sandbox.py (ifcurl-vm5) prevents DoS but not RCE: if a crafted IFC file achieves code execution inside the render child, that child still has full access to execve and the network. A seccomp BPF filter applied at the start of _worker() would block the two most useful post-exploitation steps even under active code execution.\n\nThe filter should:\n- Block execve/execveat (cannot exec a shell or any binary)\n- Block socket() for AF_INET/AF_INET6/AF_UNIX (cannot open a reverse shell or connect to the Forgejo Unix socket)\n- Allow clone/fork (ifcopenshell's geom.iterator spawns its own worker processes)\n- Allow everything else needed by Python + ifcopenshell + pyvista\n\nThe python-seccomp library (pip install seccomp, wraps libseccomp) is the cleanest way to do this. The filter would be installed via resource.setrlimit companion code already present in sandbox._worker().\n\nThe main risk is over-filtering: if the seccomp policy blocks a syscall that ifcopenshell or pyvista needs, renders fail with EPERM. The policy needs to be validated against the full render path (including the geometry iterator subprocesses, which inherit the filter across fork).\n\nAcceptance criteria:\n- A render subprocess cannot exec /bin/sh even with arbitrary code execution (verified by replacing fn with a ctypes execve call — should get EPERM/killed, not a shell)\n- Normal renders continue to produce correct PNG output\n- The seccomp library is optional (graceful fallback if libseccomp is absent, e.g. on macOS or minimal containers)","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T20:47:00Z","created_by":"Bruno Postle","updated_at":"2026-04-24T20:47:00Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-zxs","title":"sandbox.py: add seccomp filter to block execve and socket in render subprocess","description":"The subprocess isolation in sandbox.py (ifcurl-vm5) prevents DoS but not RCE: if a crafted IFC file achieves code execution inside the render child, that child still has full access to execve and the network. A seccomp BPF filter applied at the start of _worker() would block the two most useful post-exploitation steps even under active code execution.\n\nThe filter should:\n- Block execve/execveat (cannot exec a shell or any binary)\n- Block socket() for AF_INET/AF_INET6/AF_UNIX (cannot open a reverse shell or connect to the Forgejo Unix socket)\n- Allow clone/fork (ifcopenshell's geom.iterator spawns its own worker processes)\n- Allow everything else needed by Python + ifcopenshell + pyvista\n\nThe python-seccomp library (pip install seccomp, wraps libseccomp) is the cleanest way to do this. The filter would be installed via resource.setrlimit companion code already present in sandbox._worker().\n\nThe main risk is over-filtering: if the seccomp policy blocks a syscall that ifcopenshell or pyvista needs, renders fail with EPERM. The policy needs to be validated against the full render path (including the geometry iterator subprocesses, which inherit the filter across fork).\n\nAcceptance criteria:\n- A render subprocess cannot exec /bin/sh even with arbitrary code execution (verified by replacing fn with a ctypes execve call — should get EPERM/killed, not a shell)\n- Normal renders continue to produce correct PNG output\n- The seccomp library is optional (graceful fallback if libseccomp is absent, e.g. on macOS or minimal containers)","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T20:47:00Z","created_by":"Bruno Postle","updated_at":"2026-04-25T07:30:44Z","closed_at":"2026-04-25T07:30:44Z","close_reason":"systemd hardening accepted as sufficient; execve cannot be blocked per-subprocess without non-standard seccomp library; residual risk acceptable for threat model","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-9cr","title":"ifc:// URLs in Forgejo issues and PR comments not documented as a collaboration tool","description":"Forgejo's markdown extension renders ifc:// URLs as clickable 3D preview images in file README views, but it works equally in issue bodies, PR descriptions, and comment threads — which is where the actual collaboration happens. This is potentially the most useful feature: a reviewer can paste an ifc:// URL in a PR comment and everyone in the thread sees the 3D view inline. This is not mentioned anywhere in README.md, forgejo/README.md, or SPECIFICATION.md. Add a 'Collaboration workflow' section to the README showing: (1) navigate to view in viewer, (2) copy URL from address bar, (3) paste as [label](ifc://...) in a Forgejo comment/issue, (4) collaborators click the preview image to open the view.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:44Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:24Z","started_at":"2026-04-24T11:14:59Z","closed_at":"2026-04-24T11:47:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-n00","title":"Forgejo markdown previews fail silently for private repos — no auth path from preview service","description":"The Forgejo markdown extension generates \u003cimg src='http://localhost:8000/preview?url=ifc://...'\u003e tags. The preview service fetches the IFC file server-side using GET /preview with no authentication. For private repos, this returns HTTP 404 or 403 and the preview image shows as broken. There is no mechanism for the Forgejo markdown renderer to pass the user's session token to the preview service. Options to investigate: (1) the Forgejo integration could pass a signed request token; (2) the preview service could run as the Forgejo git user (which has read access to all repos); (3) SSH-based fetch from the service using the Forgejo server's own key. The current state is a silent failure that will confuse users.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:39Z","created_by":"Bruno Postle","updated_at":"2026-04-24T12:39:46Z","started_at":"2026-04-24T11:56:30Z","closed_at":"2026-04-24T12:39:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-2y6","title":"Private Forgejo repos work in viewer via session cookie (same-origin) but this is undocumented and untested","description":"When the viewer is served at https://forgejo.example.com/assets/viewer.html, toRawUrl() constructs a same-origin URL like https://forgejo.example.com/org/repo/raw/branch/main/model.ifc. The browser automatically attaches the Forgejo session cookie to this fetch, so private repos on the same Forgejo instance should be accessible to logged-in users without any extra configuration. This is a significant capability that is not documented or tested. Needs: (1) a test or note confirming this works, (2) documentation in forgejo/README.md explaining that private repos work automatically for logged-in users when the viewer is served from the same Forgejo instance, (3) clear note that external repos (other Forgejo, GitHub, GitLab) have no auth path from the browser viewer.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:26Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:25Z","started_at":"2026-04-24T11:15:00Z","closed_at":"2026-04-24T11:47:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -89,7 +89,10 @@ def main() -> None:
)
render_parser.add_argument("url", help="The ifc:// URL to render")
render_parser.add_argument(
"-o", "--output", default="", metavar="FILE",
"-o",
"--output",
default="",
metavar="FILE",
help="Output PNG path (default: ifc-url-render.png)",
)
@ -98,16 +101,22 @@ def main() -> None:
help="Start the ifcurl preview HTTP service",
description=(
"Start an HTTP service that renders ifc:// URLs to PNG images on demand.\n"
"Accepts POST /preview with a JSON body {\"url\": \"ifc://...\"} and returns image/png.\n"
'Accepts POST /preview with a JSON body {"url": "ifc://..."} and returns image/png.\n'
"Results are cached: mutable refs (branches, HEAD) are cached in memory;\n"
"immutable refs (commit hashes, tags) are also cached to disk."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
serve_parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
serve_parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
serve_parser.add_argument(
"--allowed-hosts", default="", metavar="HOSTS",
"--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)"
)
serve_parser.add_argument(
"--port", type=int, default=8000, help="Bind port (default: 8000)"
)
serve_parser.add_argument(
"--allowed-hosts",
default="",
metavar="HOSTS",
help=(
"Comma-separated list of git hostnames (with optional :port) the service "
"is permitted to fetch from, e.g. 'github.com,gitlab.example.com:3000'. "
@ -127,14 +136,19 @@ def main() -> None:
)
cache_sub = cache_parser.add_subparsers(dest="cache_cmd")
cache_sub.add_parser("list", help="Show cached repos with size and last-access time")
cache_sub.add_parser(
"list", help="Show cached repos with size and last-access time"
)
prune_parser = cache_sub.add_parser(
"prune",
help="Remove oldest repos until cache is under the size limit",
)
prune_parser.add_argument(
"--max-gb", type=float, default=None, metavar="GB",
"--max-gb",
type=float,
default=None,
metavar="GB",
help=(
"Maximum total cache size in GB. Defaults to IFCURL_CACHE_MAX_GB "
"environment variable; required if that is not set."
@ -261,7 +275,10 @@ def _cmd_cache(args: argparse.Namespace) -> None:
if os.environ.get("IFCURL_CACHE_MAX_GB"):
max_gb = float(os.environ["IFCURL_CACHE_MAX_GB"])
else:
print("Error: --max-gb is required when IFCURL_CACHE_MAX_GB is not set", file=sys.stderr)
print(
"Error: --max-gb is required when IFCURL_CACHE_MAX_GB is not set",
file=sys.stderr,
)
sys.exit(1)
os.environ["IFCURL_CACHE_MAX_GB"] = str(max_gb)
before = sum(s for _, s, _, _ in _repo_cache_entries())

View file

@ -30,11 +30,10 @@ from __future__ import annotations
import io
import uuid
import zipfile
import xml.etree.ElementTree as ET
import zipfile
from datetime import datetime, timezone
_VERSION_XML = b"""\
<?xml version="1.0" encoding="utf-8"?>
<Version VersionId="2.1" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/buildingSMART/BCF-XML/release_2_1/Schemas/version.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
@ -96,7 +95,10 @@ def _viewpoint_xml(
_xyz(cp, "Location", clip[0], clip[1], clip[2])
_xyz(cp, "Direction", clip[3], clip[4], clip[5])
return b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode").encode()
return (
b'<?xml version="1.0" encoding="utf-8"?>\n'
+ ET.tostring(root, encoding="unicode").encode()
)
def _markup_xml(
@ -109,8 +111,9 @@ def _markup_xml(
description: str = "",
) -> bytes:
root = ET.Element("Markup")
topic = ET.SubElement(root, "Topic",
Guid=topic_guid, TopicType="Coordination", TopicStatus="Open")
topic = ET.SubElement(
root, "Topic", Guid=topic_guid, TopicType="Coordination", TopicStatus="Open"
)
ET.SubElement(topic, "Title").text = title or "IFC View"
if description:
ET.SubElement(topic, "Description").text = description
@ -128,7 +131,10 @@ def _markup_xml(
if vp_guid:
ET.SubElement(c, "Viewpoint", Guid=vp_guid)
return b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode").encode()
return (
b'<?xml version="1.0" encoding="utf-8"?>\n'
+ ET.tostring(root, encoding="unicode").encode()
)
def build_bcf(
@ -171,6 +177,8 @@ def build_bcf(
if vp_guid is not None:
zf.writestr(
f"{topic_guid}/viewpoint.bcfv",
_viewpoint_xml(vp_guid, camera, fov, scale, clips or [], guids, visibility),
_viewpoint_xml(
vp_guid, camera, fov, scale, clips or [], guids, visibility
),
)
return buf.getvalue()

View file

@ -77,7 +77,7 @@ def step_ids_from_diff(diff_text: str) -> DiffIds:
}
def expand_step_ids(model: "ifcopenshell.file", step_ids: DiffIds) -> DiffIds:
def expand_step_ids(model: ifcopenshell.file, step_ids: DiffIds) -> DiffIds:
"""Propagate changed step IDs to the IfcProduct entities that own them.
:param model: The *head* (newer) model, used to walk IFC relationships.

View file

@ -67,7 +67,9 @@ def fetch_ifc(ifc_url: IfcUrl, token: str | None = None) -> tuple[str, bytes]:
reached, or the file is not found at the specified ref.
"""
if not _HAS_GITPYTHON:
raise ImportError("GitPython is not installed. Install with: pip install gitpython")
raise ImportError(
"GitPython is not installed. Install with: pip install gitpython"
)
if ifc_url.path is None:
raise ValueError("URL has no 'path' parameter — cannot fetch IFC file")
@ -90,7 +92,9 @@ def diff_text(base_url: IfcUrl, head_url: IfcUrl, token: str | None = None) -> s
cannot be produced.
"""
if not _HAS_GITPYTHON:
raise ImportError("GitPython is not installed. Install with: pip install gitpython")
raise ImportError(
"GitPython is not installed. Install with: pip install gitpython"
)
if base_url.path is None or head_url.path is None:
raise ValueError("Both URLs must have a 'path' parameter")
if base_url.path != head_url.path:
@ -232,8 +236,11 @@ def _cache_dir_for(remote_url: str) -> Path:
def _auth_url(remote_url: str, token: str | None) -> str:
"""Return *remote_url* with the token injected, or the original URL."""
if token and (remote_url.startswith("https://") or remote_url.startswith("http://")):
if token and (
remote_url.startswith("https://") or remote_url.startswith("http://")
):
from ifcurl.auth import inject_token
return inject_token(remote_url, token)
return remote_url
@ -258,10 +265,14 @@ def _clone_bare(auth_url: str, git_dir: Path, remote_url: str) -> git.Repo:
return git.Repo.clone_from(fallback, str(git_dir), bare=True)
except git.exc.GitCommandError:
pass # report the original error
raise ValueError(f"Failed to clone {remote_url!r}: {exc.stderr.strip()}") from exc
raise ValueError(
f"Failed to clone {remote_url!r}: {exc.stderr.strip()}"
) from exc
def _open_remote(remote_url: str, is_mutable: bool, token: str | None = None) -> git.Repo:
def _open_remote(
remote_url: str, is_mutable: bool, token: str | None = None
) -> git.Repo:
"""Return a GitPython Repo for *remote_url*, cloning it if necessary.
Bare clones are stored under the OS cache dir keyed on the clean URL.
@ -309,7 +320,9 @@ def _open_remote(remote_url: str, is_mutable: bool, token: str | None = None) ->
return repo
def _read_commit_blob(repo: git.Repo, git_ref: str, file_path: str) -> tuple[str, bytes]:
def _read_commit_blob(
repo: git.Repo, git_ref: str, file_path: str
) -> tuple[str, bytes]:
"""Return ``(commit_hexsha, bytes)`` for *file_path* at *git_ref* in *repo*."""
try:
commit = repo.commit(git_ref)

View file

@ -58,8 +58,10 @@ except (ImportError, AttributeError):
_HAS_PYVISTA = False
try:
from PIL import Image as _PILImage
import io as _io
from PIL import Image as _PILImage
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
@ -114,10 +116,16 @@ def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings
settings.set("use-world-coords", True)
clearance_ids = {
c.id() for c in model.by_type("IfcGeometricRepresentationSubContext") if c.ContextIdentifier == "Clearance"
c.id()
for c in model.by_type("IfcGeometricRepresentationSubContext")
if c.ContextIdentifier == "Clearance"
}
if clearance_ids:
ctx_ids = [c.id() for c in model.by_type("IfcGeometricRepresentationContext") if c.id() not in clearance_ids]
ctx_ids = [
c.id()
for c in model.by_type("IfcGeometricRepresentationContext")
if c.id() not in clearance_ids
]
if ctx_ids:
settings.set("context-ids", ctx_ids)
@ -173,7 +181,12 @@ def _add_shape(
is_selected = selection_ids is not None and shape.product.id() in selection_ids
if diff_ids is None and visibility == "isolate" and selection_ids is not None and not is_selected:
if (
diff_ids is None
and visibility == "isolate"
and selection_ids is not None
and not is_selected
):
return
for midx, mat in enumerate(geom.materials):
@ -182,7 +195,9 @@ def _add_shape(
continue
sub_faces = faces[tri_mask]
faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel()
faces_pv = np.hstack(
[np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]
).ravel()
mesh = pv.PolyData(verts, faces_pv)
# Apply clipping planes — spec: normal points toward the visible side
@ -260,7 +275,14 @@ def _render_iterator(
while True:
try:
_add_shape(iterator.get(), plotter, frozen_ids, visibility, clips, diff_ids=diff_ids)
_add_shape(
iterator.get(),
plotter,
frozen_ids,
visibility,
clips,
diff_ids=diff_ids,
)
except Exception:
pass # skip broken shapes, keep rendering the rest
if not iterator.next():
@ -290,7 +312,9 @@ def _get_occurrence_class(type_entity: object) -> str:
return "IfcBuildingElementProxy"
def _make_type_occurrence(model: ifcopenshell.file, type_entity: object) -> object | None:
def _make_type_occurrence(
model: ifcopenshell.file, type_entity: object
) -> object | None:
rep_maps = getattr(type_entity, "RepresentationMaps", None) or []
if not rep_maps:
return None
@ -298,8 +322,14 @@ def _make_type_occurrence(model: ifcopenshell.file, type_entity: object) -> obje
mapped_items = []
for rep_map in rep_maps:
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
transform = model.create_entity("IfcCartesianTransformationOperator3D", LocalOrigin=origin)
mapped_items.append(model.create_entity("IfcMappedItem", MappingSource=rep_map, MappingTarget=transform))
transform = model.create_entity(
"IfcCartesianTransformationOperator3D", LocalOrigin=origin
)
mapped_items.append(
model.create_entity(
"IfcMappedItem", MappingSource=rep_map, MappingTarget=transform
)
)
context = rep_maps[0].MappedRepresentation.ContextOfItems
shape_rep = model.create_entity(
@ -309,14 +339,18 @@ def _make_type_occurrence(model: ifcopenshell.file, type_entity: object) -> obje
RepresentationType="MappedRepresentation",
Items=mapped_items,
)
prod_def = model.create_entity("IfcProductDefinitionShape", Representations=[shape_rep])
prod_def = model.create_entity(
"IfcProductDefinitionShape", Representations=[shape_rep]
)
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
axis2 = model.create_entity(
"IfcAxis2Placement3D",
Location=pt,
Axis=model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
RefDirection=model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
RefDirection=model.create_entity(
"IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)
),
)
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
@ -372,7 +406,9 @@ def _render_with_types(
include.extend(tmp.by_id(e.id()) for e in selector_elements)
settings = _build_geom_settings(tmp)
iterator = ifcopenshell.geom.iterator(settings, tmp, _WORKER_COUNT, include=include)
iterator = ifcopenshell.geom.iterator(
settings, tmp, _WORKER_COUNT, include=include
)
if not iterator.initialize():
raise ValueError("Type entities have no renderable geometry")
@ -387,7 +423,9 @@ def _render_with_types(
else:
new_highlight.append(hid)
return _render_iterator(iterator, new_highlight, visibility, clips, camera, fov, scale)
return _render_iterator(
iterator, new_highlight, visibility, clips, camera, fov, scale
)
finally:
try:
os.unlink(tmp_path)
@ -404,7 +442,9 @@ def render(
model: ifcopenshell.file,
selector: str | None = None,
element_ids: list[int] | None = None,
camera: tuple[float, float, float, float, float, float, float, float, float] | None = None,
camera: (
tuple[float, float, float, float, float, float, float, float, float] | None
) = None,
fov: float | None = None,
scale: float | None = None,
clips: list[tuple[float, float, float, float, float, float]] | None = None,
@ -434,7 +474,9 @@ def render(
renderable geometry.
"""
if not _HAS_PYVISTA:
raise ImportError("pyvista and numpy are required for rendering. Install with: pip install pyvista numpy")
raise ImportError(
"pyvista and numpy are required for rendering. Install with: pip install pyvista numpy"
)
clips = clips or []
@ -444,7 +486,9 @@ def render(
if not matched:
raise ValueError(f"Selector {selector!r} matched no elements")
types = [e for e in matched if e.is_a("IfcTypeProduct")]
selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")]
selector_elements: list | None = [
e for e in matched if not e.is_a("IfcTypeProduct")
]
else:
types = []
selector_elements = None
@ -469,8 +513,16 @@ def render(
# --- Delegate to temp-copy path when any type entities are involved ---
if types:
return _render_with_types(
model, types, selector_elements, selection_ids, type_highlight_ids,
visibility, clips, camera, fov, scale,
model,
types,
selector_elements,
selection_ids,
type_highlight_ids,
visibility,
clips,
camera,
fov,
scale,
)
# --- Regular element rendering ---
@ -486,14 +538,18 @@ def render(
else:
exclude = list(model.by_type("IfcOpeningElement"))
iterator = ifcopenshell.geom.iterator(
settings, model, _WORKER_COUNT,
settings,
model,
_WORKER_COUNT,
exclude=exclude if exclude else None,
)
if not iterator.initialize():
raise ValueError("No renderable geometry found")
return _render_iterator(iterator, selection_ids, visibility, clips, camera, fov, scale)
return _render_iterator(
iterator, selection_ids, visibility, clips, camera, fov, scale
)
def _entity_bounds(model: ifcopenshell.file, entities: list) -> list[float] | None:
@ -517,9 +573,14 @@ def _entity_bounds(model: ifcopenshell.file, entities: list) -> list[float] | No
if not all_verts:
return None
v = np.vstack(all_verts)
return [float(v[:, 0].min()), float(v[:, 0].max()),
float(v[:, 1].min()), float(v[:, 1].max()),
float(v[:, 2].min()), float(v[:, 2].max())]
return [
float(v[:, 0].min()),
float(v[:, 0].max()),
float(v[:, 1].min()),
float(v[:, 1].max()),
float(v[:, 2].min()),
float(v[:, 2].max()),
]
def _merge_bounds(a: list[float] | None, b: list[float] | None) -> list[float] | None:
@ -528,16 +589,23 @@ def _merge_bounds(a: list[float] | None, b: list[float] | None) -> list[float] |
return b
if b is None:
return a
return [min(a[0], b[0]), max(a[1], b[1]),
min(a[2], b[2]), max(a[3], b[3]),
min(a[4], b[4]), max(a[5], b[5])]
return [
min(a[0], b[0]),
max(a[1], b[1]),
min(a[2], b[2]),
max(a[3], b[3]),
min(a[4], b[4]),
max(a[5], b[5]),
]
def render_diff(
model_head: ifcopenshell.file,
model_base: ifcopenshell.file,
diff_ids: dict[str, set[int]],
camera: tuple[float, float, float, float, float, float, float, float, float] | None = None,
camera: (
tuple[float, float, float, float, float, float, float, float, float] | None
) = None,
fov: float | None = None,
scale: float | None = None,
clips: list[tuple[float, float, float, float, float, float]] | None = None,
@ -569,9 +637,13 @@ def render_diff(
:raises ValueError: If the head model has no renderable geometry.
"""
if not _HAS_PYVISTA:
raise ImportError("pyvista and numpy are required for rendering. Install with: pip install pyvista numpy")
raise ImportError(
"pyvista and numpy are required for rendering. Install with: pip install pyvista numpy"
)
if not _HAS_PIL:
raise ImportError("Pillow is required for diff rendering. Install with: pip install Pillow")
raise ImportError(
"Pillow is required for diff rendering. Install with: pip install Pillow"
)
clips = clips or []
@ -595,7 +667,9 @@ def render_diff(
# Both are fast because they operate on small subsets.
# ------------------------------------------------------------------
if camera is None:
changed_head_ids = frozen_diff.get("added", frozenset()) | frozen_diff.get("modified", frozenset())
changed_head_ids = frozen_diff.get("added", frozenset()) | frozen_diff.get(
"modified", frozenset()
)
changed_head_entities = []
for eid in changed_head_ids:
try:
@ -615,7 +689,9 @@ def render_diff(
settings_head = _build_geom_settings(model_head)
exclude_head = list(model_head.by_type("IfcOpeningElement"))
iterator_head = ifcopenshell.geom.iterator(
settings_head, model_head, _WORKER_COUNT,
settings_head,
model_head,
_WORKER_COUNT,
exclude=exclude_head if exclude_head else None,
)
if not iterator_head.initialize():
@ -626,7 +702,14 @@ def render_diff(
while True:
try:
_add_shape(iterator_head.get(), plotter1, None, "highlight", clips, diff_ids=frozen_diff)
_add_shape(
iterator_head.get(),
plotter1,
None,
"highlight",
clips,
diff_ids=frozen_diff,
)
except Exception:
pass
if not iterator_head.next():
@ -671,14 +754,25 @@ def render_diff(
if not iterator_base.initialize():
return pass1_png
removed_diff = {"added": frozenset(), "modified": frozenset(), "removed": frozenset(removed_ids)}
removed_diff = {
"added": frozenset(),
"modified": frozenset(),
"removed": frozenset(removed_ids),
}
plotter2 = _Plotter(off_screen=True, window_size=(1280, 960))
plotter2.background_color = "white"
while True:
try:
_add_shape(iterator_base.get(), plotter2, None, "highlight", clips, diff_ids=removed_diff)
_add_shape(
iterator_base.get(),
plotter2,
None,
"highlight",
clips,
diff_ids=removed_diff,
)
except Exception:
pass
if not iterator_base.next():

View file

@ -49,6 +49,7 @@ import os
try:
import resource as _resource
_HAS_RESOURCE = True
except ImportError:
_HAS_RESOURCE = False # Windows
@ -70,14 +71,18 @@ class SandboxTimeoutError(SandboxError):
"""Child process exceeded the allowed wall-clock time."""
def _worker(conn: multiprocessing.connection.Connection, fn, args: tuple, kwargs: dict) -> None:
def _worker(
conn: multiprocessing.connection.Connection, fn, args: tuple, kwargs: dict
) -> None:
"""Entry point for the sandboxed child process."""
if _HAS_RESOURCE:
if _SANDBOX_MEMORY_MB > 0:
limit = _SANDBOX_MEMORY_MB * 1024 * 1024
_resource.setrlimit(_resource.RLIMIT_AS, (limit, limit))
if _SANDBOX_CPU_SECS > 0:
_resource.setrlimit(_resource.RLIMIT_CPU, (_SANDBOX_CPU_SECS, _SANDBOX_CPU_SECS))
_resource.setrlimit(
_resource.RLIMIT_CPU, (_SANDBOX_CPU_SECS, _SANDBOX_CPU_SECS)
)
try:
result = fn(*args, **kwargs)
@ -123,8 +128,14 @@ def run_sandboxed(fn, *args, timeout: int = _SANDBOX_TIMEOUT, **kwargs):
except EOFError:
parent_conn.close()
proc.join()
sig = -proc.exitcode if (proc.exitcode is not None and proc.exitcode < 0) else proc.exitcode
raise SandboxCrashError(f"Subprocess terminated without sending result (exit {sig})")
sig = (
-proc.exitcode
if (proc.exitcode is not None and proc.exitcode < 0)
else proc.exitcode
)
raise SandboxCrashError(
f"Subprocess terminated without sending result (exit {sig})"
)
parent_conn.close()
proc.join()

View file

@ -40,7 +40,6 @@ import ipaddress
import os
import threading
import time
from collections import OrderedDict
from pathlib import Path
@ -60,6 +59,7 @@ from ifcurl.url import IfcUrl
# Subprocess pipeline functions (run inside run_sandboxed — no service imports)
# ---------------------------------------------------------------------------
def _sandboxed_pipeline(
ifc_bytes: bytes,
selector: str | None,
@ -77,6 +77,7 @@ def _sandboxed_pipeline(
"""
import ifcopenshell
import ifcopenshell.util.selector
from ifcurl import render as render_mod
model = ifcopenshell.file.from_string(ifc_bytes.decode())
@ -134,6 +135,7 @@ def _sandboxed_diff(
) -> bytes:
"""Parse both models, expand diff IDs, and render the two-pass diff image."""
import ifcopenshell
from ifcurl import render as render_mod
from ifcurl.diff import expand_step_ids, step_ids_from_diff
@ -182,7 +184,12 @@ def _is_private_ip(host: str) -> bool:
bare = host.split(":")[0].strip("[]") # strip port and IPv6 brackets
try:
addr = ipaddress.ip_address(bare)
return addr.is_loopback or addr.is_link_local or addr.is_private or addr.is_reserved
return (
addr.is_loopback
or addr.is_link_local
or addr.is_private
or addr.is_reserved
)
except ValueError:
return False
@ -190,12 +197,21 @@ def _is_private_ip(host: str) -> bool:
def _ssrf_check(ifc_url: IfcUrl) -> None:
"""Raise HTTPException if the URL fails SSRF protection checks."""
if ifc_url.transport == "local":
raise HTTPException(status_code=403, detail="Local file transport is not permitted in service mode")
raise HTTPException(
status_code=403,
detail="Local file transport is not permitted in service mode",
)
if _allowed_hosts is not None:
if ifc_url.host not in _allowed_hosts:
raise HTTPException(status_code=403, detail=f"Host {ifc_url.host!r} is not in the allowed-hosts list")
raise HTTPException(
status_code=403,
detail=f"Host {ifc_url.host!r} is not in the allowed-hosts list",
)
elif _is_private_ip(ifc_url.host):
raise HTTPException(status_code=403, detail="Requests to private/loopback addresses are not permitted")
raise HTTPException(
status_code=403,
detail="Requests to private/loopback addresses are not permitted",
)
# ---------------------------------------------------------------------------
@ -256,6 +272,7 @@ def _t3_put(hexsha: str, path: str, selector: str, guids: frozenset[str]) -> Non
# Tier 4: sha256(url) → PNG (filesystem, immutable refs only, no expiry)
# ---------------------------------------------------------------------------
def _t4_path(url: str) -> Path:
cache_dir = Path(user_cache_dir("ifcurl")) / "renders" / "immutable"
cache_dir.mkdir(parents=True, exist_ok=True)
@ -308,6 +325,7 @@ def _t4m_put(url: str, png: bytes) -> None:
# Request / response models
# ---------------------------------------------------------------------------
class PreviewRequest(BaseModel):
url: str
token: str | None = None
@ -437,7 +455,9 @@ def preview(request: PreviewRequest) -> Response:
ifc_url.visibility,
)
except SandboxCrashError as exc:
raise HTTPException(status_code=422, detail=f"IFC parse/render crashed: {exc}") from exc
raise HTTPException(
status_code=422, detail=f"IFC parse/render crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Render timed out: {exc}") from exc
except ImportError as exc:
@ -503,11 +523,17 @@ def bcf_export(request: BcfRequest) -> Response:
try:
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
except SandboxCrashError as exc:
raise HTTPException(status_code=422, detail=f"IFC parse/select crashed: {exc}") from exc
raise HTTPException(
status_code=422, detail=f"IFC parse/select crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Select timed out: {exc}") from exc
raise HTTPException(
status_code=503, detail=f"Select timed out: {exc}"
) from exc
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid selector: {exc}") from exc
raise HTTPException(
status_code=422, detail=f"Invalid selector: {exc}"
) from exc
bcf_bytes = build_bcf(
camera=ifc_url.camera,
@ -555,7 +581,9 @@ def render_diff(request: DiffRequest) -> Response:
for label, ifc_url in (("base", base_url), ("head", head_url)):
if ifc_url.path is None:
raise HTTPException(status_code=400, detail=f"{label} URL has no 'path' parameter")
raise HTTPException(
status_code=400, detail=f"{label} URL has no 'path' parameter"
)
if base_url.path != head_url.path:
raise HTTPException(
@ -626,9 +654,13 @@ def render_diff(request: DiffRequest) -> Response:
head_url.clips or [],
)
except SandboxCrashError as exc:
raise HTTPException(status_code=422, detail=f"IFC diff render crashed: {exc}") from exc
raise HTTPException(
status_code=422, detail=f"IFC diff render crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Diff render timed out: {exc}") from exc
raise HTTPException(
status_code=503, detail=f"Diff render timed out: {exc}"
) from exc
except ImportError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as exc:

View file

@ -44,7 +44,9 @@ class IfcUrl:
camera: tuple[float, float, float, float, float, float, float, float, float] | None
fov: float | None # perspective field of view in degrees
scale: float | None # orthographic view-to-world scale (BCF ViewToWorldScale)
clips: list[tuple[float, float, float, float, float, float]] = field(default_factory=list)
clips: list[tuple[float, float, float, float, float, float]] = field(
default_factory=list
)
visibility: str = "highlight" # 'highlight', 'ghost', or 'isolate'
@classmethod
@ -99,9 +101,13 @@ class IfcUrl:
try:
vals = [float(v) for v in qs["camera"][0].split(",")]
except ValueError as exc:
raise ValueError(f"'camera' contains non-numeric value: {qs['camera'][0]!r}") from exc
raise ValueError(
f"'camera' contains non-numeric value: {qs['camera'][0]!r}"
) from exc
if len(vals) != 9:
raise ValueError(f"'camera' requires exactly 9 values (px,py,pz,dx,dy,dz,ux,uy,uz), got {len(vals)}")
raise ValueError(
f"'camera' requires exactly 9 values (px,py,pz,dx,dy,dz,ux,uy,uz), got {len(vals)}"
)
camera = tuple(vals) # type: ignore[assignment]
fov: float | None = None
@ -109,17 +115,23 @@ class IfcUrl:
try:
fov = float(qs["fov"][0])
except ValueError as exc:
raise ValueError(f"'fov' must be a number, got {qs['fov'][0]!r}") from exc
raise ValueError(
f"'fov' must be a number, got {qs['fov'][0]!r}"
) from exc
scale: float | None = None
if "scale" in qs:
try:
scale = float(qs["scale"][0])
except ValueError as exc:
raise ValueError(f"'scale' must be a number, got {qs['scale'][0]!r}") from exc
raise ValueError(
f"'scale' must be a number, got {qs['scale'][0]!r}"
) from exc
if camera is not None and fov is None and scale is None:
raise ValueError("'camera' requires either 'fov' (perspective) or 'scale' (orthographic)")
raise ValueError(
"'camera' requires either 'fov' (perspective) or 'scale' (orthographic)"
)
if fov is not None and scale is not None:
raise ValueError("'fov' and 'scale' are mutually exclusive")
@ -128,14 +140,20 @@ class IfcUrl:
try:
vals = [float(v) for v in clip_str.split(",")]
except ValueError as exc:
raise ValueError(f"'clip' contains non-numeric value: {clip_str!r}") from exc
raise ValueError(
f"'clip' contains non-numeric value: {clip_str!r}"
) from exc
if len(vals) != 6:
raise ValueError(f"'clip' requires exactly 6 values (px,py,pz,nx,ny,nz), got {len(vals)}")
raise ValueError(
f"'clip' requires exactly 6 values (px,py,pz,nx,ny,nz), got {len(vals)}"
)
clips.append(tuple(vals)) # type: ignore[arg-type]
visibility_raw = qs.get("visibility", ["highlight"])[0]
if visibility_raw not in ("highlight", "ghost", "isolate"):
raise ValueError(f"Unknown visibility mode {visibility_raw!r}; expected 'highlight', 'ghost', or 'isolate'")
raise ValueError(
f"Unknown visibility mode {visibility_raw!r}; expected 'highlight', 'ghost', or 'isolate'"
)
return cls(
transport=transport,

View file

@ -19,8 +19,12 @@ import pytest
def _suppress_owner(f):
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
ifcopenshell.api.owner.settings.get_user = lambda ifc: (
ifc.by_type("IfcPersonAndOrganization") or [None]
)[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (
ifc.by_type("IfcApplication") or [None]
)[0]
return f
@ -30,32 +34,59 @@ def model_with_geometry():
f = ifcopenshell.api.project.create_file()
_suppress_owner(f)
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
project = ifcopenshell.api.root.create_entity(
f, ifc_class="IfcProject", name="TestProject"
)
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
building = ifcopenshell.api.root.create_entity(
f, ifc_class="IfcBuilding", name="TestBuilding"
)
storey = ifcopenshell.api.root.create_entity(
f, ifc_class="IfcBuildingStorey", name="Ground Floor"
)
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
ifcopenshell.api.aggregate.assign_object(
f, products=[site], relating_object=project
)
ifcopenshell.api.aggregate.assign_object(
f, products=[building], relating_object=site
)
ifcopenshell.api.aggregate.assign_object(
f, products=[storey], relating_object=building
)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body",
target_view="MODEL_VIEW", parent=model_ctx,
f,
context_type="Model",
context_identifier="Body",
target_view="MODEL_VIEW",
parent=model_ctx,
)
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1)
ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey)
rep1 = ifcopenshell.api.geometry.add_wall_representation(
f, context=body, length=5, height=3, thickness=0.2
)
ifcopenshell.api.geometry.assign_representation(
f, product=wall1, representation=rep1
)
ifcopenshell.api.spatial.assign_container(
f, products=[wall1], relating_structure=storey
)
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002")
rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=4, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2)
ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey)
rep2 = ifcopenshell.api.geometry.add_wall_representation(
f, context=body, length=4, height=3, thickness=0.2
)
ifcopenshell.api.geometry.assign_representation(
f, product=wall2, representation=rep2
)
ifcopenshell.api.spatial.assign_container(
f, products=[wall2], relating_structure=storey
)
matrix = np.eye(4)
matrix[1, 3] = 3.0
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix)

View file

@ -23,7 +23,9 @@ class TestGetTokenForHost:
assert get_token_for_host("gitlab.com") is None
def test_returns_none_when_file_missing(self, tmp_path, monkeypatch):
monkeypatch.setattr("ifcurl.auth.config_path", lambda: tmp_path / "nonexistent.json")
monkeypatch.setattr(
"ifcurl.auth.config_path", lambda: tmp_path / "nonexistent.json"
)
assert get_token_for_host("github.com") is None
def test_returns_none_on_malformed_json(self, tmp_path, monkeypatch):
@ -40,12 +42,16 @@ class TestGetTokenForHost:
def test_multiple_hosts(self, tmp_path, monkeypatch):
config = tmp_path / "tokens.json"
config.write_text(json.dumps({
config.write_text(
json.dumps(
{
"hosts": {
"github.com": "ghp_token",
"gitlab.example.com": "glpat_token",
}
}))
}
)
)
monkeypatch.setattr("ifcurl.auth.config_path", lambda: config)
assert get_token_for_host("github.com") == "ghp_token"
assert get_token_for_host("gitlab.example.com") == "glpat_token"

View file

@ -168,9 +168,9 @@ class TestBcfEndpoint:
assert not any(n.endswith("viewpoint.bcfv") for n in names)
def test_title_and_comment_in_markup(self):
r = client.post("/bcf", json={
"url": CAMERA_URL, "title": "My issue", "comment": "Fix this"
})
r = client.post(
"/bcf", json={"url": CAMERA_URL, "title": "My issue", "comment": "Fix this"}
)
markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf"))
content = _zip_read(r.content, markup)
assert "My issue" in content
@ -193,7 +193,9 @@ class TestBcfEndpoint:
assert r.status_code == 403
def test_ssrf_private_ip_rejected(self):
r = client.post("/bcf", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"})
r = client.post(
"/bcf", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"}
)
assert r.status_code == 403
def test_invalid_url_returns_400(self):
@ -206,7 +208,9 @@ class TestBcfEndpoint:
def mock_fetch(ifc_url, token=None):
return FAKE_HEXSHA, ifc_bytes
selector_url = MUTABLE_URL.replace("?path=model.ifc", "?path=model.ifc&selector=IfcWall")
selector_url = MUTABLE_URL.replace(
"?path=model.ifc", "?path=model.ifc&selector=IfcWall"
)
with patch("ifcurl.service.fetch_ifc", mock_fetch):
r = client.post("/bcf", json={"url": selector_url})
assert r.status_code == 200

View file

@ -6,7 +6,6 @@ import pytest
from ifcurl.diff import expand_step_ids, step_ids_from_diff
# ---------------------------------------------------------------------------
# step_ids_from_diff
# ---------------------------------------------------------------------------
@ -59,7 +58,11 @@ class TestStepIdsFromDiff:
def test_context_lines_ignored(self):
diff = " #5=IFCSITE('site');\n"
ids = step_ids_from_diff(diff)
assert 5 not in ids["added"] and 5 not in ids["removed"] and 5 not in ids["modified"]
assert (
5 not in ids["added"]
and 5 not in ids["removed"]
and 5 not in ids["modified"]
)
def test_git_header_lines_ignored(self):
diff = "--- a/model.ifc\n+++ b/model.ifc\n"

View file

@ -2,6 +2,8 @@
from __future__ import annotations
import time
import pytest
from ifcurl.git import fetch_ifc, fetch_ifc_bytes
@ -45,6 +47,7 @@ class TestFetchIfc:
def test_branch_ref(self, local_ifc_repo):
import git as gitpkg
branch = gitpkg.Repo(local_ifc_repo["path"]).active_branch.name
url = _local_url(local_ifc_repo["path"], ref=f"heads/{branch}")
hexsha, data = fetch_ifc(url)
@ -107,9 +110,10 @@ class TestInvalidRepo:
from ifcurl.git import _dir_size, _evict_if_needed, _repo_cache_entries, _touch_cache
def _make_fake_repo_cache(base: "Path", url: str, size_bytes: int = 1024) -> "Path":
def _make_fake_repo_cache(base: Path, url: str, size_bytes: int = 1024) -> Path:
"""Create a fake cached-repo directory structure under *base*."""
import hashlib
key = hashlib.sha256(url.encode()).hexdigest()[:24]
entry = base / key
git_dir = entry / "repo.git"
@ -128,6 +132,7 @@ class TestCacheHelpers:
def test_touch_cache_updates_mtime(self, tmp_path):
import time
old_mtime = tmp_path.stat().st_mtime
time.sleep(0.05)
_touch_cache(tmp_path)
@ -151,9 +156,13 @@ class TestCacheHelpers:
def test_evict_removes_oldest_over_limit(self, tmp_path, monkeypatch):
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
monkeypatch.setenv("IFCURL_CACHE_MAX_GB", "0.000001") # ~1 KB limit
old = _make_fake_repo_cache(tmp_path, "https://old.example.com/repo", size_bytes=512)
import time; time.sleep(0.05)
new = _make_fake_repo_cache(tmp_path, "https://new.example.com/repo", size_bytes=512)
old = _make_fake_repo_cache(
tmp_path, "https://old.example.com/repo", size_bytes=512
)
time.sleep(0.05)
new = _make_fake_repo_cache(
tmp_path, "https://new.example.com/repo", size_bytes=512
)
_evict_if_needed()
assert not old.exists()
assert new.exists()

View file

@ -8,6 +8,7 @@ from ifcurl.render import render
try:
import pyvista # noqa: F401
HAS_PYVISTA = True
except ImportError:
HAS_PYVISTA = False
@ -38,16 +39,26 @@ class TestRenderBasic:
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: None
ifcopenshell.api.owner.settings.get_application = lambda ifc: None
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
project = ifcopenshell.api.root.create_entity(
f, ifc_class="IfcProject", name="P"
)
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
ifcopenshell.api.aggregate.assign_object(
f, products=[site], relating_object=project
)
ifcopenshell.api.aggregate.assign_object(
f, products=[building], relating_object=site
)
ifcopenshell.api.aggregate.assign_object(
f, products=[storey], relating_object=building
)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="W")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
ifcopenshell.api.spatial.assign_container(
f, products=[wall], relating_structure=storey
)
with pytest.raises(ValueError, match="No renderable geometry"):
render(f)
@ -140,24 +151,34 @@ class TestRenderClips:
class TestRenderVisibility:
def test_highlight_mode(self, model_with_geometry):
wall = model_with_geometry.by_type("IfcWall")[0]
result = render(model_with_geometry, element_ids=[wall.id()], visibility="highlight")
result = render(
model_with_geometry, element_ids=[wall.id()], visibility="highlight"
)
assert result[:4] == PNG_MAGIC
def test_ghost_mode(self, model_with_geometry):
wall = model_with_geometry.by_type("IfcWall")[0]
result = render(model_with_geometry, element_ids=[wall.id()], visibility="ghost")
result = render(
model_with_geometry, element_ids=[wall.id()], visibility="ghost"
)
assert result[:4] == PNG_MAGIC
def test_isolate_mode(self, model_with_geometry):
wall = model_with_geometry.by_type("IfcWall")[0]
result = render(model_with_geometry, element_ids=[wall.id()], visibility="isolate")
result = render(
model_with_geometry, element_ids=[wall.id()], visibility="isolate"
)
assert result[:4] == PNG_MAGIC
def test_visibility_modes_produce_different_images(self, model_with_geometry):
wall = model_with_geometry.by_type("IfcWall")[0]
highlight = render(model_with_geometry, element_ids=[wall.id()], visibility="highlight")
highlight = render(
model_with_geometry, element_ids=[wall.id()], visibility="highlight"
)
ghost = render(model_with_geometry, element_ids=[wall.id()], visibility="ghost")
isolate = render(model_with_geometry, element_ids=[wall.id()], visibility="isolate")
isolate = render(
model_with_geometry, element_ids=[wall.id()], visibility="isolate"
)
# All three are valid PNG but should differ in content
assert highlight[:4] == ghost[:4] == isolate[:4] == PNG_MAGIC
assert len({highlight, ghost, isolate}) > 1

View file

@ -29,6 +29,7 @@ def _segfault():
def _sleep_forever():
import time
time.sleep(9999)

View file

@ -9,7 +9,14 @@ from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from ifcurl.service import _t2_cache, _t3_cache, _t2_get, _t2_put, app, configure_allowed_hosts
from ifcurl.service import (
_t2_cache,
_t2_get,
_t2_put,
_t3_cache,
app,
configure_allowed_hosts,
)
client = TestClient(app)
@ -75,12 +82,16 @@ class TestValidation:
assert r.status_code == 422
def test_url_without_path_param_returns_400(self):
r = client.post("/preview", json={"url": "ifc://example.com/org/repo@heads/main"})
r = client.post(
"/preview", json={"url": "ifc://example.com/org/repo@heads/main"}
)
assert r.status_code == 400
assert "path" in r.json()["detail"]
def test_malformed_url_returns_400(self):
r = client.post("/preview", json={"url": "ifc://example.com/repo_no_ref?path=m.ifc"})
r = client.post(
"/preview", json={"url": "ifc://example.com/repo_no_ref?path=m.ifc"}
)
assert r.status_code == 400
@ -92,8 +103,10 @@ class TestValidation:
class TestPreviewRender:
def test_returns_png_content_type(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
r = client.post("/preview", json={"url": MUTABLE_URL})
assert r.status_code == 200
assert r.headers["content-type"] == "image/png"
@ -106,8 +119,10 @@ class TestPreviewRender:
def test_render_error_returns_422(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", side_effect=ValueError("No geometry")):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", side_effect=ValueError("No geometry")),
):
r = client.post("/preview", json={"url": MUTABLE_URL})
assert r.status_code == 422
@ -120,8 +135,10 @@ class TestPreviewRender:
class TestTier2Cache:
def test_bytes_cached_after_first_request(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
client.post("/preview", json={"url": MUTABLE_URL})
assert (FAKE_HEXSHA, "model.ifc") in _t2_cache
@ -137,8 +154,10 @@ class TestTier2Cache:
# Disable tier-4m so both requests reach fetch_ifc, letting us
# verify that tier-2 serves ifc_bytes from cache on the second call.
monkeypatch.setattr("ifcurl.service._T4M_TTL", 0)
with patch("ifcurl.service.fetch_ifc", counting_fetch), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", counting_fetch),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
client.post("/preview", json={"url": MUTABLE_URL})
client.post("/preview", json={"url": MUTABLE_URL})
# fetch_ifc still called both times (for the commit hexsha),
@ -155,15 +174,19 @@ class TestTier2Cache:
class TestTier3Cache:
def test_guids_cached_after_selector_request(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
client.post("/preview", json={"url": SELECTOR_URL})
assert (FAKE_HEXSHA, "model.ifc", "IfcWall") in _t3_cache
def test_cached_guids_are_frozenset(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
client.post("/preview", json={"url": SELECTOR_URL})
cached = _t3_cache[(FAKE_HEXSHA, "model.ifc", "IfcWall")]
assert isinstance(cached, frozenset)
@ -171,16 +194,20 @@ class TestTier3Cache:
def test_second_selector_request_skips_filter_elements(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
filter_call_count = 0
real_filter = __import__("ifcopenshell.util.selector", fromlist=["filter_elements"]).filter_elements
real_filter = __import__(
"ifcopenshell.util.selector", fromlist=["filter_elements"]
).filter_elements
def counting_filter(model, selector):
nonlocal filter_call_count
filter_call_count += 1
return real_filter(model, selector)
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG), \
patch("ifcopenshell.util.selector.filter_elements", counting_filter):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
patch("ifcopenshell.util.selector.filter_elements", counting_filter),
):
client.post("/preview", json={"url": SELECTOR_URL})
client.post("/preview", json={"url": SELECTOR_URL})
@ -202,8 +229,10 @@ class TestTier4Cache:
render_call_count += 1
return FAKE_PNG
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", counting_render):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", counting_render),
):
r1 = client.post("/preview", json={"url": IMMUTABLE_URL})
r2 = client.post("/preview", json={"url": IMMUTABLE_URL})
@ -211,7 +240,9 @@ class TestTier4Cache:
assert r2.content == FAKE_PNG
assert render_call_count == 1 # render only called once
def test_png_cached_for_mutable_ref_within_ttl(self, tmp_t4_cache, model_with_geometry):
def test_png_cached_for_mutable_ref_within_ttl(
self, tmp_t4_cache, model_with_geometry
):
ifc_bytes = model_with_geometry.to_string().encode()
render_call_count = 0
@ -220,14 +251,18 @@ class TestTier4Cache:
render_call_count += 1
return FAKE_PNG
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", counting_render):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", counting_render),
):
client.post("/preview", json={"url": MUTABLE_URL})
client.post("/preview", json={"url": MUTABLE_URL})
assert render_call_count == 1 # second request served from tier-4m cache
def test_png_not_cached_for_mutable_ref_after_ttl(self, tmp_t4_cache, model_with_geometry, monkeypatch):
def test_png_not_cached_for_mutable_ref_after_ttl(
self, tmp_t4_cache, model_with_geometry, monkeypatch
):
ifc_bytes = model_with_geometry.to_string().encode()
render_call_count = 0
@ -237,8 +272,10 @@ class TestTier4Cache:
return FAKE_PNG
monkeypatch.setattr("ifcurl.service._T4M_TTL", 0)
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", counting_render):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", counting_render),
):
client.post("/preview", json={"url": MUTABLE_URL})
client.post("/preview", json={"url": MUTABLE_URL})
@ -246,8 +283,10 @@ class TestTier4Cache:
def test_tier4_cache_file_exists_on_disk(self, tmp_t4_cache, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
client.post("/preview", json={"url": IMMUTABLE_URL})
png_files = list(tmp_t4_cache.rglob("*.png"))
assert len(png_files) == 1
@ -268,13 +307,17 @@ class TestAuthentication:
received_token.append(token)
return FAKE_HEXSHA, ifc_bytes
with patch("ifcurl.service.fetch_ifc", capturing_fetch), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", capturing_fetch),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
client.post("/preview", json={"url": MUTABLE_URL, "token": "mytoken123"})
assert received_token == ["mytoken123"]
def test_config_token_used_when_no_request_token(self, model_with_geometry, monkeypatch):
def test_config_token_used_when_no_request_token(
self, model_with_geometry, monkeypatch
):
ifc_bytes = model_with_geometry.to_string().encode()
received_token = []
@ -282,14 +325,20 @@ class TestAuthentication:
received_token.append(token)
return FAKE_HEXSHA, ifc_bytes
monkeypatch.setattr("ifcurl.service.get_token_for_host", lambda host: "config_token")
with patch("ifcurl.service.fetch_ifc", capturing_fetch), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
monkeypatch.setattr(
"ifcurl.service.get_token_for_host", lambda host: "config_token"
)
with (
patch("ifcurl.service.fetch_ifc", capturing_fetch),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
client.post("/preview", json={"url": MUTABLE_URL})
assert received_token == ["config_token"]
def test_request_token_overrides_config_token(self, model_with_geometry, monkeypatch):
def test_request_token_overrides_config_token(
self, model_with_geometry, monkeypatch
):
ifc_bytes = model_with_geometry.to_string().encode()
received_token = []
@ -297,9 +346,13 @@ class TestAuthentication:
received_token.append(token)
return FAKE_HEXSHA, ifc_bytes
monkeypatch.setattr("ifcurl.service.get_token_for_host", lambda host: "config_token")
with patch("ifcurl.service.fetch_ifc", capturing_fetch), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
monkeypatch.setattr(
"ifcurl.service.get_token_for_host", lambda host: "config_token"
)
with (
patch("ifcurl.service.fetch_ifc", capturing_fetch),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
client.post("/preview", json={"url": MUTABLE_URL, "token": "request_token"})
assert received_token == ["request_token"]
@ -313,8 +366,10 @@ class TestAuthentication:
class TestGetEndpoint:
def test_get_returns_png(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
r = client.get("/preview", params={"url": MUTABLE_URL})
assert r.status_code == 200
assert r.headers["content-type"] == "image/png"
@ -336,8 +391,10 @@ class TestGetEndpoint:
received["token"] = token
return FAKE_HEXSHA, ifc_bytes
with patch("ifcurl.service.fetch_ifc", mock_fetch), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", mock_fetch),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
r = client.get("/preview", params={"url": MUTABLE_URL, "token": "mytoken"})
assert r.status_code == 200
assert received["token"] == "mytoken"
@ -392,36 +449,49 @@ class TestSSRF:
def test_host_in_allowlist_permitted(self, model_with_geometry):
configure_allowed_hosts({"example.com"})
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
r = client.post("/preview", json={"url": MUTABLE_URL})
assert r.status_code == 200
def test_no_allowlist_permits_public_host(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
r = client.post("/preview", json={"url": MUTABLE_URL})
assert r.status_code == 200
def test_loopback_ip_rejected_without_allowlist(self):
r = client.post("/preview", json={"url": "ifc://127.0.0.1/org/repo@HEAD?path=m.ifc"})
r = client.post(
"/preview", json={"url": "ifc://127.0.0.1/org/repo@HEAD?path=m.ifc"}
)
assert r.status_code == 403
def test_private_ip_rejected_without_allowlist(self):
r = client.post("/preview", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"})
r = client.post(
"/preview", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"}
)
assert r.status_code == 403
def test_link_local_ip_rejected_without_allowlist(self):
r = client.post("/preview", json={"url": "ifc://169.254.169.254/org/repo@HEAD?path=m.ifc"})
r = client.post(
"/preview", json={"url": "ifc://169.254.169.254/org/repo@HEAD?path=m.ifc"}
)
assert r.status_code == 403
def test_allowlist_with_port(self, model_with_geometry):
configure_allowed_hosts({"localhost:3000"})
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.render.render", return_value=FAKE_PNG):
r = client.post("/preview", json={
"url": "ifc://localhost:3000/org/repo@heads/main?path=model.ifc"
})
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcurl.render.render", return_value=FAKE_PNG),
):
r = client.post(
"/preview",
json={"url": "ifc://localhost:3000/org/repo@heads/main?path=model.ifc"},
)
assert r.status_code == 200

View file

@ -103,7 +103,9 @@ def test_selector():
def test_selector_percent_encoded():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall%2C%2BName%3D%22X%22")
url = IfcUrl.parse(
"ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall%2C%2BName%3D%22X%22"
)
assert url.selector == 'IfcWall,+Name="X"'
@ -218,7 +220,9 @@ def test_immutable_tag():
def test_spec_example_default_view():
url = IfcUrl.parse("ifc://example.com/org/project@heads/main?path=models/building.ifc")
url = IfcUrl.parse(
"ifc://example.com/org/project@heads/main?path=models/building.ifc"
)
assert url.transport == "https"
assert url.ref == "heads/main"
assert url.path == "models/building.ifc"
@ -228,10 +232,10 @@ def test_spec_example_default_view():
def test_spec_example_perspective():
url = IfcUrl.parse(
'ifc://git@example.com/org/project@abc123def'
'?path=models/building.ifc'
'&selector=IfcWall%2C%2BName%3D%22Core%2BWall%22'
'&camera=10,20,5,0,-1,0,0,0,1&fov=60&visibility=ghost'
"ifc://git@example.com/org/project@abc123def"
"?path=models/building.ifc"
"&selector=IfcWall%2C%2BName%3D%22Core%2BWall%22"
"&camera=10,20,5,0,-1,0,0,0,1&fov=60&visibility=ghost"
)
assert url.transport == "ssh"
assert url.fov == 60.0