From 6f1737bb58bc1eb8292e727257740e2bcba01e23 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Fri, 3 Jul 2026 21:43:11 +0100 Subject: [PATCH] Feature #5753 - Autosave for ifc files Implemented as described in #5753, with two options: - A nag dialog with save or cancel options. - An autosaved file. Settings are in preference to activate the feature (default: off), the period before prompting/saving, and choosing between the two methods. Prevent the autosave file being added to the recent files list when the user opens the original, but selects to open the autosaved version. black/ruff This commit was created using AI assistance. Cursor for the initial code, then Grok and I fixing all the errors that Cursor made. Finally Copilot did a code review. I have reviewed and tested the code, and I understand it, and it works and does not introduce any obvious bugs. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Grok Co-authored-by: Cursor --- src/bonsai/bonsai/bim/handler.py | 2 + .../bonsai/bim/module/project/__init__.py | 5 + .../bonsai/bim/module/project/operator.py | 166 +++++++++++++++++- src/bonsai/bonsai/bim/ui.py | 46 +++++ src/bonsai/bonsai/tool/__init__.py | 3 + src/bonsai/bonsai/tool/autosave.py | 149 ++++++++++++++++ .../test/bim/module/project/test_autosave.py | 68 +++++++ 7 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 src/bonsai/bonsai/tool/autosave.py create mode 100644 src/bonsai/test/bim/module/project/test_autosave.py diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index a7fc1dff67..c8f91ed6ee 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -320,9 +320,11 @@ def loadIfcStore(scene: bpy.types.Scene) -> None: IfcStore.purge() refresh_ui_data() if not tool.Ifc.get(): + tool.Autosave.cancel_timer() return tool.Ifc.schema() IfcStore.relink_all_objects() + tool.Autosave.reset_timer() @persistent diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 945cac5f66..caa9458471 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -18,6 +18,7 @@ import bpy +import bonsai.tool as tool from . import decorator, gizmo, operator, prop, ui, workspace classes = ( @@ -58,6 +59,9 @@ classes = ( operator.LinkIfc, operator.LoadBlendMetadataAndIFC, operator.LoadLink, + operator.AutosavePrompt, + operator.LoadAutosavedRecoveryPopup, + operator.LoadAutosavedRecovery, operator.LoadLinkedProject, operator.LoadProject, operator.LoadProjectElements, @@ -136,6 +140,7 @@ def register(): def unregister(): if not bpy.app.background: bpy.utils.unregister_tool(workspace.ExploreTool) + tool.Autosave.cancel_timer() del bpy.types.Scene.BIMProjectProperties del bpy.types.Scene.MeasureToolSettings bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index b0d9fc166d..d289df44c0 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -985,8 +985,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): ), default=False, ) + skip_autosave_recovery: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"}) filename_ext = ".ifc" + skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) if TYPE_CHECKING: filepath: str @@ -995,6 +997,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): use_relative_path: bool should_start_fresh_session: bool import_without_ifc_data: bool + skip_autosave_recovery: bool use_detailed_tooltip: bool @classmethod @@ -1041,7 +1044,26 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): return tooltip + def check_autosave_recovery(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"] | None: + if self.skip_autosave_recovery: + return None + autosaved_filepath = tool.Autosave.get_newer_autosaved_path(self.get_filepath_abs()) + if not autosaved_filepath: + return None + return bpy.ops.bim.load_autosaved_recovery_popup( + "INVOKE_DEFAULT", + original_filepath=str(self.get_filepath_abs()), + autosaved_filepath=autosaved_filepath, + is_advanced=self.is_advanced, + use_relative_path=self.use_relative_path, + should_start_fresh_session=self.should_start_fresh_session, + import_without_ifc_data=self.import_without_ifc_data, + ) + def execute(self, context): + if recovery := self.check_autosave_recovery(context): + return recovery + if ( tool.Blender.get_addon_preferences().save_metadata_blend_file and self.should_start_fresh_session @@ -1136,7 +1158,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): props.should_save_metadata_for_this_file = metadata_doc is not None tool.Blender.register_toolbar() - tool.Project.add_recent_ifc_project(self.get_filepath_abs()) + if not self.skip_recent: + tool.Project.add_recent_ifc_project(self.get_filepath_abs()) if self.is_advanced: pass @@ -1149,10 +1172,13 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): except: bonsai.last_error = traceback.format_exc() raise + tool.Autosave.reset_timer() return {"FINISHED"} def invoke(self, context, event): if self.filepath: + if recovery := self.check_autosave_recovery(context): + return recovery return self.execute(context) return ImportHelper.invoke(self, context, event) @@ -1947,6 +1973,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False) should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) + skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) if TYPE_CHECKING: filter_glob: str @@ -2007,6 +2034,18 @@ class ExportIFC(bpy.types.Operator, ExportHelper): return {"FINISHED"} def _execute(self, context): + project_props = tool.Project.get_project_props() + project_props.use_relative_project_path = self.use_relative_path + + # Fallback if filepath is not set + if not getattr(self, "filepath", None) or self.filepath.strip() in ("", ".ifc"): + props = tool.Blender.get_bim_props() + if props.ifc_file: + self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file))) + else: + self.report({"ERROR"}, "No filepath available for saving.") + return {"CANCELLED"} + committed, failed_commits = tool.Parametric.commit_pending_edits() # Previews are session-transient — discard rather than commit. Sibling # gizmo polls gate on each preview's is_active flag, and a stuck flag @@ -2069,7 +2108,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper): settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start)) print("Export finished in {:.2f} seconds".format(time.time() - start)) # New project created in Bonsai should be in recent projects too. - tool.Project.add_recent_ifc_project(Path(output_file)) + if not self.skip_recent: + tool.Project.add_recent_ifc_project(Path(output_file)) props = tool.Project.get_project_props() if props.use_relative_project_path and bpy.data.is_saved: output_file = os.path.relpath(output_file, bpy.path.abspath("//")) @@ -2103,6 +2143,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): ) bonsai.bim.handler.refresh_ui_data() + tool.Autosave.reset_timer() @classmethod def description(cls, context, properties): @@ -2111,6 +2152,127 @@ class ExportIFC(bpy.types.Operator, ExportHelper): return "Save the IFC file. Will save both .IFC/.BLEND files if synced together" +class LoadAutosavedRecoveryPopup(bpy.types.Operator): + bl_idname = "bim.load_autosaved_recovery_popup" + bl_label = "Recover Autosaved File" + bl_options = {"REGISTER", "UNDO"} + + original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) + autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) + is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) + import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + + def draw(self, context): + layout = self.layout + layout.label(text="A newer autosaved copy was found:", icon="INFO") + layout.label(text=os.path.basename(self.autosaved_filepath)) + layout.separator() + layout.label(text=f"Original: {os.path.basename(self.original_filepath)}") + layout.label(text="Which one do you want to load?") + + row = layout.row(align=True) + op = row.operator("bim.load_autosaved_recovery", text="Load Original", icon="LOOP_BACK") + op.file_type = "ORIGINAL" + self._pass_props(op) + + op = row.operator("bim.load_autosaved_recovery", text="Load Autosave", icon="LOOP_FORWARDS") + op.file_type = "AUTOSAVE" + self._pass_props(op) + + def _pass_props(self, op): + op.original_filepath = self.original_filepath + op.autosaved_filepath = self.autosaved_filepath + op.is_advanced = self.is_advanced + op.use_relative_path = self.use_relative_path + op.should_start_fresh_session = self.should_start_fresh_session + op.import_without_ifc_data = self.import_without_ifc_data + + def invoke(self, context, event): + return context.window_manager.invoke_popup(self, width=420) + + def execute(self, context): + # This should almost never run + self.report({"INFO"}, "Popup closed without choosing") + return {"FINISHED"} + + +class LoadAutosavedRecovery(bpy.types.Operator): + bl_idname = "bim.load_autosaved_recovery" + bl_label = "Recover Autosaved File" + bl_options = {"REGISTER", "UNDO"} + + file_type: bpy.props.StringProperty(default="AUTOSAVE") + original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) + autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"}) + is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) + import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + + def execute(self, context): + if self.file_type == "ORIGINAL": + filepath = self.original_filepath + else: + filepath = self.autosaved_filepath + + # Call the real loader + result = bpy.ops.bim.load_project( + filepath=filepath, + skip_autosave_recovery=True, # Prevent infinite loop + is_advanced=self.is_advanced, + use_relative_path=self.use_relative_path, + should_start_fresh_session=self.should_start_fresh_session, + import_without_ifc_data=self.import_without_ifc_data, + skip_recent=(self.file_type == "AUTOSAVE") + ) + + # If user chose autosave, override the stored path + if self.file_type == "AUTOSAVE": + tool.Ifc.set_path(self.original_filepath) + + return result + + +class AutosavePrompt(bpy.types.Operator): + bl_idname = "bim.autosave_prompt" + bl_label = "Autosave Reminder" + bl_options = set() + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog( + self, width=400, confirm_text="Save", title="Autosave Reminder" + ) + + def draw(self, context): + layout = self.layout + layout.label(text="The autosave timer has expired.", icon="INFO") + layout.label(text="Would you like to save your IFC project now?") + + def execute(self, context): + # Get current IFC path + props = tool.Blender.get_bim_props() + current_ifc_path = props.ifc_file + + if not current_ifc_path: + self.report({"WARNING"}, "No IFC file path set. Please save manually.") + tool.Autosave.reset_timer() + return {"CANCELLED"} + + # Call save_project with explicit filepath using EXEC_DEFAULT + result = bpy.ops.bim.save_project( + "EXEC_DEFAULT", filepath=current_ifc_path, should_save_as=False, skip_recent=True + ) + + tool.Autosave.reset_timer() + return result + + def cancel(self, context): + tool.Autosave.reset_timer() + return {"CANCELLED"} + + class LoadLinkedProject(bpy.types.Operator, ImportHelper): bl_idname = "bim.load_linked_project" bl_label = "Load Project For Viewing Only" diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 2c2886f464..42092ff558 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -577,6 +577,43 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): should_disable_undo_on_save: BoolProperty( name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False ) + + def update_autosave_settings(self, context: bpy.types.Context) -> None: + if self.autosave_enabled: + tool.Autosave.reset_timer() + else: + tool.Autosave.cancel_timer() + + autosave_enabled: BoolProperty( + name="Enable IFC Autosave Timer", + description="Periodically remind you to save or automatically create a backup copy of the IFC file", + default=False, + update=update_autosave_settings, + ) + autosave_interval_minutes: bpy.props.IntProperty( + name="Autosave Interval (Minutes)", + description="Time between autosave reminders or backups. The timer resets whenever you open or save a project", + default=10, + min=1, + max=1440, + update=update_autosave_settings, + ) + autosave_mode: bpy.props.EnumProperty( + name="Autosave Mode", + items=[ + ( + "PROMPT", + "Prompt to Save", + "Show a dialog offering to save the IFC project when the timer expires", + ), + ( + "BACKUP", + "Automatic Backup", + "Save a backup copy as filename_autosaved.ifc when the timer expires", + ), + ], + default="PROMPT", + ) should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False) should_always_cache: BoolProperty( name="Always Cache Geometry", @@ -689,6 +726,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): bsdd_load_test_dictionaries: bool bsdd_baseurl: str should_disable_undo_on_save: bool + autosave_enabled: bool + autosave_interval_minutes: int + autosave_mode: Literal["PROMPT", "BACKUP"] should_stream: bool should_always_cache: bool occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"] @@ -837,6 +877,12 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "opening_focus_opacity") layout.prop(self, "should_disable_undo_on_save") + layout.separator() + layout.label(text="Autosave:") + layout.prop(self, "autosave_enabled") + if self.autosave_enabled: + layout.prop(self, "autosave_interval_minutes") + layout.prop(self, "autosave_mode") layout.prop(self, "should_stream") layout.prop(self, "should_always_cache") layout.label(text="bSDD:") diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index f927e5e1e1..817780ddb2 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -80,3 +80,6 @@ from bonsai.tool.type import Type from bonsai.tool.unit import Unit from bonsai.tool.wall import Wall from bonsai.tool.web import Web + +# Have to move after import of tool.drawing +from bonsai.tool.autosave import Autosave diff --git a/src/bonsai/bonsai/tool/autosave.py b/src/bonsai/bonsai/tool/autosave.py new file mode 100644 index 0000000000..7fb4a04e33 --- /dev/null +++ b/src/bonsai/bonsai/tool/autosave.py @@ -0,0 +1,149 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Callable, Union + +import bpy + +import bonsai.tool as tool +from bonsai.bim import export_ifc +from bonsai.bim.module.model import preview_base + + +AUTOSAVING_SUFFIX = "_autosaving.ifc" +AUTOSAVED_SUFFIX = "_autosaved.ifc" + +_timer_callback: Union[Callable[[], None], None] = None + + +class Autosave: + @classmethod + def get_paths(cls, ifc_path: Union[str, Path]) -> tuple[Path, Path, Path]: + path = Path(ifc_path) + stem = path.stem if path.suffix.lower() == ".ifc" else path.name + parent = path.parent + main_path = path if path.suffix.lower() == ".ifc" else parent / f"{stem}.ifc" + autosaving_path = parent / f"{stem}{AUTOSAVING_SUFFIX}" + autosaved_path = parent / f"{stem}{AUTOSAVED_SUFFIX}" + return main_path, autosaving_path, autosaved_path + + @classmethod + def get_active_ifc_path(cls) -> Union[Path, None]: + props = tool.Blender.get_bim_props() + if not props.ifc_file: + return None + path = tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file)) + if path.suffix.lower() != ".ifc": + return None + return path + + @classmethod + def is_enabled(cls) -> bool: + return bool(tool.Blender.get_addon_preferences().autosave_enabled) + + @classmethod + def get_interval_seconds(cls) -> float: + minutes = tool.Blender.get_addon_preferences().autosave_interval_minutes + return max(1.0, float(minutes) * 60.0) + + @classmethod + def is_eligible(cls) -> bool: + return cls.is_enabled() and tool.Ifc.get() is not None and cls.get_active_ifc_path() is not None + + @classmethod + def cancel_timer(cls) -> None: + global _timer_callback + if _timer_callback is not None and bpy.app.timers.is_registered(_timer_callback): + bpy.app.timers.unregister(_timer_callback) + _timer_callback = None + + @classmethod + def reset_timer(cls) -> None: + cls.cancel_timer() + if not cls.is_eligible(): + return + + def on_timer() -> None: + cls._on_timer_expired() + return None + + global _timer_callback + _timer_callback = on_timer + bpy.app.timers.register(on_timer, first_interval=cls.get_interval_seconds()) + + @classmethod + def _on_timer_expired(cls) -> None: + if not cls.is_eligible(): + return + + prefs = tool.Blender.get_addon_preferences() + bim_props = tool.Blender.get_bim_props() + + if bim_props.is_dirty: + if prefs.autosave_mode == "PROMPT": + bpy.ops.bim.autosave_prompt("INVOKE_DEFAULT") + elif prefs.autosave_mode == "BACKUP": + try: + cls.perform_backup(bpy.context) + except Exception as error: + print(f"Bonsai: autosave backup failed: {error}") + cls.reset_timer() + + @classmethod + def perform_backup(cls, context: bpy.types.Context) -> None: + ifc_path = cls.get_active_ifc_path() + if ifc_path is None: + return + + _, autosaving_path, autosaved_path = cls.get_paths(ifc_path) + autosaving_path.parent.mkdir(parents=True, exist_ok=True) + + tool.Parametric.commit_pending_edits() + preview_base.discard_pending_previews(context.scene) + + logger = logging.getLogger("ExportIFC") + output_file = autosaving_path.as_posix().replace("\\", "/") + settings = export_ifc.IfcExportSettings.factory(context, output_file, logger) + export_ifc.IfcExporter(settings).export() + + try: + os.replace(autosaving_path, autosaved_path) + except OSError: + if autosaving_path.is_file(): + autosaving_path.unlink(missing_ok=True) + raise + + @classmethod + def get_newer_autosaved_path(cls, ifc_path: Union[str, Path]) -> Union[str, None]: + path = Path(ifc_path) + if path.suffix.lower() != ".ifc" or not path.is_file(): + return None + + _, _, autosaved_path = cls.get_paths(path) + if not autosaved_path.is_file(): + return None + if autosaved_path.stat().st_mtime > path.stat().st_mtime: + return autosaved_path.as_posix().replace("\\", "/") + return None diff --git a/src/bonsai/test/bim/module/project/test_autosave.py b/src/bonsai/test/bim/module/project/test_autosave.py new file mode 100644 index 0000000000..8f1b5277a5 --- /dev/null +++ b/src/bonsai/test/bim/module/project/test_autosave.py @@ -0,0 +1,68 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import os +import time +from pathlib import Path + +import pytest + +from bonsai.tool.autosave import AUTOSAVED_SUFFIX, AUTOSAVING_SUFFIX, Autosave + +pytestmark = pytest.mark.project + + +class TestAutosavePaths: + def test_get_paths_for_ifc_file(self): + main_path, autosaving_path, autosaved_path = Autosave.get_paths("/tmp/myfile.ifc") + assert main_path == Path("/tmp/myfile.ifc") + assert autosaving_path == Path(f"/tmp/myfile{AUTOSAVING_SUFFIX}") + assert autosaved_path == Path(f"/tmp/myfile{AUTOSAVED_SUFFIX}") + + def test_get_newer_autosaved_path_when_missing(self, tmp_path): + ifc_path = tmp_path / "myfile.ifc" + ifc_path.write_text("ifc") + assert Autosave.get_newer_autosaved_path(ifc_path) is None + + def test_get_newer_autosaved_path_when_older(self, tmp_path): + ifc_path = tmp_path / "myfile.ifc" + autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}" + ifc_path.write_text("ifc") + autosaved_path.write_text("autosaved") + past = time.time() - 10 + os.utime(ifc_path, (past, past)) + os.utime(autosaved_path, (time.time(), time.time())) + assert Autosave.get_newer_autosaved_path(ifc_path) == autosaved_path.as_posix() + + def test_get_newer_autosaved_path_when_not_newer(self, tmp_path): + ifc_path = tmp_path / "myfile.ifc" + autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}" + ifc_path.write_text("ifc") + autosaved_path.write_text("autosaved") + now = time.time() + os.utime(ifc_path, (now, now)) + past = now - 10 + os.utime(autosaved_path, (past, past)) + assert Autosave.get_newer_autosaved_path(ifc_path) is None + + def test_get_newer_autosaved_path_ignores_non_ifc(self, tmp_path): + path = tmp_path / "myfile.ifczip" + path.write_text("zip") + assert Autosave.get_newer_autosaved_path(path) is None