Source code for syncgitlab2msproject.ms_project

import dateutil
import pywintypes
import win32com.client
from datetime import datetime
from enum import IntEnum
from logging import getLogger
from os import PathLike
from typing import Any, List, Optional, Sequence, Union
from win32com.universal import com_error

from .custom_types import ComMSProjectApplication, ComMSProjectProject
from .decorators import make_none_safe
from .exceptions import ClassNotInitiated, LoadingError, MSProjectValueSetError
from .funcions import convert_to_int_or_raise_exception, raise_exception_if_not_datetime

# Classes and functions to access Microsoft Project
# Inspired by https://gist.github.com/zlorb/ff122e8563793bb28f79


logger = getLogger(f"{__package__}.{__name__}")


[docs]class PjTaskFixedType(IntEnum): """ MS Project Task Type as defined in https://docs.microsoft.com/en-us/office/vba/api/project.pjtaskfixedtype """ pjFixedDuration = 1 # Fixed Duration pjFixedUnits = 0 # Fixed Unit pjFixedWork = 2 # Fixed Work
[docs]@make_none_safe def win2python_datetime(win32datetime: "pywintypes.datetime") -> datetime: """ Convert MSProject time to Python time might be a cumbersome to convert to string first but seems the only way to be sure the timezone is correct see http://timgolden.me.uk/python/win32_how_do_i/use-a-pytime-value.html for an alternative. This solution is taken from: https://stackoverflow.com/questions/39028290/ """ return dateutil.parser.parse(str(win32datetime))
[docs]def na_win2py_datetime(win32datetime: "pywintypes.datetime") -> Optional[datetime]: """ Convert also NA datetime to Python Datetype, give None if NA """ if isinstance(win32datetime, str) and win32datetime.lower() == "na": return None else: return win2python_datetime(win32datetime)
[docs]def na_py2win_datetime(dt: Optional[datetime]) -> Union[datetime, str]: """ Convert nullable datetype for usage in MS Project """ if dt is None: return "NA" else: return dt
[docs]def get_project_path(ms_project) -> str: return ms_project.Path + "\\" + ms_project.Name
[docs]class MSProject(Sequence[Optional["Task"]]): """ Python Wrapper around the Communication with MS Project Offers at task list """ def __init__(self, doc_path: PathLike): self.project: ComMSProjectProject = None self._close_after: Optional[bool] = None self.mpp: ComMSProjectApplication = win32com.client.Dispatch( "MSProject.Application" ) self.doc_path: PathLike = doc_path def __repr__(self): if self.project is None: return "<MSProject(not loaded)>" else: return f"<MSProject('{self.project.Name}')>" def __enter__(self): self.load() return self def __exit__(self, exc_type, exc_val, exc_tb): # Only save on success if exc_type is None: self.save() if self._close_after: self.close() def _open_projects(self) -> List[str]: return [get_project_path(project) for project in self.mpp.Projects]
[docs] def load(self) -> None: """Load a given MSProject file.""" already_open = self._open_projects() try: self.mpp.FileOpen(str(self.doc_path)) self.project = self.mpp.ActiveProject self._close_after = get_project_path(self.project) not in already_open except Exception as e: logger.exception(f"Error opening file: {self.doc_path}") raise LoadingError(e)
[docs] def close(self) -> None: """Forces a close without saving (has to be done manually)""" if self.project is not None: try: self.mpp.FileClose(False) except com_error as e: logger.info(f"File close failed: {e}") self.mpp.Quit() del self.mpp
[docs] def save(self) -> None: """Close an open MSProject, saving changes.""" if self.project is not None: self.mpp.FileSave()
def __len__(self) -> int: if self.project is None: raise ClassNotInitiated("Can't get length for a not loaded project") return self.project.Tasks.Count
[docs] def add_task(self, name: str) -> "Task": ms_task = self.project.Tasks.Add(name) return Task(self, ms_task.ID - 1)
[docs] def get_task(self, task_nr: int) -> Optional["Task"]: return self.project.Tasks(task_nr + 1)
# TODO: Fix MYPY def __getitem__(self, i: int) -> Optional["Task"]: # type: ignore if i >= len(self): raise IndexError("Out of tasks") if self.get_task(i) is None: return None else: return Task(self, i)
[docs]class Task: """ Python Wrapper Class around MS Project Task API Find API reference here: https://docs.microsoft.com/office/vba/api/project.task this helps to use the Task object in a pythonic way, converting all values automatically. Properties naming follows PEP8 (lower case naming) """ __slots__ = ("_project", "_tasknr") def __init__(self, project: MSProject, task_number: int): self._project = project self._tasknr = task_number def __repr__(self): return f"<Task({self._project.__repr__()}, {self._tasknr}) '{self.name}'>" def __str__(self): return f"'{self.name}' (ID: {self.id})" def _get_task(self): return self._project.get_task(self._tasknr) def _set_task_val(self, attribute: str, value: Any): """ Set attribute to MS Project task but do not fail if set is not working """ try: setattr(self._get_task(), attribute, value) except com_error as e: logger.error( f"Could not set attribute {attribute} with value '{value}' " f"({type(value)}) for {self}.\n Exception: '{e}'" ) @property def id(self) -> int: return self._get_task().ID @property def has_children(self) -> bool: return len(self._get_task().OutlineChildren) > 0 @property def name(self) -> str: return self._get_task().Name @name.setter def name(self, value: str): self._set_task_val("Name", value) @property def start(self) -> datetime: assert (val := win2python_datetime(self._get_task().Start)) is not None return val @start.setter def start(self, value: datetime): raise_exception_if_not_datetime(value) self._set_task_val("Start", value) @property def finish(self) -> datetime: assert (val := win2python_datetime(self._get_task().Finish)) is not None return val @finish.setter def finish(self, value: datetime): raise_exception_if_not_datetime(value) self._set_task_val("Finish", value) @property def actual_start(self) -> Optional[datetime]: return na_win2py_datetime(self._get_task().ActualStart) @actual_start.setter def actual_start(self, value: Optional[datetime]): if value is not None: raise_exception_if_not_datetime(value) self._set_task_val("ActualStart", na_py2win_datetime(value)) @property def actual_finish(self) -> Optional[datetime]: return na_win2py_datetime(self._get_task().ActualFinish) @actual_finish.setter def actual_finish(self, value: Optional[datetime]): if value is not None: raise_exception_if_not_datetime(value) self._set_task_val("ActualFinish", na_py2win_datetime(value)) @property def deadline(self) -> Optional[datetime]: return na_win2py_datetime(self._get_task().Deadline) @deadline.setter def deadline(self, value: Optional[datetime]): if value is not None: raise_exception_if_not_datetime(value) self._set_task_val("Deadline", na_py2win_datetime(value)) @property def notes(self) -> str: return self._get_task().Notes @notes.setter def notes(self, value: str): self._set_task_val("Notes", value) @property def duration(self) -> Optional[int]: """Gets the duration (in minutes) of a task. Read-only for summary tasks. Read/write Variant.""" return self._get_task().duration @duration.setter def duration(self, value: int): self._set_task_val("duration", convert_to_int_or_raise_exception(value)) @property def percent_complete(self) -> int: return self._get_task().PercentComplete @percent_complete.setter def percent_complete(self, value: int): value = convert_to_int_or_raise_exception(value) if isinstance(value, int) and 0 <= value <= 100: self._set_task_val("PercentComplete", value) else: raise MSProjectValueSetError( "Attribute percent_complete must be an integer between 0 and 100" ) @property def work(self) -> int: """Gets or sets the work (in minutes) for the task. Read/write Variant.""" return self._get_task().Work @work.setter def work(self, value: int): self._set_task_val("Work", convert_to_int_or_raise_exception(value)) @property def actual_work(self): """ Gets or sets the actual work (in minutes) for the task. Read/write Variant. """ return self._get_task().ActualWork @actual_work.setter def actual_work(self, value: int): self._set_task_val("ActualWork", convert_to_int_or_raise_exception(value)) @property def estimated(self) -> bool: """ True if the task duration is an estimate. False if the task duration is a set value. Read/write Variant. """ return self._get_task().Estimated @estimated.setter def estimated(self, value: bool): self._set_task_val("Estimated", value) @property def hyperlink_name(self) -> str: """set or get hyplerlink (name) see https://docs.microsoft.com/en-us/office/vba/api/project.task.hyperlink """ return self._get_task().Hyperlink @hyperlink_name.setter def hyperlink_name(self, value: str): self._set_task_val("Hyperlink", value) @property def hyperlink_address(self) -> str: """set or get hyplerlink (url) see https://docs.microsoft.com/en-us/office/vba/api/project.task.hyperlink """ return self._get_task().HyperlinkAddress @hyperlink_address.setter def hyperlink_address(self, value: str): self._set_task_val("HyperlinkAddress", value) @property def outline_level(self) -> int: return self._get_task().OutlineLevel @outline_level.setter def outline_level(self, value: int): value = convert_to_int_or_raise_exception(value) if value >= 1: self._set_task_val("OutlineLevel", value) else: raise MSProjectValueSetError( "Outline Level has to be an int larger then zero." ) @property def text1(self) -> str: """get or sets the Text1 Property""" return self._get_task().Text1 @text1.setter def text1(self, value: str): self._set_task_val("Text1", value) @property def text2(self) -> str: """get or sets the Text2 Property""" return self._get_task().Text2 @text2.setter def text2(self, value: str): self._set_task_val("Text2", value) @property def text3(self) -> str: """get or sets the Text3 Property""" return self._get_task().Text3 @text3.setter def text3(self, value: str): self._set_task_val("Text3", value) @property def text4(self) -> str: """get or sets the Text4 Property""" return self._get_task().Text4 @text4.setter def text4(self, value: str): self._set_task_val("Text4", value) @property def text5(self) -> str: """get or sets the Text5 Property""" return self._get_task().Text5 @text5.setter def text5(self, value: str): self._set_task_val("Text5", value) @property def text6(self) -> str: """get or sets the Text6 Property""" return self._get_task().Text6 @text6.setter def text6(self, value: str): self._set_task_val("Text6", value) @property def text7(self) -> str: """get or sets the Text7 Property""" return self._get_task().Text7 @text7.setter def text7(self, value: str): self._set_task_val("Text7", value) @property def text8(self) -> str: """get or sets the Text8 Property""" return self._get_task().Text8 @text8.setter def text8(self, value: str): self._set_task_val("Text8", value) @property def text9(self) -> str: """get or sets the Text9 Property""" return self._get_task().Text9 @text9.setter def text9(self, value: str): self._set_task_val("Text9", value) @property def text10(self) -> str: """get or sets the Text10 Property""" return self._get_task().Text10 @text10.setter def text10(self, value: str): self._set_task_val("Text10", value) @property def text11(self) -> str: """get or sets the Text11 Property""" return self._get_task().Text11 @text11.setter def text11(self, value: str): self._set_task_val("Text11", value) @property def text12(self) -> str: """get or sets the Text12 Property""" return self._get_task().Text12 @text12.setter def text12(self, value: str): self._set_task_val("Text12", value) @property def text13(self) -> str: """get or sets the Text13 Property""" return self._get_task().Text13 @text13.setter def text13(self, value: str): self._set_task_val("Text13", value) @property def text14(self) -> str: """get or sets the Text14 Property""" return self._get_task().Text14 @text14.setter def text14(self, value: str): self._set_task_val("Text14", value) @property def text15(self) -> str: """get or sets the Text15 Property""" return self._get_task().Text15 @text15.setter def text15(self, value: str): self._set_task_val("Text15", value) @property def text16(self) -> str: """get or sets the Text16 Property""" return self._get_task().Text16 @text16.setter def text16(self, value: str): self._set_task_val("Text16", value) @property def text17(self) -> str: """get or sets the Text17 Property""" return self._get_task().Text17 @text17.setter def text17(self, value: str): self._set_task_val("Text17", value) @property def text18(self) -> str: """get or sets the Text18 Property""" return self._get_task().Text18 @text18.setter def text18(self, value: str): self._set_task_val("Text18", value) @property def text19(self) -> str: """get or sets the Text19 Property""" return self._get_task().Text19 @text19.setter def text19(self, value: str): self._set_task_val("Text19", value) @property def text20(self) -> str: """get or sets the Text20 Property""" return self._get_task().Text20 @text20.setter def text20(self, value: str): self._set_task_val("Text20", value) @property def text21(self) -> str: """get or sets the Text21 Property""" return self._get_task().Text21 @text21.setter def text21(self, value: str): self._set_task_val("Text21", value) @property def text22(self) -> str: """get or sets the Text22 Property""" return self._get_task().Text22 @text22.setter def text22(self, value: str): self._set_task_val("Text22", value) @property def text23(self) -> str: """get or sets the Text23 Property""" return self._get_task().Text23 @text23.setter def text23(self, value: str): self._set_task_val("Text23", value) @property def text24(self) -> str: """get or sets the Text24 Property""" return self._get_task().Text24 @text24.setter def text24(self, value: str): self._set_task_val("Text24", value) @property def text25(self) -> str: """get or sets the Text25 Property""" return self._get_task().Text25 @text25.setter def text25(self, value: str): self._set_task_val("Text25", value) @property def text26(self) -> str: """get or sets the Text26 Property""" return self._get_task().Text26 @text26.setter def text26(self, value: str): self._set_task_val("Text26", value) @property def text27(self) -> str: """get or sets the Text27 Property""" return self._get_task().Text27 @text27.setter def text27(self, value: str): self._set_task_val("Text27", value) @property def text28(self) -> str: """get or sets the Text28 Property""" return self._get_task().Text28 @text28.setter def text28(self, value: str): self._set_task_val("Text28", value) @property def text29(self) -> str: """get or sets the Text29 Property""" return self._get_task().Text29 @text29.setter def text29(self, value: str): self._set_task_val("Text29", value) @property def text30(self) -> str: """get or sets the Text30 Property""" return self._get_task().Text30 @text30.setter def text30(self, value: str): self._set_task_val("Text30", value) @property def type(self) -> PjTaskFixedType: return PjTaskFixedType(self._get_task().Type) @type.setter def type(self, value: Union[PjTaskFixedType, int]): if isinstance(value, int): value = PjTaskFixedType(value) if value.value not in [item.value for item in PjTaskFixedType]: raise ValueError(f"Can't set '{value}' it is not a valid task type.") self._set_task_val("Type", value.value) @property def effort_driven(self) -> bool: return self._get_task().EffortDriven @effort_driven.setter def effort_driven(self, value: bool): if self.type == PjTaskFixedType.pjFixedWork: if not value: raise ValueError("Can't unset EffortDrive for FixedWork") # In case we have Fixed Work it is already effort driven, so lets # ignore it else: self._set_task_val("EffortDriven", bool(value))