diff --git a/novelwriter/core/document.py b/novelwriter/core/document.py
index 672fbee5..2d609538 100644
--- a/novelwriter/core/document.py
+++ b/novelwriter/core/document.py
@@ -23,9 +23,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
-import os
import logging
+from pathlib import Path
+
from novelwriter.enum import nwItemLayout, nwItemClass
from novelwriter.error import formatException
from novelwriter.common import isHandle, sha256sum
@@ -73,7 +74,7 @@ class NWDoc:
empty string. If something went wrong, return None.
"""
self._docError = ""
- if self._docHandle is None:
+ if not isinstance(self._docHandle, str):
logger.error("No document handle set")
return None
@@ -81,17 +82,22 @@ class NWDoc:
logger.error("Unknown novelWriter document")
return None
+ contentPath = self.theProject.storage.contentPath
+ if not isinstance(contentPath, Path):
+ logger.error("No content path set")
+ return None
+
docFile = self._docHandle+".nwd"
logger.debug("Opening document: %s", docFile)
- docPath = os.path.join(self.theProject.projContent, docFile)
+ docPath = contentPath / docFile
self._fileLoc = docPath
theText = ""
self._docMeta = {}
self._prevHash = None
- if os.path.isfile(docPath):
+ if docPath.exists():
self._prevHash = sha256sum(docPath)
try:
with open(docPath, mode="r", encoding="utf-8") as inFile:
@@ -125,17 +131,20 @@ class NWDoc:
if not.
"""
self._docError = ""
- if self._docHandle is None:
+ if not isinstance(self._docHandle, str):
logger.error("No document handle set")
return False
- self.theProject.ensureFolderStructure()
+ contentPath = self.theProject.storage.contentPath
+ if not isinstance(contentPath, Path):
+ logger.error("No content path set")
+ return None
docFile = self._docHandle+".nwd"
logger.debug("Saving document: %s", docFile)
- docPath = os.path.join(self.theProject.projContent, docFile)
- docTemp = os.path.join(self.theProject.projContent, docFile+"~")
+ docPath = contentPath / docFile
+ docTemp = docPath.with_suffix(".tmp")
if self._prevHash is not None and not forceWrite:
self._currHash = sha256sum(docPath)
@@ -164,7 +173,7 @@ class NWDoc:
# If we're here, the file was successfully saved, so we can
# replace the temp file with the actual file
try:
- os.replace(docTemp, docPath)
+ docTemp.replace(docPath)
except OSError as exc:
self._docError = formatException(exc)
return False
@@ -179,23 +188,24 @@ class NWDoc:
from the project data folder.
"""
self._docError = ""
- if self._docHandle is None:
+ if not isinstance(self._docHandle, str):
logger.error("No document handle set")
return False
- chkList = [
- os.path.join(self.theProject.projContent, f"{self._docHandle}.nwd"),
- os.path.join(self.theProject.projContent, f"{self._docHandle}.nwd~"),
- ]
+ contentPath = self.theProject.storage.contentPath
+ if not isinstance(contentPath, Path):
+ logger.error("No content path set")
+ return None
- for chkFile in chkList:
- if os.path.isfile(chkFile):
- try:
- os.unlink(chkFile)
- logger.debug("Deleted: %s", chkFile)
- except Exception as exc:
- self._docError = formatException(exc)
- return False
+ docPath = contentPath / f"{self._docHandle}.nwd"
+ docTemp = docPath.with_suffix(".tmp")
+
+ try:
+ docPath.unlink(missing_ok=True)
+ docTemp.unlink(missing_ok=True)
+ except Exception as exc:
+ self._docError = formatException(exc)
+ return False
return True
@@ -206,7 +216,7 @@ class NWDoc:
def getFileLocation(self):
"""Return the file location of the current document.
"""
- return self._fileLoc
+ return str(self._fileLoc)
def getCurrentItem(self):
"""Return a pointer to the currently open NWItem.
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index 886fe1c2..66ae755a 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -27,12 +27,12 @@ from __future__ import annotations
import os
import json
-from pathlib import Path
import shutil
import logging
import novelwriter
from time import time
+from pathlib import Path
from functools import partial
from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal
@@ -84,10 +84,9 @@ class NWProject(QObject):
self.lockedBy = None # Data on which computer has the project open
# Class Settings
- self.projPath = None # The full path to where the currently open project is saved
- self.projContent = None # The full path to the project's content folder
- self.projDict = None # The spell check dictionary
- self.projFiles = [] # A list of all files in the content folder on load
+ self.projPath = None # The full path to where the currently open project is saved
+ self.projDict = None # The spell check dictionary
+ self.projFiles = [] # A list of all files in the content folder on load
# Internal Mapping
self.tr = partial(QCoreApplication.translate, "NWProject")
@@ -252,10 +251,9 @@ class NWProject(QObject):
self._data = NWProjectData(self)
# Project Settings
- self.projPath = None
- self.projContent = None
- self.projDict = None
- self.projFiles = []
+ self.projPath = None
+ self.projDict = None
+ self.projFiles = []
return
@@ -271,7 +269,6 @@ class NWProject(QObject):
# ToDo: These should not be set explicitly, and should stay as Path
self.projPath = str(self._storage.runtimePath)
- self.projContent = str(self._storage.contentPath)
logger.info("Opening project: %s", self.projPath)
@@ -468,24 +465,6 @@ class NWProject(QObject):
self.lockedBy = None
return True
- def ensureFolderStructure(self):
- """Ensure that all necessary folders exist in the project
- folder.
- """
- if self.projPath is None or self.projPath == "":
- return False
-
- self.projContent = os.path.join(self.projPath, "content")
-
- if self.projPath == os.path.expanduser("~"):
- # Don't make a mess in the user's home folder
- return False
-
- if not self._checkFolder(self.projContent):
- return False
-
- return True
-
def setDefaultStatusImport(self):
"""Set the default status and importance values.
"""
@@ -790,31 +769,34 @@ class NWProject(QObject):
orphaned files so the user can either delete them, or put them
back into the project tree.
"""
- if self.projPath is None:
+ contentPath = self._storage.contentPath
+ if not isinstance(contentPath, Path):
return False
# Then check the files in the data folder
logger.debug("Checking files in project content folder")
orphanFiles = []
self.projFiles = []
- for fileItem in os.listdir(self.projContent):
- if not fileItem.endswith(".nwd"):
- logger.warning("Skipping file: %s", fileItem)
+
+ for item in contentPath.iterdir():
+ itemName = item.name
+ if not itemName.endswith(".nwd"):
+ logger.warning("Skipping file: %s", itemName)
continue
- if len(fileItem) != 17:
- logger.warning("Skipping file: %s", fileItem)
+ if len(itemName) != 17:
+ logger.warning("Skipping file: %s", itemName)
continue
- fHandle = fileItem[:13]
+ fHandle = itemName[:13]
if not isHandle(fHandle):
- logger.warning("Skipping file: %s", fileItem)
+ logger.warning("Skipping file: %s", itemName)
continue
if fHandle in self._tree:
self.projFiles.append(fHandle)
- logger.debug("Checking file %s, handle '%s': OK", fileItem, fHandle)
+ logger.debug("Checking file %s, handle '%s': OK", itemName, fHandle)
else:
- logger.warning("Checking file %s, handle '%s': Orphaned", fileItem, fHandle)
+ logger.warning("Checking file %s, handle '%s': Orphaned", itemName, fHandle)
orphanFiles.append(fHandle)
# Report status
diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py
index 05522469..b82421c6 100644
--- a/novelwriter/core/storage.py
+++ b/novelwriter/core/storage.py
@@ -151,6 +151,8 @@ class NWStorage:
return xmlWriter
def getDocument(self, tHandle):
+ """Return a document wrapper object.
+ """
pass
def getMetaFile(self, fileName):
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index e6d66f21..9d499aaf 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -23,10 +23,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
-import os
import random
import logging
+from pathlib import Path
+
from novelwriter.enum import nwItemClass, nwItemLayout
from novelwriter.error import logException
from novelwriter.common import checkHandle
@@ -140,16 +141,22 @@ class NWTree:
"""Write the convenience table of contents file in the root of
the project directory.
"""
+ runtimePath = self.theProject.storage.runtimePath
+ contentPath = self.theProject.storage.contentPath
+ if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
+ return False
+
tocList = []
tocLen = 0
for tHandle in self._treeOrder:
tItem = self.__getitem__(tHandle)
if tItem is None:
continue
+
tFile = tHandle+".nwd"
- if os.path.isfile(os.path.join(self.theProject.projContent, tFile)):
+ if (contentPath / tFile).is_file():
tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format(
- os.path.join("content", tFile),
+ str(Path("content") / tFile),
tItem.itemClass.name,
tItem.itemLayout.name,
tItem.itemName,
@@ -159,7 +166,7 @@ class NWTree:
try:
# Dump the text
- tocText = os.path.join(self.theProject.projPath, nwFiles.TOC_TXT)
+ tocText = runtimePath / nwFiles.TOC_TXT
with open(tocText, mode="w", encoding="utf-8") as outFile:
outFile.write("\n")
outFile.write("Table of Contents\n")
diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py
index baabccdb..9281df0a 100644
--- a/novelwriter/dialogs/wordlist.py
+++ b/novelwriter/dialogs/wordlist.py
@@ -163,9 +163,7 @@ class GuiWordList(QDialog):
if item is not None:
outFile.write(item.text() + "\n")
- if dctFile.exists():
- dctFile.unlink()
- tmpFile.rename(dctFile)
+ tmpFile.replace(dctFile)
except Exception:
logger.error("Could not save new word list")
diff --git a/tests/conftest.py b/tests/conftest.py
index 758d46da..bcaa2ea3 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -64,6 +64,13 @@ def tmpDir():
return theDir
+@pytest.fixture(scope="function")
+def tmpPath(tmpDir):
+ """A temporary folder for a single test function.
+ """
+ return Path(tmpDir)
+
+
@pytest.fixture(scope="session")
def tstPaths(tmpDir):
"""Returns an object that can provide the various paths needed for
diff --git a/tests/test_core/test_core_document.py b/tests/test_core/test_core_document.py
index 761859ef..1923bc34 100644
--- a/tests/test_core/test_core_document.py
+++ b/tests/test_core/test_core_document.py
@@ -114,7 +114,7 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncDir, mockRnd):
# Cause os.replace() to fail while saving
with monkeypatch.context() as mp:
- mp.setattr("os.replace", causeOSError)
+ mp.setattr("pathlib.Path.replace", causeOSError)
assert theDoc.writeDocument(theText) is False
assert theDoc.getError() == "OSError: Mock OSError"
@@ -135,7 +135,7 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncDir, mockRnd):
# Cause the delete to fail
with monkeypatch.context() as mp:
- mp.setattr("os.unlink", causeOSError)
+ mp.setattr("pathlib.Path.unlink", causeOSError)
theDoc = NWDoc(theProject, xHandle)
assert theDoc.deleteDocument() is False
assert theDoc.getError() == "OSError: Mock OSError"
diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py
index 8371a748..76f420d6 100644
--- a/tests/test_core/test_core_project.py
+++ b/tests/test_core/test_core_project.py
@@ -141,7 +141,7 @@ def testCoreProject_NewFileFolder(monkeypatch, fncDir, outDir, refDir, mockGUI,
# Delete new file, but block access
with monkeypatch.context() as mp:
- mp.setattr("os.unlink", causeOSError)
+ mp.setattr("pathlib.Path.unlink", causeOSError)
assert theProject.removeItem("0000000000011") is False
assert "0000000000011" in theProject.tree
@@ -270,36 +270,6 @@ def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncDir, refDir):
# END Test testCoreProject_Save
-@pytest.mark.core
-def testCoreProject_Helpers(monkeypatch, fncDir, mockGUI):
- """Test helper functions for the project folder.
- """
- theProject = NWProject(mockGUI)
-
- # No path
- assert theProject.ensureFolderStructure() is False
-
- # Set the correct dir
- theProject.projPath = fncDir
-
- # Block user's home folder
- with monkeypatch.context() as mp:
- mp.setattr("os.path.expanduser", lambda *a, **k: fncDir)
- assert theProject.ensureFolderStructure() is False
-
- # Create a file to block content folder
- contentDir = os.path.join(fncDir, "content")
- writeFile(contentDir, "stuff")
- assert theProject.ensureFolderStructure() is False
- os.unlink(contentDir)
-
- # Now, do it right
- assert theProject.ensureFolderStructure() is True
- assert os.path.isdir(contentDir)
-
-# END Test testCoreProject_Helpers
-
-
@pytest.mark.core
def testCoreProject_AccessItems(mockGUI, fncDir, mockRnd):
"""Test helper functions for the project folder.
diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py
index 518122e7..9c3d0961 100644
--- a/tests/test_core/test_core_tree.py
+++ b/tests/test_core/test_core_tree.py
@@ -19,10 +19,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
-import os
import pytest
import random
+from pathlib import Path
+
from tools import readFile
from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout
@@ -394,7 +395,7 @@ def testCoreTree_Reorder(mockGUI, mockItems):
@pytest.mark.core
-def testCoreTree_ToCFile(monkeypatch, mockGUI, mockItems, tmpDir):
+def testCoreTree_ToCFile(monkeypatch, mockGUI, mockItems, tmpPath):
"""Test writing the ToC.txt file.
"""
theProject = NWProject(mockGUI)
@@ -411,24 +412,23 @@ def testCoreTree_ToCFile(monkeypatch, mockGUI, mockItems, tmpDir):
"""Return True for items that are files in novelWriter and
should thus also be files in the project folder structure.
"""
- dItem = theTree[fileName[8:21]]
+ dItem = theTree[fileName.name[:13]]
assert dItem is not None
return dItem.itemType == nwItemType.FILE
- monkeypatch.setattr("os.path.isfile", mockIsFile)
+ monkeypatch.setattr("pathlib.Path.is_file", mockIsFile)
- theProject.projContent = "content"
- theProject.projPath = None
- assert not theTree.writeToCFile()
+ theProject._storage._runtimePath = None
+ assert theTree.writeToCFile() is False
- theProject.projPath = tmpDir
- assert theTree.writeToCFile()
+ theProject._storage._runtimePath = tmpPath
+ assert theTree.writeToCFile() is True
- pathA = os.path.join("content", "c000000000001.nwd")
- pathB = os.path.join("content", "c000000000002.nwd")
- pathC = os.path.join("content", "b000000000002.nwd")
+ pathA = str(Path("content") / "c000000000001.nwd")
+ pathB = str(Path("content") / "c000000000002.nwd")
+ pathC = str(Path("content") / "b000000000002.nwd")
- assert readFile(os.path.join(tmpDir, nwFiles.TOC_TXT)) == (
+ assert readFile(tmpPath / nwFiles.TOC_TXT) == (
"\n"
"Table of Contents\n"
"=================\n"
diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py
index 4ec79000..cfaeecc4 100644
--- a/tests/test_gui/test_gui_noveltree.py
+++ b/tests/test_gui/test_gui_noveltree.py
@@ -19,10 +19,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
-import os
import pytest
-from tools import C, buildTestProject, writeFile
+from pathlib import Path
+
+from tools import C, buildTestProject
from PyQt5.QtGui import QFocusEvent
from PyQt5.QtCore import Qt, QEvent
@@ -46,18 +47,18 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, fncProj, mockRnd):
nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE)
- writeFile(
- os.path.join(nwGUI.theProject.projContent, "0000000000010.nwd"),
- "# Jane Doe\n\n@tag: Jane\n\n"
- )
- writeFile(
- os.path.join(nwGUI.theProject.projContent, "000000000000f.nwd"), (
- "### Scene One\n\n"
- "@pov: Jane\n"
- "@focus: Jane\n\n"
- "% Synopsis: This is a scene."
- )
+ contentPath = nwGUI.theProject.storage.contentPath
+ assert isinstance(contentPath, Path)
+
+ (contentPath / "0000000000010.nwd").write_text(
+ "# Jane Doe\n\n@tag: Jane\n\n", encoding="utf-8"
)
+ (contentPath / "000000000000f.nwd").write_text((
+ "### Scene One\n\n"
+ "@pov: Jane\n"
+ "@focus: Jane\n\n"
+ "% Synopsis: This is a scene."
+ ), encoding="utf-8")
novelView = nwGUI.novelView
novelTree = novelView.novelTree