pymppwriter 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymppwriter-0.3.0/LICENSE +21 -0
- pymppwriter-0.3.0/PKG-INFO +259 -0
- pymppwriter-0.3.0/README.md +225 -0
- pymppwriter-0.3.0/pymppwriter/__init__.py +12 -0
- pymppwriter-0.3.0/pymppwriter/blocks.py +363 -0
- pymppwriter-0.3.0/pymppwriter/cfb.py +248 -0
- pymppwriter-0.3.0/pymppwriter/cli.py +107 -0
- pymppwriter-0.3.0/pymppwriter/native_fields.json +1991 -0
- pymppwriter-0.3.0/pymppwriter/reader.py +248 -0
- pymppwriter-0.3.0/pymppwriter/writer.py +1258 -0
- pymppwriter-0.3.0/pymppwriter.egg-info/PKG-INFO +259 -0
- pymppwriter-0.3.0/pymppwriter.egg-info/SOURCES.txt +17 -0
- pymppwriter-0.3.0/pymppwriter.egg-info/dependency_links.txt +1 -0
- pymppwriter-0.3.0/pymppwriter.egg-info/entry_points.txt +2 -0
- pymppwriter-0.3.0/pymppwriter.egg-info/requires.txt +6 -0
- pymppwriter-0.3.0/pymppwriter.egg-info/top_level.txt +1 -0
- pymppwriter-0.3.0/pyproject.toml +47 -0
- pymppwriter-0.3.0/setup.cfg +4 -0
- pymppwriter-0.3.0/tests/test_core.py +715 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin McAleer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pymppwriter
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Write Microsoft Project .mpp files from pure Python (template-based MPP14 writer)
|
|
5
|
+
Author: Kevin McAleer
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kevinmcaleer/pymppwriter
|
|
8
|
+
Project-URL: Source, https://github.com/kevinmcaleer/pymppwriter
|
|
9
|
+
Project-URL: Documentation, https://kevinmcaleer.github.io/pymppwriter/
|
|
10
|
+
Project-URL: Issues, https://github.com/kevinmcaleer/pymppwriter/issues
|
|
11
|
+
Keywords: mpp,microsoft-project,project-management,gantt,scheduling
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: olefile>=0.47
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Requires-Dist: mpxj; extra == "dev"
|
|
32
|
+
Requires-Dist: jpype1; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# pymppwriter
|
|
36
|
+
|
|
37
|
+
**Write Microsoft Project `.mpp` files from pure Python.** No Java, no .NET, no Microsoft Project
|
|
38
|
+
installation, no commercial library. MIT licensed.
|
|
39
|
+
|
|
40
|
+
`pymppwriter` produces native MPP14 files (the format used by Project 2010 through the current
|
|
41
|
+
Microsoft 365 desktop client). Files it writes open in Project by double-click — which is the
|
|
42
|
+
whole point: an `.mpp` download is associated with Project on every corporate PC, whereas the
|
|
43
|
+
MSPDI `.xml` export has to be opened manually from inside Project.
|
|
44
|
+
|
|
45
|
+
> **Status: alpha.** Verified to open correctly in Project M365: task names, hierarchy, dates,
|
|
46
|
+
> durations (values, display units, estimated flags, milestones and summary rollups),
|
|
47
|
+
> dependencies, resources and assignments, calendars (working weeks, holidays, extra base
|
|
48
|
+
> calendars, per-task calendars), the wider task fields (notes, WBS, constraints, deadlines,
|
|
49
|
+
> progress, priority, type, custom text/number/date/flag fields, manual scheduling) and project
|
|
50
|
+
> properties (document metadata, status date, currency). Resource rates and costs, baselines and
|
|
51
|
+
> timephased data are not yet written. Treat this as a working proof-of-concept, not a product.
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
The MPP format is an undocumented OLE2 compound document. Reading it selectively is a solved
|
|
56
|
+
problem (see [MPXJ](https://www.mpxj.org)); writing one from scratch means reproducing ~170 KB
|
|
57
|
+
of view definitions, Gantt bar styles, tables and filters that Project insists on.
|
|
58
|
+
|
|
59
|
+
`pymppwriter` sidesteps that with a **template-and-patch** approach:
|
|
60
|
+
|
|
61
|
+
1. You save a near-empty project from your own copy of Project once (`templates/template.mpp`).
|
|
62
|
+
2. The library keeps every stream it doesn't understand byte-for-byte.
|
|
63
|
+
3. It rewrites only the task, dependency and project-property streams, cloning prototype
|
|
64
|
+
records from the template and patching the fields it controls.
|
|
65
|
+
|
|
66
|
+
The field-offset map is read from the template's own `Props` stream, so the writer adapts to
|
|
67
|
+
whatever Project version wrote the template. Details in [`docs/FORMAT_NOTES.md`](docs/FORMAT_NOTES.md).
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install git+https://github.com/kevinmcaleer/pymppwriter
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Only runtime dependency: [`olefile`](https://pypi.org/project/olefile/) (used to *read* the
|
|
76
|
+
template; the container writer is our own).
|
|
77
|
+
|
|
78
|
+
## Make your template (one-time)
|
|
79
|
+
|
|
80
|
+
In Microsoft Project: **File → New → Blank Project**, then
|
|
81
|
+
|
|
82
|
+
| # | Task Name | Action |
|
|
83
|
+
|---|-----------|--------|
|
|
84
|
+
| 1 | Task 1 | leave as-is |
|
|
85
|
+
| 2 | Task 2 | **Indent** it under Task 1 (Task 1 becomes a summary) |
|
|
86
|
+
| 3 | Task 3 | select Tasks 2 and 3, **Link Tasks** (Finish-to-Start) |
|
|
87
|
+
|
|
88
|
+
Don't add resources, baselines or calendar changes. **Save As** `templates/template.mpp`.
|
|
89
|
+
|
|
90
|
+
Why you must make it yourself: the template is a file written by Project, so it must come from
|
|
91
|
+
a copy you're licensed to use, and it embeds your username. It is `.gitignore`d.
|
|
92
|
+
|
|
93
|
+
Save the template from the **same Project version that will open the generated files** — several
|
|
94
|
+
structures (calendar definitions in particular) are stored in version-specific dialects, and a
|
|
95
|
+
template written by your own copy guarantees the output speaks the dialect your Project reads.
|
|
96
|
+
Both current M365 and 2010-era templates are supported.
|
|
97
|
+
|
|
98
|
+
## Usage
|
|
99
|
+
|
|
100
|
+
### Command line
|
|
101
|
+
|
|
102
|
+
Describe the plan in JSON ([`examples/example_project.json`](examples/example_project.json)):
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"title": "My plan",
|
|
107
|
+
"start": "2026-09-07T08:00",
|
|
108
|
+
"tasks": [
|
|
109
|
+
{"uid": 1, "name": "Phase 1", "start": "2026-09-07T08:00", "finish": "2026-09-09T17:00", "duration_days": 3, "outline_level": 1},
|
|
110
|
+
{"uid": 2, "name": "Do the thing", "start": "2026-09-07T08:00", "finish": "2026-09-08T17:00", "duration_days": 2, "outline_level": 2, "parent_uid": 1}
|
|
111
|
+
],
|
|
112
|
+
"links": [ {"pred": 1, "succ": 2, "type": "FS", "lag_days": 0} ]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pymppwriter build examples/example_project.json --template templates/template.mpp --out plan.mpp
|
|
118
|
+
pymppwriter inspect plan.mpp # dump the OLE stream tree
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Python API
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from datetime import datetime as D
|
|
125
|
+
from pymppwriter import MppWriter, Project, Task, Relation
|
|
126
|
+
|
|
127
|
+
project = Project(
|
|
128
|
+
title="Robot build plan",
|
|
129
|
+
start=D(2026, 10, 5, 8, 0),
|
|
130
|
+
tasks=[
|
|
131
|
+
Task(uid=1, name="Design", start=D(2026,10,5,8), finish=D(2026,10,9,17), duration_days=5),
|
|
132
|
+
Task(uid=2, name="Print parts", start=D(2026,10,12,8), finish=D(2026,10,14,17), duration_days=3),
|
|
133
|
+
Task(uid=3, name="Assemble", start=D(2026,10,15,8), finish=D(2026,10,16,17), duration_days=2,
|
|
134
|
+
outline_level=1, parent_uid=0),
|
|
135
|
+
],
|
|
136
|
+
relations=[Relation(1, 2), Relation(2, 3, type="FS", lag_days=0)],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
MppWriter("templates/template.mpp").write(project, "robot-build.mpp")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Model reference**
|
|
143
|
+
|
|
144
|
+
| Class | Field | Notes |
|
|
145
|
+
|-------|-------|-------|
|
|
146
|
+
| `Project` | `title`, `start`, `tasks`, `relations` | `start` sets the project start date |
|
|
147
|
+
| `Task` | `uid` | unique, > 0, stable across exports |
|
|
148
|
+
| | `name`, `start`, `finish` | `datetime`s |
|
|
149
|
+
| | `duration_days` | working days; 0 = milestone; ignored for summary tasks (rolled up from children in working time) |
|
|
150
|
+
| | `duration_units` | display units: `"m"`, `"h"`, `"d"` (default), `"w"`, `"mo"` |
|
|
151
|
+
| | `estimated` | `True` shows the duration with a trailing `?` |
|
|
152
|
+
| | `outline_level` | 1 = top level, 2 = child, … |
|
|
153
|
+
| | `parent_uid` | 0 = top level, else uid of the summary task |
|
|
154
|
+
| | `guid` | auto-generated; pass your own to keep GUIDs stable between exports |
|
|
155
|
+
| `Relation` | `pred_uid`, `succ_uid` | |
|
|
156
|
+
| | `type` | `"FS"` (default), `"SS"`, `"FF"`, `"SF"` |
|
|
157
|
+
| | `lag_days` | may be negative for lead |
|
|
158
|
+
| `Resource` | `uid` | unique, > 0 |
|
|
159
|
+
| | `name`, `initials`, `email` | strings; only `name` is required |
|
|
160
|
+
| | `max_units` | 1.0 = 100% (default) |
|
|
161
|
+
| | `guid` | auto-generated; pass your own to keep GUIDs stable |
|
|
162
|
+
| `Assignment` | `task_uid`, `resource_uid` | must reference existing tasks/resources |
|
|
163
|
+
| | `units` | 1.0 = 100% (default); work is computed from the task's duration |
|
|
164
|
+
| `Calendar` | `name` | `Project.calendar` edits Standard; `Project.calendars` adds base calendars |
|
|
165
|
+
| | `week` | `{weekday: ranges}`; weekday 0=Mon..6=Sun; ranges = `[(start_min, end_min), …]` or `None` for non-working; missing days keep defaults |
|
|
166
|
+
| | `exceptions` | list of `CalendarException(start, finish=None, name="")` — non-working dates |
|
|
167
|
+
| `CalendarException` | `start`, `finish` | `datetime.date`s; `finish` defaults to `start` |
|
|
168
|
+
|
|
169
|
+
Set `Task.calendar` to a calendar name to schedule that task on it, and
|
|
170
|
+
`Project.default_calendar` to change the project calendar. Summary/rollup durations are computed
|
|
171
|
+
in working time using `Project.calendar`'s week and holidays.
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
"calendar": {"week": {"wed": [["08:00", "12:00"]], "sat": null},
|
|
175
|
+
"holidays": ["2026-09-21", {"from": "2026-10-01", "to": "2026-10-02", "name": "Conf"}]},
|
|
176
|
+
"calendars": [ {"name": "Nights", "week": {"mon": [["18:00", "22:00"]]}} ]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
In JSON specs, resources and assignments look like:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
"resources": [ {"uid": 1, "name": "Kevin", "initials": "K", "max_units": 1.0} ],
|
|
183
|
+
"assignments": [ {"task": 1, "resource": 1, "units": 0.5} ]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Tasks are written in list order, which becomes the ID / row order in Project.
|
|
187
|
+
|
|
188
|
+
### Validation
|
|
189
|
+
|
|
190
|
+
`MppWriter.write()` validates the plan first and refuses to write a file Project would reject or
|
|
191
|
+
silently repair: duplicate or non-positive task uids, parent references that contradict the outline
|
|
192
|
+
levels, finishes before starts, links to unknown tasks, self-links and dependency cycles all raise
|
|
193
|
+
`ValueError`. Two softer disagreements come back as `ScheduleWarning`s, because Project accepts the
|
|
194
|
+
file but changes it: a task whose declared start is *earlier* than its predecessors allow (Project
|
|
195
|
+
moves it on the next recalculation), and a task calendar that shares no working time with its
|
|
196
|
+
assigned resources' calendars (Project opens with "Not enough common working time").
|
|
197
|
+
|
|
198
|
+
Declared starts that Project's scheduler would not produce on its own are held in place with a
|
|
199
|
+
Start-No-Earlier-Than constraint, exactly as Project does for a typed-in date; tasks their
|
|
200
|
+
predecessors already place are left as-soon-as-possible so plans stay link-driven.
|
|
201
|
+
|
|
202
|
+
### Reading a plan back
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from pymppwriter import read_project
|
|
206
|
+
|
|
207
|
+
project = read_project("plan.mpp") # any MPP14 file, 2010 through M365
|
|
208
|
+
for task in project.tasks:
|
|
209
|
+
print(task.uid, task.name, task.duration_days, task.percent_complete)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`read_project()` returns the same `Project` the writer takes, so a file can be
|
|
213
|
+
read, edited and written again. Every offset comes from the file's own `Props`
|
|
214
|
+
field maps and every flag from its meta bitmaps — there are no hard-coded record
|
|
215
|
+
layouts — so it reads files saved by any Project version of that era, not just
|
|
216
|
+
ones this library wrote. Tasks (names, dates, durations, outline, notes, WBS,
|
|
217
|
+
constraints, progress, manual scheduling), dependencies with types and lags,
|
|
218
|
+
resources and assignments come back; baselines, costs and timephased data do
|
|
219
|
+
not. A file that is not an MPP14 project raises `MppReadError`.
|
|
220
|
+
|
|
221
|
+
## Verifying output without Project
|
|
222
|
+
|
|
223
|
+
If you have Java installed, `scripts/mpxj_oracle.py` reads any `.mpp` back through
|
|
224
|
+
[MPXJ](https://www.mpxj.org) (`pip install mpxj jpype1`) and prints tasks, links and
|
|
225
|
+
resources. `scripts/analyze_mpp.py` dumps the task records field-by-field — useful when
|
|
226
|
+
diffing against a file Project saved.
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
git clone https://github.com/kevinmcaleer/pymppwriter && cd pymppwriter
|
|
232
|
+
pip install -e ".[dev]"
|
|
233
|
+
pytest
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The end-to-end test is skipped unless `templates/template.mpp` exists.
|
|
237
|
+
|
|
238
|
+
## Roadmap
|
|
239
|
+
|
|
240
|
+
Tracked in the [GitHub Project](../../projects). Headline epics:
|
|
241
|
+
|
|
242
|
+
1. ~~**Durations honoured by Project**~~ — done (verified in Project M365)
|
|
243
|
+
2. ~~**Resources & assignments**~~ — done (verified in Project M365)
|
|
244
|
+
3. ~~**Calendars**~~ — project calendar, extra base calendars, per-task calendars — done
|
|
245
|
+
4. ~~**Notes, custom fields, WBS, constraints, deadlines**~~ — done
|
|
246
|
+
5. ~~**Project properties**~~ — document metadata, status date, currency — done
|
|
247
|
+
6. ~~**Round-trip fidelity**~~ — `Save` from Project after opening produces the same schedule
|
|
248
|
+
7. **NoodlePlanner integration** — markdown → `.mpp` export
|
|
249
|
+
|
|
250
|
+
## Provenance & licensing
|
|
251
|
+
|
|
252
|
+
All format knowledge comes from the public [MS-CFB] specification, the observable read behaviour
|
|
253
|
+
of the LGPL MPXJ library, and byte-diffing files saved by Microsoft Project. No code was
|
|
254
|
+
derived from MPXJ or from any proprietary library, and no proprietary binaries were decompiled.
|
|
255
|
+
The repository ships **no** `.mpp` files: MPXJ's test fixtures are LGPL and were used only as
|
|
256
|
+
read-only references during development.
|
|
257
|
+
|
|
258
|
+
Microsoft Project is a trademark of Microsoft Corporation. This project is not affiliated with
|
|
259
|
+
Microsoft.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# pymppwriter
|
|
2
|
+
|
|
3
|
+
**Write Microsoft Project `.mpp` files from pure Python.** No Java, no .NET, no Microsoft Project
|
|
4
|
+
installation, no commercial library. MIT licensed.
|
|
5
|
+
|
|
6
|
+
`pymppwriter` produces native MPP14 files (the format used by Project 2010 through the current
|
|
7
|
+
Microsoft 365 desktop client). Files it writes open in Project by double-click — which is the
|
|
8
|
+
whole point: an `.mpp` download is associated with Project on every corporate PC, whereas the
|
|
9
|
+
MSPDI `.xml` export has to be opened manually from inside Project.
|
|
10
|
+
|
|
11
|
+
> **Status: alpha.** Verified to open correctly in Project M365: task names, hierarchy, dates,
|
|
12
|
+
> durations (values, display units, estimated flags, milestones and summary rollups),
|
|
13
|
+
> dependencies, resources and assignments, calendars (working weeks, holidays, extra base
|
|
14
|
+
> calendars, per-task calendars), the wider task fields (notes, WBS, constraints, deadlines,
|
|
15
|
+
> progress, priority, type, custom text/number/date/flag fields, manual scheduling) and project
|
|
16
|
+
> properties (document metadata, status date, currency). Resource rates and costs, baselines and
|
|
17
|
+
> timephased data are not yet written. Treat this as a working proof-of-concept, not a product.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
The MPP format is an undocumented OLE2 compound document. Reading it selectively is a solved
|
|
22
|
+
problem (see [MPXJ](https://www.mpxj.org)); writing one from scratch means reproducing ~170 KB
|
|
23
|
+
of view definitions, Gantt bar styles, tables and filters that Project insists on.
|
|
24
|
+
|
|
25
|
+
`pymppwriter` sidesteps that with a **template-and-patch** approach:
|
|
26
|
+
|
|
27
|
+
1. You save a near-empty project from your own copy of Project once (`templates/template.mpp`).
|
|
28
|
+
2. The library keeps every stream it doesn't understand byte-for-byte.
|
|
29
|
+
3. It rewrites only the task, dependency and project-property streams, cloning prototype
|
|
30
|
+
records from the template and patching the fields it controls.
|
|
31
|
+
|
|
32
|
+
The field-offset map is read from the template's own `Props` stream, so the writer adapts to
|
|
33
|
+
whatever Project version wrote the template. Details in [`docs/FORMAT_NOTES.md`](docs/FORMAT_NOTES.md).
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install git+https://github.com/kevinmcaleer/pymppwriter
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Only runtime dependency: [`olefile`](https://pypi.org/project/olefile/) (used to *read* the
|
|
42
|
+
template; the container writer is our own).
|
|
43
|
+
|
|
44
|
+
## Make your template (one-time)
|
|
45
|
+
|
|
46
|
+
In Microsoft Project: **File → New → Blank Project**, then
|
|
47
|
+
|
|
48
|
+
| # | Task Name | Action |
|
|
49
|
+
|---|-----------|--------|
|
|
50
|
+
| 1 | Task 1 | leave as-is |
|
|
51
|
+
| 2 | Task 2 | **Indent** it under Task 1 (Task 1 becomes a summary) |
|
|
52
|
+
| 3 | Task 3 | select Tasks 2 and 3, **Link Tasks** (Finish-to-Start) |
|
|
53
|
+
|
|
54
|
+
Don't add resources, baselines or calendar changes. **Save As** `templates/template.mpp`.
|
|
55
|
+
|
|
56
|
+
Why you must make it yourself: the template is a file written by Project, so it must come from
|
|
57
|
+
a copy you're licensed to use, and it embeds your username. It is `.gitignore`d.
|
|
58
|
+
|
|
59
|
+
Save the template from the **same Project version that will open the generated files** — several
|
|
60
|
+
structures (calendar definitions in particular) are stored in version-specific dialects, and a
|
|
61
|
+
template written by your own copy guarantees the output speaks the dialect your Project reads.
|
|
62
|
+
Both current M365 and 2010-era templates are supported.
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### Command line
|
|
67
|
+
|
|
68
|
+
Describe the plan in JSON ([`examples/example_project.json`](examples/example_project.json)):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"title": "My plan",
|
|
73
|
+
"start": "2026-09-07T08:00",
|
|
74
|
+
"tasks": [
|
|
75
|
+
{"uid": 1, "name": "Phase 1", "start": "2026-09-07T08:00", "finish": "2026-09-09T17:00", "duration_days": 3, "outline_level": 1},
|
|
76
|
+
{"uid": 2, "name": "Do the thing", "start": "2026-09-07T08:00", "finish": "2026-09-08T17:00", "duration_days": 2, "outline_level": 2, "parent_uid": 1}
|
|
77
|
+
],
|
|
78
|
+
"links": [ {"pred": 1, "succ": 2, "type": "FS", "lag_days": 0} ]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pymppwriter build examples/example_project.json --template templates/template.mpp --out plan.mpp
|
|
84
|
+
pymppwriter inspect plan.mpp # dump the OLE stream tree
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Python API
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from datetime import datetime as D
|
|
91
|
+
from pymppwriter import MppWriter, Project, Task, Relation
|
|
92
|
+
|
|
93
|
+
project = Project(
|
|
94
|
+
title="Robot build plan",
|
|
95
|
+
start=D(2026, 10, 5, 8, 0),
|
|
96
|
+
tasks=[
|
|
97
|
+
Task(uid=1, name="Design", start=D(2026,10,5,8), finish=D(2026,10,9,17), duration_days=5),
|
|
98
|
+
Task(uid=2, name="Print parts", start=D(2026,10,12,8), finish=D(2026,10,14,17), duration_days=3),
|
|
99
|
+
Task(uid=3, name="Assemble", start=D(2026,10,15,8), finish=D(2026,10,16,17), duration_days=2,
|
|
100
|
+
outline_level=1, parent_uid=0),
|
|
101
|
+
],
|
|
102
|
+
relations=[Relation(1, 2), Relation(2, 3, type="FS", lag_days=0)],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
MppWriter("templates/template.mpp").write(project, "robot-build.mpp")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Model reference**
|
|
109
|
+
|
|
110
|
+
| Class | Field | Notes |
|
|
111
|
+
|-------|-------|-------|
|
|
112
|
+
| `Project` | `title`, `start`, `tasks`, `relations` | `start` sets the project start date |
|
|
113
|
+
| `Task` | `uid` | unique, > 0, stable across exports |
|
|
114
|
+
| | `name`, `start`, `finish` | `datetime`s |
|
|
115
|
+
| | `duration_days` | working days; 0 = milestone; ignored for summary tasks (rolled up from children in working time) |
|
|
116
|
+
| | `duration_units` | display units: `"m"`, `"h"`, `"d"` (default), `"w"`, `"mo"` |
|
|
117
|
+
| | `estimated` | `True` shows the duration with a trailing `?` |
|
|
118
|
+
| | `outline_level` | 1 = top level, 2 = child, … |
|
|
119
|
+
| | `parent_uid` | 0 = top level, else uid of the summary task |
|
|
120
|
+
| | `guid` | auto-generated; pass your own to keep GUIDs stable between exports |
|
|
121
|
+
| `Relation` | `pred_uid`, `succ_uid` | |
|
|
122
|
+
| | `type` | `"FS"` (default), `"SS"`, `"FF"`, `"SF"` |
|
|
123
|
+
| | `lag_days` | may be negative for lead |
|
|
124
|
+
| `Resource` | `uid` | unique, > 0 |
|
|
125
|
+
| | `name`, `initials`, `email` | strings; only `name` is required |
|
|
126
|
+
| | `max_units` | 1.0 = 100% (default) |
|
|
127
|
+
| | `guid` | auto-generated; pass your own to keep GUIDs stable |
|
|
128
|
+
| `Assignment` | `task_uid`, `resource_uid` | must reference existing tasks/resources |
|
|
129
|
+
| | `units` | 1.0 = 100% (default); work is computed from the task's duration |
|
|
130
|
+
| `Calendar` | `name` | `Project.calendar` edits Standard; `Project.calendars` adds base calendars |
|
|
131
|
+
| | `week` | `{weekday: ranges}`; weekday 0=Mon..6=Sun; ranges = `[(start_min, end_min), …]` or `None` for non-working; missing days keep defaults |
|
|
132
|
+
| | `exceptions` | list of `CalendarException(start, finish=None, name="")` — non-working dates |
|
|
133
|
+
| `CalendarException` | `start`, `finish` | `datetime.date`s; `finish` defaults to `start` |
|
|
134
|
+
|
|
135
|
+
Set `Task.calendar` to a calendar name to schedule that task on it, and
|
|
136
|
+
`Project.default_calendar` to change the project calendar. Summary/rollup durations are computed
|
|
137
|
+
in working time using `Project.calendar`'s week and holidays.
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
"calendar": {"week": {"wed": [["08:00", "12:00"]], "sat": null},
|
|
141
|
+
"holidays": ["2026-09-21", {"from": "2026-10-01", "to": "2026-10-02", "name": "Conf"}]},
|
|
142
|
+
"calendars": [ {"name": "Nights", "week": {"mon": [["18:00", "22:00"]]}} ]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
In JSON specs, resources and assignments look like:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
"resources": [ {"uid": 1, "name": "Kevin", "initials": "K", "max_units": 1.0} ],
|
|
149
|
+
"assignments": [ {"task": 1, "resource": 1, "units": 0.5} ]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Tasks are written in list order, which becomes the ID / row order in Project.
|
|
153
|
+
|
|
154
|
+
### Validation
|
|
155
|
+
|
|
156
|
+
`MppWriter.write()` validates the plan first and refuses to write a file Project would reject or
|
|
157
|
+
silently repair: duplicate or non-positive task uids, parent references that contradict the outline
|
|
158
|
+
levels, finishes before starts, links to unknown tasks, self-links and dependency cycles all raise
|
|
159
|
+
`ValueError`. Two softer disagreements come back as `ScheduleWarning`s, because Project accepts the
|
|
160
|
+
file but changes it: a task whose declared start is *earlier* than its predecessors allow (Project
|
|
161
|
+
moves it on the next recalculation), and a task calendar that shares no working time with its
|
|
162
|
+
assigned resources' calendars (Project opens with "Not enough common working time").
|
|
163
|
+
|
|
164
|
+
Declared starts that Project's scheduler would not produce on its own are held in place with a
|
|
165
|
+
Start-No-Earlier-Than constraint, exactly as Project does for a typed-in date; tasks their
|
|
166
|
+
predecessors already place are left as-soon-as-possible so plans stay link-driven.
|
|
167
|
+
|
|
168
|
+
### Reading a plan back
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from pymppwriter import read_project
|
|
172
|
+
|
|
173
|
+
project = read_project("plan.mpp") # any MPP14 file, 2010 through M365
|
|
174
|
+
for task in project.tasks:
|
|
175
|
+
print(task.uid, task.name, task.duration_days, task.percent_complete)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`read_project()` returns the same `Project` the writer takes, so a file can be
|
|
179
|
+
read, edited and written again. Every offset comes from the file's own `Props`
|
|
180
|
+
field maps and every flag from its meta bitmaps — there are no hard-coded record
|
|
181
|
+
layouts — so it reads files saved by any Project version of that era, not just
|
|
182
|
+
ones this library wrote. Tasks (names, dates, durations, outline, notes, WBS,
|
|
183
|
+
constraints, progress, manual scheduling), dependencies with types and lags,
|
|
184
|
+
resources and assignments come back; baselines, costs and timephased data do
|
|
185
|
+
not. A file that is not an MPP14 project raises `MppReadError`.
|
|
186
|
+
|
|
187
|
+
## Verifying output without Project
|
|
188
|
+
|
|
189
|
+
If you have Java installed, `scripts/mpxj_oracle.py` reads any `.mpp` back through
|
|
190
|
+
[MPXJ](https://www.mpxj.org) (`pip install mpxj jpype1`) and prints tasks, links and
|
|
191
|
+
resources. `scripts/analyze_mpp.py` dumps the task records field-by-field — useful when
|
|
192
|
+
diffing against a file Project saved.
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
git clone https://github.com/kevinmcaleer/pymppwriter && cd pymppwriter
|
|
198
|
+
pip install -e ".[dev]"
|
|
199
|
+
pytest
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The end-to-end test is skipped unless `templates/template.mpp` exists.
|
|
203
|
+
|
|
204
|
+
## Roadmap
|
|
205
|
+
|
|
206
|
+
Tracked in the [GitHub Project](../../projects). Headline epics:
|
|
207
|
+
|
|
208
|
+
1. ~~**Durations honoured by Project**~~ — done (verified in Project M365)
|
|
209
|
+
2. ~~**Resources & assignments**~~ — done (verified in Project M365)
|
|
210
|
+
3. ~~**Calendars**~~ — project calendar, extra base calendars, per-task calendars — done
|
|
211
|
+
4. ~~**Notes, custom fields, WBS, constraints, deadlines**~~ — done
|
|
212
|
+
5. ~~**Project properties**~~ — document metadata, status date, currency — done
|
|
213
|
+
6. ~~**Round-trip fidelity**~~ — `Save` from Project after opening produces the same schedule
|
|
214
|
+
7. **NoodlePlanner integration** — markdown → `.mpp` export
|
|
215
|
+
|
|
216
|
+
## Provenance & licensing
|
|
217
|
+
|
|
218
|
+
All format knowledge comes from the public [MS-CFB] specification, the observable read behaviour
|
|
219
|
+
of the LGPL MPXJ library, and byte-diffing files saved by Microsoft Project. No code was
|
|
220
|
+
derived from MPXJ or from any proprietary library, and no proprietary binaries were decompiled.
|
|
221
|
+
The repository ships **no** `.mpp` files: MPXJ's test fixtures are LGPL and were used only as
|
|
222
|
+
read-only references during development.
|
|
223
|
+
|
|
224
|
+
Microsoft Project is a trademark of Microsoft Corporation. This project is not affiliated with
|
|
225
|
+
Microsoft.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .writer import (MppWriter, Project, Task, Relation, Resource, Assignment,
|
|
2
|
+
Calendar, CalendarException, ScheduleWarning, validate)
|
|
3
|
+
from .reader import read_project, MppReadError
|
|
4
|
+
|
|
5
|
+
try: # the installed distribution's version
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
7
|
+
__version__ = _version("pymppwriter")
|
|
8
|
+
except (ImportError, PackageNotFoundError): # running from a source tree
|
|
9
|
+
__version__ = "0.0.0.dev0"
|
|
10
|
+
__all__ = ["MppWriter", "Project", "Task", "Relation", "Resource", "Assignment",
|
|
11
|
+
"Calendar", "CalendarException", "ScheduleWarning", "validate",
|
|
12
|
+
"read_project", "MppReadError", "__version__"]
|