project2latex/project2latex/project.py

184 lines
5.4 KiB
Python

"""
Data model for project files.
Defines a data model and methods for parsing Microsoft Project XML (MSPDI)
files into this model.
"""
from datetime import datetime
from dataclasses import dataclass as dataclass
from enum import Enum as Enum
"""MSPDI XML namespace."""
PROJECT_NS = 'http://schemas.microsoft.com/project'
def find(elt, tagname):
"""
Find a child element with a given tag name in the project namespace.
:param elt: root element in which to search
:param tagname: unnamespaced tag name of the child element
:returns: an Element instance if such a child is found, or None otherwise
"""
return elt.find(('{%s}' % PROJECT_NS) + tagname)
def find_text(elt, tagname):
"""
Retrieve the text content of a child with a given tag name.
:param elt: root element in which to search
:param tagname: unnamespaced tag name of the child element
:returns: text contained in the matching child element, or None otherwise
"""
child = find(elt, tagname)
if child is None:
return None
else:
return child.text
class TaskKind(Enum):
"""
Type of project task.
"""
bar = 0
milestone = 1
class PredecessorKind(Enum):
"""
Type of predecessor relationship between two tasks.
"""
none = -1
finish_finish = 0
finish_start = 1
start_finish = 2
start_start = 3
@dataclass
class Task:
"""
Project task.
:var uid: identifier for the task that is unique across the project
:var start: date and time at which the task starts
:var finish: date and time at which the task ends
:var name: label describing the work that has to be done in this task
:var kind: kind of task
:var predecessor_uid: unique identifier of a task that needs to be finished
before starting this task. If this value is 0 or if the
:py:attr:`predecessor_kind` attribute is :py:attr:`PredecessorKind.none`,
this task has no predecessor
:var predecessor_kind: kind of relationship with the preceding task
"""
uid: int
start: datetime
finish: datetime
name: str
kind: TaskKind
predecessor_uid: int
predecessor_kind: PredecessorKind
@staticmethod
def from_xml(task_elt):
"""
Turn a Task element into a task instance.
:param task_elt: root Task element to transform
:return: a task instance if the given element represents a valid
task, or None otherwise
"""
assert task_elt.tag == '{%s}Task' % PROJECT_NS
if find_text(task_elt, 'IsNull') == '1':
return None
# required attributes
uid = int(find_text(task_elt, 'UID'))
start = datetime.fromisoformat(find_text(task_elt, 'Start'))
finish = datetime.fromisoformat(find_text(task_elt, 'Finish'))
if uid is None or start is None or finish is None:
return None
# ignore dummy task of UID 0
if uid == 0:
return None
# optional label of the task
name = find_text(task_elt, 'Name') or ''
# find out the kind of task
is_milestone = find_text(task_elt, 'Milestone') == '1'
kind = TaskKind.milestone if is_milestone else TaskKind.bar
# preceding task, if any
link_elt = find(task_elt, 'PredecessorLink')
pred_uid = 0
pred_kind = PredecessorKind.none
if link_elt is not None:
pred_uid = int(find_text(link_elt, 'PredecessorUID'))
pred_kind = PredecessorKind(int(find_text(link_elt, 'Type')))
return Task(uid, start, finish, name, kind, pred_uid, pred_kind)
# TODO: parse calendars and holidays
# @dataclass
# class Calendar:
# uid: int
# name: str
# days: dict
# exceptions: list
# @staticmethod
# def from_xml(calendar_elt):
# assert calendar_elt.tag == '{%s}Calendar' % PROJECT_NS
# uid = int(find_text(calendar_elt, 'UID'))
# # find which days are working (by default, none)
# days = dict()
# for day_number in range(1, 8):
# days[day_number] = False
# week_days = find(calendar_elt, 'WeekDays')
# for week_day in week_days:
# day_number = int(find_text(week_day, 'DayType'))
# day_working = find_text(week_day, 'DayWorking') == '1'
# days[day_number] = day_working
# # working exceptions
# exceptions =
@dataclass
class Project:
"""
Project.
:var start: date and time at which the project starts
:var finish: date and time at which the project ends
:var tasks: list of tasks in the project
"""
start: datetime
finish: datetime
tasks: list
@staticmethod
def from_xml(project_elt):
"""
Turn a Project element into a project instance.
:param project_elt: root Project element to transform
:return a project instance if the given element represents a valid
project, or None otherwise
"""
assert project_elt.tag == '{%s}Project' % PROJECT_NS
start = datetime.fromisoformat(find_text(project_elt, 'StartDate'))
finish = datetime.fromisoformat(find_text(project_elt, 'FinishDate'))
tasks_elt = find(project_elt, 'Tasks')
if start is None or finish is None or tasks_elt is None:
return None
raw_tasks = [Task.from_xml(task) for task in tasks_elt]
tasks = [task for task in raw_tasks if task is not None]
return Project(start, finish, tasks)