From c8d588d6d2157d29ac3d987ede3d76fb4a67d439 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Jul 2023 01:06:34 +0200 Subject: [PATCH] Complete annotations in project class and update tests --- novelwriter/core/options.py | 7 +- novelwriter/core/project.py | 136 +++++++++++++-------------- tests/test_core/test_core_index.py | 10 +- tests/test_core/test_core_project.py | 30 +++--- tests/test_core/test_core_storage.py | 6 +- 5 files changed, 96 insertions(+), 93 deletions(-) diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 9409f743..0cc7ac0a 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -77,7 +77,7 @@ class OptionState: the Config instead. """ - def __init__(self, project: NWProject): + def __init__(self, project: NWProject) -> None: self._project = project self._state = {} return @@ -87,8 +87,7 @@ class OptionState: ## def loadSettings(self) -> bool: - """Load the options dictionary from the project settings file. - """ + """Load the options dictionary from the project.""" stateFile = self._project.storage.getMetaFile(nwFiles.OPTS_FILE) if not isinstance(stateFile, Path): return False @@ -116,7 +115,7 @@ class OptionState: return True def saveSettings(self) -> bool: - """Save the options dictionary to the project settings file.""" + """Save the options dictionary to the project.""" stateFile = self._project.storage.getMetaFile(nwFiles.OPTS_FILE) if not isinstance(stateFile, Path): return False diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 8c5180a8..37153e71 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -28,6 +28,7 @@ import json import logging from time import time +from typing import TYPE_CHECKING, Iterator from pathlib import Path from functools import partial @@ -48,6 +49,11 @@ from novelwriter.common import ( checkStringNone, formatTimeStamp, hexToInt, makeFileNameSafe, minmax ) +if TYPE_CHECKING: # pragma: no cover + from novelwriter.guimain import GuiMain + from novelwriter.core.item import NWItem + from novelwriter.core.status import NWStatus + logger = logging.getLogger(__name__) @@ -55,7 +61,7 @@ class NWProject(QObject): projectStatusChanged = pyqtSignal(bool) - def __init__(self, mainGui): + def __init__(self, mainGui: GuiMain) -> None: super().__init__(parent=mainGui) # Internal @@ -69,19 +75,14 @@ class NWProject(QObject): self._index = NWIndex(self) # The project index self._session = NWSessionLog(self) # The session record - # Data Cache - self._langData = {} # Localisation data - # Project Status + self._langData = {} # Localisation data self._projChanged = False # The project has unsaved changes self._lockedBy = None # Data on which computer has the project open # Internal Mapping self.tr = partial(QCoreApplication.translate, "NWProject") - # Set Defaults - self.clearProject() - return ## @@ -89,23 +90,23 @@ class NWProject(QObject): ## @property - def options(self): + def options(self) -> OptionState: return self._options @property - def storage(self): + def storage(self) -> NWStorage: return self._storage @property - def data(self): + def data(self) -> NWProjectData: return self._data @property - def tree(self): + def tree(self) -> NWTree: return self._tree @property - def index(self): + def index(self) -> NWIndex: return self._index @property @@ -113,11 +114,11 @@ class NWProject(QObject): return self._session @property - def projOpened(self): + def projOpened(self) -> float: return self._session.start @property - def projChanged(self): + def projChanged(self) -> bool: return self._projChanged ## @@ -139,7 +140,7 @@ class NWProject(QObject): """Add a new file with a given label and parent item.""" return self._tree.create(label, parent, nwItemType.FILE) - def writeNewFile(self, tHandle, hLevel, isDocument, addText=""): + def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, addText: str = "") -> bool: """Write content to a new document after it is created. This will not run if the file exists and is not empty. """ @@ -165,7 +166,7 @@ class NWProject(QObject): return True - def removeItem(self, tHandle): + def removeItem(self, tHandle: str) -> bool: """Remove an item from the project. This will delete both the project entry and a document file if it exists. """ @@ -182,7 +183,7 @@ class NWProject(QObject): return True - def trashFolder(self): + def trashFolder(self) -> str: """Add the special trash root folder to the project.""" trashHandle = self._tree.trashRoot() if trashHandle is None: @@ -194,23 +195,28 @@ class NWProject(QObject): # Project Methods ## - def clearProject(self): + def clearProject(self) -> None: """Clear the data for the current project, and set them to default values. - """ - # Project Status - self._projChanged = False - # Project Tree + Note: Don't clear the lockedBy data here as it is needed after + this function is called. + """ + # Core Elements + self._options = OptionState(self) self._storage.clear() + self._data = NWProjectData(self) self._tree.clear() self._index.clearIndex() - self._data = NWProjectData(self) self._session = NWSessionLog(self) + # Project Status + self._langData = {} + self._projChanged = False + return - def openProject(self, projPath, overrideLock=False): + def openProject(self, projPath: str | Path, overrideLock: bool = False) -> bool: """Open the project file provided. If it doesn't exist, assume it is a folder and look for the file within it. If successful, parse the XML of the file and populate the project variables and @@ -342,7 +348,7 @@ class NWProject(QObject): return True - def saveProject(self, autoSave=False): + def saveProject(self, autoSave: bool = False) -> bool: """Save the project main XML file. The saving command itself uses a temporary filename, and the file is replaced afterwards to make sure if the save fails, we're not left with a truncated @@ -395,9 +401,8 @@ class NWProject(QObject): return True - def closeProject(self, idleTime=0.0): - """Close the current project and clear all meta data. - """ + def closeProject(self, idleTime: float = 0.0) -> None: + """Close the current project and clear all meta data.""" logger.info("Closing project") self._options.saveSettings() self._tree.writeToCFile() @@ -406,11 +411,10 @@ class NWProject(QObject): self._storage.closeSession() self.clearProject() self._lockedBy = None - return True + return - def backupProject(self, doNotify): - """Create a zip file of the entire project. - """ + def backupProject(self, doNotify: bool) -> bool: + """Create a zip file of the entire project.""" if not self._storage.isOpen(): logger.error("No project open") return False @@ -467,9 +471,8 @@ class NWProject(QObject): # Setters ## - def setDefaultStatusImport(self): - """Set the default status and importance values. - """ + def setDefaultStatusImport(self) -> None: + """Set the default status and importance values.""" self._data.itemStatus.write(None, self.tr("New"), (100, 100, 100)) self._data.itemStatus.write(None, self.tr("Note"), (200, 50, 0)) self._data.itemStatus.write(None, self.tr("Draft"), (200, 150, 0)) @@ -480,43 +483,40 @@ class NWProject(QObject): self._data.itemImport.write(None, self.tr("Main"), (50, 200, 0)) return - def setProjectLang(self, theLang): - """Set the project-specific language. - """ - theLang = checkStringNone(theLang, None) - if self._data.language != theLang: - self._data.setLanguage(theLang) + def setProjectLang(self, language: str | None) -> None: + """Set the project-specific language.""" + language = checkStringNone(language, None) + if self._data.language != language: + self._data.setLanguage(language) self._loadProjectLocalisation() self.setProjectChanged(True) - return True + return - def setTreeOrder(self, newOrder): + def setTreeOrder(self, order: list[str]) -> None: """A list representing the linear/flattened order of project items in the GUI project tree. The user can rearrange the order by drag-and-drop. Forwarded to the NWTree class. """ - if len(self._tree) != len(newOrder): + if len(self._tree) != len(order): logger.warning("Sizes of new and old tree order do not match") - self._tree.setOrder(newOrder) + self._tree.setOrder(order) self.setProjectChanged(True) - return True + return - def setStatusColours(self, newCols, delCols): - """Update the list of novel file status flags. - """ - return self._setStatusImport(newCols, delCols, self._data.itemStatus) + def setStatusColours(self, new: list[dict], deleted: list[str]) -> bool: + """Update the list of novel file status flags.""" + return self._setStatusImport(new, deleted, self._data.itemStatus) - def setImportColours(self, newCols, delCols): - """Update the list of note file importance flags. - """ - return self._setStatusImport(newCols, delCols, self._data.itemImport) + def setImportColours(self, new: list[dict], deleted: list[str]) -> bool: + """Update the list of note file importance flags.""" + return self._setStatusImport(new, deleted, self._data.itemImport) - def setProjectChanged(self, value): + def setProjectChanged(self, status: bool) -> bool: """Toggle the project changed flag, and propagate the information to the GUI statusbar. """ - if isinstance(value, bool): - self._projChanged = value + if isinstance(status, bool): + self._projChanged = status self.projectStatusChanged.emit(self._projChanged) return self._projChanged @@ -536,7 +536,7 @@ class NWProject(QObject): """ return self._data.editTime + round(time() - self._session.start) - def getProjectItems(self): + def getProjectItems(self) -> Iterator[NWItem]: """This function ensures that the item tree loaded is sent to the GUI tree view in such a way that the tree can be built. That is, the parent item must be sent before its child. In principle, @@ -577,19 +577,19 @@ class NWProject(QObject): logger.error("Item '%s' has no parent in current tree", tHandle) tItem.setParent(None) yield tItem + return ## # Class Methods ## - def updateWordCounts(self): - """Update the total word count values. - """ + def updateWordCounts(self) -> None: + """Update the total word count values.""" novel, notes = self._tree.sumWords() self._data.setCurrCounts(novel=novel, notes=notes) return - def countStatus(self): + def countStatus(self) -> None: """Count how many times the various status flags are used in the project tree. The counts themselves are kept in the NWStatus objects. This is essentially a refresh. @@ -603,18 +603,18 @@ class NWProject(QObject): self._data.itemImport.increment(nwItem.itemImport) return - def localLookup(self, theWord): - """Look up a word in the translation map for the project and - return it. The variable is cast to a string before lookup. If - the word does not exist, it returns itself. + def localLookup(self, word: str | int) -> str: + """Look up a word or number in the translation map for the + project and return it. The variable is cast to a string before + lookup. If the word does not exist, it returns itself. """ - return self._langData.get(str(theWord), str(theWord)) + return self._langData.get(str(word), str(word)) ## # Internal Functions ## - def _setStatusImport(self, new, delete, target): + def _setStatusImport(self, new: list[dict], delete: list[str], target: NWStatus) -> bool: """Update the list of novel file status or importance flags, and delete those that have been requested deleted. """ diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 9b8871d6..c3b09a43 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -147,7 +147,7 @@ def testCoreIndex_LoadSave(monkeypatch, prjLipsum, mockGUI, tstPaths): assert "7a992350f3eb6" in theIndex._itemIndex # Finalise - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_LoadSave @@ -195,7 +195,7 @@ def testCoreIndex_ScanThis(mockGUI): assert theBits == ["@tag", "this", "and this"] assert thePos == [0, 6, 12] - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_ScanThis @@ -273,7 +273,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd): assert theIndex.checkThese(["@who", "Jane", "John"], cItem) == [False, False, False] assert theIndex.checkThese(["@pov", "Jane", "John"], nItem) == [True, True, False] - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_CheckThese @@ -494,7 +494,7 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd): assert theIndex._itemIndex[pHandle]["T0000"].paraCount == 1 assert theIndex._itemIndex[pHandle]["T0000"].synopsis == "" - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_ScanText @@ -774,7 +774,7 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): assert theIndex.saveIndex() is True assert theProject.saveProject() is True - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_ExtractData diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index a12d933e..b4d5f338 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -58,7 +58,7 @@ def testCoreProject_NewRoot(fncPath, tstPaths, mockGUI, mockRnd): assert theProject.projChanged is True assert theProject.saveProject() is True - assert theProject.closeProject() is True + theProject.closeProject() copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) @@ -154,7 +154,7 @@ def testCoreProject_NewFileFolder(monkeypatch, fncPath, tstPaths, mockGUI, mockR assert "0000000000011" not in theProject.tree assert "0000000000012" not in theProject.tree - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreProject_NewFileFolder @@ -182,12 +182,12 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): caplog.clear() assert theProject.openProject(fncPath) is True assert "Failed to check lock file" in caplog.text - assert theProject.closeProject() + theProject.closeProject() # Force open with lockfile assert theProject._storage.writeLockFile() assert theProject.openProject(fncPath, overrideLock=True) is True - assert theProject.closeProject() + theProject.closeProject() assert theProject.getLockStatus() is None # Fail getting xml reader @@ -237,7 +237,7 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): mp.setattr("novelwriter.core.tree.NWTree.updateItemData", lambda *a: False) assert theProject.openProject(fncPath) is True - assert theProject.closeProject() + theProject.closeProject() # Trigger an index rebuild with monkeypatch.context() as mp: @@ -249,7 +249,7 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): assert "The file format of your project is about to be" in mockGUI.lastQuestion[1] assert theProject.index._indexBroken is False - assert theProject.closeProject() + theProject.closeProject() # END Test testCoreProject_Open @@ -278,7 +278,7 @@ def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncPath): # Save with and without autosave assert theProject.saveProject(autoSave=False) is True assert theProject.saveProject(autoSave=True) is True - assert theProject.closeProject() + theProject.closeProject() # END Test testCoreProject_Save @@ -316,7 +316,7 @@ def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd): C.hWorldRoot, ] assert theProject.tree.handles() == oldOrder - assert theProject.setTreeOrder(newOrder) + theProject.setTreeOrder(newOrder) assert theProject.tree.handles() == newOrder # Add a non-existing item @@ -451,7 +451,7 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd): assert len(theProject.data.itemStatus) == 0 assert len(theProject.data.itemImport) == 0 assert theProject.saveProject() is True - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreProject_StatusImport @@ -509,9 +509,9 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): # Project Language theProject.setProjectChanged(False) theProject.data.setLanguage("en") - assert theProject.setProjectLang(None) is True + theProject.setProjectLang(None) assert theProject.data.language is None - assert theProject.setProjectLang("en_GB") is True + theProject.setProjectLang("en_GB") assert theProject.data.language == "en_GB" # Language Lookup @@ -562,9 +562,9 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): "000000000000e", "000000000000f", ] assert theProject.tree.handles() == oldOrder - assert theProject.setTreeOrder(newOrder) + theProject.setTreeOrder(newOrder) assert theProject.tree.handles() == newOrder - assert theProject.setTreeOrder(oldOrder) + theProject.setTreeOrder(oldOrder) assert theProject.tree.handles() == oldOrder # END Test testCoreProject_Methods @@ -590,7 +590,7 @@ def testCoreProject_OrphanedFiles(mockGUI, prjLipsum): # Save and close assert theProject.saveProject() is True - assert theProject.closeProject() is True + theProject.closeProject() # First Item with Meta Data orphPath = prjLipsum / "content" / "636b6aa9b697b.nwd" @@ -645,7 +645,7 @@ def testCoreProject_OrphanedFiles(mockGUI, prjLipsum): assert oItem.itemLayout == nwItemLayout.NOTE assert theProject.saveProject(prjLipsum) - assert theProject.closeProject() + theProject.closeProject() # Finally, check that the orphaned files function returns # if no project is open and no path is set diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py index 38871beb..18d69c48 100644 --- a/tests/test_core/test_core_storage.py +++ b/tests/test_core/test_core_storage.py @@ -63,6 +63,7 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): assert storage.getXmlWriter() is None assert bool(storage.getDocument(C.hSceneDoc)) is False assert storage.getMetaFile("file") is None + assert storage.scanContent() == [] # Open project as a new project should fail assert storage.openProjectInPlace(fncPath, newProject=True) is False @@ -90,6 +91,9 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): assert isinstance(storage.getXmlReader(), ProjectXMLReader) assert isinstance(storage.getXmlWriter(), ProjectXMLWriter) + # Get content + assert storage.scanContent() == [C.hTitlePage, C.hChapterDoc, C.hSceneDoc] + # Get document assert storage.getDocument(C.hSceneDoc).readDocument() == "### New Scene\n\n" @@ -97,7 +101,7 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): assert storage.getMetaFile("stuff") == fncPath / "meta" / "stuff" # Clean up - assert theProject.closeProject() is True + theProject.closeProject() # Check closed project return values (again) assert storage.isOpen() is False