MS Project Schedule Parsing
Two schedulers on the same job rarely use the same tool: the owner’s team runs Primavera P6, the trade contractor runs Microsoft Project, and the change order engine downstream must not care which one produced a given activity. The specific sub-problem this page solves is how a pipeline converts a Microsoft Project export — the MSPDI XML schema, with its Tasks, Task UID and OutlineNumber, PredecessorLink, and Calendars elements — into the exact same typed, WBS-bound ScheduleActivity model that Primavera P6 export parsing emits, so that all downstream critical-path and impact logic stays source-agnostic. When that normalization is skipped, a project team ends up with two parallel schedule representations that drift apart: an MSP task whose duration is measured in working hours never reconciles against a P6 activity whose float is measured in days, and the moment someone tries to compare them the critical path delta detection stage silently compares apples to timezone-naive oranges. This page details the full MSPDI-to-ScheduleActivity pipeline: parsing the XML with the standard-library xml.etree.ElementTree while respecting the MSPDI default namespace, decoding the PT8H0M0S ISO 8601 duration format, coercing bare local timestamps into timezone-aware values, and binding each task to a canonical Work Breakdown Structure node before anything routes. It targets Python automation builders and schedulers who need one activity contract regardless of which planning tool authored the file. It sits inside the broader schedule sync integration subsystem alongside the P6 parser, feeding the same critical-path and baseline comparators.
Prerequisites
This subsystem sits downstream of file intake and upstream of the schedule comparators. Before implementing the patterns below, you need:
- Python 3.11+ with
pydanticv2 for typed validation, plus the standard-libraryxml.etree.ElementTree,re,decimal,datetime,zoneinfo,enum, andloggingmodules. No third-party XML dependency is required; MSPDI is plain XML and the stdlib parser handles it once the namespace is registered. - The canonical activity contract shared with P6. The whole point is a single
ScheduleActivitymodel, so both parsers must import the same schema. The MSP parser’s only job is to fill that contract from a different source shape, exactly as Primavera P6 export parsing fills it from tab-delimited XER tables. - A canonical WBS map to resolve tasks against. Each activity binds to the same Work Breakdown Structure that drives WBS mapping strategies; the MSP
OutlineNumberor a custom text field carries the crosswalk hint, and the resolver turns it into aPROJ-NNN-DIV-NNnode. - A project time zone. MSPDI timestamps are written as bare local datetimes with no offset, so the pipeline needs the project’s IANA zone (for example
America/New_York) to make everyStartandFinishtimezone-aware before it reaches the schedule math. - A dead-letter path. Tasks that fail to parse, carry an unresolvable outline number, or bind below the confidence floor are parked rather than dropped; escalation of parked records is owned by fallback alert routing.
The pipeline assumes the uploaded .xml file has already cleared structural schema validation rules at the ingestion boundary — the root is a well-formed MSPDI <Project> — so the work here is MSPDI-specific extraction, normalization, and WBS binding, not raw upload handling.
Architecture: parse, normalize, bind, route
An MSPDI file is a single <Project> document whose <Tasks> block holds one <Task> per row. The parser cannot treat those tasks as a flat list: task UID 0 is a reserved project summary row that must be skipped, summary tasks (<Summary>1</Summary>) roll up their children and carry no independent scope, and every element is namespace-qualified under the MSPDI default namespace, so an unqualified tag lookup returns nothing. The stages below run in strict order — parse, normalize, bind, route — and each has a failure branch that feeds the dead-letter queue rather than emitting a half-formed activity.
Each stage transforms one representation into the next and owns exactly one failure mode, which keeps the parser debuggable: a record that reaches the comparators has provably cleared every earlier gate.
| Stage | Input | Output | Error branch |
|---|---|---|---|
| Namespace-aware parse | MSPDI <Project> tree |
Per-task element bundle | Unbound namespace / missing <Tasks> → quarantine |
| Normalize duration + dates | PT#H#M#S string + local timestamps |
Decimal days + timezone-aware datetime |
Bad ISO 8601 duration / naive timestamp → quarantine |
| WBS binding | OutlineNumber or crosswalk field |
Canonical WBS node + confidence | < 0.75 → quarantine; 0.75–0.92 → review |
Emit ScheduleActivity |
Bound, normalized task | Typed activity record (P6-identical) | Contract validation error → quarantine |
| Route | Validated activity | Comparators / review / dead-letter | SLA breach → fallback alert router |
Step-by-step implementation
Step 1 — Bind the MSPDI namespace and iterate tasks
Every element in an MSPDI file lives in the default namespace http://schemas.microsoft.com/project, declared once on the root <Project xmlns="...">. ElementTree does not strip that namespace; it prefixes every tag with {http://schemas.microsoft.com/project}, so a lookup for the bare tag "Task" finds nothing. The robust pattern is to register the namespace under a short prefix and use it in every find/findall path. Task UID 0 is the project summary row — it has no real scope — so it is skipped, and the <ID> field is the 1-based ordinal position that field staff recognize.
from __future__ import annotations
import logging
from xml.etree.ElementTree import Element, parse
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("mspdi.parse")
# The MSPDI default namespace. ElementTree qualifies every tag with this URI,
# so all lookups must go through the registered prefix, never the bare tag.
MSPDI_NS = {"p": "http://schemas.microsoft.com/project"}
def _text(node: Element, path: str, default: str | None = None) -> str | None:
"""Namespace-safe child text lookup; returns default when absent or empty."""
child = node.find(path, MSPDI_NS)
if child is None or child.text is None or not child.text.strip():
return default
return child.text.strip()
def iter_task_elements(xml_path: str) -> list[Element]:
"""Yield every real <Task> element, skipping the reserved UID 0 summary row."""
root = parse(xml_path).getroot()
tasks_block = root.find("p:Tasks", MSPDI_NS)
if tasks_block is None:
raise ValueError("MSPDI file has no <Tasks> block; not a schedule export")
real_tasks: list[Element] = []
for task in tasks_block.findall("p:Task", MSPDI_NS):
uid = _text(task, "p:UID")
if uid is None:
logger.warning("Task with no UID skipped")
continue
if uid == "0":
continue # UID 0 is the project summary; it carries no scope
real_tasks.append(task)
logger.info("Parsed %d real tasks from %s", len(real_tasks), xml_path)
return real_tasksStep 2 — Decode the PT8H0M0S duration and coerce dates to timezone-aware
MSPDI expresses task durations as ISO 8601 durations of working time — PT8H0M0S is one eight-hour working day, PT16H0M0S is two. Converting to days requires the calendar’s HoursPerDay (usually 8), not a wall-clock 24. Dates arrive as bare local timestamps such as 2026-07-15T08:00:00 with no offset, so they must be stamped with the project’s IANA time zone before any comparison; a naive datetime silently corrupts float and duration math the moment two schedules from different sites are compared, exactly the failure the ISO 8601 date and time standard exists to prevent.
import re
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
# ISO 8601 working-time duration as MS Project writes it: PT<h>H<m>M<s>S.
_DURATION_RE = re.compile(r"^PT(\d+)H(\d+)M(\d+)S$")
def duration_to_days(raw: str, hours_per_day: Decimal = Decimal("8")) -> Decimal:
"""Convert an MSPDI PT#H#M#S working-time duration into working days."""
match = _DURATION_RE.match(raw.strip())
if match is None:
raise ValueError(f"Unrecognized MSPDI duration format: {raw!r}")
hours, minutes, seconds = (Decimal(g) for g in match.groups())
total_hours = hours + (minutes / Decimal("60")) + (seconds / Decimal("3600"))
# Round to 3 dp of a working day; a task is priced/scheduled in days, not seconds.
return (total_hours / hours_per_day).quantize(Decimal("0.001"))
def to_aware(raw: str, project_tz: ZoneInfo) -> datetime:
"""Parse a bare MSPDI local timestamp and stamp it with the project zone."""
parsed = datetime.fromisoformat(raw) # e.g. 2026-07-15T08:00:00 — no offset
if parsed.tzinfo is not None:
return parsed # already carried an offset; trust it
return parsed.replace(tzinfo=project_tz)Step 3 — Bind each task to a canonical WBS node by confidence
MS Project stores hierarchy in OutlineNumber ("1.2.3") and OutlineLevel, not in an enterprise WBS code. Binding is a resolution problem, and it reuses the site-canonical confidence bands so the behavior matches every other routing decision on the site: an exact crosswalk hit (an outline number or custom field that maps directly to a PROJ-NNN-DIV-NN node) scores 0.92 or above and auto-routes, a fuzzy or division-level match scores in the 0.75–0.92 band and waits for a scheduler to confirm, and anything below 0.75 is quarantined rather than bound to a guessed node. This is the same contract described in WBS mapping strategies, applied to a schedule task instead of a cost code.
from typing import Literal
WBS_RE = re.compile(r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$")
AUTO_ROUTE = Decimal("0.92") # site-canonical: >= 0.92 binds automatically
REVIEW_FLOOR = Decimal("0.75") # 0.75–0.92 holds for a scheduler; < 0.75 quarantines
RouteState = Literal["auto_route", "human_review", "quarantine"]
def classify_confidence(score: Decimal) -> RouteState:
if score >= AUTO_ROUTE:
return "auto_route"
if score >= REVIEW_FLOOR:
return "human_review"
return "quarantine"
def bind_wbs(
outline_number: str,
outline_crosswalk: dict[str, str],
division_crosswalk: dict[str, str],
) -> tuple[str | None, Decimal, RouteState]:
"""Resolve an MSP OutlineNumber to a canonical WBS node with a confidence score."""
if outline_number in outline_crosswalk:
node = outline_crosswalk[outline_number]
if not WBS_RE.match(node):
raise ValueError(f"Crosswalk target violates PROJ-NNN-DIV-NN: {node!r}")
return node, Decimal("0.98"), "auto_route"
# Fall back to the parent outline (e.g. "1.2.3" -> "1.2") for a division rollup.
parent = outline_number.rsplit(".", 1)[0] if "." in outline_number else outline_number
if parent in division_crosswalk:
return division_crosswalk[parent], Decimal("0.83"), "human_review"
return None, Decimal("0.00"), "quarantine" # never bind to a guessed nodeStep 4 — Emit the shared ScheduleActivity contract
The final step fills the same ScheduleActivity model the P6 parser produces, tagging source="msp" so provenance survives without changing the shape. Identity, dates, float, and criticality are typed exactly as the comparators expect; predecessor links carry the MSPDI relationship type decoded from its integer code (0=FF, 1=FS, 2=SF, 3=SS). Because the contract is identical across sources, critical path delta detection and baseline comparison never branch on tool of origin.
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_LINK_TYPES = {"0": "FF", "1": "FS", "2": "SF", "3": "SS"}
LinkType = Literal["FF", "FS", "SF", "SS"]
class PredecessorLink(BaseModel):
predecessor_uid: int = Field(ge=1)
link_type: LinkType = "FS"
lag_days: Decimal = Field(default=Decimal("0"))
class ScheduleActivity(BaseModel):
"""Source-agnostic activity contract; P6 and MSP parsers both emit this shape."""
model_config = ConfigDict(frozen=True)
activity_id: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=240)
wbs_node: str = Field(pattern=r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$")
start: datetime
finish: datetime
duration_days: Decimal = Field(ge=0)
total_float_days: int
is_critical: bool
predecessors: list[PredecessorLink] = Field(default_factory=list)
source: Literal["p6", "msp"]
@field_validator("start", "finish")
@classmethod
def require_tz_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("start/finish must be timezone-aware (see ISO 8601)")
return v
@model_validator(mode="after")
def finish_after_start(self) -> "ScheduleActivity":
if self.finish < self.start:
raise ValueError("finish precedes start")
# Total float below zero means the activity is behind and drives the path.
if self.total_float_days <= 0 and not self.is_critical:
raise ValueError("non-positive total float must be marked critical")
return self
def parse_predecessors(task: Element) -> list[PredecessorLink]:
"""Decode every <PredecessorLink> under a task into typed links."""
links: list[PredecessorLink] = []
for link in task.findall("p:PredecessorLink", MSPDI_NS):
uid = _text(link, "p:PredecessorUID")
if uid is None:
continue
type_code = _text(link, "p:Type", default="1") # default Finish-Start
links.append(
PredecessorLink(
predecessor_uid=int(uid),
link_type=_LINK_TYPES.get(type_code or "1", "FS"),
)
)
return links
def build_activity(
task: Element,
wbs_node: str,
project_tz: ZoneInfo,
hours_per_day: Decimal = Decimal("8"),
) -> ScheduleActivity:
"""Assemble one ScheduleActivity from a normalized, bound MSPDI task element."""
total_float = int(_text(task, "p:TotalSlack", default="0") or "0")
return ScheduleActivity(
activity_id=_text(task, "p:UID") or "",
name=_text(task, "p:Name", default="(unnamed task)") or "(unnamed task)",
wbs_node=wbs_node,
start=to_aware(_text(task, "p:Start") or "", project_tz),
finish=to_aware(_text(task, "p:Finish") or "", project_tz),
duration_days=duration_to_days(_text(task, "p:Duration") or "PT0H0M0S", hours_per_day),
total_float_days=total_float,
is_critical=(_text(task, "p:Critical", default="0") == "1"),
predecessors=parse_predecessors(task),
source="msp",
)Note that MSPDI TotalSlack is expressed in tenths of a minute in some exports; when the source calendar reports slack that way, divide by the calendar’s minutes-per-day before casting to whole days rather than trusting the raw integer.
Schema and configuration reference
| MSPDI element | Extracted field | Rule | Why it matters |
|---|---|---|---|
Task/UID |
activity_id |
UID 0 skipped; real UIDs ≥ 1 |
Drops the project-summary phantom row |
Task/ID |
ordinal position | 1-based sequence | The row number field staff reference |
Task/Name |
name |
Required, non-empty | Human label carried into comparators |
Task/OutlineNumber |
WBS crosswalk key | Dotted path 1.2.3 |
Resolves to PROJ-NNN-DIV-NN |
Task/Summary |
filter flag | 1 = rollup, skipped as leaf scope |
Summary tasks carry no independent activity |
Task/Duration |
duration_days |
PT#H#M#S ÷ HoursPerDay |
Working days, never wall-clock 24h |
Task/Start,Finish |
start,finish |
Bare local → project tz | Prevents naive-datetime float drift |
Task/TotalSlack |
total_float_days |
Integer days | Feeds critical-path detection |
Task/Critical |
is_critical |
1/0 boolean |
Marks driving activities |
PredecessorLink/Type |
link_type |
0/1/2/3 → FF/FS/SF/SS |
Relationship logic downstream |
Routing constants are site-canonical and must appear identically wherever schedule tasks bind: AUTO_ROUTE = Decimal("0.92") and REVIEW_FLOOR = Decimal("0.75"). The MSPDI namespace URI (http://schemas.microsoft.com/project) and the project HoursPerDay (default Decimal("8")) are the two configuration values that vary per export.
| Config key | Default | Source |
|---|---|---|
MSPDI_NS["p"] |
http://schemas.microsoft.com/project |
Fixed by the MSPDI schema |
hours_per_day |
Decimal("8") |
Calendar <HoursPerDay> |
project_tz |
ZoneInfo("America/New_York") |
Project settings |
AUTO_ROUTE |
Decimal("0.92") |
Site-canonical band |
REVIEW_FLOOR |
Decimal("0.75") |
Site-canonical band |
Verification and testing
Treat parsing, duration decoding, and binding as pure functions and assert against known inputs. Round-trip the emitted contract with model_dump_json so the wire format shared with the P6 parser is part of the test surface.
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
def test_duration_decodes_to_working_days():
assert duration_to_days("PT8H0M0S") == Decimal("1.000")
assert duration_to_days("PT16H0M0S") == Decimal("2.000")
assert duration_to_days("PT4H0M0S") == Decimal("0.500")
def test_bad_duration_raises():
import pytest
with pytest.raises(ValueError):
duration_to_days("8 days")
def test_naive_timestamp_is_made_aware():
tz = ZoneInfo("America/New_York")
aware = to_aware("2026-07-15T08:00:00", tz)
assert aware.tzinfo is not None
assert aware.utcoffset() is not None
def test_exact_outline_binds_and_auto_routes():
node, score, state = bind_wbs(
"1.2.3",
outline_crosswalk={"1.2.3": "PROJ-014-STR-02"},
division_crosswalk={},
)
assert node == "PROJ-014-STR-02"
assert state == "auto_route"
assert score >= AUTO_ROUTE
def test_unmapped_outline_quarantines():
node, score, state = bind_wbs("9.9.9", {}, {})
assert node is None
assert state == "quarantine"
assert score < REVIEW_FLOOR
def test_activity_round_trips_and_matches_contract():
tz = ZoneInfo("America/New_York")
act = ScheduleActivity(
activity_id="12",
name="Erect structural steel — level 3",
wbs_node="PROJ-014-STR-02",
start=datetime(2026, 7, 15, 8, 0, tzinfo=tz),
finish=datetime(2026, 7, 17, 17, 0, tzinfo=tz),
duration_days=Decimal("3.000"),
total_float_days=0,
is_critical=True,
source="msp",
)
restored = ScheduleActivity.model_validate_json(act.model_dump_json())
assert restored.source == "msp"
assert restored.wbs_node == act.wbs_nodeRun the suite with pytest -q. For a quick manual smoke test, point iter_task_elements at a sample export and print len(...) — it should equal the task count in Project minus the summary row — then build one activity and confirm its start echoes back with an offset.
Troubleshooting
findall("Task")returns an empty list from a valid file. The tags are namespace-qualified, so the bare name never matches. Register the MSPDI namespace once (MSPDI_NS = {"p": "http://schemas.microsoft.com/project"}) and query with the prefix (findall("p:Task", MSPDI_NS)), or every lookup silently yields nothing and the parser reports zero tasks on a full schedule.ValueError: Unrecognized MSPDI duration format. The export used a different working-time granularity — for instancePT8H30M0Sfor a half-hour tail, or an elapsed duration likeP2DT0H0M0Swith a leadingP#D. Widen_DURATION_REto capture an optionalP(\d+)Dday group and fold those days in atHoursPerDay, rather than assuming every duration isPT-only.- Float math is off by hours between an MSP and a P6 activity. The MSP timestamps were left naive, so a comparison against a timezone-aware P6 activity compared local wall-clock to UTC. Always run
to_awarewith the project’sZoneInfoat parse time; never let a baredatetime.fromisoformatresult reach the comparators. - A whole outline branch binds to the wrong division. MS Project renumbers
OutlineNumberwhen a summary task is inserted or moved, so a crosswalk keyed on last week’s outline path resolves this week’s tasks to the wrong node. Key the crosswalk on a stable custom text field (an enterprise WBS attribute set in Project) and treatOutlineNumberonly as a fuzzy fallback that lands in the review band. - Summary tasks double-count scope. Emitting an activity for a
<Summary>1</Summary>row and its children posts the same duration twice. Filter summary tasks to leaf-only before binding, and reconstruct the hierarchy fromOutlineLevelseparately if the comparators need the rollup.
Frequently Asked Questions
Why register the MSPDI namespace instead of stripping it?
MSPDI declares a default namespace on the root <Project>, and ElementTree faithfully qualifies every tag with the full URI. Registering it under a short prefix and querying p:Task is explicit and safe. Stripping the namespace with a text hack works until an export nests a second namespace (custom fields sometimes do), at which point the strip silently drops elements. The prefix approach never has that failure mode.
Why convert PT8H0M0S durations to days with HoursPerDay rather than 24?
MSPDI durations are working-time, not elapsed time. PT8H0M0S is a full working day on a standard 8-hour calendar, not a third of a calendar day. Dividing by the calendar’s HoursPerDay yields the working-day figure schedulers and the critical-path engine expect; dividing by 24 would understate every duration by two-thirds and misclassify which activities drive the path.
How does the MS Project parser stay compatible with the Primavera P6 output?
Both parsers import and emit the identical ScheduleActivity model; only the extraction differs. P6 reads tab-delimited XER tables, MS Project reads MSPDI XML, but each fills the same typed fields — activity_id, wbs_node, timezone-aware start/finish, total_float_days, is_critical, and typed predecessor links — and tags source. Downstream critical-path and baseline logic consumes the contract, never the raw file, so it never branches on tool of origin.
What happens to a task whose OutlineNumber does not resolve?
It is quarantined, not bound to a guessed node. The resolver returns a confidence below 0.75 and no WBS node, which parks the record in the dead-letter queue for a scheduler to triage. Binding a task to an approximate node would let its float and criticality post against the wrong scope, which is exactly the silent corruption the confidence floor prevents.
Why skip UID 0 and summary tasks?
Task UID 0 is a reserved project-summary row that aggregates the whole schedule; it has no independent scope and no meaningful WBS binding. Summary tasks (<Summary>1</Summary>) roll up their children, so emitting activities for them double-counts duration and float. Filtering both to leaf-level activities keeps the emitted set one-to-one with real work.