Rewrite project open and creation in storage class

This commit is contained in:
Veronica Berglyd Olsen
2023-11-30 22:29:54 +01:00
parent 60b0a4c901
commit 4f42407c23
4 changed files with 213 additions and 178 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ class ProjectBuilder:
return False return False
project = NWProject() project = NWProject()
if not project.storage.openProjectInPlace(projPath, newProject=True): if not project.storage.createNewProject(projPath):
return False return False
lblNewProject = self.tr("New Project") lblNewProject = self.tr("New Project")
+38 -34
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import json import json
import logging import logging
from enum import Enum
from time import time from time import time
from typing import TYPE_CHECKING, Iterator from typing import TYPE_CHECKING, Iterator
from pathlib import Path from pathlib import Path
@@ -40,7 +41,7 @@ from novelwriter.constants import trConst, nwLabels
from novelwriter.core.tree import NWTree from novelwriter.core.tree import NWTree
from novelwriter.core.index import NWIndex from novelwriter.core.index import NWIndex
from novelwriter.core.options import OptionState from novelwriter.core.options import OptionState
from novelwriter.core.storage import NWStorage from novelwriter.core.storage import NWStorage, NWStorageOpen
from novelwriter.core.sessions import NWSessionLog from novelwriter.core.sessions import NWSessionLog
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.projectdata import NWProjectData from novelwriter.core.projectdata import NWProjectData
@@ -55,6 +56,16 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NWProjectState(Enum):
UNKNOWN = 0
LOCKED = 1
RECOVERY = 2
READY = 3
# END Enum NWProjectState
class NWProject: class NWProject:
def __init__(self) -> None: def __init__(self) -> None:
@@ -69,9 +80,9 @@ class NWProject:
# Project Status # Project Status
self._langData = {} # Localisation data self._langData = {} # Localisation data
self._lockedBy = None # Data on which computer has the project open
self._changed = False # The project has unsaved changes self._changed = False # The project has unsaved changes
self._valid = False # The project was successfully loaded self._valid = False # The project was successfully loaded
self._state = NWProjectState.UNKNOWN
# Internal Mapping # Internal Mapping
self.tr = partial(QCoreApplication.translate, "NWProject") self.tr = partial(QCoreApplication.translate, "NWProject")
@@ -125,12 +136,15 @@ class NWProject:
"""Return True if a project is loaded.""" """Return True if a project is loaded."""
return self._valid return self._valid
@property
def state(self) -> NWProjectState:
"""Return the current project state."""
return self._state
@property @property
def lockStatus(self) -> list | None: def lockStatus(self) -> list | None:
"""Return the project lock information.""" """Return the project lock information."""
if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4: return self._storage.lockStatus
return self._lockedBy
return None
@property @property
def currentEditTime(self) -> int: def currentEditTime(self) -> int:
@@ -219,29 +233,23 @@ class NWProject:
build the tree of project items. build the tree of project items.
""" """
logger.info("Opening project: %s", projPath) logger.info("Opening project: %s", projPath)
if not self._storage.openProjectInPlace(projPath):
SHARED.error(self.tr("Could not open project with path: {0}").format(projPath)) status = self._storage.initProjectStorage(projPath, clearLock)
if status != NWStorageOpen.READY:
if status == NWStorageOpen.UNKOWN:
SHARED.error(self.tr("Not a known project file format."))
elif status == NWStorageOpen.NOT_FOUND:
SHARED.error(self.tr("Project file not found."))
elif status == NWStorageOpen.LOCKED:
self._state = NWProjectState.LOCKED
elif status == NWStorageOpen.RECOVERY:
self._state = NWProjectState.RECOVERY
elif status == NWStorageOpen.FAILED:
SHARED.error(self.tr("Failed to open project."), exc=self._storage.exc)
return False return False
# Project Lock # Read Project XML
# ============ # ================
if clearLock:
self._storage.clearLockFile()
lockStatus = self._storage.readLockFile()
if len(lockStatus) > 0:
if lockStatus[0] == "ERROR":
logger.warning("Failed to check lock file")
else:
logger.error("Project is locked, so not opening")
self._lockedBy = lockStatus
return False
else:
logger.debug("Project is not locked")
# Open The Project XML File
# =========================
xmlReader = self._storage.getXmlReader() xmlReader = self._storage.getXmlReader()
if not isinstance(xmlReader, ProjectXMLReader): if not isinstance(xmlReader, ProjectXMLReader):
@@ -250,9 +258,7 @@ class NWProject:
self._data = NWProjectData(self) self._data = NWProjectData(self)
projContent = [] projContent = []
xmlParsed = xmlReader.read(self._data, projContent) xmlParsed = xmlReader.read(self._data, projContent)
appVersion = xmlReader.appVersion or self.tr("Unknown") appVersion = xmlReader.appVersion or self.tr("Unknown")
if not xmlParsed: if not xmlParsed:
if xmlReader.state == XMLReadState.NOT_NWX_FILE: if xmlReader.state == XMLReadState.NOT_NWX_FILE:
SHARED.error(self.tr( SHARED.error(self.tr(
@@ -323,9 +329,9 @@ class NWProject:
self.updateWordCounts() self.updateWordCounts()
self._session.startSession() self._session.startSession()
self._storage.writeLockFile()
self.setProjectChanged(False) self.setProjectChanged(False)
self._valid = True self._valid = True
self._state = NWProjectState.READY
SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name)) SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name))
@@ -370,13 +376,11 @@ class NWProject:
self._storage.runPostSaveTasks(autoSave=autoSave) self._storage.runPostSaveTasks(autoSave=autoSave)
# Update recent projects # Update recent projects
storePath = self._storage.storagePath if storagePath := self._storage.storagePath:
if storePath:
CONFIG.recentProjects.update( CONFIG.recentProjects.update(
storePath, self._data.name, sum(self._data.currCounts), saveTime storagePath, self._data.name, sum(self._data.currCounts), saveTime
) )
self._storage.writeLockFile()
SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name)) SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name))
self.setProjectChanged(False) self.setProjectChanged(False)
@@ -420,8 +424,8 @@ class NWProject:
timeStamp = formatTimeStamp(time(), fileSafe=True) timeStamp = formatTimeStamp(time(), fileSafe=True)
archName = baseDir / f"{cleanName} {timeStamp}.zip" archName = baseDir / f"{cleanName} {timeStamp}.zip"
if self._storage.zipIt(archName, compression=2): if self._storage.zipIt(archName, compression=2):
size = formatInt(getFileSize(archName))
if doNotify: if doNotify:
size = formatInt(getFileSize(archName))
SHARED.info( SHARED.info(
self.tr("Created a backup of your project of size {0}B.").format(size), self.tr("Created a backup of your project of size {0}B.").format(size),
info=self.tr("Path: {0}").format(str(backupPath)) info=self.tr("Path: {0}").format(str(backupPath))
+5 -7
View File
@@ -38,7 +38,6 @@ from novelwriter.common import (
checkBool, checkInt, checkString, checkStringNone, formatTimeStamp, checkBool, checkInt, checkString, checkStringNone, formatTimeStamp,
hexToInt, simplified, xmlIndent, yesNo hexToInt, simplified, xmlIndent, yesNo
) )
from novelwriter.constants import nwFiles
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.status import NWStatus from novelwriter.core.status import NWStatus
@@ -558,9 +557,8 @@ class ProjectXMLWriter:
xName.text = item["name"] xName.text = item["name"]
# Write the XML tree to file # Write the XML tree to file
saveFile = self._path / nwFiles.PROJ_FILE tempFile = self._path.with_suffix(".tmp")
tempFile = saveFile.with_suffix(".tmp") backFile = self._path.with_suffix(".bak")
backFile = saveFile.with_suffix(".bak")
try: try:
xml = ET.ElementTree(xRoot) xml = ET.ElementTree(xRoot)
xmlIndent(xml) xmlIndent(xml)
@@ -572,9 +570,9 @@ class ProjectXMLWriter:
# If we're here, the file was successfully saved, # If we're here, the file was successfully saved,
# so let's sort out the temps and backups # so let's sort out the temps and backups
try: try:
if saveFile.exists(): if self._path.exists():
saveFile.replace(backFile) self._path.replace(backFile)
tempFile.replace(saveFile) tempFile.replace(self._path)
except Exception as exc: except Exception as exc:
self._error = exc self._error = exc
return False return False
+169 -136
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import json import json
import logging import logging
from enum import Enum
from time import time from time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pathlib import Path from pathlib import Path
@@ -45,6 +46,18 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NWStorageOpen(Enum):
UNKOWN = 0
NOT_FOUND = 1
LOCKED = 2
RECOVERY = 3
FAILED = 4
READY = 5
# END Enum NWStorageOpen
class NWStorage: class NWStorage:
"""Core: Project Storage Class """Core: Project Storage Class
@@ -60,7 +73,10 @@ class NWStorage:
self._storagePath = None self._storagePath = None
self._runtimePath = None self._runtimePath = None
self._lockFilePath = None self._lockFilePath = None
self._lockedBy = None
self._openMode = self.MODE_INACTIVE self._openMode = self.MODE_INACTIVE
self._ready = False
self._exception = None
return return
def clear(self) -> None: def clear(self) -> None:
@@ -69,6 +85,8 @@ class NWStorage:
self._runtimePath = None self._runtimePath = None
self._lockFilePath = None self._lockFilePath = None
self._openMode = self.MODE_INACTIVE self._openMode = self.MODE_INACTIVE
self._ready = False
self._exception = None
return return
## ##
@@ -77,12 +95,12 @@ class NWStorage:
@property @property
def storagePath(self) -> Path | None: def storagePath(self) -> Path | None:
"""Get the path where the project is saved.""" """Return the path where the project is stored."""
return self._storagePath return self._storagePath
@property @property
def runtimePath(self) -> Path | None: def runtimePath(self) -> Path | None:
"""Get the path where the project is saved at runtime.""" """Return the path where the project is stored at runtime."""
return self._runtimePath return self._runtimePath
@property @property
@@ -97,6 +115,18 @@ class NWStorage:
logger.error("Content path cannot be resolved") logger.error("Content path cannot be resolved")
return None return None
@property
def lockStatus(self) -> list | None:
"""Return the project lock information."""
if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4:
return self._lockedBy
return None
@property
def exc(self) -> Exception | None:
"""Return the latest exception of the storage instance."""
return self._exception
## ##
# Core Methods # Core Methods
## ##
@@ -105,18 +135,11 @@ class NWStorage:
"""Check if the storage location is open.""" """Check if the storage location is open."""
return self._runtimePath is not None return self._runtimePath is not None
def openProjectInPlace(self, path: str | Path, newProject: bool = False) -> bool: def createNewProject(self, path: str | Path) -> bool:
"""Open a novelWriter project in-place. That is, it is opened """Create a new project at the given location."""
directly from a project folder.
"""
inPath = Path(path).resolve() inPath = Path(path).resolve()
if inPath.is_file(): if inPath.is_dir() and len(list(inPath.iterdir())) > 0:
# The path should not point to an existing file, logger.error("Folder is not empty: %s", inPath)
# but it can point to a folder containing files
inPath = inPath.parent
if not (inPath.is_dir() or newProject):
# If the project is not new, the folder must already exist.
return False return False
self._storagePath = inPath self._storagePath = inPath
@@ -124,17 +147,93 @@ class NWStorage:
self._lockFilePath = inPath / nwFiles.PROJ_LOCK self._lockFilePath = inPath / nwFiles.PROJ_LOCK
self._openMode = self.MODE_INPLACE self._openMode = self.MODE_INPLACE
if not self._prepareStorage(checkLegacy=True, newProject=newProject): basePath = self._runtimePath
metaPath = basePath / "meta"
contPath = basePath / "content"
try:
basePath.mkdir(exist_ok=True)
metaPath.mkdir(exist_ok=True)
contPath.mkdir(exist_ok=True)
except Exception as exc:
logger.error("Failed to create project folders", exc_info=exc)
self.clear() self.clear()
return False return False
self._ready = True
return True return True
def openProjectArchive(self, path: str | Path) -> bool: # pragma: no cover def initProjectStorage(self, path: str | Path, clearLock: bool = False) -> NWStorageOpen:
"""Open the project from a single file. """Initialise the a novelWriter project."""
Placeholder for later implementation. See #977. inPath = Path(path).resolve()
"""
return False # Initialise Storage Instance
# ===========================
# Check what we're opening. Only three options are allowed:
# 1. A folder with a nwProject.nwx file in it (not home)
# 2. A full path to an nwProject.nwx file
if inPath.is_dir() and inPath != Path.home().resolve():
nwxFile = inPath / nwFiles.PROJ_FILE
elif inPath.is_file() and inPath.name == nwFiles.PROJ_FILE:
nwxFile = inPath
else:
logger.error("Not a novelWriter project")
return NWStorageOpen.UNKOWN
if not nwxFile.exists():
# The .nwx file must exist to continue
logger.error("Not found: %s", nwxFile)
return NWStorageOpen.NOT_FOUND
nwxPath = nwxFile.parent
self._storagePath = nwxPath
self._runtimePath = nwxPath
self._lockFilePath = nwxPath / nwFiles.PROJ_LOCK
self._openMode = self.MODE_INPLACE
# Check Project Lock
# ==================
if clearLock:
self._clearLockFile()
self._readLockFile()
if self._lockedBy and len(self._lockedBy) == 4:
if self._lockedBy[0] == "ERROR":
logger.warning("Failed to check lock file")
else:
logger.error("Project is locked, so not opening")
return NWStorageOpen.LOCKED
else:
logger.debug("Project is not locked")
# Prepare Folder
# ==============
basePath = self._runtimePath
metaPath = basePath / "meta"
contPath = basePath / "content"
try:
metaPath.mkdir(exist_ok=True)
contPath.mkdir(exist_ok=True)
except Exception as exc:
logger.error("Failed to create project folders", exc_info=exc)
self.clear()
return NWStorageOpen.FAILED
# Check for legacy data folders
legacy = _LegacyStorage(self._project)
legacy.deprecatedFiles(basePath)
for child in basePath.iterdir():
if child.is_dir() and child.name.startswith("data_"):
legacy.legacyDataFolder(basePath, child)
self._writeLockFile()
self._ready = True
return NWStorageOpen.READY
def runPostSaveTasks(self, autoSave: bool = False) -> bool: # pragma: no cover def runPostSaveTasks(self, autoSave: bool = False) -> bool: # pragma: no cover
"""Run tasks after the project has been saved. """Run tasks after the project has been saved.
@@ -147,7 +246,7 @@ class NWStorage:
def closeSession(self) -> None: def closeSession(self) -> None:
"""Run tasks related to closing the session.""" """Run tasks related to closing the session."""
self.clearLockFile() self._clearLockFile()
self.clear() self.clear()
return return
@@ -157,26 +256,25 @@ class NWStorage:
def getXmlReader(self) -> ProjectXMLReader | None: def getXmlReader(self) -> ProjectXMLReader | None:
"""Return a properly configured ProjectXMLReader instance.""" """Return a properly configured ProjectXMLReader instance."""
if isinstance(self._runtimePath, Path): if isinstance(self._runtimePath, Path) and self._ready:
projFile = self._runtimePath / nwFiles.PROJ_FILE return ProjectXMLReader(self._runtimePath / nwFiles.PROJ_FILE)
return ProjectXMLReader(projFile)
return None return None
def getXmlWriter(self) -> ProjectXMLWriter | None: def getXmlWriter(self) -> ProjectXMLWriter | None:
"""Return a properly configured ProjectXMLWriter instance.""" """Return a properly configured ProjectXMLWriter instance."""
if isinstance(self._runtimePath, Path): if isinstance(self._runtimePath, Path) and self._ready:
return ProjectXMLWriter(self._runtimePath) return ProjectXMLWriter(self._runtimePath / nwFiles.PROJ_FILE)
return None return None
def getDocument(self, tHandle: str | None) -> NWDocument: def getDocument(self, tHandle: str | None) -> NWDocument:
"""Return a document wrapper object.""" """Return a document wrapper object."""
if isinstance(self._runtimePath, Path): if isinstance(self._runtimePath, Path) and self._ready:
return NWDocument(self._project, tHandle) return NWDocument(self._project, tHandle)
return NWDocument(self._project, None) return NWDocument(self._project, None)
def getMetaFile(self, fileName: str) -> Path | None: def getMetaFile(self, fileName: str) -> Path | None:
"""Return the path to a file in the project meta folder.""" """Return the path to a file in the project meta folder."""
if isinstance(self._runtimePath, Path): if isinstance(self._runtimePath, Path) and self._ready:
return self._runtimePath / "meta" / fileName return self._runtimePath / "meta" / fileName
return None return None
@@ -190,59 +288,6 @@ class NWStorage:
if item.suffix == ".nwd" and isHandle(item.stem) if item.suffix == ".nwd" and isHandle(item.stem)
] if contentPath else [] ] if contentPath else []
def readLockFile(self) -> list[str]:
"""Read the project lock file."""
if self._lockFilePath is None:
return ["ERROR"]
if not self._lockFilePath.exists():
return []
try:
lines = self._lockFilePath.read_text(encoding="utf-8").strip().split(";")
except Exception:
logger.error("Failed to read project lockfile")
logException()
return ["ERROR"]
if len(lines) != 4:
return ["ERROR"]
return lines
def writeLockFile(self) -> bool:
"""Write the project lock file."""
if self._lockFilePath is None:
return False
data = [
CONFIG.hostName, CONFIG.osType,
CONFIG.kernelVer, str(int(time()))
]
try:
self._lockFilePath.write_text(";".join(data), encoding="utf-8")
except Exception:
logger.error("Failed to write project lockfile")
logException()
return False
return True
def clearLockFile(self) -> bool:
"""Remove the lock file, if it exists."""
if self._lockFilePath is None:
return False
if self._lockFilePath.exists():
try:
self._lockFilePath.unlink()
except Exception:
logger.error("Failed to remove project lockfile")
logException()
return False
return True
def zipIt(self, target: str | Path, compression: int | None = None) -> bool: def zipIt(self, target: str | Path, compression: int | None = None) -> bool:
"""Zip the content of the project at its runtime location into a """Zip the content of the project at its runtime location into a
zip file. This process will only grab files that are supposed to zip file. This process will only grab files that are supposed to
@@ -279,7 +324,7 @@ class NWStorage:
zipObj.write(srcPath, zipPath) zipObj.write(srcPath, zipPath)
logger.debug("Added: %s", zipPath) logger.debug("Added: %s", zipPath)
except Exception: except Exception:
logger.error("Failed to create acrhive") logger.error("Failed to create archive")
logException() logException()
return False return False
@@ -289,59 +334,48 @@ class NWStorage:
# Internal Functions # Internal Functions
## ##
def _prepareStorage(self, checkLegacy: bool = True, newProject: bool = False) -> bool: def _readLockFile(self) -> None:
"""Prepare the storage area for the project.""" """Read the project lock file."""
path = self._runtimePath self._lockedBy = None
if not isinstance(path, Path): path = self._lockFilePath
logger.error("No path set") if isinstance(path, Path) and path.exists():
self.clear() try:
self._lockedBy = path.read_text(encoding="utf-8").strip().split(";")
except Exception:
logger.error("Failed to read project lockfile")
logException()
self._lockedBy = ["ERROR", "ERROR", "ERROR", "ERROR"]
return
return
def _writeLockFile(self) -> bool:
"""Write the project lock file."""
if self._lockFilePath is None:
return False return False
if path == Path.home().absolute():
logger.error("Cannot use the user's home path as the root of a project")
self.clear()
return False
if newProject:
# If it's a new project, we check that there is no existing
# project in the selected path.
if path.exists() and len(list(path.iterdir())) > 0:
logger.error("The new project folder is not empty")
self.clear()
return False
# The folder is not required to exist, as it could be a new
# project, so we make sure it does. Then we add subfolders.
try: try:
path.mkdir(exist_ok=True) self._lockFilePath.write_text(
(path / "content").mkdir(exist_ok=True) f"{CONFIG.hostName};{CONFIG.osType};{CONFIG.kernelVer};{int(time())}",
(path / "meta").mkdir(exist_ok=True) encoding="utf-8"
except Exception as exc: )
logger.error("Failed to create required project folders", exc_info=exc) except Exception:
self.clear() logger.error("Failed to write project lockfile")
logException()
return False return False
if not checkLegacy:
# The legacy content check is only needed for project folder
# storage, so if it is not expected to be that, there's no
# need for the remaining checks.
return True
legacy = _LegacyStorage(self._project)
# Check for legacy data folders
for child in path.iterdir():
if child.is_dir() and child.name.startswith("data_"):
legacy.legacyDataFolder(path, child)
# Check for no longer used files, and delete them
legacy.deprecatedFiles(path)
return True return True
## def _clearLockFile(self) -> bool:
# Legacy Project Data Handlers """Remove the lock file, if it exists."""
## if self._lockFilePath is None:
return False
if self._lockFilePath.exists():
try:
self._lockFilePath.unlink()
except Exception:
logger.error("Failed to remove project lockfile")
logException()
return False
self._lockedBy = None
return True
# END Class NWStorage # END Class NWStorage
@@ -350,7 +384,7 @@ class _LegacyStorage:
"""Core: Legacy Storage Converter Utils """Core: Legacy Storage Converter Utils
A class with various functions to convert old file formats and A class with various functions to convert old file formats and
file/folder layout to the current project format. file/folder layouts to the current project format.
""" """
def __init__(self, project: NWProject) -> None: def __init__(self, project: NWProject) -> None:
@@ -359,7 +393,8 @@ class _LegacyStorage:
def legacyDataFolder(self, path: Path, child: Path) -> None: def legacyDataFolder(self, path: Path, child: Path) -> None:
"""Handle the content of a legacy data folder from a version 1.0 """Handle the content of a legacy data folder from a version 1.0
project. project. This format had 16 data folders where there now is only
one content folder.
""" """
logger.info("Processing legacy data folder: %s", path) logger.info("Processing legacy data folder: %s", path)
@@ -411,6 +446,7 @@ class _LegacyStorage:
path / "meta" / nwFiles.OPTS_FILE path / "meta" / nwFiles.OPTS_FILE
) )
# Delete removed files
remove = [ remove = [
path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1 path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1
path / "meta" / "mainOptions.json", # Replaced in 0.5 path / "meta" / "mainOptions.json", # Replaced in 0.5
@@ -457,7 +493,6 @@ class _LegacyStorage:
# Save dictionary and clean up old file # Save dictionary and clean up old file
userDict.save() userDict.save()
assert wordJson.exists()
wordList.unlink() wordList.unlink()
except Exception: except Exception:
@@ -467,9 +502,7 @@ class _LegacyStorage:
return return
def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> None: def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> None:
"""Convert the old text log file format to the new JSON Lines """Convert the old text log file format to JSON Lines."""
format.
"""
if sessJson.exists() or not sessLog.exists(): if sessJson.exists() or not sessLog.exists():
# If the new file already exists, we won't overwrite it # If the new file already exists, we won't overwrite it
return return
@@ -508,7 +541,7 @@ class _LegacyStorage:
return return
def _convertOldOptionsFile(self, optsOld: Path, optsNew: Path) -> None: def _convertOldOptionsFile(self, optsOld: Path, optsNew: Path) -> None:
"""Convert the old options state file format to the format.""" """Convert the old options state file format to new format."""
if optsNew.exists() or not optsOld.exists(): if optsNew.exists() or not optsOld.exists():
# If the new file already exists, we won't overwrite it # If the new file already exists, we won't overwrite it
return return