pptlive 0.1.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.
Files changed (95) hide show
  1. pptlive-0.1.0/.github/workflows/docs.yml +60 -0
  2. pptlive-0.1.0/.github/workflows/release.yml +118 -0
  3. pptlive-0.1.0/.gitignore +22 -0
  4. pptlive-0.1.0/.python-version +1 -0
  5. pptlive-0.1.0/CLAUDE.md +166 -0
  6. pptlive-0.1.0/IMPLEMENTATION.md +721 -0
  7. pptlive-0.1.0/PKG-INFO +382 -0
  8. pptlive-0.1.0/README.md +346 -0
  9. pptlive-0.1.0/docs/cli.md +558 -0
  10. pptlive-0.1.0/docs/concepts.md +238 -0
  11. pptlive-0.1.0/docs/cookbook.md +392 -0
  12. pptlive-0.1.0/docs/design.md +152 -0
  13. pptlive-0.1.0/docs/errors.md +179 -0
  14. pptlive-0.1.0/docs/getting-started.md +162 -0
  15. pptlive-0.1.0/docs/index.md +71 -0
  16. pptlive-0.1.0/docs/mcp.md +152 -0
  17. pptlive-0.1.0/docs/python-api.md +191 -0
  18. pptlive-0.1.0/docs/stylesheets/extra.css +10 -0
  19. pptlive-0.1.0/examples/README.md +56 -0
  20. pptlive-0.1.0/examples/powershell/build_report.ps1 +76 -0
  21. pptlive-0.1.0/examples/powershell/quickstart.ps1 +62 -0
  22. pptlive-0.1.0/examples/python/01_quickstart.py +72 -0
  23. pptlive-0.1.0/examples/python/02_build_a_deck.py +95 -0
  24. pptlive-0.1.0/examples/python/03_restyle_deck.py +70 -0
  25. pptlive-0.1.0/mcpb/.mcpbignore +3 -0
  26. pptlive-0.1.0/mcpb/README.md +48 -0
  27. pptlive-0.1.0/mcpb/manifest.json +34 -0
  28. pptlive-0.1.0/mcpb/pyproject.toml +7 -0
  29. pptlive-0.1.0/mcpb/src/server.py +13 -0
  30. pptlive-0.1.0/mkdocs.yml +87 -0
  31. pptlive-0.1.0/pyproject.toml +139 -0
  32. pptlive-0.1.0/scripts/chart_spike.py +131 -0
  33. pptlive-0.1.0/scripts/layout_spike.py +163 -0
  34. pptlive-0.1.0/scripts/master_spike.py +152 -0
  35. pptlive-0.1.0/scripts/mcp_spike.py +81 -0
  36. pptlive-0.1.0/scripts/picture_spike.py +177 -0
  37. pptlive-0.1.0/scripts/render_select_spike.py +195 -0
  38. pptlive-0.1.0/scripts/shape_spike.py +201 -0
  39. pptlive-0.1.0/scripts/show_spike.py +119 -0
  40. pptlive-0.1.0/scripts/smartart_spike.py +236 -0
  41. pptlive-0.1.0/scripts/spike.py +201 -0
  42. pptlive-0.1.0/scripts/table_spike.py +144 -0
  43. pptlive-0.1.0/scripts/text_spike.py +190 -0
  44. pptlive-0.1.0/scripts/undo_test.py +223 -0
  45. pptlive-0.1.0/spec.md +495 -0
  46. pptlive-0.1.0/src/pptlive/__init__.py +89 -0
  47. pptlive-0.1.0/src/pptlive/_anchors.py +484 -0
  48. pptlive-0.1.0/src/pptlive/_app.py +93 -0
  49. pptlive-0.1.0/src/pptlive/_charts.py +243 -0
  50. pptlive-0.1.0/src/pptlive/_com.py +127 -0
  51. pptlive-0.1.0/src/pptlive/_edit.py +101 -0
  52. pptlive-0.1.0/src/pptlive/_guide.py +58 -0
  53. pptlive-0.1.0/src/pptlive/_presentation.py +414 -0
  54. pptlive-0.1.0/src/pptlive/_selection.py +249 -0
  55. pptlive-0.1.0/src/pptlive/_shapes.py +775 -0
  56. pptlive-0.1.0/src/pptlive/_show.py +232 -0
  57. pptlive-0.1.0/src/pptlive/_skill/pptlive-cli/SKILL.md +110 -0
  58. pptlive-0.1.0/src/pptlive/_skill/pptlive-python/SKILL.md +163 -0
  59. pptlive-0.1.0/src/pptlive/_slides.py +356 -0
  60. pptlive-0.1.0/src/pptlive/_smartart.py +213 -0
  61. pptlive-0.1.0/src/pptlive/_tables.py +227 -0
  62. pptlive-0.1.0/src/pptlive/_theme.py +296 -0
  63. pptlive-0.1.0/src/pptlive/cli/__init__.py +1 -0
  64. pptlive-0.1.0/src/pptlive/cli/__main__.py +4 -0
  65. pptlive-0.1.0/src/pptlive/cli/commands.py +2129 -0
  66. pptlive-0.1.0/src/pptlive/cli/main.py +96 -0
  67. pptlive-0.1.0/src/pptlive/constants.py +1072 -0
  68. pptlive-0.1.0/src/pptlive/exceptions.py +219 -0
  69. pptlive-0.1.0/src/pptlive/mcp/__init__.py +19 -0
  70. pptlive-0.1.0/src/pptlive/mcp/__main__.py +19 -0
  71. pptlive-0.1.0/src/pptlive/mcp/server.py +848 -0
  72. pptlive-0.1.0/src/pptlive/py.typed +0 -0
  73. pptlive-0.1.0/src/pptlive/units.py +37 -0
  74. pptlive-0.1.0/tests/conftest.py +1638 -0
  75. pptlive-0.1.0/tests/test_anchor_by_id.py +63 -0
  76. pptlive-0.1.0/tests/test_anchors.py +80 -0
  77. pptlive-0.1.0/tests/test_app.py +80 -0
  78. pptlive-0.1.0/tests/test_charts.py +261 -0
  79. pptlive-0.1.0/tests/test_cli.py +628 -0
  80. pptlive-0.1.0/tests/test_exceptions.py +124 -0
  81. pptlive-0.1.0/tests/test_mcp.py +716 -0
  82. pptlive-0.1.0/tests/test_paragraphs.py +151 -0
  83. pptlive-0.1.0/tests/test_pictures.py +175 -0
  84. pptlive-0.1.0/tests/test_render.py +90 -0
  85. pptlive-0.1.0/tests/test_selection.py +91 -0
  86. pptlive-0.1.0/tests/test_selection_edit.py +106 -0
  87. pptlive-0.1.0/tests/test_shapes.py +156 -0
  88. pptlive-0.1.0/tests/test_show.py +190 -0
  89. pptlive-0.1.0/tests/test_slide_lifecycle.py +135 -0
  90. pptlive-0.1.0/tests/test_slides.py +120 -0
  91. pptlive-0.1.0/tests/test_smartart.py +232 -0
  92. pptlive-0.1.0/tests/test_smoke.py +84 -0
  93. pptlive-0.1.0/tests/test_tables.py +179 -0
  94. pptlive-0.1.0/tests/test_theme.py +230 -0
  95. pptlive-0.1.0/uv.lock +1988 -0
