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