@@ -0,0 +1,60 @@
1
+ name: docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - 'docs/**'
8
+ - 'mkdocs.yml'
9
+ - 'src/pptlive/**'
10
+ - 'pyproject.toml'
11
+ - '.github/workflows/docs.yml'
12
+ pull_request:
13
+ paths:
14
+ - 'docs/**'
15
+ - 'mkdocs.yml'
16
+ - 'src/pptlive/**'
17
+ - 'pyproject.toml'
18
+ - '.github/workflows/docs.yml'
19
+ workflow_dispatch:
20
+
21
+ permissions:
22
+ contents: read
23
+ pages: write
24
+ id-token: write
25
+
26
+ concurrency:
27
+ group: pages
28
+ cancel-in-progress: false
29
+
30
+ jobs:
31
+ build:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+
36
+ - uses: actions/setup-python@v5
37
+ with:
38
+ python-version: '3.13'
39
+ cache: pip
40
+
41
+ - name: Install docs deps
42
+ run: pip install -e ".[docs]"
43
+
44
+ - name: Build site
45
+ run: mkdocs build --strict
46
+
47
+ - uses: actions/upload-pages-artifact@v3
48
+ with:
49
+ path: site
50
+
51
+ deploy:
52
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
53
+ needs: build
54
+ runs-on: ubuntu-latest
55
+ environment:
56
+ name: github-pages
57
+ url: ${{ steps.deployment.outputs.page_url }}
58
+ steps:
59
+ - id: deployment
60
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,118 @@
1
+ name: release
2
+
3
+ # Push a version tag to publish: git tag v0.1.0 && git push origin v0.1.0
4
+ on:
5
+ push:
6
+ tags:
7
+ - 'v*'
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: '3.13'
21
+
22
+ - name: Verify tag matches the package version
23
+ run: |
24
+ version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
25
+ tag="${GITHUB_REF_NAME#v}"
26
+ echo "pyproject version: $version"
27
+ echo "release tag: $GITHUB_REF_NAME -> $tag"
28
+ if [ "$version" != "$tag" ]; then
29
+ echo "::error::Tag '$GITHUB_REF_NAME' does not match pyproject version '$version'. Bump the version in pyproject.toml or retag."
30
+ exit 1
31
+ fi
32
+ # The .mcpb bundle pins the same version in two files; we keep them in
33
+ # sync by hand (no bumpversion wiring yet), so guard against shipping a
34
+ # stale-versioned bundle.
35
+ mcpb_manifest=$(python -c "import json; print(json.load(open('mcpb/manifest.json'))['version'])")
36
+ mcpb_pyproject=$(python -c "import tomllib; print(tomllib.load(open('mcpb/pyproject.toml','rb'))['project']['version'])")
37
+ echo "mcpb manifest version: $mcpb_manifest"
38
+ echo "mcpb pyproject version: $mcpb_pyproject"
39
+ if [ "$mcpb_manifest" != "$tag" ] || [ "$mcpb_pyproject" != "$tag" ]; then
40
+ echo "::error::mcpb/ version ($mcpb_manifest / $mcpb_pyproject) does not match tag '$tag'. Update mcpb/manifest.json and mcpb/pyproject.toml (the '>=' dep pin too) to $tag."
41
+ exit 1
42
+ fi
43
+
44
+ - name: Build sdist + wheel
45
+ run: |
46
+ python -m pip install --upgrade build twine
47
+ python -m build
48
+ twine check dist/*
49
+
50
+ - uses: actions/upload-artifact@v4
51
+ with:
52
+ name: dist
53
+ path: dist/
54
+
55
+ - uses: actions/setup-node@v4
56
+ with:
57
+ node-version: '20'
58
+
59
+ - name: Build the .mcpb bundle
60
+ # Package the built-in MCP server as a .mcpb for one-click install in
61
+ # Claude Desktop. The bundle only declares `pptlive[mcp]` as a dependency
62
+ # (resolved by uv on the user's machine at install time), so packing here
63
+ # before the PyPI publish is fine. Pin the CLI for reproducible releases.
64
+ run: |
65
+ npm install -g @anthropic-ai/mcpb@2.1.2
66
+ mcpb validate mcpb/manifest.json
67
+ mcpb pack mcpb pptlive.mcpb
68
+
69
+ # Kept out of the `dist` artifact on purpose: PyPI rejects non-wheel/sdist
70
+ # files, and the github-release job attaches this separately.
71
+ - uses: actions/upload-artifact@v4
72
+ with:
73
+ name: mcpb
74
+ path: pptlive.mcpb
75
+
76
+ pypi-publish:
77
+ needs: build
78
+ runs-on: ubuntu-latest
79
+ # Must match the PyPI trusted-publisher "Environment name".
80
+ environment:
81
+ name: pypi
82
+ url: https://pypi.org/p/pptlive
83
+ permissions:
84
+ id-token: write # OIDC token for trusted publishing — no API token needed
85
+ steps:
86
+ - uses: actions/download-artifact@v4
87
+ with:
88
+ name: dist
89
+ path: dist/
90
+
91
+ - name: Publish to PyPI
92
+ uses: pypa/gh-action-pypi-publish@release/v1
93
+
94
+ github-release:
95
+ needs: pypi-publish
96
+ runs-on: ubuntu-latest
97
+ permissions:
98
+ contents: write # to create the Release and upload assets
99
+ steps:
100
+ - uses: actions/download-artifact@v4
101
+ with:
102
+ name: dist
103
+ path: dist/
104
+
105
+ - uses: actions/download-artifact@v4
106
+ with:
107
+ name: mcpb
108
+ path: .
109
+
110
+ - name: Create GitHub Release from the tag
111
+ env:
112
+ GH_TOKEN: ${{ github.token }}
113
+ run: >-
114
+ gh release create "$GITHUB_REF_NAME"
115
+ --repo "$GITHUB_REPOSITORY"
116
+ --title "$GITHUB_REF_NAME"
117
+ --generate-notes
118
+ dist/* pptlive.mcpb
@@ -0,0 +1,22 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # MkDocs build output
13
+ site/
14
+
15
+ # Built MCPB bundles (produced by `mcpb pack`; attached to releases, not checked in)
16
+ *.mcpb
17
+
18
+ # Spike harness scratch state (scripts/undo_test.py)
19
+ scripts/.undo_test_state.json
20
+
21
+ # Personal scratch notes (local only)
22
+ notes.md
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,166 @@
1
+ # pptlive — guide for Claude
2
+
3
+ `pptlive` drives a **running** Microsoft PowerPoint instance from Python over COM
4
+ (pywin32) — `xlwings`, but for PowerPoint, and built for LLM agents. Windows-only.
5
+ It is the sibling of `../wordlive`: the same design, re-applied to PowerPoint's
6
+ 2-D object model.
7
+
8
+ - **`spec.md`** is the design doc. It is written deliberately as *the diff
9
+ against wordlive* — read wordlive's `spec.md` first if a section assumes it.
10
+ - **`IMPLEMENTATION.md`** tracks staged build progress (the analog of wordlive's
11
+ `feature-plan.md`). Update it as milestones land.
12
+
13
+ ## `../wordlive` is the reference implementation
14
+
15
+ pptlive copies wordlive's structure, error taxonomy, `EditScope` shape, CLI
16
+ contract, `_com` seam, and test approach **almost verbatim**. Before designing
17
+ or building anything, open the equivalent wordlive module
18
+ (`../wordlive/src/wordlive/`), then apply *only* the PowerPoint diff from
19
+ `spec.md`. Matching wordlive's established conventions matters more than
20
+ inventing new ones — when in doubt, do what wordlive does.
21
+
22
+ ## Toolchain — use `uv` for everything
23
+
24
+ Never call `pip` or a bare `python`; always go through `uv`.
25
+
26
+ ```
27
+ uv sync --extra dev # install deps + dev tools into .venv
28
+ uv run pytest # unit tests (fake COM; no PowerPoint needed)
29
+ uv run pytest -m smoke # smoke suite — needs PowerPoint installed
30
+ uv run ruff check . && uv run ruff format .
31
+ uv run mypy
32
+ uv run pptlive status # exercise the CLI
33
+ ```
34
+
35
+ Dev pins Python 3.13 (`.python-version`), but the **library targets 3.10+** to
36
+ match wordlive — do not use 3.11+ syntax. `ruff` and `mypy` are configured for
37
+ `py310` in `pyproject.toml`.
38
+
39
+ ## Module layout (built through v0.9 — see IMPLEMENTATION.md)
40
+
41
+ ```
42
+ src/pptlive/
43
+ __init__.py public surface + __all__
44
+ constants.py typed IntEnums for Mso*/Pp*/Xl* magic constants (+ friendly string aliases)
45
+ exceptions.py PptliveError taxonomy; _decode_com_error / from_com_error / _BUSY_HRESULTS
46
+ units.py points / inches() / cm() helpers — never expose EMUs
47
+ _com.py the ONLY pywin32 seam: com_apartment, get_active_powerpoint,
48
+ launch_powerpoint, translate_com_errors, retry_on_busy
49
+ (tests monkeypatch the getters)
50
+ _app.py PowerPoint handle + attach() / connect()
51
+ _presentation.py Presentation (the wordlive Document analog) + PresentationCollection
52
+ _slides.py SlideCollection / Slide (add/delete/duplicate/move_to/set_layout, notes, read())
53
+ _shapes.py ShapeCollection / Shape (a Shape IS an Anchor when it has a text frame; geometry verbs)
54
+ _anchors.py Anchor base + Paragraph, Cell, Notes
55
+ _tables.py Table / Cell (a table is a shape; cell:S:N:R:C anchors) [v0.5]
56
+ _charts.py Chart (a chart is a shape; data via embedded Excel) [v0.7]
57
+ _smartart.py SmartArt (a diagram is a shape; node tree read/set_nodes) [v0.8]
58
+ _theme.py Theme + Master (deck-wide palette/fonts/text-styles/background) [v0.9]
59
+ _selection.py viewed-slide + Selection snapshot/restore
60
+ _edit.py EditScope — view/Selection preservation + atomic undo via StartNewUndoEntry (see below)
61
+ _show.py SlideShow control (deck.show)
62
+ _guide.py loads the bundled SKILL.md guides (cli/python); shared by CLI + MCP
63
+ _skill/pptlive-cli/SKILL.md, _skill/pptlive-python/SKILL.md the two agent skills
64
+ cli/{__init__,__main__,main,commands}.py + llm-help / install-skill / install-mcp
65
+ mcp/{__init__,__main__,server}.py five op-dispatch tools (ppt_read/edit/render/show/batch)
66
+ + pptlive://guide resources; pptlive[mcp]
67
+ mcpb/ one-click `.mcpb` bundle (manifest.json, pyproject.toml, src/server.py)
68
+ tests/conftest.py fake_powerpoint fixture (MagicMock COM), no_powerpoint, real_powerpoint
69
+ ```
70
+
71
+ Not yet built (still in `spec.md`): `_findreplace.py` (`find()` / `find_replace()`).
72
+ Agent skills shipped as **two** guides (`pptlive-cli` + `pptlive-python`), not
73
+ wordlive's single one — `llm-help [--python]` dumps one, `install-skill` writes
74
+ them to `.agents/skills/`, and `install-mcp` / the `mcpb/` bundle wire up MCP.
75
+
76
+ ## Conventions (inherited from wordlive — keep them)
77
+
78
+ 1. **`.com` escape hatch on every wrapper.** Each wrapper (`PowerPoint`,
79
+ `Presentation`, `Slide`, `Shape`, `Anchor`, …) exposes a `.com` property
80
+ returning the raw COM object. We never block a caller because we haven't
81
+ wrapped something.
82
+ 2. **`_com.py` is the only place that touches pywin32.** Everything else sees
83
+ duck-typed dispatch objects. This is the mockable seam: tests monkeypatch
84
+ `_com.get_active_powerpoint` / `_com.launch_powerpoint` to inject a fake.
85
+ Don't `import win32com` anywhere else.
86
+ 3. **Wrap COM calls in `with _com.translate_com_errors():`** so
87
+ `pywintypes.com_error` becomes a typed `PptliveError`. Reuse wordlive's
88
+ `_decode_com_error` / `from_com_error` / `_BUSY_HRESULTS` verbatim.
89
+ 4. **`from __future__ import annotations` at the top of every module.**
90
+ 5. **Points throughout, never EMUs.** PowerPoint's COM layer is points
91
+ (1 in = 72 pt). EMUs are a `python-pptx`/OOXML concern and must never surface.
92
+ Provide `pl.units.inches()` / `pl.units.cm()` helpers.
93
+ 6. **Structured I/O.** Reads return dataclasses/dicts. The CLI prints exactly
94
+ **one JSON object on stdout** per invocation (logs to stderr), with
95
+ deterministic exit codes. No string scraping. Global flags (`--json`/`--text`,
96
+ `--doc NAME`) go *before* the subcommand.
97
+ 7. **Constants are typed `IntEnum`s, added only as a feature needs them.** Don't
98
+ pre-populate. Friendly string aliases (`"title"`, `"two_content"`,
99
+ `"textbox"`) coerce to the right int the way wordlive's alignment names do.
100
+
101
+ ## The three things PowerPoint changes vs. Word — read before coding
102
+
103
+ 1. **Atomic undo works after all — just differently. PowerPoint has no
104
+ `UndoRecord`.** Word's `EditScope` brackets a block with
105
+ `Application.UndoRecord` (start/end); PowerPoint has no such bracket. **But the
106
+ 2026-05-26 spike (`scripts/undo_test.py`) found PowerPoint already groups
107
+ consecutive in-session COM edits into one undo entry by default**, and
108
+ `Application.StartNewUndoEntry()` is a verified *boundary* primitive. So
109
+ `edit()` calls `StartNewUndoEntry()` on entry to fence the block, and the whole
110
+ block is **one Ctrl-Z** — near-parity with wordlive. Honest caveat: there's no
111
+ explicit "end" fence (the next `edit()` or a user action closes it), so always
112
+ wrap mutations in `deck.edit(...)`. Cross-*process* edits (separate CLI calls)
113
+ are verified to stay distinct undo entries — each re-fences at its own `edit()`
114
+ entry. (`_edit.py` is where the fence lives.)
115
+ 2. **PowerPoint must be visible.** `Application.Visible = False` raises in most
116
+ builds, so `connect()` has no `visible=False` mode. Politeness is about *not
117
+ moving the user's view*, not about working hidden.
118
+ 3. **Anchors are hierarchical (slide → shape → paragraph), not a global offset.**
119
+ There is no document-wide character stream and no deck-wide `range:`. Anchor
120
+ ids are colon-separated, slide-index first:
121
+
122
+ | anchor_id | resolves to |
123
+ | ---------------- | ----------- |
124
+ | `slide:S` | slide S (1-based) — a **container**, not a text anchor; use `deck.slides[S]`, not `anchor_by_id` |
125
+ | `shape:S:N` | Nth shape (1-based z-order) on slide S — canonical handle; an `Anchor` if it has a text frame |
126
+ | `ph:S:KIND` | placeholder of semantic KIND (`title`/`ctrtitle`/`subtitle`/`body`/`footer`/`date`/`slidenum`) — the LLM-preferred form |
127
+ | `para:S:N:P` | paragraph P in shape N on slide S |
128
+ | `cell:S:N:R:C` | cell (row R, col C) of the table in shape N on slide S |
129
+ | `notes:S` | speaker-notes body of slide S |
130
+
131
+ `shape:` is int-only (z-order) to avoid index-vs-name ambiguity; expose shape
132
+ `.Name` separately (`slide.shapes["Title 1"]`). **z-order drifts** when shapes
133
+ are added/removed: resolve `shape:S:N` **live** (never cache it), and have
134
+ every shape listing emit `name` + `id` (`Shape.Id`, stable across reorder) so
135
+ an agent can re-identify after drift. Steer toward `ph:S:KIND` and `.Name` as
136
+ the drift-proof forms; document the hazard honestly rather than building
137
+ re-resolution machinery. (Resolved Open Q #3 — symbolic `exec` binding deferred.)
138
+
139
+ ## Politeness model (the whole point)
140
+
141
+ Default behavior preserves the user's **viewed slide**, **shape/text Selection**,
142
+ and focus. Operations that *must* move what the user sees say so in their name
143
+ (`go_to`, `show.goto`, `allow_view_move()`). Never target the live `Selection`
144
+ unless explicitly asked — write text through `Shape.TextFrame.TextRange.Text`
145
+ directly so no edit needs to select anything.
146
+
147
+ ## Error taxonomy → exit codes
148
+
149
+ `PptliveError` base; reuse wordlive's COM-error decoding. Exit codes:
150
+ `0` ok · `1` other · `2` anchor/slide/shape/presentation not found (incl. zero
151
+ `find` matches) · `3` PowerPoint busy / slide show running · `4` PowerPoint not
152
+ running · `5` ambiguous match · `6` shape has no text frame (`NoTextFrameError`,
153
+ the one genuinely new code). `SlideNotFoundError` subclasses
154
+ `AnchorNotFoundError` (so it reuses exit 2).
155
+
156
+ ## Testing
157
+
158
+ - Unit tests run against a **`fake_powerpoint`** MagicMock fixture in
159
+ `tests/conftest.py` (model it on wordlive's `fake_word`) — no PowerPoint
160
+ needed, runs on any OS in CI. This is where the politeness/anchor logic is
161
+ proven.
162
+ - Live COM behavior goes behind `@pytest.mark.smoke`, skipped by default
163
+ (`addopts = -m 'not smoke'`); the `real_powerpoint` fixture skips if PowerPoint
164
+ isn't reachable. Run with `uv run pytest -m smoke` on a Windows box.
165
+ - Resolve spike questions (see IMPLEMENTATION.md) against real PowerPoint before
166
+ hardening the corresponding code.